Skip to content

Fix animation state bugs and restructure the engine - #1

Merged
davedbase merged 4 commits into
solidjs-community:mainfrom
lxsmnsyc:fix/motion-state-engine
Sep 5, 2026
Merged

Fix animation state bugs and restructure the engine#1
davedbase merged 4 commits into
solidjs-community:mainfrom
lxsmnsyc:fix/motion-state-engine

Conversation

@lxsmnsyc

@lxsmnsyc lxsmnsyc commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

A review of src/engine.ts turned 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

  • An element that entered while another was exiting under Presence got permanently stuck on its exit target. Every later animate update was ignored. mount() now clears the exit and gesture flags, because 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. inView had no entry in the active state, so any later recompute dropped it.
  • 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. It now compares which gestures exist, which is what it was meant to do.
  • 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. That made Motion look 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

  • Added a LAYERS list ranking the four animation sources: animate, inView, hover, press.
  • Added resolveActiveTarget() as the single place that merges active layers into a target. mount(), update(), setActive() and every gesture now go through it.
  • Moved exit out of the active flags into its own exiting flag, since it replaces the layer stack rather than merging on top of it.
  • Replaced three near identical gesture blocks with one GESTURES table and a short loop. hover, press and inView share 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 previous animate prop.
  • Split engine.ts into three labelled sections: targets and styles, layers and gestures, state.
  • Replaced 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. It now lints the src directory by extension.
  • lint used & instead of &&, so lint failures were silently dropped. pnpm run lint now passes and reports zero problems.

Tests

Four regression tests added:

  • 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.
  • The Motion proxy is not a thenable.

Each new test was confirmed to fail against the unfixed code.

Verification

  • 25 client tests and 13 SSR tests pass.
  • pnpm run lint passes, both lint:code and lint:types.
  • Prettier reports no formatting issues.

🤖 Generated with Claude Code

lxsmnsyc and others added 2 commits September 3, 2026 01:10
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>
@lxsmnsyc

lxsmnsyc commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a second commit: the test setup moves from Jest and Storybook to Vitest and Playwright.

Why

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. Playwright replaces Storybook as the browser-driven layer, and unlike the stories it actually asserts.

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 through @vitest/coverage-v8, thresholds set just under the current numbers: 96% statements, 94% branches, 94% functions, 96% lines.
  • New test/engine.test.tsx covers createStyles, normalizeTransition and createMotionState directly. normalizeTransition is now exported as @internal so the Motion One compatibility shim can be checked on its own, which it never was.
  • New createMotion and useScroll cases in 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 now write from outside it. 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 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 runs now.

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 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 bugs the browser suite found

Both are recorded as test.fail() expectations so they report as soon as they are fixed:

  1. initial on an SVG element never animates. createStyles builds the target with motion-dom's buildHTMLStyles and applies it as an inline style, but Motion animates SVG geometry through attributes. The inline height: 20px outranks the animated height attribute in the cascade, so the element never moves. Fixing it means branching createStyles onto buildSVGAttrs for SVG elements.
  2. A gesture with no animate base never reverts. With no animate prop the target resolved on hover-out or press-release is empty, and an empty target is a no-op, so the element stays on the gesture's values. Reverting would need the engine to remember the pre-gesture base style.

Neither is a regression from the first commit; both predate it.

Verification

  • 64 Vitest tests pass (client and SSR).
  • 96 Playwright tests pass per full run, confirmed stable over two consecutive --repeat-each=2 passes (192/192).
  • pnpm run lint passes; it now also covers test, e2e and playground.
  • pnpm run build and Prettier are clean.

Also switches from vite-plugin-solid to @solidjs/vite-plugin; the former is now a stub that only re-exports the latter.

🤖 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>
@lxsmnsyc

lxsmnsyc commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Third commit fixes the two bugs the browser suite had recorded as test.fail(). Both are now ordinary passing tests.

SVG geometry never animated. createStyles built every start target with 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" and outranked the animated attribute. It now takes the tag, routes SVG through buildSVGAttrs, and returns style and attributes separately. The attributes are folded into the existing prop spread rather than added as a second one, since an extra prop source shifts Solid's hydration key numbering; non-SVG elements render exactly as before. One SSR expectation changes from style="height:50px" to height="50px", which is the fix.

A gesture with no animate never reverted. Releasing it resolved to a target with no values, and an empty target is a no-op. The engine now records what the element showed before a gesture introduced a key nothing else drives, and animates back to that.

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>
@davedbase
davedbase merged commit 42e7ac9 into solidjs-community:main Sep 5, 2026
2 checks passed
@lxsmnsyc
lxsmnsyc deleted the fix/motion-state-engine branch September 5, 2026 14:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants