Files
Nathan Nguyen a775527b8b fix(app-router): keep isPending true across programmatic router.push (#868)
* fix(app-router): track pending router state for programmatic navigations

`router.push()` / `router.replace()` / `router.refresh()` dispatched the
new tree only after the RSC payload arrived, so `useTransition().isPending`
dropped to false the moment the user callback returned and Suspense
boundaries visibly committed their fallbacks during the fetch window.
Wrapping the programmatic call in `useTransition` did not help because
the state update React needed to latch onto was not scheduled until the
navigation was already committed. Issue #639 describes the downstream
symptom: double-flashing Suspense fallbacks during client navigation.

Root cause: the browser router state was a `useReducer` that was only
dispatched at commit time. React had no in-flight promise to wait on,
so transition priority could not keep the previous UI on screen.

Switch the browser router state to `useState<State | Promise<State>>`
and, when a programmatic navigation starts, publish a deferred promise
into the state slot synchronously. `BrowserRoot` unwraps the slot via
`use()`, so any render during the RSC fetch suspends with transition
priority and the previous UI stays visible. The deferred promise is
resolved with the reducer output when the payload dispatches, or settled
with the current committed state on supersession / error / internal RSC
redirect, so `useTransition().isPending` latches true for the full
navigation window and drops exactly once at commit. `router.push` /
`replace` / `refresh` now also wrap their synchronous entry in
`React.startTransition`, matching Next.js's `app-router-instance.ts`.

Covered by `tests/e2e/app-router/nextjs-compat/router-push-pending.spec.ts`:
same-route search-param push inside `useTransition` must keep the
pending flag true until the server component commits its new filter.

Known limitation (tracked as a follow-up): the internal RSC-redirect
path still recurses through `__VINEXT_RSC_NAVIGATE__` with
`programmaticTransition=false`, so the outer pending must be settled
before the recursion. `useTransition().isPending` flashes false mid-redirect
until the recursive call finishes. Fixing this requires converting the
recursive redirect into an inline loop inside `navigateRsc` so a single
deferred promise can span all hops, the way Next.js's action queue does.

* address review feedback on programmatic router pending state

Replace duck-type "then" in value check with instanceof Promise in
isRouterStatePromise — the promises are always created locally so
instanceof is correct and avoids misclassification if AppRouterState
ever gains a then property.

Add a comment in dispatchBrowserTree explaining why the pendingRouterState
early return safely ignores the useTransitionMode branch — the outer
React.startTransition in router.push/replace/refresh already provides
transition priority, so no additional wrap is needed.

Remove dead typeof React.startTransition === "function" guards in
push/replace/refresh — vinext requires React 19+, where startTransition
is always present. Inline the transition call directly.

Bump SlowFilterResult delay from 200ms to 1_000ms so the isPending
assertion has a wider window and is less susceptible to CI timing races.
2026-04-22 10:32:56 +01:00
..
2026-04-09 09:14:13 +01:00
2026-03-15 10:50:13 +00:00
2026-04-09 09:14:13 +01:00
2026-04-09 09:14:13 +01:00
2026-04-09 09:14:13 +01:00
2026-04-09 09:14:13 +01:00