Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/fix-enter-anim-real-microtask.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"@effex/dom": patch
---

Fix intro animations still stalling on client-mode re-mount even after
the connection wait added in the previous release.

The previous fix used `Effect.yieldNow()` to defer the enter fiber past
the outer synchronous render flow. That only reschedules the fiber
inside Effect's own queue — if no other work is queued, the fiber can
run again immediately without the browser actually flushing microtasks
or committing DOM changes. On real client re-mounts, `element.isConnected`
stayed `false` when the animation fiber checked it, we hit the 3-attempt
bound, and proceeded against a disconnected element — same failure
mode as before.

Replace `Effect.yieldNow` with `queueMicrotask`-based yielding (via
`Effect.async`), bounded to 32 attempts. `queueMicrotask` is a real
browser primitive that guarantees the fiber won't resume until the
current task's synchronous work and other queued microtasks have run
— which is when the outer render flow finishes inserting the wrapper's
ancestor chain into the document. 32 microtasks is well under a
millisecond in modern engines and comfortably covers any realistic
outer-flow depth.

Also force a style/layout computation (`element.offsetHeight`) after
the element is connected. Some engines defer style computation for
freshly-inserted nodes until it's needed; without this, `forceReflow`
inside `runEnterAnimation` could record the enterFrom state as the
initial snapshot for an element that hasn't been styled yet, leaving
the browser without a valid "before" to interpolate the transition
from.
72 changes: 56 additions & 16 deletions packages/dom/src/Control/slotAnimation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,23 +162,30 @@ export const forkSlotEnter = (
// Wait for the element to be attached to the document before the
// enter lifecycle starts. On client-mode re-mount (e.g. a router
// nav-back), the fiber is forked from inside addSlot while the
// ancestor chain is still being assembled bottom-up in memory —
// Effect's scheduler can hand this fiber control on the next
// microtask, before the outer flow appends the wrapper into the
// document. onBeforeEnter would then fire against a detached
// node, getComputedStyle would return empty strings, and the
// transition would never fire (browsers won't compute or
// transition styles on disconnected nodes).
// ancestor chain is still being assembled bottom-up in memory.
// The forked fiber can win the race against the outer flow, and
// `onBeforeEnter` would then fire against a detached node —
// `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.
//
// Yield microtasks until the element is connected, up to a small
// bound. In practice the outer flow finishes within one or two
// microtasks; the retry bound guards against callers that never
// insert their result (e.g. tests that yield an animated element
// without appending it to the document) — we proceed after a
// handful of tries even if still detached so the animation
// continues to be best-effort rather than hanging.
for (let i = 0; i < 3 && !element.isConnected; i++) {
yield* Effect.yieldNow();
// Effect.yieldNow is not enough — Effect's scheduler can
// reschedule the fiber right back if no other work is queued.
// Real browser microtasks (via queueMicrotask) DO defer past
// the current synchronous+microtask window, so the outer flow's
// DOM insertion completes before this fiber resumes.
if (!element.isConnected) {
yield* waitForConnection(element);
}
// Force a style/layout computation on the (now-connected)
// element. Some engines defer style computation for freshly-
// inserted nodes until it's needed; without this, forceReflow
// inside runEnterAnimation may end up recording the enterFrom-
// applied state as the initial snapshot for a node that hasn't
// been styled yet, leaving the browser without a valid "before"
// to interpolate the transition from.
if (element instanceof HTMLElement) {
void element.offsetHeight;
}
const run = runEnterAnimation(Effect.succeed(element), animate).pipe(
Effect.ensuring(grp ? _complete(grp) : Effect.void),
Expand All @@ -190,6 +197,39 @@ export const forkSlotEnter = (
}).pipe(Effect.asVoid);
};

/**
* Yield a real browser microtask. Different from `Effect.yieldNow` — that
* only reschedules the fiber inside Effect's queue and can re-run
* immediately when nothing else is queued. `queueMicrotask` guarantees
* the browser will finish the current task's remaining synchronous work
* and flush other pending microtasks before resuming.
*/
const yieldMicrotask: Effect.Effect<void> = Effect.async<void>((resume) => {
queueMicrotask(() => resume(Effect.void));
});

/**
* Wait for an element to become connected to the document. On client-mode
* re-mount, the enter fiber is forked from inside `addSlot` before the
* outer synchronous render flow has finished appending the wrapper's
* ancestor chain — but that flow runs entirely within the current task's
* synchronous+microtask window, so yielding real microtasks in a bounded
* loop lets it complete and the element becomes connected.
*
* The bound is generous (32 microtasks): each takes ~microseconds in
* modern engines, so a full spin is well under a millisecond even in the
* worst case, but it comfortably covers any realistic outer-flow depth.
* Callers that never insert their animated element (e.g. tests that yield
* `animated(...)` inline without appending) hit the bound and proceed
* anyway — best-effort rather than hanging.
*/
const waitForConnection = (element: HTMLElement): Effect.Effect<void> =>
Effect.gen(function* () {
for (let i = 0; i < 32 && !element.isConnected; i++) {
yield* yieldMicrotask;
}
});

/**
* Fork the full slot-removal sequence into the parent scope:
* 1. Close the slot's scope (interrupts any in-flight enter animation).
Expand Down
Loading