Skip to content

feat(router): promote intercepted preservation through planner#1249

Open
NathanDrake2406 wants to merge 3 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/core-15-intercepted-preservation
Open

feat(router): promote intercepted preservation through planner#1249
NathanDrake2406 wants to merge 3 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/core-15-intercepted-preservation

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Overview

Item Details
Goal Implement #726-CORE-15 from #726 by promoting intercepted route preservation into the planner and lifecycle path.
Core change Interception matching and wire metadata now carry proof, but the planner decides whether source UI and unrelated slots may remain visible.
Main boundary The AppElements payload is transport, not authority. No proof means no reuse, no skip, and no visible commit.
Primary files navigation-planner.ts, app-browser-state.ts, app-browser-entry.ts, app-elements-wire.ts, app page dispatch/build wiring, and the intercepted route tests.
Expected impact Soft intercepted navigation keeps proven source UI and unrelated slots, while direct load and refresh resolve to the normal target page unless the visible world proves interception is current.

Bonk reviewer note: please read #726 before reviewing this PR so the Core-15 slice is evaluated in the architectural big picture.

Why

This implements #726-CORE-15 by promoting intercepted route preservation through the planner/lifecycle path. The invariant is that route state and lifecycle authority decide what previous UI may remain visible. Context suffixes, wire-key suffixes, mounted-slot headers, cache partitioning, and missing payload entries are evidence only.

Area Principle / invariant What this PR changes
Route matcher Finds a possible intercepted render target Requires a matching source route and carries the target slot id as evidence.
Wire payload Transports render data and metadata Adds explicit interception proof metadata without making it commit authority.
Planner Owns visible-world preservation decisions Approves current-root intercepted preservation only when source, target, root, and slot proof line up.
Lifecycle Rejects stale or wrong-context results Clears stale same-URL interception state when normal target payloads commit and syncs current-context action/refresh state.
Visible commit Applies the approved world change Preserves proven source route and unrelated slots while replacing the intercepted slot.

What Changed

Scenario Before After
Soft intercepted navigation Preservation could fall out of context-bearing route ids, missing entries, or slot overrides. The planner approves preservation from explicit source and slot proof.
Missing or stale proof Legacy interception context could still influence preservation. The planner rejects preservation and falls back to hard navigation.
Direct load and refresh Stale history context could be treated as current interception context. Direct target payloads clear previous-next state and render the normal target page.
Current-context refresh/action Same-URL lifecycle paths could rely on stale context strings. The browser lifecycle syncs history state with the committed visible world before refresh/action work.
Traverse Back/forward could be tempted to infer semantics from URL plus context. Traverse restores intercepted state only when the payload carries valid proof for the visible source world.
Maintainer review path
  1. packages/vinext/src/server/navigation-planner.ts for the new Core-15 authority boundary and rejection reasons.
  2. packages/vinext/src/server/app-elements-wire.ts for the proof metadata boundary and malformed proof fencing.
  3. packages/vinext/src/server/app-page-element-builder.ts, app-page-dispatch.ts, and app-rsc-route-matching.ts for how server route facts become proof evidence.
  4. packages/vinext/src/server/app-browser-state.ts, app-browser-visible-commit.ts, app-browser-entry.ts, and app-browser-navigation-controller.ts for lifecycle and same-URL current-context handling.
  5. tests/navigation-planner.test.ts, tests/app-browser-entry.test.ts, tests/app-elements.test.ts, and tests/e2e/app-router/advanced.spec.ts for the hostile cases.
Validation

Ran:

vp run vinext#build
vp check
vp test run tests/navigation-planner.test.ts tests/app-rsc-route-matching.test.ts tests/app-elements.test.ts tests/app-browser-entry.test.ts tests/app-page-element-builder.test.ts tests/app-page-dispatch.test.ts tests/app-server-action-execution.test.ts
vp test run tests/app-router.test.ts -t "intercept|extracts actual URL params|middleware response headers"
CI=1 PLAYWRIGHT_PROJECT=app-router pnpm run test:e2e -- tests/e2e/app-router/advanced.spec.ts -g "refresh on direct target clears stale intercepted history context"
vp check --fix
vp run knip --no-progress

The Playwright command selected the app-router advanced project and completed with 29 passed, 1 skipped.

Risk / compatibility
Surface Notes
Public API No public API change. This is router runtime behavior and payload metadata.
AppElements wire Adds optional __interception metadata and validates it strictly when present. Legacy context-only payloads no longer authorize preservation.
Browser lifecycle Same-URL direct target commits now clear stale previous-next history state. Intercepted commits retain it only with proof.
Existing apps Happy-path intercepted routes preserve today’s modal/source behavior, but accidental preservation without proof is intentionally fenced.
Cache and prefetch Prefetch remains cache seed/no-commit only. Cache keys and mounted-slot context do not decide preservation.
Non-goals
  • Discarded server-action revalidation.
  • RSC redirect lifecycle.
  • Cache reuse and skip transport.
  • Streaming chunk lifecycle.
  • Activity or hidden-route preservation.
  • A full transition automaton or giant new router model.

References

Reference Why it matters
#726 Original architecture issue and roadmap context.
#726-CORE-15 Specific semantic slice implemented here.
Next.js intercepted route E2E references in tests Anchors user-visible behavior around intercepted navigation, refresh, and source preservation.

Intercepted navigation could still preserve source UI through context-bearing payload shape and same-URL history state. That made modal preservation depend on transport metadata instead of the visible route world that had actually been committed.

The planner now requires explicit interception proof before preserving source layouts or unrelated slots, and the browser lifecycle clears stale previous-next history state when normal target payloads commit. Wire metadata carries proof, while missing, stale, or incompatible proof falls back to hard navigation.

Tests cover approved source-slot preservation, missing, stale, and malformed proof rejection, current-context refresh and action handling, traverse restoration, and direct-target refresh clearing stale interception state.
@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@1249

commit: 24221dc

@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1eac9588db

ℹ️ 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".


return {
sourceMatchedUrl,
sourceRouteId: AppElementsWire.encodeRouteId(sourceMatchedUrl, null),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Normalize interception proof route IDs before encoding

createAppPageInterceptionProof builds sourceRouteId/targetRouteId directly from interceptSourceMatchedUrl and routePath, but those two inputs are not canonicalized to the same representation used by the committed route snapshot IDs. In particular, browser-derived paths can remain percent-encoded while request normalization decodes segments, so intercepted navigations on encoded/non-ASCII URLs (for example /caf%C3%A9) can produce proof IDs that fail validateInterceptedPreservation’s source/target ID checks and get downgraded to a hard navigation instead of preserving the intercepted UI.

Useful? React with 👍 / 👎.

Interception proof could be built from browser-derived percent-encoded paths while committed route snapshots used the server-normalized route-state form. Non-ASCII intercepted source or target paths could then fail proof validation and fall back to a hard navigation.

Normalize proof matched URLs before encoding source and target route IDs, and normalize client planner snapshot matched URLs to the same route-state representation without changing the user-facing navigation snapshot.

Adds regressions for encoded non-ASCII proof generation and planner validation.
@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: Core-15 intercepted preservation through planner

I read #726 and reviewed this PR in the context of that roadmap. The core architectural move — promoting interception preservation decisions from implicit wire-shape inference to explicit planner authority with proof validation — is sound and well-executed. The invariant that "no proof means no reuse, no skip, no visible commit" is clearly enforced across the planner, wire codec, and lifecycle boundaries.

The test coverage is thorough: hostile planner tests for missing proof, stale source, inactive slot; E2E tests for chained intercept refresh and stale history clearing; and the URL-normalization round-trip test addressing the Codex reviewer's encoding concern. The findIntercept behavioral tightening (requiring source match) is a correctness improvement.

A few observations below, mostly minor, one medium-priority.

if (proof.targetMatchedUrl !== options.targetSnapshot.matchedUrl) {
return {
kind: "rejected",
reasonCode: NavigationTraceReasonCodes.interceptedRejectedUnknownSource,
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.

Nit: when proof.targetMatchedUrl !== targetSnapshot.matchedUrl, the rejection reason is interceptedRejectedUnknownSource. This is slightly misleading — the target didn't match, not the source. Consider a dedicated interceptedRejectedTargetMismatch reason code, or at minimum a comment explaining why the source-rejection code is reused here.

Not blocking, but it would make planner traces easier to debug when the target URL diverges (e.g., from an encoding mismatch).

Comment on lines +239 to +248
function syncCurrentHistoryStatePreviousNextUrl(previousNextUrl: string | null): void {
if (readHistoryStatePreviousNextUrl(window.history.state) === previousNextUrl) {
return;
}

const nextHistoryState = createHistoryStateWithPreviousNextUrl(
window.history.state,
previousNextUrl,
);
replaceHistoryStateWithoutNotify(nextHistoryState, "", window.location.href);
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 double-attempt pattern here (try replaceHistoryStateWithoutNotify, re-check, then fall back to raw window.history.replaceState) is defensive but undocumented. Under what circumstances does replaceHistoryStateWithoutNotify succeed but readHistoryStatePreviousNextUrl still return the wrong value? A brief comment explaining when the fallback fires would help future readers understand whether this is guarding against a browser quirk, a race with popstate listeners, or something else.

Comment on lines +466 to +475
case "navigate": {
const currentPreviousNextUrl = getBrowserRouterState().previousNextUrl;
if (currentPreviousNextUrl !== null) {
return {
interceptionContext: resolveInterceptionContextFromPreviousNextUrl(
currentPreviousNextUrl,
__basePath,
),
previousNextUrl: currentPreviousNextUrl,
};
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.

In the "navigate" case, when currentPreviousNextUrl !== null, the code takes the new early-return path using resolveInterceptionContextFromPreviousNextUrl. When currentPreviousNextUrl === null, it falls through to the legacy getCurrentInterceptionContext() / getCurrentNextUrl() path.

Is the legacy fallback still reachable in any production scenario after this PR? If the only way to reach previousNextUrl !== null is through a committed intercepted navigation (which now requires proof), and all non-intercepted commits clear previousNextUrl to null, then the fallback always fires for non-intercepted navigations. If that's the case, a comment clarifying the two branches (proven interception state vs. legacy DOM-derived context) would make the intent clearer.


const targetSnapshot = options.event.result.targetSnapshot;
const hasInterceptedPayload =
targetSnapshot.interception !== null || targetSnapshot.interceptionContext !== 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.

The hasInterceptedPayload check is targetSnapshot.interception !== null || targetSnapshot.interceptionContext !== null. This means a payload with only legacy interceptionContext (no proof interception) still enters the interception validation path, where it immediately gets rejected with interceptedRejectedMissingProof → hard navigation.

This is intentional (fencing legacy context-only payloads), but it's a behavioral change for any existing payloads that carried interceptionContext without the new __interception metadata — for example, cached RSC responses from before this deploy. Is there a cache invalidation story for this? If a stale cached RSC response has interceptionContext but no __interception, it will trigger a hard navigation instead of falling through to the normal root-boundary path.

If artifact compatibility or cache busting already covers this, a comment here explaining the migration safety would help.

interceptParams: intercept.matchedParams,
interceptSlotId: intercept.slotId ?? null,
interceptSlotKey: intercept.slotKey,
interceptSourceMatchedUrl: interceptionContext,
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.

Both here and in the generated entry (app-rsc-entry.ts:737), interceptSourceMatchedUrl is set to interceptionContext, which is the raw header value from X-Vinext-Interception-Context. The normalization happens downstream in createAppPageInterceptionProof via normalizeInterceptionProofMatchedUrl.

This is fine because the normalization is defensive, but it's worth noting that interceptionContext is the browser-reported source pathname (possibly percent-encoded), while the proof's sourceMatchedUrl is the normalized form. The Codex reviewer flagged exactly this concern, and the test at app-page-element-builder.test.ts:"normalizes encoded interception proof paths" covers it. Just confirming this is addressed.

options.previousNextUrl !== undefined
? options.previousNextUrl
: options.currentState.previousNextUrl;
const previousNextUrl = metadata.interception === null ? null : requestedPreviousNextUrl;
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.

This is the key line that implements the clearing invariant: when metadata.interception === null (non-intercepted payload), previousNextUrl is forced to null regardless of the requested value. This ensures direct-load and refresh-to-normal-target commits clear stale interception history context.

Clean and correct. The behavioral change is well-tested (the existing test expectation changed from "/feed" to null at line 1102 of the test file).

Comment thread packages/vinext/src/shims/slot.tsx Outdated
Comment on lines +75 to +82
function isInterceptionMetadataValue(value: unknown): value is AppElementsInterception {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
return (
"sourceMatchedUrl" in value &&
"sourceRouteId" in value &&
"slotId" in value &&
"targetMatchedUrl" in value &&
"targetRouteId" in value
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 isInterceptionMetadataValue type guard uses duck-typing (checking for presence of 5 known fields). This is fine for the transport boundary, but it could false-positive on a user-created React element props object that happens to have these field names. The risk is low since these are internal wire keys, but you might consider adding a discriminant field (e.g., __kind: "interception") or at minimum checking that the values are strings, not just present. The wire-side parser (parseInterceptionMetadata) already does strict type checking, so this is a defense-in-depth suggestion for the slot rendering path.

const slotBindings = normalizeAppElementsSlotBindings(input.slotBindings, { layoutIds });
return {
...entries,
...(input.interception ? { [APP_INTERCEPTION_KEY]: input.interception } : {}),
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: the __interception metadata is only included when slotBindings are present (inside the if block) or when there are no slot bindings (the else branch). Both branches handle it, but the pattern is slightly asymmetric — the slot-bindings branch spreads interception conditionally, while the no-slot-bindings branch returns a ternary.

This works but is a bit fragile under future edits. Consider extracting the interception attachment into a shared step after the slot-bindings branch, so there's one place to reason about when __interception appears in the metadata entries.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 16, 2026

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

Overall: The PR is architecturally sound. Promoting interception preservation from implicit wire-shape inference to explicit planner authority with proof validation is the right move. Test coverage is thorough.

8 inline comments posted:

  1. navigation-planner.ts:408 (minor) — The interceptedRejectedUnknownSource reason code is reused for a target-mismatch check, which could confuse trace debugging.

  2. app-browser-entry.ts:239-248 (minor) — The double-attempt replaceHistoryState pattern lacks a comment explaining when the fallback fires.

  3. app-browser-entry.ts:466-475 (minor) — The navigate case's two branches (proven interception state vs legacy DOM context) would benefit from a clarifying comment.

  4. navigation-planner.ts:480 (medium) — The hasInterceptedPayload check causes legacy cached RSC responses with interceptionContext but no __interception to trigger hard navigation instead of normal routing. Needs a cache invalidation story or at minimum a comment on migration safety.

  5. app-page-dispatch.ts:318 (confirming) — Acknowledged that the Codex reviewer's encoding concern is addressed by the normalization in createAppPageInterceptionProof and tested.

  6. app-browser-state.ts:435 (positive) — The previousNextUrl clearing invariant is clean and well-tested.

  7. shims/slot.tsx:75-82 (minor) — The duck-typing guard could false-positive; consider value-type checks for defense-in-depth.

  8. app-elements-wire.ts:330 (minor) — The asymmetric interception attachment across the two branches is slightly fragile; consider extracting to a shared step.

github run

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/core-15-intercepted-preservation branch from ad91766 to 7ad5832 Compare May 16, 2026 16:37
- Add interceptedRejectedTargetMismatch reason code for target URL
  mismatch in planner traces (was misusing UnknownSource)
- Document double-attempt replaceState fallback for browser quirks
- Clarify navigate branches: proven interception vs legacy fallback
- Document cache migration safety for legacy interceptionContext
- Tighten isInterceptionMetadataValue type guard with string checks
- Extract interception attachment into shared step in wire metadata
  entries to remove asymmetric branching
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/core-15-intercepted-preservation branch from 7ad5832 to 24221dc Compare May 16, 2026 16:41
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