Skip to content

fix(app-router): model RSC redirect and traversal lifecycle#1248

Open
NathanDrake2406 wants to merge 1 commit into
cloudflare:mainfrom
NathanDrake2406:nathan/726-core-18-19-rsc-redirect-traverse
Open

fix(app-router): model RSC redirect and traversal lifecycle#1248
NathanDrake2406 wants to merge 1 commit into
cloudflare:mainfrom
NathanDrake2406:nathan/726-core-18-19-rsc-redirect-traverse

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented May 16, 2026

Overview

Field Details
Task #726-CORE-18/19: Model RSC redirect lifecycle and back/forward traversal intent
Parent issue Refs #726
Core change RSC redirects now stay inside the initiating navigation lifecycle until an approved visible commit publishes URL/history state.
Traversal model App Router RSC commits carry vinext traversal metadata. Metadata-less entries, including hash-only entries in this slice, are modeled as unknown rather than guessed.
Follow-up Hash-only App Router traversal metadata is tracked in #1252.
Primary review files app-browser-entry.ts, app-browser-rsc-redirect.ts, app-browser-state.ts, navigation-planner.ts

Bonk: please read issue #726 before reviewing this PR. The redirect and traversal changes are part of the broader navigation lifecycle architecture, not an isolated redirect patch.

Why

For App Router navigation to be correct, visible browser state must only change after the lifecycle controller approves a commit. The previous RSC redirect path followed server redirects by writing history before the final RSC payload was approved, which could collapse the source entry for router.push() and leave back/forward traversal without a durable intent model.

Area Principle / invariant What this PR changes
RSC redirects One user navigation should keep one pending operation where possible. Redirect hops are followed inline and only publish history on the eventual approved commit.
Terminal redirects Redirect escape paths must be explicit. External redirects and redirect-budget exhaustion return terminal hard-navigation decisions.
Traversal intent Missing history metadata must not be guessed. back, forward, and unknown are modeled explicitly, and unknown traversal targets keep the local index unknown.
Planner The planner should remain small and pure. It only carries the traversal direction through requested work and trace fields.

What changed

Scenario Before After
router.push() to an RSC server redirect The redirect hop could replace history before the final payload committed. The redirected destination is pushed as the final approved commit, preserving Back to the source entry.
Same-path redirect with changed query Redirect detection only compared pathnames. Redirect lifecycle decision compares visible app hrefs, so query-only redirects are followed.
External RSC redirect Mixed into redirect-following control flow. Returns an explicit terminal hard navigation.
Browser back/forward Traversal direction was not represented in planner work. Traversal work and traces carry back, forward, or unknown.
Metadata-less history entry Could be classified against stale local index state. Unknown target index stays unknown and does not synthesize traversal metadata on replace commits.
Maintainer review path
  1. packages/vinext/src/server/app-browser-rsc-redirect.ts: pure redirect lifecycle decision helper.
  2. packages/vinext/src/server/app-browser-entry.ts: inline redirect following, approved-commit history publishing, popstate traversal intent capture.
  3. packages/vinext/src/server/app-browser-state.ts: history metadata read/write helpers and traversal intent resolution.
  4. packages/vinext/src/server/navigation-planner.ts and navigation-trace.ts: small planner surface for traversal direction and traces.
  5. tests/app-browser-entry.test.ts, tests/navigation-planner.test.ts, and the Playwright spec for coverage.
Validation
  • vp test run tests/app-browser-entry.test.ts tests/navigation-planner.test.ts
  • PLAYWRIGHT_PROJECT=app-router pnpm run test:e2e -- tests/e2e/app-router/nextjs-compat/router-push-pending.spec.ts
  • vp check
  • Commit hook also ran formatting, lint/type checks, and knip.
  • Ran local elegance review, then a GPT-5.5 high-effort review subagent. Addressed its findings on metadata-less traversal state and exact pathname assertions.
Risk and compatibility
  • Runtime impact is limited to App Router browser RSC navigation.
  • Public API impact is limited to the internal window.__VINEXT_RSC_NAVIGATE__ signature.
  • Traversal metadata uses vinext-owned history-state keys and preserves existing history state fields.
  • Unknown browser history entries remain unknown instead of being repaired speculatively.
  • Hash-only App Router entries still bypass the RSC commit metadata writer in this slice and remain intentionally metadata-less/unknown. The follow-up is Track App Router hash-only entries in traversal metadata model #1252.
  • This does not model streaming chunk lifecycle. That remains outside #726-CORE-18/19.

References

Reference Why it matters
#726 Parent architecture issue and roadmap context.
#726-CORE-18/19 Specific task slice implemented here.
#1252 Follow-up for hash-only App Router entries in the traversal metadata model.
.nextjs-ref/packages/next/src/client/components/router-reducer/fetch-server-response.ts Next.js preserves navigation type while using the post-redirect canonical URL.
.nextjs-ref/packages/next/src/client/components/app-router.tsx Next.js popstate handling context for traversal intent.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 16, 2026

Open in StackBlitz

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

commit: f99f4a3

@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@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: RSC redirect lifecycle and traversal intent model

Read #726 before reviewing. This is a well-structured slice — the redirect lifecycle extraction is clean, the traversal metadata model is deliberately minimal, and the PR maintains the discipline of not guessing when metadata is absent.

What works well

  • Redirect lifecycle stays inside the initiating navigation. The previous pattern of writing history mid-redirect-chain before the final RSC payload was approved is the correct bug to fix. Deferring URL/history mutation to the approved commit effect is the right architectural choice.
  • resolveRscRedirectLifecycleHop is pure, testable, and correctly factored. Explicit decision types (no-redirect, follow, terminal-hard-navigation) with exhaustive handling in the caller. This matches the planner's decision-model spirit from #726.
  • Unknown traversal direction is kept explicit. Not synthesizing back/forward from stale index state is correct. The test at navigation-planner.test.ts:668 explicitly validates this.
  • nextHistoryTraversalIndex / currentHistoryTraversalIndex split is sound. nextHistoryTraversalIndex tracks the high-water mark so push always allocates forward, while currentHistoryTraversalIndex tracks what was last committed (including null for unknown traversal targets). The ?? 0 initializer correctly bootstraps from a metadata-less initial entry.
  • E2E coverage is good. The back-after-redirect test directly validates the core invariant this PR exists to enforce.

Issues

One concern with the redirect decision and two minor observations below.

options.currentHref,
options.origin,
);
if (redirectedHref === toVisibleAppHref(options.currentHref, options.origin)) {
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.

Redirect detection compares visible hrefs, but resolveHardNavigationTargetFromRscResponse preserves the hash from currentHref (the request URL), not the response.

Looking at resolveHardNavigationTargetFromRscResponse (app-rsc-cache-busting.ts:124-126):

let hardNavigationTarget = pathname + parsed.search;
if (origUrl.hash) hardNavigationTarget += origUrl.hash;

So redirectedHref will carry the original request's hash appended to the response's pathname+search. Then toVisibleAppHref(options.currentHref, ...) also carries the original hash.

This means the hash component cancels out in the comparison and doesn't affect redirect detection — which is probably fine for now since hash-only entries are out of scope (#1252). But it's worth being aware that if a server redirect changes the hash (e.g. response URL has a fragment), resolveHardNavigationTargetFromRscResponse ignores it and keeps the original hash. That's consistent with HTTP semantics (servers can't set fragments), but if the RSC response URL ever carries a hash hint, this comparison would miss it.

Not blocking — just flagging for the #1252 follow-up.

};
}

const redirectedHref = resolveHardNavigationTargetFromRscResponse(
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: resolveHardNavigationTargetFromRscResponse can return currentHref unchanged when responseUrl is null/undefined. That shouldn't happen here since the caller always passes a non-null responseUrl (from navResponseUrl ?? navResponse.url), but the types don't enforce it — options.responseUrl is typed as string, so we're fine, though the function itself accepts string | null | undefined. Just noting the implicit contract.

Comment thread packages/vinext/src/server/app-browser-entry.ts
0,
"traverse",
undefined,
undefined,
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 positional arguments to __VINEXT_RSC_NAVIGATE__ are getting long (7 args, several undefined placeholders). This is fine for now, but the next addition might be the time to switch to an options object for the internal call site.

Non-blocking observation.

const targetHistoryIndex = readHistoryStateTraversalIndex(options.historyState);
let direction: TraverseDirection = "unknown";

if (options.currentHistoryIndex !== null && targetHistoryIndex !== null) {
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.

Good: when both indices are present and equal (e.g., currentHistoryIndex === targetHistoryIndex), this falls through to direction: "unknown". That's the correct choice — same-index traversal (replace on the current entry, or some exotic history API usage) shouldn't be classified as back or forward.

href: "/target?tab=1",
historyUpdateMode: "push",
kind: "follow",
previousNextUrl: "/feed",
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.

Nice: this test validates that same-path-different-search is treated as a redirect. This is the specific scenario the PR description calls out ("Redirect detection only compared pathnames" → now compares visible app hrefs).

);
const redirectDecision = resolveRscRedirectLifecycleHop({
currentHref,
historyUpdateMode: currentHistoryMode ?? "replace",
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 fallback currentHistoryMode ?? "replace" means that if the redirect decision helper receives historyUpdateMode: undefined (which happens after a previous redirect hop set currentHistoryMode = undefined), it gets coerced to "replace". But the follow decision then propagates this "replace" back as redirectDecision.historyUpdateMode, overwriting the loop variable.

On a multi-hop chain: first hop enters with the original mode (e.g. "push"), follow preserves "push". On the second hop, if the first hop's follow already set currentHistoryMode = "push", we're fine. But re-check: the first hop's follow sets currentHistoryMode = redirectDecision.historyUpdateMode which is the input options.historyUpdateMode ("push"). Second hop: currentHistoryMode is still "push", so "push" ?? "replace" = "push". Correct.

The ?? "replace" only fires if historyUpdateMode was previously set to undefined — which only happens in the old code path (now removed). With the new code, historyUpdateMode stays as the original value through all hops. The fallback is defensive. This is fine.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 16, 2026

Review posted. Here's a summary of what I found:

Overall assessment: Clean, well-structured PR. The redirect lifecycle extraction into resolveRscRedirectLifecycleHop is correctly factored as a pure decision helper, and the traversal metadata model is deliberately conservative (unknown stays unknown). The core invariant — visible browser state only changes after the lifecycle controller approves a commit — is properly enforced.

Key feedback points:

  1. Suggestion (minor): Add an inline comment on the null + 1 → 1 coercion in allocateNavigationHistoryTraversalIndex — it's intentional recovery from unknown state but reads like a bug on first glance.

  2. Flag for Track App Router hash-only entries in traversal metadata model #1252: The hash handling in resolveHardNavigationTargetFromRscResponse cancels out in the redirect comparison (original hash is carried to both sides), which is fine now but worth noting for the hash-only traversal follow-up.

  3. Observation: The positional args to __VINEXT_RSC_NAVIGATE__ (7 params, several undefined placeholders) are getting unwieldy — next addition would be a good time to switch to an options object.

  4. Positive notes: Same-index traversal correctly falls through to "unknown", the ?? "replace" fallback in the redirect loop is defensive but correct, and the test coverage (unit + E2E back-after-redirect) directly validates the invariants the PR claims.

github run

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/726-core-18-19-rsc-redirect-traverse branch 2 times, most recently from 23a9771 to da9e33a Compare May 16, 2026 17:00
RSC redirects were followed by mutating browser history before the redirected payload had passed the visible commit lifecycle. That could collapse the initiating history entry and leave back/forward traversal without an explicit intent model.

Keep redirect hops inside the initiating navigation until an approved commit publishes history, add explicit terminal redirect decisions, and record per-entry traversal metadata so unknown browser history state stays unknown instead of being guessed.

Add focused unit coverage for redirect decisions and traversal metadata plus a browser regression for router.push redirect back navigation.
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/726-core-18-19-rsc-redirect-traverse branch from da9e33a to f99f4a3 Compare May 16, 2026 17:15
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.

2 participants