From 8e9426f86599ddafc2feb3226632bdea15312c93 Mon Sep 17 00:00:00 2001 From: Jon Laing Date: Mon, 3 Aug 2026 11:06:21 -0400 Subject: [PATCH] fix(dom): use real microtask + force layout for enter connection wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the previous release's connection wait. That version used Effect.yieldNow() to defer the enter fiber past the outer synchronous render flow, but yieldNow only reschedules inside Effect's own queue — if no other work is queued, the fiber can re-run immediately without the browser flushing microtasks or committing DOM. In practice this meant real client re-mounts still saw element.isConnected === false at the 3-attempt bound, the animation ran against a disconnected element, and the transition never fired. Switch to queueMicrotask via Effect.async — a real browser microtask guarantees the fiber won't resume until the current task's synchronous work and other queued microtasks have run. That's when the outer flow finishes inserting the wrapper's ancestor chain, so element becomes connected. Bounded at 32 attempts; each is submicrosecond in modern engines, so a full spin is well under 1ms even for callers whose element never gets inserted. Also force a style/layout computation via `element.offsetHeight` after connection. Some engines defer styling for freshly-inserted nodes until needed, and without this forceReflow in runEnterAnimation could sample an unstyled snapshot as the transition's "before" reference, leaving nothing for the browser to interpolate from. Co-Authored-By: Claude Opus 4.7 --- .changeset/fix-enter-anim-real-microtask.md | 32 +++++++++ packages/dom/src/Control/slotAnimation.ts | 72 ++++++++++++++++----- 2 files changed, 88 insertions(+), 16 deletions(-) create mode 100644 .changeset/fix-enter-anim-real-microtask.md diff --git a/.changeset/fix-enter-anim-real-microtask.md b/.changeset/fix-enter-anim-real-microtask.md new file mode 100644 index 0000000..b94fa88 --- /dev/null +++ b/.changeset/fix-enter-anim-real-microtask.md @@ -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. diff --git a/packages/dom/src/Control/slotAnimation.ts b/packages/dom/src/Control/slotAnimation.ts index 4a86d40..009d504 100644 --- a/packages/dom/src/Control/slotAnimation.ts +++ b/packages/dom/src/Control/slotAnimation.ts @@ -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), @@ -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 = Effect.async((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 => + 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).