-
Notifications
You must be signed in to change notification settings - Fork 325
fix(app-router): model RSC redirect and traversal lifecycle #1248
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 |
|---|---|---|
|
|
@@ -69,11 +69,14 @@ import { | |
| type AppWireElements, | ||
| } from "./app-elements.js"; | ||
| import { | ||
| createHistoryStateWithPreviousNextUrl, | ||
| createHistoryStateWithNavigationMetadata, | ||
| readHistoryStatePreviousNextUrl, | ||
| readHistoryStateTraversalIndex, | ||
| resolveHistoryTraversalIntent, | ||
| resolveInterceptionContextFromPreviousNextUrl, | ||
| resolveServerActionRequestState, | ||
| type AppRouterState, | ||
| type HistoryTraversalIntent, | ||
| type OperationLane, | ||
| } from "./app-browser-state.js"; | ||
| import { createPopstateRestoreHandler } from "./app-browser-popstate.js"; | ||
|
|
@@ -96,12 +99,11 @@ import { | |
| getVinextRscCompatibilityId, | ||
| resolveHardNavigationTargetFromRscResponse, | ||
| resolveRscCompatibilityNavigationDecision, | ||
| stripRscCacheBustingSearchParam, | ||
| stripRscSuffix, | ||
| VINEXT_RSC_COMPATIBILITY_ID_HEADER, | ||
| VINEXT_RSC_CONTENT_TYPE, | ||
| } from "./app-rsc-cache-busting.js"; | ||
| import { APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI } from "./app-rsc-render-mode.js"; | ||
| import { resolveRscRedirectLifecycleHop } from "./app-browser-rsc-redirect.js"; | ||
| import { | ||
| ACTION_REDIRECT_HEADER, | ||
| ACTION_REDIRECT_TYPE_HEADER, | ||
|
|
@@ -190,6 +192,36 @@ let browserRouterStateHasEverCommitted = false; | |
| // of stranding them on the previous URL with a blank page. Cleared once the | ||
| // commit effect runs (URL update succeeded) or the navigation is superseded. | ||
| let pendingNavigationRecoveryHref: string | null = null; | ||
| let currentHistoryTraversalIndex: number | null = | ||
| readHistoryStateTraversalIndex(window.history.state) ?? 0; | ||
| let nextHistoryTraversalIndex: number = currentHistoryTraversalIndex; | ||
|
|
||
| function allocateNavigationHistoryTraversalIndex( | ||
| historyUpdateMode: HistoryUpdateMode | undefined, | ||
| ): number | null { | ||
| switch (historyUpdateMode) { | ||
| case "push": | ||
| return nextHistoryTraversalIndex + 1; | ||
| case "replace": | ||
| return currentHistoryTraversalIndex; | ||
| case undefined: | ||
| return null; | ||
| default: { | ||
| const _exhaustive: never = historyUpdateMode; | ||
| throw new Error("[vinext] Unknown history update mode: " + String(_exhaustive)); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| function commitHistoryTraversalIndex(index: number | null): void { | ||
| currentHistoryTraversalIndex = index; | ||
| if (index !== null) { | ||
| // Keep allocation anchored to the highest app-owned entry we know about. | ||
| // Traversing to metadata-less entries makes the current index unknown, but | ||
| // the next app-owned push should still continue from known app history. | ||
| nextHistoryTraversalIndex = Math.max(nextHistoryTraversalIndex, index); | ||
| } | ||
| } | ||
|
|
||
| function getBrowserRouterState(): AppRouterState { | ||
| return browserNavigationController.getBrowserRouterState(); | ||
|
|
@@ -251,8 +283,9 @@ function createNavigationCommitEffect(options: { | |
| navId: number; | ||
| params: Record<string, string | string[]>; | ||
| previousNextUrl: string | null; | ||
| targetHistoryIndex?: number | null; | ||
| }): () => void { | ||
| const { href, historyUpdateMode, navId, params, previousNextUrl } = options; | ||
| const { href, historyUpdateMode, navId, params, previousNextUrl, targetHistoryIndex } = options; | ||
|
|
||
| return () => { | ||
| // Only update URL if this is still the active navigation. | ||
|
|
@@ -267,15 +300,26 @@ function createNavigationCommitEffect(options: { | |
| const targetHref = new URL(href, window.location.origin).href; | ||
| stageClientParams(params); | ||
| const preserveExistingState = historyUpdateMode === "replace"; | ||
| const historyState = createHistoryStateWithPreviousNextUrl( | ||
| const navigationHistoryIndex = | ||
| targetHistoryIndex !== undefined | ||
| ? targetHistoryIndex | ||
| : allocateNavigationHistoryTraversalIndex(historyUpdateMode); | ||
| const historyState = createHistoryStateWithNavigationMetadata( | ||
| preserveExistingState ? window.history.state : null, | ||
| previousNextUrl, | ||
| { | ||
| previousNextUrl, | ||
| traversalIndex: navigationHistoryIndex, | ||
| }, | ||
| ); | ||
|
|
||
| if (historyUpdateMode === "replace" && window.location.href !== targetHref) { | ||
| replaceHistoryStateWithoutNotify(historyState, "", href); | ||
| commitHistoryTraversalIndex(navigationHistoryIndex); | ||
| } else if (historyUpdateMode === "push" && window.location.href !== targetHref) { | ||
| pushHistoryStateWithoutNotify(historyState, "", href); | ||
| commitHistoryTraversalIndex(navigationHistoryIndex); | ||
| } else if (targetHistoryIndex !== undefined) { | ||
| commitHistoryTraversalIndex(targetHistoryIndex); | ||
| } | ||
|
|
||
| // URL has been updated; the recovery hard-nav target is no longer needed. | ||
|
|
@@ -295,6 +339,7 @@ async function renderNavigationPayload( | |
| pendingRouterState: PendingBrowserRouterState | null, | ||
| actionType: "navigate" | "replace" | "traverse" = "navigate", | ||
| operationLane: OperationLane = "navigation", | ||
| traversalIntent: HistoryTraversalIntent | null = null, | ||
| ): Promise<NavigationPayloadOutcome> { | ||
| try { | ||
| return await browserNavigationController.renderNavigationPayload({ | ||
|
|
@@ -310,6 +355,7 @@ async function renderNavigationPayload( | |
| params, | ||
| pendingRouterState, | ||
| previousNextUrl, | ||
| targetHistoryIndex: traversalIntent === null ? undefined : traversalIntent.targetHistoryIndex, | ||
| targetHref, | ||
| navId, | ||
| }); | ||
|
|
@@ -424,6 +470,7 @@ type NavigationRequestState = { | |
| function getRequestState( | ||
| navigationKind: NavigationKind, | ||
| previousNextUrlOverride?: string | null, | ||
| traverseHistoryState?: unknown, | ||
| ): NavigationRequestState { | ||
| if (previousNextUrlOverride !== undefined) { | ||
| return { | ||
|
|
@@ -442,7 +489,9 @@ function getRequestState( | |
| previousNextUrl: getCurrentNextUrl(), | ||
| }; | ||
| case "traverse": { | ||
| const previousNextUrl = readHistoryStatePreviousNextUrl(window.history.state); | ||
| const previousNextUrl = readHistoryStatePreviousNextUrl( | ||
| traverseHistoryState ?? window.history.state, | ||
| ); | ||
| return { | ||
| interceptionContext: resolveInterceptionContextFromPreviousNextUrl( | ||
| previousNextUrl, | ||
|
|
@@ -555,7 +604,10 @@ function BrowserRoot({ | |
| } | ||
|
|
||
| replaceHistoryStateWithoutNotify( | ||
| createHistoryStateWithPreviousNextUrl(window.history.state, treeState.previousNextUrl), | ||
| createHistoryStateWithNavigationMetadata(window.history.state, { | ||
| previousNextUrl: treeState.previousNextUrl, | ||
| traversalIndex: currentHistoryTraversalIndex, | ||
| }), | ||
| "", | ||
| window.location.href, | ||
| ); | ||
|
|
@@ -961,7 +1013,10 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): void { | |
| latestClientParams, | ||
| ); | ||
| replaceHistoryStateWithoutNotify( | ||
| createHistoryStateWithPreviousNextUrl(window.history.state, null), | ||
| createHistoryStateWithNavigationMetadata(window.history.state, { | ||
| previousNextUrl: null, | ||
| traversalIndex: currentHistoryTraversalIndex, | ||
| }), | ||
| "", | ||
| window.location.href, | ||
| ); | ||
|
|
@@ -1009,6 +1064,7 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): void { | |
| historyUpdateMode?: HistoryUpdateMode, | ||
| previousNextUrlOverride?: string | null, | ||
| programmaticTransition = false, | ||
| traversalIntent?: HistoryTraversalIntent, | ||
| ): Promise<void> { | ||
| let pendingRouterState: PendingBrowserRouterState | null = null; | ||
| // Hoist navId above try so the catch and finally blocks can reference it. | ||
|
|
@@ -1022,6 +1078,14 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): void { | |
| let currentHistoryMode = historyUpdateMode; | ||
| let currentPrevNextUrl = previousNextUrlOverride; | ||
| let redirectCount = redirectDepth; | ||
| const activeTraversalIntent = | ||
| navigationKind === "traverse" | ||
| ? (traversalIntent ?? | ||
| resolveHistoryTraversalIntent({ | ||
| currentHistoryIndex: currentHistoryTraversalIndex, | ||
| historyState: window.history.state, | ||
| })) | ||
| : null; | ||
|
|
||
| try { | ||
| const shouldUsePendingRouterState = programmaticTransition; | ||
|
|
@@ -1037,16 +1101,12 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): void { | |
| } | ||
|
|
||
| while (true) { | ||
| if (redirectCount > 10) { | ||
| console.error( | ||
| "[vinext] Too many RSC redirects — aborting navigation to prevent infinite loop.", | ||
| ); | ||
| window.location.href = currentHref; | ||
| return; | ||
| } | ||
|
|
||
| const url = new URL(currentHref, window.location.origin); | ||
| const requestState = getRequestState(navigationKind, currentPrevNextUrl); | ||
| const requestState = getRequestState( | ||
| navigationKind, | ||
| currentPrevNextUrl, | ||
| activeTraversalIntent?.historyState, | ||
| ); | ||
| const requestInterceptionContext = requestState.interceptionContext; | ||
| const requestPreviousNextUrl = requestState.previousNextUrl; | ||
|
|
||
|
|
@@ -1117,6 +1177,7 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): void { | |
| pendingRouterState, | ||
| toActionType(navigationKind), | ||
| toOperationLane(navigationKind), | ||
| activeTraversalIntent, | ||
| ); | ||
| return; | ||
| } | ||
|
|
@@ -1190,26 +1251,34 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): void { | |
| return; | ||
| } | ||
|
|
||
| const finalUrl = new URL(navResponseUrl ?? navResponse.url, window.location.origin); | ||
| stripRscCacheBustingSearchParam(finalUrl); | ||
| const requestedUrl = new URL(rscUrl, window.location.origin); | ||
|
|
||
| if (finalUrl.pathname !== requestedUrl.pathname) { | ||
| // Server-side redirect: update the URL in history and loop to fetch | ||
| // the destination without settling pendingRouterState. This keeps | ||
| // isPending true across all redirect hops instead of flashing false. | ||
| const destinationPath = stripRscSuffix(finalUrl.pathname) + finalUrl.search; | ||
| replaceHistoryStateWithoutNotify( | ||
| createHistoryStateWithPreviousNextUrl(null, requestPreviousNextUrl), | ||
| "", | ||
| destinationPath, | ||
| ); | ||
| const redirectDecision = resolveRscRedirectLifecycleHop({ | ||
| currentHref, | ||
| historyUpdateMode: currentHistoryMode ?? "replace", | ||
|
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 fallback On a multi-hop chain: first hop enters with the original mode (e.g. The |
||
| origin: window.location.origin, | ||
| redirectDepth: redirectCount, | ||
| requestPreviousNextUrl, | ||
| responseUrl: navResponseUrl ?? navResponse.url, | ||
| }); | ||
|
|
||
| if (redirectDecision.kind === "terminal-hard-navigation") { | ||
| if (redirectDecision.reason === "maxRedirectsExceeded") { | ||
| console.error( | ||
| "[vinext] Too many RSC redirects — aborting navigation to prevent infinite loop.", | ||
| ); | ||
| } | ||
| window.location.href = redirectDecision.href; | ||
| return; | ||
| } | ||
|
|
||
| currentHref = destinationPath; | ||
| // URL already written above; the commit effect must not push/replace again. | ||
| currentHistoryMode = undefined; | ||
| currentPrevNextUrl = requestPreviousNextUrl; | ||
| redirectCount += 1; | ||
| if (redirectDecision.kind === "follow") { | ||
| // Server-side redirect: keep the redirect chain inside this operation | ||
| // and defer URL/history mutation to the eventual approved commit. | ||
| // This keeps isPending true across all hops and avoids publishing a | ||
| // destination URL before its RSC payload is lifecycle-approved. | ||
| currentHref = redirectDecision.href; | ||
| currentHistoryMode = redirectDecision.historyUpdateMode; | ||
| currentPrevNextUrl = redirectDecision.previousNextUrl; | ||
| redirectCount = redirectDecision.redirectDepth; | ||
| continue; | ||
| } | ||
|
|
||
|
|
@@ -1264,6 +1333,7 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): void { | |
| pendingRouterState, | ||
| toActionType(navigationKind), | ||
| toOperationLane(navigationKind), | ||
| activeTraversalIntent, | ||
| ); | ||
| if (renderOutcome !== "committed") return; | ||
| // Don't cache the response if this navigation was superseded during | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.