Files
Nathan Nguyen ad1d1971af fix(app-router): keep isPending true across RSC-level redirects (#870)
* test(app-router): add E2E coverage for isPending across RSC redirect

router.push() to a page that calls redirect() in its server component
produced isPending flashing false mid-redirect. This test exposes that
by installing a MutationObserver before click, recording every text
value of #pending-state, and asserting no idle entry appears between
the first pending transition and the destination commit.

Ported from Next.js:
test/e2e/app-dir/navigation/navigation.test.ts#L482

New fixtures:
- /nextjs-compat/router-push-pending-redirect  -- server component that calls redirect()
- /nextjs-compat/router-push-pending-destination -- final destination with #redirect-destination
- Push-redirect button added to existing PendingClient

* fix(app-router): keep isPending true across RSC-level redirects

When router.push() targeted a page whose server component called
redirect(), isPending flashed false mid-redirect. The fetch returned
a 307, navigateRsc detected the response URL differing from the
requested URL, settled the deferred pendingRouterState to prevent
promise orphaning, then recursed with programmaticTransition=false.
The settle fired before the recursive call so React resolved the
transition and isPending dropped to false during the hop.

Root cause: the recursive re-entry model required settling the
deferred promise before handing off, because the recursive call
had no way to inherit the existing promise. This was explicitly
documented as a tradeoff in the bandage comment at the settle site.

Convert the recursive redirect follow into an inline while loop.
The loop variables (currentHref, currentHistoryMode,
currentPrevNextUrl, redirectCount) are updated on each redirect
hop and the loop continues without re-entering navigateRsc. A
single pendingRouterState is created before the loop and lives
until the finally block, so isPending stays true across all
redirect hops and drops exactly once when the final destination
commits.

The scattered settlePendingBrowserRouterState() calls inside the
try body (early-return stale-id guards) are removed. The finally
owns the single settlement site, and the function is already inside
React.startTransition from the programmatic push, so the correct
transition priority is maintained throughout.

Closes #869.
2026-04-22 13:35:10 +01:00
..