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,