From 01d80426cb8ea010145d1cc36ee5d0d89dbe5a21 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Wed, 11 Mar 2026 21:08:52 -0500 Subject: [PATCH 1/8] Update navigation pathname handling --- packages/vinext/src/entries/app-rsc-entry.ts | 13 ++-- packages/vinext/src/shims/metadata.tsx | 72 +++++++++-------- .../entry-templates.test.ts.snap | 78 ++++++++++--------- tests/features.test.ts | 15 +++- .../api/(grouped)/endpoint/nested/route.ts | 3 + .../nextjs-compat/api/cookies-has/route.ts | 10 +++ .../api/permanent-redirect/route.ts | 5 ++ .../nextjs-compat/metadata-socials/page.tsx | 13 ++++ .../extra/inner/deep/page.tsx | 3 + .../extra/inner/page.tsx | 9 +++ .../metadata-title-template/extra/layout.tsx | 12 +++ .../use-layout-title/page.tsx | 3 + .../fixtures/app-basic/app/not-found/page.tsx | 3 + tests/fixtures/app-basic/next.config.ts | 10 +++ tests/nextjs-compat/app-routes.test.ts | 39 +++++++++- tests/nextjs-compat/hooks.test.ts | 21 +++++ tests/nextjs-compat/metadata-suspense.test.ts | 14 ++++ tests/nextjs-compat/metadata.test.ts | 46 ++++++++++- tests/nextjs-compat/not-found.test.ts | 9 +++ 19 files changed, 294 insertions(+), 84 deletions(-) create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/api/(grouped)/endpoint/nested/route.ts create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/api/cookies-has/route.ts create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/api/permanent-redirect/route.ts create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/metadata-socials/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/metadata-title-template/extra/inner/deep/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/metadata-title-template/extra/inner/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/metadata-title-template/extra/layout.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/metadata-title-template/use-layout-title/page.tsx create mode 100644 tests/fixtures/app-basic/app/not-found/page.tsx diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index a594972e2..8bd05c7bd 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -1560,6 +1560,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); let cleanPathname = pathname.replace(/\\.rsc$/, ""); + const navigationPathname = cleanPathname; // Middleware response headers and custom rewrite status are stored in // _mwCtx (per-request container) so handler() can merge them into @@ -1766,7 +1767,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Set navigation context for Server Components. // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params: {}, }); @@ -1882,7 +1883,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (match) { const { route: actionRoute, params: actionParams } = match; setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params: actionParams, }); @@ -1979,7 +1980,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Update navigation context with matched params setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params, }); @@ -2158,7 +2159,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (isForceStatic) { setHeadersContext({ headers: new Headers(), cookies: new Map() }); setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: new URLSearchParams(), params, }); @@ -2177,7 +2178,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { accessError: new Error(errorMsg), }); setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: new URLSearchParams(), params, }); @@ -2393,7 +2394,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const sourceMatch = matchRoute(sourceRoute.pattern); const sourceParams = sourceMatch ? sourceMatch.params : {}; setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params: intercept.matchedParams, }); diff --git a/packages/vinext/src/shims/metadata.tsx b/packages/vinext/src/shims/metadata.tsx index 0051ca860..26a9aea8b 100644 --- a/packages/vinext/src/shims/metadata.tsx +++ b/packages/vinext/src/shims/metadata.tsx @@ -290,52 +290,60 @@ export function mergeMetadata(metadataList: Metadata[]): Metadata { const merged: Metadata = {}; - // Track the most recent title template from LAYOUTS (not from page). - // The page is always the last entry in metadataList. - let parentTemplate: string | undefined; - for (let i = 0; i < metadataList.length; i++) { const meta = metadataList[i]; - const isPage = i === metadataList.length - 1; - - // Collect template from layouts only (page templates are ignored per Next.js spec) - if (!isPage && meta.title && typeof meta.title === "object" && meta.title.template) { - parentTemplate = meta.title.template; - } // Shallow merge — later entries override earlier for top-level keys for (const key of Object.keys(meta)) { if (key === "title") continue; // Handle title separately below (merged as Record)[key] = (meta as Record)[key]; } + } - // Title resolution - if (meta.title !== undefined) { - merged.title = meta.title; + let resolvedTitle: string | undefined; + let titleSourceIndex = -1; + let absoluteTitle = false; + + // Find the deepest segment that contributes the actual title string. + // `title.template` alone does not render a title; it only decorates child segments. + for (let i = metadataList.length - 1; i >= 0; i--) { + const title = metadataList[i].title; + if (!title) continue; + + if (typeof title === "string") { + resolvedTitle = title; + titleSourceIndex = i; + break; + } + + if (title.absolute) { + resolvedTitle = title.absolute; + titleSourceIndex = i; + absoluteTitle = true; + break; + } + + if (title.default) { + resolvedTitle = title.default; + titleSourceIndex = i; + break; } } - // Now resolve the final title, applying the parent template if applicable - const finalTitle = merged.title; - if (finalTitle) { - if (typeof finalTitle === "string") { - // Simple string title — apply parent template - if (parentTemplate) { - merged.title = parentTemplate.replace("%s", finalTitle); - } - } else if (typeof finalTitle === "object") { - if (finalTitle.absolute) { - // Absolute title — skip all templates - merged.title = finalTitle.absolute; - } else if (finalTitle.default) { - // Title object with default — this is used when the segment IS the - // defining layout (its own default doesn't get template-wrapped) - merged.title = finalTitle.default; - } else if (finalTitle.template && !finalTitle.default && !finalTitle.absolute) { - // Template only with no default — no title to render - merged.title = undefined; + if (resolvedTitle !== undefined) { + if (!absoluteTitle && titleSourceIndex > 0) { + // Only the nearest ancestor template applies. A nested layout template + // shadows any outer template for its descendants. + for (let i = titleSourceIndex - 1; i >= 0; i--) { + const title = metadataList[i].title; + if (title && typeof title === "object" && title.template) { + resolvedTitle = title.template.replace("%s", resolvedTitle); + break; + } } } + + merged.title = resolvedTitle; } return merged; diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 8682a2cb1..3abe3db1b 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -1786,6 +1786,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); let cleanPathname = pathname.replace(/\\.rsc$/, ""); + const navigationPathname = cleanPathname; // Middleware response headers and custom rewrite status are stored in // _mwCtx (per-request container) so handler() can merge them into @@ -1900,7 +1901,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Set navigation context for Server Components. // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params: {}, }); @@ -2016,7 +2017,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (match) { const { route: actionRoute, params: actionParams } = match; setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params: actionParams, }); @@ -2113,7 +2114,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Update navigation context with matched params setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params, }); @@ -2292,7 +2293,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (isForceStatic) { setHeadersContext({ headers: new Headers(), cookies: new Map() }); setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: new URLSearchParams(), params, }); @@ -2311,7 +2312,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { accessError: new Error(errorMsg), }); setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: new URLSearchParams(), params, }); @@ -2527,7 +2528,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const sourceMatch = matchRoute(sourceRoute.pattern); const sourceParams = sourceMatch ? sourceMatch.params : {}; setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params: intercept.matchedParams, }); @@ -4565,6 +4566,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); let cleanPathname = pathname.replace(/\\.rsc$/, ""); + const navigationPathname = cleanPathname; // Middleware response headers and custom rewrite status are stored in // _mwCtx (per-request container) so handler() can merge them into @@ -4679,7 +4681,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Set navigation context for Server Components. // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params: {}, }); @@ -4795,7 +4797,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (match) { const { route: actionRoute, params: actionParams } = match; setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params: actionParams, }); @@ -4892,7 +4894,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Update navigation context with matched params setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params, }); @@ -5071,7 +5073,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (isForceStatic) { setHeadersContext({ headers: new Headers(), cookies: new Map() }); setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: new URLSearchParams(), params, }); @@ -5090,7 +5092,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { accessError: new Error(errorMsg), }); setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: new URLSearchParams(), params, }); @@ -5306,7 +5308,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const sourceMatch = matchRoute(sourceRoute.pattern); const sourceParams = sourceMatch ? sourceMatch.params : {}; setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params: intercept.matchedParams, }); @@ -7371,6 +7373,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); let cleanPathname = pathname.replace(/\\.rsc$/, ""); + const navigationPathname = cleanPathname; // Middleware response headers and custom rewrite status are stored in // _mwCtx (per-request container) so handler() can merge them into @@ -7485,7 +7488,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Set navigation context for Server Components. // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params: {}, }); @@ -7601,7 +7604,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (match) { const { route: actionRoute, params: actionParams } = match; setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params: actionParams, }); @@ -7698,7 +7701,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Update navigation context with matched params setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params, }); @@ -7877,7 +7880,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (isForceStatic) { setHeadersContext({ headers: new Headers(), cookies: new Map() }); setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: new URLSearchParams(), params, }); @@ -7896,7 +7899,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { accessError: new Error(errorMsg), }); setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: new URLSearchParams(), params, }); @@ -8112,7 +8115,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const sourceMatch = matchRoute(sourceRoute.pattern); const sourceParams = sourceMatch ? sourceMatch.params : {}; setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params: intercept.matchedParams, }); @@ -10187,6 +10190,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); let cleanPathname = pathname.replace(/\\.rsc$/, ""); + const navigationPathname = cleanPathname; // Middleware response headers and custom rewrite status are stored in // _mwCtx (per-request container) so handler() can merge them into @@ -10301,7 +10305,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Set navigation context for Server Components. // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params: {}, }); @@ -10417,7 +10421,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (match) { const { route: actionRoute, params: actionParams } = match; setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params: actionParams, }); @@ -10514,7 +10518,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Update navigation context with matched params setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params, }); @@ -10693,7 +10697,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (isForceStatic) { setHeadersContext({ headers: new Headers(), cookies: new Map() }); setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: new URLSearchParams(), params, }); @@ -10712,7 +10716,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { accessError: new Error(errorMsg), }); setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: new URLSearchParams(), params, }); @@ -10928,7 +10932,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const sourceMatch = matchRoute(sourceRoute.pattern); const sourceParams = sourceMatch ? sourceMatch.params : {}; setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params: intercept.matchedParams, }); @@ -12970,6 +12974,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); let cleanPathname = pathname.replace(/\\.rsc$/, ""); + const navigationPathname = cleanPathname; // Middleware response headers and custom rewrite status are stored in // _mwCtx (per-request container) so handler() can merge them into @@ -13084,7 +13089,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Set navigation context for Server Components. // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params: {}, }); @@ -13200,7 +13205,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (match) { const { route: actionRoute, params: actionParams } = match; setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params: actionParams, }); @@ -13297,7 +13302,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Update navigation context with matched params setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params, }); @@ -13476,7 +13481,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (isForceStatic) { setHeadersContext({ headers: new Headers(), cookies: new Map() }); setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: new URLSearchParams(), params, }); @@ -13495,7 +13500,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { accessError: new Error(errorMsg), }); setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: new URLSearchParams(), params, }); @@ -13711,7 +13716,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const sourceMatch = matchRoute(sourceRoute.pattern); const sourceParams = sourceMatch ? sourceMatch.params : {}; setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params: intercept.matchedParams, }); @@ -15975,6 +15980,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); let cleanPathname = pathname.replace(/\\.rsc$/, ""); + const navigationPathname = cleanPathname; // Middleware response headers and custom rewrite status are stored in // _mwCtx (per-request container) so handler() can merge them into @@ -16177,7 +16183,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Set navigation context for Server Components. // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params: {}, }); @@ -16293,7 +16299,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (match) { const { route: actionRoute, params: actionParams } = match; setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params: actionParams, }); @@ -16390,7 +16396,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Update navigation context with matched params setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params, }); @@ -16569,7 +16575,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (isForceStatic) { setHeadersContext({ headers: new Headers(), cookies: new Map() }); setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: new URLSearchParams(), params, }); @@ -16588,7 +16594,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { accessError: new Error(errorMsg), }); setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: new URLSearchParams(), params, }); @@ -16804,7 +16810,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const sourceMatch = matchRoute(sourceRoute.pattern); const sourceParams = sourceMatch ? sourceMatch.params : {}; setNavigationContext({ - pathname: cleanPathname, + pathname: navigationPathname, searchParams: url.searchParams, params: intercept.matchedParams, }); diff --git a/tests/features.test.ts b/tests/features.test.ts index f530b34ed..93f3bc55a 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -2011,9 +2011,18 @@ describe("metadata title templates", () => { { title: { template: "%s | Site", default: "Site" } }, { title: { template: "%s - Page Template", default: "Page Default" } }, ]); - // The page's template should be ignored; the page's default is used - // because the page has a title object (not a string), so we use its default - expect(result.title).toBe("Page Default"); + // The page's own template is ignored, but its default title still behaves + // like the terminal title value and gets wrapped by the nearest ancestor template. + expect(result.title).toBe("Page Default | Site"); + }); + + it("applies the parent template to a nested layout default title", () => { + const result = mergeMetadata([ + { title: { template: "%s | Layout", default: "Layout" } }, + { title: { template: "%s | Extra Layout", default: "Extra Layout Default" } }, + {}, + ]); + expect(result.title).toBe("Extra Layout Default | Layout"); }); it("preserves non-title metadata during merge", () => { diff --git a/tests/fixtures/app-basic/app/nextjs-compat/api/(grouped)/endpoint/nested/route.ts b/tests/fixtures/app-basic/app/nextjs-compat/api/(grouped)/endpoint/nested/route.ts new file mode 100644 index 000000000..ead8cd1bb --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/api/(grouped)/endpoint/nested/route.ts @@ -0,0 +1,3 @@ +export async function GET() { + return new Response("grouped nested route handler"); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/api/cookies-has/route.ts b/tests/fixtures/app-basic/app/nextjs-compat/api/cookies-has/route.ts new file mode 100644 index 000000000..e2ca9b254 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/api/cookies-has/route.ts @@ -0,0 +1,10 @@ +import { cookies } from "next/headers"; + +export async function GET() { + const cookieStore = await cookies(); + + return Response.json({ + hasSession: cookieStore.has("session"), + hasMissing: cookieStore.has("missing"), + }); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/api/permanent-redirect/route.ts b/tests/fixtures/app-basic/app/nextjs-compat/api/permanent-redirect/route.ts new file mode 100644 index 000000000..9825ec854 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/api/permanent-redirect/route.ts @@ -0,0 +1,5 @@ +import { permanentRedirect } from "next/navigation"; + +export function GET() { + permanentRedirect("/about"); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/metadata-socials/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/metadata-socials/page.tsx new file mode 100644 index 000000000..c68a455a3 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/metadata-socials/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + other: { + "fb:app_id": "12345678", + "fb:admins": ["87654321", "11223344", "55667788"], + "pinterest-rich-pin": "false", + }, +}; + +export default function Page() { + return
Metadata socials page
; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/metadata-title-template/extra/inner/deep/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/metadata-title-template/extra/inner/deep/page.tsx new file mode 100644 index 000000000..5df521567 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/metadata-title-template/extra/inner/deep/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
Deep nested title template page
; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/metadata-title-template/extra/inner/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/metadata-title-template/extra/inner/page.tsx new file mode 100644 index 000000000..d71e19040 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/metadata-title-template/extra/inner/page.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Inner Page", +}; + +export default function Page() { + return
Nested title template page
; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/metadata-title-template/extra/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/metadata-title-template/extra/layout.tsx new file mode 100644 index 000000000..a98cc6871 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/metadata-title-template/extra/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: { + template: "%s | Extra Layout", + default: "extra layout default", + }, +}; + +export default function Layout({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/metadata-title-template/use-layout-title/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/metadata-title-template/use-layout-title/page.tsx new file mode 100644 index 000000000..7769c7cab --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/metadata-title-template/use-layout-title/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
Title template layout default page
; +} diff --git a/tests/fixtures/app-basic/app/not-found/page.tsx b/tests/fixtures/app-basic/app/not-found/page.tsx new file mode 100644 index 000000000..45620abef --- /dev/null +++ b/tests/fixtures/app-basic/app/not-found/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
This is the /not-found route
; +} diff --git a/tests/fixtures/app-basic/next.config.ts b/tests/fixtures/app-basic/next.config.ts index e779e321e..cf9e754c3 100644 --- a/tests/fixtures/app-basic/next.config.ts +++ b/tests/fixtures/app-basic/next.config.ts @@ -74,6 +74,16 @@ const nextConfig: NextConfig = { beforeFiles: [ // Used by Vitest: app-router.test.ts { source: "/rewrite-about", destination: "/about" }, + // Used by Vitest: tests/nextjs-compat/hooks.test.ts + { + source: "/nextjs-compat/hooks-rewrite-path", + destination: "/nextjs-compat/hooks-search", + }, + // Used by Vitest: tests/nextjs-compat/hooks.test.ts + { + source: "/nextjs-compat/hooks-rewrite-search", + destination: "/nextjs-compat/hooks-search", + }, // Used by Vitest: app-router.test.ts — repeated param substitution { source: "/repeat-rewrite/:slug", diff --git a/tests/nextjs-compat/app-routes.test.ts b/tests/nextjs-compat/app-routes.test.ts index f9ed48571..95804e0ad 100644 --- a/tests/nextjs-compat/app-routes.test.ts +++ b/tests/nextjs-compat/app-routes.test.ts @@ -96,6 +96,21 @@ describe("Next.js compat: app-routes", () => { expect(data.ping).toBe("pong"); }); + // Next.js: 'gets the correct values' (cookies.has) + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-routes/app-custom-routes.test.ts#L474-L481 + + it("can check cookie presence via cookies().has()", async () => { + const res = await fetch(`${baseUrl}/nextjs-compat/api/cookies-has`, { + headers: { cookie: "session=present" }, + }); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data).toEqual({ + hasSession: true, + hasMissing: false, + }); + }); + // ── JSON body ──────────────────────────────────────────────── // Next.js: 'can read a JSON encoded body' // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-routes/app-custom-routes.test.ts#L137-L149 @@ -232,6 +247,17 @@ describe("Next.js compat: app-routes", () => { expect(res.headers.get("location")).toContain("/about"); }); + // Next.js: 'can respond correctly' (permanentRedirect) + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-routes/app-custom-routes.test.ts#L498-L509 + + it("permanentRedirect() produces 308 with Location header", async () => { + const res = await fetch(`${baseUrl}/nextjs-compat/api/permanent-redirect`, { + redirect: "manual", + }); + expect(res.status).toBe(308); + expect(res.headers.get("location")).toContain("/about"); + }); + // ── notFound() in route handlers ───────────────────────────── // Next.js: 'can respond correctly in nodejs' (notFound) // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-routes/app-custom-routes.test.ts#L186-L191 @@ -293,6 +319,15 @@ describe("Next.js compat: app-routes", () => { expect(data.name).toBe("Widget"); }); + // Next.js: route groups are transparent for route handler matching + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-routes/app-custom-routes.test.ts#L166-L173 + + it("matches route handlers through transparent route groups", async () => { + const res = await fetch(`${baseUrl}/nextjs-compat/api/endpoint/nested`); + expect(res.status).toBe(200); + expect(await res.text()).toBe("grouped nested route handler"); + }); + // ── Route segment config: revalidate ──────────────────────── // Next.js: GET-only route handlers with `export const revalidate = N` // get Cache-Control: s-maxage=N, stale-while-revalidate @@ -357,8 +392,6 @@ describe("Next.js compat: app-routes", () => { // N/A: 'abort via a request' (various methods) // Tests AbortController on fetch — needs stderr inspection not available in vitest // - // N/A: 'route groups' — Tests (group) routing syntax, separate feature - // // N/A: 'can handle a streaming request and streaming response' // Tests streaming body upload — complex streaming setup // @@ -378,7 +411,5 @@ describe("Next.js compat: app-routes", () => { // // N/A: 'no response returned' — Tests console error inspection // - // N/A: 'permanentRedirect' — Would need fixture, minor variant of redirect - // // N/A: 'catch-all routes' — Would need fixture with [...slug] route handler }); diff --git a/tests/nextjs-compat/hooks.test.ts b/tests/nextjs-compat/hooks.test.ts index 84a900dd8..f190776cd 100644 --- a/tests/nextjs-compat/hooks.test.ts +++ b/tests/nextjs-compat/hooks.test.ts @@ -125,6 +125,27 @@ describe("Next.js compat: hooks", () => { expect(html).toContain("/nextjs-compat/hooks-search"); }); + // Next.js: rewrite hook canonical URL coverage + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/hooks/hooks.test.ts#L43-L47 + + it("usePathname returns the canonical requested pathname after a rewrite", async () => { + const { html } = await fetchHtml(baseUrl, "/nextjs-compat/hooks-rewrite-path"); + expect(html).toContain('

/nextjs-compat/hooks-rewrite-path

'); + }); + + // Next.js: rewrite searchParams canonical URL coverage + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/hooks/hooks.test.ts#L64-L72 + + it("useSearchParams preserves the requested query string after a rewrite", async () => { + const { html } = await fetchHtml( + baseUrl, + "/nextjs-compat/hooks-rewrite-search?q=rewritten&page=9", + ); + expect(html).toContain('

rewritten

'); + expect(html).toContain('

9

'); + expect(html).toContain('

q=rewritten&page=9

'); + }); + // ── useRouter SSR ─────────────────────────────────────────── // Next.js: 'should have the correct hooks at /adapter-hooks/1' // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/hooks/hooks.test.ts diff --git a/tests/nextjs-compat/metadata-suspense.test.ts b/tests/nextjs-compat/metadata-suspense.test.ts index 746da9f92..83b7e7d50 100644 --- a/tests/nextjs-compat/metadata-suspense.test.ts +++ b/tests/nextjs-compat/metadata-suspense.test.ts @@ -40,6 +40,20 @@ describe("Next.js compat: metadata-suspense", () => { ); }); + it("should render metadata in head for HTML-limited bot user agents", async () => { + const { html } = await fetchHtml(ctx.baseUrl, "/nextjs-compat/metadata-suspense-test", { + headers: { + "User-Agent": "Discordbot/2.0;", + }, + }); + + expect(html).toContain("Suspense Metadata Title"); + expect(html).toMatch(/App Basic, which made metadata routes appear to emit // duplicate titles. Keep this assertion live so metadata fixtures rely on diff --git a/tests/nextjs-compat/metadata.test.ts b/tests/nextjs-compat/metadata.test.ts index 463ad5460..ebf098e24 100644 --- a/tests/nextjs-compat/metadata.test.ts +++ b/tests/nextjs-compat/metadata.test.ts @@ -73,6 +73,33 @@ describe("Next.js compat: metadata", () => { expect(html).toContain("Extra Page | Layout"); }); + // Next.js: 'should support title template default fallback' + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/metadata/metadata.test.ts#L49-L54 + + it("should fall back to the layout default title when the page exports no title", async () => { + const { html } = await fetchHtml( + baseUrl, + "/nextjs-compat/metadata-title-template/use-layout-title", + ); + expect(html).toContain("title template layout default"); + }); + + // Next.js: 'should support stashed title in multiple layers' + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/metadata/metadata.test.ts#L56-L62 + + it("should prefer the nearest nested title template when a child page provides a title", async () => { + const { html } = await fetchHtml(baseUrl, "/nextjs-compat/metadata-title-template/extra/inner"); + expect(html).toContain("Inner Page | Extra Layout"); + }); + + it("should compose nested default titles back through the parent template", async () => { + const { html } = await fetchHtml( + baseUrl, + "/nextjs-compat/metadata-title-template/extra/inner/deep", + ); + expect(html).toContain("extra layout default | Layout"); + }); + // ── Basic metadata tags ────────────────────────────────────── // Next.js: 'should support other basic tags' // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/metadata/metadata.test.ts#L52-L89 @@ -361,6 +388,22 @@ describe("Next.js compat: metadata", () => { expect(html).toContain('property="al:web:should_fallback" content="true"'); }); + // ── Social metadata tags ────────────── + // Ported from Next.js: test/e2e/app-dir/metadata/metadata.test.ts + // 'should support socials related tags' + + it("should render facebook and pinterest social metadata tags", async () => { + const { html } = await fetchHtml(baseUrl, "/nextjs-compat/metadata-socials"); + expect(html).toContain('name="fb:app_id" content="12345678"'); + expect(html).toContain('name="pinterest-rich-pin" content="false"'); + + const adminMatches = html.match(/name="fb:admins" content="/g); + expect(adminMatches).toHaveLength(3); + expect(html).toContain('name="fb:admins" content="87654321"'); + expect(html).toContain('name="fb:admins" content="11223344"'); + expect(html).toContain('name="fb:admins" content="55667788"'); + }); + // ── Twitter player cards ────────────── // Ported from Next.js: test/e2e/app-dir/metadata/metadata.test.ts // 'should support twitter player/app cards' @@ -400,9 +443,6 @@ describe("Next.js compat: metadata", () => { // N/A: 'should support title template' (browser eval) // Some template tests use browser.eval('document.title') — ported above at SSR level // - // N/A: 'should support socials related tags' - // Would need dedicated fixture page (fb:app_id, pinterest) - // // N/A: 'should support verification tags' // Would need dedicated fixture page // diff --git a/tests/nextjs-compat/not-found.test.ts b/tests/nextjs-compat/not-found.test.ts index d7eabc84a..ca3985c3c 100644 --- a/tests/nextjs-compat/not-found.test.ts +++ b/tests/nextjs-compat/not-found.test.ts @@ -61,6 +61,15 @@ describe("Next.js compat: not-found", () => { expect(html).toContain(''); }); + // Next.js: conflict route should coexist with special app/not-found.tsx + // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/not-found/conflict-route/index.test.ts#L14-L25 + + it("should allow a real /not-found route alongside the special root not-found file", async () => { + const { res, html } = await fetchHtml(baseUrl, "/not-found"); + expect(res.status).toBe(200); + expect(html).toContain("This is the /not-found route"); + }); + // ── Shell notFound() ─────────────────────────────────────── // Next.js: it('should return 404 status if notFound() is called in shell', ...) // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/not-found/basic/index.test.ts#L59-L63 From 4867f38717eac98276dc9c1579e2f47567c789d0 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Wed, 11 Mar 2026 22:04:25 -0500 Subject: [PATCH 2/8] jstowell/fix-nextjs-metadata-and-rewrite-parity --- packages/vinext/src/entries/app-rsc-entry.ts | 6 +- packages/vinext/src/shims/metadata.tsx | 104 +++++++++++------- .../entry-templates.test.ts.snap | 36 +++--- tests/features.test.ts | 21 ++++ .../metadata-parent-generate/layout.tsx | 4 + .../metadata-parent-generate/page.tsx | 3 + tests/fixtures/app-basic/middleware.ts | 6 + tests/nextjs-compat/hooks.test.ts | 5 + tests/nextjs-compat/metadata.test.ts | 18 ++- 9 files changed, 137 insertions(+), 66 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 8bd05c7bd..a561a54aa 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -306,7 +306,7 @@ import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, import { NextRequest, NextFetchEvent } from "next/server"; import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; import { LayoutSegmentProvider } from "vinext/layout-segment-context"; -import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; +import { MetadataHead, mergeMetadata, mergeMetadataForParent, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; ${middlewarePath ? `import * as middlewareModule from ${JSON.stringify(middlewarePath.replace(/\\/g, "/"))};` : ""} ${instrumentationPath ? `import * as _instrumentation from ${JSON.stringify(instrumentationPath.replace(/\\/g, "/"))};` : ""} ${effectiveMetaRoutes.length > 0 ? `import { sitemapToXml, robotsToText, manifestToJson } from ${JSON.stringify(fileURLToPath(new URL("../server/metadata-routes.js", import.meta.url)).replace(/\\/g, "/"))};` : ""} @@ -702,7 +702,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req .catch((err) => { console.error("[vinext] Layout generateMetadata() failed:", err); return null; }); _layoutMetaPromises.push(_metaP); _accumulatedMeta = _metaP.then(async (_r) => - _r ? mergeMetadata([await _parentForLayout, _r]) : await _parentForLayout + _r ? mergeMetadataForParent([await _parentForLayout, _r]) : await _parentForLayout ); } const [_metaResults, _vpResults] = await Promise.all([ @@ -1077,7 +1077,7 @@ async function buildPageElement(route, params, opts, searchParams) { layoutMetaPromises.push(metaPromise); // Advance accumulator: resolves to merged(layouts[0..i]) once layout[i] is done. accumulatedMetaPromise = metaPromise.then(async (result) => - result ? mergeMetadata([await parentForThisLayout, result]) : await parentForThisLayout + result ? mergeMetadataForParent([await parentForThisLayout, result]) : await parentForThisLayout ); } // Page's parent is the fully-accumulated layout metadata. diff --git a/packages/vinext/src/shims/metadata.tsx b/packages/vinext/src/shims/metadata.tsx index 26a9aea8b..047c83fc9 100644 --- a/packages/vinext/src/shims/metadata.tsx +++ b/packages/vinext/src/shims/metadata.tsx @@ -274,6 +274,55 @@ interface TwitterAppDescriptor { name?: string; } +interface ResolvedTitleState { + absolute?: string; + template?: string; +} + +function applyTitleTemplate(template: string | undefined, title: string) { + return template ? template.replace(/%s/g, title) : title; +} + +function resolveTitleState( + metadataList: Metadata[], + { terminal = true }: { terminal?: boolean } = {}, +): ResolvedTitleState | null { + let resolvedAbsolute: string | undefined; + let deepestTemplate: string | undefined; + let stashedTemplate: string | undefined; + let sawTitle = false; + const templateCarrierIndex = terminal ? metadataList.length - 2 : metadataList.length - 1; + + for (let i = 0; i < metadataList.length; i++) { + const title = metadataList[i].title; + if (!title) continue; + + sawTitle = true; + const titleTemplate = typeof title === "object" ? title.template : undefined; + + if (typeof title === "string") { + resolvedAbsolute = applyTitleTemplate(stashedTemplate, title); + } else { + if (title.default !== undefined) { + resolvedAbsolute = applyTitleTemplate(stashedTemplate, title.default); + } + if (title.absolute) { + resolvedAbsolute = title.absolute; + } + } + + if (titleTemplate && i <= templateCarrierIndex) { + deepestTemplate = titleTemplate; + } + if (titleTemplate) { + stashedTemplate = titleTemplate; + } + } + + if (!sawTitle) return null; + return { absolute: resolvedAbsolute, template: deepestTemplate }; +} + /** * Merge metadata from multiple sources (layouts + page). * @@ -300,50 +349,23 @@ export function mergeMetadata(metadataList: Metadata[]): Metadata { } } - let resolvedTitle: string | undefined; - let titleSourceIndex = -1; - let absoluteTitle = false; - - // Find the deepest segment that contributes the actual title string. - // `title.template` alone does not render a title; it only decorates child segments. - for (let i = metadataList.length - 1; i >= 0; i--) { - const title = metadataList[i].title; - if (!title) continue; - - if (typeof title === "string") { - resolvedTitle = title; - titleSourceIndex = i; - break; - } - - if (title.absolute) { - resolvedTitle = title.absolute; - titleSourceIndex = i; - absoluteTitle = true; - break; - } - - if (title.default) { - resolvedTitle = title.default; - titleSourceIndex = i; - break; - } + const resolvedTitle = resolveTitleState(metadataList); + if (resolvedTitle?.absolute !== undefined) { + merged.title = resolvedTitle.absolute; } - if (resolvedTitle !== undefined) { - if (!absoluteTitle && titleSourceIndex > 0) { - // Only the nearest ancestor template applies. A nested layout template - // shadows any outer template for its descendants. - for (let i = titleSourceIndex - 1; i >= 0; i--) { - const title = metadataList[i].title; - if (title && typeof title === "object" && title.template) { - resolvedTitle = title.template.replace("%s", resolvedTitle); - break; - } - } - } + return merged; +} + +export function mergeMetadataForParent(metadataList: Metadata[]): Metadata { + const merged = mergeMetadata(metadataList); + const resolvedTitle = resolveTitleState(metadataList, { terminal: false }); - merged.title = resolvedTitle; + if (resolvedTitle) { + merged.title = { + absolute: resolvedTitle.absolute || "", + ...(resolvedTitle.template ? { template: resolvedTitle.template } : {}), + }; } return merged; diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 3abe3db1b..79bf5b9c5 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -348,7 +348,7 @@ import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, import { NextRequest, NextFetchEvent } from "next/server"; import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; import { LayoutSegmentProvider } from "vinext/layout-segment-context"; -import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; +import { MetadataHead, mergeMetadata, mergeMetadataForParent, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; @@ -825,7 +825,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req .catch((err) => { console.error("[vinext] Layout generateMetadata() failed:", err); return null; }); _layoutMetaPromises.push(_metaP); _accumulatedMeta = _metaP.then(async (_r) => - _r ? mergeMetadata([await _parentForLayout, _r]) : await _parentForLayout + _r ? mergeMetadataForParent([await _parentForLayout, _r]) : await _parentForLayout ); } const [_metaResults, _vpResults] = await Promise.all([ @@ -1167,7 +1167,7 @@ async function buildPageElement(route, params, opts, searchParams) { layoutMetaPromises.push(metaPromise); // Advance accumulator: resolves to merged(layouts[0..i]) once layout[i] is done. accumulatedMetaPromise = metaPromise.then(async (result) => - result ? mergeMetadata([await parentForThisLayout, result]) : await parentForThisLayout + result ? mergeMetadataForParent([await parentForThisLayout, result]) : await parentForThisLayout ); } // Page's parent is the fully-accumulated layout metadata. @@ -3125,7 +3125,7 @@ import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, import { NextRequest, NextFetchEvent } from "next/server"; import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; import { LayoutSegmentProvider } from "vinext/layout-segment-context"; -import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; +import { MetadataHead, mergeMetadata, mergeMetadataForParent, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; @@ -3602,7 +3602,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req .catch((err) => { console.error("[vinext] Layout generateMetadata() failed:", err); return null; }); _layoutMetaPromises.push(_metaP); _accumulatedMeta = _metaP.then(async (_r) => - _r ? mergeMetadata([await _parentForLayout, _r]) : await _parentForLayout + _r ? mergeMetadataForParent([await _parentForLayout, _r]) : await _parentForLayout ); } const [_metaResults, _vpResults] = await Promise.all([ @@ -3944,7 +3944,7 @@ async function buildPageElement(route, params, opts, searchParams) { layoutMetaPromises.push(metaPromise); // Advance accumulator: resolves to merged(layouts[0..i]) once layout[i] is done. accumulatedMetaPromise = metaPromise.then(async (result) => - result ? mergeMetadata([await parentForThisLayout, result]) : await parentForThisLayout + result ? mergeMetadataForParent([await parentForThisLayout, result]) : await parentForThisLayout ); } // Page's parent is the fully-accumulated layout metadata. @@ -5905,7 +5905,7 @@ import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, import { NextRequest, NextFetchEvent } from "next/server"; import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; import { LayoutSegmentProvider } from "vinext/layout-segment-context"; -import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; +import { MetadataHead, mergeMetadata, mergeMetadataForParent, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; @@ -6383,7 +6383,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req .catch((err) => { console.error("[vinext] Layout generateMetadata() failed:", err); return null; }); _layoutMetaPromises.push(_metaP); _accumulatedMeta = _metaP.then(async (_r) => - _r ? mergeMetadata([await _parentForLayout, _r]) : await _parentForLayout + _r ? mergeMetadataForParent([await _parentForLayout, _r]) : await _parentForLayout ); } const [_metaResults, _vpResults] = await Promise.all([ @@ -6746,7 +6746,7 @@ async function buildPageElement(route, params, opts, searchParams) { layoutMetaPromises.push(metaPromise); // Advance accumulator: resolves to merged(layouts[0..i]) once layout[i] is done. accumulatedMetaPromise = metaPromise.then(async (result) => - result ? mergeMetadata([await parentForThisLayout, result]) : await parentForThisLayout + result ? mergeMetadataForParent([await parentForThisLayout, result]) : await parentForThisLayout ); } // Page's parent is the fully-accumulated layout metadata. @@ -8720,7 +8720,7 @@ import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, import { NextRequest, NextFetchEvent } from "next/server"; import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; import { LayoutSegmentProvider } from "vinext/layout-segment-context"; -import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; +import { MetadataHead, mergeMetadata, mergeMetadataForParent, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; import * as _instrumentation from "/tmp/test/instrumentation.ts"; @@ -9226,7 +9226,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req .catch((err) => { console.error("[vinext] Layout generateMetadata() failed:", err); return null; }); _layoutMetaPromises.push(_metaP); _accumulatedMeta = _metaP.then(async (_r) => - _r ? mergeMetadata([await _parentForLayout, _r]) : await _parentForLayout + _r ? mergeMetadataForParent([await _parentForLayout, _r]) : await _parentForLayout ); } const [_metaResults, _vpResults] = await Promise.all([ @@ -9568,7 +9568,7 @@ async function buildPageElement(route, params, opts, searchParams) { layoutMetaPromises.push(metaPromise); // Advance accumulator: resolves to merged(layouts[0..i]) once layout[i] is done. accumulatedMetaPromise = metaPromise.then(async (result) => - result ? mergeMetadata([await parentForThisLayout, result]) : await parentForThisLayout + result ? mergeMetadataForParent([await parentForThisLayout, result]) : await parentForThisLayout ); } // Page's parent is the fully-accumulated layout metadata. @@ -11529,7 +11529,7 @@ import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, import { NextRequest, NextFetchEvent } from "next/server"; import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; import { LayoutSegmentProvider } from "vinext/layout-segment-context"; -import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; +import { MetadataHead, mergeMetadata, mergeMetadataForParent, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; import { sitemapToXml, robotsToText, manifestToJson } from "/packages/vinext/src/server/metadata-routes.js"; @@ -12013,7 +12013,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req .catch((err) => { console.error("[vinext] Layout generateMetadata() failed:", err); return null; }); _layoutMetaPromises.push(_metaP); _accumulatedMeta = _metaP.then(async (_r) => - _r ? mergeMetadata([await _parentForLayout, _r]) : await _parentForLayout + _r ? mergeMetadataForParent([await _parentForLayout, _r]) : await _parentForLayout ); } const [_metaResults, _vpResults] = await Promise.all([ @@ -12355,7 +12355,7 @@ async function buildPageElement(route, params, opts, searchParams) { layoutMetaPromises.push(metaPromise); // Advance accumulator: resolves to merged(layouts[0..i]) once layout[i] is done. accumulatedMetaPromise = metaPromise.then(async (result) => - result ? mergeMetadata([await parentForThisLayout, result]) : await parentForThisLayout + result ? mergeMetadataForParent([await parentForThisLayout, result]) : await parentForThisLayout ); } // Page's parent is the fully-accumulated layout metadata. @@ -14313,7 +14313,7 @@ import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, import { NextRequest, NextFetchEvent } from "next/server"; import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; import { LayoutSegmentProvider } from "vinext/layout-segment-context"; -import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; +import { MetadataHead, mergeMetadata, mergeMetadataForParent, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; import * as middlewareModule from "/tmp/test/middleware.ts"; @@ -14790,7 +14790,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req .catch((err) => { console.error("[vinext] Layout generateMetadata() failed:", err); return null; }); _layoutMetaPromises.push(_metaP); _accumulatedMeta = _metaP.then(async (_r) => - _r ? mergeMetadata([await _parentForLayout, _r]) : await _parentForLayout + _r ? mergeMetadataForParent([await _parentForLayout, _r]) : await _parentForLayout ); } const [_metaResults, _vpResults] = await Promise.all([ @@ -15132,7 +15132,7 @@ async function buildPageElement(route, params, opts, searchParams) { layoutMetaPromises.push(metaPromise); // Advance accumulator: resolves to merged(layouts[0..i]) once layout[i] is done. accumulatedMetaPromise = metaPromise.then(async (result) => - result ? mergeMetadata([await parentForThisLayout, result]) : await parentForThisLayout + result ? mergeMetadataForParent([await parentForThisLayout, result]) : await parentForThisLayout ); } // Page's parent is the fully-accumulated layout metadata. diff --git a/tests/features.test.ts b/tests/features.test.ts index 93f3bc55a..6bfdd7a02 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -1965,10 +1965,12 @@ describe("basePath + trailingSlash interaction", () => { describe("metadata title templates", () => { let mergeMetadata: typeof import("../packages/vinext/src/shims/metadata.js").mergeMetadata; + let mergeMetadataForParent: typeof import("../packages/vinext/src/shims/metadata.js").mergeMetadataForParent; beforeAll(async () => { const mod = await import("../packages/vinext/src/shims/metadata.js"); mergeMetadata = mod.mergeMetadata; + mergeMetadataForParent = mod.mergeMetadataForParent; }); it("applies layout template to child page string title", () => { @@ -2025,6 +2027,25 @@ describe("metadata title templates", () => { expect(result.title).toBe("Extra Layout Default | Layout"); }); + it("replaces every %s occurrence in a title template", () => { + const result = mergeMetadata([ + { title: { template: "%s | %s", default: "Layout" } }, + { title: "Inner" }, + ]); + expect(result.title).toBe("Inner | Inner"); + }); + + it("preserves resolved parent title.absolute and title.template for child metadata", () => { + const result = mergeMetadataForParent([ + { title: { template: "%s | Root", default: "Root" } }, + { title: { template: "%s | Nested", default: "Nested Default" } }, + ]); + expect(result.title).toEqual({ + absolute: "Nested Default | Root", + template: "%s | Nested", + }); + }); + it("preserves non-title metadata during merge", () => { const result = mergeMetadata([ { title: { template: "%s | Site", default: "Site" }, description: "Root desc" }, diff --git a/tests/fixtures/app-basic/app/nextjs-compat/metadata-parent-generate/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/metadata-parent-generate/layout.tsx index 93a190523..8a9cc86b8 100644 --- a/tests/fixtures/app-basic/app/nextjs-compat/metadata-parent-generate/layout.tsx +++ b/tests/fixtures/app-basic/app/nextjs-compat/metadata-parent-generate/layout.tsx @@ -4,6 +4,10 @@ import type { Metadata } from "next"; // The child page will access this via the `parent` parameter. export async function generateMetadata(): Promise { return { + title: { + default: "Parent Layout", + template: "%s | Parent Layout", + }, openGraph: { images: ["/base-image.jpg"], }, diff --git a/tests/fixtures/app-basic/app/nextjs-compat/metadata-parent-generate/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/metadata-parent-generate/page.tsx index 4d2f6302e..1c2912bc5 100644 --- a/tests/fixtures/app-basic/app/nextjs-compat/metadata-parent-generate/page.tsx +++ b/tests/fixtures/app-basic/app/nextjs-compat/metadata-parent-generate/page.tsx @@ -11,8 +11,11 @@ export async function generateMetadata( ): Promise { const parentMeta = await parent; const previousImages = parentMeta.openGraph?.images ?? []; + const parentTitle = typeof parentMeta.title === "object" ? parentMeta.title : null; return { title: "parent-generate page", + creator: parentTitle?.absolute, + description: parentTitle?.template ?? "missing-parent-title-template", openGraph: { images: ["/new-image.jpg", ...(previousImages as string[])], }, diff --git a/tests/fixtures/app-basic/middleware.ts b/tests/fixtures/app-basic/middleware.ts index 818699e56..b737ab92a 100644 --- a/tests/fixtures/app-basic/middleware.ts +++ b/tests/fixtures/app-basic/middleware.ts @@ -57,6 +57,10 @@ export function middleware(request: NextRequest) { ); } + if (pathname === "/nextjs-compat/hooks-middleware-rewrite") { + return NextResponse.rewrite(new URL("/nextjs-compat/hooks-search", request.url)); + } + // Rewrite with custom status code // Ref: opennextjs-cloudflare middleware.ts — NextResponse.rewrite with status if (pathname === "/middleware-rewrite-status") { @@ -158,6 +162,8 @@ export const config = { "/middleware-redirect", "/middleware-rewrite", "/middleware-rewrite-query", + "/nextjs-compat/hooks-middleware-rewrite", + "/middleware-rewrite-query", "/middleware-rewrite-status", "/middleware-blocked", "/middleware-throw", diff --git a/tests/nextjs-compat/hooks.test.ts b/tests/nextjs-compat/hooks.test.ts index f190776cd..2d9d71d84 100644 --- a/tests/nextjs-compat/hooks.test.ts +++ b/tests/nextjs-compat/hooks.test.ts @@ -133,6 +133,11 @@ describe("Next.js compat: hooks", () => { expect(html).toContain('

/nextjs-compat/hooks-rewrite-path

'); }); + it("usePathname returns the canonical requested pathname after a middleware rewrite", async () => { + const { html } = await fetchHtml(baseUrl, "/nextjs-compat/hooks-middleware-rewrite"); + expect(html).toContain('

/nextjs-compat/hooks-middleware-rewrite

'); + }); + // Next.js: rewrite searchParams canonical URL coverage // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/hooks/hooks.test.ts#L64-L72 diff --git a/tests/nextjs-compat/metadata.test.ts b/tests/nextjs-compat/metadata.test.ts index ebf098e24..8d99973bb 100644 --- a/tests/nextjs-compat/metadata.test.ts +++ b/tests/nextjs-compat/metadata.test.ts @@ -23,6 +23,10 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; import type { ViteDevServer } from "vite"; import { APP_FIXTURE_DIR, startFixtureServer, fetchHtml } from "../helpers.js"; +function getTitleTags(html: string) { + return Array.from(html.matchAll(/(.*?)<\/title>/g)).map((match) => match[1]); +} + describe("Next.js compat: metadata", () => { let server: ViteDevServer; let baseUrl: string; @@ -81,7 +85,7 @@ describe("Next.js compat: metadata", () => { baseUrl, "/nextjs-compat/metadata-title-template/use-layout-title", ); - expect(html).toContain("<title>title template layout default"); + expect(getTitleTags(html)).toEqual(["title template layout default"]); }); // Next.js: 'should support stashed title in multiple layers' @@ -89,7 +93,7 @@ describe("Next.js compat: metadata", () => { it("should prefer the nearest nested title template when a child page provides a title", async () => { const { html } = await fetchHtml(baseUrl, "/nextjs-compat/metadata-title-template/extra/inner"); - expect(html).toContain("Inner Page | Extra Layout"); + expect(getTitleTags(html)).toEqual(["Inner Page | Extra Layout"]); }); it("should compose nested default titles back through the parent template", async () => { @@ -97,7 +101,7 @@ describe("Next.js compat: metadata", () => { baseUrl, "/nextjs-compat/metadata-title-template/extra/inner/deep", ); - expect(html).toContain("extra layout default | Layout"); + expect(getTitleTags(html)).toEqual(["extra layout default | Layout"]); }); // ── Basic metadata tags ────────────────────────────────────── @@ -333,7 +337,13 @@ describe("Next.js compat: metadata", () => { it("should render page title from generateMetadata that uses parent", async () => { const { html } = await fetchHtml(baseUrl, "/nextjs-compat/metadata-parent-generate"); - expect(html).toContain("parent-generate page"); + expect(html).toContain("parent-generate page | Parent Layout"); + }); + + it("should expose resolved parent title.absolute and title.template to generateMetadata()", async () => { + const { html } = await fetchHtml(baseUrl, "/nextjs-compat/metadata-parent-generate"); + expect(html).toMatch(/meta\s+name="creator"\s+content="Parent Layout"/); + expect(html).toMatch(/meta\s+name="description"\s+content="%s \| Parent Layout"/); }); it("parent parameter should not be undefined (await parent must not throw)", async () => { From c1898db3e9945e48fc0bd740dc2ff2e8dab7fb11 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Wed, 11 Mar 2026 22:32:40 -0500 Subject: [PATCH 3/8] Fix PR 486 parity issues --- packages/vinext/src/entries/app-rsc-entry.ts | 6 ++- .../entry-templates.test.ts.snap | 36 +++++++++++++--- tests/app-router.test.ts | 41 +++++++++++++++++++ .../nextjs-compat/isr-rewrite-target/page.tsx | 12 ++++++ .../isr-rewrite-target/pathname-client.tsx | 9 ++++ tests/fixtures/app-basic/next.config.ts | 5 +++ 6 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/isr-rewrite-target/page.tsx create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/isr-rewrite-target/pathname-client.tsx diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index a561a54aa..93a696e6a 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -2261,7 +2261,11 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); - setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params }); + setNavigationContext({ + pathname: navigationPathname, + searchParams: url.searchParams, + params, + }); const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 79bf5b9c5..c0992f403 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -2395,7 +2395,11 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); - setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params }); + setNavigationContext({ + pathname: navigationPathname, + searchParams: url.searchParams, + params, + }); const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -5175,7 +5179,11 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); - setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params }); + setNavigationContext({ + pathname: navigationPathname, + searchParams: url.searchParams, + params, + }); const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -7982,7 +7990,11 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); - setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params }); + setNavigationContext({ + pathname: navigationPathname, + searchParams: url.searchParams, + params, + }); const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -10799,7 +10811,11 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); - setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params }); + setNavigationContext({ + pathname: navigationPathname, + searchParams: url.searchParams, + params, + }); const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -13583,7 +13599,11 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); - setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params }); + setNavigationContext({ + pathname: navigationPathname, + searchParams: url.searchParams, + params, + }); const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -16677,7 +16697,11 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); const __revalResult = await _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); - setNavigationContext({ pathname: cleanPathname, searchParams: url.searchParams, params }); + setNavigationContext({ + pathname: navigationPathname, + searchParams: url.searchParams, + params, + }); const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index eead11c3a..6ea1510a9 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -1682,6 +1682,47 @@ describe("App Router Production server (startProdServer)", () => { expect(reqId3).not.toBe(reqId1); expect(res3.headers.get("x-vinext-cache")).toBe("MISS"); }); + + it("preserves the canonical pathname when stale ISR regeneration runs on a rewritten route", async () => { + const sourcePath = "/nextjs-compat/isr-rewrite-source"; + + const res1 = await fetch(`${baseUrl}${sourcePath}`); + expect(res1.status).toBe(200); + const html1 = await res1.text(); + expect(html1).toContain(`

${sourcePath}

`); + expect(res1.headers.get("x-vinext-cache")).toBe("MISS"); + + const res2 = await fetch(`${baseUrl}${sourcePath}`); + expect(res2.status).toBe(200); + const html2 = await res2.text(); + expect(html2).toContain(`

${sourcePath}

`); + expect(res2.headers.get("x-vinext-cache")).toBe("HIT"); + + await new Promise((resolve) => setTimeout(resolve, 1_100)); + + const staleRes = await fetch(`${baseUrl}${sourcePath}`); + expect(staleRes.status).toBe(200); + const staleHtml = await staleRes.text(); + expect(staleHtml).toContain(`

${sourcePath}

`); + expect(staleRes.headers.get("x-vinext-cache")).toBe("STALE"); + + let hitHtml = ""; + let sawHit = false; + for (let attempt = 0; attempt < 10; attempt++) { + const hitRes = await fetch(`${baseUrl}${sourcePath}`); + expect(hitRes.status).toBe(200); + hitHtml = await hitRes.text(); + if (hitRes.headers.get("x-vinext-cache") === "HIT") { + sawHit = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + expect(sawHit).toBe(true); + expect(hitHtml).toContain(`

${sourcePath}

`); + expect(hitHtml).not.toContain('

/nextjs-compat/isr-rewrite-target

'); + }); }); describe("App Router Production server worker entry compatibility", () => { diff --git a/tests/fixtures/app-basic/app/nextjs-compat/isr-rewrite-target/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/isr-rewrite-target/page.tsx new file mode 100644 index 000000000..bdd520bd9 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/isr-rewrite-target/page.tsx @@ -0,0 +1,12 @@ +import { PathnameClient } from "./pathname-client"; + +export const revalidate = 1; + +export default function IsrRewriteTargetPage() { + return ( +
+

ISR Rewrite Target

+ +
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/isr-rewrite-target/pathname-client.tsx b/tests/fixtures/app-basic/app/nextjs-compat/isr-rewrite-target/pathname-client.tsx new file mode 100644 index 000000000..89a91e4fb --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/isr-rewrite-target/pathname-client.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { usePathname } from "next/navigation"; + +export function PathnameClient() { + const pathname = usePathname(); + + return

{pathname}

; +} diff --git a/tests/fixtures/app-basic/next.config.ts b/tests/fixtures/app-basic/next.config.ts index cf9e754c3..16154a289 100644 --- a/tests/fixtures/app-basic/next.config.ts +++ b/tests/fixtures/app-basic/next.config.ts @@ -84,6 +84,11 @@ const nextConfig: NextConfig = { source: "/nextjs-compat/hooks-rewrite-search", destination: "/nextjs-compat/hooks-search", }, + // Used by Vitest: tests/app-router.test.ts + { + source: "/nextjs-compat/isr-rewrite-source", + destination: "/nextjs-compat/isr-rewrite-target", + }, // Used by Vitest: app-router.test.ts — repeated param substitution { source: "/repeat-rewrite/:slug", From 5d5b8c7593cff41a2407e25e981026f5d75e17f0 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Wed, 11 Mar 2026 22:51:28 -0500 Subject: [PATCH 4/8] Fix ISR cache pathname keys --- packages/vinext/src/entries/app-rsc-entry.ts | 38 +-- .../entry-templates.test.ts.snap | 228 +++++++++--------- tests/app-router.test.ts | 17 +- 3 files changed, 156 insertions(+), 127 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 93a696e6a..75c82a6ac 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -2206,7 +2206,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { !isForceDynamic && revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity ) { - const __isrKey = isRscRequest ? __isrRscKey(cleanPathname) : __isrHtmlKey(cleanPathname); + const __cachePathname = navigationPathname; + const __isrKey = isRscRequest ? __isrRscKey(__cachePathname) : __isrHtmlKey(__cachePathname); try { const __cached = await __isrGet(__isrKey); if (__cached && !__cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_PAGE") { @@ -2214,7 +2215,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __hasRsc = !!__cachedValue.rscData; const __hasHtml = typeof __cachedValue.html === "string" && __cachedValue.html.length > 0; if (isRscRequest && __hasRsc) { - __isrDebug?.("HIT (RSC)", cleanPathname); + __isrDebug?.("HIT (RSC)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__cachedValue.rscData, { @@ -2228,7 +2229,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); } if (!isRscRequest && __hasHtml) { - __isrDebug?.("HIT (HTML)", cleanPathname); + __isrDebug?.("HIT (HTML)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__cachedValue.html, { @@ -2241,7 +2242,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, }); } - __isrDebug?.("MISS (empty cached entry)", cleanPathname); + __isrDebug?.("MISS (empty cached entry)", __cachePathname); } if (__cached && __cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_PAGE") { // Stale cache hit — serve stale immediately, trigger background regeneration. @@ -2249,7 +2250,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __staleValue = __cached.value.value; const __staleStatus = __staleValue.status || 200; const __revalSecs = revalidateSeconds; - __triggerBackgroundRegeneration(cleanPathname, async function() { + __triggerBackgroundRegeneration(__cachePathname, async function() { // Re-render the page to produce fresh HTML + RSC data for the cache // Use an empty headers context for background regeneration — not the original // user request — to prevent user-specific cookies/auth headers from leaking @@ -2304,18 +2305,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __revalChunks.push(__revalDecoder.decode()); const __freshHtml = __revalChunks.join(""); const __freshRscData = await __rscDataPromise; - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __pageTags = __pageCacheTags(__cachePathname, getCollectedFetchTags()); return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags }; }); // Write HTML and RSC to their own keys independently — no races await Promise.all([ - __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), - __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrHtmlKey(__cachePathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrRscKey(__cachePathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), ]); - __isrDebug?.("regen complete", cleanPathname); + __isrDebug?.("regen complete", __cachePathname); }); if (isRscRequest && __staleValue.rscData) { - __isrDebug?.("STALE (RSC)", cleanPathname); + __isrDebug?.("STALE (RSC)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__staleValue.rscData, { @@ -2329,7 +2330,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); } if (!isRscRequest && typeof __staleValue.html === "string" && __staleValue.html.length > 0) { - __isrDebug?.("STALE (HTML)", cleanPathname); + __isrDebug?.("STALE (HTML)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__staleValue.html, { @@ -2343,10 +2344,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); } // Stale entry exists but is empty for this request type — fall through to render - __isrDebug?.("STALE MISS (empty stale entry)", cleanPathname); + __isrDebug?.("STALE MISS (empty stale entry)", __cachePathname); } if (!__cached) { - __isrDebug?.("MISS (no cache entry)", cleanPathname); + __isrDebug?.("MISS (no cache entry)", __cachePathname); } } catch (__isrReadErr) { // Cache read failure — fall through to normal rendering @@ -2718,12 +2719,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { responseHeaders["X-Vinext-Cache"] = "MISS"; - const __isrKeyRsc = __isrRscKey(cleanPathname); + const __isrKeyRsc = __isrRscKey(__cachePathname); const __revalSecsRsc = revalidateSeconds; const __rscWritePromise = (async () => { try { const __rscDataForCache = await __isrRscDataPromise; - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __pageTags = __pageCacheTags(__cachePathname, getCollectedFetchTags()); await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: undefined, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); __isrDebug?.("RSC cache written", __isrKeyRsc); } catch (__rscWriteErr) { @@ -2897,6 +2898,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // revalidate=Infinity means "cache forever" (no periodic revalidation) — treated as // static here so we emit s-maxage=31536000 but skip ISR cache management. if (revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity) { + const __cachePathname = navigationPathname; // In production, tee the HTML response body to simultaneously stream to the // client and collect the full HTML string for the ISR cache. rscData was // already captured above by teeing the RSC stream before SSR. @@ -2913,8 +2915,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { })); if (__isrResponseProd.body) { const [__streamForClient, __streamForCache] = __isrResponseProd.body.tee(); - const __isrKey = __isrHtmlKey(cleanPathname); - const __isrKeyRscFromHtml = __isrRscKey(cleanPathname); + const __isrKey = __isrHtmlKey(__cachePathname); + const __isrKeyRscFromHtml = __isrRscKey(__cachePathname); const __revalSecs = revalidateSeconds; const __capturedRscDataPromise = __isrRscDataPromise; const __cachePromise = (async () => { @@ -2929,7 +2931,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __chunks.push(__decoder.decode()); const __fullHtml = __chunks.join(""); - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __pageTags = __pageCacheTags(__cachePathname, getCollectedFetchTags()); // Write HTML and RSC to their own keys independently. // RSC data was captured by the tee above (before isRscRequest branch) // so an initial browser visit (HTML request) also populates the RSC key, diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index c0992f403..9c73e9181 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -2340,7 +2340,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { !isForceDynamic && revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity ) { - const __isrKey = isRscRequest ? __isrRscKey(cleanPathname) : __isrHtmlKey(cleanPathname); + const __cachePathname = navigationPathname; + const __isrKey = isRscRequest ? __isrRscKey(__cachePathname) : __isrHtmlKey(__cachePathname); try { const __cached = await __isrGet(__isrKey); if (__cached && !__cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_PAGE") { @@ -2348,7 +2349,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __hasRsc = !!__cachedValue.rscData; const __hasHtml = typeof __cachedValue.html === "string" && __cachedValue.html.length > 0; if (isRscRequest && __hasRsc) { - __isrDebug?.("HIT (RSC)", cleanPathname); + __isrDebug?.("HIT (RSC)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__cachedValue.rscData, { @@ -2362,7 +2363,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); } if (!isRscRequest && __hasHtml) { - __isrDebug?.("HIT (HTML)", cleanPathname); + __isrDebug?.("HIT (HTML)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__cachedValue.html, { @@ -2375,7 +2376,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, }); } - __isrDebug?.("MISS (empty cached entry)", cleanPathname); + __isrDebug?.("MISS (empty cached entry)", __cachePathname); } if (__cached && __cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_PAGE") { // Stale cache hit — serve stale immediately, trigger background regeneration. @@ -2383,7 +2384,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __staleValue = __cached.value.value; const __staleStatus = __staleValue.status || 200; const __revalSecs = revalidateSeconds; - __triggerBackgroundRegeneration(cleanPathname, async function() { + __triggerBackgroundRegeneration(__cachePathname, async function() { // Re-render the page to produce fresh HTML + RSC data for the cache // Use an empty headers context for background regeneration — not the original // user request — to prevent user-specific cookies/auth headers from leaking @@ -2438,18 +2439,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __revalChunks.push(__revalDecoder.decode()); const __freshHtml = __revalChunks.join(""); const __freshRscData = await __rscDataPromise; - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __pageTags = __pageCacheTags(__cachePathname, getCollectedFetchTags()); return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags }; }); // Write HTML and RSC to their own keys independently — no races await Promise.all([ - __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), - __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrHtmlKey(__cachePathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrRscKey(__cachePathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), ]); - __isrDebug?.("regen complete", cleanPathname); + __isrDebug?.("regen complete", __cachePathname); }); if (isRscRequest && __staleValue.rscData) { - __isrDebug?.("STALE (RSC)", cleanPathname); + __isrDebug?.("STALE (RSC)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__staleValue.rscData, { @@ -2463,7 +2464,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); } if (!isRscRequest && typeof __staleValue.html === "string" && __staleValue.html.length > 0) { - __isrDebug?.("STALE (HTML)", cleanPathname); + __isrDebug?.("STALE (HTML)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__staleValue.html, { @@ -2477,10 +2478,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); } // Stale entry exists but is empty for this request type — fall through to render - __isrDebug?.("STALE MISS (empty stale entry)", cleanPathname); + __isrDebug?.("STALE MISS (empty stale entry)", __cachePathname); } if (!__cached) { - __isrDebug?.("MISS (no cache entry)", cleanPathname); + __isrDebug?.("MISS (no cache entry)", __cachePathname); } } catch (__isrReadErr) { // Cache read failure — fall through to normal rendering @@ -2852,12 +2853,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { responseHeaders["X-Vinext-Cache"] = "MISS"; - const __isrKeyRsc = __isrRscKey(cleanPathname); + const __isrKeyRsc = __isrRscKey(__cachePathname); const __revalSecsRsc = revalidateSeconds; const __rscWritePromise = (async () => { try { const __rscDataForCache = await __isrRscDataPromise; - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __pageTags = __pageCacheTags(__cachePathname, getCollectedFetchTags()); await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: undefined, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); __isrDebug?.("RSC cache written", __isrKeyRsc); } catch (__rscWriteErr) { @@ -3019,6 +3020,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // revalidate=Infinity means "cache forever" (no periodic revalidation) — treated as // static here so we emit s-maxage=31536000 but skip ISR cache management. if (revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity) { + const __cachePathname = navigationPathname; // In production, tee the HTML response body to simultaneously stream to the // client and collect the full HTML string for the ISR cache. rscData was // already captured above by teeing the RSC stream before SSR. @@ -3035,8 +3037,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { })); if (__isrResponseProd.body) { const [__streamForClient, __streamForCache] = __isrResponseProd.body.tee(); - const __isrKey = __isrHtmlKey(cleanPathname); - const __isrKeyRscFromHtml = __isrRscKey(cleanPathname); + const __isrKey = __isrHtmlKey(__cachePathname); + const __isrKeyRscFromHtml = __isrRscKey(__cachePathname); const __revalSecs = revalidateSeconds; const __capturedRscDataPromise = __isrRscDataPromise; const __cachePromise = (async () => { @@ -3051,7 +3053,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __chunks.push(__decoder.decode()); const __fullHtml = __chunks.join(""); - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __pageTags = __pageCacheTags(__cachePathname, getCollectedFetchTags()); // Write HTML and RSC to their own keys independently. // RSC data was captured by the tee above (before isRscRequest branch) // so an initial browser visit (HTML request) also populates the RSC key, @@ -5124,7 +5126,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { !isForceDynamic && revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity ) { - const __isrKey = isRscRequest ? __isrRscKey(cleanPathname) : __isrHtmlKey(cleanPathname); + const __cachePathname = navigationPathname; + const __isrKey = isRscRequest ? __isrRscKey(__cachePathname) : __isrHtmlKey(__cachePathname); try { const __cached = await __isrGet(__isrKey); if (__cached && !__cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_PAGE") { @@ -5132,7 +5135,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __hasRsc = !!__cachedValue.rscData; const __hasHtml = typeof __cachedValue.html === "string" && __cachedValue.html.length > 0; if (isRscRequest && __hasRsc) { - __isrDebug?.("HIT (RSC)", cleanPathname); + __isrDebug?.("HIT (RSC)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__cachedValue.rscData, { @@ -5146,7 +5149,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); } if (!isRscRequest && __hasHtml) { - __isrDebug?.("HIT (HTML)", cleanPathname); + __isrDebug?.("HIT (HTML)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__cachedValue.html, { @@ -5159,7 +5162,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, }); } - __isrDebug?.("MISS (empty cached entry)", cleanPathname); + __isrDebug?.("MISS (empty cached entry)", __cachePathname); } if (__cached && __cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_PAGE") { // Stale cache hit — serve stale immediately, trigger background regeneration. @@ -5167,7 +5170,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __staleValue = __cached.value.value; const __staleStatus = __staleValue.status || 200; const __revalSecs = revalidateSeconds; - __triggerBackgroundRegeneration(cleanPathname, async function() { + __triggerBackgroundRegeneration(__cachePathname, async function() { // Re-render the page to produce fresh HTML + RSC data for the cache // Use an empty headers context for background regeneration — not the original // user request — to prevent user-specific cookies/auth headers from leaking @@ -5222,18 +5225,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __revalChunks.push(__revalDecoder.decode()); const __freshHtml = __revalChunks.join(""); const __freshRscData = await __rscDataPromise; - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __pageTags = __pageCacheTags(__cachePathname, getCollectedFetchTags()); return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags }; }); // Write HTML and RSC to their own keys independently — no races await Promise.all([ - __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), - __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrHtmlKey(__cachePathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrRscKey(__cachePathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), ]); - __isrDebug?.("regen complete", cleanPathname); + __isrDebug?.("regen complete", __cachePathname); }); if (isRscRequest && __staleValue.rscData) { - __isrDebug?.("STALE (RSC)", cleanPathname); + __isrDebug?.("STALE (RSC)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__staleValue.rscData, { @@ -5247,7 +5250,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); } if (!isRscRequest && typeof __staleValue.html === "string" && __staleValue.html.length > 0) { - __isrDebug?.("STALE (HTML)", cleanPathname); + __isrDebug?.("STALE (HTML)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__staleValue.html, { @@ -5261,10 +5264,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); } // Stale entry exists but is empty for this request type — fall through to render - __isrDebug?.("STALE MISS (empty stale entry)", cleanPathname); + __isrDebug?.("STALE MISS (empty stale entry)", __cachePathname); } if (!__cached) { - __isrDebug?.("MISS (no cache entry)", cleanPathname); + __isrDebug?.("MISS (no cache entry)", __cachePathname); } } catch (__isrReadErr) { // Cache read failure — fall through to normal rendering @@ -5636,12 +5639,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { responseHeaders["X-Vinext-Cache"] = "MISS"; - const __isrKeyRsc = __isrRscKey(cleanPathname); + const __isrKeyRsc = __isrRscKey(__cachePathname); const __revalSecsRsc = revalidateSeconds; const __rscWritePromise = (async () => { try { const __rscDataForCache = await __isrRscDataPromise; - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __pageTags = __pageCacheTags(__cachePathname, getCollectedFetchTags()); await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: undefined, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); __isrDebug?.("RSC cache written", __isrKeyRsc); } catch (__rscWriteErr) { @@ -5803,6 +5806,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // revalidate=Infinity means "cache forever" (no periodic revalidation) — treated as // static here so we emit s-maxage=31536000 but skip ISR cache management. if (revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity) { + const __cachePathname = navigationPathname; // In production, tee the HTML response body to simultaneously stream to the // client and collect the full HTML string for the ISR cache. rscData was // already captured above by teeing the RSC stream before SSR. @@ -5819,8 +5823,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { })); if (__isrResponseProd.body) { const [__streamForClient, __streamForCache] = __isrResponseProd.body.tee(); - const __isrKey = __isrHtmlKey(cleanPathname); - const __isrKeyRscFromHtml = __isrRscKey(cleanPathname); + const __isrKey = __isrHtmlKey(__cachePathname); + const __isrKeyRscFromHtml = __isrRscKey(__cachePathname); const __revalSecs = revalidateSeconds; const __capturedRscDataPromise = __isrRscDataPromise; const __cachePromise = (async () => { @@ -5835,7 +5839,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __chunks.push(__decoder.decode()); const __fullHtml = __chunks.join(""); - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __pageTags = __pageCacheTags(__cachePathname, getCollectedFetchTags()); // Write HTML and RSC to their own keys independently. // RSC data was captured by the tee above (before isRscRequest branch) // so an initial browser visit (HTML request) also populates the RSC key, @@ -7935,7 +7939,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { !isForceDynamic && revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity ) { - const __isrKey = isRscRequest ? __isrRscKey(cleanPathname) : __isrHtmlKey(cleanPathname); + const __cachePathname = navigationPathname; + const __isrKey = isRscRequest ? __isrRscKey(__cachePathname) : __isrHtmlKey(__cachePathname); try { const __cached = await __isrGet(__isrKey); if (__cached && !__cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_PAGE") { @@ -7943,7 +7948,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __hasRsc = !!__cachedValue.rscData; const __hasHtml = typeof __cachedValue.html === "string" && __cachedValue.html.length > 0; if (isRscRequest && __hasRsc) { - __isrDebug?.("HIT (RSC)", cleanPathname); + __isrDebug?.("HIT (RSC)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__cachedValue.rscData, { @@ -7957,7 +7962,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); } if (!isRscRequest && __hasHtml) { - __isrDebug?.("HIT (HTML)", cleanPathname); + __isrDebug?.("HIT (HTML)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__cachedValue.html, { @@ -7970,7 +7975,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, }); } - __isrDebug?.("MISS (empty cached entry)", cleanPathname); + __isrDebug?.("MISS (empty cached entry)", __cachePathname); } if (__cached && __cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_PAGE") { // Stale cache hit — serve stale immediately, trigger background regeneration. @@ -7978,7 +7983,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __staleValue = __cached.value.value; const __staleStatus = __staleValue.status || 200; const __revalSecs = revalidateSeconds; - __triggerBackgroundRegeneration(cleanPathname, async function() { + __triggerBackgroundRegeneration(__cachePathname, async function() { // Re-render the page to produce fresh HTML + RSC data for the cache // Use an empty headers context for background regeneration — not the original // user request — to prevent user-specific cookies/auth headers from leaking @@ -8033,18 +8038,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __revalChunks.push(__revalDecoder.decode()); const __freshHtml = __revalChunks.join(""); const __freshRscData = await __rscDataPromise; - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __pageTags = __pageCacheTags(__cachePathname, getCollectedFetchTags()); return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags }; }); // Write HTML and RSC to their own keys independently — no races await Promise.all([ - __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), - __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrHtmlKey(__cachePathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrRscKey(__cachePathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), ]); - __isrDebug?.("regen complete", cleanPathname); + __isrDebug?.("regen complete", __cachePathname); }); if (isRscRequest && __staleValue.rscData) { - __isrDebug?.("STALE (RSC)", cleanPathname); + __isrDebug?.("STALE (RSC)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__staleValue.rscData, { @@ -8058,7 +8063,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); } if (!isRscRequest && typeof __staleValue.html === "string" && __staleValue.html.length > 0) { - __isrDebug?.("STALE (HTML)", cleanPathname); + __isrDebug?.("STALE (HTML)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__staleValue.html, { @@ -8072,10 +8077,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); } // Stale entry exists but is empty for this request type — fall through to render - __isrDebug?.("STALE MISS (empty stale entry)", cleanPathname); + __isrDebug?.("STALE MISS (empty stale entry)", __cachePathname); } if (!__cached) { - __isrDebug?.("MISS (no cache entry)", cleanPathname); + __isrDebug?.("MISS (no cache entry)", __cachePathname); } } catch (__isrReadErr) { // Cache read failure — fall through to normal rendering @@ -8447,12 +8452,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { responseHeaders["X-Vinext-Cache"] = "MISS"; - const __isrKeyRsc = __isrRscKey(cleanPathname); + const __isrKeyRsc = __isrRscKey(__cachePathname); const __revalSecsRsc = revalidateSeconds; const __rscWritePromise = (async () => { try { const __rscDataForCache = await __isrRscDataPromise; - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __pageTags = __pageCacheTags(__cachePathname, getCollectedFetchTags()); await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: undefined, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); __isrDebug?.("RSC cache written", __isrKeyRsc); } catch (__rscWriteErr) { @@ -8622,6 +8627,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // revalidate=Infinity means "cache forever" (no periodic revalidation) — treated as // static here so we emit s-maxage=31536000 but skip ISR cache management. if (revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity) { + const __cachePathname = navigationPathname; // In production, tee the HTML response body to simultaneously stream to the // client and collect the full HTML string for the ISR cache. rscData was // already captured above by teeing the RSC stream before SSR. @@ -8638,8 +8644,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { })); if (__isrResponseProd.body) { const [__streamForClient, __streamForCache] = __isrResponseProd.body.tee(); - const __isrKey = __isrHtmlKey(cleanPathname); - const __isrKeyRscFromHtml = __isrRscKey(cleanPathname); + const __isrKey = __isrHtmlKey(__cachePathname); + const __isrKeyRscFromHtml = __isrRscKey(__cachePathname); const __revalSecs = revalidateSeconds; const __capturedRscDataPromise = __isrRscDataPromise; const __cachePromise = (async () => { @@ -8654,7 +8660,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __chunks.push(__decoder.decode()); const __fullHtml = __chunks.join(""); - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __pageTags = __pageCacheTags(__cachePathname, getCollectedFetchTags()); // Write HTML and RSC to their own keys independently. // RSC data was captured by the tee above (before isRscRequest branch) // so an initial browser visit (HTML request) also populates the RSC key, @@ -10756,7 +10762,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { !isForceDynamic && revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity ) { - const __isrKey = isRscRequest ? __isrRscKey(cleanPathname) : __isrHtmlKey(cleanPathname); + const __cachePathname = navigationPathname; + const __isrKey = isRscRequest ? __isrRscKey(__cachePathname) : __isrHtmlKey(__cachePathname); try { const __cached = await __isrGet(__isrKey); if (__cached && !__cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_PAGE") { @@ -10764,7 +10771,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __hasRsc = !!__cachedValue.rscData; const __hasHtml = typeof __cachedValue.html === "string" && __cachedValue.html.length > 0; if (isRscRequest && __hasRsc) { - __isrDebug?.("HIT (RSC)", cleanPathname); + __isrDebug?.("HIT (RSC)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__cachedValue.rscData, { @@ -10778,7 +10785,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); } if (!isRscRequest && __hasHtml) { - __isrDebug?.("HIT (HTML)", cleanPathname); + __isrDebug?.("HIT (HTML)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__cachedValue.html, { @@ -10791,7 +10798,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, }); } - __isrDebug?.("MISS (empty cached entry)", cleanPathname); + __isrDebug?.("MISS (empty cached entry)", __cachePathname); } if (__cached && __cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_PAGE") { // Stale cache hit — serve stale immediately, trigger background regeneration. @@ -10799,7 +10806,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __staleValue = __cached.value.value; const __staleStatus = __staleValue.status || 200; const __revalSecs = revalidateSeconds; - __triggerBackgroundRegeneration(cleanPathname, async function() { + __triggerBackgroundRegeneration(__cachePathname, async function() { // Re-render the page to produce fresh HTML + RSC data for the cache // Use an empty headers context for background regeneration — not the original // user request — to prevent user-specific cookies/auth headers from leaking @@ -10854,18 +10861,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __revalChunks.push(__revalDecoder.decode()); const __freshHtml = __revalChunks.join(""); const __freshRscData = await __rscDataPromise; - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __pageTags = __pageCacheTags(__cachePathname, getCollectedFetchTags()); return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags }; }); // Write HTML and RSC to their own keys independently — no races await Promise.all([ - __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), - __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrHtmlKey(__cachePathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrRscKey(__cachePathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), ]); - __isrDebug?.("regen complete", cleanPathname); + __isrDebug?.("regen complete", __cachePathname); }); if (isRscRequest && __staleValue.rscData) { - __isrDebug?.("STALE (RSC)", cleanPathname); + __isrDebug?.("STALE (RSC)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__staleValue.rscData, { @@ -10879,7 +10886,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); } if (!isRscRequest && typeof __staleValue.html === "string" && __staleValue.html.length > 0) { - __isrDebug?.("STALE (HTML)", cleanPathname); + __isrDebug?.("STALE (HTML)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__staleValue.html, { @@ -10893,10 +10900,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); } // Stale entry exists but is empty for this request type — fall through to render - __isrDebug?.("STALE MISS (empty stale entry)", cleanPathname); + __isrDebug?.("STALE MISS (empty stale entry)", __cachePathname); } if (!__cached) { - __isrDebug?.("MISS (no cache entry)", cleanPathname); + __isrDebug?.("MISS (no cache entry)", __cachePathname); } } catch (__isrReadErr) { // Cache read failure — fall through to normal rendering @@ -11268,12 +11275,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { responseHeaders["X-Vinext-Cache"] = "MISS"; - const __isrKeyRsc = __isrRscKey(cleanPathname); + const __isrKeyRsc = __isrRscKey(__cachePathname); const __revalSecsRsc = revalidateSeconds; const __rscWritePromise = (async () => { try { const __rscDataForCache = await __isrRscDataPromise; - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __pageTags = __pageCacheTags(__cachePathname, getCollectedFetchTags()); await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: undefined, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); __isrDebug?.("RSC cache written", __isrKeyRsc); } catch (__rscWriteErr) { @@ -11435,6 +11442,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // revalidate=Infinity means "cache forever" (no periodic revalidation) — treated as // static here so we emit s-maxage=31536000 but skip ISR cache management. if (revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity) { + const __cachePathname = navigationPathname; // In production, tee the HTML response body to simultaneously stream to the // client and collect the full HTML string for the ISR cache. rscData was // already captured above by teeing the RSC stream before SSR. @@ -11451,8 +11459,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { })); if (__isrResponseProd.body) { const [__streamForClient, __streamForCache] = __isrResponseProd.body.tee(); - const __isrKey = __isrHtmlKey(cleanPathname); - const __isrKeyRscFromHtml = __isrRscKey(cleanPathname); + const __isrKey = __isrHtmlKey(__cachePathname); + const __isrKeyRscFromHtml = __isrRscKey(__cachePathname); const __revalSecs = revalidateSeconds; const __capturedRscDataPromise = __isrRscDataPromise; const __cachePromise = (async () => { @@ -11467,7 +11475,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __chunks.push(__decoder.decode()); const __fullHtml = __chunks.join(""); - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __pageTags = __pageCacheTags(__cachePathname, getCollectedFetchTags()); // Write HTML and RSC to their own keys independently. // RSC data was captured by the tee above (before isRscRequest branch) // so an initial browser visit (HTML request) also populates the RSC key, @@ -13544,7 +13552,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { !isForceDynamic && revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity ) { - const __isrKey = isRscRequest ? __isrRscKey(cleanPathname) : __isrHtmlKey(cleanPathname); + const __cachePathname = navigationPathname; + const __isrKey = isRscRequest ? __isrRscKey(__cachePathname) : __isrHtmlKey(__cachePathname); try { const __cached = await __isrGet(__isrKey); if (__cached && !__cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_PAGE") { @@ -13552,7 +13561,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __hasRsc = !!__cachedValue.rscData; const __hasHtml = typeof __cachedValue.html === "string" && __cachedValue.html.length > 0; if (isRscRequest && __hasRsc) { - __isrDebug?.("HIT (RSC)", cleanPathname); + __isrDebug?.("HIT (RSC)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__cachedValue.rscData, { @@ -13566,7 +13575,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); } if (!isRscRequest && __hasHtml) { - __isrDebug?.("HIT (HTML)", cleanPathname); + __isrDebug?.("HIT (HTML)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__cachedValue.html, { @@ -13579,7 +13588,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, }); } - __isrDebug?.("MISS (empty cached entry)", cleanPathname); + __isrDebug?.("MISS (empty cached entry)", __cachePathname); } if (__cached && __cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_PAGE") { // Stale cache hit — serve stale immediately, trigger background regeneration. @@ -13587,7 +13596,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __staleValue = __cached.value.value; const __staleStatus = __staleValue.status || 200; const __revalSecs = revalidateSeconds; - __triggerBackgroundRegeneration(cleanPathname, async function() { + __triggerBackgroundRegeneration(__cachePathname, async function() { // Re-render the page to produce fresh HTML + RSC data for the cache // Use an empty headers context for background regeneration — not the original // user request — to prevent user-specific cookies/auth headers from leaking @@ -13642,18 +13651,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __revalChunks.push(__revalDecoder.decode()); const __freshHtml = __revalChunks.join(""); const __freshRscData = await __rscDataPromise; - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __pageTags = __pageCacheTags(__cachePathname, getCollectedFetchTags()); return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags }; }); // Write HTML and RSC to their own keys independently — no races await Promise.all([ - __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), - __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrHtmlKey(__cachePathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrRscKey(__cachePathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), ]); - __isrDebug?.("regen complete", cleanPathname); + __isrDebug?.("regen complete", __cachePathname); }); if (isRscRequest && __staleValue.rscData) { - __isrDebug?.("STALE (RSC)", cleanPathname); + __isrDebug?.("STALE (RSC)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__staleValue.rscData, { @@ -13667,7 +13676,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); } if (!isRscRequest && typeof __staleValue.html === "string" && __staleValue.html.length > 0) { - __isrDebug?.("STALE (HTML)", cleanPathname); + __isrDebug?.("STALE (HTML)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__staleValue.html, { @@ -13681,10 +13690,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); } // Stale entry exists but is empty for this request type — fall through to render - __isrDebug?.("STALE MISS (empty stale entry)", cleanPathname); + __isrDebug?.("STALE MISS (empty stale entry)", __cachePathname); } if (!__cached) { - __isrDebug?.("MISS (no cache entry)", cleanPathname); + __isrDebug?.("MISS (no cache entry)", __cachePathname); } } catch (__isrReadErr) { // Cache read failure — fall through to normal rendering @@ -14056,12 +14065,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { responseHeaders["X-Vinext-Cache"] = "MISS"; - const __isrKeyRsc = __isrRscKey(cleanPathname); + const __isrKeyRsc = __isrRscKey(__cachePathname); const __revalSecsRsc = revalidateSeconds; const __rscWritePromise = (async () => { try { const __rscDataForCache = await __isrRscDataPromise; - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __pageTags = __pageCacheTags(__cachePathname, getCollectedFetchTags()); await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: undefined, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); __isrDebug?.("RSC cache written", __isrKeyRsc); } catch (__rscWriteErr) { @@ -14223,6 +14232,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // revalidate=Infinity means "cache forever" (no periodic revalidation) — treated as // static here so we emit s-maxage=31536000 but skip ISR cache management. if (revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity) { + const __cachePathname = navigationPathname; // In production, tee the HTML response body to simultaneously stream to the // client and collect the full HTML string for the ISR cache. rscData was // already captured above by teeing the RSC stream before SSR. @@ -14239,8 +14249,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { })); if (__isrResponseProd.body) { const [__streamForClient, __streamForCache] = __isrResponseProd.body.tee(); - const __isrKey = __isrHtmlKey(cleanPathname); - const __isrKeyRscFromHtml = __isrRscKey(cleanPathname); + const __isrKey = __isrHtmlKey(__cachePathname); + const __isrKeyRscFromHtml = __isrRscKey(__cachePathname); const __revalSecs = revalidateSeconds; const __capturedRscDataPromise = __isrRscDataPromise; const __cachePromise = (async () => { @@ -14255,7 +14265,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __chunks.push(__decoder.decode()); const __fullHtml = __chunks.join(""); - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __pageTags = __pageCacheTags(__cachePathname, getCollectedFetchTags()); // Write HTML and RSC to their own keys independently. // RSC data was captured by the tee above (before isRscRequest branch) // so an initial browser visit (HTML request) also populates the RSC key, @@ -16642,7 +16652,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { !isForceDynamic && revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity ) { - const __isrKey = isRscRequest ? __isrRscKey(cleanPathname) : __isrHtmlKey(cleanPathname); + const __cachePathname = navigationPathname; + const __isrKey = isRscRequest ? __isrRscKey(__cachePathname) : __isrHtmlKey(__cachePathname); try { const __cached = await __isrGet(__isrKey); if (__cached && !__cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_PAGE") { @@ -16650,7 +16661,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __hasRsc = !!__cachedValue.rscData; const __hasHtml = typeof __cachedValue.html === "string" && __cachedValue.html.length > 0; if (isRscRequest && __hasRsc) { - __isrDebug?.("HIT (RSC)", cleanPathname); + __isrDebug?.("HIT (RSC)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__cachedValue.rscData, { @@ -16664,7 +16675,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); } if (!isRscRequest && __hasHtml) { - __isrDebug?.("HIT (HTML)", cleanPathname); + __isrDebug?.("HIT (HTML)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__cachedValue.html, { @@ -16677,7 +16688,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, }); } - __isrDebug?.("MISS (empty cached entry)", cleanPathname); + __isrDebug?.("MISS (empty cached entry)", __cachePathname); } if (__cached && __cached.isStale && __cached.value.value && __cached.value.value.kind === "APP_PAGE") { // Stale cache hit — serve stale immediately, trigger background regeneration. @@ -16685,7 +16696,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __staleValue = __cached.value.value; const __staleStatus = __staleValue.status || 200; const __revalSecs = revalidateSeconds; - __triggerBackgroundRegeneration(cleanPathname, async function() { + __triggerBackgroundRegeneration(__cachePathname, async function() { // Re-render the page to produce fresh HTML + RSC data for the cache // Use an empty headers context for background regeneration — not the original // user request — to prevent user-specific cookies/auth headers from leaking @@ -16740,18 +16751,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { __revalChunks.push(__revalDecoder.decode()); const __freshHtml = __revalChunks.join(""); const __freshRscData = await __rscDataPromise; - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __pageTags = __pageCacheTags(__cachePathname, getCollectedFetchTags()); return { html: __freshHtml, rscData: __freshRscData, tags: __pageTags }; }); // Write HTML and RSC to their own keys independently — no races await Promise.all([ - __isrSet(__isrHtmlKey(cleanPathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), - __isrSet(__isrRscKey(cleanPathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrHtmlKey(__cachePathname), { kind: "APP_PAGE", html: __revalResult.html, rscData: undefined, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), + __isrSet(__isrRscKey(__cachePathname), { kind: "APP_PAGE", html: "", rscData: __revalResult.rscData, headers: undefined, postponed: undefined, status: 200 }, __revalSecs, __revalResult.tags), ]); - __isrDebug?.("regen complete", cleanPathname); + __isrDebug?.("regen complete", __cachePathname); }); if (isRscRequest && __staleValue.rscData) { - __isrDebug?.("STALE (RSC)", cleanPathname); + __isrDebug?.("STALE (RSC)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__staleValue.rscData, { @@ -16765,7 +16776,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); } if (!isRscRequest && typeof __staleValue.html === "string" && __staleValue.html.length > 0) { - __isrDebug?.("STALE (HTML)", cleanPathname); + __isrDebug?.("STALE (HTML)", __cachePathname); setHeadersContext(null); setNavigationContext(null); return new Response(__staleValue.html, { @@ -16779,10 +16790,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }); } // Stale entry exists but is empty for this request type — fall through to render - __isrDebug?.("STALE MISS (empty stale entry)", cleanPathname); + __isrDebug?.("STALE MISS (empty stale entry)", __cachePathname); } if (!__cached) { - __isrDebug?.("MISS (no cache entry)", cleanPathname); + __isrDebug?.("MISS (no cache entry)", __cachePathname); } } catch (__isrReadErr) { // Cache read failure — fall through to normal rendering @@ -17154,12 +17165,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // these writes never race or clobber each other. if (process.env.NODE_ENV === "production" && __isrRscDataPromise) { responseHeaders["X-Vinext-Cache"] = "MISS"; - const __isrKeyRsc = __isrRscKey(cleanPathname); + const __isrKeyRsc = __isrRscKey(__cachePathname); const __revalSecsRsc = revalidateSeconds; const __rscWritePromise = (async () => { try { const __rscDataForCache = await __isrRscDataPromise; - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __pageTags = __pageCacheTags(__cachePathname, getCollectedFetchTags()); await __isrSet(__isrKeyRsc, { kind: "APP_PAGE", html: "", rscData: __rscDataForCache, headers: undefined, postponed: undefined, status: 200 }, __revalSecsRsc, __pageTags); __isrDebug?.("RSC cache written", __isrKeyRsc); } catch (__rscWriteErr) { @@ -17321,6 +17332,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // revalidate=Infinity means "cache forever" (no periodic revalidation) — treated as // static here so we emit s-maxage=31536000 but skip ISR cache management. if (revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity) { + const __cachePathname = navigationPathname; // In production, tee the HTML response body to simultaneously stream to the // client and collect the full HTML string for the ISR cache. rscData was // already captured above by teeing the RSC stream before SSR. @@ -17337,8 +17349,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { })); if (__isrResponseProd.body) { const [__streamForClient, __streamForCache] = __isrResponseProd.body.tee(); - const __isrKey = __isrHtmlKey(cleanPathname); - const __isrKeyRscFromHtml = __isrRscKey(cleanPathname); + const __isrKey = __isrHtmlKey(__cachePathname); + const __isrKeyRscFromHtml = __isrRscKey(__cachePathname); const __revalSecs = revalidateSeconds; const __capturedRscDataPromise = __isrRscDataPromise; const __cachePromise = (async () => { @@ -17353,7 +17365,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } __chunks.push(__decoder.decode()); const __fullHtml = __chunks.join(""); - const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags()); + const __pageTags = __pageCacheTags(__cachePathname, getCollectedFetchTags()); // Write HTML and RSC to their own keys independently. // RSC data was captured by the tee above (before isRscRequest branch) // so an initial browser visit (HTML request) also populates the RSC key, diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 6ea1510a9..832c97e5b 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -1685,6 +1685,7 @@ describe("App Router Production server (startProdServer)", () => { it("preserves the canonical pathname when stale ISR regeneration runs on a rewritten route", async () => { const sourcePath = "/nextjs-compat/isr-rewrite-source"; + const targetPath = "/nextjs-compat/isr-rewrite-target"; const res1 = await fetch(`${baseUrl}${sourcePath}`); expect(res1.status).toBe(200); @@ -1721,7 +1722,21 @@ describe("App Router Production server (startProdServer)", () => { expect(sawHit).toBe(true); expect(hitHtml).toContain(`

${sourcePath}

`); - expect(hitHtml).not.toContain('

/nextjs-compat/isr-rewrite-target

'); + expect(hitHtml).not.toContain(`

${targetPath}

`); + + const targetMissRes = await fetch(`${baseUrl}${targetPath}`); + expect(targetMissRes.status).toBe(200); + const targetMissHtml = await targetMissRes.text(); + expect(targetMissHtml).toContain(`

${targetPath}

`); + expect(targetMissHtml).not.toContain(`

${sourcePath}

`); + expect(targetMissRes.headers.get("x-vinext-cache")).toBe("MISS"); + + const targetHitRes = await fetch(`${baseUrl}${targetPath}`); + expect(targetHitRes.status).toBe(200); + const targetHitHtml = await targetHitRes.text(); + expect(targetHitHtml).toContain(`

${targetPath}

`); + expect(targetHitHtml).not.toContain(`

${sourcePath}

`); + expect(targetHitRes.headers.get("x-vinext-cache")).toBe("HIT"); }); }); From af4491c36de7832e8e9d8db37d61a62dc790636f Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Wed, 11 Mar 2026 23:18:15 -0500 Subject: [PATCH 5/8] Fix ISR cache keys for rewritten App --- packages/vinext/src/shims/metadata.tsx | 8 ++-- tests/app-router.test.ts | 21 ++++++--- tests/features.test.ts | 8 ++++ tests/nextjs-compat/hooks.test.ts | 11 +++++ tests/nextjs-compat/metadata-suspense.test.ts | 43 +++++++++++++++++-- 5 files changed, 78 insertions(+), 13 deletions(-) diff --git a/packages/vinext/src/shims/metadata.tsx b/packages/vinext/src/shims/metadata.tsx index 047c83fc9..be290b5be 100644 --- a/packages/vinext/src/shims/metadata.tsx +++ b/packages/vinext/src/shims/metadata.tsx @@ -125,7 +125,7 @@ export function ViewportHead({ viewport }: { viewport: Viewport }) { // --------------------------------------------------------------------------- export interface Metadata { - title?: string | { default?: string; template?: string; absolute?: string }; + title?: string | { default?: string; template?: string | null; absolute?: string }; description?: string; generator?: string; applicationName?: string; @@ -276,7 +276,7 @@ interface TwitterAppDescriptor { interface ResolvedTitleState { absolute?: string; - template?: string; + template: string | null; } function applyTitleTemplate(template: string | undefined, title: string) { @@ -288,7 +288,7 @@ function resolveTitleState( { terminal = true }: { terminal?: boolean } = {}, ): ResolvedTitleState | null { let resolvedAbsolute: string | undefined; - let deepestTemplate: string | undefined; + let deepestTemplate: string | null = null; let stashedTemplate: string | undefined; let sawTitle = false; const templateCarrierIndex = terminal ? metadataList.length - 2 : metadataList.length - 1; @@ -364,7 +364,7 @@ export function mergeMetadataForParent(metadataList: Metadata[]): Metadata { if (resolvedTitle) { merged.title = { absolute: resolvedTitle.absolute || "", - ...(resolvedTitle.template ? { template: resolvedTitle.template } : {}), + template: resolvedTitle.template, }; } diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 832c97e5b..4f6e01014 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -1701,15 +1701,26 @@ describe("App Router Production server (startProdServer)", () => { await new Promise((resolve) => setTimeout(resolve, 1_100)); - const staleRes = await fetch(`${baseUrl}${sourcePath}`); - expect(staleRes.status).toBe(200); - const staleHtml = await staleRes.text(); + let staleHtml = ""; + let sawStale = false; + const staleDeadline = Date.now() + 5_000; + while (Date.now() < staleDeadline) { + const staleRes = await fetch(`${baseUrl}${sourcePath}`); + expect(staleRes.status).toBe(200); + staleHtml = await staleRes.text(); + if (staleRes.headers.get("x-vinext-cache") === "STALE") { + sawStale = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + expect(sawStale).toBe(true); expect(staleHtml).toContain(`

${sourcePath}

`); - expect(staleRes.headers.get("x-vinext-cache")).toBe("STALE"); let hitHtml = ""; let sawHit = false; - for (let attempt = 0; attempt < 10; attempt++) { + const hitDeadline = Date.now() + 5_000; + while (Date.now() < hitDeadline) { const hitRes = await fetch(`${baseUrl}${sourcePath}`); expect(hitRes.status).toBe(200); hitHtml = await hitRes.text(); diff --git a/tests/features.test.ts b/tests/features.test.ts index 6bfdd7a02..7db23af9c 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -2046,6 +2046,14 @@ describe("metadata title templates", () => { }); }); + it("normalizes parent title.template to null when no template applies", () => { + const result = mergeMetadataForParent([{ title: "Leaf Title" }]); + expect(result.title).toEqual({ + absolute: "Leaf Title", + template: null, + }); + }); + it("preserves non-title metadata during merge", () => { const result = mergeMetadata([ { title: { template: "%s | Site", default: "Site" }, description: "Root desc" }, diff --git a/tests/nextjs-compat/hooks.test.ts b/tests/nextjs-compat/hooks.test.ts index 2d9d71d84..31a50cde2 100644 --- a/tests/nextjs-compat/hooks.test.ts +++ b/tests/nextjs-compat/hooks.test.ts @@ -138,6 +138,17 @@ describe("Next.js compat: hooks", () => { expect(html).toContain('

/nextjs-compat/hooks-middleware-rewrite

'); }); + it("useSearchParams preserves the requested query string after a middleware rewrite", async () => { + const { html } = await fetchHtml( + baseUrl, + "/nextjs-compat/hooks-middleware-rewrite?q=middleware&page=7", + ); + expect(html).toContain('

/nextjs-compat/hooks-middleware-rewrite

'); + expect(html).toContain('

middleware

'); + expect(html).toContain('

7

'); + expect(html).toContain('

q=middleware&page=7

'); + }); + // Next.js: rewrite searchParams canonical URL coverage // Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/hooks/hooks.test.ts#L64-L72 diff --git a/tests/nextjs-compat/metadata-suspense.test.ts b/tests/nextjs-compat/metadata-suspense.test.ts index 83b7e7d50..fd57f41ee 100644 --- a/tests/nextjs-compat/metadata-suspense.test.ts +++ b/tests/nextjs-compat/metadata-suspense.test.ts @@ -16,6 +16,27 @@ import { let ctx: TestServerResult; +async function readUntilHeadCloses(res: Response) { + expect(res.body).toBeTruthy(); + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let html = ""; + + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) { + html += decoder.decode(); + return html; + } + html += decoder.decode(value, { stream: true }); + if (html.includes("")) return html; + } + } finally { + await reader.cancel(); + } +} + describe("Next.js compat: metadata-suspense", () => { beforeAll(async () => { ctx = await startFixtureServer(APP_FIXTURE_DIR, { appRouter: true }); @@ -41,17 +62,31 @@ describe("Next.js compat: metadata-suspense", () => { }); it("should render metadata in head for HTML-limited bot user agents", async () => { - const { html } = await fetchHtml(ctx.baseUrl, "/nextjs-compat/metadata-suspense-test", { + const res = await fetch(`${ctx.baseUrl}/nextjs-compat/metadata-suspense-test`, { headers: { "User-Agent": "Discordbot/2.0;", }, }); + expect(res.status).toBe(200); - expect(html).toContain("Suspense Metadata Title"); - expect(html).toMatch(/"); + const titleIndex = headHtml.indexOf("Suspense Metadata Title"); + const appNameIndex = headHtml.search( + / Date: Wed, 11 Mar 2026 23:26:54 -0500 Subject: [PATCH 6/8] fix test --- tests/app-router.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 4f6e01014..249168824 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -3540,7 +3540,7 @@ describe("generateRscEntry ISR code generation", () => { expect(code).toContain("function __pageCacheTags(pathname, extraTags)"); expect(code).toContain('const tags = [pathname, "_N_T_" + pathname]'); expect(code).toContain( - "const __pageTags = __pageCacheTags(cleanPathname, getCollectedFetchTags())", + "const __pageTags = __pageCacheTags(__cachePathname, getCollectedFetchTags())", ); expect(code).toContain("Array.isArray(tags) ? tags : []"); }); From ad9cab22b30f60b04f3a1b4702409288c7341048 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Thu, 12 Mar 2026 12:55:04 -0500 Subject: [PATCH 7/8] Fix PR 486 feedback --- packages/vinext/src/entries/app-rsc-entry.ts | 3 +-- packages/vinext/src/shims/metadata.tsx | 8 ++++---- .../__snapshots__/entry-templates.test.ts.snap | 18 ++++++------------ tests/app-router.test.ts | 16 ++++++++++++++++ tests/features.test.ts | 17 +++++++++++++++++ 5 files changed, 44 insertions(+), 18 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 75c82a6ac..76d54a7e2 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -1561,6 +1561,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); let cleanPathname = pathname.replace(/\\.rsc$/, ""); const navigationPathname = cleanPathname; + const __cachePathname = navigationPathname; // Middleware response headers and custom rewrite status are stored in // _mwCtx (per-request container) so handler() can merge them into @@ -2206,7 +2207,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { !isForceDynamic && revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity ) { - const __cachePathname = navigationPathname; const __isrKey = isRscRequest ? __isrRscKey(__cachePathname) : __isrHtmlKey(__cachePathname); try { const __cached = await __isrGet(__isrKey); @@ -2898,7 +2898,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // revalidate=Infinity means "cache forever" (no periodic revalidation) — treated as // static here so we emit s-maxage=31536000 but skip ISR cache management. if (revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity) { - const __cachePathname = navigationPathname; // In production, tee the HTML response body to simultaneously stream to the // client and collect the full HTML string for the ISR cache. rscData was // already captured above by teeing the RSC stream before SSR. diff --git a/packages/vinext/src/shims/metadata.tsx b/packages/vinext/src/shims/metadata.tsx index be290b5be..7210be7e6 100644 --- a/packages/vinext/src/shims/metadata.tsx +++ b/packages/vinext/src/shims/metadata.tsx @@ -306,16 +306,16 @@ function resolveTitleState( if (title.default !== undefined) { resolvedAbsolute = applyTitleTemplate(stashedTemplate, title.default); } - if (title.absolute) { + if (title.absolute !== undefined) { resolvedAbsolute = title.absolute; } } - if (titleTemplate && i <= templateCarrierIndex) { + if (titleTemplate !== undefined && i <= templateCarrierIndex) { deepestTemplate = titleTemplate; } - if (titleTemplate) { - stashedTemplate = titleTemplate; + if (titleTemplate !== undefined) { + stashedTemplate = titleTemplate ?? undefined; } } diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 9c73e9181..61ddb28af 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -1787,6 +1787,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); let cleanPathname = pathname.replace(/\\.rsc$/, ""); const navigationPathname = cleanPathname; + const __cachePathname = navigationPathname; // Middleware response headers and custom rewrite status are stored in // _mwCtx (per-request container) so handler() can merge them into @@ -2340,7 +2341,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { !isForceDynamic && revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity ) { - const __cachePathname = navigationPathname; const __isrKey = isRscRequest ? __isrRscKey(__cachePathname) : __isrHtmlKey(__cachePathname); try { const __cached = await __isrGet(__isrKey); @@ -3020,7 +3020,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // revalidate=Infinity means "cache forever" (no periodic revalidation) — treated as // static here so we emit s-maxage=31536000 but skip ISR cache management. if (revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity) { - const __cachePathname = navigationPathname; // In production, tee the HTML response body to simultaneously stream to the // client and collect the full HTML string for the ISR cache. rscData was // already captured above by teeing the RSC stream before SSR. @@ -4573,6 +4572,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); let cleanPathname = pathname.replace(/\\.rsc$/, ""); const navigationPathname = cleanPathname; + const __cachePathname = navigationPathname; // Middleware response headers and custom rewrite status are stored in // _mwCtx (per-request container) so handler() can merge them into @@ -5126,7 +5126,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { !isForceDynamic && revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity ) { - const __cachePathname = navigationPathname; const __isrKey = isRscRequest ? __isrRscKey(__cachePathname) : __isrHtmlKey(__cachePathname); try { const __cached = await __isrGet(__isrKey); @@ -5806,7 +5805,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // revalidate=Infinity means "cache forever" (no periodic revalidation) — treated as // static here so we emit s-maxage=31536000 but skip ISR cache management. if (revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity) { - const __cachePathname = navigationPathname; // In production, tee the HTML response body to simultaneously stream to the // client and collect the full HTML string for the ISR cache. rscData was // already captured above by teeing the RSC stream before SSR. @@ -7386,6 +7384,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); let cleanPathname = pathname.replace(/\\.rsc$/, ""); const navigationPathname = cleanPathname; + const __cachePathname = navigationPathname; // Middleware response headers and custom rewrite status are stored in // _mwCtx (per-request container) so handler() can merge them into @@ -7939,7 +7938,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { !isForceDynamic && revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity ) { - const __cachePathname = navigationPathname; const __isrKey = isRscRequest ? __isrRscKey(__cachePathname) : __isrHtmlKey(__cachePathname); try { const __cached = await __isrGet(__isrKey); @@ -8627,7 +8625,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // revalidate=Infinity means "cache forever" (no periodic revalidation) — treated as // static here so we emit s-maxage=31536000 but skip ISR cache management. if (revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity) { - const __cachePathname = navigationPathname; // In production, tee the HTML response body to simultaneously stream to the // client and collect the full HTML string for the ISR cache. rscData was // already captured above by teeing the RSC stream before SSR. @@ -10209,6 +10206,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); let cleanPathname = pathname.replace(/\\.rsc$/, ""); const navigationPathname = cleanPathname; + const __cachePathname = navigationPathname; // Middleware response headers and custom rewrite status are stored in // _mwCtx (per-request container) so handler() can merge them into @@ -10762,7 +10760,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { !isForceDynamic && revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity ) { - const __cachePathname = navigationPathname; const __isrKey = isRscRequest ? __isrRscKey(__cachePathname) : __isrHtmlKey(__cachePathname); try { const __cached = await __isrGet(__isrKey); @@ -11442,7 +11439,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // revalidate=Infinity means "cache forever" (no periodic revalidation) — treated as // static here so we emit s-maxage=31536000 but skip ISR cache management. if (revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity) { - const __cachePathname = navigationPathname; // In production, tee the HTML response body to simultaneously stream to the // client and collect the full HTML string for the ISR cache. rscData was // already captured above by teeing the RSC stream before SSR. @@ -12999,6 +12995,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); let cleanPathname = pathname.replace(/\\.rsc$/, ""); const navigationPathname = cleanPathname; + const __cachePathname = navigationPathname; // Middleware response headers and custom rewrite status are stored in // _mwCtx (per-request container) so handler() can merge them into @@ -13552,7 +13549,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { !isForceDynamic && revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity ) { - const __cachePathname = navigationPathname; const __isrKey = isRscRequest ? __isrRscKey(__cachePathname) : __isrHtmlKey(__cachePathname); try { const __cached = await __isrGet(__isrKey); @@ -14232,7 +14228,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // revalidate=Infinity means "cache forever" (no periodic revalidation) — treated as // static here so we emit s-maxage=31536000 but skip ISR cache management. if (revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity) { - const __cachePathname = navigationPathname; // In production, tee the HTML response body to simultaneously stream to the // client and collect the full HTML string for the ISR cache. rscData was // already captured above by teeing the RSC stream before SSR. @@ -16011,6 +16006,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); let cleanPathname = pathname.replace(/\\.rsc$/, ""); const navigationPathname = cleanPathname; + const __cachePathname = navigationPathname; // Middleware response headers and custom rewrite status are stored in // _mwCtx (per-request container) so handler() can merge them into @@ -16652,7 +16648,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { !isForceDynamic && revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity ) { - const __cachePathname = navigationPathname; const __isrKey = isRscRequest ? __isrRscKey(__cachePathname) : __isrHtmlKey(__cachePathname); try { const __cached = await __isrGet(__isrKey); @@ -17332,7 +17327,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // revalidate=Infinity means "cache forever" (no periodic revalidation) — treated as // static here so we emit s-maxage=31536000 but skip ISR cache management. if (revalidateSeconds !== null && revalidateSeconds > 0 && revalidateSeconds !== Infinity) { - const __cachePathname = navigationPathname; // In production, tee the HTML response body to simultaneously stream to the // client and collect the full HTML string for the ISR cache. rscData was // already captured above by teeing the RSC stream before SSR. diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 249168824..92b83ebd9 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -3545,6 +3545,22 @@ describe("generateRscEntry ISR code generation", () => { expect(code).toContain("Array.isArray(tags) ? tags : []"); }); + it("generated code hoists the canonical ISR cache pathname for shared reads and writes", () => { + const code = generateRscEntry("/tmp/test/app", minimalRoutes); + const declaration = "const __cachePathname = navigationPathname;"; + const firstIndex = code.indexOf(declaration); + expect(firstIndex).toBeGreaterThanOrEqual(0); + expect(code.match(/const __cachePathname = navigationPathname;/g)).toHaveLength(1); + expect(firstIndex).toBeLessThan( + code.indexOf( + "const __isrKey = isRscRequest ? __isrRscKey(__cachePathname) : __isrHtmlKey(__cachePathname);", + ), + ); + expect(firstIndex).toBeLessThan( + code.indexOf("const __isrKeyRsc = __isrRscKey(__cachePathname);"), + ); + }); + it("generated handler exports async function handler(request, ctx)", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes); // The handler must accept a ctx param so ExecutionContext is threaded through diff --git a/tests/features.test.ts b/tests/features.test.ts index 7db23af9c..664283150 100644 --- a/tests/features.test.ts +++ b/tests/features.test.ts @@ -1997,6 +1997,14 @@ describe("metadata title templates", () => { expect(result.title).toBe("Custom Title"); }); + it("title.absolute preserves an explicit empty string", () => { + const result = mergeMetadata([ + { title: { template: "%s | My Site", default: "My Site" } }, + { title: { absolute: "" } }, + ]); + expect(result.title).toBe(""); + }); + it("nearest layout template wins over root", () => { const result = mergeMetadata([ { title: { template: "%s | Root", default: "Root" } }, @@ -2035,6 +2043,15 @@ describe("metadata title templates", () => { expect(result.title).toBe("Inner | Inner"); }); + it("title.template null blocks ancestor template inheritance for deeper descendants", () => { + const result = mergeMetadata([ + { title: { template: "%s | Root", default: "Root" } }, + { title: { default: "Section", template: null } }, + { title: "Leaf" }, + ]); + expect(result.title).toBe("Leaf"); + }); + it("preserves resolved parent title.absolute and title.template for child metadata", () => { const result = mergeMetadataForParent([ { title: { template: "%s | Root", default: "Root" } }, From baa03d033d660c88afe826501dcd6e83387450c2 Mon Sep 17 00:00:00 2001 From: Jared Stowell Date: Thu, 12 Mar 2026 19:16:22 -0500 Subject: [PATCH 8/8] Fix PR 486 feedback --- packages/vinext/src/entries/app-rsc-entry.ts | 23 +++-- packages/vinext/src/shims/metadata.tsx | 22 +++-- .../entry-templates.test.ts.snap | 93 +++++++++++++------ tests/app-router.test.ts | 9 ++ tests/fixtures/app-basic/middleware.ts | 3 +- 5 files changed, 105 insertions(+), 45 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 76d54a7e2..48e5f26e1 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -1562,6 +1562,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { let cleanPathname = pathname.replace(/\\.rsc$/, ""); const navigationPathname = cleanPathname; const __cachePathname = navigationPathname; + let pageSearchParams = url.searchParams; // Middleware response headers and custom rewrite status are stored in // _mwCtx (per-request container) so handler() can merge them into @@ -1621,10 +1622,11 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (rewriteUrl) { const rewriteParsed = new URL(rewriteUrl, request.url); cleanPathname = rewriteParsed.pathname; - // Carry over query params from the rewrite URL so that - // searchParams props, useSearchParams(), and navigation context - // reflect the rewrite destination, not the original request. - url.search = rewriteParsed.search; + // Carry the rewrite query into the server-rendered page props + // without mutating the original request URL or client navigation state. + if (rewriteParsed.search) { + pageSearchParams = rewriteParsed.searchParams; + } // Capture custom status code from rewrite (e.g. NextResponse.rewrite(url, { status: 403 })) if (mwResponse.status !== 200) { _mwCtx.status = mwResponse.status; @@ -1888,7 +1890,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params: actionParams, }); - element = buildPageElement(actionRoute, actionParams, undefined, url.searchParams); + element = buildPageElement(actionRoute, actionParams, undefined, pageSearchParams); } else { element = createElement("div", null, "Page not found"); } @@ -2267,7 +2269,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params, }); - const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); + const __revalElement = await buildPageElement( + route, + params, + undefined, + pageSearchParams, + ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); // Tee RSC stream: one for SSR, one to capture rscData @@ -2407,7 +2414,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { interceptSlot: intercept.slotName, interceptPage: intercept.page, interceptParams: intercept.matchedParams, - }, url.searchParams); + }, pageSearchParams); const interceptOnError = createRscOnErrorHandler( request, cleanPathname, @@ -2433,7 +2440,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { let element; try { - element = await buildPageElement(route, params, interceptOpts, url.searchParams); + element = await buildPageElement(route, params, interceptOpts, pageSearchParams); } catch (buildErr) { // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components if (buildErr && typeof buildErr === "object" && "digest" in buildErr) { diff --git a/packages/vinext/src/shims/metadata.tsx b/packages/vinext/src/shims/metadata.tsx index 7210be7e6..84f76d8ac 100644 --- a/packages/vinext/src/shims/metadata.tsx +++ b/packages/vinext/src/shims/metadata.tsx @@ -337,6 +337,17 @@ function resolveTitleState( export function mergeMetadata(metadataList: Metadata[]): Metadata { if (metadataList.length === 0) return {}; + const merged = mergeNonTitleMetadata(metadataList); + const resolvedTitle = resolveTitleState(metadataList); + + if (resolvedTitle?.absolute !== undefined) { + merged.title = resolvedTitle.absolute; + } + + return merged; +} + +function mergeNonTitleMetadata(metadataList: Metadata[]): Metadata { const merged: Metadata = {}; for (let i = 0; i < metadataList.length; i++) { @@ -349,21 +360,18 @@ export function mergeMetadata(metadataList: Metadata[]): Metadata { } } - const resolvedTitle = resolveTitleState(metadataList); - if (resolvedTitle?.absolute !== undefined) { - merged.title = resolvedTitle.absolute; - } - return merged; } export function mergeMetadataForParent(metadataList: Metadata[]): Metadata { - const merged = mergeMetadata(metadataList); + if (metadataList.length === 0) return {}; + + const merged = mergeNonTitleMetadata(metadataList); const resolvedTitle = resolveTitleState(metadataList, { terminal: false }); if (resolvedTitle) { merged.title = { - absolute: resolvedTitle.absolute || "", + absolute: resolvedTitle.absolute ?? "", template: resolvedTitle.template, }; } diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 61ddb28af..276991421 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -1788,6 +1788,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { let cleanPathname = pathname.replace(/\\.rsc$/, ""); const navigationPathname = cleanPathname; const __cachePathname = navigationPathname; + let pageSearchParams = url.searchParams; // Middleware response headers and custom rewrite status are stored in // _mwCtx (per-request container) so handler() can merge them into @@ -2022,7 +2023,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params: actionParams, }); - element = buildPageElement(actionRoute, actionParams, undefined, url.searchParams); + element = buildPageElement(actionRoute, actionParams, undefined, pageSearchParams); } else { element = createElement("div", null, "Page not found"); } @@ -2401,7 +2402,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params, }); - const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); + const __revalElement = await buildPageElement( + route, + params, + undefined, + pageSearchParams, + ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); // Tee RSC stream: one for SSR, one to capture rscData @@ -2541,7 +2547,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { interceptSlot: intercept.slotName, interceptPage: intercept.page, interceptParams: intercept.matchedParams, - }, url.searchParams); + }, pageSearchParams); const interceptOnError = createRscOnErrorHandler( request, cleanPathname, @@ -2567,7 +2573,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { let element; try { - element = await buildPageElement(route, params, interceptOpts, url.searchParams); + element = await buildPageElement(route, params, interceptOpts, pageSearchParams); } catch (buildErr) { // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components if (buildErr && typeof buildErr === "object" && "digest" in buildErr) { @@ -4573,6 +4579,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { let cleanPathname = pathname.replace(/\\.rsc$/, ""); const navigationPathname = cleanPathname; const __cachePathname = navigationPathname; + let pageSearchParams = url.searchParams; // Middleware response headers and custom rewrite status are stored in // _mwCtx (per-request container) so handler() can merge them into @@ -4807,7 +4814,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params: actionParams, }); - element = buildPageElement(actionRoute, actionParams, undefined, url.searchParams); + element = buildPageElement(actionRoute, actionParams, undefined, pageSearchParams); } else { element = createElement("div", null, "Page not found"); } @@ -5186,7 +5193,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params, }); - const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); + const __revalElement = await buildPageElement( + route, + params, + undefined, + pageSearchParams, + ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); // Tee RSC stream: one for SSR, one to capture rscData @@ -5326,7 +5338,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { interceptSlot: intercept.slotName, interceptPage: intercept.page, interceptParams: intercept.matchedParams, - }, url.searchParams); + }, pageSearchParams); const interceptOnError = createRscOnErrorHandler( request, cleanPathname, @@ -5352,7 +5364,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { let element; try { - element = await buildPageElement(route, params, interceptOpts, url.searchParams); + element = await buildPageElement(route, params, interceptOpts, pageSearchParams); } catch (buildErr) { // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components if (buildErr && typeof buildErr === "object" && "digest" in buildErr) { @@ -7385,6 +7397,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { let cleanPathname = pathname.replace(/\\.rsc$/, ""); const navigationPathname = cleanPathname; const __cachePathname = navigationPathname; + let pageSearchParams = url.searchParams; // Middleware response headers and custom rewrite status are stored in // _mwCtx (per-request container) so handler() can merge them into @@ -7619,7 +7632,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params: actionParams, }); - element = buildPageElement(actionRoute, actionParams, undefined, url.searchParams); + element = buildPageElement(actionRoute, actionParams, undefined, pageSearchParams); } else { element = createElement("div", null, "Page not found"); } @@ -7998,7 +8011,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params, }); - const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); + const __revalElement = await buildPageElement( + route, + params, + undefined, + pageSearchParams, + ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); // Tee RSC stream: one for SSR, one to capture rscData @@ -8138,7 +8156,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { interceptSlot: intercept.slotName, interceptPage: intercept.page, interceptParams: intercept.matchedParams, - }, url.searchParams); + }, pageSearchParams); const interceptOnError = createRscOnErrorHandler( request, cleanPathname, @@ -8164,7 +8182,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { let element; try { - element = await buildPageElement(route, params, interceptOpts, url.searchParams); + element = await buildPageElement(route, params, interceptOpts, pageSearchParams); } catch (buildErr) { // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components if (buildErr && typeof buildErr === "object" && "digest" in buildErr) { @@ -10207,6 +10225,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { let cleanPathname = pathname.replace(/\\.rsc$/, ""); const navigationPathname = cleanPathname; const __cachePathname = navigationPathname; + let pageSearchParams = url.searchParams; // Middleware response headers and custom rewrite status are stored in // _mwCtx (per-request container) so handler() can merge them into @@ -10441,7 +10460,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params: actionParams, }); - element = buildPageElement(actionRoute, actionParams, undefined, url.searchParams); + element = buildPageElement(actionRoute, actionParams, undefined, pageSearchParams); } else { element = createElement("div", null, "Page not found"); } @@ -10820,7 +10839,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params, }); - const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); + const __revalElement = await buildPageElement( + route, + params, + undefined, + pageSearchParams, + ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); // Tee RSC stream: one for SSR, one to capture rscData @@ -10960,7 +10984,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { interceptSlot: intercept.slotName, interceptPage: intercept.page, interceptParams: intercept.matchedParams, - }, url.searchParams); + }, pageSearchParams); const interceptOnError = createRscOnErrorHandler( request, cleanPathname, @@ -10986,7 +11010,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { let element; try { - element = await buildPageElement(route, params, interceptOpts, url.searchParams); + element = await buildPageElement(route, params, interceptOpts, pageSearchParams); } catch (buildErr) { // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components if (buildErr && typeof buildErr === "object" && "digest" in buildErr) { @@ -12996,6 +13020,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { let cleanPathname = pathname.replace(/\\.rsc$/, ""); const navigationPathname = cleanPathname; const __cachePathname = navigationPathname; + let pageSearchParams = url.searchParams; // Middleware response headers and custom rewrite status are stored in // _mwCtx (per-request container) so handler() can merge them into @@ -13230,7 +13255,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params: actionParams, }); - element = buildPageElement(actionRoute, actionParams, undefined, url.searchParams); + element = buildPageElement(actionRoute, actionParams, undefined, pageSearchParams); } else { element = createElement("div", null, "Page not found"); } @@ -13609,7 +13634,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params, }); - const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); + const __revalElement = await buildPageElement( + route, + params, + undefined, + pageSearchParams, + ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); // Tee RSC stream: one for SSR, one to capture rscData @@ -13749,7 +13779,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { interceptSlot: intercept.slotName, interceptPage: intercept.page, interceptParams: intercept.matchedParams, - }, url.searchParams); + }, pageSearchParams); const interceptOnError = createRscOnErrorHandler( request, cleanPathname, @@ -13775,7 +13805,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { let element; try { - element = await buildPageElement(route, params, interceptOpts, url.searchParams); + element = await buildPageElement(route, params, interceptOpts, pageSearchParams); } catch (buildErr) { // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components if (buildErr && typeof buildErr === "object" && "digest" in buildErr) { @@ -16007,6 +16037,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { let cleanPathname = pathname.replace(/\\.rsc$/, ""); const navigationPathname = cleanPathname; const __cachePathname = navigationPathname; + let pageSearchParams = url.searchParams; // Middleware response headers and custom rewrite status are stored in // _mwCtx (per-request container) so handler() can merge them into @@ -16064,10 +16095,11 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (rewriteUrl) { const rewriteParsed = new URL(rewriteUrl, request.url); cleanPathname = rewriteParsed.pathname; - // Carry over query params from the rewrite URL so that - // searchParams props, useSearchParams(), and navigation context - // reflect the rewrite destination, not the original request. - url.search = rewriteParsed.search; + // Carry the rewrite query into the server-rendered page props + // without mutating the original request URL or client navigation state. + if (rewriteParsed.search) { + pageSearchParams = rewriteParsed.searchParams; + } // Capture custom status code from rewrite (e.g. NextResponse.rewrite(url, { status: 403 })) if (mwResponse.status !== 200) { _mwCtx.status = mwResponse.status; @@ -16329,7 +16361,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params: actionParams, }); - element = buildPageElement(actionRoute, actionParams, undefined, url.searchParams); + element = buildPageElement(actionRoute, actionParams, undefined, pageSearchParams); } else { element = createElement("div", null, "Page not found"); } @@ -16708,7 +16740,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params, }); - const __revalElement = await buildPageElement(route, params, undefined, url.searchParams); + const __revalElement = await buildPageElement( + route, + params, + undefined, + pageSearchParams, + ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); // Tee RSC stream: one for SSR, one to capture rscData @@ -16848,7 +16885,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { interceptSlot: intercept.slotName, interceptPage: intercept.page, interceptParams: intercept.matchedParams, - }, url.searchParams); + }, pageSearchParams); const interceptOnError = createRscOnErrorHandler( request, cleanPathname, @@ -16874,7 +16911,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { let element; try { - element = await buildPageElement(route, params, interceptOpts, url.searchParams); + element = await buildPageElement(route, params, interceptOpts, pageSearchParams); } catch (buildErr) { // Check for redirect/notFound/forbidden/unauthorized thrown during metadata resolution or async components if (buildErr && typeof buildErr === "object" && "digest" in buildErr) { diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 92b83ebd9..e51c026f0 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -3014,6 +3014,15 @@ describe("App Router middleware with NextRequest", () => { expect(html).toContain("Welcome to App Router"); }); + it("middleware rewrite can inject query params for the rewritten target", async () => { + const { res, html } = await fetchHtml(baseUrl, "/middleware-rewrite-query"); + expect(res.status).toBe(200); + expect(html).toContain("Search Query Test"); + expect(html).toMatch( + /data-testid="props-params">Search Params via Props:\s*(?:)?from-rewrite { const res = await fetch(`${baseUrl}/middleware-blocked`); expect(res.status).toBe(403); diff --git a/tests/fixtures/app-basic/middleware.ts b/tests/fixtures/app-basic/middleware.ts index b737ab92a..d2503e1dd 100644 --- a/tests/fixtures/app-basic/middleware.ts +++ b/tests/fixtures/app-basic/middleware.ts @@ -50,7 +50,7 @@ export function middleware(request: NextRequest) { } // Rewrite with query params — the rewrite URL's query string should be - // visible to the target page via searchParams props and useSearchParams(). + // visible to the target page via searchParams props. if (pathname === "/middleware-rewrite-query") { return NextResponse.rewrite( new URL("/search-query?searchParams=from-rewrite&extra=injected", request.url), @@ -161,7 +161,6 @@ export const config = { "/about", "/middleware-redirect", "/middleware-rewrite", - "/middleware-rewrite-query", "/nextjs-compat/hooks-middleware-rewrite", "/middleware-rewrite-query", "/middleware-rewrite-status",