-
Notifications
You must be signed in to change notification settings - Fork 272
Description
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
vinext dev, open the page- Click "Apply filter" — navigates to
/?filter=active - Click "Clear filter" — navigates back to
/ - 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
- Add a detail route (
app/item/[id]/page.tsx) with an async server component - Scroll down on the list page
- Click a link to
/item/1 - Press browser back
- 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.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
- 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.