Skip to content

Commit 252f3d0

Browse files
feat: track previousNextUrl for intercepted App Router entries
1 parent c34d0a5 commit 252f3d0

8 files changed

Lines changed: 268 additions & 51 deletions

File tree

packages/vinext/src/global.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ declare global {
9393
redirectDepth?: number,
9494
navigationKind?: "navigate" | "traverse" | "refresh",
9595
historyUpdateMode?: "push" | "replace",
96+
previousNextUrlOverride?: string | null,
9697
) => Promise<void>)
9798
| undefined;
9899

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

Lines changed: 74 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,12 @@ import {
2626
commitClientNavigationState,
2727
consumePrefetchResponse,
2828
createClientNavigationRenderSnapshot,
29+
getCurrentNextUrl,
2930
getCurrentInterceptionContext,
3031
getClientNavigationRenderContext,
3132
getPrefetchCache,
3233
getPrefetchedUrls,
3334
pushHistoryStateWithoutNotify,
34-
readHistoryStateInterceptionContext,
3535
replaceClientParamsWithoutNotify,
3636
replaceHistoryStateWithoutNotify,
3737
restoreRscResponse,
@@ -40,7 +40,6 @@ import {
4040
setMountedSlotsHeader,
4141
setNavigationContext,
4242
toRscUrl,
43-
VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY,
4443
type CachedRscResponse,
4544
type ClientNavigationRenderSnapshot,
4645
} from "../shims/navigation.js";
@@ -60,8 +59,11 @@ import {
6059
type AppWireElements,
6160
} from "./app-elements.js";
6261
import {
62+
createHistoryStateWithPreviousNextUrl,
6363
createPendingNavigationCommit,
64+
readHistoryStatePreviousNextUrl,
6465
resolveAndClassifyNavigationCommit,
66+
resolveInterceptionContextFromPreviousNextUrl,
6567
resolvePendingNavigationCommitDisposition,
6668
routerReducer,
6769
type AppRouterAction,
@@ -98,9 +100,6 @@ type VisitedResponseCacheEntry = {
98100
const MAX_VISITED_RESPONSE_CACHE_SIZE = 50;
99101
const VISITED_RESPONSE_CACHE_TTL = 5 * 60_000;
100102
const MAX_TRAVERSAL_CACHE_TTL = 30 * 60_000;
101-
type HistoryStateRecord = {
102-
[key: string]: unknown;
103-
};
104103

105104
// These are plain module-level variables, unlike ClientNavigationState in
106105
// navigation.ts which uses Symbol.for to survive multiple Vite module instances.
@@ -208,15 +207,15 @@ function createNavigationCommitEffect(
208207
href: string,
209208
historyUpdateMode: HistoryUpdateMode | undefined,
210209
params: Record<string, string | string[]>,
211-
interceptionContext: string | null,
210+
previousNextUrl: string | null,
212211
): () => void {
213212
return () => {
214213
const targetHref = new URL(href, window.location.origin).href;
215214
stageClientParams(params);
216215
const preserveExistingState = historyUpdateMode === "replace";
217-
const historyState = createHistoryStateWithInterceptionContext(
216+
const historyState = createHistoryStateWithPreviousNextUrl(
218217
preserveExistingState ? window.history.state : null,
219-
interceptionContext,
218+
previousNextUrl,
220219
);
221220

222221
if (historyUpdateMode === "replace" && window.location.href !== targetHref) {
@@ -300,41 +299,49 @@ function storeVisitedResponseSnapshot(
300299
});
301300
}
302301

303-
function cloneHistoryState(state: unknown): HistoryStateRecord {
304-
if (!state || typeof state !== "object") {
305-
return {};
306-
}
307-
308-
const nextState: HistoryStateRecord = {};
309-
for (const [key, value] of Object.entries(state)) {
310-
nextState[key] = value;
311-
}
312-
return nextState;
313-
}
314-
315-
function createHistoryStateWithInterceptionContext(
316-
state: unknown,
317-
interceptionContext: string | null,
318-
): HistoryStateRecord | null {
319-
const nextState = cloneHistoryState(state);
302+
type NavigationRequestState = {
303+
interceptionContext: string | null;
304+
previousNextUrl: string | null;
305+
};
320306

321-
if (interceptionContext === null) {
322-
delete nextState[VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY];
323-
} else {
324-
nextState[VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY] = interceptionContext;
307+
function getRequestState(
308+
navigationKind: NavigationKind,
309+
previousNextUrlOverride?: string | null,
310+
): NavigationRequestState {
311+
if (previousNextUrlOverride !== undefined) {
312+
return {
313+
interceptionContext: resolveInterceptionContextFromPreviousNextUrl(
314+
previousNextUrlOverride,
315+
__basePath,
316+
),
317+
previousNextUrl: previousNextUrlOverride,
318+
};
325319
}
326320

327-
return Object.keys(nextState).length > 0 ? nextState : null;
328-
}
329-
330-
function getRequestInterceptionContext(navigationKind: NavigationKind): string | null {
331321
switch (navigationKind) {
332322
case "navigate":
333-
return getCurrentInterceptionContext();
334-
case "traverse":
335-
return readHistoryStateInterceptionContext(window.history.state);
323+
return {
324+
interceptionContext: getCurrentInterceptionContext(),
325+
previousNextUrl: getCurrentNextUrl(),
326+
};
327+
case "traverse": {
328+
const previousNextUrl = readHistoryStatePreviousNextUrl(window.history.state);
329+
return {
330+
interceptionContext: resolveInterceptionContextFromPreviousNextUrl(
331+
previousNextUrl,
332+
__basePath,
333+
),
334+
previousNextUrl,
335+
};
336+
}
336337
case "refresh":
337-
return null;
338+
return {
339+
interceptionContext: resolveInterceptionContextFromPreviousNextUrl(
340+
getBrowserRouterState().previousNextUrl,
341+
__basePath,
342+
),
343+
previousNextUrl: getBrowserRouterState().previousNextUrl,
344+
};
338345
default: {
339346
const _exhaustive: never = navigationKind;
340347
throw new Error("[vinext] Unknown navigation kind: " + String(_exhaustive));
@@ -434,6 +441,7 @@ async function commitSameUrlNavigatePayload(
434441
pending.action.renderId,
435442
"navigate",
436443
pending.interceptionContext,
444+
pending.previousNextUrl,
437445
pending.routeId,
438446
pending.rootLayoutTreePath,
439447
false,
@@ -467,6 +475,7 @@ function BrowserRoot({
467475
elements: resolvedElements,
468476
interceptionContext: initialMetadata.interceptionContext,
469477
navigationSnapshot: initialNavigationSnapshot,
478+
previousNextUrl: null,
470479
renderId: 0,
471480
rootLayoutTreePath: initialMetadata.rootLayoutTreePath,
472481
routeId: initialMetadata.routeId,
@@ -510,14 +519,11 @@ function BrowserRoot({
510519
}
511520

512521
replaceHistoryStateWithoutNotify(
513-
createHistoryStateWithInterceptionContext(
514-
window.history.state,
515-
treeState.interceptionContext,
516-
),
522+
createHistoryStateWithPreviousNextUrl(window.history.state, treeState.previousNextUrl),
517523
"",
518524
window.location.href,
519525
);
520-
}, [treeState.interceptionContext, treeState.renderId]);
526+
}, [treeState.previousNextUrl, treeState.renderId]);
521527

522528
const committedTree = createElement(
523529
NavigationCommitSignal,
@@ -547,6 +553,7 @@ function dispatchBrowserTree(
547553
renderId: number,
548554
actionType: "navigate" | "replace" | "traverse",
549555
interceptionContext: string | null,
556+
previousNextUrl: string | null,
550557
routeId: string,
551558
rootLayoutTreePath: string | null,
552559
useTransitionMode: boolean,
@@ -558,6 +565,7 @@ function dispatchBrowserTree(
558565
elements,
559566
interceptionContext,
560567
navigationSnapshot,
568+
previousNextUrl,
561569
renderId,
562570
rootLayoutTreePath,
563571
routeId,
@@ -578,6 +586,7 @@ async function renderNavigationPayload(
578586
navId: number,
579587
historyUpdateMode: HistoryUpdateMode | undefined,
580588
params: Record<string, string | string[]>,
589+
previousNextUrl: string | null,
581590
useTransition = true,
582591
actionType: "navigate" | "replace" | "traverse" = "navigate",
583592
): Promise<void> {
@@ -593,6 +602,7 @@ async function renderNavigationPayload(
593602
currentState,
594603
nextElements: payload,
595604
navigationSnapshot,
605+
previousNextUrl,
596606
renderId,
597607
type: actionType,
598608
});
@@ -619,12 +629,7 @@ async function renderNavigationPayload(
619629

620630
queuePrePaintNavigationEffect(
621631
renderId,
622-
createNavigationCommitEffect(
623-
targetHref,
624-
historyUpdateMode,
625-
params,
626-
pending.interceptionContext,
627-
),
632+
createNavigationCommitEffect(targetHref, historyUpdateMode, params, pending.previousNextUrl),
628633
);
629634
activateNavigationSnapshot();
630635
snapshotActivated = true;
@@ -634,6 +639,7 @@ async function renderNavigationPayload(
634639
renderId,
635640
actionType,
636641
pending.interceptionContext,
642+
pending.previousNextUrl,
637643
pending.routeId,
638644
pending.rootLayoutTreePath,
639645
useTransition,
@@ -813,6 +819,11 @@ async function main(): Promise<void> {
813819
window.location.href,
814820
latestClientParams,
815821
);
822+
replaceHistoryStateWithoutNotify(
823+
createHistoryStateWithPreviousNextUrl(window.history.state, null),
824+
"",
825+
window.location.href,
826+
);
816827

817828
window.__VINEXT_RSC_ROOT__ = hydrateRoot(
818829
document,
@@ -829,6 +840,7 @@ async function main(): Promise<void> {
829840
redirectDepth = 0,
830841
navigationKind: NavigationKind = "navigate",
831842
historyUpdateMode?: HistoryUpdateMode,
843+
previousNextUrlOverride?: string | null,
832844
): Promise<void> {
833845
if (redirectDepth > 10) {
834846
console.error(
@@ -845,7 +857,9 @@ async function main(): Promise<void> {
845857
try {
846858
const url = new URL(href, window.location.origin);
847859
const rscUrl = toRscUrl(url.pathname + url.search);
848-
const requestInterceptionContext = getRequestInterceptionContext(navigationKind);
860+
const requestState = getRequestState(navigationKind, previousNextUrlOverride);
861+
const requestInterceptionContext = requestState.interceptionContext;
862+
const requestPreviousNextUrl = requestState.previousNextUrl;
849863
// Use startTransition for same-route navigations (searchParam changes)
850864
// so React keeps the old UI visible during the transition. For cross-route
851865
// navigations (different pathname), use synchronous updates — React's
@@ -895,6 +909,7 @@ async function main(): Promise<void> {
895909
navId,
896910
historyUpdateMode,
897911
cachedParams,
912+
requestPreviousNextUrl,
898913
isSameRoute,
899914
toActionType(navigationKind),
900915
);
@@ -942,7 +957,7 @@ async function main(): Promise<void> {
942957
if (finalUrl.pathname !== requestedUrl.pathname) {
943958
const destinationPath = finalUrl.pathname.replace(/\.rsc$/, "") + finalUrl.search;
944959
replaceHistoryStateWithoutNotify(
945-
createHistoryStateWithInterceptionContext(null, requestInterceptionContext),
960+
createHistoryStateWithPreviousNextUrl(null, requestPreviousNextUrl),
946961
"",
947962
destinationPath,
948963
);
@@ -956,7 +971,13 @@ async function main(): Promise<void> {
956971
// The URL has already been updated via replaceHistoryStateWithoutNotify above,
957972
// so the recursive navigation should NOT push/replace again. Pass undefined
958973
// for historyUpdateMode to make the commit effect a no-op for history updates.
959-
return navigate(destinationPath, redirectDepth + 1, navigationKind, undefined);
974+
return navigate(
975+
destinationPath,
976+
redirectDepth + 1,
977+
navigationKind,
978+
undefined,
979+
requestPreviousNextUrl,
980+
);
960981
}
961982

962983
let navParams: Record<string, string | string[]> = {};
@@ -993,6 +1014,7 @@ async function main(): Promise<void> {
9931014
navId,
9941015
historyUpdateMode,
9951016
navParams,
1017+
requestPreviousNextUrl,
9961018
isSameRoute,
9971019
toActionType(navigationKind),
9981020
);
@@ -1088,6 +1110,7 @@ async function main(): Promise<void> {
10881110
pending.action.renderId,
10891111
"replace",
10901112
pending.interceptionContext,
1113+
pending.previousNextUrl,
10911114
pending.routeId,
10921115
pending.rootLayoutTreePath,
10931116
false,

0 commit comments

Comments
 (0)