Skip to content

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

Merged
james-elicx merged 2 commits intocloudflare:mainfrom
NathanDrake2406:fix/app-router-redirect-inline-loop
Apr 22, 2026
Merged

fix(app-router): keep isPending true across RSC-level redirects#870
james-elicx merged 2 commits intocloudflare:mainfrom
NathanDrake2406:fix/app-router-redirect-inline-loop

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

What this changes

router.push('/a') where /a's server component calls redirect('/b') no longer causes useTransition().isPending to flash false mid-redirect. The pending indicator stays true continuously from the push through the final destination commit.

Why

PR #868 latched isPending during programmatic navigations via a deferred promise in browser router state. But when the RSC response redirected (307), navigateRsc detected the response URL differing from the requested URL, settled the deferred promise to avoid orphaning it, 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.

The bug was explicitly documented in the bandage comment before the recursive call:

Settling here with the current committed state makes isPending flip false mid-redirect -- a deliberate tradeoff over leaning on React's internal transition-completion heuristic.

Issue #869 asked to remove that tradeoff.

Approach

Convert the recursive redirect follow into an inline while(true) loop inside navigateRsc. Loop variables (currentHref, currentHistoryMode, currentPrevNextUrl, redirectCount) are updated on each redirect hop; 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 hops.

The scattered settlePendingBrowserRouterState(pendingRouterState) calls inside the try body (early-return stale-id guards) are removed. The finally owns the single settlement site and is idempotent via the settled flag on PendingBrowserRouterState.

Scope: this is the minimal fix described in issue #869. The NavigationTask-based refactor in nathan/navigation-architecture.md is explicitly out of scope.

Validation

New E2E test (router-push-pending.spec.ts) installs a MutationObserver before click, records every text value of #pending-state, and asserts no "idle" entry appears between the first "pending" and the destination commit.

Test log on unmodified code: ["idle","pending","idle","__removed__"] -- the mid-redirect flash at index 2 fails the assertion.

Test log after fix: ["idle","pending","__removed__"] -- no idle flash.

  • New redirect E2E test: passes
  • Existing same-route pending E2E test: passes
  • Existing search-params-key E2E tests: pass
  • All 62 nextjs-compat E2E tests: pass
  • vp check: clean

Ported test from Next.js:
https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/navigation/navigation.test.ts#L482

Closes #869.

Risks / follow-ups

  • The redirectDepth parameter on navigateRsc is now vestigial (no caller passes non-zero since recursion is gone). Left as-is to avoid an unnecessary signature churn; can be cleaned up in a separate pass.
  • Next.js history semantics for "push that redirects" (should it land as push or replace in history?) are unchanged; this PR is about isPending correctness only.

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
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 cloudflare#869.
Copilot AI review requested due to automatic review settings April 22, 2026 10:52
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 22, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@870

commit: 01079cb

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes App Router programmatic navigation so useTransition().isPending stays continuously true across RSC-level redirects (e.g. router.push('/a') where /a server component calls redirect('/b')), aligning behavior with the intent from #868 and addressing #869.

Changes:

  • Refactors navigateRsc redirect-following from recursive re-entry into an inline while(true) loop so a single PendingBrowserRouterState spans all redirect hops.
  • Consolidates pending-router-state settlement to a single finally site (idempotent via the settled flag), removing scattered settle calls in the main body.
  • Adds a new Next.js-compat E2E case + fixtures to verify no mid-redirect "idle" flash in the pending indicator.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/fixtures/app-basic/app/nextjs-compat/router-push-pending/pending-client.tsx Adds a new button to trigger a programmatic push to a server-redirecting route under startTransition.
tests/fixtures/app-basic/app/nextjs-compat/router-push-pending-redirect/page.tsx New server component route that immediately calls redirect() to drive the RSC redirect path.
tests/fixtures/app-basic/app/nextjs-compat/router-push-pending-destination/page.tsx New destination page used as the “committed” sentinel for the redirect test.
tests/e2e/app-router/nextjs-compat/router-push-pending.spec.ts Adds an E2E test using a MutationObserver to assert isPending never flashes "idle" mid-redirect.
packages/vinext/src/server/app-browser-entry.ts Core fix: inline redirect loop inside navigateRsc, keeping a single pending promise alive across hops and settling it once in finally.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +57 to +61
* Detection strategy: install a MutationObserver before click, record every
* text value #pending-state takes. After the destination commits, assert
* that no "idle" value appeared before the final "idle" (i.e. no mid-redirect
* flash). Because the destination page does not render #pending-state, we
* track when the element is removed as the end-of-navigation signal.
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment says we assert no "idle" appeared before the final "idle", but the destination page unmounts #pending-state and the actual sentinel is "removed". Update the comment to match the implemented assertion (no "idle" between first "pending" and removal) to avoid confusion when maintaining this test.

Suggested change
* Detection strategy: install a MutationObserver before click, record every
* text value #pending-state takes. After the destination commits, assert
* that no "idle" value appeared before the final "idle" (i.e. no mid-redirect
* flash). Because the destination page does not render #pending-state, we
* track when the element is removed as the end-of-navigation signal.
* Detection strategy: install a MutationObserver before click and record
* every text value #pending-state takes. Because the destination page does
* not render #pending-state, the observer records "__removed__" when the
* element is unmounted and uses that as the end-of-navigation signal. The
* assertion then checks that no "idle" appears between the first "pending"
* value and "__removed__" (i.e. no mid-redirect flash).

Copilot uses AI. Check for mistakes.
Comment on lines +1210 to +1212
// Clear pendingPathname on all exit paths. On the success path this fires
// before the RAF commit effect, but commitClientNavigationState() in the
// commit effect clears it again — that double-clear is idempotent. Skipped
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says this finally-block clear runs "before the RAF commit effect", but navigateRsc awaits renderNavigationPayload's committed promise (resolved via RAF) before reaching finally. Please reword to accurately describe when/why pendingPathname is cleared, so future changes don't rely on incorrect timing assumptions.

Suggested change
// Clear pendingPathname on all exit paths. On the success path this fires
// before the RAF commit effect, but commitClientNavigationState() in the
// commit effect clears it again — that double-clear is idempotent. Skipped
// Clear pendingPathname on all exit paths as a final cleanup step. On
// successful navigations it may already have been cleared as part of the
// navigation commit, so this clear is intentionally idempotent. Skipped

Copilot uses AI. Check for mistakes.
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Clean fix. The recursion-to-loop refactor is the right call — it makes the pending-state lifetime obvious (one pendingRouterState created before the loop, one finally settlement after) and eliminates the structural reason the bug existed (settling before recursing).

The E2E test using MutationObserver to record the full state transition log is solid — it captures the exact sequence and fails deterministically on the old code.

A few items below, mostly minor.

Comment on lines +1210 to +1212
// Clear pendingPathname on all exit paths. On the success path this fires
// before the RAF commit effect, but commitClientNavigationState() in the
// commit effect clears it again — that double-clear is idempotent. Skipped
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot flagged this too and I agree — this comment is inaccurate. On the success path, await renderNavigationPayload(...) awaits the committed promise (resolved by the RAF commit effect), so the finally block runs after the commit effect, not before it.

The comment matters because it's explaining when clearPendingPathname fires relative to commit — getting that wrong could mislead future maintainers into adding order-dependent logic.

Suggested change
// Clear pendingPathname on all exit paths. On the success path this fires
// before the RAF commit effect, but commitClientNavigationState() in the
// commit effect clears it again — that double-clear is idempotent. Skipped
// Clear pendingPathname on all exit paths. On the success path,
// renderNavigationPayload's committed promise already resolved (via the
// RAF commit effect), so this is a no-op double-clear. On early-return
// and error paths it is the primary cleanup. Skipped

let currentHref = href;
let currentHistoryMode = historyUpdateMode;
let currentPrevNextUrl = previousNextUrlOverride;
let redirectCount = redirectDepth;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description notes redirectDepth is now vestigial — no caller passes non-zero since recursion is gone. Consider adding a brief // TODO: remove — vestigial after inline-loop refactor so it doesn't get cargo-culted. Not blocking.

Comment on lines +1017 to +1019
setPendingPathname(url.pathname, navId);

const elementsAtNavStart = getBrowserRouterState().elements;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor observation: on redirect hops (second+ iteration), setPendingPathname is called again with the redirect destination. This is correct — it updates the pending pathname to the destination URL so isSameRoute and usePathname() see the right value. Just want to confirm this is intentional rather than an artifact of the loop running the full preamble on every hop.

Also: elementsAtNavStart and mountedSlotsHeader are recomputed on every hop. On redirect hops the browser tree hasn't changed (no render happened), so these will be identical to the first iteration's values. Not a correctness issue, but worth noting if this path gets hot.

"",
destinationPath,
);
if (finalUrl.pathname !== requestedUrl.pathname) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old code had a guard: if (!window.__VINEXT_RSC_NAVIGATE__) { ... window.location.href = destinationPath; return; } before recursing. Since we're now inside a loop within navigateRsc (which is __VINEXT_RSC_NAVIGATE__), that guard was unreachable dead code and removing it is correct.

Comment on lines +57 to +61
* Detection strategy: install a MutationObserver before click, record every
* text value #pending-state takes. After the destination commits, assert
* that no "idle" value appeared before the final "idle" (i.e. no mid-redirect
* flash). Because the destination page does not render #pending-state, we
* track when the element is removed as the end-of-navigation signal.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says:

assert that no "idle" value appeared before the final "idle"

but the destination page doesn't render #pending-state, so there is no "final idle" — the signal is __removed__. The assertion logic at lines 124-134 correctly checks for no "idle" between first "pending" and __removed__, but this comment describes a different contract. Copilot's suggestion to reword is accurate.

Suggested change
* Detection strategy: install a MutationObserver before click, record every
* text value #pending-state takes. After the destination commits, assert
* that no "idle" value appeared before the final "idle" (i.e. no mid-redirect
* flash). Because the destination page does not render #pending-state, we
* track when the element is removed as the end-of-navigation signal.
* Detection strategy: install a MutationObserver before click, record every
* text value #pending-state takes. Because the destination page does not
* render #pending-state, the observer records "__removed__" when the element
* is unmounted and uses that as the end-of-navigation signal. The assertion
* then checks that no "idle" appears between the first "pending" and
* "__removed__" (i.e. no mid-redirect flash).

obs.disconnect();
}
});
obs.observe(document.body, { childList: true, subtree: true, characterData: true });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The observer watches { childList: true, subtree: true, characterData: true } but does not set characterData: true on the #pending-state element's text node specifically — it watches document.body. This means if any text node in the document changes (e.g., a loading spinner animates, or dev-mode HMR injects content), the callback fires and re-queries #pending-state. This is fine for correctness (the callback always re-reads the actual text), but it could produce duplicate log entries. The test handles duplicates gracefully since it only checks for the presence of "idle" in the window, so this is not a problem — just noting.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 22, 2026

Review submitted. Here's a summary of the feedback:

Approved — this is a clean, well-scoped fix. The recursion-to-loop refactor directly addresses the root cause (settling the pending promise before recursing) and the single finally settlement site is a clear improvement.

6 comments posted:

  1. Inaccurate timing comment (lines 1210-1212, app-browser-entry.ts): The comment claims clearPendingPathname fires "before the RAF commit effect" on the success path, but renderNavigationPayload awaits the committed promise which is resolved by the RAF commit effect. Suggested a reword.

  2. Vestigial redirectDepth parameter (line 982): Since recursion is gone, no caller passes non-zero. Suggested adding a // TODO: remove comment to prevent cargo-culting.

  3. Loop preamble recomputation on redirect hops (lines 1017-1019): setPendingPathname, elementsAtNavStart, and mountedSlotsHeader are recomputed on every hop. Confirmed setPendingPathname is intentionally updating to the redirect destination. Noted elementsAtNavStart/mountedSlotsHeader will be identical across hops since no render happened — not a correctness issue.

  4. Removed __VINEXT_RSC_NAVIGATE__ guard (line 1106): Confirmed the old guard was dead code (we're already inside the function that is __VINEXT_RSC_NAVIGATE__), so removing it is correct.

  5. Misleading test comment (lines 57-61, test file): Comment says "no idle before the final idle" but the destination page doesn't render #pending-state — the signal is __removed__. Suggested a reword to match the actual assertion logic.

  6. MutationObserver scope (line 95, test file): Observer watches document.body so any DOM mutation triggers re-query, which could produce duplicate log entries. Not a correctness issue since the assertion only checks for presence of "idle" in the window.

github run

@james-elicx james-elicx merged commit ad1d197 into cloudflare:main Apr 22, 2026
28 of 29 checks passed
@NathanDrake2406 NathanDrake2406 deleted the fix/app-router-redirect-inline-loop branch April 22, 2026 13:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

App Router: convert internal RSC-redirect recursion into an inline loop so a single pending transition can span all hops

3 participants