Fix animation state bugs and restructure the engine - #1
Conversation
Three code paths each worked out "what should this element look like now"
in their own way and disagreed with each other. Consolidate them into a
single layer stack, then fix the issues the restructure did not cover.
Bug fixes:
- An element that entered while another was exiting under Presence got
permanently stuck on its exit target, and every later animate update was
ignored. mount() now clears the exit and gesture flags, since a
MotionState can outlive its element and be mounted again.
- An exit that resolved to no values (exit={{}}, or a variant key with no
matching entry) left the element in the DOM forever. Both Presence and
the mount effect wait on motioncomplete, which was never dispatched for
an empty target. An exiting element now reports itself finished either
way.
- Hovering or pressing an element wiped out its inView styles, because
inView had no entry in the active state.
- onViewEnter handlers received the element instead of the
IntersectionObserverEntry. Motion's inView callback is (element, entry)
and the old code read the first argument as the entry; an `as any` cast
had hidden the mismatch.
- Changing variants while animate stayed on the same string key was stored
but never applied.
- Gestures were torn down and rebound on every unrelated update. The check
compared prop objects by reference, and Solid re-evaluates inline JSX
prop objects on every read, so it always reported a change.
- Target comparison was key-order sensitive, so initial={{opacity, scale}}
against animate={{scale, opacity}} triggered a redundant animation.
- Two motioncomplete listeners were never removed; both now use
{once: true}.
- Every property access on the Motion proxy returned a component,
including Motion.then, which made Motion look like a thenable and would
hang anything awaiting it or resolving it as a lazily imported
component.
Refactor:
- Add a LAYERS list ranking animate, inView, hover and press, and
resolveActiveTarget() as the single place that merges the active layers.
mount(), update(), setActive() and every gesture now go through it.
- Move exit into its own flag, since it replaces the layer stack rather
than merging on top of it.
- Replace three near identical gesture blocks with one GESTURES table and
a short loop.
- update() now diffs against the target it last animated to, rather than
against the previous animate prop.
- Split engine.ts into three labelled sections.
- Replace a stray queueMicrotask in Presence with onSettled, Solid 2.0's
replacement for 1.x onMount. The flag it flips controls whether
initial={false} still applies, and the microtask queue knows nothing
about Solid's scheduler.
The public API is unchanged.
Tooling:
- lint:code used a shell-expanded glob that matched no .js files in src,
so it exited with an error every time it ran.
- lint used & instead of &&, so lint failures were silently dropped.
pnpm run lint now passes and reports zero problems.
Tests:
Four regression tests added, each confirmed to fail against the unfixed
code: an element that entered during an exit still animates on later
updates; an element is removed even when exit resolves to no values;
initial: false only suppresses children present on the first render; and
the Motion proxy is not a thenable.
25 client tests and 13 SSR tests pass. Prettier reports no issues.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jest needed two babel transformers, a custom resolver to get past @solid-primitives' ESM-only exports, and an SSR=true environment variable to pick between two config objects. Vitest runs the client and server projects together off one config with no custom resolver, and Playwright replaces Storybook as the browser-driven layer. Vitest: - vitest.config.ts defines a jsdom "client" project and a node "ssr" project, so pnpm test covers both compilation targets in one pass. - Coverage via @vitest/coverage-v8, with thresholds set just under the current numbers: 96% statements, 94% branches, 94% functions, 96% lines. - Adds test/engine.test.tsx, covering createStyles, normalizeTransition and createMotionState directly. normalizeTransition is now exported as @internal so the Motion One compatibility shim can be checked on its own. - Adds createMotion and useScroll cases to the primitives tests. Vitest resolves Solid's development build, which Jest did not, so its strict-mode checks now apply. Two existing tests wrote to signals from inside an owned scope and are restructured to write from outside it, and one built its element in a bare createRoot; jsdom 30 throws when Motion reads computed style off a node with no owner document, so it now renders into the document like the others. Playwright: - playground/ replaces stories/: a small Vite app serving one demo per ?demo=<id> route, with an index page. It is what pnpm dev now runs. - e2e/ drives it across Chromium, Firefox and WebKit, covering what jsdom cannot reach: real Web Animations interpolation, real IntersectionObserver for inView, real pointer input for hover and press, and real scrolling for useScroll. - Assertions wait for an animation to settle rather than sleeping for a fixed duration, which was flaky across the three engines. - CI gains an end-to-end job that installs the browsers and uploads the report. Two known gaps are recorded as test.fail() expectations so they report as soon as they are fixed: - initial on an SVG element is applied as an inline style by createStyles, which builds it with buildHTMLStyles, while Motion animates SVG geometry through attributes. The inline style outranks the attribute and the element never moves. - With no animate prop, the target resolved when a gesture ends is empty, and an empty target is a no-op, so the element stays on the gesture's values instead of reverting. Also switches from vite-plugin-solid to @solidjs/vite-plugin; the former is now a stub that only re-exports the latter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Pushed a second commit: the test setup moves from Jest and Storybook to Vitest and Playwright. WhyJest needed two babel transformers, a custom resolver to get past Vitest
Vitest resolves Solid's development build, which Jest did not, so its strict-mode checks now apply. Two existing tests wrote to signals from inside an owned scope and now write from outside it. One built its element in a bare Playwright
CI gains an end-to-end job that installs the browsers and uploads the report. Two bugs the browser suite foundBoth are recorded as
Neither is a regression from the first commit; both predate it. Verification
Also switches from 🤖 Generated with Claude Code |
The two bugs the Playwright suite recorded as expected failures. Both
test.fail() markers become ordinary tests.
SVG geometry never animated. createStyles built every start target with
motion-dom's buildHTMLStyles and applied it as an inline style, but Motion
animates SVG geometry through attributes, so `initial={{height: 20}}` on a
rect emitted `style="height: 20px"`, which outranks the animated `height`
attribute in the cascade and pinned the element to its starting value.
createStyles now takes the tag, routes SVG elements through buildSVGAttrs,
and returns the style and the attributes separately. Callers apply both:
mount sets the attributes on the element, and the Motion component folds
them into the props it spreads.
They are folded into the existing spread rather than added as a second one,
because an extra prop source shifts Solid's hydration key numbering and adds
a stray separator to the rendered markup. An element with no SVG geometry
therefore renders exactly as before. The SSR expectation for a rect changes
from style="height:50px" to height="50px", which is the point of the fix.
A gesture with no `animate` prop never reverted. Releasing it resolved to a
target holding no values at all, and an empty target is a no-op, so the
element stayed on the gesture's values. The engine now records what the
element showed before a gesture layer first introduced a key that no other
layer drives, reading a transform shorthand's identity value from
defaultTransformValue and everything else off computed style, and feeds
those back into the resolved target once the layer goes inactive. The record
is cleared on mount along with the other per-element flags.
Adds unit tests for the SVG attribute path, both revert paths, and the
transform identity case, keeping coverage above its thresholds.
69 Vitest tests and 96 Playwright tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Third commit fixes the two bugs the browser suite had recorded as SVG geometry never animated. A gesture with no 69 Vitest tests and 96 Playwright tests pass; lint, build and Prettier are clean. 🤖 Generated with Claude Code |
Every jsdom test file failed on GitHub Actions with: Error: [vitest-pool]: Failed to start forks worker for test files ... Caused by: TypeError: webidl.util.markAsUncloneable is not a function jsdom 30 pulls in undici, which destructures `markAsUncloneable` off node:worker_threads at module scope and assigns it to `webidl.util` unconditionally. That API only exists from Node 22.10, so on Node 20 it resolves to undefined and every worker that loads jsdom dies on startup. The `ssr` project has no jsdom and was unaffected, which is why exactly the four client test files failed. The workflow asked for `node-version: 20`, which the runner resolved to 20.20.2. Both workflows now ask for 22, with a comment recording why, and an .nvmrc pins the same version for local work. Node 22.12 is also the floor Vite 8 asks for, so this clears that unmet peer warning too; @types/node moves to ^22.12.0 to match. `engines` is deliberately left alone: this only affects the test environment, and the published library still runs on Node 20. Verified on Node 22.12.0, the new floor: 69 Vitest tests pass, lint and build are clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summary
A review of
src/engine.tsturned up several bugs where an element could get stuck on the wrong target, get stuck in the DOM, or silently ignore updates. Most of them traced back to one structural problem: three different code paths each worked out "what should this element look like now" in their own way, and they disagreed with each other.This PR consolidates that into a single layer stack, then fixes the remaining issues that the restructure did not cover.
Bug fixes
Presencegot permanently stuck on its exit target. Every lateranimateupdate was ignored.mount()now clears the exit and gesture flags, because aMotionStatecan outlive its element and be mounted again.exitthat resolved to no values (exit={{}}, or a variant key with no matching entry) left the element in the DOM forever. BothPresenceand the mount effect wait onmotioncomplete, which was never dispatched for an empty target. An exiting element now reports itself finished either way.inViewstyles.inViewhad no entry in the active state, so any later recompute dropped it.onViewEnterhandlers received the element instead of theIntersectionObserverEntry. Motion'sinViewcallback is(element, entry)and the old code read the first argument as the entry. Anas anycast had hidden the mismatch.variantswhileanimatestayed on the same string key was stored but never applied.initial={{opacity, scale}}againstanimate={{scale, opacity}}triggered a redundant animation.motioncompletelisteners were never removed. Both now use{once: true}.Motionproxy returned a component, includingMotion.then. That madeMotionlook like a thenable and would hang anything awaiting it or resolving it as a lazily imported component. Unknown string keys are still treated as tag names; everything else falls through to the component function.Refactor
LAYERSlist ranking the four animation sources:animate,inView,hover,press.resolveActiveTarget()as the single place that merges active layers into a target.mount(),update(),setActive()and every gesture now go through it.exitout of the active flags into its ownexitingflag, since it replaces the layer stack rather than merging on top of it.GESTUREStable and a short loop.hover,pressandinViewshare the same bind signature, so adding a layer is now one table entry.update()now diffs against the target it last animated to, rather than against the previousanimateprop.engine.tsinto three labelled sections: targets and styles, layers and gestures, state.queueMicrotaskinPresencewithonSettled, Solid 2.0's replacement for 1.xonMount. The flag it flips controls whetherinitial={false}still applies, and the microtask queue knows nothing about Solid's scheduler.The public API is unchanged.
Tooling
lint:codeused a shell-expanded glob that matched no.jsfiles insrc, so it exited with an error every time it ran. It now lints thesrcdirectory by extension.lintused&instead of&&, so lint failures were silently dropped.pnpm run lintnow passes and reports zero problems.Tests
Four regression tests added:
exitresolves to no values.initial: falseonly suppresses children present on the first render.Motionproxy is not a thenable.Each new test was confirmed to fail against the unfixed code.
Verification
pnpm run lintpasses, bothlint:codeandlint:types.🤖 Generated with Claude Code