fix(app-router): model RSC redirect and traversal lifecycle#1248
fix(app-router): model RSC redirect and traversal lifecycle#1248NathanDrake2406 wants to merge 1 commit into
Conversation
commit: |
|
@codex review |
|
Codex Review: Didn't find any major issues. More of your lovely PRs please. ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
|
/bigbonk review |
There was a problem hiding this comment.
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.
resolveRscRedirectLifecycleHopis 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/forwardfrom stale index state is correct. The test atnavigation-planner.test.ts:668explicitly validates this. nextHistoryTraversalIndex/currentHistoryTraversalIndexsplit is sound.nextHistoryTraversalIndextracks the high-water mark so push always allocates forward, whilecurrentHistoryTraversalIndextracks what was last committed (including null for unknown traversal targets). The?? 0initializer 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)) { |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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.
| 0, | ||
| "traverse", | ||
| undefined, | ||
| undefined, |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
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.
|
Review posted. Here's a summary of what I found: Overall assessment: Clean, well-structured PR. The redirect lifecycle extraction into Key feedback points:
|
23a9771 to
da9e33a
Compare
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.
da9e33a to
f99f4a3
Compare
Overview
#726-CORE-18/19: Model RSC redirect lifecycle and back/forward traversal intentunknownrather than guessed.app-browser-entry.ts,app-browser-rsc-redirect.ts,app-browser-state.ts,navigation-planner.tsBonk: 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.back,forward, andunknownare modeled explicitly, and unknown traversal targets keep the local index unknown.What changed
router.push()to an RSC server redirectback,forward, orunknown.Maintainer review path
packages/vinext/src/server/app-browser-rsc-redirect.ts: pure redirect lifecycle decision helper.packages/vinext/src/server/app-browser-entry.ts: inline redirect following, approved-commit history publishing, popstate traversal intent capture.packages/vinext/src/server/app-browser-state.ts: history metadata read/write helpers and traversal intent resolution.packages/vinext/src/server/navigation-planner.tsandnavigation-trace.ts: small planner surface for traversal direction and traces.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.tsPLAYWRIGHT_PROJECT=app-router pnpm run test:e2e -- tests/e2e/app-router/nextjs-compat/router-push-pending.spec.tsvp checkknip.Risk and compatibility
window.__VINEXT_RSC_NAVIGATE__signature.#726-CORE-18/19.References
#726-CORE-18/19.nextjs-ref/packages/next/src/client/components/router-reducer/fetch-server-response.ts.nextjs-ref/packages/next/src/client/components/app-router.tsx