mirror of
https://github.com/cloudflare/vinext.git
synced 2026-05-09 08:25:34 +02:00
[GH-ISSUE #639] App Router client navigation double-flashes Suspense fallbacks and janky back-button scroll restoration #133
Labels
No labels
enhancement
enhancement
good first issue
help wanted
nextjs-tracking
nextjs-tracking
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
starred/vinext#133
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally created by @NathanDrake2406 on GitHub (Mar 22, 2026).
Original GitHub issue: https://github.com/cloudflare/vinext/issues/639
Summary
Client-side navigation in vinext App Router visibly commits partial destination UI (Suspense fallbacks) instead of keeping the old content on screen until the new content is ready. This produces a double-flash on list/detail transitions and janky scroll restoration on back/forward navigation.
This does not happen in the equivalent app running on Next.js.
Reproduction
Any vinext App Router app with async server components inside Suspense boundaries will show this. Minimal pattern:
Page (server component):
Client component:
Steps — double flash
vinext dev, open the page/?filter=active/Steps — back button scroll jank
app/item/[id]/page.tsx) with an async server component/item/1What I found
The RSC navigation render in
packages/vinext/src/server/app-browser-entry.tsusesflushSync:flushSyncforces React to synchronously commit the new tree, including Suspense fallbacks for any unresolved async server components. Next.js usesstartTransitionhere instead, which tells React to keep the old UI visible until all Suspense boundaries resolve.The back-button scroll jank appears to involve a second coordination issue:
navigation.tsregisters apopstatelistener that defers scroll restoration via microtask, whileapp-browser-entry.tsregisters a separatepopstatelistener that calls__VINEXT_RSC_NAVIGATE__(which usesflushSync). TheflushSynccommits the incomplete tree before scroll restoration has the right content to scroll within.These are likely two related issues (both downstream of the render mode), not necessarily one complete fix.
Relevant paths
packages/vinext/src/server/app-browser-entry.ts—flushSyncrender on navigationpackages/vinext/src/shims/navigation.ts—popstatelistener, scroll save/restorepackages/vinext/src/shims/link.tsx—<Link>also calls__VINEXT_RSC_NAVIGATE__Expected behavior
Actual behavior
Happy to help with a minimal standalone repro if useful.
@NathanDrake2406 commented on GitHub (Mar 22, 2026):
This is on new version. @southpolesteve happy to help on this when you're done refactoring
@NathanDrake2406 commented on GitHub (Mar 22, 2026):
actually i think browser-entry is done I'll just fix it
@southpolesteve commented on GitHub (Mar 22, 2026):
Yeah feel free. I am getting near the end of the refactor I was planning on doing. My hope is this makes things easier going forward. Right now AI is struggling with the massive template strings on a couple of my branches.
@NathanDrake2406 commented on GitHub (Mar 22, 2026):
I've been running 5.4 high for 45m and it still hasn't figured it out. There's progress tho. Tough one to crack
@southpolesteve commented on GitHub (Mar 22, 2026):
@NathanDrake2406 want to see if this PR fixes the flash for you https://github.com/cloudflare/vinext/pull/647
I did some manual verification and I am not seeing a flash
@NathanDrake2406 commented on GitHub (Mar 22, 2026):
It's still janky.
Here are videos comparison. It's subtle and kind of nitpicky but it's quite offputting for someone as obsessed about UX as I am. Prod version is just more tasteful even tho it's slower since cache missed
This PR:
https://github.com/user-attachments/assets/3d3811e6-8753-4749-9930-6b540b7175ec
Prod (16.1.7, opennext)
https://github.com/user-attachments/assets/91784484-3dd1-425a-9dfb-caa7419bf624
The PR I'm working on (somehow it's 2k lines lmao)
https://github.com/user-attachments/assets/06f9182e-8ea0-428e-a3b5-e23f2f75c4f7
@NathanDrake2406 commented on GitHub (Mar 22, 2026):
Hey @james-elicx @southpolesteve, sharing some research findings here. Claude did this research by reading through the Next.js source code (specifically
segment-cache/navigation.ts,layout-router.tsx,app-router-instance.ts, anduse-action-queue.ts).Why the flash happens
The core issue is that vinext replaces the entire RSC tree on every navigation. When the full tree is swapped, every element re-mounts, every CSS animation replays, and Suspense boundaries can briefly flash their fallbacks before content resolves. No amount of client-side animation suppression or response buffering fully fixes this because the DOM elements are literally being destroyed and recreated.
How Next.js avoids it
1. Segment-level caching, not page-level
Next.js has a
CacheNodetree that mirrors the route segments (layouts, templates, pages). On navigation, it diffs the old and new route trees and only swaps the segments that actually changed. Unchanged layouts keep their DOM nodes. No re-mount, no animation replay, no flash.2. Single
startTransitionwrapping the reducerNext.js wraps the entire navigation dispatch in one
startTransition. The reducer returns the newAppRouterState(sync for cache hits, Promise for cache misses). React suspends on the Promise and keeps the old UI visible. There is no second nestedstartTransitioninside the render path.3.
useDeferredValuefor prefetch-to-dynamic transitionslayout-router.tsxdoesuseDeferredValue(cacheNode.rsc, resolvedPrefetchRsc). React renders the prefetched static shell first, then smoothly transitions to the dynamic data. This is why Next.js navigations feel smooth. The static parts appear instantly, and the dynamic parts fill in afterward.4. Never blocks on pending prefetches
From their source comment: "Don't bother to wait for a prefetch response; go straight to a full navigation that contains both static and dynamic data in a single stream." If a prefetch hasn't settled, they fire a fresh request immediately. (We found that vinext's
consumePrefetchResponsewas awaiting pending prefetches, which caused Firefox navigation to hang on Cloudflare Workers.)5.
useInsertionEffectfor URL updatesNext.js pushes history in
useInsertionEffect(fires before layout effects, before paint). This is earlier in the commit cycle thanuseLayoutEffect.What this means for vinext
The flash fix likely needs a
CacheNode-style tree that diffs route segments on navigation instead of replacing the whole RSC tree. That is the same architecture Next.js uses inlayout-router.tsx+segment-cache/navigation.ts. The other issues (response buffering, prefetch timing, transition nesting) are symptoms of not having segment-level diffing.@james-elicx what do you think? Is segment-level caching on the roadmap, or is there a simpler path to avoid the full-tree replacement?
For reference, PR #643 has fixes for some of the secondary issues (shallow routing reactivity, Firefox prefetch hang, server action redirect deadlock) that are valuable regardless of the flash fix.
@NathanDrake2406 commented on GitHub (Mar 23, 2026):
@southpolesteve I've got this idea, do you think making an eval that provides strong feedback and then run a Pi-autoresearch loop to implement this segment caching feature will work? I went a bit deeper into the rabbit holes yesterday and it turns out that segment caching is quite a complex feature, main reason why nextjs feels so good
@NathanDrake2406 commented on GitHub (Apr 21, 2026):
@james-elicx it's actually a vite rsc bug. God damn...
@james-elicx commented on GitHub (Apr 21, 2026):
What is the bug in the RSC plugin? Do you have an example of what you mean?
@NathanDrake2406 commented on GitHub (Apr 21, 2026):
from my convo w Claude:
Verification
Root cause is real. The source confirms it exactly as you described:
node_modules/@vitejs/plugin-rsc/dist/ssr.js:74-77preloadDepscallsReactDOM.preinit(href, { as: "style", precedence: "vite-rsc/client-reference" })for every CSS dep reached through a"use client"Proxy.node_modules/@vitejs/plugin-rsc/dist/plugin-DMfc_Eqq.js:1889-1913generateResourcesCodeemits<link rel="stylesheet" precedence="vite-rsc/importer-resources">from the server-importer graph on every RSC render, including navigation payloads.node_modules/@vitejs/plugin-rsc/dist/plugin-DMfc_Eqq.js:1869-1886RemoveDuplicateServerCssruns once inuseEffecton the initialResourcesmount and stripsdata-precedencestarting withvite-rsc/client-reference. It does not re-run on navigation commits.So on initial SSR, any CSS reached via both a client reference and the server importer graph ends up in
<head>twice. The client-reference copy gets evicted post-mount, and only the importer-resources copy survives.But a CSS reached only via the client reference, for example
top.module.css, where the server-side import is a.module.cssthat resolves to a JS class-name module and apparently doesn't land inserverResources, survives in head at client-reference precedence until navigation. At that point,Resourcesemits a fresh importer-resources link with a different(href, precedence)key. React 19 cannot dedupe it, suspends on load, and causes a flash.Insight
React 19's stylesheet dedup keys on
(href, precedence), nothrefalone. This is by design. React treatsprecedenceas the ordering axis for the layered cascade, so two links at different precedences are not considered the same resource.That is why
plugin-rsc's ownRemoveDuplicateServerCssis a DOM-level eviction rather than React-side reconciliation. React cannot see the two links as duplicates.The flash window is React's stylesheet Suspense, not a CSS repaint. The CSS is already in the HTTP cache and its
loadevent fires in a few milliseconds. But React 19 treats a newly inserted<link rel="stylesheet" precedence>as a Suspense boundary and holds the whole transition commit untilloadfires. The 23 ms is not paint time. It is React blocking commit.Your reported tests don't exist on this branch or on
mainThe following files are not tracked on
mainor on the current branchfix/sibling-interception:tests/fixtures/app-basic/app/nextjs-compat/search-params-dom-identity/page.tsxtests/fixtures/app-basic/app/nextjs-compat/search-params-dom-identity/navigate-button.tsxtests/e2e/app-router/nextjs-compat/search-params-dom-identity.spec.tsEither they live in
~/Projects/movies-ranking/.worktrees/vinextand were never committed upstream, or the note is stale.Worth confirming before writing a failing test next to them. I do not want to duplicate or collide.
Fix direction tradeoffs
1a.
MutationObserverondocument.headduring navigationArm it as navigation enters
dispatchBrowserTree, then remove a new<link rel="stylesheet" data-precedence>whosehrefalready exists under any precedence.Problem:
MutationObserverfires in a microtask after insertion, but React suspends synchronously on the link'sloadevent as part of the same commit. The flash has already happened.1b. Rewrite
data-precedenceon existing head nodes before commitWalk
headonce per navigation and rewrite anydata-precedence="vite-rsc/client-reference"tovite-rsc/importer-resources.React's stylesheet dedup queries the live DOM for matching
(href, precedence)pairs, so the pre-existing link becomes the match and the incoming element is deduped.Risk: React may also keep an internal registry keyed by the precedence it originally saw. Attribute mutation could desynchronise that. Needs empirical verification.
1c.
ReactDOM.preinitall current head stylesheets under theimporter-resourcesprecedence before dispatchTell React's resource registry that the stylesheet is already loaded at precedence
Xbefore render. Then the incoming<link precedence="X">matches an already-loaded resource and does not suspend.This is probably cleaner than 1b because it uses React's public API rather than mutating DOM managed by React.
1d. Walk the incoming RSC payload tree and prune duplicate
<link>elementsThis is the most invasive option. It requires walking
Fragmentchildren inside a deep React tree that may include lazy segments.Avoid.
2. Upstream fix in
plugin-rscUnify precedence per
href.preloadDepsandResourcesshould agree.This is correct, but not in this repo.
3. Change
rel="stylesheet"torel="preload" as="style"in the RSC payloadThis drops React's stylesheet Suspense entirely.
It removes the flash, but at the cost of the load-before-paint guarantee. Cloudflare edge latencies make this tempting, but that guarantee is load-bearing for dark-mode-first flashes on cold navigation to routes with new CSS.
Current lean
My lean is 1c. It is the cleanest option, uses React's public API, and if it works it also handles the case you described where a CSS file is client-reference-only and never emitted by
Resourcesinitially.Before implementing:
@james-elicx commented on GitHub (Apr 21, 2026):
We should definitely try and fix upstream if your investigation points to the plugin - would you be interested in looking at proposing a potential fix for the plugin?
@NathanDrake2406 commented on GitHub (Apr 22, 2026):
fake news!!! I found the actual bug (tested on my app).
Will push a PR
@james-elicx commented on GitHub (Apr 22, 2026):
Nice! Thanks for digging into it!
@NathanDrake2406 commented on GitHub (Apr 22, 2026):
Have u been building anything cool lately? I'm kinda bored now lol, seems like people stopped using this.
I originally wanted to migrate my app to vinext but next got so good in 16.2
Only thing left bugging me is the Opennext bug that causes a hard 0.8s TTFB.
Have you built w Tanstack before? I heard it's the highest performant framework atm
@james-elicx commented on GitHub (Apr 22, 2026):
There's a few things I've been playing with when I get some spare time over the past month;
But yeah, I don't really get much time to work on any of them properly, which is why they're all half-baked and no where near completed 😅.
Busy with the day job most of the time - helping my team get some of our current projects over the line.
I haven't used TanStack Start but I do use some of the other TanStack libraries like Query / Form / Virtual though.
There's a vinext channel in the Cloudflare Discord as well btw in case you ever want to chat in there!