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,