[GH-ISSUE #639] App Router client navigation double-flashes Suspense fallbacks and janky back-button scroll restoration #133

Closed
opened 2026-05-06 12:37:29 +02:00 by BreizhHardware · 16 comments

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):

// app/page.tsx
import { Suspense } from "react";
import { FilterToggle } from "./FilterToggle";

async function ItemList({ filter }: { filter: string }) {
  // Simulate DB query — any async server component triggers it
  await new Promise((r) => setTimeout(r, 300));
  const items = filter
    ? [{ id: 1, name: `Filtered: ${filter}` }]
    : [{ id: 1, name: "Item A" }, { id: 2, name: "Item B" }, { id: 3, name: "Item C" }];
  return <ul>{items.map((i) => <li key={i.id}>{i.name}</li>)}</ul>;
}

export default async function Page({ searchParams }: { searchParams: Promise<{ filter?: string }> }) {
  const { filter = "" } = await searchParams;
  return (
    <div>
      <h1>{filter ? `Filtered: ${filter}` : "All items"}</h1>
      <FilterToggle current={filter} />
      <Suspense fallback={<div style={{ height: 400 }} />}>
        <ItemList filter={filter} />
      </Suspense>
    </div>
  );
}

Client component:

// app/FilterToggle.tsx
"use client";
import { useRouter } from "next/navigation";

export function FilterToggle({ current }: { current: string }) {
  const router = useRouter();
  return (
    <button onClick={() => router.push(current ? "/" : "/?filter=active")}>
      {current ? "Clear filter" : "Apply filter"}
    </button>
  );
}

Steps — double flash

  1. vinext dev, open the page
  2. Click "Apply filter" — navigates to /?filter=active
  3. Click "Clear filter" — navigates back to /
  4. Observe: the heading updates to "All items" while the Suspense fallback (empty space) is visible, then the list pops in afterward — two visible stages instead of one clean swap

Steps — back button scroll jank

  1. Add a detail route (app/item/[id]/page.tsx) with an async server component
  2. Scroll down on the list page
  3. Click a link to /item/1
  4. Press browser back
  5. Observe: scroll position jumps before the list content is ready, producing a visual stutter

What I found

The RSC navigation render in packages/vinext/src/server/app-browser-entry.ts uses flushSync:

const rscPayload = await createFromFetch(Promise.resolve(navResponse));
flushSync(() => {
  getReactRoot().render(rscPayload as ReactNode);
});

flushSync forces React to synchronously commit the new tree, including Suspense fallbacks for any unresolved async server components. Next.js uses startTransition here 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.ts registers a popstate listener that defers scroll restoration via microtask, while app-browser-entry.ts registers a separate popstate listener that calls __VINEXT_RSC_NAVIGATE__ (which uses flushSync). The flushSync commits 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.tsflushSync render on navigation
  • packages/vinext/src/shims/navigation.tspopstate listener, scroll save/restore
  • packages/vinext/src/shims/link.tsx<Link> also calls __VINEXT_RSC_NAVIGATE__

Expected behavior

  • Client navigations keep old UI visible until new content is fully resolved (no Suspense fallback flash)
  • Back/forward restores scroll only after the destination UI is ready to present
  • Matches Next.js App Router behavior

Actual behavior

  • Suspense fallbacks are visibly committed during client navigation, producing a multi-stage flash
  • Back/forward scroll restoration fires on incomplete content, producing a visual stutter

Happy to help with a minimal standalone repro if useful.

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):** ```tsx // app/page.tsx import { Suspense } from "react"; import { FilterToggle } from "./FilterToggle"; async function ItemList({ filter }: { filter: string }) { // Simulate DB query — any async server component triggers it await new Promise((r) => setTimeout(r, 300)); const items = filter ? [{ id: 1, name: `Filtered: ${filter}` }] : [{ id: 1, name: "Item A" }, { id: 2, name: "Item B" }, { id: 3, name: "Item C" }]; return <ul>{items.map((i) => <li key={i.id}>{i.name}</li>)}</ul>; } export default async function Page({ searchParams }: { searchParams: Promise<{ filter?: string }> }) { const { filter = "" } = await searchParams; return ( <div> <h1>{filter ? `Filtered: ${filter}` : "All items"}</h1> <FilterToggle current={filter} /> <Suspense fallback={<div style={{ height: 400 }} />}> <ItemList filter={filter} /> </Suspense> </div> ); } ``` **Client component:** ```tsx // app/FilterToggle.tsx "use client"; import { useRouter } from "next/navigation"; export function FilterToggle({ current }: { current: string }) { const router = useRouter(); return ( <button onClick={() => router.push(current ? "/" : "/?filter=active")}> {current ? "Clear filter" : "Apply filter"} </button> ); } ``` ### Steps — double flash 1. `vinext dev`, open the page 2. Click "Apply filter" — navigates to `/?filter=active` 3. Click "Clear filter" — navigates back to `/` 4. **Observe:** the heading updates to "All items" while the Suspense fallback (empty space) is visible, then the list pops in afterward — two visible stages instead of one clean swap ### Steps — back button scroll jank 1. Add a detail route (`app/item/[id]/page.tsx`) with an async server component 2. Scroll down on the list page 3. Click a link to `/item/1` 4. Press browser back 5. **Observe:** scroll position jumps before the list content is ready, producing a visual stutter ## What I found The RSC navigation render in `packages/vinext/src/server/app-browser-entry.ts` uses `flushSync`: ```ts const rscPayload = await createFromFetch(Promise.resolve(navResponse)); flushSync(() => { getReactRoot().render(rscPayload as ReactNode); }); ``` `flushSync` forces React to synchronously commit the new tree, including Suspense fallbacks for any unresolved async server components. Next.js uses `startTransition` here 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.ts` registers a `popstate` listener that defers scroll restoration via microtask, while `app-browser-entry.ts` registers a separate `popstate` listener that calls `__VINEXT_RSC_NAVIGATE__` (which uses `flushSync`). The `flushSync` commits 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` — `flushSync` render on navigation - `packages/vinext/src/shims/navigation.ts` — `popstate` listener, scroll save/restore - `packages/vinext/src/shims/link.tsx` — `<Link>` also calls `__VINEXT_RSC_NAVIGATE__` ## Expected behavior - Client navigations keep old UI visible until new content is fully resolved (no Suspense fallback flash) - Back/forward restores scroll only after the destination UI is ready to present - Matches Next.js App Router behavior ## Actual behavior - Suspense fallbacks are visibly committed during client navigation, producing a multi-stage flash - Back/forward scroll restoration fires on incomplete content, producing a visual stutter Happy to help with a minimal standalone repro if useful.
Author
Owner

@NathanDrake2406 commented on GitHub (Mar 22, 2026):

This is on new version. @southpolesteve happy to help on this when you're done refactoring

<!-- gh-comment-id:4105146356 --> @NathanDrake2406 commented on GitHub (Mar 22, 2026): This is on new version. @southpolesteve happy to help on this when you're done refactoring
Author
Owner

@NathanDrake2406 commented on GitHub (Mar 22, 2026):

actually i think browser-entry is done I'll just fix it

<!-- gh-comment-id:4105150094 --> @NathanDrake2406 commented on GitHub (Mar 22, 2026): actually i think browser-entry is done I'll just fix it
Author
Owner

@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.

<!-- gh-comment-id:4105245949 --> @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.
Author
Owner

@NathanDrake2406 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.

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

<!-- gh-comment-id:4105477047 --> @NathanDrake2406 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. 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
Author
Owner

@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

<!-- gh-comment-id:4106223953 --> @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
Author
Owner

@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

<!-- gh-comment-id:4106351690 --> @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
Author
Owner

@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, and use-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 CacheNode tree 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 startTransition wrapping the reducer

Next.js wraps the entire navigation dispatch in one startTransition. The reducer returns the new AppRouterState (sync for cache hits, Promise for cache misses). React suspends on the Promise and keeps the old UI visible. There is no second nested startTransition inside the render path.

3. useDeferredValue for prefetch-to-dynamic transitions

layout-router.tsx does useDeferredValue(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 consumePrefetchResponse was awaiting pending prefetches, which caused Firefox navigation to hang on Cloudflare Workers.)

5. useInsertionEffect for URL updates

Next.js pushes history in useInsertionEffect (fires before layout effects, before paint). This is earlier in the commit cycle than useLayoutEffect.

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 in layout-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.

<!-- gh-comment-id:4106396631 --> @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`, and `use-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 `CacheNode` tree 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 `startTransition` wrapping the reducer** Next.js wraps the entire navigation dispatch in one `startTransition`. The reducer returns the new `AppRouterState` (sync for cache hits, Promise for cache misses). React suspends on the Promise and keeps the old UI visible. There is no second nested `startTransition` inside the render path. **3. `useDeferredValue` for prefetch-to-dynamic transitions** `layout-router.tsx` does `useDeferredValue(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 `consumePrefetchResponse` was awaiting pending prefetches, which caused Firefox navigation to hang on Cloudflare Workers.) **5. `useInsertionEffect` for URL updates** Next.js pushes history in `useInsertionEffect` (fires before layout effects, before paint). This is earlier in the commit cycle than `useLayoutEffect`. ## 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 in `layout-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.
Author
Owner

@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

<!-- gh-comment-id:4107438034 --> @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
Author
Owner

@NathanDrake2406 commented on GitHub (Apr 21, 2026):

@james-elicx it's actually a vite rsc bug. God damn...

<!-- gh-comment-id:4290320435 --> @NathanDrake2406 commented on GitHub (Apr 21, 2026): @james-elicx it's actually a vite rsc bug. God damn...
Author
Owner

@james-elicx commented on GitHub (Apr 21, 2026):

@james-elicx it's actually a vite rsc bug. God damn...

What is the bug in the RSC plugin? Do you have an example of what you mean?

<!-- gh-comment-id:4290330682 --> @james-elicx commented on GitHub (Apr 21, 2026): > [@james-elicx](https://github.com/james-elicx) it's actually a vite rsc bug. God damn... What is the bug in the RSC plugin? Do you have an example of what you mean?
Author
Owner

@NathanDrake2406 commented on GitHub (Apr 21, 2026):

@james-elicx it's actually a vite rsc bug. God damn...

What is the bug in the RSC plugin? Do you have an example of what you mean?

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-77
    preloadDeps calls ReactDOM.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-1913
    generateResourcesCode emits <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-1886
    RemoveDuplicateServerCss runs once in useEffect on the initial Resources mount and strips data-precedence starting with vite-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.css that resolves to a JS class-name module and apparently doesn't land in serverResources, survives in head at client-reference precedence until navigation. At that point, Resources emits 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), not href alone. This is by design. React treats precedence as 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 own RemoveDuplicateServerCss is 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 load event 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 until load fires. The 23 ms is not paint time. It is React blocking commit.

Your reported tests don't exist on this branch or on main

The following files are not tracked on main or on the current branch fix/sibling-interception:

  • tests/fixtures/app-basic/app/nextjs-compat/search-params-dom-identity/page.tsx
  • tests/fixtures/app-basic/app/nextjs-compat/search-params-dom-identity/navigate-button.tsx
  • tests/e2e/app-router/nextjs-compat/search-params-dom-identity.spec.ts

Either they live in ~/Projects/movies-ranking/.worktrees/vinext and 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. MutationObserver on document.head during navigation

Arm it as navigation enters dispatchBrowserTree, then remove a new <link rel="stylesheet" data-precedence> whose href already exists under any precedence.

Problem: MutationObserver fires in a microtask after insertion, but React suspends synchronously on the link's load event as part of the same commit. The flash has already happened.

1b. Rewrite data-precedence on existing head nodes before commit

Walk head once per navigation and rewrite any data-precedence="vite-rsc/client-reference" to vite-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.preinit all current head stylesheets under the importer-resources precedence before dispatch

Tell React's resource registry that the stylesheet is already loaded at precedence X before 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.

This is the most invasive option. It requires walking Fragment children inside a deep React tree that may include lazy segments.

Avoid.

2. Upstream fix in plugin-rsc

Unify precedence per href. preloadDeps and Resources should agree.

This is correct, but not in this repo.

3. Change rel="stylesheet" to rel="preload" as="style" in the RSC payload

This 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 Resources initially.

Before implementing:

  • confirm the test files either exist somewhere retrievable, or
  • give the go-ahead to author fresh fixtures plus a failing E2E that reproduces the duplicated-link case.
<!-- gh-comment-id:4290373744 --> @NathanDrake2406 commented on GitHub (Apr 21, 2026): > > [@james-elicx](https://github.com/james-elicx) it's actually a vite rsc bug. God damn... > > What is the bug in the RSC plugin? Do you have an example of what you mean? 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-77` `preloadDeps` calls `ReactDOM.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-1913` `generateResourcesCode` emits `<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-1886` `RemoveDuplicateServerCss` runs once in `useEffect` on the initial `Resources` mount and strips `data-precedence` starting with `vite-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.css` that resolves to a JS class-name module and apparently doesn't land in `serverResources`, survives in head at client-reference precedence until navigation. At that point, `Resources` emits 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)`, not `href` alone. This is by design. React treats `precedence` as 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 own `RemoveDuplicateServerCss` is 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 `load` event 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 until `load` fires. The 23 ms is not paint time. It is React blocking commit. # Your reported tests don't exist on this branch or on `main` The following files are not tracked on `main` or on the current branch `fix/sibling-interception`: - `tests/fixtures/app-basic/app/nextjs-compat/search-params-dom-identity/page.tsx` - `tests/fixtures/app-basic/app/nextjs-compat/search-params-dom-identity/navigate-button.tsx` - `tests/e2e/app-router/nextjs-compat/search-params-dom-identity.spec.ts` Either they live in `~/Projects/movies-ranking/.worktrees/vinext` and 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. `MutationObserver` on `document.head` during navigation Arm it as navigation enters `dispatchBrowserTree`, then remove a new `<link rel="stylesheet" data-precedence>` whose `href` already exists under any precedence. **Problem:** `MutationObserver` fires in a microtask after insertion, but React suspends synchronously on the link's `load` event as part of the same commit. The flash has already happened. ## 1b. Rewrite `data-precedence` on existing head nodes before commit Walk `head` once per navigation and rewrite any `data-precedence="vite-rsc/client-reference"` to `vite-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.preinit` all current head stylesheets under the `importer-resources` precedence before dispatch Tell React's resource registry that the stylesheet is already loaded at precedence `X` before 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>` elements This is the most invasive option. It requires walking `Fragment` children inside a deep React tree that may include lazy segments. Avoid. ## 2. Upstream fix in `plugin-rsc` Unify precedence per `href`. `preloadDeps` and `Resources` should agree. This is correct, but not in this repo. ## 3. Change `rel="stylesheet"` to `rel="preload" as="style"` in the RSC payload This 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 `Resources` initially. Before implementing: - confirm the test files either exist somewhere retrievable, or - give the go-ahead to author fresh fixtures plus a failing E2E that reproduces the duplicated-link case.
Author
Owner

@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?

<!-- gh-comment-id:4290512068 --> @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?
Author
Owner

@NathanDrake2406 commented on GitHub (Apr 22, 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?

fake news!!! I found the actual bug (tested on my app).
Will push a PR

<!-- gh-comment-id:4293620680 --> @NathanDrake2406 commented on GitHub (Apr 22, 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? fake news!!! I found the actual bug (tested on my app). Will push a PR
Author
Owner

@james-elicx commented on GitHub (Apr 22, 2026):

Nice! Thanks for digging into it!

<!-- gh-comment-id:4295114123 --> @james-elicx commented on GitHub (Apr 22, 2026): Nice! Thanks for digging into it!
Author
Owner

@NathanDrake2406 commented on GitHub (Apr 22, 2026):

Nice! Thanks for digging into it!

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

<!-- gh-comment-id:4296131422 --> @NathanDrake2406 commented on GitHub (Apr 22, 2026): > Nice! Thanks for digging into it! 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
Author
Owner

@james-elicx 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

There's a few things I've been playing with when I get some spare time over the past month;

  • Generated an alternative frontend for a website I use to track what I watch since I don't particularly like their UI.
  • Started playing around with the idea of vibe coding my own agent orchestration tool that uses cloudflare sandboxes.
  • Whipped up a POC of something I've wanted for a while to track theatre shows.
  • I also have a hobby programming language that I've been working on-and-off on over the past year.

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!

<!-- gh-comment-id:4296403321 --> @james-elicx 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 There's a few things I've been playing with when I get some spare time over the past month; - Generated an alternative frontend for a website I use to track what I watch since I don't particularly like their UI. - Started playing around with the idea of vibe coding my own agent orchestration tool that uses cloudflare sandboxes. - Whipped up a POC of something I've wanted for a while to track theatre shows. - I also have a hobby programming language that I've been working on-and-off on over the past year. 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!
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/vinext#133
No description provided.