Skip to content

Commit 712737c

Browse files
fix(app-router): model RSC redirect and traversal lifecycle
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.
1 parent d558923 commit 712737c

11 files changed

Lines changed: 477 additions & 43 deletions

packages/vinext/src/global.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ declare global {
9292
* @param redirectDepth - Internal parameter used to detect redirect loops.
9393
* @param navigationKind - Internal hint for traversal vs regular navigation.
9494
* @param historyUpdateMode - Internal hint for when history should publish.
95+
* @param traversalIntent - Internal popstate direction/history metadata.
9596
*/
9697
__VINEXT_RSC_NAVIGATE__:
9798
| ((
@@ -101,6 +102,11 @@ declare global {
101102
historyUpdateMode?: "push" | "replace",
102103
previousNextUrlOverride?: string | null,
103104
programmaticTransition?: boolean,
105+
traversalIntent?: {
106+
direction: "back" | "forward" | "unknown";
107+
historyState: unknown;
108+
targetHistoryIndex: number | null;
109+
},
104110
) => Promise<void>)
105111
| undefined;
106112

packages/vinext/src/server/app-browser-entry.ts

Lines changed: 107 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,14 @@ import {
6969
type AppWireElements,
7070
} from "./app-elements.js";
7171
import {
72-
createHistoryStateWithPreviousNextUrl,
72+
createHistoryStateWithNavigationMetadata,
7373
readHistoryStatePreviousNextUrl,
74+
readHistoryStateTraversalIndex,
75+
resolveHistoryTraversalIntent,
7476
resolveInterceptionContextFromPreviousNextUrl,
7577
resolveServerActionRequestState,
7678
type AppRouterState,
79+
type HistoryTraversalIntent,
7780
type OperationLane,
7881
} from "./app-browser-state.js";
7982
import { createPopstateRestoreHandler } from "./app-browser-popstate.js";
@@ -96,12 +99,11 @@ import {
9699
getVinextRscCompatibilityId,
97100
resolveHardNavigationTargetFromRscResponse,
98101
resolveRscCompatibilityNavigationDecision,
99-
stripRscCacheBustingSearchParam,
100-
stripRscSuffix,
101102
VINEXT_RSC_COMPATIBILITY_ID_HEADER,
102103
VINEXT_RSC_CONTENT_TYPE,
103104
} from "./app-rsc-cache-busting.js";
104105
import { APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI } from "./app-rsc-render-mode.js";
106+
import { resolveRscRedirectLifecycleHop } from "./app-browser-rsc-redirect.js";
105107
import {
106108
ACTION_REDIRECT_HEADER,
107109
ACTION_REDIRECT_TYPE_HEADER,
@@ -190,6 +192,36 @@ let browserRouterStateHasEverCommitted = false;
190192
// of stranding them on the previous URL with a blank page. Cleared once the
191193
// commit effect runs (URL update succeeded) or the navigation is superseded.
192194
let pendingNavigationRecoveryHref: string | null = null;
195+
let currentHistoryTraversalIndex: number | null =
196+
readHistoryStateTraversalIndex(window.history.state) ?? 0;
197+
let nextHistoryTraversalIndex: number = currentHistoryTraversalIndex;
198+
199+
function allocateNavigationHistoryTraversalIndex(
200+
historyUpdateMode: HistoryUpdateMode | undefined,
201+
): number | null {
202+
switch (historyUpdateMode) {
203+
case "push":
204+
return nextHistoryTraversalIndex + 1;
205+
case "replace":
206+
return currentHistoryTraversalIndex;
207+
case undefined:
208+
return null;
209+
default: {
210+
const _exhaustive: never = historyUpdateMode;
211+
throw new Error("[vinext] Unknown history update mode: " + String(_exhaustive));
212+
}
213+
}
214+
}
215+
216+
function commitHistoryTraversalIndex(index: number | null): void {
217+
currentHistoryTraversalIndex = index;
218+
if (index !== null) {
219+
// Keep allocation anchored to the highest app-owned entry we know about.
220+
// Traversing to metadata-less entries makes the current index unknown, but
221+
// the next app-owned push should still continue from known app history.
222+
nextHistoryTraversalIndex = Math.max(nextHistoryTraversalIndex, index);
223+
}
224+
}
193225

194226
function getBrowserRouterState(): AppRouterState {
195227
return browserNavigationController.getBrowserRouterState();
@@ -251,8 +283,9 @@ function createNavigationCommitEffect(options: {
251283
navId: number;
252284
params: Record<string, string | string[]>;
253285
previousNextUrl: string | null;
286+
targetHistoryIndex?: number | null;
254287
}): () => void {
255-
const { href, historyUpdateMode, navId, params, previousNextUrl } = options;
288+
const { href, historyUpdateMode, navId, params, previousNextUrl, targetHistoryIndex } = options;
256289

257290
return () => {
258291
// Only update URL if this is still the active navigation.
@@ -267,15 +300,26 @@ function createNavigationCommitEffect(options: {
267300
const targetHref = new URL(href, window.location.origin).href;
268301
stageClientParams(params);
269302
const preserveExistingState = historyUpdateMode === "replace";
270-
const historyState = createHistoryStateWithPreviousNextUrl(
303+
const navigationHistoryIndex =
304+
targetHistoryIndex !== undefined
305+
? targetHistoryIndex
306+
: allocateNavigationHistoryTraversalIndex(historyUpdateMode);
307+
const historyState = createHistoryStateWithNavigationMetadata(
271308
preserveExistingState ? window.history.state : null,
272-
previousNextUrl,
309+
{
310+
previousNextUrl,
311+
traversalIndex: navigationHistoryIndex,
312+
},
273313
);
274314

275315
if (historyUpdateMode === "replace" && window.location.href !== targetHref) {
276316
replaceHistoryStateWithoutNotify(historyState, "", href);
317+
commitHistoryTraversalIndex(navigationHistoryIndex);
277318
} else if (historyUpdateMode === "push" && window.location.href !== targetHref) {
278319
pushHistoryStateWithoutNotify(historyState, "", href);
320+
commitHistoryTraversalIndex(navigationHistoryIndex);
321+
} else if (targetHistoryIndex !== undefined) {
322+
commitHistoryTraversalIndex(targetHistoryIndex);
279323
}
280324

281325
// URL has been updated; the recovery hard-nav target is no longer needed.
@@ -295,6 +339,7 @@ async function renderNavigationPayload(
295339
pendingRouterState: PendingBrowserRouterState | null,
296340
actionType: "navigate" | "replace" | "traverse" = "navigate",
297341
operationLane: OperationLane = "navigation",
342+
traversalIntent: HistoryTraversalIntent | null = null,
298343
): Promise<NavigationPayloadOutcome> {
299344
try {
300345
return await browserNavigationController.renderNavigationPayload({
@@ -310,6 +355,7 @@ async function renderNavigationPayload(
310355
params,
311356
pendingRouterState,
312357
previousNextUrl,
358+
targetHistoryIndex: traversalIntent === null ? undefined : traversalIntent.targetHistoryIndex,
313359
targetHref,
314360
navId,
315361
});
@@ -424,6 +470,7 @@ type NavigationRequestState = {
424470
function getRequestState(
425471
navigationKind: NavigationKind,
426472
previousNextUrlOverride?: string | null,
473+
traverseHistoryState?: unknown,
427474
): NavigationRequestState {
428475
if (previousNextUrlOverride !== undefined) {
429476
return {
@@ -442,7 +489,9 @@ function getRequestState(
442489
previousNextUrl: getCurrentNextUrl(),
443490
};
444491
case "traverse": {
445-
const previousNextUrl = readHistoryStatePreviousNextUrl(window.history.state);
492+
const previousNextUrl = readHistoryStatePreviousNextUrl(
493+
traverseHistoryState ?? window.history.state,
494+
);
446495
return {
447496
interceptionContext: resolveInterceptionContextFromPreviousNextUrl(
448497
previousNextUrl,
@@ -555,7 +604,10 @@ function BrowserRoot({
555604
}
556605

557606
replaceHistoryStateWithoutNotify(
558-
createHistoryStateWithPreviousNextUrl(window.history.state, treeState.previousNextUrl),
607+
createHistoryStateWithNavigationMetadata(window.history.state, {
608+
previousNextUrl: treeState.previousNextUrl,
609+
traversalIndex: currentHistoryTraversalIndex,
610+
}),
559611
"",
560612
window.location.href,
561613
);
@@ -961,7 +1013,10 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): void {
9611013
latestClientParams,
9621014
);
9631015
replaceHistoryStateWithoutNotify(
964-
createHistoryStateWithPreviousNextUrl(window.history.state, null),
1016+
createHistoryStateWithNavigationMetadata(window.history.state, {
1017+
previousNextUrl: null,
1018+
traversalIndex: currentHistoryTraversalIndex,
1019+
}),
9651020
"",
9661021
window.location.href,
9671022
);
@@ -1009,6 +1064,7 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): void {
10091064
historyUpdateMode?: HistoryUpdateMode,
10101065
previousNextUrlOverride?: string | null,
10111066
programmaticTransition = false,
1067+
traversalIntent?: HistoryTraversalIntent,
10121068
): Promise<void> {
10131069
let pendingRouterState: PendingBrowserRouterState | null = null;
10141070
// Hoist navId above try so the catch and finally blocks can reference it.
@@ -1022,6 +1078,14 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): void {
10221078
let currentHistoryMode = historyUpdateMode;
10231079
let currentPrevNextUrl = previousNextUrlOverride;
10241080
let redirectCount = redirectDepth;
1081+
const activeTraversalIntent =
1082+
navigationKind === "traverse"
1083+
? (traversalIntent ??
1084+
resolveHistoryTraversalIntent({
1085+
currentHistoryIndex: currentHistoryTraversalIndex,
1086+
historyState: window.history.state,
1087+
}))
1088+
: null;
10251089

10261090
try {
10271091
const shouldUsePendingRouterState = programmaticTransition;
@@ -1037,16 +1101,12 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): void {
10371101
}
10381102

10391103
while (true) {
1040-
if (redirectCount > 10) {
1041-
console.error(
1042-
"[vinext] Too many RSC redirects — aborting navigation to prevent infinite loop.",
1043-
);
1044-
window.location.href = currentHref;
1045-
return;
1046-
}
1047-
10481104
const url = new URL(currentHref, window.location.origin);
1049-
const requestState = getRequestState(navigationKind, currentPrevNextUrl);
1105+
const requestState = getRequestState(
1106+
navigationKind,
1107+
currentPrevNextUrl,
1108+
activeTraversalIntent?.historyState,
1109+
);
10501110
const requestInterceptionContext = requestState.interceptionContext;
10511111
const requestPreviousNextUrl = requestState.previousNextUrl;
10521112

@@ -1117,6 +1177,7 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): void {
11171177
pendingRouterState,
11181178
toActionType(navigationKind),
11191179
toOperationLane(navigationKind),
1180+
activeTraversalIntent,
11201181
);
11211182
return;
11221183
}
@@ -1190,26 +1251,34 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): void {
11901251
return;
11911252
}
11921253

1193-
const finalUrl = new URL(navResponseUrl ?? navResponse.url, window.location.origin);
1194-
stripRscCacheBustingSearchParam(finalUrl);
1195-
const requestedUrl = new URL(rscUrl, window.location.origin);
1196-
1197-
if (finalUrl.pathname !== requestedUrl.pathname) {
1198-
// Server-side redirect: update the URL in history and loop to fetch
1199-
// the destination without settling pendingRouterState. This keeps
1200-
// isPending true across all redirect hops instead of flashing false.
1201-
const destinationPath = stripRscSuffix(finalUrl.pathname) + finalUrl.search;
1202-
replaceHistoryStateWithoutNotify(
1203-
createHistoryStateWithPreviousNextUrl(null, requestPreviousNextUrl),
1204-
"",
1205-
destinationPath,
1206-
);
1254+
const redirectDecision = resolveRscRedirectLifecycleHop({
1255+
currentHref,
1256+
historyUpdateMode: currentHistoryMode ?? "replace",
1257+
origin: window.location.origin,
1258+
redirectDepth: redirectCount,
1259+
requestPreviousNextUrl,
1260+
responseUrl: navResponseUrl ?? navResponse.url,
1261+
});
1262+
1263+
if (redirectDecision.kind === "terminal-hard-navigation") {
1264+
if (redirectDecision.reason === "maxRedirectsExceeded") {
1265+
console.error(
1266+
"[vinext] Too many RSC redirects — aborting navigation to prevent infinite loop.",
1267+
);
1268+
}
1269+
window.location.href = redirectDecision.href;
1270+
return;
1271+
}
12071272

1208-
currentHref = destinationPath;
1209-
// URL already written above; the commit effect must not push/replace again.
1210-
currentHistoryMode = undefined;
1211-
currentPrevNextUrl = requestPreviousNextUrl;
1212-
redirectCount += 1;
1273+
if (redirectDecision.kind === "follow") {
1274+
// Server-side redirect: keep the redirect chain inside this operation
1275+
// and defer URL/history mutation to the eventual approved commit.
1276+
// This keeps isPending true across all hops and avoids publishing a
1277+
// destination URL before its RSC payload is lifecycle-approved.
1278+
currentHref = redirectDecision.href;
1279+
currentHistoryMode = redirectDecision.historyUpdateMode;
1280+
currentPrevNextUrl = redirectDecision.previousNextUrl;
1281+
redirectCount = redirectDecision.redirectDepth;
12131282
continue;
12141283
}
12151284

@@ -1264,6 +1333,7 @@ function bootstrapHydration(rscStream: ReadableStream<Uint8Array>): void {
12641333
pendingRouterState,
12651334
toActionType(navigationKind),
12661335
toOperationLane(navigationKind),
1336+
activeTraversalIntent,
12671337
);
12681338
if (renderOutcome !== "committed") return;
12691339
// Don't cache the response if this navigation was superseded during

packages/vinext/src/server/app-browser-navigation-controller.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ type BrowserNavigationCommitEffectFactory = (options: {
3939
navId: number;
4040
params: Record<string, string | string[]>;
4141
previousNextUrl: string | null;
42+
targetHistoryIndex?: number | null;
4243
}) => () => void;
4344

4445
type BrowserRouterStateRef = {
@@ -80,6 +81,7 @@ type BrowserNavigationController = {
8081
params: Record<string, string | string[]>;
8182
pendingRouterState: PendingBrowserRouterState | null;
8283
previousNextUrl: string | null;
84+
targetHistoryIndex?: number | null;
8385
targetHref: string;
8486
navId: number;
8587
}): Promise<NavigationPayloadOutcome>;
@@ -488,6 +490,7 @@ export function createAppBrowserNavigationController(
488490
params: Record<string, string | string[]>;
489491
pendingRouterState: PendingBrowserRouterState | null;
490492
previousNextUrl: string | null;
493+
targetHistoryIndex?: number | null;
491494
targetHref: string;
492495
navId: number;
493496
}): Promise<NavigationPayloadOutcome> {
@@ -545,6 +548,7 @@ export function createAppBrowserNavigationController(
545548
navId: options.navId,
546549
params: options.params,
547550
previousNextUrl: approvedCommit.previousNextUrl,
551+
targetHistoryIndex: options.targetHistoryIndex,
548552
}),
549553
);
550554
activateNavigationSnapshot();

0 commit comments

Comments
 (0)