-
Notifications
You must be signed in to change notification settings - Fork 326
feat(router): promote intercepted preservation through planner #1249
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -147,7 +147,9 @@ const MAX_VISITED_RESPONSE_CACHE_SIZE = 50; | |
| const VISITED_RESPONSE_CACHE_TTL = 5 * 60_000; | ||
| const MAX_TRAVERSAL_CACHE_TTL = 30 * 60_000; | ||
| const CLIENT_RSC_COMPATIBILITY_ID = getVinextRscCompatibilityId(); | ||
| const browserNavigationController = createAppBrowserNavigationController(); | ||
| const browserNavigationController = createAppBrowserNavigationController({ | ||
| syncHistoryStatePreviousNextUrl: syncCurrentHistoryStatePreviousNextUrl, | ||
| }); | ||
| const discardedServerActionRefreshScheduler = createDiscardedServerActionRefreshScheduler({ | ||
| runRefresh() { | ||
| clearClientNavigationCaches(); | ||
|
|
@@ -234,6 +236,28 @@ function clearClientNavigationCaches(): void { | |
| clearPrefetchState(); | ||
| } | ||
|
|
||
| function syncCurrentHistoryStatePreviousNextUrl(previousNextUrl: string | null): void { | ||
| if (readHistoryStatePreviousNextUrl(window.history.state) === previousNextUrl) { | ||
| return; | ||
| } | ||
|
|
||
| const nextHistoryState = createHistoryStateWithPreviousNextUrl( | ||
| window.history.state, | ||
| previousNextUrl, | ||
| ); | ||
| // First attempt: use replaceHistoryStateWithoutNotify which fires no popstate | ||
| // or hashchange events. If the browser accepted the state update (checked via | ||
| // readHistoryStatePreviousNextUrl), we're done. The double-read is needed | ||
| // because some browsers (notably Safari) can silently coalesce or ignore | ||
| // replaceState calls when called in rapid succession (e.g. back-to-back | ||
| // navigation commits). The fallback fires only when the state didn't stick. | ||
| replaceHistoryStateWithoutNotify(nextHistoryState, "", window.location.href); | ||
|
Comment on lines
+239
to
+254
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The double-attempt pattern here (try |
||
| if (readHistoryStatePreviousNextUrl(window.history.state) === previousNextUrl) { | ||
| return; | ||
| } | ||
| window.history.replaceState(nextHistoryState, "", window.location.href); | ||
| } | ||
|
|
||
| function createActionInitiationSnapshot() { | ||
| const routerState = getBrowserRouterState(); | ||
| return createServerActionInitiationSnapshot({ | ||
|
|
@@ -265,17 +289,26 @@ function createNavigationCommitEffect(options: { | |
| } | ||
|
|
||
| const targetHref = new URL(href, window.location.origin).href; | ||
| stageClientParams(params); | ||
| const preserveExistingState = historyUpdateMode === "replace"; | ||
| const historyState = createHistoryStateWithPreviousNextUrl( | ||
| preserveExistingState ? window.history.state : null, | ||
| previousNextUrl, | ||
| ); | ||
|
|
||
| let wroteHistoryState = false; | ||
| if (historyUpdateMode === "replace" && window.location.href !== targetHref) { | ||
| stageClientParams(params); | ||
| replaceHistoryStateWithoutNotify(historyState, "", href); | ||
| wroteHistoryState = true; | ||
| } else if (historyUpdateMode === "push" && window.location.href !== targetHref) { | ||
| stageClientParams(params); | ||
| pushHistoryStateWithoutNotify(historyState, "", href); | ||
| wroteHistoryState = true; | ||
| } | ||
|
|
||
| if (!wroteHistoryState) { | ||
| syncCurrentHistoryStatePreviousNextUrl(previousNextUrl); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Subtle edge case: when neither The ordering matters: |
||
| stageClientParams(params); | ||
| } | ||
|
|
||
| // URL has been updated; the recovery hard-nav target is no longer needed. | ||
|
|
@@ -435,12 +468,30 @@ function getRequestState( | |
| }; | ||
| } | ||
|
|
||
| // Two branches for "navigate": | ||
| // 1. previousNextUrl !== null → a committed intercepted navigation set this | ||
| // in browser state (requires proof). This is the proven interception path. | ||
| // 2. previousNextUrl === null → fall through to legacy DOM-derived context. | ||
| // This fires for non-intercepted navigations (direct loads, normal client | ||
| // navs) where no proven interception state exists. The legacy path returns | ||
| // whatever the current DOM/history context reflects. | ||
| switch (navigationKind) { | ||
| case "navigate": | ||
| case "navigate": { | ||
| const currentPreviousNextUrl = getBrowserRouterState().previousNextUrl; | ||
| if (currentPreviousNextUrl !== null) { | ||
| return { | ||
| interceptionContext: resolveInterceptionContextFromPreviousNextUrl( | ||
| currentPreviousNextUrl, | ||
| __basePath, | ||
| ), | ||
| previousNextUrl: currentPreviousNextUrl, | ||
| }; | ||
|
Comment on lines
+479
to
+488
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In the Is the legacy fallback still reachable in any production scenario after this PR? If the only way to reach |
||
| } | ||
| return { | ||
| interceptionContext: getCurrentInterceptionContext(), | ||
| previousNextUrl: getCurrentNextUrl(), | ||
| }; | ||
| } | ||
| case "traverse": { | ||
| const previousNextUrl = readHistoryStatePreviousNextUrl(window.history.state); | ||
| return { | ||
|
|
@@ -500,6 +551,7 @@ function BrowserRoot({ | |
| const [treeStateValue, setTreeStateValue] = useState<AppRouterState | Promise<AppRouterState>>({ | ||
| activeOperation: null, | ||
| elements: resolvedElements, | ||
| interception: initialMetadata.interception, | ||
| interceptionContext: initialMetadata.interceptionContext, | ||
| layoutIds: initialMetadata.layoutIds, | ||
| layoutFlags: initialMetadata.layoutFlags, | ||
|
|
@@ -842,6 +894,7 @@ function registerServerActionCallback(): void { | |
| // which sends `Next-URL` on action POSTs when the current tree contains | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This But there's a timing question: |
||
| // an interception route. | ||
| const actionInitiation = createActionInitiationSnapshot(); | ||
| syncCurrentHistoryStatePreviousNextUrl(actionInitiation.routerState.previousNextUrl); | ||
| const body = await encodeReply(args, { temporaryReferences }); | ||
| const { headers } = resolveServerActionRequestState({ | ||
| actionId: id, | ||
|
|
@@ -1061,6 +1114,9 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): void { | |
| const requestState = getRequestState(navigationKind, currentPrevNextUrl); | ||
| const requestInterceptionContext = requestState.interceptionContext; | ||
| const requestPreviousNextUrl = requestState.previousNextUrl; | ||
| if (navigationKind === "refresh") { | ||
| syncCurrentHistoryStatePreviousNextUrl(requestPreviousNextUrl); | ||
| } | ||
|
|
||
| // Set this navigation as the pending pathname, overwriting any previous. | ||
| // Pass navId so only this navigation (or a newer one) can clear it later. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -55,6 +55,7 @@ type SameUrlServerActionLifecycleOptions = { | |
| type BrowserNavigationControllerDeps = { | ||
| commitClientNavigationState?: typeof commitClientNavigationState; | ||
| performHardNavigation?: (href: string, mode?: HardNavigationMode) => boolean; | ||
| syncHistoryStatePreviousNextUrl?: (previousNextUrl: string | null) => void; | ||
| }; | ||
|
|
||
| type BrowserNavigationController = { | ||
|
|
@@ -190,6 +191,7 @@ export function createAppBrowserNavigationController( | |
| const commitClientNavigationStateImpl = | ||
| deps.commitClientNavigationState ?? commitClientNavigationState; | ||
| const performHardNavigation = deps.performHardNavigation ?? performHardNavigationWithLoopGuard; | ||
| const syncHistoryStatePreviousNextUrl = deps.syncHistoryStatePreviousNextUrl ?? (() => {}); | ||
|
|
||
| // These are plain module-level variables (inside the controller closure), | ||
| // unlike ClientNavigationState which uses Symbol.for to survive multiple | ||
|
|
@@ -624,6 +626,7 @@ export function createAppBrowserNavigationController( | |
|
|
||
| if (latestApproval.approvedCommit) { | ||
| dispatchSynchronousVisibleCommit(latestApproval.approvedCommit); | ||
| syncHistoryStatePreviousNextUrl(latestApproval.approvedCommit.previousNextUrl); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This Question: should the sync also happen on the |
||
| } else { | ||
| notifyDiscardedServerActionRevalidation(lifecycleOptions); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ import { | |
| getMountedSlotIds, | ||
| getMountedSlotIdsHeader, | ||
| type AppElements, | ||
| type AppElementsInterception, | ||
| type AppElementsSlotBinding, | ||
| type LayoutFlags, | ||
| } from "./app-elements.js"; | ||
|
|
@@ -29,6 +30,8 @@ import { | |
| type RouteSnapshotV0, | ||
| } from "./navigation-planner.js"; | ||
| import type { ClientNavigationRenderSnapshot } from "vinext/shims/navigation"; | ||
| import { normalizePathnameForRouteMatch } from "../routing/utils.js"; | ||
| import { normalizePath } from "./normalize-path.js"; | ||
|
|
||
| const VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY = "__vinext_previousNextUrl"; | ||
|
|
||
|
|
@@ -58,6 +61,7 @@ export type OperationRecord = PendingOperationRecord | CommittedOperationRecord; | |
| export type AppRouterState = { | ||
| activeOperation: OperationRecord | null; | ||
| elements: AppElements; | ||
| interception: AppElementsInterception | null; | ||
| interceptionContext: string | null; | ||
| layoutFlags: LayoutFlags; | ||
| layoutIds: readonly string[]; | ||
|
|
@@ -72,6 +76,7 @@ export type AppRouterState = { | |
|
|
||
| export type AppRouterAction = { | ||
| elements: AppElements; | ||
| interception: AppElementsInterception | null; | ||
| interceptionContext: string | null; | ||
| layoutFlags: LayoutFlags; | ||
| layoutIds: readonly string[]; | ||
|
|
@@ -87,6 +92,7 @@ export type AppRouterAction = { | |
|
|
||
| export type PendingNavigationCommit = { | ||
| action: AppRouterAction; | ||
| interception: AppElementsInterception | null; | ||
| interceptionContext: string | null; | ||
| previousNextUrl: string | null; | ||
| rootLayoutTreePath: string | null; | ||
|
|
@@ -155,6 +161,10 @@ function createOperationRecord(options: { | |
| }; | ||
| } | ||
|
|
||
| function normalizeNavigationSnapshotMatchedUrl(pathname: string): string { | ||
| return normalizePath(normalizePathnameForRouteMatch(pathname)); | ||
| } | ||
|
|
||
| export function resolveInterceptionContextFromPreviousNextUrl( | ||
| previousNextUrl: string | null, | ||
| basePath: string = "", | ||
|
|
@@ -284,13 +294,17 @@ function createMountedParallelSlotSnapshots( | |
|
|
||
| function createVisibleRouteSnapshot(state: AppRouterState): RouteSnapshotV0 { | ||
| const displayUrl = createNavigationSnapshotUrl(state.navigationSnapshot); | ||
| const matchedUrl = normalizeNavigationSnapshotMatchedUrl(state.navigationSnapshot.pathname); | ||
| return { | ||
| displayUrl, | ||
| interception: state.interception, | ||
| interceptionContext: state.interceptionContext, | ||
| layoutIds: state.layoutIds, | ||
| // `displayUrl` preserves the browser-visible query string for decisions and | ||
| // traces. `matchedUrl` stays path-only because route matching has already | ||
| // consumed query params before AppElements metadata reaches this boundary. | ||
| matchedUrl: state.navigationSnapshot.pathname, | ||
| // `displayUrl` preserves the browser-visible URL for decisions and traces. | ||
| // `matchedUrl` uses the route-state canonical pathname, matching the | ||
| // server's segment-decoded representation without changing user-facing | ||
| // navigation state such as usePathname(). | ||
| matchedUrl, | ||
| mountedParallelSlots: createMountedParallelSlotSnapshots(state.elements), | ||
| rootBoundaryId: state.rootLayoutTreePath, | ||
| routeId: state.routeId, | ||
|
|
@@ -300,12 +314,17 @@ function createVisibleRouteSnapshot(state: AppRouterState): RouteSnapshotV0 { | |
|
|
||
| function createPendingRouteSnapshot(pending: PendingNavigationCommit): RouteSnapshotV0 { | ||
| const displayUrl = createNavigationSnapshotUrl(pending.action.navigationSnapshot); | ||
| const matchedUrl = normalizeNavigationSnapshotMatchedUrl( | ||
| pending.action.navigationSnapshot.pathname, | ||
| ); | ||
| return { | ||
| displayUrl, | ||
| interception: pending.action.interception, | ||
| interceptionContext: pending.action.interceptionContext, | ||
| layoutIds: pending.action.layoutIds, | ||
| // See createVisibleRouteSnapshot: matchedUrl intentionally models the route | ||
| // identity, not the address bar URL. | ||
| matchedUrl: pending.action.navigationSnapshot.pathname, | ||
| matchedUrl, | ||
| mountedParallelSlots: createMountedParallelSlotSnapshots(pending.action.elements), | ||
| rootBoundaryId: pending.rootLayoutTreePath, | ||
| routeId: pending.routeId, | ||
|
|
@@ -409,14 +428,16 @@ export async function createPendingNavigationCommit(options: { | |
| }): Promise<PendingNavigationCommit> { | ||
| const elements = await options.nextElements; | ||
| const metadata = AppElementsWire.readMetadata(elements); | ||
| const previousNextUrl = | ||
| const requestedPreviousNextUrl = | ||
| options.previousNextUrl !== undefined | ||
| ? options.previousNextUrl | ||
| : options.currentState.previousNextUrl; | ||
| const previousNextUrl = metadata.interception === null ? null : requestedPreviousNextUrl; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the key line that implements the clearing invariant: when Clean and correct. The behavioral change is well-tested (the existing test expectation changed from
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the key invariant: One subtlety worth noting: this means any code path that creates a |
||
|
|
||
| return { | ||
| action: { | ||
| elements, | ||
| interception: metadata.interception, | ||
| interceptionContext: metadata.interceptionContext, | ||
| layoutIds: metadata.layoutIds, | ||
| layoutFlags: metadata.layoutFlags, | ||
|
|
@@ -434,6 +455,7 @@ export async function createPendingNavigationCommit(options: { | |
| type: options.type, | ||
| }, | ||
| // Convenience aliases — always equal action.interceptionContext / action.rootLayoutTreePath / action.routeId. | ||
| interception: metadata.interception, | ||
| interceptionContext: metadata.interceptionContext, | ||
| previousNextUrl, | ||
| rootLayoutTreePath: metadata.rootLayoutTreePath, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note:
interceptSourceMatchedUrl: interceptionContextmeans the source URL for proof comes from the rawX-Vinext-Interception-Contextheader value. The normalization happens downstream increateAppPageInterceptionProofvianormalizeInterceptionProofMatchedUrl. This is the same pattern the Codex reviewer flagged and the test atapp-page-element-builder.test.tscovers. Correct as-is.