Skip to content

fix(dom): wait for element connection before starting enter animation - #71

Merged
jonlaing merged 1 commit into
mainfrom
fix/enter-anim-detached-element
Aug 3, 2026
Merged

fix(dom): wait for element connection before starting enter animation#71
jonlaing merged 1 commit into
mainfrom
fix/enter-anim-detached-element

Conversation

@jonlaing

@jonlaing jonlaing commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes intro animations stalling on client-mode re-mounts (e.g. router nav-back) when the animated block is nested inside other wrapper elements. Directly diagnosed from a user report — onBeforeEnter's getComputedStyle snapshot returned all-empty values on re-mount, which only happens when the element is disconnected from the document.

Root cause

forkSlotEnter forks the enter animation fiber via Effect.forkIn(slotScope) from inside addSlot. Effect's scheduler can hand that fiber control on the next microtask, but for a nested animated block the outer render flow is still building the ancestor chain bottom-up in memory:

  1. animated's addSlot creates its wrapper div, applies enterFrom classes, inserts it into its own containerElement (a display:contents div).
  2. forkSlotEnter forks the animation fiber → queued for the next microtask.
  3. Outer synchronous flow continues: the containerElement gets appended to Headline's div → HomePage's div → into the Outlet's slot container, and finally into the actual document at the top.

Steps 2 and 3 race. If the forked fiber gets control before step 3 finishes, onBeforeEnter fires against a wrapper whose grandparents aren't in the document yet. getComputedStyle returns empty strings on disconnected nodes, and browsers won't compute or transition styles against them — so the enter transition never fires and the animation stalls to the timeout.

Why hydration didn't surface this: the hydration walker only ever sees DOM that was emitted by SSR and already exists in the document. Every element was connected when the fiber ran, so the race never triggered a visible bug. Client-mode re-mounts (nav-back, when toggle) were the first path where the tree got built in memory and only inserted at the top level.

Reproduction

User's onBeforeEnter diagnostic dumped on nav-back:

{ className: "opacity-0 -rotate-45 -translate-y-[100px]",
  opacity: "", transform: "", transitionProperty: "", transitionDuration: "",
  parent: "DIV" }

Class list correctly has enterFrom → applyPreInsertEnterFrom fired. But every computed-style field is "" — the tell for a disconnected node. Confirmed reproduction is the timing race, not a missing enterFrom.

Fix

At the start of the animation fiber's body (after gate but before runEnterAnimation), yield microtasks until element.isConnected is true, bounded at 3 attempts. One or two microtasks is enough to let the outer synchronous flow commit the tree in practice; the bound keeps tests that never insert their animated result (e.g. the existing gates on an AnimationGroup test) from spinning.

Doesn't use requestAnimationFrame — that would add ~16ms latency per animation and broke a timing-sensitive existing test. Microtask polling is essentially free when the element is connected.

Tests

New regression in Control.test.ts under animated:

  • when toggle wrapping two $.div levels around an animated block.
  • Root is appended under document.body so isConnected propagates.
  • Assert element.isConnected === true at onBeforeEnter on the initial mount AND the toggle-back.

Fails before the fix on the second onBeforeEnter (isConnected returns false); passes after.

  • 789 tests pass across the workspace.
  • tsc --noEmit clean.

Changeset: @effex/dom patch.

🤖 Generated with Claude Code

forkSlotEnter forks the enter animation fiber via
Effect.forkIn(slotScope) from inside addSlot. Effect's scheduler can
hand that fiber control on the next microtask, before the outer
synchronous render flow has finished appending the wrapper's ancestor
chain to the document. When that happens, onBeforeEnter runs against a
disconnected node — getComputedStyle returns empty strings, browsers
won't compute or transition styles against detached nodes, and the
enter transition never fires. The animation stalls to the timeout.

Hydration didn't surface this: it walks pre-existing DOM so every node
was already connected when the fiber ran. Client-mode re-mounts (e.g.
router nav-back) broke it, but only when the animated block sat inside
another wrapper — its ancestor had to be appended AFTER the fork ran.
Reproduced with a top-level `when` toggle wrapping two levels of
`$.div` around an `animated` — element.isConnected was false at
onBeforeEnter on the second mount.

Yield microtasks at the start of the animation fiber's body until the
element is connected, up to 3 attempts. The outer flow completes
within 1-2 microtasks in practice; the bound keeps tests that yield an
animated element without ever appending it (e.g. the existing
"gates on an AnimationGroup" test) from hanging.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying effex with  Cloudflare Pages  Cloudflare Pages

Latest commit: ec2ad34
Status: ✅  Deploy successful!
Preview URL: https://4164b0a6.effex.pages.dev
Branch Preview URL: https://fix-enter-anim-detached-elem.effex.pages.dev

View logs

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying effex-api with  Cloudflare Pages  Cloudflare Pages

Latest commit: ec2ad34
Status: ✅  Deploy successful!
Preview URL: https://c611c9cb.effex-api.pages.dev
Branch Preview URL: https://fix-enter-anim-detached-elem.effex-api.pages.dev

View logs

@jonlaing
jonlaing merged commit 90173ba into main Aug 3, 2026
4 checks passed
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.

1 participant