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
52 changes: 52 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<div><h1>hello world</h1></div>`));

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 ["<div>", "<h1>hello world!</h1>", "</div>"]) {
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) => {
Expand Down
19 changes: 18 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading