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

Open
opened 2026-05-06 13:09:54 +02:00 by BreizhHardware · 0 comments

📋 Pull Request Information

Original PR: https://github.com/cloudflare/vinext/pull/647
Author: @southpolesteve
Created: 3/22/2026
Status: 🔄 Open

Base: mainHead: fix/suspense-flash-navigation-639


📝 Commits (3)

  • 1695009 fix: prevent Suspense fallback flash during App Router client navigation (#639)
  • f0d522a fix: also fix back-button scroll restoration jank (#639)
  • e952f80 fix: buffer full RSC response before rendering to eliminate partial commits

📊 Changes

8 files changed (+408 additions, -14 deletions)

View changed files

📝 packages/vinext/src/server/app-browser-entry.ts (+121 -7)
📝 packages/vinext/src/shims/navigation.ts (+25 -7)
📝 tests/e2e/app-router/loading.spec.ts (+38 -0)
📝 tests/e2e/app-router/navigation-flows.spec.ts (+132 -0)
📝 tests/fixtures/app-basic/app/page.tsx (+3 -0)
tests/fixtures/app-basic/app/suspense-nav-test/FilterToggle.tsx (+16 -0)
tests/fixtures/app-basic/app/suspense-nav-test/item/[id]/page.tsx (+28 -0)
tests/fixtures/app-basic/app/suspense-nav-test/page.tsx (+45 -0)

📄 Description

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 #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


🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/cloudflare/vinext/pull/647 **Author:** [@southpolesteve](https://github.com/southpolesteve) **Created:** 3/22/2026 **Status:** 🔄 Open **Base:** `main` ← **Head:** `fix/suspense-flash-navigation-639` --- ### 📝 Commits (3) - [`1695009`](https://github.com/cloudflare/vinext/commit/16950098ca123fb2743e56fd6cb9248761aa29cf) fix: prevent Suspense fallback flash during App Router client navigation (#639) - [`f0d522a`](https://github.com/cloudflare/vinext/commit/f0d522ab6d47854fc8945b5c2ac1632b477fb125) fix: also fix back-button scroll restoration jank (#639) - [`e952f80`](https://github.com/cloudflare/vinext/commit/e952f8006a1155147f9dee4cc85e4576d53d12f7) fix: buffer full RSC response before rendering to eliminate partial commits ### 📊 Changes **8 files changed** (+408 additions, -14 deletions) <details> <summary>View changed files</summary> 📝 `packages/vinext/src/server/app-browser-entry.ts` (+121 -7) 📝 `packages/vinext/src/shims/navigation.ts` (+25 -7) 📝 `tests/e2e/app-router/loading.spec.ts` (+38 -0) 📝 `tests/e2e/app-router/navigation-flows.spec.ts` (+132 -0) 📝 `tests/fixtures/app-basic/app/page.tsx` (+3 -0) ➕ `tests/fixtures/app-basic/app/suspense-nav-test/FilterToggle.tsx` (+16 -0) ➕ `tests/fixtures/app-basic/app/suspense-nav-test/item/[id]/page.tsx` (+28 -0) ➕ `tests/fixtures/app-basic/app/suspense-nav-test/page.tsx` (+45 -0) </details> ### 📄 Description ## 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.ts` — `NavigationRoot` 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 #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 --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
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#742
No description provided.