From c34d0a52b569ed25703fa400d10836fbaa46acf5 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:17:40 +1100 Subject: [PATCH 1/7] docs: refresh stale app browser entry comments --- packages/vinext/src/server/app-browser-entry.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 09b0cf62..22dcf824 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -152,8 +152,7 @@ function stageClientParams(params: Record): void { // read latestClientParams, so a // server action fired during this window would get the pending (not yet // committed) params. This is acceptable because the commit effect fires - // synchronously in the same React commit phase, keeping the window - // vanishingly small. + // before hooks observe the new URL state, keeping the window vanishingly small. latestClientParams = params; replaceClientParamsWithoutNotify(params); } From 252f3d0dad1344e5669a7f1bb09fe023d35cd66a Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:29:15 +1100 Subject: [PATCH 2/7] feat: track previousNextUrl for intercepted App Router entries --- packages/vinext/src/global.d.ts | 1 + .../vinext/src/server/app-browser-entry.ts | 125 +++++++++++------- .../vinext/src/server/app-browser-state.ts | 62 +++++++++ packages/vinext/src/shims/navigation.ts | 8 ++ tests/app-browser-entry.test.ts | 78 +++++++++++ tests/e2e/app-router/advanced.spec.ts | 30 +++++ .../app/feed/@modal/(...)photos/[id]/page.tsx | 2 + .../(...)photos/[id]/refresh-button.tsx | 13 ++ 8 files changed, 268 insertions(+), 51 deletions(-) create mode 100644 tests/fixtures/app-basic/app/feed/@modal/(...)photos/[id]/refresh-button.tsx diff --git a/packages/vinext/src/global.d.ts b/packages/vinext/src/global.d.ts index 2e85310c..1b3584b3 100644 --- a/packages/vinext/src/global.d.ts +++ b/packages/vinext/src/global.d.ts @@ -93,6 +93,7 @@ declare global { redirectDepth?: number, navigationKind?: "navigate" | "traverse" | "refresh", historyUpdateMode?: "push" | "replace", + previousNextUrlOverride?: string | null, ) => Promise) | undefined; diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 22dcf824..3aa3cfd7 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -26,12 +26,12 @@ import { commitClientNavigationState, consumePrefetchResponse, createClientNavigationRenderSnapshot, + getCurrentNextUrl, getCurrentInterceptionContext, getClientNavigationRenderContext, getPrefetchCache, getPrefetchedUrls, pushHistoryStateWithoutNotify, - readHistoryStateInterceptionContext, replaceClientParamsWithoutNotify, replaceHistoryStateWithoutNotify, restoreRscResponse, @@ -40,7 +40,6 @@ import { setMountedSlotsHeader, setNavigationContext, toRscUrl, - VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY, type CachedRscResponse, type ClientNavigationRenderSnapshot, } from "../shims/navigation.js"; @@ -60,8 +59,11 @@ import { type AppWireElements, } from "./app-elements.js"; import { + createHistoryStateWithPreviousNextUrl, createPendingNavigationCommit, + readHistoryStatePreviousNextUrl, resolveAndClassifyNavigationCommit, + resolveInterceptionContextFromPreviousNextUrl, resolvePendingNavigationCommitDisposition, routerReducer, type AppRouterAction, @@ -98,9 +100,6 @@ type VisitedResponseCacheEntry = { const MAX_VISITED_RESPONSE_CACHE_SIZE = 50; const VISITED_RESPONSE_CACHE_TTL = 5 * 60_000; const MAX_TRAVERSAL_CACHE_TTL = 30 * 60_000; -type HistoryStateRecord = { - [key: string]: unknown; -}; // These are plain module-level variables, unlike ClientNavigationState in // navigation.ts which uses Symbol.for to survive multiple Vite module instances. @@ -208,15 +207,15 @@ function createNavigationCommitEffect( href: string, historyUpdateMode: HistoryUpdateMode | undefined, params: Record, - interceptionContext: string | null, + previousNextUrl: string | null, ): () => void { return () => { const targetHref = new URL(href, window.location.origin).href; stageClientParams(params); const preserveExistingState = historyUpdateMode === "replace"; - const historyState = createHistoryStateWithInterceptionContext( + const historyState = createHistoryStateWithPreviousNextUrl( preserveExistingState ? window.history.state : null, - interceptionContext, + previousNextUrl, ); if (historyUpdateMode === "replace" && window.location.href !== targetHref) { @@ -300,41 +299,49 @@ function storeVisitedResponseSnapshot( }); } -function cloneHistoryState(state: unknown): HistoryStateRecord { - if (!state || typeof state !== "object") { - return {}; - } - - const nextState: HistoryStateRecord = {}; - for (const [key, value] of Object.entries(state)) { - nextState[key] = value; - } - return nextState; -} - -function createHistoryStateWithInterceptionContext( - state: unknown, - interceptionContext: string | null, -): HistoryStateRecord | null { - const nextState = cloneHistoryState(state); +type NavigationRequestState = { + interceptionContext: string | null; + previousNextUrl: string | null; +}; - if (interceptionContext === null) { - delete nextState[VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY]; - } else { - nextState[VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY] = interceptionContext; +function getRequestState( + navigationKind: NavigationKind, + previousNextUrlOverride?: string | null, +): NavigationRequestState { + if (previousNextUrlOverride !== undefined) { + return { + interceptionContext: resolveInterceptionContextFromPreviousNextUrl( + previousNextUrlOverride, + __basePath, + ), + previousNextUrl: previousNextUrlOverride, + }; } - return Object.keys(nextState).length > 0 ? nextState : null; -} - -function getRequestInterceptionContext(navigationKind: NavigationKind): string | null { switch (navigationKind) { case "navigate": - return getCurrentInterceptionContext(); - case "traverse": - return readHistoryStateInterceptionContext(window.history.state); + return { + interceptionContext: getCurrentInterceptionContext(), + previousNextUrl: getCurrentNextUrl(), + }; + case "traverse": { + const previousNextUrl = readHistoryStatePreviousNextUrl(window.history.state); + return { + interceptionContext: resolveInterceptionContextFromPreviousNextUrl( + previousNextUrl, + __basePath, + ), + previousNextUrl, + }; + } case "refresh": - return null; + return { + interceptionContext: resolveInterceptionContextFromPreviousNextUrl( + getBrowserRouterState().previousNextUrl, + __basePath, + ), + previousNextUrl: getBrowserRouterState().previousNextUrl, + }; default: { const _exhaustive: never = navigationKind; throw new Error("[vinext] Unknown navigation kind: " + String(_exhaustive)); @@ -434,6 +441,7 @@ async function commitSameUrlNavigatePayload( pending.action.renderId, "navigate", pending.interceptionContext, + pending.previousNextUrl, pending.routeId, pending.rootLayoutTreePath, false, @@ -467,6 +475,7 @@ function BrowserRoot({ elements: resolvedElements, interceptionContext: initialMetadata.interceptionContext, navigationSnapshot: initialNavigationSnapshot, + previousNextUrl: null, renderId: 0, rootLayoutTreePath: initialMetadata.rootLayoutTreePath, routeId: initialMetadata.routeId, @@ -510,14 +519,11 @@ function BrowserRoot({ } replaceHistoryStateWithoutNotify( - createHistoryStateWithInterceptionContext( - window.history.state, - treeState.interceptionContext, - ), + createHistoryStateWithPreviousNextUrl(window.history.state, treeState.previousNextUrl), "", window.location.href, ); - }, [treeState.interceptionContext, treeState.renderId]); + }, [treeState.previousNextUrl, treeState.renderId]); const committedTree = createElement( NavigationCommitSignal, @@ -547,6 +553,7 @@ function dispatchBrowserTree( renderId: number, actionType: "navigate" | "replace" | "traverse", interceptionContext: string | null, + previousNextUrl: string | null, routeId: string, rootLayoutTreePath: string | null, useTransitionMode: boolean, @@ -558,6 +565,7 @@ function dispatchBrowserTree( elements, interceptionContext, navigationSnapshot, + previousNextUrl, renderId, rootLayoutTreePath, routeId, @@ -578,6 +586,7 @@ async function renderNavigationPayload( navId: number, historyUpdateMode: HistoryUpdateMode | undefined, params: Record, + previousNextUrl: string | null, useTransition = true, actionType: "navigate" | "replace" | "traverse" = "navigate", ): Promise { @@ -593,6 +602,7 @@ async function renderNavigationPayload( currentState, nextElements: payload, navigationSnapshot, + previousNextUrl, renderId, type: actionType, }); @@ -619,12 +629,7 @@ async function renderNavigationPayload( queuePrePaintNavigationEffect( renderId, - createNavigationCommitEffect( - targetHref, - historyUpdateMode, - params, - pending.interceptionContext, - ), + createNavigationCommitEffect(targetHref, historyUpdateMode, params, pending.previousNextUrl), ); activateNavigationSnapshot(); snapshotActivated = true; @@ -634,6 +639,7 @@ async function renderNavigationPayload( renderId, actionType, pending.interceptionContext, + pending.previousNextUrl, pending.routeId, pending.rootLayoutTreePath, useTransition, @@ -813,6 +819,11 @@ async function main(): Promise { window.location.href, latestClientParams, ); + replaceHistoryStateWithoutNotify( + createHistoryStateWithPreviousNextUrl(window.history.state, null), + "", + window.location.href, + ); window.__VINEXT_RSC_ROOT__ = hydrateRoot( document, @@ -829,6 +840,7 @@ async function main(): Promise { redirectDepth = 0, navigationKind: NavigationKind = "navigate", historyUpdateMode?: HistoryUpdateMode, + previousNextUrlOverride?: string | null, ): Promise { if (redirectDepth > 10) { console.error( @@ -845,7 +857,9 @@ async function main(): Promise { try { const url = new URL(href, window.location.origin); const rscUrl = toRscUrl(url.pathname + url.search); - const requestInterceptionContext = getRequestInterceptionContext(navigationKind); + const requestState = getRequestState(navigationKind, previousNextUrlOverride); + const requestInterceptionContext = requestState.interceptionContext; + const requestPreviousNextUrl = requestState.previousNextUrl; // Use startTransition for same-route navigations (searchParam changes) // so React keeps the old UI visible during the transition. For cross-route // navigations (different pathname), use synchronous updates — React's @@ -895,6 +909,7 @@ async function main(): Promise { navId, historyUpdateMode, cachedParams, + requestPreviousNextUrl, isSameRoute, toActionType(navigationKind), ); @@ -942,7 +957,7 @@ async function main(): Promise { if (finalUrl.pathname !== requestedUrl.pathname) { const destinationPath = finalUrl.pathname.replace(/\.rsc$/, "") + finalUrl.search; replaceHistoryStateWithoutNotify( - createHistoryStateWithInterceptionContext(null, requestInterceptionContext), + createHistoryStateWithPreviousNextUrl(null, requestPreviousNextUrl), "", destinationPath, ); @@ -956,7 +971,13 @@ async function main(): Promise { // The URL has already been updated via replaceHistoryStateWithoutNotify above, // so the recursive navigation should NOT push/replace again. Pass undefined // for historyUpdateMode to make the commit effect a no-op for history updates. - return navigate(destinationPath, redirectDepth + 1, navigationKind, undefined); + return navigate( + destinationPath, + redirectDepth + 1, + navigationKind, + undefined, + requestPreviousNextUrl, + ); } let navParams: Record = {}; @@ -993,6 +1014,7 @@ async function main(): Promise { navId, historyUpdateMode, navParams, + requestPreviousNextUrl, isSameRoute, toActionType(navigationKind), ); @@ -1088,6 +1110,7 @@ async function main(): Promise { pending.action.renderId, "replace", pending.interceptionContext, + pending.previousNextUrl, pending.routeId, pending.rootLayoutTreePath, false, diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts index 743bee33..dffd6752 100644 --- a/packages/vinext/src/server/app-browser-state.ts +++ b/packages/vinext/src/server/app-browser-state.ts @@ -1,10 +1,18 @@ import { mergeElements } from "../shims/slot.js"; +import { stripBasePath } from "../utils/base-path.js"; import { readAppElementsMetadata, type AppElements } from "./app-elements.js"; import type { ClientNavigationRenderSnapshot } from "../shims/navigation.js"; +const VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY = "__vinext_previousNextUrl"; + +type HistoryStateRecord = { + [key: string]: unknown; +}; + export type AppRouterState = { elements: AppElements; interceptionContext: string | null; + previousNextUrl: string | null; renderId: number; navigationSnapshot: ClientNavigationRenderSnapshot; rootLayoutTreePath: string | null; @@ -15,6 +23,7 @@ export type AppRouterAction = { elements: AppElements; interceptionContext: string | null; navigationSnapshot: ClientNavigationRenderSnapshot; + previousNextUrl: string | null; renderId: number; rootLayoutTreePath: string | null; routeId: string; @@ -24,6 +33,7 @@ export type AppRouterAction = { export type PendingNavigationCommit = { action: AppRouterAction; interceptionContext: string | null; + previousNextUrl: string | null; rootLayoutTreePath: string | null; routeId: string; }; @@ -34,6 +44,50 @@ export type ClassifiedPendingNavigationCommit = { pending: PendingNavigationCommit; }; +function cloneHistoryState(state: unknown): HistoryStateRecord { + if (!state || typeof state !== "object") { + return {}; + } + + const nextState: HistoryStateRecord = {}; + for (const [key, value] of Object.entries(state)) { + nextState[key] = value; + } + return nextState; +} + +export function createHistoryStateWithPreviousNextUrl( + state: unknown, + previousNextUrl: string | null, +): HistoryStateRecord | null { + const nextState = cloneHistoryState(state); + + if (previousNextUrl === null) { + delete nextState[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY]; + } else { + nextState[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY] = previousNextUrl; + } + + return Object.keys(nextState).length > 0 ? nextState : null; +} + +export function readHistoryStatePreviousNextUrl(state: unknown): string | null { + const value = cloneHistoryState(state)[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY]; + return typeof value === "string" ? value : null; +} + +export function resolveInterceptionContextFromPreviousNextUrl( + previousNextUrl: string | null, + basePath: string = "", +): string | null { + if (previousNextUrl === null) { + return null; + } + + const parsedUrl = new URL(previousNextUrl, "http://localhost"); + return stripBasePath(parsedUrl.pathname, basePath); +} + export function routerReducer(state: AppRouterState, action: AppRouterAction): AppRouterState { switch (action.type) { case "traverse": @@ -42,6 +96,7 @@ export function routerReducer(state: AppRouterState, action: AppRouterAction): A elements: mergeElements(state.elements, action.elements, action.type === "traverse"), interceptionContext: action.interceptionContext, navigationSnapshot: action.navigationSnapshot, + previousNextUrl: action.previousNextUrl, renderId: action.renderId, rootLayoutTreePath: action.rootLayoutTreePath, routeId: action.routeId, @@ -51,6 +106,7 @@ export function routerReducer(state: AppRouterState, action: AppRouterAction): A elements: action.elements, interceptionContext: action.interceptionContext, navigationSnapshot: action.navigationSnapshot, + previousNextUrl: action.previousNextUrl, renderId: action.renderId, rootLayoutTreePath: action.rootLayoutTreePath, routeId: action.routeId, @@ -97,17 +153,20 @@ export async function createPendingNavigationCommit(options: { currentState: AppRouterState; nextElements: Promise; navigationSnapshot: ClientNavigationRenderSnapshot; + previousNextUrl?: string | null; renderId: number; type: "navigate" | "replace" | "traverse"; }): Promise { const elements = await options.nextElements; const metadata = readAppElementsMetadata(elements); + const previousNextUrl = options.previousNextUrl ?? options.currentState.previousNextUrl; return { action: { elements, interceptionContext: metadata.interceptionContext, navigationSnapshot: options.navigationSnapshot, + previousNextUrl, renderId: options.renderId, rootLayoutTreePath: metadata.rootLayoutTreePath, routeId: metadata.routeId, @@ -115,6 +174,7 @@ export async function createPendingNavigationCommit(options: { }, // Convenience aliases — always equal action.interceptionContext / action.rootLayoutTreePath / action.routeId. interceptionContext: metadata.interceptionContext, + previousNextUrl, rootLayoutTreePath: metadata.rootLayoutTreePath, routeId: metadata.routeId, }; @@ -125,6 +185,7 @@ export async function resolveAndClassifyNavigationCommit(options: { currentState: AppRouterState; navigationSnapshot: ClientNavigationRenderSnapshot; nextElements: Promise; + previousNextUrl?: string | null; renderId: number; startedNavigationId: number; type: "navigate" | "replace" | "traverse"; @@ -133,6 +194,7 @@ export async function resolveAndClassifyNavigationCommit(options: { currentState: options.currentState, nextElements: options.nextElements, navigationSnapshot: options.navigationSnapshot, + previousNextUrl: options.previousNextUrl, renderId: options.renderId, type: options.type, }); diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index ccbc2197..371692d4 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -297,6 +297,14 @@ export function getCurrentInterceptionContext(): string | null { ); } +export function getCurrentNextUrl(): string { + if (isServer) { + return "/"; + } + + return window.location.pathname + window.location.search; +} + /** Get or create the shared in-memory RSC prefetch cache on window. */ export function getPrefetchCache(): Map { if (isServer) return new Map(); diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index d79a7efb..694771ae 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -12,8 +12,11 @@ import { } from "../packages/vinext/src/server/app-elements.js"; import { createClientNavigationRenderSnapshot } from "../packages/vinext/src/shims/navigation.js"; import { + createHistoryStateWithPreviousNextUrl, createPendingNavigationCommit, + readHistoryStatePreviousNextUrl, resolveAndClassifyNavigationCommit, + resolveInterceptionContextFromPreviousNextUrl, routerReducer, resolvePendingNavigationCommitDisposition, shouldHardNavigate, @@ -40,6 +43,7 @@ function createState(overrides: Partial = {}): AppRouterState { navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/initial", {}), renderId: 0, interceptionContext: null, + previousNextUrl: null, rootLayoutTreePath: "/", routeId: "route:/initial", ...overrides, @@ -73,6 +77,7 @@ describe("app browser entry state helpers", () => { elements: nextElements, interceptionContext: null, navigationSnapshot: createState().navigationSnapshot, + previousNextUrl: null, renderId: 1, rootLayoutTreePath: "/", routeId: "route:/next", @@ -82,6 +87,7 @@ describe("app browser entry state helpers", () => { expect(nextState.routeId).toBe("route:/next"); expect(nextState.interceptionContext).toBeNull(); + expect(nextState.previousNextUrl).toBeNull(); expect(nextState.rootLayoutTreePath).toBe("/"); expect(nextState.elements).toMatchObject({ "layout:/": expect.anything(), @@ -98,6 +104,7 @@ describe("app browser entry state helpers", () => { elements: nextElements, interceptionContext: null, navigationSnapshot: createState().navigationSnapshot, + previousNextUrl: null, renderId: 1, rootLayoutTreePath: "/", routeId: "route:/next", @@ -106,6 +113,7 @@ describe("app browser entry state helpers", () => { expect(nextState.elements).toBe(nextElements); expect(nextState.interceptionContext).toBeNull(); + expect(nextState.previousNextUrl).toBeNull(); expect(nextState.elements).toMatchObject({ "page:/next": expect.anything(), }); @@ -120,13 +128,16 @@ describe("app browser entry state helpers", () => { }), ), navigationSnapshot: createState().navigationSnapshot, + previousNextUrl: "/feed", renderId: 1, type: "navigate", }); expect(pending.routeId).toBe("route:/photos/42\0/feed"); expect(pending.interceptionContext).toBe("/feed"); + expect(pending.previousNextUrl).toBe("/feed"); expect(pending.action.interceptionContext).toBe("/feed"); + expect(pending.action.previousNextUrl).toBe("/feed"); }); it("hard navigates instead of merging when the root layout changes", async () => { @@ -213,6 +224,7 @@ describe("app browser entry state helpers", () => { currentState: createState(), nextElements: Promise.resolve(createResolvedElements("route:/dashboard", "/")), navigationSnapshot: createState().navigationSnapshot, + previousNextUrl: "/feed", renderId: 1, type: "navigate", }); @@ -220,6 +232,70 @@ describe("app browser entry state helpers", () => { expect(refreshCommit.action.type).toBe("navigate"); expect(refreshCommit.routeId).toBe("route:/dashboard"); expect(refreshCommit.rootLayoutTreePath).toBe("/"); + expect(refreshCommit.previousNextUrl).toBe("/feed"); + }); + + it("stores previousNextUrl on navigate actions", () => { + const nextState = routerReducer(createState(), { + elements: createResolvedElements("route:/photos/42\0/feed", "/", "/feed"), + interceptionContext: "/feed", + navigationSnapshot: createState().navigationSnapshot, + previousNextUrl: "/feed", + renderId: 1, + rootLayoutTreePath: "/", + routeId: "route:/photos/42\0/feed", + type: "navigate", + }); + + expect(nextState.interceptionContext).toBe("/feed"); + expect(nextState.previousNextUrl).toBe("/feed"); + }); +}); + +describe("app browser entry previousNextUrl helpers", () => { + it("stores previousNextUrl alongside existing history state", () => { + expect( + createHistoryStateWithPreviousNextUrl( + { + __vinext_scrollY: 120, + }, + "/feed?tab=latest", + ), + ).toEqual({ + __vinext_previousNextUrl: "/feed?tab=latest", + __vinext_scrollY: 120, + }); + }); + + it("drops previousNextUrl when cleared", () => { + expect( + createHistoryStateWithPreviousNextUrl( + { + __vinext_previousNextUrl: "/feed", + __vinext_scrollY: 120, + }, + null, + ), + ).toEqual({ + __vinext_scrollY: 120, + }); + }); + + it("reads previousNextUrl from history state", () => { + expect( + readHistoryStatePreviousNextUrl({ + __vinext_previousNextUrl: "/feed?tab=latest", + }), + ).toBe("/feed?tab=latest"); + }); + + it("derives interception context from previousNextUrl pathname", () => { + expect(resolveInterceptionContextFromPreviousNextUrl("/feed?tab=latest")).toBe("/feed"); + }); + + it("returns null when previousNextUrl is missing", () => { + expect(readHistoryStatePreviousNextUrl({})).toBeNull(); + expect(resolveInterceptionContextFromPreviousNextUrl(null)).toBeNull(); }); it("classifies pending commits in one step for same-url payloads", async () => { @@ -260,6 +336,7 @@ describe("app browser entry state helpers", () => { elements: nextElements, interceptionContext: null, navigationSnapshot: createState().navigationSnapshot, + previousNextUrl: null, renderId: 1, rootLayoutTreePath: "/", routeId: "route:/feed", @@ -281,6 +358,7 @@ describe("app browser entry state helpers", () => { elements: nextElements, interceptionContext: null, navigationSnapshot: createState().navigationSnapshot, + previousNextUrl: null, renderId: 1, rootLayoutTreePath: "/", routeId: "route:/feed/comments", diff --git a/tests/e2e/app-router/advanced.spec.ts b/tests/e2e/app-router/advanced.spec.ts index 88b781b4..7b940945 100644 --- a/tests/e2e/app-router/advanced.spec.ts +++ b/tests/e2e/app-router/advanced.spec.ts @@ -120,6 +120,36 @@ test.describe("Intercepting Routes", () => { await expect(page.locator('[data-testid="photo-modal"]')).not.toBeVisible(); }); + test("hard reload after intercepted navigation renders the full page", async ({ page }) => { + await page.goto(`${BASE}/feed`); + await waitForAppRouterHydration(page); + + await page.click("#feed-photo-42-link"); + await expect(page.locator('[data-testid="photo-modal"]')).toBeVisible(); + await expect(page.locator('[data-testid="feed-page"]')).toBeVisible(); + + await page.reload(); + await waitForAppRouterHydration(page); + + await expect(page.locator('[data-testid="photo-page"]')).toBeVisible(); + await expect(page.locator('[data-testid="photo-modal"]')).not.toBeVisible(); + await expect(page.locator('[data-testid="feed-page"]')).not.toBeVisible(); + }); + + test("router.refresh preserves intercepted modal view", async ({ page }) => { + await page.goto(`${BASE}/feed`); + await waitForAppRouterHydration(page); + + await page.click("#feed-photo-42-link"); + await expect(page.locator('[data-testid="photo-modal"]')).toBeVisible(); + + await page.click('[data-testid="photo-modal-refresh"]'); + + await expect(page.locator('[data-testid="photo-modal"]')).toBeVisible(); + await expect(page.locator('[data-testid="feed-page"]')).toBeVisible(); + await expect(page.locator('[data-testid="photo-page"]')).not.toBeVisible(); + }); + test("prefetches keep separate cache entries for feed and gallery interception contexts", async ({ page, }) => { diff --git a/tests/fixtures/app-basic/app/feed/@modal/(...)photos/[id]/page.tsx b/tests/fixtures/app-basic/app/feed/@modal/(...)photos/[id]/page.tsx index 3de76a92..46fdbf71 100644 --- a/tests/fixtures/app-basic/app/feed/@modal/(...)photos/[id]/page.tsx +++ b/tests/fixtures/app-basic/app/feed/@modal/(...)photos/[id]/page.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { PhotoModalRefreshButton } from "./refresh-button"; // Intercepting route: renders when navigating from /feed to /photos/[id]. // Shows a modal version of the photo instead of the full page. @@ -10,6 +11,7 @@ export default function PhotoModal({ params }: { params: { id: string } }) { Next Photo + ); } diff --git a/tests/fixtures/app-basic/app/feed/@modal/(...)photos/[id]/refresh-button.tsx b/tests/fixtures/app-basic/app/feed/@modal/(...)photos/[id]/refresh-button.tsx new file mode 100644 index 00000000..e974df5b --- /dev/null +++ b/tests/fixtures/app-basic/app/feed/@modal/(...)photos/[id]/refresh-button.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +export function PhotoModalRefreshButton() { + const router = useRouter(); + + return ( + + ); +} From 64342b1d4b021cd4d986a5ed24c9ff4d4af3e036 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 10:20:37 +0000 Subject: [PATCH 3/7] refactor: bind getBrowserRouterState() once in refresh case Avoid calling getBrowserRouterState() twice in the refresh branch of getRequestState by binding previousNextUrl to a local. Behavior-preserving nit raised in PR review. --- packages/vinext/src/server/app-browser-entry.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 3aa3cfd7..eec32069 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -334,14 +334,16 @@ function getRequestState( previousNextUrl, }; } - case "refresh": + case "refresh": { + const currentPreviousNextUrl = getBrowserRouterState().previousNextUrl; return { interceptionContext: resolveInterceptionContextFromPreviousNextUrl( - getBrowserRouterState().previousNextUrl, + currentPreviousNextUrl, __basePath, ), - previousNextUrl: getBrowserRouterState().previousNextUrl, + previousNextUrl: currentPreviousNextUrl, }; + } default: { const _exhaustive: never = navigationKind; throw new Error("[vinext] Unknown navigation kind: " + String(_exhaustive)); From 8974afb300af3b0659df4d5fa28d8e769d86116b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 10:24:58 +0000 Subject: [PATCH 4/7] refactor: drop dead history-state interception-context read path After previousNextUrl became the source of truth for interception context in history state, nothing in production writes the legacy __vinext_interceptionContext key anymore. The fall-through read in getCurrentInterceptionContext was vestigial and getCurrentInterceptionContext now derives strictly from the current pathname. - Removes the unused VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY export - Removes readHistoryStateInterceptionContext - Updates the prefetch-cache unit test to assert the new pathname-derived contract instead of the legacy history-state override --- packages/vinext/src/shims/navigation.ts | 16 +--------------- tests/prefetch-cache.test.ts | 7 ++----- 2 files changed, 3 insertions(+), 20 deletions(-) diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 371692d4..a7b2dc23 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -244,8 +244,6 @@ export const MAX_PREFETCH_CACHE_SIZE = 50; /** TTL for prefetch cache entries in ms (matches Next.js static prefetch TTL). */ export const PREFETCH_CACHE_TTL = 30_000; -export const VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY = "__vinext_interceptionContext"; - /** A buffered RSC response stored as an ArrayBuffer for replay. */ export type CachedRscResponse = { buffer: ArrayBuffer; @@ -277,24 +275,12 @@ export function toRscUrl(href: string): string { return normalizedPath + ".rsc" + query; } -export function readHistoryStateInterceptionContext(state: unknown): string | null { - if (!state || typeof state !== "object") { - return null; - } - - const value = Reflect.get(state, VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY); - return typeof value === "string" ? value : null; -} - export function getCurrentInterceptionContext(): string | null { if (isServer) { return null; } - return ( - readHistoryStateInterceptionContext(window.history.state) ?? - stripBasePath(window.location.pathname, __basePath) - ); + return stripBasePath(window.location.pathname, __basePath); } export function getCurrentNextUrl(): string { diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts index 59e74289..693864be 100644 --- a/tests/prefetch-cache.test.ts +++ b/tests/prefetch-cache.test.ts @@ -115,11 +115,8 @@ describe("prefetch cache eviction", () => { expect(prefetched.has(rscUrl)).toBe(false); }); - it("reuses the committed interception context for soft navigations", () => { - (globalThis as any).window.location.pathname = "/photos/42"; - (globalThis as any).window.history.state = { - __vinext_interceptionContext: "/feed", - }; + it("derives the interception context from the current pathname", () => { + (globalThis as any).window.location.pathname = "/feed"; expect(getCurrentInterceptionContext()).toBe("/feed"); }); From 873cb626e02daa075aee0dffcf5efac00a4a99a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 10:25:06 +0000 Subject: [PATCH 5/7] test(e2e): cover back/forward restoration of intercepted modal view Adds the back-then-forward case for App Router intercepting routes: after intercepted navigation from /feed to /photos/42, going back to /feed and then forward should restore the modal (rather than rendering the full /photos/42 page). Directly exercises the previousNextUrl read in the traverse branch of getRequestState. --- tests/e2e/app-router/advanced.spec.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/e2e/app-router/advanced.spec.ts b/tests/e2e/app-router/advanced.spec.ts index 7b940945..d943e2ed 100644 --- a/tests/e2e/app-router/advanced.spec.ts +++ b/tests/e2e/app-router/advanced.spec.ts @@ -150,6 +150,24 @@ test.describe("Intercepting Routes", () => { await expect(page.locator('[data-testid="photo-page"]')).not.toBeVisible(); }); + test("back then forward restores intercepted modal view", async ({ page }) => { + await page.goto(`${BASE}/feed`); + await waitForAppRouterHydration(page); + + await page.click("#feed-photo-42-link"); + await expect(page.locator('[data-testid="photo-modal"]')).toBeVisible(); + await expect(page.locator('[data-testid="feed-page"]')).toBeVisible(); + + await page.goBack(); + await expect(page.locator('[data-testid="photo-modal"]')).not.toBeVisible(); + await expect(page.locator('[data-testid="feed-page"]')).toBeVisible(); + + await page.goForward(); + await expect(page.locator('[data-testid="photo-modal"]')).toBeVisible(); + await expect(page.locator('[data-testid="feed-page"]')).toBeVisible(); + await expect(page.locator('[data-testid="photo-page"]')).not.toBeVisible(); + }); + test("prefetches keep separate cache entries for feed and gallery interception contexts", async ({ page, }) => { From fae429fbdabf26dee35b399af18253d08875d874 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 10:40:04 +0000 Subject: [PATCH 6/7] docs: reword stale 'belongs to PR 5' comments Two inline comments in app-browser-entry.ts referenced "PR 5" as a future PR, but this is PR 5. Reworded to describe the deferred scope as-is: interception context on server-action POSTs and HMR re-renders is intentionally not propagated. --- packages/vinext/src/server/app-browser-entry.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index eec32069..19835073 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -754,8 +754,9 @@ function registerServerActionCallback(): void { const temporaryReferences = createTemporaryReferenceSet(); const body = await encodeReply(args, { temporaryReferences }); - // Intentionally omit interception context for server action re-renders in - // this PR. Durable intercepted refresh/action parity belongs to PR 5. + // Interception context on server-action re-renders is intentionally + // deferred: action POSTs always target the current URL's full page without + // propagating the source-route provenance. const fetchResponse = await fetch(toRscUrl(window.location.pathname + window.location.search), { method: "POST", headers: { "x-rsc-action": id }, @@ -1093,8 +1094,9 @@ async function main(): Promise { window.location.href, latestClientParams, ); - // Intentionally omit interception context for HMR re-renders. - // Preserving intercepted modal state across HMR belongs to PR 5. + // Interception context on HMR re-renders is intentionally deferred: + // preserving intercepted modal state across HMR reloads is out of scope + // for the previousNextUrl mechanism. const pending = await createPendingNavigationCommit({ currentState: getBrowserRouterState(), nextElements: normalizeAppElementsPromise( From 96dad49e1bdebacc43003c059c246bfa0aff813e Mon Sep 17 00:00:00 2001 From: James Date: Sun, 12 Apr 2026 13:56:49 +0100 Subject: [PATCH 7/7] fix: distinguish explicit null from undefined in previousNextUrl fallback --- .../vinext/src/server/app-browser-state.ts | 5 +++- tests/app-browser-entry.test.ts | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts index dffd6752..a7008570 100644 --- a/packages/vinext/src/server/app-browser-state.ts +++ b/packages/vinext/src/server/app-browser-state.ts @@ -159,7 +159,10 @@ export async function createPendingNavigationCommit(options: { }): Promise { const elements = await options.nextElements; const metadata = readAppElementsMetadata(elements); - const previousNextUrl = options.previousNextUrl ?? options.currentState.previousNextUrl; + const previousNextUrl = + options.previousNextUrl !== undefined + ? options.previousNextUrl + : options.currentState.previousNextUrl; return { action: { diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index 694771ae..b3fae1b4 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -140,6 +140,30 @@ describe("app browser entry state helpers", () => { expect(pending.action.previousNextUrl).toBe("/feed"); }); + it("clears previousNextUrl when traversing to a non-intercepted entry", async () => { + // Traversing back from an intercepted modal (/photos/42 from /feed) to + // /feed itself. The traverse branch reads null from /feed's history state + // and passes previousNextUrl: null explicitly — meaning "not intercepted". + // This must not inherit the current state's stale "/feed" value. + const interceptedState = createState({ + interceptionContext: "/feed", + previousNextUrl: "/feed", + routeId: "route:/photos/42\0/feed", + }); + + const pending = await createPendingNavigationCommit({ + currentState: interceptedState, + nextElements: Promise.resolve(createResolvedElements("route:/feed", "/")), + navigationSnapshot: createState().navigationSnapshot, + previousNextUrl: null, + renderId: 2, + type: "traverse", + }); + + expect(pending.previousNextUrl).toBeNull(); + expect(pending.action.previousNextUrl).toBeNull(); + }); + it("hard navigates instead of merging when the root layout changes", async () => { const currentState = createState({ rootLayoutTreePath: "/(marketing)",