Skip to content

App Router client navigation double-flashes Suspense fallbacks and janky back-button scroll restoration #639

@NathanDrake2406

Description

@NathanDrake2406

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions