fix: do not deadlock when the diff runs inside a view transition - #24
Merged
Conversation
`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
marked this pull request as ready for review
July 31, 2026 09:58
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
wait()settled the stream walk onrequestAnimationFramealone: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:
So a caller that wraps the whole diff in
document.startViewTransitiondeadlocks 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: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-nameelements across a full page change.transition: truecannot 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.
After, same page swap:
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 realstartViewTransitionand asserts the DOM updated,readyresolved (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:
expected "resolved", received "rejected: TimeoutError"— 4137 msFull 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
bundlewatchmaxSizeof1.5 kB, so it passes with 6 bytes of headroom. That is tight enough that the next change is likely to trip it. Worth bumpingmaxSizeto1.6 kBin the same PR if you agree — say the word and I will add it.🤖 Generated with Claude Code