mirror of
https://github.com/cloudflare/vinext.git
synced 2026-05-09 08:25:34 +02:00
[PR #868] [MERGED] fix(app-router): keep isPending true across programmatic router.push #908
Labels
No labels
enhancement
enhancement
good first issue
help wanted
nextjs-tracking
nextjs-tracking
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
starred/vinext#908
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
📋 Pull Request Information
Original PR: https://github.com/cloudflare/vinext/pull/868
Author: @NathanDrake2406
Created: 4/22/2026
Status: ✅ Merged
Merged: 4/22/2026
Merged by: @james-elicx
Base:
main← Head:fix/programmatic-router-push-pending-transition📝 Commits (2)
5a4b984fix(app-router): track pending router state for programmatic navigations12ee56daddress review feedback on programmatic router pending state📊 Changes
7 files changed (+290 additions, -37 deletions)
View changed files
📝
packages/vinext/src/global.d.ts(+1 -0)📝
packages/vinext/src/server/app-browser-entry.ts(+155 -31)📝
packages/vinext/src/shims/navigation.ts(+21 -5)➕
tests/e2e/app-router/nextjs-compat/router-push-pending.spec.ts(+45 -0)➕
tests/fixtures/app-basic/app/nextjs-compat/router-push-pending/page.tsx(+26 -0)➕
tests/fixtures/app-basic/app/nextjs-compat/router-push-pending/pending-client.tsx(+28 -0)📝
tests/form.test.ts(+14 -1)📄 Description
Closes #639.
What this changes
router.push()/router.replace()/router.refresh()now publish a deferred router-state promise into the browser'suseStateslot the moment the programmatic call is made, instead of dispatching the new tree only at commit.BrowserRootunwraps the slot withuse(), so any render during the RSC fetch suspends with transition priority and the previous UI stays visible.useTransition().isPendinglatches true for the entire navigation window and drops exactly once, at commit.router.push/replace/refreshalso wrap their synchronous entry inReact.startTransition, mirroring Next.js'spackages/next/src/client/components/app-router-instance.ts.Why
Before this change, the browser router state was a
useReducerthat only dispatched at commit time. React had nothing in-flight to wait on, so wrapping a programmatic navigation inuseTransitiondid not latchisPending, and Suspense boundaries inside the destination tree visibly committed their fallbacks during the RSC fetch. Issue #639 describes the user-visible symptom: double-flashing Suspense fallbacks during client navigation triggered byrouter.push. A keyed list remount in a reporter'smovies-rankingapp is one concrete reproduction — the provider chip flashed empty becauseisPendingwas false while the new server component was still fetching.Link clicks already worked because they go through a different code path that keeps old UI visible; the gap was specifically programmatic navigations.
Approach
useReducer→useState<AppRouterState | Promise<AppRouterState>>inserver/app-browser-entry.ts.BrowserRootunwraps the slot viause().PendingBrowserRouterState(deferred promise + resolver + settled flag). Programmatic navigations callbeginPendingBrowserRouterState()synchronously insideReact.startTransition, which publishes the promise into the state slot. On successful dispatch,dispatchBrowserTreeresolves it to the reducer output. On supersession, error, or an internal RSC redirect, it's settled with the current committed state so the slot recovers.navigateClientSidegains aprogrammaticTransitionarg, threaded through__VINEXT_RSC_NAVIGATE__'s public signature so onlyrouter.push/replace/refreshopt into the pending-promise machinery. Link clicks still go through the direct-setter path._appRouter.push/replace/refreshwrap their synchronous entry inReact.startTransition, matching Next.js.Scope boundaries (non-goals):
<Link>navigation. Link clicks already preserved the previous UI via the existing transition path.commitSameUrlNavigatePayloadpassesnullfor pending state — server actions don't open a user-visible transition.app-browser-entry.tsaround the pathname-mismatch branch) still recurses through__VINEXT_RSC_NAVIGATE__. The outer pending is settled before the recursion, soisPendingbriefly flashes false across the redirect hop. A full Next.js-style inline redirect loop is filed as a follow-up.Validation
tests/e2e/app-router/nextjs-compat/router-push-pending.spec.ts+ fixture undertests/fixtures/app-basic/app/nextjs-compat/router-push-pending/: same-route search-paramrouter.pushinsideuseTransitionmust keepisPendingtrue through the SSR delay and drop to false once the server filter commits.vp exec playwright test tests/e2e/app-router/nextjs-compat/router-push-pending.spec.ts -c tests/e2e/app-router/nextjs-compat/playwright.nextjs-compat.config.ts— passes.vp exec playwright test tests/e2e/app-router/nextjs-compat/search-params-key.spec.ts -c tests/e2e/app-router/nextjs-compat/playwright.nextjs-compat.config.ts— passes (no regression in the existing search-param navigation spec).vp check— clean (format, lint, type).movies-ranking) that the mid-navigation flash no longer occurs.Risks / follow-ups
navigateRscremains. A follow-up issue converts it into an inline loop insidenavigateRscso a single deferred promise can span all redirect hops, the way Next.js's action queue does. Until then,isPendingacross a same-call internal redirect is not perfectly continuous.PendingBrowserRouterStatesettlement is diffused across multiple bailout sites innavigateRsc. Each is idempotent via thesettledflag, but a future early return will need to remember to settle. Atry / finallywrapper around the body is a reasonable follow-up cleanup.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.