mirror of
https://github.com/cloudflare/vinext.git
synced 2026-05-09 08:25:34 +02:00
[PR #876] [CLOSED] fix(app-router): keep isPending true across router.back() and router.forward() #913
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#913
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/876
Author: @NathanDrake2406
Created: 4/23/2026
Status: ❌ Closed
Base:
main← Head:fix/router-back-forward-ispending📝 Commits (10+)
2f6c475test(app-router): add failing E2E for router.back/forward isPending continuitye33b851fix(app-router): keep isPending true across router.back() and router.forward()f644540fix(types): declare window.navigation shape used by traversal shim3db6157test(app-router): wait for router readiness in E2E751a541fix(app-router): gate traversal-pending arm on router readiness7bfc4c9fix(app-router): guard traversal pending hook5881aa3fix(app-router): heal applyAction race via dual-write5f46d3dfix(app-router): assign traversal arm hook from mount effectef879b2docs(app-router): clarify traversal degraded mode and transition wrapping3099335test(e2e): symmetrize hydration probe and annotate forward-test setup📊 Changes
10 files changed (+673 additions, -18 deletions)
View changed files
📝
packages/vinext/src/global.d.ts(+17 -0)📝
packages/vinext/src/server/app-browser-entry.ts(+71 -13)📝
packages/vinext/src/shims/navigation.ts(+157 -2)➕
tests/e2e/app-router/nextjs-compat/router-back-forward-pending.spec.ts(+276 -0)📝
tests/e2e/helpers.ts(+2 -3)➕
tests/fixtures/app-basic/app/nextjs-compat/router-back-forward-pending/destination/back-client.tsx(+58 -0)➕
tests/fixtures/app-basic/app/nextjs-compat/router-back-forward-pending/destination/page.tsx(+19 -0)➕
tests/fixtures/app-basic/app/nextjs-compat/router-back-forward-pending/destination/step2/page.tsx(+19 -0)➕
tests/fixtures/app-basic/app/nextjs-compat/router-back-forward-pending/nav-client.tsx(+35 -0)➕
tests/fixtures/app-basic/app/nextjs-compat/router-back-forward-pending/page.tsx(+19 -0)📄 Description
What this changes
router.back()androuter.forward()now keepuseTransition().isPendingtrue from the synchronous call site until the RSC traversal commits. Matches the behavior shipped forrouter.push/replacein #868 and for RSC-level redirects in #870.Why
router.back()androuter.forward()callwindow.history.back/forward()synchronously. The browser queuespopstateas a new task, so by the time it fires the user'sReact.startTransitioncallback has already exited. No setState is tied to the transition,useTransition().isPendingnever latches, and any loading UI flashes to its idle state before the new route renders.Wrapping the popstate handler itself in
startTransitiondoes not help, because that creates a transition unrelated to the one the user opened in their component.Approach
Arm a deferred
PendingBrowserRouterState(a Promise published into the router'suseStateslot) inside the user's transition, then adopt it on the subsequent popstate'snavigateRsc.Three small production changes:
shims/navigation.ts- addrunProgrammaticTraversal(direction). It opens the user'sstartTransition, callswindow.__VINEXT_ARM_TRAVERSAL_PENDING__?.(), thenhistory.back/forward(). Gate the arm onwindow.navigation.canGoBack/canGoForwardso a no-op traversal does not leave an unsettled pending.server/app-browser-entry.ts- register__VINEXT_ARM_TRAVERSAL_PENDING__at hydration time; it invokesbeginPendingBrowserRouterState(), which auto-settles any stale pending, publishes a fresh promise via thesetBrowserRouterStatesetter, and records it asactivePendingBrowserRouterState.navigateRscadoption branch - whenprogrammaticTransitionis false and an active pending exists, adopt it so the commit resolves the promise the user's transition is suspended on. User-initiated popstates (keyboard, gesture, address bar) find no active pending and fall through unchanged.No registry, no id map, no safety timer. The Navigation API's synchronous availability check handles the no-op case; on older browsers without the Navigation API, arming is skipped and
isPendingpreservation degrades to current Next.js behavior (no hang).Non-goals: Pages Router is unaffected; the arm hook is defined only for App Router pages.
Validation
tests/e2e/app-router/nextjs-compat/router-back-forward-pending.spec.ts, four specs using the MutationObserver idle-flash pattern from #870:router.back()keepsisPendingtrue until page A commits (reproduces the current bug pre-fix with log["b:idle","b:pending","b:idle","__removed__"])router.forward()keepsisPendingtrue until page B commitsrouter.back(); router.back();stays pending through the second hoprouter.back()thenrouter.forward()across separate clicks round-trips cleanlyvp checkclean (format, lint, type-aware).Risks / follow-ups
isPendingpreservation is skipped for traversals. Same behavior as Next.js today on those browsers, no hang.history.statewith a programmatic marker would close this; deliberately out of scope.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.