@@ -69,11 +69,14 @@ import {
6969 type AppWireElements ,
7070} from "./app-elements.js" ;
7171import {
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" ;
7982import { 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" ;
104105import { APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI } from "./app-rsc-render-mode.js" ;
106+ import { resolveRscRedirectLifecycleHop } from "./app-browser-rsc-redirect.js" ;
105107import {
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.
192194let 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
194226function 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 = {
424470function 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
0 commit comments