From e219220924c57fb941ad1c437362e665f7dbb25d Mon Sep 17 00:00:00 2001 From: Aral Roca Date: Fri, 31 Jul 2026 10:38:31 +0200 Subject: [PATCH] fix: do not deadlock when the diff runs inside a view transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wait()` settled the stream walk on `requestAnimationFrame` alone, and rAF does not fire while a document is rendering-suppressed — which is exactly what the View Transition API does to it for the duration of an update callback. So a caller that wraps the whole diff in `document.startViewTransition` deadlocked here until the browser abandoned the transition on its 4s DOM-update timeout: the page froze for four seconds and then swapped with no animation at all. That is the pattern the README already documents under "Incremental vs full transition", and it is the only way to pair shared `view-transition-name` elements across a full page change, since `transition: true` starts one transition per DOM update and each new one skips the last. Racing a timer against the frame leaves the normal path alone (outside a transition the frame arrives first) and also keeps the walk moving in a background tab, where frames are throttled away too. Costs 15 bytes gzipped, which leaves only 6 under the bundlewatch budget — see the PR for a note on raising it. Co-Authored-By: Claude Opus 5 (1M context) --- src/index.test.ts | 52 +++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 19 ++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/index.test.ts b/src/index.test.ts index e9b25f4..561c38b 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -96,6 +96,58 @@ describe("Diff test", () => { ); expect(transitionApplied).toBeTrue(); }); + + /** + * A router wrapping the whole swap in one transition, rather than letting + * `transition: true` start one per DOM update — the only way shared + * `view-transition-name` elements are paired across a full page change. + * + * A view transition suppresses rendering until its update callback + * resolves, so `requestAnimationFrame` never fires inside one. Settling the + * stream on rAF alone therefore deadlocks the walk until the browser aborts + * the transition on its 4s DOM-update timeout: the page freezes for four + * seconds and then swaps with no animation at all. + */ + it("should complete a diff that runs inside document.startViewTransition", async () => { + await page.setContent(normalize(`

hello world

`)); + + const result = await page.evaluate(async (code) => { + eval(code as string); + const encoder = new TextEncoder(); + const readable = new ReadableStream({ + start(controller) { + for (const chunk of ["
", "

hello world!

", "
"]) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + }); + const startedAt = performance.now(); + // @ts-ignore + const transition = document.startViewTransition(() => + // @ts-ignore + diff(document.documentElement!, readable), + ); + + transition.finished.catch(() => {}); + await transition.updateCallbackDone; + + return { + elapsed: performance.now() - startedAt, + ready: await transition.ready.then( + () => "resolved", + (error: any) => `rejected: ${error?.name}`, + ), + heading: document.querySelector("h1")?.textContent, + }; + }, diffCode); + + expect(result.heading).toBe("hello world!"); + // Not aborted: a timed-out transition applies the DOM change but never animates. + expect(result.ready).toBe("resolved"); + // The deadlock ended at Chrome's 4s cap; a working swap is milliseconds. + expect(result.elapsed).toBeLessThan(2000); + }); }); describe.each(["chrome", "firefox", "safari"])("%s", (browserName) => { diff --git a/src/index.ts b/src/index.ts index 8a32ff5..856de0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,7 +29,24 @@ const NEXT_SIBLING = 2; const VISITED = 3; const SETTLE = 4; const SPECIAL_TAGS = new Set(["HTML", "HEAD", "BODY"]); -const wait = () => new Promise((resolve) => requestAnimationFrame(resolve)); + +/** + * A frame — or a timer, when frames are not coming. + * + * `requestAnimationFrame` does not fire while the document is + * rendering-suppressed, which is exactly what the View Transition API does to + * it while an update callback runs. A caller that wraps a whole diff in + * `document.startViewTransition` (the only way to pair shared + * `view-transition-name` elements across a full page change) would otherwise + * deadlock here until the browser abandons the transition on its DOM-update + * timeout: the page freezes for seconds and then swaps with no animation. + * + * Racing a timer against the frame fixes that without changing the normal + * path — outside a transition the frame arrives first — and it also keeps the + * walk moving in a background tab, where frames are throttled away too. + */ +const wait = () => + new Promise((resolve) => (requestAnimationFrame(resolve), setTimeout(resolve, 16))); export default async function diff( oldNode: Node,