Skip to content

fix: prevent Suspense fallback flash during App Router client navigation (#639)#647

Open
southpolesteve wants to merge 3 commits intomainfrom
fix/suspense-flash-navigation-639
Open

fix: prevent Suspense fallback flash during App Router client navigation (#639)#647
southpolesteve wants to merge 3 commits intomainfrom
fix/suspense-flash-navigation-639

Conversation

@southpolesteve
Copy link
Collaborator

Summary

  • Replaces flushSync(() => root.render(...)) with a NavigationRoot wrapper component that holds RSC content in React state
  • Navigation updates go through startTransition(() => setState(newContent)) inside NavigationRoot, which tells React to keep the current committed UI visible until all Suspense boundaries in the new tree resolve, then commit atomically
  • Fixes the same issue for server action re-renders, which were bypassing NavigationRoot via root.render() and destroying the wrapper

Root cause

flushSync forced a synchronous commit of the incoming RSC tree including any unresolved Suspense fallbacks. This produced a visible double-flash: content outside a Suspense boundary (e.g. a heading) updated immediately while content inside it still showed the loading fallback, before finally resolving.

Why startTransition(() => root.render(...)) wasn't enough

root.render() replaces the entire fiber tree, so React has no previously committed content to hold onto. New Suspense boundaries in the replacement tree can still flash their fallbacks.

startTransition(() => setState(...)) inside a persistent component is the correct approach: React keeps that component's current committed output visible until the new render (including all Suspense boundaries) is fully ready, matching Next.js App Router behavior.

Changes

  • packages/vinext/src/server/app-browser-entry.tsNavigationRoot component + _scheduleRscUpdate wiring
  • tests/fixtures/app-basic/app/suspense-nav-test/ — new fixture reproducing the exact partial-flash scenario from the issue
  • tests/e2e/app-router/navigation-flows.spec.ts — regression tests for issue App Router client navigation double-flashes Suspense fallbacks and janky back-button scroll restoration #639
  • tests/e2e/app-router/loading.spec.ts — updated: removed incorrect assertion (showing loading.tsx during navigation to a new route is correct Next.js behavior)

Closes #639

…ion (#639)

Replace flushSync + root.render() with a NavigationRoot wrapper component
that holds RSC content in React state. Navigation updates go through
startTransition(() => setState()), which tells React to keep the current
committed UI visible until all Suspense boundaries in the new tree have
resolved, then commit atomically.

flushSync forced an immediate synchronous commit including unresolved
Suspense fallbacks, producing a visible double-flash where content outside
a Suspense boundary (e.g. a heading) updated before content inside it was
ready. The NavigationRoot approach matches Next.js App Router behavior.

Also fixes the same bug for server action re-renders, which were bypassing
NavigationRoot via root.render() and destroying the wrapper.

Adds regression tests and a /suspense-nav-test fixture page that reproduces
the exact partial-flash scenario from the issue report.
@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 22, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@647

commit: e952f80

@github-actions
Copy link

Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original

@southpolesteve
Copy link
Collaborator Author

/bigbonk review

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 22, 2026

@southpolesteve Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

The NavigationRoot fix committed Suspense fallbacks (short content) before
the resolved content, so restoreScrollPosition was calling scrollTo on a
page too short to reach the saved position. The fix retries scrollTo each
rAF until the scroll target is reached or a 1.5s deadline passes, which
lets it succeed once the full content (e.g. ItemList) is in the DOM.

Also adds history.scrollRestoration = 'manual' so the browser's auto
scroll restoration does not override our manual restoration, and adds
useTransition to NavigationRoot so navigateRsc awaits the transition
commit before resolving (so __VINEXT_RSC_PENDING__ carries full timing).

Adds item detail fixture route and a scroll restoration E2E test.
…ommits

Per analysis by @NathanDrake2406 in issue #639: without buffering,
createFromFetch processes a streaming response and creates React elements
with lazy chunks for async server components. React commits the Suspense
fallback first (short content), then the resolved content in a second
pass - causing the flash.

Buffering the full response body before createFromFetch ensures the flight
parser processes all rows in one synchronous pass. React renders and commits
the complete content in a single pass with no Suspense suspension.

The known remaining limitation (CSS animation replay on navigation) is a
deeper architectural issue requiring segment-level diffing, tracked
separately and referenced in the PR.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant