From 0df1dcf9b333ca44a624580e868b20b735a5b948 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 11 Aug 2026 00:43:35 +0100 Subject: [PATCH 1/8] fix(app-router): match soft navigation identity --- .../vinext/src/server/app-browser-entry.ts | 9 +++ .../src/server/app-browser-visible-commit.ts | 43 ++++++++++++- .../vinext/src/server/navigation-planner.ts | 11 ++++ .../vinext/src/server/navigation-trace.ts | 2 + packages/vinext/src/shims/navigation.ts | 17 +++++ tests/app-browser-entry.test.ts | 62 +++++++++++++++++++ .../nextjs-compat/soft-navigation.spec.ts | 44 +++++++++++++ .../nextjs-compat/template-navigation.spec.ts | 16 +++++ .../app/nextjs-compat/link-soft-push/page.tsx | 9 +++ .../nextjs-compat/link-soft-replace/page.tsx | 14 +++++ .../nextjs-compat/link-soft-target/page.tsx | 5 ++ .../template-server/template.tsx | 3 + tests/navigation-planner-early-intent.test.ts | 7 ++- tests/prefetch-cache.test.ts | 38 ++++++++++++ 14 files changed, 277 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/app-router/nextjs-compat/soft-navigation.spec.ts create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/link-soft-push/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/link-soft-replace/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/link-soft-target/page.tsx diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index f5be6d0cd6..7ef2c979c3 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -28,6 +28,7 @@ import { createCachedRscResponseSnapshot, createClientNavigationRenderSnapshot, deletePrefetchResponseSnapshot, + disableNavigationResponsePrefetchCacheReuse, DYNAMIC_NAVIGATION_CACHE_TTL, PREFETCH_CACHE_TTL, getClientNavigationRenderContext, @@ -390,6 +391,14 @@ function restoreHistoryStateSnapshot( }); if (!restored) return false; + // History entries restore their own visible tree, but a later Link click is + // a new navigation. Drop response snapshots published by the route we just + // left while retaining explicit prefetches. Advance the generation first so + // an async publication already waiting on a response body cannot repopulate + // the departed response after the maps are cleared. + clientNavigationCacheGeneration += 1; + clearVisitedResponseCache(); + disableNavigationResponsePrefetchCacheReuse(); commitClientNavigationState(navId, { releaseSnapshot: false }); return true; } diff --git a/packages/vinext/src/server/app-browser-visible-commit.ts b/packages/vinext/src/server/app-browser-visible-commit.ts index 2dc0f93c19..2314e9f831 100644 --- a/packages/vinext/src/server/app-browser-visible-commit.ts +++ b/packages/vinext/src/server/app-browser-visible-commit.ts @@ -1,7 +1,9 @@ import type { ClientNavigationRenderSnapshot } from "vinext/shims/navigation"; import type { RouteManifest } from "../routing/app-route-graph.js"; import { mergeElements } from "vinext/shims/slot"; +import { resolveAppPageRouteStateKey } from "./app-page-segment-state.js"; import { + AppElementsWire, normalizeAppElementsSlotBindings, type AppElements, type AppElementsSlotBinding, @@ -72,6 +74,21 @@ type ClassifiedPendingNavigationCommit = { trace: NavigationTrace; }; +function hasSameServerTemplateSeedIdentity( + id: string, + currentSnapshot: ClientNavigationRenderSnapshot, + nextSnapshot: ClientNavigationRenderSnapshot, +): boolean { + const parsed = AppElementsWire.parseElementKey(id); + if (parsed?.kind !== "template") return false; + + const templateSegments = parsed.treePath.split("/").filter(Boolean); + return ( + resolveAppPageRouteStateKey(templateSegments, currentSnapshot.params) === + resolveAppPageRouteStateKey(templateSegments, nextSnapshot.params) + ); +} + export function applyApprovedVisibleCommit( state: AppRouterState, commit: ApprovedVisibleCommit, @@ -206,10 +223,34 @@ function reduceApprovedVisibleCommitState( action.operation.lane === "hmr" ? hmrUniquePreserveElementIds : bfcacheCompatiblePreserveElementIds; + // Next stores a server template's rendered seed on the persistent layout + // router. The template's child-segment state key may change to remount + // client state without replacing that seed. Compare only params bound by + // the template's own route prefix; refresh/HMR or a changed owner param + // still installs fresh server output. + const preserveTemplateIds = + action.reuseCurrentBfcacheIds && + (action.operation.lane === "navigation" || + action.operation.lane === "traverse" || + action.operation.lane === "server-action") + ? Object.keys(state.elements).filter( + (id) => + Object.hasOwn(action.elements, id) && + hasSameServerTemplateSeedIdentity( + id, + state.navigationSnapshot, + action.navigationSnapshot, + ), + ) + : []; + const mergedPreserveElementIds = + preserveTemplateIds.length === 0 + ? preserveElementIds + : [...new Set([...preserveElementIds, ...preserveTemplateIds])]; const mergedElements = mergeElements(state.elements, action.elements, { clearAbsentSlots: action.type === "traverse" || !action.reuseCurrentBfcacheIds, preserveAbsentSlots: action.reuseCurrentBfcacheIds && commit.decision.preserveAbsentSlots, - preserveElementIds, + preserveElementIds: mergedPreserveElementIds, preservePreviousSlotIds, }); return commitVisibleRouterState( diff --git a/packages/vinext/src/server/navigation-planner.ts b/packages/vinext/src/server/navigation-planner.ts index 388d81d064..0eebb374ed 100644 --- a/packages/vinext/src/server/navigation-planner.ts +++ b/packages/vinext/src/server/navigation-planner.ts @@ -653,6 +653,17 @@ function classifyEarlyNavigationIntent( }; } + // A Link to the exact current URL still invalidates the page segment in + // Next.js. Re-fetch Flight data instead of replaying the response that + // produced the page already on screen. + if (current.href === next.href) { + return { + bypassNavigationCache: true, + kind: "flightNavigation", + trace: createEarlyNavigationIntentTrace(NavigationTraceReasonCodes.samePageRefresh, facts), + }; + } + if (samePathname && !sameSearch) { return { bypassNavigationCache: true, diff --git a/packages/vinext/src/server/navigation-trace.ts b/packages/vinext/src/server/navigation-trace.ts index 51c1f8203f..7d802976db 100644 --- a/packages/vinext/src/server/navigation-trace.ts +++ b/packages/vinext/src/server/navigation-trace.ts @@ -29,6 +29,7 @@ export const NavigationTraceReasonCodes = { rscCompatibilityMismatch: "NC_RSC_COMPAT_MISMATCH", rscNavigationError: "NC_RSC_NAV_ERROR", sameDocumentScroll: "NC_SAME_DOC_SCROLL", + samePageRefresh: "NC_SAME_PAGE_REFRESH", samePageSearch: "NC_SAME_PAGE_SEARCH", serverActionRedirectCompatibilityMismatch: "NC_SA_REDIRECT_COMPAT", serverActionRscCompatibilityMismatch: "NC_SA_RSC_COMPAT", @@ -62,6 +63,7 @@ export const NavigationTraceReasonCodes = { rscCompatibilityMismatch: "NC_RSC_COMPAT_MISMATCH"; rscNavigationError: "NC_RSC_NAV_ERROR"; sameDocumentScroll: "NC_SAME_DOC_SCROLL"; + samePageRefresh: "NC_SAME_PAGE_REFRESH"; samePageSearch: "NC_SAME_PAGE_SEARCH"; serverActionRedirectCompatibilityMismatch: "NC_SA_REDIRECT_COMPAT"; serverActionRscCompatibilityMismatch: "NC_SA_RSC_COMPAT"; diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 23a60daabe..f1f672040f 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -1099,6 +1099,23 @@ export function invalidatePrefetchCache(): void { } } +/** + * Prevent completed navigation responses from becoming authoritative again + * after restoring a history snapshot. Explicit Link/router prefetches remain + * consumable, and the demoted responses remain available as optimistic route + * template sources. + */ +export function disableNavigationResponsePrefetchCacheReuse(): void { + for (const entry of new Set(getPrefetchCache().values())) { + if (entry.prefetchKind === undefined) { + entry.cacheForNavigation = false; + } + } + if (!isServer) { + getNavigationRuntime()?.functions.pingVisibleLinks?.(); + } +} + export function seedPrefetchResponseSnapshot( rscUrl: string, snapshot: CachedRscResponse, diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index c05a9696d1..4191763128 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -5690,6 +5690,68 @@ describe("app browser entry previousNextUrl helpers", () => { ]); }); + // Ported from Next.js: test/e2e/app-dir/app/index.test.ts + // https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/app/index.test.ts + it("reuses a matching server template seed when its child state identity changes", async () => { + const previousTemplate = React.createElement("h1", null, "template seed 1"); + const nextTemplate = React.createElement("h1", null, "template seed 2"); + const templateId = "template:/template/servercomponent"; + const state = createState({ + bfcacheIds: { [templateId]: "_b_1_" }, + elements: createResolvedElements("route:/template/page", "/", null, { + [APP_BFCACHE_SEGMENT_IDENTITIES_KEY]: { + [templateId]: '["template","template-graph","root","index"]', + }, + [templateId]: previousTemplate, + }), + routeId: "route:/template/page", + }); + + const nextState = await applyApprovedTestCommit(state, { + extraEntries: { + [APP_BFCACHE_SEGMENT_IDENTITIES_KEY]: { + [templateId]: '["template","template-graph","root","other"]', + }, + [templateId]: nextTemplate, + "page:/template/other": React.createElement("main", null, "other"), + }, + rootLayoutTreePath: "/", + routeId: "route:/template/other", + }); + + expect(nextState.bfcacheIds[templateId]).not.toBe(state.bfcacheIds[templateId]); + expect(nextState.elements[templateId]).toBe(previousTemplate); + }); + + it("installs fresh server template output when its own dynamic param changes", async () => { + const templateId = "template:/template/[section]"; + const previousTemplate = React.createElement("h1", null, "alpha template"); + const nextTemplate = React.createElement("h1", null, "beta template"); + const state = createState({ + elements: createResolvedElements("route:/template/[section]", "/", null, { + [templateId]: previousTemplate, + }), + navigationSnapshot: createClientNavigationRenderSnapshot( + "https://example.com/template/alpha", + { section: "alpha" }, + ), + routeId: "route:/template/[section]", + }); + + const nextState = await applyApprovedTestCommit(state, { + extraEntries: { [templateId]: nextTemplate }, + navigationSnapshot: createClientNavigationRenderSnapshot( + "https://example.com/template/beta", + { section: "beta" }, + ), + rootLayoutTreePath: "/", + routeId: "route:/template/[section]", + targetHref: "https://example.com/template/beta", + }); + + expect(nextState.elements[templateId]).toBe(nextTemplate); + }); + it("installs fresh same-layout output on refresh commits", async () => { const previousLayout = React.createElement("div", null, "previous layout"); const nextLayout = React.createElement("div", null, "refreshed layout"); diff --git a/tests/e2e/app-router/nextjs-compat/soft-navigation.spec.ts b/tests/e2e/app-router/nextjs-compat/soft-navigation.spec.ts new file mode 100644 index 0000000000..07fe950882 --- /dev/null +++ b/tests/e2e/app-router/nextjs-compat/soft-navigation.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from "@playwright/test"; +import { waitForAppRouterHydration } from "../../helpers"; + +const BASE = "http://localhost:4174"; + +// Ported from Next.js: test/e2e/app-dir/app/index.test.ts +// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/app/index.test.ts +test.describe("soft Link navigation", () => { + test("a new push after back does not replay the departed page response", async ({ page }) => { + await page.goto(`${BASE}/nextjs-compat/link-soft-push`); + await waitForAppRouterHydration(page); + await page.evaluate(() => { + (window as Window & { __vinextSoftPushTest?: number }).__vinextSoftPushTest = 1; + }); + + await page.getByTestId("soft-push-link").click(); + const firstId = await page.getByTestId("soft-push-render-id").textContent(); + expect(firstId).toBeTruthy(); + + await page.goBack(); + await page.getByTestId("soft-push-link").click(); + await expect(page.getByTestId("soft-push-render-id")).not.toHaveText(firstId!); + expect( + await page.evaluate( + () => (window as Window & { __vinextSoftPushTest?: number }).__vinextSoftPushTest, + ), + ).toBe(1); + }); + + test("an identical replace fetches fresh page output without adding history", async ({ + page, + }) => { + await page.goto(`${BASE}/nextjs-compat/link-soft-replace`); + await waitForAppRouterHydration(page); + + const firstId = await page.getByTestId("soft-replace-render-id").textContent(); + const historyLength = await page.evaluate(() => window.history.length); + expect(firstId).toBeTruthy(); + + await page.getByTestId("soft-replace-link").click(); + await expect(page.getByTestId("soft-replace-render-id")).not.toHaveText(firstId!); + expect(await page.evaluate(() => window.history.length)).toBe(historyLength); + }); +}); diff --git a/tests/e2e/app-router/nextjs-compat/template-navigation.spec.ts b/tests/e2e/app-router/nextjs-compat/template-navigation.spec.ts index 4d60f8f7ff..f3c4c4b54a 100644 --- a/tests/e2e/app-router/nextjs-compat/template-navigation.spec.ts +++ b/tests/e2e/app-router/nextjs-compat/template-navigation.spec.ts @@ -28,6 +28,9 @@ test.describe("template navigation", () => { await page.goto(`${BASE}/nextjs-compat/template-server/alpha`); await waitForAppRouterHydration(page); + const renderId = await page.getByTestId("server-template-render-id").textContent(); + expect(renderId).toBeTruthy(); + await page.getByTestId("server-template-identity-increment").click(); await expect(page.getByTestId("server-template-identity")).toHaveText("1"); @@ -38,9 +41,22 @@ test.describe("template navigation", () => { await page.getByTestId("server-template-child-link").click(); await expect(page.getByTestId("server-template-child-page")).toHaveText("Child alpha"); await expect(page.getByTestId("server-template-identity")).toHaveText("1"); + await expect(page.getByTestId("server-template-render-id")).toHaveText(renderId!); await page.getByTestId("server-template-param-link").click(); await expect(page.getByTestId("server-template-section-page")).toHaveText("Section beta"); await expect(page.getByTestId("server-template-identity")).toHaveText("0"); }); + + test("server template output stays stable from its index page to a child", async ({ page }) => { + await page.goto(`${BASE}/nextjs-compat/template-server`); + await waitForAppRouterHydration(page); + + const renderId = await page.getByTestId("server-template-render-id").textContent(); + expect(renderId).toBeTruthy(); + + await page.getByTestId("server-template-link").click(); + await expect(page.getByTestId("server-template-section-page")).toHaveText("Section alpha"); + await expect(page.getByTestId("server-template-render-id")).toHaveText(renderId!); + }); }); diff --git a/tests/fixtures/app-basic/app/nextjs-compat/link-soft-push/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/link-soft-push/page.tsx new file mode 100644 index 0000000000..300dde91f8 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/link-soft-push/page.tsx @@ -0,0 +1,9 @@ +import Link from "next/link"; + +export default function Page() { + return ( + + Target + + ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/link-soft-replace/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/link-soft-replace/page.tsx new file mode 100644 index 0000000000..d24de7e8c3 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/link-soft-replace/page.tsx @@ -0,0 +1,14 @@ +import Link from "next/link"; + +export const revalidate = 0; + +export default function Page() { + return ( + <> +

{crypto.randomUUID()}

+ + Refresh + + + ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/link-soft-target/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/link-soft-target/page.tsx new file mode 100644 index 0000000000..0ba2a64b36 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/link-soft-target/page.tsx @@ -0,0 +1,5 @@ +export const revalidate = 0; + +export default function Page() { + return

{crypto.randomUUID()}

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/template-server/template.tsx b/tests/fixtures/app-basic/app/nextjs-compat/template-server/template.tsx index ce629ab3d5..6ae7fc4940 100644 --- a/tests/fixtures/app-basic/app/nextjs-compat/template-server/template.tsx +++ b/tests/fixtures/app-basic/app/nextjs-compat/template-server/template.tsx @@ -1,9 +1,12 @@ import { TemplateIdentity } from "./template-identity"; export default function Template({ children }: { children: React.ReactNode }) { + const renderId = crypto.randomUUID(); + return ( <>

Server template

+ {renderId} {children} diff --git a/tests/navigation-planner-early-intent.test.ts b/tests/navigation-planner-early-intent.test.ts index 51ba94f230..6db947c003 100644 --- a/tests/navigation-planner-early-intent.test.ts +++ b/tests/navigation-planner-early-intent.test.ts @@ -136,13 +136,16 @@ describe("navigationPlanner early navigation intent classification", () => { expect(decision).toMatchObject({ kind: "flightNavigation", bypassNavigationCache: false }); }); - it("does not treat an identical URL as a same-document scroll", () => { + it("refreshes an identical URL without replaying the current page response", () => { const decision = classify({ currentHref: "https://example.com/docs?q=1", targetHref: "https://example.com/docs?q=1", }); - expect(decision).toMatchObject({ kind: "flightNavigation", bypassNavigationCache: false }); + expect(decision).toMatchObject({ kind: "flightNavigation", bypassNavigationCache: true }); + expectSingleTraceEntry(decision, NavigationTraceReasonCodes.samePageRefresh, { + targetHref: "https://example.com/docs?q=1", + }); }); it("treats hash removal as a flight navigation, not a same-document scroll", () => { diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts index b330babf69..85d5a599c5 100644 --- a/tests/prefetch-cache.test.ts +++ b/tests/prefetch-cache.test.ts @@ -42,6 +42,7 @@ let restoreRscResponse: Navigation["restoreRscResponse"]; let resolveCachedRscResponseTtlMs: Navigation["resolveCachedRscResponseTtlMs"]; let prefetchRscResponse: Navigation["prefetchRscResponse"]; let invalidatePrefetchCache: Navigation["invalidatePrefetchCache"]; +let disableNavigationResponsePrefetchCacheReuse: Navigation["disableNavigationResponsePrefetchCacheReuse"]; let hasPrefetchCacheEntryForNavigation: Navigation["hasPrefetchCacheEntryForNavigation"]; let hasSearchAgnosticPrefetchShellForRoute: Navigation["hasSearchAgnosticPrefetchShellForRoute"]; let peekPrefetchResponseForNavigation: Navigation["peekPrefetchResponseForNavigation"]; @@ -82,6 +83,7 @@ beforeEach(async () => { resolveCachedRscResponseTtlMs = nav.resolveCachedRscResponseTtlMs; prefetchRscResponse = nav.prefetchRscResponse; invalidatePrefetchCache = nav.invalidatePrefetchCache; + disableNavigationResponsePrefetchCacheReuse = nav.disableNavigationResponsePrefetchCacheReuse; hasPrefetchCacheEntryForNavigation = nav.hasPrefetchCacheEntryForNavigation; hasSearchAgnosticPrefetchShellForRoute = nav.hasSearchAgnosticPrefetchShellForRoute; peekPrefetchResponseForNavigation = nav.peekPrefetchResponseForNavigation; @@ -251,6 +253,42 @@ describe("prefetch cache eviction", () => { expect(secondInvalidate).toHaveBeenCalledTimes(1); }); + it("demotes completed navigation snapshots without discarding explicit prefetches", () => { + const cache = getPrefetchCache(); + const prefetched = getPrefetchedUrls(); + const navigationKey = "/departed.rsc"; + const prefetchKey = "/prefetched.rsc"; + const snapshot = { + buffer: new TextEncoder().encode("flight").buffer, + contentType: "text/x-component", + paramsHeader: null, + renderedPathAndSearch: null, + url: navigationKey, + }; + + cache.set(navigationKey, { + cacheForNavigation: true, + outcome: "cache-seeded", + snapshot, + timestamp: Date.now(), + }); + cache.set(prefetchKey, { + cacheForNavigation: true, + outcome: "cache-seeded", + prefetchKind: "navigation", + snapshot: { ...snapshot, url: prefetchKey }, + timestamp: Date.now(), + }); + prefetched.add(navigationKey); + prefetched.add(prefetchKey); + + disableNavigationResponsePrefetchCacheReuse(); + + expect(cache.get(navigationKey)?.cacheForNavigation).toBe(false); + expect(cache.get(prefetchKey)?.cacheForNavigation).toBe(true); + expect(prefetched).toEqual(new Set([navigationKey, prefetchKey])); + }); + it("reuses a prefetched response only when mounted-slot context matches", () => { const cache = getPrefetchCache(); const prefetched = getPrefetchedUrls(); From d8b454575a68d73a23f95395910cc6cdf91bf247 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 11 Aug 2026 00:55:59 +0100 Subject: [PATCH 2/8] fix(app-router): refresh exact links behind basePath --- .../vinext/src/server/navigation-planner.ts | 8 ++++--- .../soft-navigation.spec.ts | 19 +++++++++++++++ tests/navigation-planner-early-intent.test.ts | 23 +++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/app-router-encoded-basepath-i18n/soft-navigation.spec.ts diff --git a/packages/vinext/src/server/navigation-planner.ts b/packages/vinext/src/server/navigation-planner.ts index 0eebb374ed..302e149863 100644 --- a/packages/vinext/src/server/navigation-planner.ts +++ b/packages/vinext/src/server/navigation-planner.ts @@ -654,9 +654,11 @@ function classifyEarlyNavigationIntent( } // A Link to the exact current URL still invalidates the page segment in - // Next.js. Re-fetch Flight data instead of replaying the response that - // produced the page already on screen. - if (current.href === next.href) { + // Next.js. The committed App Router snapshot is base-stripped while a Link + // target retains basePath, so exact identity compares canonical URL parts + // rather than the raw href. Keep raw search/hash equality here: equivalent + // query encodings are the same page but not the exact same URL spelling. + if (samePathname && current.search === next.search && current.hash === next.hash) { return { bypassNavigationCache: true, kind: "flightNavigation", diff --git a/tests/e2e/app-router-encoded-basepath-i18n/soft-navigation.spec.ts b/tests/e2e/app-router-encoded-basepath-i18n/soft-navigation.spec.ts new file mode 100644 index 0000000000..7b7a45f0d9 --- /dev/null +++ b/tests/e2e/app-router-encoded-basepath-i18n/soft-navigation.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from "@playwright/test"; +import { waitForAppRouterHydration } from "../helpers"; + +const BASE_URL = process.env.VINEXT_BASEPATH_E2E_BASE_URL ?? ""; + +test("an exact-current Link refreshes page output behind basePath", async ({ page }) => { + await page.goto(`${BASE_URL}/docs/nextjs-compat/link-soft-replace`); + await waitForAppRouterHydration(page); + + const firstId = await page.getByTestId("soft-replace-render-id").textContent(); + const historyLength = await page.evaluate(() => window.history.length); + expect(firstId).toBeTruthy(); + + await page.getByTestId("soft-replace-link").click(); + + await expect(page.getByTestId("soft-replace-render-id")).not.toHaveText(firstId!); + await expect(page).toHaveURL(/\/docs\/nextjs-compat\/link-soft-replace$/); + expect(await page.evaluate(() => window.history.length)).toBe(historyLength); +}); diff --git a/tests/navigation-planner-early-intent.test.ts b/tests/navigation-planner-early-intent.test.ts index 6db947c003..f56058145a 100644 --- a/tests/navigation-planner-early-intent.test.ts +++ b/tests/navigation-planner-early-intent.test.ts @@ -148,6 +148,29 @@ describe("navigationPlanner early navigation intent classification", () => { }); }); + it("refreshes an exact current URL when the committed snapshot is base-stripped", () => { + const decision = classify({ + basePath: "/app", + currentHref: "https://example.com/docs?q=1", + targetHref: "https://example.com/app/docs?q=1", + }); + + expect(decision).toMatchObject({ kind: "flightNavigation", bypassNavigationCache: true }); + expectSingleTraceEntry(decision, NavigationTraceReasonCodes.samePageRefresh, { + targetHref: "https://example.com/app/docs?q=1", + }); + }); + + it("keeps an identical non-empty hash on the same-document scroll path", () => { + const decision = classify({ + basePath: "/app", + currentHref: "https://example.com/docs#section", + targetHref: "https://example.com/app/docs#section", + }); + + expect(decision).toMatchObject({ kind: "sameDocumentScroll", hash: "#section" }); + }); + it("treats hash removal as a flight navigation, not a same-document scroll", () => { const decision = classify({ currentHref: "https://example.com/docs#section", From 733ff1bdbf5fafa3304d6d887a8939d29f4cd0f4 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 11 Aug 2026 01:10:08 +0100 Subject: [PATCH 3/8] fix(app-router): preserve navigation URL spaces --- .../vinext/src/server/app-browser-entry.ts | 8 +-- .../vinext/src/server/navigation-planner.ts | 61 ++++++++++++------- packages/vinext/src/shims/navigation.ts | 1 + playwright.config.ts | 20 +++--- .../soft-navigation.spec.ts | 30 +++++++++ .../fixtures/app-basic/app/docs/foo/page.tsx | 14 +++++ .../nextjs-compat/link-soft-replace/page.tsx | 16 +++++ tests/navigation-planner-early-intent.test.ts | 37 ++++++++++- 8 files changed, 149 insertions(+), 38 deletions(-) create mode 100644 tests/fixtures/app-basic/app/docs/foo/page.tsx diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 7ef2c979c3..1d4f713622 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -811,10 +811,9 @@ function storeVisitedResponseSnapshot( }; } -// Build the absolute current-document href the early-intent planner compares -// against the navigation target. The committed snapshot carries a base-stripped -// pathname plus parsed search params; the planner re-strips the base (a no-op on -// an already-stripped path) so both sides reduce to the same canonical form. +// Build the absolute app-relative href the early-intent planner compares +// against the browser-space navigation target. The committed snapshot already +// has basePath stripped; its explicit URL-space tag prevents a second strip. function clientNavigationSnapshotHref(snapshot: ClientNavigationRenderSnapshot): string { return `${window.location.origin}${createSnapshotPathAndSearch(snapshot)}`; } @@ -1772,6 +1771,7 @@ function bootstrapHydration( navigationKind === "navigate" ? navigationPlanner.classifyEarlyNavigationIntent({ basePath: __basePath, + currentUrlSpace: "appRelativeSnapshot", currentHref: clientNavigationSnapshotHref( navigationInitiationState.navigationSnapshot, ), diff --git a/packages/vinext/src/server/navigation-planner.ts b/packages/vinext/src/server/navigation-planner.ts index 302e149863..c89ce6eb0f 100644 --- a/packages/vinext/src/server/navigation-planner.ts +++ b/packages/vinext/src/server/navigation-planner.ts @@ -228,16 +228,21 @@ export type RscFetchResultDecision = // planner inputs (route manifest, mounted slots) join later slices once prefetch // reuse and the remaining hard-navigation causes route through this surface. export type EarlyNavigationIntentFacts = { - // App basePath, stripped from both pathnames before comparison. + // App basePath. Navigation targets are browser-space URLs and are stripped + // once before comparison. basePath: string; - // The current visible document URL (window.location.href at navigation start), - // absolute so it can anchor relative target resolution. + // Whether currentHref is the visible browser URL or a committed App Router + // snapshot URL. Snapshot pathnames are already app-relative and must never + // have basePath stripped again: an app route whose first segment equals the + // configured basePath would otherwise lose a real route segment. + currentUrlSpace: "appRelativeSnapshot" | "browser"; + // The current URL, absolute so it can anchor relative target resolution. currentHref: string; // push/replace history intent carried through to the scroll executor. mode: "push" | "replace"; // Whether the navigation requested scroll (Link/router scroll option). scroll: boolean; - // The navigation target, absolute or relative to currentHref. + // The browser-space navigation target, absolute or relative to currentHref. targetHref: string; }; @@ -604,6 +609,14 @@ function createEarlyNavigationIntentTrace( return createNavigationTrace(reasonCode, { targetHref: facts.targetHref }); } +function appRelativeNavigationPathname( + pathname: string, + basePath: string, + urlSpace: "appRelativeSnapshot" | "browser", +): string { + return urlSpace === "browser" ? stripBasePath(pathname, basePath) : pathname; +} + function classifyEarlyNavigationIntent( facts: EarlyNavigationIntentFacts, ): EarlyNavigationIntentDecision { @@ -631,10 +644,13 @@ function classifyEarlyNavigationIntent( // here, so it cannot assume the caller already filtered same-origin: gate both // same-document outcomes on origin so a different host falls through to an // ordinary flight. - const samePathname = - current.origin === next.origin && - stripBasePath(current.pathname, facts.basePath) === - stripBasePath(next.pathname, facts.basePath); + const currentAppPathname = appRelativeNavigationPathname( + current.pathname, + facts.basePath, + facts.currentUrlSpace, + ); + const targetAppPathname = appRelativeNavigationPathname(next.pathname, facts.basePath, "browser"); + const samePathname = current.origin === next.origin && currentAppPathname === targetAppPathname; // Compare serialised search params rather than raw search strings, matching the // previous same-page-search predicate, so encoding differences that parse to // the same query (e.g. "%20" vs "+") are not read as a search change. App @@ -643,21 +659,10 @@ function classifyEarlyNavigationIntent( // key order. We intentionally do not sort, since query order can be observable. const sameSearch = current.searchParams.toString() === next.searchParams.toString(); - if (samePathname && sameSearch && next.hash !== "") { - return { - hash: next.hash, - kind: "sameDocumentScroll", - mode: facts.mode, - scroll: facts.scroll, - trace: createEarlyNavigationIntentTrace(NavigationTraceReasonCodes.sameDocumentScroll, facts), - }; - } - // A Link to the exact current URL still invalidates the page segment in - // Next.js. The committed App Router snapshot is base-stripped while a Link - // target retains basePath, so exact identity compares canonical URL parts - // rather than the raw href. Keep raw search/hash equality here: equivalent - // query encodings are the same page but not the exact same URL spelling. + // Next.js, including when both URLs contain the same non-empty hash. Keep raw + // search/hash equality here: equivalent query encodings are the same page but + // not the exact same URL spelling. if (samePathname && current.search === next.search && current.hash === next.hash) { return { bypassNavigationCache: true, @@ -666,6 +671,18 @@ function classifyEarlyNavigationIntent( }; } + // Only a change to a non-empty hash is a same-document scroll. An unchanged + // hash reached exact identity above and refreshes the page segment. + if (samePathname && sameSearch && current.hash !== next.hash && next.hash !== "") { + return { + hash: next.hash, + kind: "sameDocumentScroll", + mode: facts.mode, + scroll: facts.scroll, + trace: createEarlyNavigationIntentTrace(NavigationTraceReasonCodes.sameDocumentScroll, facts), + }; + } + if (samePathname && !sameSearch) { return { bypassNavigationCache: true, diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index f1f672040f..67318164b9 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -2570,6 +2570,7 @@ export async function navigateClientSide( // an RSC fetch; everything else proceeds to the RSC navigation below. const earlyIntent = navigationPlanner.classifyEarlyNavigationIntent({ basePath: __basePath, + currentUrlSpace: "browser", currentHref: window.location.href, mode, scroll, diff --git a/playwright.config.ts b/playwright.config.ts index 477998dbb6..db5e102508 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -373,14 +373,16 @@ const projectServers = { "app-router-encoded-basepath-i18n": { testDir: "./tests/e2e/app-router-encoded-basepath-i18n", use: { baseURL: "http://localhost:4196" }, - server: { - command: - "VINEXT_ENCODED_PATH_BASEPATH_I18N=1 npx vp run vinext#build && VINEXT_ENCODED_PATH_BASEPATH_I18N=1 node ../../../packages/vinext/dist/cli.js build && VINEXT_ENCODED_PATH_BASEPATH_I18N=1 node ../../../packages/vinext/dist/cli.js start --port 4196", - cwd: "./tests/fixtures/app-basic", - port: 4196, - reuseExistingServer: !process.env.CI, - timeout: 120_000, - }, + server: process.env.VINEXT_BASEPATH_E2E_BASE_URL + ? undefined + : { + command: + "VINEXT_ENCODED_PATH_BASEPATH_I18N=1 npx vp run vinext#build && VINEXT_ENCODED_PATH_BASEPATH_I18N=1 node ../../../packages/vinext/dist/cli.js build && VINEXT_ENCODED_PATH_BASEPATH_I18N=1 node ../../../packages/vinext/dist/cli.js start --port 4196", + cwd: "./tests/fixtures/app-basic", + port: 4196, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, }, "pages-router-complex": { // Compatibility target exercising the convoluted patterns of large, @@ -460,7 +462,7 @@ export default defineConfig({ .map((name) => projectServers[name].server) .filter( (server): server is NonNullable<(typeof projectServers)[ProjectName]["server"]> => - server !== null, + server != null, ) .map((server) => [server.port, server]), ).values(), diff --git a/tests/e2e/app-router-encoded-basepath-i18n/soft-navigation.spec.ts b/tests/e2e/app-router-encoded-basepath-i18n/soft-navigation.spec.ts index 7b7a45f0d9..01565a87fc 100644 --- a/tests/e2e/app-router-encoded-basepath-i18n/soft-navigation.spec.ts +++ b/tests/e2e/app-router-encoded-basepath-i18n/soft-navigation.spec.ts @@ -17,3 +17,33 @@ test("an exact-current Link refreshes page output behind basePath", async ({ pag await expect(page).toHaveURL(/\/docs\/nextjs-compat\/link-soft-replace$/); expect(await page.evaluate(() => window.history.length)).toBe(historyLength); }); + +test("an unchanged hash refreshes while a changed hash only scrolls", async ({ page }) => { + // Ported from Next.js: test/e2e/app-dir/segment-cache/basic/segment-cache-basic.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/segment-cache/basic/segment-cache-basic.test.ts + await page.goto(`${BASE_URL}/docs/nextjs-compat/link-soft-replace#section`); + await waitForAppRouterHydration(page); + + const firstId = await page.getByTestId("soft-replace-render-id").textContent(); + expect(firstId).toBeTruthy(); + + await page.getByTestId("soft-replace-section-link").click(); + await expect(page.getByTestId("soft-replace-render-id")).not.toHaveText(firstId!); + + const refreshedId = await page.getByTestId("soft-replace-render-id").textContent(); + await page.getByTestId("soft-replace-other-link").click(); + await expect(page).toHaveURL(/#other$/); + await expect(page.getByTestId("soft-replace-render-id")).toHaveText(refreshedId!); +}); + +test("an app-relative route beginning with basePath is not stripped twice", async ({ page }) => { + await page.goto(`${BASE_URL}/docs/docs/foo`); + await waitForAppRouterHydration(page); + + const firstId = await page.getByTestId("prefix-collision-render-id").textContent(); + expect(firstId).toBeTruthy(); + + await page.getByTestId("prefix-collision-link").click(); + await expect(page.getByTestId("prefix-collision-render-id")).not.toHaveText(firstId!); + await expect(page).toHaveURL(/\/docs\/docs\/foo$/); +}); diff --git a/tests/fixtures/app-basic/app/docs/foo/page.tsx b/tests/fixtures/app-basic/app/docs/foo/page.tsx new file mode 100644 index 0000000000..fdc62241b6 --- /dev/null +++ b/tests/fixtures/app-basic/app/docs/foo/page.tsx @@ -0,0 +1,14 @@ +import Link from "next/link"; + +export const revalidate = 0; + +export default function Page() { + return ( + <> +

{crypto.randomUUID()}

+ + Refresh + + + ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/link-soft-replace/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/link-soft-replace/page.tsx index d24de7e8c3..96d6b03f58 100644 --- a/tests/fixtures/app-basic/app/nextjs-compat/link-soft-replace/page.tsx +++ b/tests/fixtures/app-basic/app/nextjs-compat/link-soft-replace/page.tsx @@ -9,6 +9,22 @@ export default function Page() { Refresh + + Section + + + Other + +
Section target
+
Other target
); } diff --git a/tests/navigation-planner-early-intent.test.ts b/tests/navigation-planner-early-intent.test.ts index f56058145a..eb5ad54767 100644 --- a/tests/navigation-planner-early-intent.test.ts +++ b/tests/navigation-planner-early-intent.test.ts @@ -14,6 +14,7 @@ function createFacts( ): EarlyNavigationIntentFacts { return { basePath: "", + currentUrlSpace: "browser", currentHref: "https://example.com/docs?q=1", mode: "push", scroll: true, @@ -148,9 +149,10 @@ describe("navigationPlanner early navigation intent classification", () => { }); }); - it("refreshes an exact current URL when the committed snapshot is base-stripped", () => { + it("refreshes an exact current URL when the committed snapshot is app-relative", () => { const decision = classify({ basePath: "/app", + currentUrlSpace: "appRelativeSnapshot", currentHref: "https://example.com/docs?q=1", targetHref: "https://example.com/app/docs?q=1", }); @@ -161,14 +163,43 @@ describe("navigationPlanner early navigation intent classification", () => { }); }); - it("keeps an identical non-empty hash on the same-document scroll path", () => { + it("refreshes an identical non-empty hash", () => { const decision = classify({ basePath: "/app", + currentUrlSpace: "appRelativeSnapshot", currentHref: "https://example.com/docs#section", targetHref: "https://example.com/app/docs#section", }); - expect(decision).toMatchObject({ kind: "sameDocumentScroll", hash: "#section" }); + expect(decision).toMatchObject({ kind: "flightNavigation", bypassNavigationCache: true }); + expectSingleTraceEntry(decision, NavigationTraceReasonCodes.samePageRefresh, { + targetHref: "https://example.com/app/docs#section", + }); + }); + + it("does not strip a basePath-like first segment from an app-relative snapshot", () => { + const decision = classify({ + basePath: "/docs", + currentUrlSpace: "appRelativeSnapshot", + currentHref: "https://example.com/docs/foo", + targetHref: "https://example.com/docs/docs/foo", + }); + + expect(decision).toMatchObject({ kind: "flightNavigation", bypassNavigationCache: true }); + expectSingleTraceEntry(decision, NavigationTraceReasonCodes.samePageRefresh, { + targetHref: "https://example.com/docs/docs/foo", + }); + }); + + it("keeps encoded search spelling significant for app-relative exact identity", () => { + const decision = classify({ + basePath: "/docs", + currentUrlSpace: "appRelativeSnapshot", + currentHref: "https://example.com/docs/foo?q=+", + targetHref: "https://example.com/docs/docs/foo?q=%20", + }); + + expect(decision).toMatchObject({ kind: "flightNavigation", bypassNavigationCache: false }); }); it("treats hash removal as a flight navigation, not a same-document scroll", () => { From de38f7f84314a7e110b995d39db7ba8d1d97b6c3 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 11 Aug 2026 01:21:40 +0100 Subject: [PATCH 4/8] fix(app-router): preserve raw navigation identity --- .../vinext/src/server/navigation-planner.ts | 13 ++++----- packages/vinext/src/shims/navigation.ts | 8 +++-- .../soft-navigation.spec.ts | 29 +++++++++++++++++++ .../fixtures/app-basic/app/docs/foo/page.tsx | 2 +- .../nextjs-compat/link-soft-replace/page.tsx | 17 ++++++++++- tests/navigation-planner-early-intent.test.ts | 19 +++++++++--- tests/shims.test.ts | 17 +++++++++++ 7 files changed, 90 insertions(+), 15 deletions(-) diff --git a/packages/vinext/src/server/navigation-planner.ts b/packages/vinext/src/server/navigation-planner.ts index c89ce6eb0f..bae4b655f9 100644 --- a/packages/vinext/src/server/navigation-planner.ts +++ b/packages/vinext/src/server/navigation-planner.ts @@ -249,7 +249,7 @@ export type EarlyNavigationIntentFacts = { export type EarlyNavigationIntentDecision = | { kind: "sameDocumentScroll"; - // Always non-empty: same-document scroll is only chosen for a hash target. + // Empty when removing the current hash; otherwise the new hash target. hash: string; mode: "push" | "replace"; scroll: boolean; @@ -654,9 +654,8 @@ function classifyEarlyNavigationIntent( // Compare serialised search params rather than raw search strings, matching the // previous same-page-search predicate, so encoding differences that parse to // the same query (e.g. "%20" vs "+") are not read as a search change. App - // Router snapshots reach currentHref through createSnapshotPathAndSearch(); - // reparsing and serialising that canonical query is idempotent and preserves - // key order. We intentionally do not sort, since query order can be observable. + // Router snapshots retain the raw search spelling separately from parsed + // params. We intentionally do not sort, since query order can be observable. const sameSearch = current.searchParams.toString() === next.searchParams.toString(); // A Link to the exact current URL still invalidates the page segment in @@ -671,9 +670,9 @@ function classifyEarlyNavigationIntent( }; } - // Only a change to a non-empty hash is a same-document scroll. An unchanged - // hash reached exact identity above and refreshes the page segment. - if (samePathname && sameSearch && current.hash !== next.hash && next.hash !== "") { + // Any hash change is same-document, including removing the current hash. An + // unchanged hash reached exact identity above and refreshes the page segment. + if (samePathname && sameSearch && current.hash !== next.hash) { return { hash: next.hash, kind: "sameDocumentScroll", diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 67318164b9..91509bd3ee 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -2000,6 +2000,10 @@ const _EMPTY_PARAMS: Record = {}; export type ClientNavigationRenderSnapshot = { pathname: string; + // Preserve the browser URL's raw query spelling for exact navigation + // identity. ReadonlyURLSearchParams intentionally canonicalizes `%20` to `+` + // when serialized, so it cannot reconstruct the href used for a commit. + search: string; searchParams: ReadonlyURLSearchParams; params: Record; }; @@ -2043,14 +2047,14 @@ export function createClientNavigationRenderSnapshot( return { pathname: stripBasePath(url.pathname, __basePath), + search: url.search, searchParams: new ReadonlyURLSearchParams(url.search), params, }; } export function createSnapshotPathAndSearch(snapshot: ClientNavigationRenderSnapshot): string { - const query = snapshot.searchParams.toString(); - return query === "" ? snapshot.pathname : `${snapshot.pathname}?${query}`; + return snapshot.pathname + snapshot.search; } // Module-level fallback for environments without window (tests, SSR). diff --git a/tests/e2e/app-router-encoded-basepath-i18n/soft-navigation.spec.ts b/tests/e2e/app-router-encoded-basepath-i18n/soft-navigation.spec.ts index 01565a87fc..a81310e02e 100644 --- a/tests/e2e/app-router-encoded-basepath-i18n/soft-navigation.spec.ts +++ b/tests/e2e/app-router-encoded-basepath-i18n/soft-navigation.spec.ts @@ -36,6 +36,35 @@ test("an unchanged hash refreshes while a changed hash only scrolls", async ({ p await expect(page.getByTestId("soft-replace-render-id")).toHaveText(refreshedId!); }); +test("removing a hash does not fetch RSC or rerender the page", async ({ page }) => { + await page.goto(`${BASE_URL}/docs/nextjs-compat/link-soft-replace#section`); + await waitForAppRouterHydration(page); + + const renderId = await page.getByTestId("soft-replace-render-id").textContent(); + let rscRequests = 0; + page.on("request", (request) => { + if (request.headers().rsc === "1") rscRequests++; + }); + + await page.getByTestId("soft-replace-link").click(); + await expect(page).toHaveURL(/\/docs\/nextjs-compat\/link-soft-replace$/); + await expect(page.getByTestId("soft-replace-render-id")).toHaveText(renderId!); + await page.waitForTimeout(200); + expect(rscRequests).toBe(0); +}); + +test("an exact encoded query preserves raw URL identity", async ({ page }) => { + await page.goto(`${BASE_URL}/docs/nextjs-compat/link-soft-replace?q=%20`); + await waitForAppRouterHydration(page); + + const firstId = await page.getByTestId("soft-replace-render-id").textContent(); + expect(firstId).toBeTruthy(); + + await page.getByTestId("soft-replace-encoded-link").click(); + await expect(page.getByTestId("soft-replace-render-id")).not.toHaveText(firstId!); + expect(new URL(page.url()).search).toBe("?q=%20"); +}); + test("an app-relative route beginning with basePath is not stripped twice", async ({ page }) => { await page.goto(`${BASE_URL}/docs/docs/foo`); await waitForAppRouterHydration(page); diff --git a/tests/fixtures/app-basic/app/docs/foo/page.tsx b/tests/fixtures/app-basic/app/docs/foo/page.tsx index fdc62241b6..e1276ffdd6 100644 --- a/tests/fixtures/app-basic/app/docs/foo/page.tsx +++ b/tests/fixtures/app-basic/app/docs/foo/page.tsx @@ -6,7 +6,7 @@ export default function Page() { return ( <>

{crypto.randomUUID()}

- + Refresh diff --git a/tests/fixtures/app-basic/app/nextjs-compat/link-soft-replace/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/link-soft-replace/page.tsx index 96d6b03f58..161c9c7d0f 100644 --- a/tests/fixtures/app-basic/app/nextjs-compat/link-soft-replace/page.tsx +++ b/tests/fixtures/app-basic/app/nextjs-compat/link-soft-replace/page.tsx @@ -6,12 +6,26 @@ export default function Page() { return ( <>

{crypto.randomUUID()}

- + Refresh + + Encoded query + Section @@ -19,6 +33,7 @@ export default function Page() { Other diff --git a/tests/navigation-planner-early-intent.test.ts b/tests/navigation-planner-early-intent.test.ts index eb5ad54767..3bc6aff01e 100644 --- a/tests/navigation-planner-early-intent.test.ts +++ b/tests/navigation-planner-early-intent.test.ts @@ -191,24 +191,35 @@ describe("navigationPlanner early navigation intent classification", () => { }); }); - it("keeps encoded search spelling significant for app-relative exact identity", () => { + it("refreshes an app-relative exact URL that preserves %20 spelling", () => { const decision = classify({ basePath: "/docs", currentUrlSpace: "appRelativeSnapshot", - currentHref: "https://example.com/docs/foo?q=+", + currentHref: "https://example.com/docs/foo?q=%20", targetHref: "https://example.com/docs/docs/foo?q=%20", }); + expect(decision).toMatchObject({ kind: "flightNavigation", bypassNavigationCache: true }); + }); + + it("keeps %20 and + as distinct raw spellings for app-relative exact identity", () => { + const decision = classify({ + basePath: "/docs", + currentUrlSpace: "appRelativeSnapshot", + currentHref: "https://example.com/docs/foo?q=%20", + targetHref: "https://example.com/docs/docs/foo?q=+", + }); + expect(decision).toMatchObject({ kind: "flightNavigation", bypassNavigationCache: false }); }); - it("treats hash removal as a flight navigation, not a same-document scroll", () => { + it("treats hash removal as a same-document navigation", () => { const decision = classify({ currentHref: "https://example.com/docs#section", targetHref: "https://example.com/docs", }); - expect(decision).toMatchObject({ kind: "flightNavigation", bypassNavigationCache: false }); + expect(decision).toMatchObject({ kind: "sameDocumentScroll", hash: "" }); }); it("does not treat a cross-origin same-path hash target as a same-document scroll", () => { diff --git a/tests/shims.test.ts b/tests/shims.test.ts index f6ff508b5a..e60a3395fd 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -225,6 +225,23 @@ describe("next/navigation shim", () => { expect(typeof nav.useRouter).toBe("function"); }); + it("preserves raw search spelling in client navigation snapshots", async () => { + const nav = await import("../packages/vinext/src/shims/navigation.js"); + + const percentEncoded = nav.createClientNavigationRenderSnapshot( + "https://example.com/search?q=%20", + {}, + ); + const plusEncoded = nav.createClientNavigationRenderSnapshot( + "https://example.com/search?q=+", + {}, + ); + + expect(nav.createSnapshotPathAndSearch(percentEncoded)).toBe("/search?q=%20"); + expect(nav.createSnapshotPathAndSearch(plusEncoded)).toBe("/search?q=+"); + expect(percentEncoded.searchParams.toString()).toBe(plusEncoded.searchParams.toString()); + }); + // Next.js parity: next/navigation's useRouter reads AppRouterContext and // throws when it is rendered outside the App Router provider. // Ported from Next.js: From a0f53c627fb57fd613984315425235c820343177 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 11 Aug 2026 09:24:42 +0100 Subject: [PATCH 5/8] fix(app-router): preserve licensed history cache reuse --- .../vinext/src/server/app-browser-entry.ts | 21 ++++++-- .../src/server/app-visited-response-cache.ts | 20 ++++++++ packages/vinext/src/shims/navigation.ts | 13 +++-- tests/app-visited-response-cache.test.ts | 49 +++++++++++++++++++ tests/prefetch-cache.test.ts | 38 ++++++++++++++ 5 files changed, 132 insertions(+), 9 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 1d4f713622..c8f464c052 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -129,8 +129,10 @@ import { import { AppBrowserHistoryController } from "./app-browser-history-controller.js"; import { createVisitedResponseCacheEntry, + deleteInvalidatedHistoryRestoreEntries, deleteVisitedResponseCacheEntry, findVisitedResponseCacheEntry, + hasNavigationResponseHistoryLifetime, isVisitedResponseCacheEntryFresh, type VisitedResponseCacheEntry, } from "./app-visited-response-cache.js"; @@ -392,12 +394,13 @@ function restoreHistoryStateSnapshot( if (!restored) return false; // History entries restore their own visible tree, but a later Link click is - // a new navigation. Drop response snapshots published by the route we just - // left while retaining explicit prefetches. Advance the generation first so - // an async publication already waiting on a response body cannot repopulate - // the departed response after the maps are cleared. + // a new navigation. Demote unbounded response snapshots published by the + // route we just left while retaining explicit prefetches and responses whose + // segment-cache lifetime licenses reuse. Advance the generation first so an + // async publication already waiting on a response body cannot repopulate a + // departed unbounded response after the caches are pruned. clientNavigationCacheGeneration += 1; - clearVisitedResponseCache(); + deleteInvalidatedHistoryRestoreEntries(visitedResponseCache); disableNavigationResponsePrefetchCacheReuse(); commitClientNavigationState(navId, { releaseSnapshot: false }); return true; @@ -778,6 +781,7 @@ function storeVisitedResponseSnapshot( elements?: AppElements, seedPrefetchCache: boolean = true, prefetchSnapshot: CachedRscResponse = snapshot, + reuseAfterHistoryRestore: boolean = false, ): () => void { const cacheKey = AppElementsWire.encodeCacheKey(rscUrl, interceptionContext); visitedResponseCache.delete(cacheKey); @@ -790,6 +794,7 @@ function storeVisitedResponseSnapshot( mountedSlotsHeader: requestMountedSlotsHeader, params, response: snapshot, + reuseAfterHistoryRestore, }); visitedResponseCache.set(cacheKey, entry); if (seedPrefetchCache) { @@ -799,6 +804,7 @@ function storeVisitedResponseSnapshot( interceptionContext, requestMountedSlotsHeader, prefetchFallbackTtlMs, + reuseAfterHistoryRestore, ); } return () => { @@ -1578,6 +1584,9 @@ function bootstrapHydration( mountedSlotsHeader, elements, false, + snapshot, + initialRscBootstrap?.initialCacheKind === "static" || + metadata.interceptionContext !== null, ); }); }) @@ -2334,6 +2343,7 @@ function bootstrapHydration( undefined, true, prefetchSnapshot, + true, ); } else { const state = committedState; @@ -2358,6 +2368,7 @@ function bootstrapHydration( committedElements, true, prefetchSnapshot, + interceptionContext !== null || hasNavigationResponseHistoryLifetime(snapshot), ); } } catch { diff --git a/packages/vinext/src/server/app-visited-response-cache.ts b/packages/vinext/src/server/app-visited-response-cache.ts index 8b98d27d84..ce820f0877 100644 --- a/packages/vinext/src/server/app-visited-response-cache.ts +++ b/packages/vinext/src/server/app-visited-response-cache.ts @@ -11,11 +11,20 @@ export type VisitedResponseCacheEntry = { mountedSlotsHeader: string | null; params: Record; response: CachedRscResponse; + reuseAfterHistoryRestore: boolean; }; export const VISITED_RESPONSE_CACHE_TTL = 5 * 60_000; export const MAX_TRAVERSAL_CACHE_TTL = 30 * 60_000; +export function hasNavigationResponseHistoryLifetime(snapshot: CachedRscResponse): boolean { + const dynamicStaleTime = + snapshot.completedDynamicStaleTimeSeconds ?? snapshot.dynamicStaleTimeSeconds; + return dynamicStaleTime !== undefined + ? dynamicStaleTime > 0 + : snapshot.serverStaleTime !== undefined; +} + export function createVisitedResponseCacheEntry(options: { elements?: AppElements; fallbackTtlMs?: number; @@ -23,6 +32,7 @@ export function createVisitedResponseCacheEntry(options: { mountedSlotsHeader?: string | null; params: Record; response: CachedRscResponse; + reuseAfterHistoryRestore?: boolean; }): VisitedResponseCacheEntry { return { createdAt: options.now, @@ -35,6 +45,7 @@ export function createVisitedResponseCacheEntry(options: { mountedSlotsHeader: options.mountedSlotsHeader ?? null, params: options.params, response: options.response, + reuseAfterHistoryRestore: options.reuseAfterHistoryRestore === true, }; } @@ -113,3 +124,12 @@ export function deleteVisitedResponseCacheEntry( if (!match) return false; return cache.delete(match.cacheKey); } + +export function deleteInvalidatedHistoryRestoreEntries( + cache: Map, +): void { + for (const [cacheKey, entry] of cache) { + if (entry.reuseAfterHistoryRestore) continue; + cache.delete(cacheKey); + } +} diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 91509bd3ee..dd5fc4cad5 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -340,6 +340,7 @@ export type PrefetchCacheEntry = { pending?: Promise; preparedElements?: AppElements; prefetchKind?: PrefetchCacheKind; + reuseAfterHistoryRestore?: boolean; searchAgnosticShell?: boolean; size?: number; timestamp: number; @@ -1102,16 +1103,18 @@ export function invalidatePrefetchCache(): void { /** * Prevent completed navigation responses from becoming authoritative again * after restoring a history snapshot. Explicit Link/router prefetches remain - * consumable, and the demoted responses remain available as optimistic route - * template sources. + * consumable. Responses with a positive cache lifetime and interception + * responses retain the cache reuse licensed by Next's segment cache. */ export function disableNavigationResponsePrefetchCacheReuse(): void { + let didDemote = false; for (const entry of new Set(getPrefetchCache().values())) { - if (entry.prefetchKind === undefined) { + if (entry.prefetchKind === undefined && entry.reuseAfterHistoryRestore !== true) { + didDemote ||= entry.cacheForNavigation !== false; entry.cacheForNavigation = false; } } - if (!isServer) { + if (didDemote && !isServer) { getNavigationRuntime()?.functions.pingVisibleLinks?.(); } } @@ -1122,6 +1125,7 @@ export function seedPrefetchResponseSnapshot( interceptionContext: string | null = null, mountedSlotsHeader: string | null = null, fallbackTtlMs: number = DYNAMIC_NAVIGATION_CACHE_TTL, + reuseAfterHistoryRestore: boolean = false, ): void { const cacheKey = AppElementsWire.encodeCacheKey(rscUrl, interceptionContext); const cache = getPrefetchCache(); @@ -1136,6 +1140,7 @@ export function seedPrefetchResponseSnapshot( expiresAt: resolveCachedRscResponseExpiresAt(timestamp, snapshot, fallbackTtlMs), mountedSlotsHeader, outcome: "cache-seeded", + reuseAfterHistoryRestore, size: snapshot.buffer.byteLength, snapshot, timestamp, diff --git a/tests/app-visited-response-cache.test.ts b/tests/app-visited-response-cache.test.ts index 3fcd27d58e..34cdf9b7c5 100644 --- a/tests/app-visited-response-cache.test.ts +++ b/tests/app-visited-response-cache.test.ts @@ -3,8 +3,10 @@ import { MAX_TRAVERSAL_CACHE_TTL, VISITED_RESPONSE_CACHE_TTL, createVisitedResponseCacheEntry, + deleteInvalidatedHistoryRestoreEntries, deleteVisitedResponseCacheEntry, findVisitedResponseCacheEntry, + hasNavigationResponseHistoryLifetime, isVisitedResponseCacheEntryFresh, } from "../packages/vinext/src/server/app-visited-response-cache.js"; import { AppElementsWire } from "../packages/vinext/src/server/app-elements.js"; @@ -23,6 +25,53 @@ function createCachedResponse(overrides: Partial = {}): Cache } describe("visited response cache freshness", () => { + it("recognizes positive server and dynamic history lifetimes", () => { + expect(hasNavigationResponseHistoryLifetime(createCachedResponse())).toBe(false); + expect( + hasNavigationResponseHistoryLifetime(createCachedResponse({ dynamicStaleTimeSeconds: 0 })), + ).toBe(false); + expect( + hasNavigationResponseHistoryLifetime(createCachedResponse({ dynamicStaleTimeSeconds: 30 })), + ).toBe(true); + expect( + hasNavigationResponseHistoryLifetime( + createCachedResponse({ serverStaleTime: { kind: "resolved", seconds: 120 } }), + ), + ).toBe(true); + }); + + it("retains history-restored entries only when segment-cache reuse is licensed", () => { + const createEntry = (response: CachedRscResponse, reuseAfterHistoryRestore = false) => + createVisitedResponseCacheEntry({ + now: 1_000_000, + params: {}, + response, + reuseAfterHistoryRestore, + }); + const cache = new Map([ + ["/unbounded.rsc", createEntry(createCachedResponse({ url: "/unbounded.rsc" }))], + [ + "/static.rsc", + createEntry( + createCachedResponse({ + url: "/static.rsc", + }), + true, + ), + ], + ["/dynamic.rsc", createEntry(createCachedResponse({ dynamicStaleTimeSeconds: 30 }), true)], + [ + "/expired.rsc", + createEntry(createCachedResponse({ dynamicStaleTimeSeconds: 0, url: "/expired.rsc" })), + ], + ["/intercepted.rsc\0/", createEntry(createCachedResponse({ url: "/intercepted.rsc" }), true)], + ]); + + deleteInvalidatedHistoryRestoreEntries(cache); + + expect([...cache.keys()]).toEqual(["/static.rsc", "/dynamic.rsc", "/intercepted.rsc\0/"]); + }); + it("uses per-response dynamic stale time for regular navigations", () => { // Ported from Next.js: test/e2e/app-dir/segment-cache/staleness/segment-cache-per-page-dynamic-stale-time.test.ts const now = 1_000_000; diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts index 85d5a599c5..e705fc2ed8 100644 --- a/tests/prefetch-cache.test.ts +++ b/tests/prefetch-cache.test.ts @@ -258,6 +258,9 @@ describe("prefetch cache eviction", () => { const prefetched = getPrefetchedUrls(); const navigationKey = "/departed.rsc"; const prefetchKey = "/prefetched.rsc"; + const staticKey = "/static.rsc"; + const dynamicKey = "/dynamic.rsc"; + const interceptedKey = "/intercepted.rsc\0/"; const snapshot = { buffer: new TextEncoder().encode("flight").buffer, contentType: "text/x-component", @@ -279,14 +282,49 @@ describe("prefetch cache eviction", () => { snapshot: { ...snapshot, url: prefetchKey }, timestamp: Date.now(), }); + cache.set(staticKey, { + cacheForNavigation: true, + outcome: "cache-seeded", + reuseAfterHistoryRestore: true, + snapshot: { ...snapshot, url: staticKey }, + timestamp: Date.now(), + }); + cache.set(dynamicKey, { + cacheForNavigation: true, + outcome: "cache-seeded", + reuseAfterHistoryRestore: true, + snapshot: { ...snapshot, dynamicStaleTimeSeconds: 30, url: dynamicKey }, + timestamp: Date.now(), + }); + cache.set(interceptedKey, { + cacheForNavigation: true, + outcome: "cache-seeded", + reuseAfterHistoryRestore: true, + snapshot: { ...snapshot, url: "/intercepted.rsc" }, + timestamp: Date.now(), + }); prefetched.add(navigationKey); prefetched.add(prefetchKey); + const pingVisibleLinks = vi.fn(); + Reflect.set(globalThis.window, Symbol.for("vinext.navigationRuntime"), { + bootstrap: { routeManifest: null, rsc: undefined }, + functions: { pingVisibleLinks }, + }); disableNavigationResponsePrefetchCacheReuse(); expect(cache.get(navigationKey)?.cacheForNavigation).toBe(false); expect(cache.get(prefetchKey)?.cacheForNavigation).toBe(true); + expect(cache.get(staticKey)?.cacheForNavigation).toBe(true); + expect(cache.get(dynamicKey)?.cacheForNavigation).toBe(true); + expect(cache.get(interceptedKey)?.cacheForNavigation).toBe(true); expect(prefetched).toEqual(new Set([navigationKey, prefetchKey])); + expect(pingVisibleLinks).toHaveBeenCalledTimes(1); + + cache.delete(navigationKey); + pingVisibleLinks.mockClear(); + disableNavigationResponsePrefetchCacheReuse(); + expect(pingVisibleLinks).not.toHaveBeenCalled(); }); it("reuses a prefetched response only when mounted-slot context matches", () => { From 390babe63c3453cf336ca91107638321d1e6d81c Mon Sep 17 00:00:00 2001 From: James Date: Sun, 16 Aug 2026 01:24:36 +0100 Subject: [PATCH 6/8] fix(app-router): replace visited route aliases --- .../vinext/src/server/app-browser-entry.ts | 7 ++++++- .../src/server/app-visited-response-cache.ts | 12 +++++++++++ tests/app-visited-response-cache.test.ts | 21 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 824ec890df..66f1504322 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -129,6 +129,7 @@ import { import { AppBrowserHistoryController } from "./app-browser-history-controller.js"; import { createVisitedResponseCacheEntry, + deleteAllVisitedResponseCacheEntries, deleteInvalidatedHistoryRestoreEntries, deleteVisitedResponseCacheEntry, findVisitedResponseCacheEntry, @@ -784,7 +785,11 @@ function storeVisitedResponseSnapshot( reuseAfterHistoryRestore: boolean = false, ): () => void { const cacheKey = AppElementsWire.encodeCacheKey(rscUrl, interceptionContext); - visitedResponseCache.delete(cacheKey); + // Router-state fingerprints intentionally give requests for the same visible + // route distinct cache-busting URLs. A newly committed response supersedes + // every older fingerprint variant; leaving one behind lets normalized lookup + // replay stale page output after a later navigation. + deleteAllVisitedResponseCacheEntries(visitedResponseCache, rscUrl, interceptionContext); evictVisitedResponseCacheIfNeeded(); const now = Date.now(); const entry = createVisitedResponseCacheEntry({ diff --git a/packages/vinext/src/server/app-visited-response-cache.ts b/packages/vinext/src/server/app-visited-response-cache.ts index ce820f0877..4353fabead 100644 --- a/packages/vinext/src/server/app-visited-response-cache.ts +++ b/packages/vinext/src/server/app-visited-response-cache.ts @@ -125,6 +125,18 @@ export function deleteVisitedResponseCacheEntry( return cache.delete(match.cacheKey); } +export function deleteAllVisitedResponseCacheEntries( + cache: Map, + rscUrl: string, + interceptionContext: string | null, +): number { + let deleted = 0; + while (deleteVisitedResponseCacheEntry(cache, rscUrl, interceptionContext)) { + deleted++; + } + return deleted; +} + export function deleteInvalidatedHistoryRestoreEntries( cache: Map, ): void { diff --git a/tests/app-visited-response-cache.test.ts b/tests/app-visited-response-cache.test.ts index 34cdf9b7c5..6a848f923e 100644 --- a/tests/app-visited-response-cache.test.ts +++ b/tests/app-visited-response-cache.test.ts @@ -3,6 +3,7 @@ import { MAX_TRAVERSAL_CACHE_TTL, VISITED_RESPONSE_CACHE_TTL, createVisitedResponseCacheEntry, + deleteAllVisitedResponseCacheEntries, deleteInvalidatedHistoryRestoreEntries, deleteVisitedResponseCacheEntry, findVisitedResponseCacheEntry, @@ -25,6 +26,26 @@ function createCachedResponse(overrides: Partial = {}): Cache } describe("visited response cache freshness", () => { + it("deletes every cache-busting variant when a visible route response is replaced", () => { + const entry = createVisitedResponseCacheEntry({ + now: 1_000_000, + params: {}, + response: createCachedResponse(), + }); + const cache = new Map([ + ["/dashboard?_rsc=initial", entry], + ["/dashboard?_rsc=state-a", entry], + ["/dashboard?_rsc=state-b\0/interception", entry], + ["/other?_rsc=state-c", entry], + ]); + + expect(deleteAllVisitedResponseCacheEntries(cache, "/dashboard?_rsc=latest", null)).toBe(2); + expect([...cache.keys()]).toEqual([ + "/dashboard?_rsc=state-b\0/interception", + "/other?_rsc=state-c", + ]); + }); + it("recognizes positive server and dynamic history lifetimes", () => { expect(hasNavigationResponseHistoryLifetime(createCachedResponse())).toBe(false); expect( From d4f15cf209d7690d2b144827392a6b2cf20f8b8c Mon Sep 17 00:00:00 2001 From: James Date: Sun, 16 Aug 2026 01:33:17 +0100 Subject: [PATCH 7/8] fix(app-router): preserve exact hash cache bypass --- .../vinext/src/client/navigation-runtime.ts | 1 + .../vinext/src/server/app-browser-entry.ts | 15 ++++++++---- packages/vinext/src/shims/navigation.ts | 1 + .../nextjs-compat/soft-navigation.spec.ts | 23 +++++++++++++++++++ .../link-soft-hash-cacheable/page.tsx | 19 +++++++++++++++ 5 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/link-soft-hash-cacheable/page.tsx diff --git a/packages/vinext/src/client/navigation-runtime.ts b/packages/vinext/src/client/navigation-runtime.ts index 7580b06567..63cd85d128 100644 --- a/packages/vinext/src/client/navigation-runtime.ts +++ b/packages/vinext/src/client/navigation-runtime.ts @@ -51,6 +51,7 @@ export type NavigationRuntimeNavigate = ( traversalIntent?: NavigationRuntimeTraversalIntent, scrollIntent?: AppRouterScrollIntent | null, visibleCommitMode?: NavigationRuntimeVisibleCommitMode, + bypassNavigationCache?: boolean, ) => Promise; export type NavigationRuntimeFunctions = { diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 66f1504322..7bcab31a09 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -1677,6 +1677,7 @@ function bootstrapHydration( traversalIntent?: HistoryTraversalIntent, scrollIntent?: AppRouterScrollIntent | null, visibleCommitMode: NavigationRuntimeVisibleCommitMode = "transition", + initialBypassNavigationCache?: boolean, ): Promise { abortSupersededNavigation(); const navigationAbortController = new AbortController(); @@ -1781,8 +1782,13 @@ function bootstrapHydration( // already short-circuited before reaching this loop, so for a "navigate" // here the decision is always a flight navigation and only its // cache-bypass bit is consumed. + const usesInitialNavigationCachePolicy = + navigationKind === "navigate" && + currentHref === href && + redirectCount === redirectDepth && + initialBypassNavigationCache !== undefined; const earlyIntentDecision = - navigationKind === "navigate" + navigationKind === "navigate" && !usesInitialNavigationCachePolicy ? navigationPlanner.classifyEarlyNavigationIntent({ basePath: __basePath, currentUrlSpace: "appRelativeSnapshot", @@ -1796,9 +1802,10 @@ function bootstrapHydration( targetHref: url.href, }) : null; - const shouldBypassNavigationCache = - earlyIntentDecision?.kind === "flightNavigation" && - earlyIntentDecision.bypassNavigationCache; + const shouldBypassNavigationCache = usesInitialNavigationCachePolicy + ? initialBypassNavigationCache + : earlyIntentDecision?.kind === "flightNavigation" && + earlyIntentDecision.bypassNavigationCache; // The client reuse manifest is excluded from VINEXT_RSC_VARY_HEADER, so // it never affects the cache-busting URL. Defer producing it until the // visited-response cache miss is confirmed below — its producer iterates diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 1f7b40ae2b..ce51739202 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -2656,6 +2656,7 @@ export async function navigateClientSide( undefined, scrollIntent, visibleCommitMode, + earlyIntent.bypassNavigationCache, ); } else { if (mode === "replace") { diff --git a/tests/e2e/app-router/nextjs-compat/soft-navigation.spec.ts b/tests/e2e/app-router/nextjs-compat/soft-navigation.spec.ts index 07fe950882..18c61044f5 100644 --- a/tests/e2e/app-router/nextjs-compat/soft-navigation.spec.ts +++ b/tests/e2e/app-router/nextjs-compat/soft-navigation.spec.ts @@ -41,4 +41,27 @@ test.describe("soft Link navigation", () => { await expect(page.getByTestId("soft-replace-render-id")).not.toHaveText(firstId!); expect(await page.evaluate(() => window.history.length)).toBe(historyLength); }); + + test("an identical non-empty hash bypasses a reusable route response", async ({ page }) => { + const pathname = "/nextjs-compat/link-soft-hash-cacheable"; + await page.goto(`${BASE}${pathname}#section`); + await waitForAppRouterHydration(page); + + const rscRequests: string[] = []; + page.on("request", (request) => { + const url = new URL(request.url()); + if ( + url.pathname === pathname && + url.searchParams.has("_rsc") && + request.headers()["rsc"] === "1" + ) { + rscRequests.push(url.href); + } + }); + + await page.getByTestId("soft-hash-cacheable-link").click(); + + await expect.poll(() => rscRequests.length).toBe(1); + expect(page.url()).toBe(`${BASE}${pathname}#section`); + }); }); diff --git a/tests/fixtures/app-basic/app/nextjs-compat/link-soft-hash-cacheable/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/link-soft-hash-cacheable/page.tsx new file mode 100644 index 0000000000..5997cb86c8 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/link-soft-hash-cacheable/page.tsx @@ -0,0 +1,19 @@ +import Link from "next/link"; + +export const revalidate = 60; + +export default function Page() { + return ( + <> + + Refresh section + +
Section target
+ + ); +} From 5719d11ca309d721cea541244650130a8c62c7a4 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 16 Aug 2026 01:44:06 +0100 Subject: [PATCH 8/8] test(navigation): cover cache bypass runtime argument --- tests/form.test.ts | 6 ++++++ tests/link-navigation.test.ts | 1 + 2 files changed, 7 insertions(+) diff --git a/tests/form.test.ts b/tests/form.test.ts index ff6aa64b5a..e74c7dcae0 100644 --- a/tests/form.test.ts +++ b/tests/form.test.ts @@ -380,6 +380,7 @@ describe("Form client GET interception", () => { undefined, expect.objectContaining({ commitId: null, hash: null, id: expect.any(Number) }), "transition", + false, ); }); @@ -417,6 +418,7 @@ describe("Form client GET interception", () => { undefined, expect.objectContaining({ commitId: null, hash: null, id: expect.any(Number) }), "transition", + false, ); }); @@ -450,6 +452,7 @@ describe("Form client GET interception", () => { undefined, expect.objectContaining({ commitId: null, hash: null, id: expect.any(Number) }), "transition", + false, ); }); @@ -507,6 +510,7 @@ describe("Form client GET interception", () => { undefined, expect.objectContaining({ commitId: null, hash: null, id: expect.any(Number) }), "transition", + false, ); }); @@ -614,6 +618,7 @@ describe("Form client GET interception", () => { undefined, expect.objectContaining({ commitId: null, hash: null, id: expect.any(Number) }), "transition", + false, ); }); }); @@ -925,6 +930,7 @@ describe("Form file input warning", () => { undefined, expect.objectContaining({ commitId: null, hash: null, id: expect.any(Number) }), "transition", + false, ); }); }); diff --git a/tests/link-navigation.test.ts b/tests/link-navigation.test.ts index 458e10b411..a3b8b1a800 100644 --- a/tests/link-navigation.test.ts +++ b/tests/link-navigation.test.ts @@ -626,6 +626,7 @@ describe("Link App Router navigation scheduling", () => { id: expect.any(Number), }), "transition", + false, ); expect(transitionStates).toEqual([true]); });