Skip to content

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

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

feat(router): promote intercepted preservation through planner#1249
NathanDrake2406 wants to merge 4 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.

@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: b84597e

@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 👍 / 👎.

@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 2 times, most recently from 7ad5832 to 24221dc Compare May 16, 2026 16:41
@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 reviewed this against the #726 roadmap. The architectural direction is correct: promoting interception preservation from implicit wire-shape inference to explicit planner authority with proof validation. The "no proof → no reuse → no visible commit" invariant is consistently enforced across planner, wire codec, state management, and lifecycle boundaries.

The test coverage is strong across all layers — hostile planner tests (missing proof, stale source, inactive slot, incompatible root), URL normalization round-trips, the findIntercept source-required behavioral tightening, and the E2E refresh/direct-target scenarios.

A few observations below, ranging from minor naming nits to one medium-severity concern about the interception entry path.

// hard navigation. This fences legacy cached RSC payloads from before this
// deploy. The cache is busted by the deploy (different build), so no stale
// artifact should persist across deploys.
const hasInterceptedPayload =
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.

Medium: interceptionContext !== null alone is too broad of a trigger for the interception validation path.

The comment explains that legacy cached RSC payloads are expected to be cache-busted by deploy, but the condition targetSnapshot.interceptionContext !== null enters the interception validation path for any payload that has a non-null interceptionContext — including payloads for non-intercepted routes that merely passed through a request with an interception context header.

Consider: a normal refresh on /feed while an intercepted modal was open sends X-Vinext-Interception-Context: /feed. The server renders the /feed route (not an intercepted route), but the response carries interceptionContext: "/feed" in its metadata because the header was present. This payload has no __interception proof (correct — it's not an intercepted render), but it also has interceptionContext !== null. Under this logic, it enters validateInterceptedPreservation, gets interceptedRejectedMissingProof, and triggers a hard navigation.

Is this scenario prevented upstream (e.g., the server only sets interceptionContext on actually-intercepted responses)? If so, a comment here clarifying that invariant would help. If not, this could cause unexpected hard navigations on refresh/action while an intercepted modal is open.

The previous bonk review flagged this as a cache-migration concern, but I think the more immediate risk is the runtime scenario above.

if (proof.targetMatchedUrl !== options.targetSnapshot.matchedUrl) {
return {
kind: "rejected",
reasonCode: NavigationTraceReasonCodes.interceptedRejectedTargetMismatch,
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 previous review noted this already — the interceptedRejectedTargetMismatch reason code was added in navigation-trace.ts (line 11: NC_INTERCEPT_REJECT_TARGET) but this line uses interceptedRejectedTargetMismatch correctly now. Good fix from the earlier feedback.

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 invariant: metadata.interception === null → previousNextUrl = null. Clean and well-placed. The behavioral change (refresh/action payloads for non-intercepted routes clear stale interception history) is correctly tested at line 1102 of the test file where the expectation changed from "/feed" to null.

One subtlety worth noting: this means any code path that creates a PendingNavigationCommit with previousNextUrl set will have it silently cleared if the server response doesn't carry __interception. That's the right behavior, but it's a non-obvious contract — the caller's requested previousNextUrl is advisory, not authoritative. Consider adding a brief doc comment on the previousNextUrl option in createPendingNavigationCommit noting this.

return trieMatch(routeTrie, appRscPathnameParts(url));
},
findIntercept(pathname, sourcePathname = null) {
if (sourcePathname === null) return 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 tightening. Making findIntercept return null when sourcePathname === null is correct — an interception without a proven source route is not an interception. The test at app-rsc-route-matching.test.ts:141 (findIntercept("/photos/42", null)) covers this.

The previous behavior (proceeding with empty source params when source was null) could silently produce intercept matches with incomplete param merges. This is a correctness improvement.

}

if (!wroteHistoryState) {
syncCurrentHistoryStatePreviousNextUrl(previousNextUrl);
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.

Subtle edge case: when neither replace nor push wrote history state (i.e., the URL didn't change — same-URL navigation), the code falls through to syncCurrentHistoryStatePreviousNextUrl followed by stageClientParams. This correctly handles the case where an intercepted navigation resolves to the same URL but with different interception state (e.g., opening a modal from the same page).

The ordering matters: syncCurrentHistoryStatePreviousNextUrl must run before stageClientParams because stageClientParams may trigger React renders that read history state. Good.

// Empty slot binding metadata is intentionally omitted. Missing
// __slotBindings round-trips as [] and means "no route-state proof", so
// default/unmatched slot preservation is not promoted for that payload.
const interceptionEntry = 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.

The previous review noted the asymmetric __interception attachment across the two branches. I agree it's slightly fragile but also understand the reasoning: the interceptionEntry spread is computed once and applied in both branches, which is actually reasonably clean.

One potential improvement: the empty-object spread ...interceptionEntry when input.interception is null/undefined produces ...{}, which is a no-op but allocates an empty object per call. Since createMetadataEntries is called on every render, you could avoid this by conditionally spreading only when non-empty:

Suggested change
const interceptionEntry = input.interception
const interceptionEntry = input.interception
? { [APP_INTERCEPTION_KEY]: input.interception }
: null;

Then ...(interceptionEntry ?? {}) or conditional assignment. Not blocking — this is a micro-optimization.

);
const targetMatchedUrl = normalizeInterceptionProofMatchedUrl(routePath);
const slotId = opts?.interceptSlotId ?? null;
if (sourceMatchedUrl === null || targetMatchedUrl === null || slotId === null) return 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 defensive design: if any of the three required proof inputs (sourceMatchedUrl, targetMatchedUrl, slotId) is null, the proof is simply omitted rather than producing a partial/invalid proof object. This ensures the planner never sees malformed proof — it either gets a complete proof or null.


if (latestApproval.approvedCommit) {
dispatchSynchronousVisibleCommit(latestApproval.approvedCommit);
syncHistoryStatePreviousNextUrl(latestApproval.approvedCommit.previousNextUrl);
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 syncHistoryStatePreviousNextUrl after server-action commit is important for maintaining the invariant that history state and committed router state agree on previousNextUrl. Without it, a server action that revalidates an intercepted page could leave stale previousNextUrl in history while the router state has already been updated.

Question: should the sync also happen on the hard-navigate branch (line 623)? If performHardNavigation is called, the page is about to reload, so syncing history is probably moot. But if performHardNavigation fails or is deferred (e.g., loop guard), the history state could be inconsistent. Low risk, but worth considering.

return Array.isArray(value) && value.length > 0 && value.every(isSlotBindingValue);
}

function isInterceptionMetadataValue(value: unknown): value is AppElementsInterception {
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 previous review suggested adding value-type checks beyond in presence checks. I see this version already checks typeof value.sourceMatchedUrl === 'string' etc., which addresses that feedback. The guard is solid for the transport boundary.

The false-positive risk from user-created objects with all five field names as strings is extremely low given these are internal wire keys (sourceMatchedUrl, sourceRouteId, slotId, targetMatchedUrl, targetRouteId). Acceptable.

interceptLayouts: intercept.interceptLayouts,
interceptSlotId: intercept.slotId,
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.

Note: interceptSourceMatchedUrl: interceptionContext means the source URL for proof comes from the raw X-Vinext-Interception-Context header value. The normalization happens downstream in createAppPageInterceptionProof via normalizeInterceptionProofMatchedUrl. This is the same pattern the Codex reviewer flagged and the test at app-page-element-builder.test.ts covers. Correct as-is.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 16, 2026

Review posted successfully. Here's a summary:

Review Summary

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 for Core-15 of the #726 roadmap.

11 inline comments posted:

  1. navigation-planner.ts:484 (medium) — The interceptionContext !== null trigger for the interception validation path may be too broad. A non-intercepted response that merely carries interceptionContext from the request header (e.g., refreshing /feed while an intercepted modal is open) would enter validation, get interceptedRejectedMissingProof, and trigger a hard navigation. Need to confirm this is prevented upstream or add a narrower condition.

  2. navigation-planner.ts:408 — Confirmed the interceptedRejectedTargetMismatch reason code is now correctly used (addresses prior review feedback).

  3. app-browser-state.ts:435 — The interception === null → previousNextUrl = null invariant is clean, but the advisory nature of the caller's previousNextUrl should be documented.

  4. app-rsc-route-matching.ts:63 — Good tightening: findIntercept now returns null when sourcePathname === null, preventing silent intercept matches with incomplete params.

  5. app-browser-entry.ts:310 — Same-URL navigation fallthrough correctly syncs history state before staging client params. Ordering is right.

  6. app-browser-entry.ts:894 — Server action sync ordering (snapshot capture → history sync) is correct, but a clarifying comment would help.

  7. app-elements-wire.ts:326 — Minor: empty-object spread ...{} on every non-interception call is a micro-allocation. Not blocking.

  8. app-page-element-builder.ts:222 — Good defensive design: incomplete proof inputs produce null rather than partial proof.

  9. app-browser-navigation-controller.ts:629 — History sync after server-action commit is correct. Question about whether the hard-navigate branch should also sync.

  10. shims/slot.tsx:75 — The isInterceptionMetadataValue guard now includes proper string type checks. Acceptable.

  11. app-rsc-entry.ts:737 — Raw header value flowing to proof normalization is correctly handled downstream. Confirmed.

github run

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.
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.
- 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
Context-only AppElements payloads could enter intercepted preservation planning or leave context-suffixed route identity as later proof authority. That made transport metadata influence visible-world preservation when a normal response was rendered from an intercepted request context.

Only explicit interception proof now enters the preservation branch. Planner snapshots also canonicalize context-suffixed route IDs when no proof is present, keeping render keys partitioned while preventing stale context from poisoning future explicit interception proof.

Tests cover the direct planner decision and the two-step browser lifecycle regression.
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/core-15-intercepted-preservation branch from 24221dc to b84597e Compare May 17, 2026 04:29
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