Skip to content

fix: do not deadlock when the diff runs inside a view transition - #24

Merged
aralroca merged 1 commit into
mainfrom
fix/view-transition-raf-deadlock
Jul 31, 2026
Merged

fix: do not deadlock when the diff runs inside a view transition#24
aralroca merged 1 commit into
mainfrom
fix/view-transition-raf-deadlock

Conversation

@aralroca

Copy link
Copy Markdown
Collaborator

The bug

wait() settled the stream walk on requestAnimationFrame alone:

const wait = () => new Promise((resolve) => requestAnimationFrame(resolve));

rAF does not fire while a document is rendering-suppressed, and that is exactly what the View Transition API does to it for the whole duration of an update callback. Verified in Chrome:

callback entered
rAF did NOT fire within 1500ms (+1509ms)
microtask ok
setTimeout fired (+1519ms)

So a caller that wraps the whole diff in document.startViewTransition deadlocks here until the browser abandons the transition on its 4-second DOM-update timeout — the page freezes for four seconds and then swaps with no animation at all:

start
ready REJECTED: TimeoutError: Transition was aborted because of timeout in DOM update
updateDone +4009ms
finished +4009ms

This is not an exotic call pattern: it is the one this 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. transition: true cannot do it — it starts one transition per DOM update and each new one skips the previous, so on a real page swap I measured 51 transitions started, 50 skipped, 1 animated: the page snaps through and only the last fragment fades.

The fix

Race a timer against the frame. Outside a transition the frame still arrives first, so the normal path is unchanged; inside one the timer is the only thing that fires. It also keeps the walk moving in a background tab, where frames are throttled away for the same reason.

-const wait = () => new Promise((resolve) => requestAnimationFrame(resolve));
+const wait = () =>
+  new Promise((resolve) => (requestAnimationFrame(resolve), setTimeout(resolve, 16)));

After, same page swap:

start
updateDone +23ms
ready +24ms
finished +282ms

One transition, the diff completing in 23 ms inside the callback, and a real 282 ms animation.

Test

should complete a diff that runs inside document.startViewTransition — runs a real diff inside a real startViewTransition and asserts the DOM updated, ready resolved (a timed-out transition still applies the change, so asserting only the DOM would pass on the bug), and the callback finished well inside the 4 s cap.

Red before the fix, green after:

result
before expected "resolved", received "rejected: TimeoutError" — 4137 ms
after ✓ 643 ms

Full suite: 138 pass, 0 fail across Chrome, Firefox and Safari.

One thing to decide: the bundle budget

The fix costs 15 bytes gzipped (1479 → 1494) against a bundlewatch maxSize of 1.5 kB, so it passes with 6 bytes of headroom. That is tight enough that the next change is likely to trip it. Worth bumping maxSize to 1.6 kB in the same PR if you agree — say the word and I will add it.

🤖 Generated with Claude Code

`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) <noreply@anthropic.com>
@aralroca
aralroca marked this pull request as ready for review July 31, 2026 09:58
@aralroca
aralroca merged commit 4d5a1aa into main Jul 31, 2026
1 check passed
@aralroca
aralroca deleted the fix/view-transition-raf-deadlock branch July 31, 2026 09:59
aralroca added a commit to aralroca/Janux that referenced this pull request Jul 31, 2026
One transition around the whole swap, so elements sharing a
`view-transition-name` are paired across routes and carried from the old
page into the new one. Off by default; `navigation.viewTransitions`
turns it on, and it is ignored when the engine lacks the API or the user
asked for `prefers-reduced-motion: reduce`.

Not one transition per streamed chunk. diff-dom-streaming can wrap every
mutation it applies (`transition: true`), but shared elements are paired
by comparing a snapshot of the whole old page against the whole new one,
and each new transition skips the one before it — measured on a real
navigation that is 51 transitions started, 50 skipped, and one animating
the last fragment. It cannot produce the morph this is for.

Opting in changes how the page is applied: the incoming page is read in
full BEFORE the swap instead of being diffed as it streams. A transition
suppresses rendering until its callback resolves, so diffing a live
stream inside one would freeze the page for the whole download. Buffering
out here inverts that — the old page stays live and interactive while the
next one arrives, and only the swap is animated. Requires
diff-dom-streaming 0.6.9, which fixes the rAF deadlock that made this
impossible (brisa-build/diff-dom-streaming#24).

A superseded navigation skips its transition on abort, and a new one
skips whatever is still on screen, so an interrupted navigation can never
leave the old snapshot painted over a document that has moved on. The
route announcement and the focus move now wait for `finished`, so they
land after the transition rather than during it.

examples/shop opts in and shares its topbar wordmark between /shop and
/orders/*; apps/docs deliberately does not, which is what makes the
opt-in assertable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant