mirror of
https://github.com/cloudflare/vinext.git
synced 2026-05-09 08:25:34 +02:00
[GH-ISSUE #869] App Router: convert internal RSC-redirect recursion into an inline loop so a single pending transition can span all hops #190
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#190
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?
Originally created by @NathanDrake2406 on GitHub (Apr 22, 2026).
Original GitHub issue: https://github.com/cloudflare/vinext/issues/869
Follow-up to #868.
Context
#868 introduced a per-navigation
PendingBrowserRouterState(deferred promise + resolver) so programmaticrouter.push/replace/refreshcan keepuseTransition().isPendingtrue for the full RSC-fetch window. The deferred promise is published into the browser'suseStateslot synchronously at navigation start, unwrapped viause()inBrowserRoot, and resolved at commit.One edge case was left as a known limitation: the internal RSC-redirect path in
packages/vinext/src/server/app-browser-entry.ts. When the RSC response's pathname differs from the requested pathname,navigateRscrecursively callswindow.__VINEXT_RSC_NAVIGATE__(destinationPath, redirectDepth + 1, ..., programmaticTransition=false). The recursive call bumpsactiveNavigationId, which marks the outer call as superseded by its own redirect. The outer pending is settled with the current committed state just before the recursion, souseTransition().isPendingflashes false mid-redirect instead of staying true for the full user-visible navigation.Why this is awkward to fix in place
The recursive call is vinext's public navigation entry point doing double duty as the redirect follower. It:
activeNavigationId, which is what supersession checks use to bail out stale navigations — so the outer call cannot keep running past the recursionpendingRouterState = nullin the recursive framependingRouterStatebecauseprogrammaticTransition=falseshort-circuitsbeginPendingBrowserRouterState, and threading it throughwindow.__VINEXT_RSC_NAVIGATE__would pollute a public API surfaceThe outer frame's pending is therefore orphaned unless we settle it before handing control off, which is what #868 does as a bandage.
What Next.js does
Next.js's
packages/next/src/client/components/app-router-instance.tshandles navigations through a queue-based reducer:dispatchActioncreates one deferred promise per action and attaches its resolver to anActionQueueNode.setState(deferredPromise)publishes the promise into React's state slot once, insidestartTransition.handleResultcallsaction.resolve(finalState)exactly once at the end.pending.discarded = true, not recursive self-invocation.Because the entry point is never re-entered for a same-action redirect, there is nothing to orphan. A single deferred promise spans every redirect hop in that navigation.
Proposed direction
Convert
navigateRscso the redirect path becomes an inline loop instead of a recursive__VINEXT_RSC_NAVIGATE__call:(url, redirectDepth).url/rscUrl/ history state in place, bumpredirectDepth, andcontinue.navIdand onependingRouterStatefor the entire loop. Resolve the pending promise only at the final commit.window.__VINEXT_RSC_NAVIGATE__'s public signature unchanged — redirects become an implementation detail ofnavigateRsc, not a recursion through the public API.Related work that needs to move with the redirect loop:
navId !== activeNavigationId) currently fire between awaits per-frame; in the loop version they still fire per-hop, but a single hop's bailout must settle the shared pending exactly onceredirectDepth > 10check becomes a loop counter with the same thresholdreplaceHistoryStateWithoutNotifyin the current redirect block runs before the recursion — in the loop version it runs at the top of each iteration, before the next fetchAdditional cleanup that naturally falls out
PendingBrowserRouterStatesettlement is currently spread across ~7 bailout sites insidenavigateRsc. Each is idempotent via thesettledflag, but a future early return will need to remember to settle. Wrapping the loop body intry { ... } finally { settlePendingBrowserRouterState(pendingRouterState); }makes ownership explicit and collapses the scattered calls to one.Validation plan
tests/e2e/app-router/nextjs-compat/router-push-pending.spec.tsto cover a programmatic push whose RSC response redirects (e.g. a route that returns aredirect()in its server component). AssertisPendingstays true continuously until the final destination commits.References
.nextjs-ref/packages/next/src/client/components/app-router-instance.ts— Next.js's queue + reducer pattern