From 61eefa0a4ead3251125c913a87e24a9ddce9ec0b Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 13 Jun 2026 18:05:51 +1000 Subject: [PATCH 01/23] fix(router): honor hybrid pages route priority --- packages/vinext/src/entries/app-rsc-entry.ts | 4 +- packages/vinext/src/entries/app-ssr-entry.ts | 2 +- .../vinext/src/entries/pages-server-entry.ts | 14 +++++ packages/vinext/src/index.ts | 44 ++++++++++++-- .../vinext/src/server/app-pages-bridge.ts | 58 +++++++++++++++++-- packages/vinext/src/server/app-rsc-handler.ts | 33 +++++++---- .../src/server/hybrid-route-priority.ts | 38 ++++++++++++ playwright.config.ts | 12 ++++ pnpm-lock.yaml | 25 ++++++++ tests/app-router-next-config-codegen.test.ts | 7 ++- .../use-params-app-pages/use-params.spec.ts | 47 +++++++++++++++ .../app/[...path]/page.tsx | 15 +++++ .../app/[id]/[id2]/page.tsx | 16 +++++ .../use-params-app-pages/app/[id]/page.tsx | 15 +++++ .../use-params-app-pages/app/layout.tsx | 7 +++ .../use-params-app-pages/app/page.tsx | 18 ++++++ .../app/rerenders/[dynamic]/page.tsx | 16 +++++ .../use-params-app-pages/next.config.cjs | 5 ++ .../use-params-app-pages/package.json | 16 +++++ .../pages/pages-dir/[dynamic]/index.tsx | 11 ++++ .../use-params-app-pages/tsconfig.json | 21 +++++++ .../use-params-app-pages/vite.config.ts | 6 ++ tests/hybrid-route-priority.test.ts | 48 +++++++++++++++ 23 files changed, 452 insertions(+), 26 deletions(-) create mode 100644 packages/vinext/src/server/hybrid-route-priority.ts create mode 100644 tests/e2e/use-params-app-pages/use-params.spec.ts create mode 100644 tests/fixtures/use-params-app-pages/app/[...path]/page.tsx create mode 100644 tests/fixtures/use-params-app-pages/app/[id]/[id2]/page.tsx create mode 100644 tests/fixtures/use-params-app-pages/app/[id]/page.tsx create mode 100644 tests/fixtures/use-params-app-pages/app/layout.tsx create mode 100644 tests/fixtures/use-params-app-pages/app/page.tsx create mode 100644 tests/fixtures/use-params-app-pages/app/rerenders/[dynamic]/page.tsx create mode 100644 tests/fixtures/use-params-app-pages/next.config.cjs create mode 100644 tests/fixtures/use-params-app-pages/package.json create mode 100644 tests/fixtures/use-params-app-pages/pages/pages-dir/[dynamic]/index.tsx create mode 100644 tests/fixtures/use-params-app-pages/tsconfig.json create mode 100644 tests/fixtures/use-params-app-pages/vite.config.ts create mode 100644 tests/hybrid-route-priority.test.ts diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 617b507fa..96e5cc28a 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -999,9 +999,9 @@ export default __createAppRscHandler({ }, ${ hasPagesDir - ? `async renderPagesFallback({ isRscRequest, middlewareContext, request, url }) { + ? `async renderPagesFallback({ appRouteMatch, isRscRequest, middlewareContext, pathname, request, url }) { return __renderPagesFallback( - { isRscRequest, middlewareContext, request, url }, + { appRouteMatch, isRscRequest, middlewareContext, pathname, request, url }, { loadPagesEntry() { return import.meta.viteRsc.loadModule("ssr", "index"); diff --git a/packages/vinext/src/entries/app-ssr-entry.ts b/packages/vinext/src/entries/app-ssr-entry.ts index 439187f48..cc76719b8 100644 --- a/packages/vinext/src/entries/app-ssr-entry.ts +++ b/packages/vinext/src/entries/app-ssr-entry.ts @@ -20,7 +20,7 @@ export { default } from ${JSON.stringify(entryPath)}; ${ hasPagesDir ? ` -export { handleApiRoute, pageRoutes, renderPage } from "virtual:vinext-server-entry"; +export { handleApiRoute, matchApiRoute, matchPageRoute, pageRoutes, renderPage } from "virtual:vinext-server-entry"; ` : "" }`; diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index 00eeb8a4f..775bb17fc 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -344,6 +344,20 @@ export function matchPageRoute(url, request) { return matchRoute(routeUrl, pageRoutes); } +export function matchApiRoute(url, request) { + const routeUrl = i18nConfig && request + ? resolvePagesI18nRequest( + url, + i18nConfig, + request.headers, + new URL(request.url).hostname, + vinextConfig.basePath, + vinextConfig.trailingSlash, + ).url + : url; + return matchRoute(routeUrl, apiRoutes); +} + // ── Pages render orchestrator — delegates to server/pages-page-handler.ts ── // // All next/*-derived values are passed as closures so the handler module diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 51d8658c9..135efe1c5 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -15,7 +15,12 @@ import { } from "./routing/pages-router.js"; import { generateServerEntry as _generateServerEntry } from "./entries/pages-server-entry.js"; import { generateClientEntry as _generateClientEntry } from "./entries/pages-client-entry.js"; -import { appRouteGraph, appRouter, invalidateAppRouteCache } from "./routing/app-router.js"; +import { + appRouteGraph, + appRouter, + invalidateAppRouteCache, + matchAppRoute, +} from "./routing/app-router.js"; import type { NitroRouteRuleConfig } from "./build/nitro-route-rules.js"; import { buildViteResolveExtensions, @@ -103,6 +108,7 @@ import { type PagesPipelineDeps, type MiddlewareResult, } from "./server/pages-request-pipeline.js"; +import { pagesRouteHasPriorityOverAppRoute } from "./server/hybrid-route-priority.js"; import { proxyExternalRequest } from "./config/config-matchers.js"; import { detectPackageManager } from "./utils/project.js"; import { isUnknownRecord as isRecord } from "./utils/record.js"; @@ -3761,6 +3767,20 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // request, wiping the hybrid app+pages middleware context // (VINEXT_MW_CTX_HEADER, set on req.headers) that the app RSC plugin reads. const apiMatch = matchRoute(pipelineResult.apiUrl, apiRoutes); + if (apiMatch && hasAppDir && appDir) { + const appRoutes = await appRouter( + appDir, + nextConfig?.pageExtensions, + fileMatcher, + ); + const appMatch = matchAppRoute(pipelineResult.apiUrl, appRoutes); + if ( + appMatch && + !pagesRouteHasPriorityOverAppRoute(apiMatch.route, appMatch.route) + ) { + return next(); + } + } if (apiMatch) { flushStagedHeaders(); flushRequestHeaders(); @@ -3791,11 +3811,25 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const routes = await pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher); // Hybrid app+pages dev: if the resolved URL matches no pages route // and an app/ dir exists, defer to the RSC plugin (app routes live - // there). Mirrors the original hasAppDir fallthrough gates that the - // refactor centralised into the pipeline owner. + // there). If both routers match, apply Next.js's merged route + // precedence before choosing which plugin owns the request. const renderMatch = matchRoute(pipelineResult.resolvedUrl.split("?")[0], routes); - if (!renderMatch && hasAppDir) { - return next(); + if (hasAppDir && appDir) { + if (!renderMatch) { + return next(); + } + const appRoutes = await appRouter( + appDir, + nextConfig?.pageExtensions, + fileMatcher, + ); + const appMatch = matchAppRoute(pipelineResult.resolvedUrl, appRoutes); + if ( + appMatch && + !pagesRouteHasPriorityOverAppRoute(renderMatch.route, appMatch.route) + ) { + return next(); + } } const handler = createSSRHandler( server, diff --git a/packages/vinext/src/server/app-pages-bridge.ts b/packages/vinext/src/server/app-pages-bridge.ts index 1cb222b55..62cdb52a4 100644 --- a/packages/vinext/src/server/app-pages-bridge.ts +++ b/packages/vinext/src/server/app-pages-bridge.ts @@ -1,7 +1,10 @@ import type { AppMiddlewareContext } from "./app-middleware.js"; +import { pagesRouteHasPriorityOverAppRoute } from "./hybrid-route-priority.js"; export type PagesEntry = { handleApiRoute?: (request: Request, url: string) => Promise | Response; + matchApiRoute?: (url: string, request: Request) => PagesRouteMatch | null; + matchPageRoute?: (url: string, request: Request) => PagesRouteMatch | null; renderPage?: ( request: Request, url: string, @@ -11,6 +14,20 @@ export type PagesEntry = { ) => Promise | Response; }; +type PagesRouteMatch = { + route: { + isDynamic: boolean; + pattern: string; + }; +}; + +type AppRouteMatch = { + route: { + isDynamic: boolean; + pattern: string; + }; +}; + type RenderPagesFallbackDependencies = { loadPagesEntry: () => Promise | PagesEntry; buildRequestHeaders: ( @@ -41,8 +58,10 @@ type RenderPagesFallbackDependencies = { }; type RenderPagesFallbackOptions = { + appRouteMatch?: AppRouteMatch | null; isRscRequest: boolean; middlewareContext: AppMiddlewareContext; + pathname?: string; request: Request; url: URL; }; @@ -54,7 +73,14 @@ export async function renderPagesFallback( options: RenderPagesFallbackOptions, dependencies: RenderPagesFallbackDependencies, ): Promise { - const { isRscRequest, middlewareContext, request, url } = options; + const { + appRouteMatch = null, + isRscRequest, + middlewareContext, + pathname = options.url.pathname, + request, + url, + } = options; const { loadPagesEntry, buildRequestHeaders, @@ -84,10 +110,23 @@ export async function renderPagesFallback( pagesRequest = new Request(request.url, pagesRequestInit); } - const pagesUrl = decodePathParams(url.pathname) + (url.search || ""); - const pagesPathname = url.pathname; + const pagesUrl = decodePathParams(pathname) + (url.search || ""); + const pagesPathname = pathname; if (pagesPathname.startsWith("/api/") || pagesPathname === "/api") { if (typeof pagesEntry.handleApiRoute !== "function") return null; + const hasApiMatcher = typeof pagesEntry.matchApiRoute === "function"; + const apiMatch = hasApiMatcher + ? (pagesEntry.matchApiRoute?.(pagesUrl, pagesRequest) ?? null) + : null; + if (hasApiMatcher && apiMatch === null) return null; + if (appRouteMatch !== null) { + if ( + apiMatch === null || + !pagesRouteHasPriorityOverAppRoute(apiMatch.route, appRouteMatch.route) + ) { + return null; + } + } const pagesApiResponse = await pagesEntry.handleApiRoute(pagesRequest, pagesUrl); const draftCookie = getDraftModeCookieHeader(); return applyDraftModeCookie( @@ -97,6 +136,17 @@ export async function renderPagesFallback( } if (typeof pagesEntry.renderPage !== "function") return null; + const hasPageMatcher = typeof pagesEntry.matchPageRoute === "function"; + const pageMatch = hasPageMatcher + ? (pagesEntry.matchPageRoute?.(pagesUrl, pagesRequest) ?? null) + : null; + if (hasPageMatcher && pageMatch === null) return null; + if ( + appRouteMatch !== null && + (pageMatch === null || !pagesRouteHasPriorityOverAppRoute(pageMatch.route, appRouteMatch.route)) + ) { + return null; + } const pagesRes = await pagesEntry.renderPage( pagesRequest, pagesUrl, @@ -104,7 +154,7 @@ export async function renderPagesFallback( undefined, middlewareContext.requestHeaders, ); - if (pagesRes.status === 404) return null; + if (pagesRes.status === 404 && pageMatch === null) return null; return applyDraftModeCookie(pagesRes, getDraftModeCookieHeader()); } diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index e7eded2f8..f496aec7f 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -213,9 +213,18 @@ type RenderNotFoundOptions = { scriptNonce?: string; }; +type RenderPagesFallbackRouteMatch = { + route: { + isDynamic: boolean; + pattern: string; + }; +}; + type RenderPagesFallbackOptions = { + appRouteMatch?: RenderPagesFallbackRouteMatch | null; isRscRequest: boolean; middlewareContext: AppRscMiddlewareContext; + pathname: string; request: Request; url: URL; }; @@ -678,6 +687,19 @@ async function handleAppRscRequest( } } + const pagesFallbackResponse = await options.renderPagesFallback?.({ + appRouteMatch: match ?? null, + isRscRequest, + middlewareContext, + pathname: cleanPathname, + request, + url, + }); + if (pagesFallbackResponse) { + options.clearRequestContext(); + return pagesFallbackResponse; + } + if (!match) { const fallbackRewrite = await applyRewrite( { @@ -711,17 +733,6 @@ async function handleAppRscRequest( return new Response("", { status: 404 }); } - const pagesFallbackResponse = await options.renderPagesFallback?.({ - isRscRequest, - middlewareContext, - request, - url, - }); - if (pagesFallbackResponse) { - options.clearRequestContext(); - return pagesFallbackResponse; - } - const renderedNotFoundResponse = await options.renderNotFound({ isRscRequest, middlewareContext, diff --git a/packages/vinext/src/server/hybrid-route-priority.ts b/packages/vinext/src/server/hybrid-route-priority.ts new file mode 100644 index 000000000..e7b97e6ef --- /dev/null +++ b/packages/vinext/src/server/hybrid-route-priority.ts @@ -0,0 +1,38 @@ +import { sortRoutes } from "../routing/utils.js"; + +export type HybridRoutePriorityRoute = { + isDynamic: boolean; + pattern: string; +}; + +type PrioritizedRoute = HybridRoutePriorityRoute & { + owner: "app" | "pages"; +}; + +/** + * Return whether a matched Pages Router route should own the request instead + * of a matched App Router route. + * + * Next.js registers Pages providers before App providers, then sorts all + * dynamic route pathnames together in DefaultRouteMatcherManager. Vinext keeps + * separate route tries for each router, so the hybrid boundary needs to apply + * that same cross-router ordering after both routers have produced their best + * local match. + */ +export function pagesRouteHasPriorityOverAppRoute( + pagesRoute: HybridRoutePriorityRoute, + appRoute: HybridRoutePriorityRoute | null, +): boolean { + if (appRoute === null) return true; + + if (!pagesRoute.isDynamic) return appRoute.isDynamic; + if (!appRoute.isDynamic) return false; + + const routes: PrioritizedRoute[] = [ + { owner: "pages", isDynamic: true, pattern: pagesRoute.pattern }, + { owner: "app", isDynamic: true, pattern: appRoute.pattern }, + ]; + + sortRoutes(routes); + return routes[0].owner === "pages"; +} diff --git a/playwright.config.ts b/playwright.config.ts index 669156d9c..9e4a6af01 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -227,6 +227,18 @@ const projectServers = { timeout: 60_000, }, }, + "use-params-app-pages": { + testDir: "./tests/e2e/use-params-app-pages", + use: { baseURL: "http://localhost:4186" }, + server: { + command: + "cd ../../.. && npx vp run vinext#build && cd tests/fixtures/use-params-app-pages && node ../../../packages/vinext/dist/cli.js build && node ../../../packages/vinext/dist/cli.js start --port 4186", + cwd: "./tests/fixtures/use-params-app-pages", + port: 4186, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, + }, }; type ProjectName = keyof typeof projectServers; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2438ed42..dadec4226 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1552,6 +1552,31 @@ importers: specifier: npm:@voidzero-dev/vite-plus-core@0.1.24 version: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)' + tests/fixtures/use-params-app-pages: + dependencies: + '@vitejs/plugin-rsc': + specifier: 'catalog:' + version: 0.5.27(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(react-dom@19.2.7(react@19.2.7))(react-server-dom-webpack@19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + react: + specifier: 'catalog:' + version: 19.2.7 + react-dom: + specifier: 'catalog:' + version: 19.2.7(react@19.2.7) + react-server-dom-webpack: + specifier: 'catalog:' + version: 19.2.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + vinext: + specifier: workspace:* + version: link:../../../packages/vinext + vite: + specifier: npm:@voidzero-dev/vite-plus-core@0.1.24 + version: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0)' + devDependencies: + vite-plus: + specifier: 'catalog:' + version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.9.2)(@vitest/coverage-istanbul@4.1.6(@voidzero-dev/vite-plus-test@0.1.24))(@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.9.2)(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(sass@1.100.0)(tsx@4.21.1)(typescript@5.9.3)(yaml@2.9.0) + packages: '@alloc/quick-lru@5.2.0': diff --git a/tests/app-router-next-config-codegen.test.ts b/tests/app-router-next-config-codegen.test.ts index cebeeddb6..4607cf051 100644 --- a/tests/app-router-next-config-codegen.test.ts +++ b/tests/app-router-next-config-codegen.test.ts @@ -144,6 +144,9 @@ describe("App Router next.config.js features (generateRscEntry)", () => { expect(code).toContain("renderPagesFallback as __renderPagesFallback"); expect(code).toContain("server/app-pages-bridge.js"); expect(code).toContain("return __renderPagesFallback("); + expect(code).toContain( + "{ appRouteMatch, isRscRequest, middlewareContext, pathname, request, url }", + ); expect(code).toContain('return import.meta.viteRsc.loadModule("ssr", "index");'); expect(code).toContain("buildRequestHeaders: __buildRequestHeadersFromMiddlewareResponse"); expect(code).toContain( @@ -170,9 +173,7 @@ describe("App Router next.config.js features (generateRscEntry)", () => { // dispatcher as well as page rendering. const code = generateSsrEntry(true); - expect(code).toContain( - 'export { handleApiRoute, pageRoutes, renderPage } from "virtual:vinext-server-entry";', - ); + expect(code).toContain("handleApiRoute, matchApiRoute, matchPageRoute, pageRoutes, renderPage"); }); it("embeds basePath and trailingSlash alongside config", () => { diff --git a/tests/e2e/use-params-app-pages/use-params.spec.ts b/tests/e2e/use-params-app-pages/use-params.spec.ts new file mode 100644 index 000000000..cc97fc324 --- /dev/null +++ b/tests/e2e/use-params-app-pages/use-params.spec.ts @@ -0,0 +1,47 @@ +// Ported from Next.js: test/e2e/app-dir/use-params/use-params.test.ts +// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-params/use-params.test.ts +import { expect, test } from "@playwright/test"; + +test.describe("use-params", () => { + test("should work for single dynamic param", async ({ page, baseURL }) => { + await page.goto(`${baseURL}/a/b`); + await expect(page.locator("#param-id")).toHaveText("a"); + }); + + test("should work for nested dynamic params", async ({ page, baseURL }) => { + await page.goto(`${baseURL}/a/b`); + await expect(page.locator("#param-id")).toHaveText("a"); + await expect(page.locator("#param-id2")).toHaveText("b"); + }); + + test("should work for catch all params", async ({ page, baseURL }) => { + await page.goto(`${baseURL}/a/b/c/d/e/f/g`); + await expect(page.locator("#params")).toHaveText('["a","b","c","d","e","f","g"]'); + }); + + test("should work for single dynamic param client navigating", async ({ page, baseURL }) => { + await page.goto(`${baseURL}/`); + await page.locator("#to-a").click(); + await expect(page.locator("#param-id")).toHaveText("a"); + }); + + test("should work for nested dynamic params client navigating", async ({ page, baseURL }) => { + await page.goto(`${baseURL}/`); + await page.locator("#to-a-b").click(); + await expect(page.locator("#param-id")).toHaveText("a"); + await expect(page.locator("#param-id2")).toHaveText("b"); + }); + + test("should work on pages router", async ({ page, baseURL }) => { + await page.goto(`${baseURL}/pages-dir/foobar`); + await expect(page.locator("#params")).toBeVisible(); + await expect(page.locator("#params")).toHaveText('"foobar"'); + }); + + test("shouldn't rerender host component when prefetching", async ({ page, baseURL }) => { + await page.goto(`${baseURL}/rerenders/foobar`); + const initialRandom = await page.locator("#random").textContent(); + await page.locator("a").hover(); + await expect(page.locator("#random")).toHaveText(initialRandom ?? ""); + }); +}); diff --git a/tests/fixtures/use-params-app-pages/app/[...path]/page.tsx b/tests/fixtures/use-params-app-pages/app/[...path]/page.tsx new file mode 100644 index 000000000..abae9652b --- /dev/null +++ b/tests/fixtures/use-params-app-pages/app/[...path]/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { useParams } from "next/navigation"; + +export default function Page() { + const params = useParams(); + if (params === null) { + return null; + } + return ( +
+
{JSON.stringify(params.path)}
+
+ ); +} diff --git a/tests/fixtures/use-params-app-pages/app/[id]/[id2]/page.tsx b/tests/fixtures/use-params-app-pages/app/[id]/[id2]/page.tsx new file mode 100644 index 000000000..4252a867e --- /dev/null +++ b/tests/fixtures/use-params-app-pages/app/[id]/[id2]/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { useParams } from "next/navigation"; + +export default function Page() { + const params = useParams(); + if (params === null) { + return null; + } + return ( +
+
{params.id}
+
{params.id2}
+
+ ); +} diff --git a/tests/fixtures/use-params-app-pages/app/[id]/page.tsx b/tests/fixtures/use-params-app-pages/app/[id]/page.tsx new file mode 100644 index 000000000..160893258 --- /dev/null +++ b/tests/fixtures/use-params-app-pages/app/[id]/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { useParams } from "next/navigation"; + +export default function Page() { + const params = useParams(); + if (params === null) { + return null; + } + return ( +
+
{params.id}
+
+ ); +} diff --git a/tests/fixtures/use-params-app-pages/app/layout.tsx b/tests/fixtures/use-params-app-pages/app/layout.tsx new file mode 100644 index 000000000..8d739265d --- /dev/null +++ b/tests/fixtures/use-params-app-pages/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/tests/fixtures/use-params-app-pages/app/page.tsx b/tests/fixtures/use-params-app-pages/app/page.tsx new file mode 100644 index 000000000..88359ba6c --- /dev/null +++ b/tests/fixtures/use-params-app-pages/app/page.tsx @@ -0,0 +1,18 @@ +import Link from "next/link"; + +export default function Page() { + return ( + <> +
+ + To /a + +
+
+ + To /a/b + +
+ + ); +} diff --git a/tests/fixtures/use-params-app-pages/app/rerenders/[dynamic]/page.tsx b/tests/fixtures/use-params-app-pages/app/rerenders/[dynamic]/page.tsx new file mode 100644 index 000000000..bb147663a --- /dev/null +++ b/tests/fixtures/use-params-app-pages/app/rerenders/[dynamic]/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import Link from "next/link"; +import { useParams } from "next/navigation"; + +export default function Page() { + const params = useParams(); + + return ( +
+ Link +
{Math.random()}
+
{JSON.stringify(params?.dynamic)}
+
+ ); +} diff --git a/tests/fixtures/use-params-app-pages/next.config.cjs b/tests/fixtures/use-params-app-pages/next.config.cjs new file mode 100644 index 000000000..3a3c1cbfd --- /dev/null +++ b/tests/fixtures/use-params-app-pages/next.config.cjs @@ -0,0 +1,5 @@ +/** + * Ported from Next.js: test/e2e/app-dir/use-params/next.config.js + * https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-params/next.config.js + */ +module.exports = {}; diff --git a/tests/fixtures/use-params-app-pages/package.json b/tests/fixtures/use-params-app-pages/package.json new file mode 100644 index 000000000..c26261d89 --- /dev/null +++ b/tests/fixtures/use-params-app-pages/package.json @@ -0,0 +1,16 @@ +{ + "name": "use-params-app-pages-fixture", + "private": true, + "type": "module", + "dependencies": { + "@vitejs/plugin-rsc": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "react-server-dom-webpack": "catalog:", + "vinext": "workspace:*", + "vite": "catalog:" + }, + "devDependencies": { + "vite-plus": "catalog:" + } +} diff --git a/tests/fixtures/use-params-app-pages/pages/pages-dir/[dynamic]/index.tsx b/tests/fixtures/use-params-app-pages/pages/pages-dir/[dynamic]/index.tsx new file mode 100644 index 000000000..c75b6216d --- /dev/null +++ b/tests/fixtures/use-params-app-pages/pages/pages-dir/[dynamic]/index.tsx @@ -0,0 +1,11 @@ +import { useParams } from "next/navigation"; + +export default function Page() { + const params = useParams(); + + return ( +
+
{JSON.stringify(params?.dynamic)}
+
+ ); +} diff --git a/tests/fixtures/use-params-app-pages/tsconfig.json b/tests/fixtures/use-params-app-pages/tsconfig.json new file mode 100644 index 000000000..e8455b7c3 --- /dev/null +++ b/tests/fixtures/use-params-app-pages/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": false, + "strict": false, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "incremental": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "target": "ES2017", + "strictNullChecks": true + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", "**/*.test.ts", "**/*.test.tsx"] +} diff --git a/tests/fixtures/use-params-app-pages/vite.config.ts b/tests/fixtures/use-params-app-pages/vite.config.ts new file mode 100644 index 000000000..d0a0fd505 --- /dev/null +++ b/tests/fixtures/use-params-app-pages/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import vinext from "vinext"; + +export default defineConfig({ + plugins: [vinext({ appDir: import.meta.dirname })], +}); diff --git a/tests/hybrid-route-priority.test.ts b/tests/hybrid-route-priority.test.ts new file mode 100644 index 000000000..32d8f2d15 --- /dev/null +++ b/tests/hybrid-route-priority.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { pagesRouteHasPriorityOverAppRoute } from "../packages/vinext/src/server/hybrid-route-priority.js"; + +describe("hybrid App Router + Pages Router route priority", () => { + it("lets a more specific Pages dynamic route beat an App root catch-all", () => { + // Ported from Next.js: test/e2e/app-dir/use-params/use-params.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-params/use-params.test.ts + // + // Next.js's DefaultRouteMatcherManager merges Pages and App matchers before + // sorting dynamic routes, so /pages-dir/[dynamic] owns /pages-dir/foobar + // ahead of app/[...path]. + expect( + pagesRouteHasPriorityOverAppRoute( + { isDynamic: true, pattern: "/pages-dir/:dynamic" }, + { isDynamic: true, pattern: "/:path+" }, + ), + ).toBe(true); + }); + + it("keeps a more specific App static route ahead of a Pages catch-all", () => { + expect( + pagesRouteHasPriorityOverAppRoute( + { isDynamic: true, pattern: "/:path+" }, + { isDynamic: false, pattern: "/dashboard" }, + ), + ).toBe(false); + }); + + it("keeps the App route ahead of an identical static Pages route", () => { + expect( + pagesRouteHasPriorityOverAppRoute( + { isDynamic: false, pattern: "/" }, + { isDynamic: false, pattern: "/" }, + ), + ).toBe(false); + }); + + it("uses Pages provider order as the tie-breaker for identical dynamic patterns", () => { + // Next.js pushes Pages providers before App providers, then preserves + // provider order when merging dynamic matchers with the same pathname. + expect( + pagesRouteHasPriorityOverAppRoute( + { isDynamic: true, pattern: "/:slug" }, + { isDynamic: true, pattern: "/:slug" }, + ), + ).toBe(true); + }); +}); From f7f91f9faf8a854dabd95c758a6627d9cddff1f3 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:33:06 +1000 Subject: [PATCH 02/23] fix(router): share hybrid owner decision with client navigation PR #1997 fixed the server-side route ownership for direct document loads, but the same invariant broke for client-side soft navigations and the matching prefetch path: - navigateClientSide() always delegated to the App runtime's RSC fetch, even when the Pages route had higher priority. renderPagesFallback() short-circuits RSC requests with null, so the App catch-all won. - prefetchUrl() prefetched an RSC stream for any URL that matched an App route, again ignoring Pages ownership. - The Pages entry was loaded on every hybrid request, even when a static App route had already matched (Pages can never win in that case). Expose the Pages route manifest on the client via a new __VINEXT_PAGES_LINK_PREFETCH_ROUTES__ window global emitted by both the App and Pages browser entries. The link shim consults a shared resolveHybridClientRouteOwner helper that mirrors the server-side pagesRouteHasPriorityOverAppRoute comparison. When Pages owns the URL, the click handler issues a window.location navigation and the prefetch path returns early. Gate the renderPagesFallback call behind a static-App-route check in handleAppRscRequest: when a static App route matches, the bridge cannot win, so skip the eager Pages entry load. Centralise the comparison in a new resolveHybridRouteOwner helper so server and client reach the same answer for the same (URL, route pair). Adds the missing client-navigation coverage to the use-params e2e fixture (Link from /app/ to /pages-dir/foobar) and unit tests for the shared owner decision. --- .../vinext/src/client/vinext-next-data.ts | 16 ++ .../vinext/src/entries/app-browser-entry.ts | 11 +- .../vinext/src/entries/pages-client-entry.ts | 28 ++- packages/vinext/src/index.ts | 14 +- packages/vinext/src/server/app-rsc-handler.ts | 24 ++- .../src/server/hybrid-route-priority.ts | 30 ++++ .../internal/hybrid-client-route-owner.ts | 170 ++++++++++++++++++ packages/vinext/src/shims/link.tsx | 35 ++++ .../use-params-app-pages/use-params.spec.ts | 14 ++ .../use-params-app-pages/app/page.tsx | 5 + tests/hybrid-route-priority.test.ts | 65 ++++++- tests/pages-router.test.ts | 14 +- 12 files changed, 412 insertions(+), 14 deletions(-) create mode 100644 packages/vinext/src/shims/internal/hybrid-client-route-owner.ts diff --git a/packages/vinext/src/client/vinext-next-data.ts b/packages/vinext/src/client/vinext-next-data.ts index fc8aea3cb..efd63c689 100644 --- a/packages/vinext/src/client/vinext-next-data.ts +++ b/packages/vinext/src/client/vinext-next-data.ts @@ -14,6 +14,22 @@ export type VinextLinkPrefetchRoute = { patternParts: string[]; }; +/** + * Pages Router route pattern exposed to the client so the App Router's + * navigation runtime can decide whether a soft-navigated URL should be + * handled by Pages (hard nav) or App (RSC). Mirrors the public shape of + * `VinextLinkPrefetchRoute` so a single trie matcher handles both. + * + * `canPrefetchLoadingShell` is always `false` for Pages routes — Pages + * does not have a separate loading boundary and its prefetch surface is + * `_next/data//.json`. + */ +export type VinextPagesLinkPrefetchRoute = { + canPrefetchLoadingShell: false; + isDynamic: boolean; + patternParts: string[]; +}; + export type VinextNextData = { /** vinext-specific additions (not part of Next.js upstream). */ __vinext?: { diff --git a/packages/vinext/src/entries/app-browser-entry.ts b/packages/vinext/src/entries/app-browser-entry.ts index 62aaedbd9..a4c6a5d96 100644 --- a/packages/vinext/src/entries/app-browser-entry.ts +++ b/packages/vinext/src/entries/app-browser-entry.ts @@ -1,5 +1,8 @@ import { resolveClientRuntimeModule, resolveRuntimeEntryModule } from "./runtime-entry-module.js"; -import type { VinextLinkPrefetchRoute } from "../client/vinext-next-data.js"; +import type { + VinextLinkPrefetchRoute, + VinextPagesLinkPrefetchRoute, +} from "../client/vinext-next-data.js"; import type { AppRoute } from "../routing/app-router.js"; import type { RouteManifest } from "../routing/app-route-graph.js"; @@ -13,6 +16,7 @@ import type { RouteManifest } from "../routing/app-route-graph.js"; export function generateBrowserEntry( routes: readonly AppRoute[] = [], routeManifest: RouteManifest | null = null, + pagesPrefetchRoutes: readonly VinextPagesLinkPrefetchRoute[] = [], ): string { const entryPath = resolveRuntimeEntryModule("app-browser-entry"); const navigationRuntimePath = resolveClientRuntimeModule("navigation-runtime"); @@ -23,6 +27,11 @@ export function generateBrowserEntry( return `import { registerNavigationRuntimeBootstrap } from ${JSON.stringify(navigationRuntimePath)}; window.__VINEXT_LINK_PREFETCH_ROUTES__ = ${JSON.stringify(prefetchRoutes)}; +// Pages route manifest for hybrid ownership decisions. In a hybrid +// app+pages build the user can land on an App page, so the App browser +// entry must also expose the Pages manifest (the Pages client entry does +// the same — whichever entry runs first emits both globals). +window.__VINEXT_PAGES_LINK_PREFETCH_ROUTES__ = ${JSON.stringify(pagesPrefetchRoutes)}; registerNavigationRuntimeBootstrap({ routeManifest: ${buildRouteManifestExpression(routeManifest)} }); diff --git a/packages/vinext/src/entries/pages-client-entry.ts b/packages/vinext/src/entries/pages-client-entry.ts index ba46e76e3..f30974e29 100644 --- a/packages/vinext/src/entries/pages-client-entry.ts +++ b/packages/vinext/src/entries/pages-client-entry.ts @@ -16,10 +16,29 @@ import { } from "../routing/pages-router.js"; import { createValidFileMatcher } from "../routing/file-matcher.js"; import { type ResolvedNextConfig } from "../config/next-config.js"; -import type { VinextLinkPrefetchRoute } from "../client/vinext-next-data.js"; +import type { + VinextLinkPrefetchRoute, + VinextPagesLinkPrefetchRoute, +} from "../client/vinext-next-data.js"; import { findFileWithExts } from "./pages-entry-helpers.js"; import { normalizePathSeparators } from "../utils/path.js"; +/** + * Project a Pages `Route` down to the public `VinextPagesLinkPrefetchRoute` + * shape used for client-side hybrid ownership decisions. Mirrors + * `toLinkPrefetchRoute` in `app-browser-entry.ts`. + * + * Lives here (not in `routing/pages-router.ts`) so the routing module + * stays free of `vitext/client` type imports. + */ +function toPagesLinkPrefetchRoute(route: Route): VinextPagesLinkPrefetchRoute { + return { + canPrefetchLoadingShell: false, + isDynamic: route.isDynamic, + patternParts: [...route.patternParts], + }; +} + export async function generateClientEntry( pagesDir: string, nextConfig: ResolvedNextConfig, @@ -34,6 +53,8 @@ export async function generateClientEntry( const appFilePath = findFileWithExts(pagesDir, "_app", fileMatcher); const hasApp = appFilePath !== null; const appPrefetchRoutes = options.appPrefetchRoutes ?? []; + const pagesPrefetchRoutes: VinextPagesLinkPrefetchRoute[] = + pageRoutes.map(toPagesLinkPrefetchRoute); const instrumentationClientPath = options.instrumentationClientPath ?? null; // Build a map of route pattern -> dynamic import. @@ -129,6 +150,11 @@ window.__VINEXT_APP_LOADER__ = appLoader; // when the user lands on an App Router page (see app-browser-entry.ts) — the // two writes do not race because only one entry executes per page load. window.__VINEXT_LINK_PREFETCH_ROUTES__ = ${JSON.stringify(appPrefetchRoutes)}; +// Pages route manifest, exposed so the App Router runtime can decide when +// a soft-navigated URL is actually owned by Pages (and must hard-navigate +// instead of issuing an RSC request). Set here AND in app-browser-entry.ts +// so whichever entry runs first emits the Pages manifest. +window.__VINEXT_PAGES_LINK_PREFETCH_ROUTES__ = ${JSON.stringify(pagesPrefetchRoutes)}; async function hydrate() { const nextData = window.__NEXT_DATA__; diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 135efe1c5..edfb89f31 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2706,7 +2706,19 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } if (id === RESOLVED_APP_BROWSER_ENTRY && hasAppDir) { const graph = await appRouteGraph(appDir, nextConfig?.pageExtensions, fileMatcher); - return generateBrowserEntry(graph.routes, graph.routeManifest); + // In a hybrid build, the App browser entry also exposes the Pages + // route manifest so a user who lands on an App page can still + // see Pages ownership from a `` click. + const pagesPrefetchRoutes = hasPagesDir + ? (await pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher)).map( + (route) => ({ + canPrefetchLoadingShell: false as const, + isDynamic: route.isDynamic, + patternParts: [...route.patternParts], + }), + ) + : []; + return generateBrowserEntry(graph.routes, graph.routeManifest, pagesPrefetchRoutes); } if (id.startsWith(RESOLVED_VIRTUAL_GOOGLE_FONTS + "?")) { return generateGoogleFontsVirtualModule(id, _fontGoogleShimPath); diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index f496aec7f..c11767f63 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -687,14 +687,22 @@ async function handleAppRscRequest( } } - const pagesFallbackResponse = await options.renderPagesFallback?.({ - appRouteMatch: match ?? null, - isRscRequest, - middlewareContext, - pathname: cleanPathname, - request, - url, - }); + // Hybrid ownership: a static App route always wins over any Pages route + // (see `pagesRouteHasPriorityOverAppRoute`). When we have matched a static + // App route, the Pages fallback cannot own this request, so skip eagerly + // loading the Pages entry on every cold-start. The bridge still handles the + // `match === null` case (no App match) and the dynamic-App case below. + const pagesFallbackEligible = match === null || match.route.isDynamic; + const pagesFallbackResponse = pagesFallbackEligible + ? await options.renderPagesFallback?.({ + appRouteMatch: match ?? null, + isRscRequest, + middlewareContext, + pathname: cleanPathname, + request, + url, + }) + : null; if (pagesFallbackResponse) { options.clearRequestContext(); return pagesFallbackResponse; diff --git a/packages/vinext/src/server/hybrid-route-priority.ts b/packages/vinext/src/server/hybrid-route-priority.ts index e7b97e6ef..f19c36883 100644 --- a/packages/vinext/src/server/hybrid-route-priority.ts +++ b/packages/vinext/src/server/hybrid-route-priority.ts @@ -9,6 +9,13 @@ type PrioritizedRoute = HybridRoutePriorityRoute & { owner: "app" | "pages"; }; +export type HybridOwner = "app" | "pages"; + +export type HybridRouteMatch = { + route: R; + params: Record; +}; + /** * Return whether a matched Pages Router route should own the request instead * of a matched App Router route. @@ -36,3 +43,26 @@ export function pagesRouteHasPriorityOverAppRoute( sortRoutes(routes); return routes[0].owner === "pages"; } + +/** + * Compare two already-matched routes (one from each router) and decide which + * router should own the request. + * + * Returns the owning router, or `null` when both routers missed. This is the + * shape the client-side link/prefetch pipeline needs: a single answer it can + * switch on to choose between an RSC navigation (App) and a document/Pages + * navigation (Pages). + * + * Centralises the same `pagesRouteHasPriorityOverAppRoute` comparison the + * server uses so client navigations, prefetch detection, and direct document + * loads all reach the same answer for the same route pair. + */ +export function resolveHybridRouteOwner( + appMatch: HybridRouteMatch | null, + pagesMatch: HybridRouteMatch | null, +): HybridOwner | null { + if (appMatch === null && pagesMatch === null) return null; + if (appMatch === null) return "pages"; + if (pagesMatch === null) return "app"; + return pagesRouteHasPriorityOverAppRoute(pagesMatch.route, appMatch.route) ? "pages" : "app"; +} diff --git a/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts b/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts new file mode 100644 index 000000000..a7287144a --- /dev/null +++ b/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts @@ -0,0 +1,170 @@ +/** + * Client-side resolver that decides whether a URL should be soft-navigated + * (App Router / RSC) or hard-navigated (Pages Router / document). Mirrors + * the server-side `pagesRouteHasPriorityOverAppRoute` priority check so the + * click handler, hover/intent prefetch, and direct document load all reach + * the same owner for the same (URL, route pair). + * + * Lives in `shims/internal/` because both `link.tsx` and the App Router + * browser entry import it without pulling in the server route graph. + * + * The App + Pages route manifests are emitted once per page load by the + * Vite plugin onto the matching `__VINEXT_*_PREFETCH_ROUTES__` window + * globals (see `entries/app-browser-entry.ts` and + * `entries/pages-client-entry.ts`). Hybrid builds expose both globals; a + * single-router build only sets its own. + */ +import { createRouteTrieCache, matchRouteWithTrie } from "../../routing/route-matching.js"; +import { stripBasePath } from "../../utils/base-path.js"; +import { getLocalePathPrefix } from "../../utils/domain-locale.js"; +import type { + VinextLinkPrefetchRoute, + VinextPagesLinkPrefetchRoute, +} from "../../client/vinext-next-data.js"; + +type HybridClientRoute = VinextLinkPrefetchRoute | VinextPagesLinkPrefetchRoute; + +export type HybridClientOwner = "app" | "pages"; + +declare global { + // oxlint-disable-next-line typescript-eslint/consistent-type-definitions + interface Window { + __VINEXT_LINK_PREFETCH_ROUTES__?: VinextLinkPrefetchRoute[]; + __VINEXT_PAGES_LINK_PREFETCH_ROUTES__?: VinextPagesLinkPrefetchRoute[]; + } +} + +const appRouteTrieCache = createRouteTrieCache(); +const pagesRouteTrieCache = createRouteTrieCache(); + +/** + * Pure: compare two matched routes and return the owner. Mirrors the + * server-side `pagesRouteHasPriorityOverAppRoute` rules. Centralising the + * rules here keeps the link click, prefetch, and direct document load + * paths agreeing on the same owner. + */ +function pagesWins(pagesRoute: HybridClientRoute, appRoute: HybridClientRoute): boolean { + // Static routes never match a dynamic catch-all on the other router. + if (!pagesRoute.isDynamic) return appRoute.isDynamic; + if (!appRoute.isDynamic) return false; + + // Both dynamic. Apply Next.js's merged dynamic-route sorting: routes are + // compared by their pattern specificity. A path with more static segments + // (or static segments closer to the start) wins. We rebuild the pattern + // from patternParts because the client manifest is segment-shaped — the + // server-side `sortRoutes` works on `{ pattern: string }` shape. + // The trie match guarantees both routes are present, so each has at + // least one segment; an empty patternParts would be a "/" static match + // and the isDynamic guards above would have already short-circuited. + const pagesPattern = "/" + pagesRoute.patternParts.join("/"); + const appPattern = "/" + appRoute.patternParts.join("/"); + return routePrecedence(pagesPattern) < routePrecedence(appPattern); +} + +/** + * Inline copy of `routePrecedence` from `routing/utils.ts`. Kept in sync + * by hand to avoid pulling the entire `utils.ts` module (which transitively + * depends on Node-only helpers) into the client bundle. The function is + * pure and self-contained. + * + * Matches `packages/vinext/src/routing/utils.ts` `routePrecedence`: + * 1. Static routes first (scored by segment count, more = more specific) + * 2. Dynamic segments penalized by position + * 3. Catch-all comes after dynamic + * 4. Optional catch-all last + * 5. Lexicographic tiebreaker for determinism + */ +function routePrecedence(pattern: string): number { + const parts = pattern.split("/").filter(Boolean); + let score = 0; + let staticPrefixCount = 0; + for (const p of parts) { + if (p.startsWith(":") || p.endsWith("+") || p.endsWith("*")) break; + staticPrefixCount++; + } + for (let i = 0; i < parts.length; i++) { + const p = parts[i]; + if (p.endsWith("+")) { + score += 1000 + i; // catch-all + } else if (p.endsWith("*")) { + score += 2000 + i; // optional catch-all + } else if (p.startsWith(":")) { + score += 100 + i; // dynamic + } else if (i >= staticPrefixCount) { + score -= 500; // infix static + } + } + return score; +} + +function resolveSameOriginPathname(href: string, basePath: string): string | null { + if (typeof window === "undefined") return null; + let url: URL; + try { + url = new URL(href, window.location.href); + } catch { + return null; + } + if (url.origin !== window.location.origin) return null; + const pathname = stripBasePath(url.pathname, basePath); + const locale = getLocalePathPrefix(pathname, window.__VINEXT_LOCALES__); + if (!locale) return pathname; + const localePrefixLength = locale.length + 1; + return pathname.length === localePrefixLength ? "/" : pathname.slice(localePrefixLength); +} + +function matchAppRoute( + href: string, + basePath: string, + routes: readonly VinextLinkPrefetchRoute[], +): VinextLinkPrefetchRoute | null { + const pathname = resolveSameOriginPathname(href, basePath); + if (pathname === null) return null; + return ( + matchRouteWithTrie(pathname, routes as VinextLinkPrefetchRoute[], appRouteTrieCache)?.route ?? + null + ); +} + +function matchPagesRoute( + href: string, + basePath: string, + routes: readonly VinextPagesLinkPrefetchRoute[], +): VinextPagesLinkPrefetchRoute | null { + const pathname = resolveSameOriginPathname(href, basePath); + if (pathname === null) return null; + return ( + matchRouteWithTrie(pathname, routes as VinextPagesLinkPrefetchRoute[], pagesRouteTrieCache) + ?.route ?? null + ); +} + +/** + * Decide which router should own a soft-navigated URL. Returns: + * - "app" → the App Router runtime handles the navigation (RSC fetch). + * - "pages" → Pages owns the URL; the caller must hard-navigate instead. + * - null → no router matched (preserves the existing 404 path). + * + * `basePath` must match what the page uses (typically `process.env.__NEXT_ROUTER_BASEPATH`). + * + * The lookup uses the App and Pages manifests on `window` so the same + * matcher trie produces the same result the server will see when the + * request lands. + */ +export function resolveHybridClientRouteOwner( + href: string, + basePath: string, +): HybridClientOwner | null { + if (typeof window === "undefined") return null; + + const appRoutes = window.__VINEXT_LINK_PREFETCH_ROUTES__; + const pagesRoutes = window.__VINEXT_PAGES_LINK_PREFETCH_ROUTES__; + + const appMatch = appRoutes ? matchAppRoute(href, basePath, appRoutes) : null; + const pagesMatch = pagesRoutes ? matchPagesRoute(href, basePath, pagesRoutes) : null; + + if (appMatch === null && pagesMatch === null) return null; + if (pagesMatch === null) return "app"; + if (appMatch === null) return "pages"; + return pagesWins(pagesMatch, appMatch) ? "pages" : "app"; +} diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index dfa6ac71c..6a3cc0491 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -70,6 +70,7 @@ import { resolvePagesDataNavigationTarget, } from "./internal/pages-data-target.js"; import { markAppRouteDetectedOnPrefetch } from "./internal/app-route-detection.js"; +import { resolveHybridClientRouteOwner } from "./internal/hybrid-client-route-owner.js"; import { getCurrentBrowserLocale } from "./client-locale.js"; import { clearLinkForCurrentNavigation, @@ -390,6 +391,16 @@ function prefetchUrl(href: string, mode: LinkPrefetchMode, priority: "low" | "hi schedule(() => { void (async () => { if (hasAppNavigationRuntime()) { + // Hybrid ownership: skip the App RSC prefetch when Pages owns the + // URL. The App's `__VINEXT_LINK_PREFETCH_ROUTES__` may include an + // App catch-all that also matches the same path, so a naive + // prefetch would fetch an RSC stream for a Pages route — that + // stream is never consumed (the click path now hard-navigates to + // Pages) and would also race the request the browser will issue on + // the actual navigation. + if (resolveHybridClientRouteOwner(prefetchHref, __basePath) === "pages") { + return; + } const autoPrefetch = mode === "auto" ? resolveAutoAppRoutePrefetch(prefetchHref) @@ -976,6 +987,30 @@ const Link = forwardRef(function Link( } } + // Hybrid ownership check: when the App Router runtime is installed and + // the target URL is owned by the Pages Router, soft-navigating with RSC + // would either (a) hit the App catch-all, or (b) bounce off + // `renderPagesFallback` returning null for RSC requests. Either way the + // user lands on the wrong route. Pages only renders HTML documents or + // `_next/data` JSON, so the only correct path is a document navigation. + // + // We compare ownership here, not in `navigateClientSide`, because the + // document navigation is committed synchronously by the browser — there + // is no RSC stream to suspend on, so the soft-navigation bookkeeping + // (`setPending`, `setLinkForCurrentNavigation`) would be a no-op at best + // and a stale `useLinkStatus` indicator at worst. + if ( + getNavigationRuntime()?.functions.navigate && + resolveHybridClientRouteOwner(navigateHref, __basePath) === "pages" + ) { + if (replace) { + window.location.replace(absoluteFullHref); + } else { + window.location.assign(absoluteFullHref); + } + return; + } + // App Router: delegate to navigateClientSide which handles scroll save, // hash-only changes, RSC fetch, and two-phase URL commit. if (getNavigationRuntime()?.functions.navigate) { diff --git a/tests/e2e/use-params-app-pages/use-params.spec.ts b/tests/e2e/use-params-app-pages/use-params.spec.ts index cc97fc324..f1b75a977 100644 --- a/tests/e2e/use-params-app-pages/use-params.spec.ts +++ b/tests/e2e/use-params-app-pages/use-params.spec.ts @@ -38,6 +38,20 @@ test.describe("use-params", () => { await expect(page.locator("#params")).toHaveText('"foobar"'); }); + test("Pages route wins for soft-navigation from an App Link", async ({ page, baseURL }) => { + // Hybrid invariant: the App root catch-all app/[...path] matches + // /pages-dir/foobar, but Pages dynamic route pages/pages-dir/[dynamic] has + // higher priority (Pages providers sort ahead of App providers in + // DefaultRouteMatcherManager). The client must not soft-navigate through + // the App runtime when Pages owns the URL — the App RSC stream would + // render the catch-all's path array. Falls back to a document navigation + // so the Pages handler renders the page. + await page.goto(`${baseURL}/`); + await page.locator("#to-pages").click(); + await expect(page).toHaveURL(/\/pages-dir\/foobar$/); + await expect(page.locator("#params")).toHaveText('"foobar"'); + }); + test("shouldn't rerender host component when prefetching", async ({ page, baseURL }) => { await page.goto(`${baseURL}/rerenders/foobar`); const initialRandom = await page.locator("#random").textContent(); diff --git a/tests/fixtures/use-params-app-pages/app/page.tsx b/tests/fixtures/use-params-app-pages/app/page.tsx index 88359ba6c..7ef5b4a36 100644 --- a/tests/fixtures/use-params-app-pages/app/page.tsx +++ b/tests/fixtures/use-params-app-pages/app/page.tsx @@ -13,6 +13,11 @@ export default function Page() { To /a/b +
+ + To /pages-dir/foobar (Pages) + +
); } diff --git a/tests/hybrid-route-priority.test.ts b/tests/hybrid-route-priority.test.ts index 32d8f2d15..d3c7d7fb2 100644 --- a/tests/hybrid-route-priority.test.ts +++ b/tests/hybrid-route-priority.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { pagesRouteHasPriorityOverAppRoute } from "../packages/vinext/src/server/hybrid-route-priority.js"; +import { + pagesRouteHasPriorityOverAppRoute, + resolveHybridRouteOwner, +} from "../packages/vinext/src/server/hybrid-route-priority.js"; describe("hybrid App Router + Pages Router route priority", () => { it("lets a more specific Pages dynamic route beat an App root catch-all", () => { @@ -46,3 +49,63 @@ describe("hybrid App Router + Pages Router route priority", () => { ).toBe(true); }); }); + +describe("resolveHybridRouteOwner", () => { + it("returns null when neither router matched", () => { + expect(resolveHybridRouteOwner(null, null)).toBeNull(); + }); + + it("returns the matched router when only one router matched", () => { + const matched = { + route: { isDynamic: true, pattern: "/a" }, + params: { id: "1" }, + }; + expect(resolveHybridRouteOwner(matched, null)).toBe("app"); + expect(resolveHybridRouteOwner(null, matched)).toBe("pages"); + }); + + it("lets a more specific Pages dynamic route beat an App root catch-all", () => { + // /pages-dir/[dynamic] owns /pages-dir/foobar ahead of app/[...path]. + expect( + resolveHybridRouteOwner( + { + route: { isDynamic: true, pattern: "/:path+" }, + params: { path: ["pages-dir", "foobar"] }, + }, + { + route: { isDynamic: true, pattern: "/pages-dir/:dynamic" }, + params: { dynamic: "foobar" }, + }, + ), + ).toBe("pages"); + }); + + it("lets an App static route own the request when Pages only has a catch-all", () => { + expect( + resolveHybridRouteOwner( + { route: { isDynamic: false, pattern: "/dashboard" }, params: {} }, + { route: { isDynamic: true, pattern: "/:path+" }, params: { path: "dashboard" } }, + ), + ).toBe("app"); + }); + + it("lets an App static route win over an identical static Pages route", () => { + expect( + resolveHybridRouteOwner( + { route: { isDynamic: false, pattern: "/" }, params: {} }, + { route: { isDynamic: false, pattern: "/" }, params: {} }, + ), + ).toBe("app"); + }); + + it("uses Pages provider order as the tie-breaker for identical dynamic patterns", () => { + // Next.js pushes Pages providers before App providers, so identical + // dynamic patterns go to Pages. + expect( + resolveHybridRouteOwner( + { route: { isDynamic: true, pattern: "/:slug" }, params: { slug: "x" } }, + { route: { isDynamic: true, pattern: "/:slug" }, params: { slug: "x" } }, + ), + ).toBe("pages"); + }); +}); diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 190ac706b..dc04135e9 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -2089,8 +2089,18 @@ describe("Virtual server entry generation", () => { expect(code).toContain('"/docs/[...slug]"'); // Should NOT contain Express-style :param patterns for any route expect(code).not.toMatch(/["']\/(posts|blog|articles|docs|products)\/:[\w]+["']/); - expect(code).not.toContain(":slug+"); - expect(code).not.toContain(":slug*"); + // Strip the `__VINEXT_PAGES_LINK_PREFETCH_ROUTES__` manifest before the + // next two assertions. The manifest is exempt because it carries the + // internal pattern shape (with `:slug+` / `:slug*`) so the client-side + // hybrid owner resolver can rebuild a pattern from `patternParts` to + // feed `routePrecedence`. The pageLoaders map (above) still uses + // Next.js bracket format for hydration keys. + const codeWithoutPrefetchManifest = code.replace( + /__VINEXT_PAGES_LINK_PREFETCH_ROUTES__\s*=\s*(\[[\s\S]*?\]);/, + "__VINEXT_PAGES_LINK_PREFETCH_ROUTES__ = /* stripped for test */;", + ); + expect(codeWithoutPrefetchManifest).not.toContain(":slug+"); + expect(codeWithoutPrefetchManifest).not.toContain(":slug*"); } finally { await testServer.close(); } From 50983b71c65a4b6c77dadc30029ef87b82141558 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:51:16 +1000 Subject: [PATCH 03/23] fix(router): mirror server hybrid owner decision on the client PR review flagged two split-brain bugs in the previous hybrid ownership fix: a hand-copied client comparator and a Link-only ownership gate that left programmatic App Router navigations on the wrong path. The hand-copied routePrecedence in hybrid-client-route-owner.ts omitted the static-prefix reduction that lives in routing/utils.ts#routePrecedence, and used a strict-less-than comparison that returned App for identical dynamic patterns. The server returns Pages for both cases (Pages providers sort ahead of App providers, and routePrecedence subtracts 50 per static prefix segment). The split produced a real ownership disagreement on overlapping patterns like /_sites/:slug* (Pages) vs /:slug* (App). Add a shared compareHybridRoutePatterns to routing/utils.ts as the single source of truth: the static/dynamic short-circuits plus a sortRoutes call (which carries the static-prefix reduction and the Pages-first equal-pattern tiebreak). The server pagesRouteHasPriorityOverAppRoute and the client resolveHybridClientRouteOwner both delegate to it, so they cannot diverge. Drop the hand-copied routePrecedence entirely. Wire the ownership check at the App navigation runtime boundary so useRouter().push, useRouter().replace, gesturePush, and form submits all get the same hard-nav contract as the Link click handler. Specifically: - navigateClientSide: after same-origin normalization, if Pages owns the URL, hard-navigate via window.location and return (matching the existing external-URL branch). - _appRouter.prefetch: short-circuit RSC URL construction for Pages-owned targets so we do not warm an unusable cache entry. Centralise the existing two inline hard-nav branches in navigateClientSide into a hardNavigateTo helper for clarity. Tests: - Direct unit tests for compareHybridRoutePatterns covering identical-dynamic tiebreak, static-prefix dynamic overlap, static-prefix catch-all overlap, and infix-static bonus. - Direct unit tests for resolveHybridClientRouteOwner mirroring the server assertions plus a basePath-stripping test. - e2e: useRouter().push('/pages-dir/foobar') from an App page resolves to the Pages document. - e2e: useRouter().prefetch('/pages-dir/foobar') issues zero RSC requests for the target URL. --- packages/vinext/src/routing/utils.ts | 50 +++++ .../src/server/hybrid-route-priority.ts | 36 ++-- .../internal/hybrid-client-route-owner.ts | 82 ++------ packages/vinext/src/shims/navigation.ts | 56 +++++- .../use-params-app-pages/use-params.spec.ts | 44 +++++ .../use-params-app-pages/app/page.tsx | 22 +++ tests/hybrid-client-route-owner.test.ts | 184 ++++++++++++++++++ tests/hybrid-route-priority.test.ts | 66 +++++++ 8 files changed, 449 insertions(+), 91 deletions(-) create mode 100644 tests/hybrid-client-route-owner.test.ts diff --git a/packages/vinext/src/routing/utils.ts b/packages/vinext/src/routing/utils.ts index 2c4faaf18..260100568 100644 --- a/packages/vinext/src/routing/utils.ts +++ b/packages/vinext/src/routing/utils.ts @@ -97,6 +97,56 @@ export function sortRoutes(routes: T[]): T[] { }); } +/** + * Single source of truth for hybrid App/Pages route ownership. + * + * Mirrors Next.js's DefaultRouteMatcherManager ordering: Pages providers + * are registered before App providers, then merged dynamic matchers sort + * together. Returns the router that should own a request/navigation to + * a URL that matched BOTH routers. + * + * Centralised so the server's request handling and the client's link / + * prefetch / programmatic-navigation paths all reach the same owner for + * the same (pages pattern, app pattern) pair. Encapsulates the static / + * dynamic short-circuits and the `sortRoutes` call (which carries the + * static-prefix reduction and the Pages-first equal-pattern tiebreak). + * + * Usage: + * compareHybridRoutePatterns("/:slug", true, "/:slug", true) // → "pages" + * compareHybridRoutePatterns("/_sites/:slug*", true, "/:slug*", true) // → "pages" + * compareHybridRoutePatterns("/:path+", true, "/dashboard", false) // → "app" + * compareHybridRoutePatterns("/", false, "/", false) // → "app" + */ +export function compareHybridRoutePatterns( + pagesPattern: string, + pagesIsDynamic: boolean, + appPattern: string, + appIsDynamic: boolean, +): "app" | "pages" { + // Static-only paths: if Pages is static and App is also static, both have + // a literal route, but the App's catch-all is irrelevant — App still owns + // the literal hit (Next.js registers App providers after Pages but a + // static App segment always beats a static Pages segment when both match + // the same URL). If App is dynamic, Pages wins because Pages has the + // literal and App only has a catch-all. + if (!pagesIsDynamic) return appIsDynamic ? "pages" : "app"; + // Pages is dynamic, App is static: App's literal always wins. + if (!appIsDynamic) return "app"; + // Both dynamic: insert Pages first, then App, and let `sortRoutes` decide. + // The Pages-first insertion is the provider-order tiebreak — when + // `routePrecedence` produces equal scores, `sortRoutes`' lexicographic + // tiebreak also returns 0 for identical patterns, and the front element + // (Pages) is preserved by `Array.prototype.sort`. The static-prefix + // reduction inside `routePrecedence` makes + // `/_sites/:slug*` (51) beat `/:slug*` (100) and similar. + const sorted: { owner: "app" | "pages"; pattern: string }[] = [ + { owner: "pages", pattern: pagesPattern }, + { owner: "app", pattern: appPattern }, + ]; + sortRoutes(sorted); + return sorted[0].owner; +} + // Matches literal delimiter characters and their percent-encoded equivalents. // Literal `/`, `#`, `?` can appear after decodeURIComponent when the input was // originally encoded (e.g. `%2F` → `/`); they are re-encoded to preserve their diff --git a/packages/vinext/src/server/hybrid-route-priority.ts b/packages/vinext/src/server/hybrid-route-priority.ts index f19c36883..f41cb8380 100644 --- a/packages/vinext/src/server/hybrid-route-priority.ts +++ b/packages/vinext/src/server/hybrid-route-priority.ts @@ -1,14 +1,10 @@ -import { sortRoutes } from "../routing/utils.js"; +import { compareHybridRoutePatterns } from "../routing/utils.js"; export type HybridRoutePriorityRoute = { isDynamic: boolean; pattern: string; }; -type PrioritizedRoute = HybridRoutePriorityRoute & { - owner: "app" | "pages"; -}; - export type HybridOwner = "app" | "pages"; export type HybridRouteMatch = { @@ -24,24 +20,23 @@ export type HybridRouteMatch = { * dynamic route pathnames together in DefaultRouteMatcherManager. Vinext keeps * separate route tries for each router, so the hybrid boundary needs to apply * that same cross-router ordering after both routers have produced their best - * local match. + * local match. The decision itself lives in + * `routing/utils.ts#compareHybridRoutePatterns` so the server and client + * always reach the same answer. */ export function pagesRouteHasPriorityOverAppRoute( pagesRoute: HybridRoutePriorityRoute, appRoute: HybridRoutePriorityRoute | null, ): boolean { if (appRoute === null) return true; - - if (!pagesRoute.isDynamic) return appRoute.isDynamic; - if (!appRoute.isDynamic) return false; - - const routes: PrioritizedRoute[] = [ - { owner: "pages", isDynamic: true, pattern: pagesRoute.pattern }, - { owner: "app", isDynamic: true, pattern: appRoute.pattern }, - ]; - - sortRoutes(routes); - return routes[0].owner === "pages"; + return ( + compareHybridRoutePatterns( + pagesRoute.pattern, + pagesRoute.isDynamic, + appRoute.pattern, + appRoute.isDynamic, + ) === "pages" + ); } /** @@ -64,5 +59,10 @@ export function resolveHybridRouteOwner( if (appMatch === null && pagesMatch === null) return null; if (appMatch === null) return "pages"; if (pagesMatch === null) return "app"; - return pagesRouteHasPriorityOverAppRoute(pagesMatch.route, appMatch.route) ? "pages" : "app"; + return compareHybridRoutePatterns( + pagesMatch.route.pattern, + pagesMatch.route.isDynamic, + appMatch.route.pattern, + appMatch.route.isDynamic, + ); } diff --git a/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts b/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts index a7287144a..39afd16cd 100644 --- a/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts +++ b/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts @@ -1,9 +1,9 @@ /** * Client-side resolver that decides whether a URL should be soft-navigated - * (App Router / RSC) or hard-navigated (Pages Router / document). Mirrors - * the server-side `pagesRouteHasPriorityOverAppRoute` priority check so the - * click handler, hover/intent prefetch, and direct document load all reach - * the same owner for the same (URL, route pair). + * (App Router / RSC) or hard-navigated (Pages Router / document). Delegates + * the owner decision to `compareHybridRoutePatterns` in `routing/utils.ts` + * so the server and the client reach the same answer for the same + * (pages pattern, app pattern) pair. * * Lives in `shims/internal/` because both `link.tsx` and the App Router * browser entry import it without pulling in the server route graph. @@ -15,6 +15,7 @@ * single-router build only sets its own. */ import { createRouteTrieCache, matchRouteWithTrie } from "../../routing/route-matching.js"; +import { compareHybridRoutePatterns } from "../../routing/utils.js"; import { stripBasePath } from "../../utils/base-path.js"; import { getLocalePathPrefix } from "../../utils/domain-locale.js"; import type { @@ -22,8 +23,6 @@ import type { VinextPagesLinkPrefetchRoute, } from "../../client/vinext-next-data.js"; -type HybridClientRoute = VinextLinkPrefetchRoute | VinextPagesLinkPrefetchRoute; - export type HybridClientOwner = "app" | "pages"; declare global { @@ -38,63 +37,15 @@ const appRouteTrieCache = createRouteTrieCache(); const pagesRouteTrieCache = createRouteTrieCache(); /** - * Pure: compare two matched routes and return the owner. Mirrors the - * server-side `pagesRouteHasPriorityOverAppRoute` rules. Centralising the - * rules here keeps the link click, prefetch, and direct document load - * paths agreeing on the same owner. - */ -function pagesWins(pagesRoute: HybridClientRoute, appRoute: HybridClientRoute): boolean { - // Static routes never match a dynamic catch-all on the other router. - if (!pagesRoute.isDynamic) return appRoute.isDynamic; - if (!appRoute.isDynamic) return false; - - // Both dynamic. Apply Next.js's merged dynamic-route sorting: routes are - // compared by their pattern specificity. A path with more static segments - // (or static segments closer to the start) wins. We rebuild the pattern - // from patternParts because the client manifest is segment-shaped — the - // server-side `sortRoutes` works on `{ pattern: string }` shape. - // The trie match guarantees both routes are present, so each has at - // least one segment; an empty patternParts would be a "/" static match - // and the isDynamic guards above would have already short-circuited. - const pagesPattern = "/" + pagesRoute.patternParts.join("/"); - const appPattern = "/" + appRoute.patternParts.join("/"); - return routePrecedence(pagesPattern) < routePrecedence(appPattern); -} - -/** - * Inline copy of `routePrecedence` from `routing/utils.ts`. Kept in sync - * by hand to avoid pulling the entire `utils.ts` module (which transitively - * depends on Node-only helpers) into the client bundle. The function is - * pure and self-contained. - * - * Matches `packages/vinext/src/routing/utils.ts` `routePrecedence`: - * 1. Static routes first (scored by segment count, more = more specific) - * 2. Dynamic segments penalized by position - * 3. Catch-all comes after dynamic - * 4. Optional catch-all last - * 5. Lexicographic tiebreaker for determinism + * Build a `/`-joined pattern from a manifest's `patternParts`. Mirrors the + * server-side route-graph shape (`{ pattern: string }`) so the same + * `sortRoutes` algorithm can score both Pages and App patterns. The + * `patternParts` array never includes an empty string for the static `/` + * route (the App catch-all handles the bare path), so the simple join is + * safe for everything the route trie actually matches. */ -function routePrecedence(pattern: string): number { - const parts = pattern.split("/").filter(Boolean); - let score = 0; - let staticPrefixCount = 0; - for (const p of parts) { - if (p.startsWith(":") || p.endsWith("+") || p.endsWith("*")) break; - staticPrefixCount++; - } - for (let i = 0; i < parts.length; i++) { - const p = parts[i]; - if (p.endsWith("+")) { - score += 1000 + i; // catch-all - } else if (p.endsWith("*")) { - score += 2000 + i; // optional catch-all - } else if (p.startsWith(":")) { - score += 100 + i; // dynamic - } else if (i >= staticPrefixCount) { - score -= 500; // infix static - } - } - return score; +function patternFromParts(parts: readonly string[]): string { + return "/" + parts.join("/"); } function resolveSameOriginPathname(href: string, basePath: string): string | null { @@ -166,5 +117,10 @@ export function resolveHybridClientRouteOwner( if (appMatch === null && pagesMatch === null) return null; if (pagesMatch === null) return "app"; if (appMatch === null) return "pages"; - return pagesWins(pagesMatch, appMatch) ? "pages" : "app"; + return compareHybridRoutePatterns( + patternFromParts(pagesMatch.patternParts), + pagesMatch.isDynamic, + patternFromParts(appMatch.patternParts), + appMatch.isDynamic, + ); } diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 75aae6fd4..daf3bdc00 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -49,6 +49,7 @@ import { ReadonlyURLSearchParams } from "./readonly-url-search-params.js"; import { assertSafeNavigationUrl } from "./url-safety.js"; import { AppRouterContext, type AppRouterInstance } from "./internal/app-router-context.js"; import { getPagesNavigationContext as _getPagesNavigationContext } from "./internal/pages-router-accessor.js"; +import { resolveHybridClientRouteOwner } from "./internal/hybrid-client-route-owner.js"; import { retryScrollTo, scrollToHashTarget } from "./hash-scroll.js"; import { beginAppRouterScrollIntent, @@ -1727,6 +1728,19 @@ function restoreScrollPosition(state: unknown): void { } } +/** + * Hard-navigate to a URL via `window.location`, preserving push/replace + * semantics. Used for URLs the App Router cannot serve (Pages-owned + * targets in a hybrid build) and for catch-all RSC failures. + */ +function hardNavigateTo(fullHref: string, mode: "push" | "replace"): void { + if (mode === "replace") { + window.location.replace(fullHref); + } else { + window.location.assign(fullHref); + } +} + /** * Navigate to a URL, handling external URLs, hash-only changes, and RSC navigation. */ @@ -1755,17 +1769,33 @@ export async function navigateClientSide( return; } - if (mode === "replace") { - window.location.replace(href); - } else { - window.location.assign(href); - } + hardNavigateTo(href, mode); await new Promise(() => {}); return; } normalizedHref = localPath; } + // Hybrid ownership: when both an App and a Pages route can match the + // destination, defer to the shared `compareHybridRoutePatterns` decision + // (the same logic the server uses for direct document loads). If Pages + // owns the URL, hard-navigate so the Pages handler renders the page + // instead of the App catch-all — soft-navigating through RSC would + // either return null (because `renderPagesFallback` short-circuits RSC + // requests) or render the App catch-all's path array. This is the + // programmatic equivalent of the link click / prefetch check in + // `link.tsx`. + if (resolveHybridClientRouteOwner(normalizedHref, __basePath) === "pages") { + const fullHref = toBrowserNavigationHref(normalizedHref, window.location.href, __basePath); + notifyAppRouterTransitionStart(fullHref, mode); + if (mode === "push") { + saveScrollPosition(); + } + hardNavigateTo(fullHref, mode); + await new Promise(() => {}); + return; + } + const fullHref = toBrowserNavigationHref(normalizedHref, window.location.href, __basePath); // Match Next.js: App Router reports navigation start before dispatching, // including hash-only navigations that short-circuit after URL update. @@ -1805,11 +1835,7 @@ export async function navigateClientSide( return; } - if (mode === "replace") { - window.location.replace(fullHref); - } else { - window.location.assign(fullHref); - } + hardNavigateTo(fullHref, mode); await new Promise(() => {}); return; } @@ -1994,6 +2020,16 @@ const _appRouter: AppRouterInstance = { prefetchHref = localPath; } + // Hybrid ownership: when a Pages route owns the URL, the App Router + // cannot serve it (Pages produces HTML documents / `_next/data` JSON, + // not RSC streams). Prefetching an RSC URL would either 404 or warm + // an unusable cache entry. The matching `push`/`replace` call will + // hard-navigate via `window.location`, so a no-op here is correct — + // the document prefetch the link shim emits on hover still runs. + if (resolveHybridClientRouteOwner(prefetchHref, __basePath) === "pages") { + return; + } + // Prefetch the RSC payload for the target route and store in cache. // We must add to prefetchedUrls manually for deduplication. // prefetchRscResponse only manages the cache Map, not the URL set. diff --git a/tests/e2e/use-params-app-pages/use-params.spec.ts b/tests/e2e/use-params-app-pages/use-params.spec.ts index f1b75a977..8be586626 100644 --- a/tests/e2e/use-params-app-pages/use-params.spec.ts +++ b/tests/e2e/use-params-app-pages/use-params.spec.ts @@ -52,6 +52,50 @@ test.describe("use-params", () => { await expect(page.locator("#params")).toHaveText('"foobar"'); }); + test("Pages route wins for useRouter().push from an App page", async ({ page, baseURL }) => { + // Same hybrid invariant as the case, but driven through the + // App Router runtime boundary (next/navigation's useRouter). The + // ownership check now lives inside `navigateClientSide` so + // `router.push`, `router.replace`, and form-driven navigations all + // hard-navigate to the Pages document instead of sending an RSC + // request that the App catch-all would otherwise answer. + await page.goto(`${baseURL}/`); + await page.locator("#router-push-pages").click(); + await expect(page).toHaveURL(/\/pages-dir\/foobar$/); + await expect(page.locator("#params")).toHaveText('"foobar"'); + }); + + test("useRouter().prefetch does not issue an RSC request for a Pages-owned URL", async ({ + page, + baseURL, + }) => { + // The hybrid check in `_appRouter.prefetch` short-circuits RSC URL + // construction for Pages-owned targets. Sending an RSC request would + // hit the App root catch-all's RSC handler and warm an unusable + // cache entry, so the prefetch should be a no-op. + await page.goto(`${baseURL}/`); + + const rscRequests: string[] = []; + page.on("request", (req) => { + if (req.url().includes(".rsc")) { + rscRequests.push(req.url()); + } + }); + + await page.locator("#router-prefetch-pages").click(); + + // Give the network layer a chance to flush anything the resolver + // accidentally started. The assertion is a strict zero RSC requests + // against /pages-dir/foobar — the only RSC traffic the page emits + // here is the bootstrap hydration (not targeted at the prefetch + // URL). + await page.waitForTimeout(250); + expect( + rscRequests.filter((u) => u.includes("/pages-dir/foobar")), + `unexpected RSC prefetch: ${rscRequests.join("\n")}`, + ).toEqual([]); + }); + test("shouldn't rerender host component when prefetching", async ({ page, baseURL }) => { await page.goto(`${baseURL}/rerenders/foobar`); const initialRandom = await page.locator("#random").textContent(); diff --git a/tests/fixtures/use-params-app-pages/app/page.tsx b/tests/fixtures/use-params-app-pages/app/page.tsx index 7ef5b4a36..ad566cb72 100644 --- a/tests/fixtures/use-params-app-pages/app/page.tsx +++ b/tests/fixtures/use-params-app-pages/app/page.tsx @@ -1,6 +1,10 @@ +"use client"; + import Link from "next/link"; +import { useRouter } from "next/navigation"; export default function Page() { + const router = useRouter(); return ( <>
@@ -18,6 +22,24 @@ export default function Page() { To /pages-dir/foobar (Pages)
+
+ +
+
+ +
); } diff --git a/tests/hybrid-client-route-owner.test.ts b/tests/hybrid-client-route-owner.test.ts new file mode 100644 index 000000000..440dddabd --- /dev/null +++ b/tests/hybrid-client-route-owner.test.ts @@ -0,0 +1,184 @@ +/** + * Direct unit tests for the client-side `resolveHybridClientRouteOwner`. + * + * Mirrors the server-side `pagesRouteHasPriorityOverAppRoute` tests and + * adds the manifest-mocking plumbing needed to drive the client resolver + * with synthetic `__VINEXT_LINK_PREFETCH_ROUTES__` and + * `__VINEXT_PAGES_LINK_PREFETCH_ROUTES__` arrays. + * + * The hard guarantee these tests assert: the client and the server reach + * the same owner for the same (pages pattern, app pattern) pair. If a + * test here ever diverges from `tests/hybrid-route-priority.test.ts`, + * the hybrid invariant is broken and the next browser hard-navigation + * will go to the wrong router. + * + * The trie matcher expects Next.js-segment-encoded patternParts: static + * segments are plain strings, dynamic segments start with `:`, catch-alls + * end with `+`, and optional catch-alls end with `*`. See + * `routing/route-trie.ts`. + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { + VinextLinkPrefetchRoute, + VinextPagesLinkPrefetchRoute, +} from "../packages/vinext/src/client/vinext-next-data.js"; +import { resolveHybridClientRouteOwner } from "../packages/vinext/src/shims/internal/hybrid-client-route-owner.js"; + +const APP_BASE = "http://localhost/"; + +type WindowState = { + app: VinextLinkPrefetchRoute[]; + pages: VinextPagesLinkPrefetchRoute[]; +}; + +function installWindow({ app, pages }: WindowState): void { + (globalThis as any).window = { + location: { href: APP_BASE, origin: "http://localhost" }, + __VINEXT_LINK_PREFETCH_ROUTES__: app, + __VINEXT_PAGES_LINK_PREFETCH_ROUTES__: pages, + }; +} + +function uninstallWindow(): void { + delete (globalThis as any).window; +} + +let savedWindow: unknown; +beforeEach(() => { + savedWindow = (globalThis as any).window; +}); +afterEach(() => { + if (savedWindow === undefined) { + uninstallWindow(); + } else { + (globalThis as any).window = savedWindow; + } +}); + +const appRoute = (patternParts: string[], isDynamic = true): VinextLinkPrefetchRoute => ({ + canPrefetchLoadingShell: !isDynamic, + isDynamic, + patternParts, +}); + +const pagesRoute = (patternParts: string[], isDynamic = true): VinextPagesLinkPrefetchRoute => ({ + canPrefetchLoadingShell: false, + isDynamic, + patternParts, +}); + +describe("resolveHybridClientRouteOwner", () => { + it("returns null when neither router has a matching manifest", () => { + installWindow({ app: [], pages: [] }); + expect(resolveHybridClientRouteOwner("/missing", "")).toBeNull(); + }); + + it("returns null when neither router matched the URL", () => { + installWindow({ + app: [appRoute(["a"])], + pages: [pagesRoute(["b"])], + }); + expect(resolveHybridClientRouteOwner("/c", "")).toBeNull(); + }); + + it("returns 'app' when only the App manifest matched", () => { + installWindow({ + app: [appRoute(["a", ":slug"])], + pages: [pagesRoute(["b"])], + }); + expect(resolveHybridClientRouteOwner("/a/foobar", "")).toBe("app"); + }); + + it("returns 'pages' when only the Pages manifest matched", () => { + installWindow({ + app: [appRoute(["a"])], + pages: [pagesRoute(["b", ":slug"])], + }); + expect(resolveHybridClientRouteOwner("/b/foobar", "")).toBe("pages"); + }); + + it("lets a more specific Pages dynamic route beat an App root catch-all", () => { + // Mirrors the server test of the same name. /pages-dir/:dynamic + // (score 51) beats /:path+ (score 1000). + installWindow({ + app: [appRoute([":path+"])], + pages: [pagesRoute(["pages-dir", ":dynamic"])], + }); + expect(resolveHybridClientRouteOwner("/pages-dir/foobar", "")).toBe("pages"); + }); + + it("lets an App static route own the request when Pages only has a catch-all", () => { + installWindow({ + app: [appRoute(["dashboard"], false)], + pages: [pagesRoute([":path+"])], + }); + expect(resolveHybridClientRouteOwner("/dashboard", "")).toBe("app"); + }); + + it("lets a static Pages route win over a dynamic App catch-all", () => { + // E.g. Pages has a literal `/about` page, App only has a catch-all. + // The literal Pages hit must own the request even though the App + // catch-all matches the same URL. + installWindow({ + app: [appRoute([":path+"])], + pages: [pagesRoute(["about"], false)], + }); + expect(resolveHybridClientRouteOwner("/about", "")).toBe("pages"); + }); + + it("lets an App static route win over an identical static Pages route", () => { + // Mirrors the server's "keeps the App route ahead of an identical + // static Pages route" assertion: both have identical static + // specificity, and the App literal wins by direct hit. + installWindow({ + app: [appRoute([], false)], + pages: [pagesRoute([], false)], + }); + expect(resolveHybridClientRouteOwner("/", "")).toBe("app"); + }); + + it("uses Pages provider order as the tie-breaker for identical dynamic patterns", () => { + // The hand-copied client comparator that this resolver used to ship + // with returned 'app' for this case (it compared + // `routePrecedence(p) < routePrecedence(a)` and ties go to false). + // Delegating to the shared `compareHybridRoutePatterns` (which puts + // Pages first into `sortRoutes` and preserves order on ties) restores + // parity with the server. + installWindow({ + app: [appRoute([":slug"])], + pages: [pagesRoute([":slug"])], + }); + expect(resolveHybridClientRouteOwner("/anything", "")).toBe("pages"); + }); + + it("lets a static-prefix Pages catch-all beat a bare App catch-all", () => { + // `/_sites/:slug*` (score 1951 with the static-prefix reduction) + // must beat `/:slug*` (score 2000). The previous hand-copied + // comparator missed the static-prefix reduction and returned 'app' + // for this case, splitting client / server ownership. + installWindow({ + app: [appRoute([":slug*"])], + pages: [pagesRoute(["_sites", ":slug*"])], + }); + expect(resolveHybridClientRouteOwner("/_sites/anything/here", "")).toBe("pages"); + }); + + it("lets a static-prefix Pages dynamic beat a bare App dynamic", () => { + installWindow({ + app: [appRoute([":subdomain"])], + pages: [pagesRoute(["_sites", ":subdomain"])], + }); + expect(resolveHybridClientRouteOwner("/_sites/foo", "")).toBe("pages"); + }); + + it("ignores the basePath prefix when matching", () => { + installWindow({ + app: [appRoute(["a"])], + pages: [pagesRoute(["pages-dir", ":dynamic"])], + }); + // The client strips the basePath before consulting the manifest, + // matching the server's normalisation. + expect(resolveHybridClientRouteOwner("/base/pages-dir/foobar", "/base")).toBe("pages"); + expect(resolveHybridClientRouteOwner("/base/a", "/base")).toBe("app"); + }); +}); diff --git a/tests/hybrid-route-priority.test.ts b/tests/hybrid-route-priority.test.ts index d3c7d7fb2..e9f1bf75c 100644 --- a/tests/hybrid-route-priority.test.ts +++ b/tests/hybrid-route-priority.test.ts @@ -1,9 +1,75 @@ import { describe, expect, it } from "vitest"; +import { compareHybridRoutePatterns } from "../packages/vinext/src/routing/utils.js"; import { pagesRouteHasPriorityOverAppRoute, resolveHybridRouteOwner, } from "../packages/vinext/src/server/hybrid-route-priority.js"; +describe("compareHybridRoutePatterns", () => { + it("lets a more specific Pages dynamic route beat an App root catch-all", () => { + // Ported from Next.js: test/e2e/app-dir/use-params/use-params.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-params/use-params.test.ts + // + // Next.js's DefaultRouteMatcherManager merges Pages and App matchers before + // sorting dynamic routes, so /pages-dir/[dynamic] owns /pages-dir/foobar + // ahead of app/[...path]. + expect(compareHybridRoutePatterns("/pages-dir/:dynamic", true, "/:path+", true)).toBe("pages"); + }); + + it("keeps a more specific App static route ahead of a Pages catch-all", () => { + expect(compareHybridRoutePatterns("/:path+", true, "/dashboard", false)).toBe("app"); + }); + + it("lets a static Pages route win over a dynamic App catch-all", () => { + // E.g. Pages has a literal `/about` page, App only has a catch-all. + // The literal Pages hit must own the request even though the App + // catch-all matches the same URL. + expect(compareHybridRoutePatterns("/about", false, "/:path+", true)).toBe("pages"); + }); + + it("keeps the App route ahead of an identical static Pages route", () => { + // App providers are registered after Pages providers, but identical + // static routes have identical specificity, and App's static literal + // wins by direct hit. + expect(compareHybridRoutePatterns("/", false, "/", false)).toBe("app"); + }); + + it("uses Pages provider order as the tie-breaker for identical dynamic patterns", () => { + // Next.js pushes Pages providers before App providers, then preserves + // provider order when merging dynamic matchers with the same pathname. + // `sortRoutes` returns 0 for equal patterns; Array.prototype.sort keeps + // the front element, and the front element is Pages. + expect(compareHybridRoutePatterns("/:slug", true, "/:slug", true)).toBe("pages"); + }); + + it("lets a static-prefix Pages catch-all beat a bare App catch-all", () => { + // /_sites/:slug* must beat /:slug*. `routePrecedence` reduces the + // static-prefix score by 50 per segment, so the Pages route scores + // 51 and the App route scores 100. The hand-copied client comparator + // missed this reduction and reversed the answer; the shared + // comparator (which delegates to `sortRoutes`) gets it right. + expect(compareHybridRoutePatterns("/_sites/:slug*", true, "/:slug*", true)).toBe("pages"); + }); + + it("lets a static-prefix Pages dynamic beat a bare App dynamic", () => { + // Same shape as the catch-all case but for a plain dynamic segment. + expect(compareHybridRoutePatterns("/_sites/:subdomain", true, "/:subdomain", true)).toBe( + "pages", + ); + }); + + it("lets a Pages dynamic with a static prefix beat an App dynamic with a static prefix", () => { + // Both have a static prefix of length 1. The Pages route has a more + // specific infix (`/_sites/blog/:slug`) versus a bare infix dynamic + // (`/_sites/:slug`); the static-prefix reduction cancels but the + // infix-static bonus inside `routePrecedence` puts the more specific + // Pages route ahead. + expect(compareHybridRoutePatterns("/_sites/blog/:slug", true, "/_sites/:slug", true)).toBe( + "pages", + ); + }); +}); + describe("hybrid App Router + Pages Router route priority", () => { it("lets a more specific Pages dynamic route beat an App root catch-all", () => { // Ported from Next.js: test/e2e/app-dir/use-params/use-params.test.ts From 85e9b8d7d26684716a315a10972a1e8868fc93d2 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 13 Jun 2026 21:04:16 +1000 Subject: [PATCH 04/23] docs(router): fix stale score in static-prefix catch-all test comment The hand-copied comparator note quoted the dynamic-segment scores (51 / 1000) for the optional-catch-all example (/_sites/:slug* vs /:slug*). The current optional-catch-all scoring actually produces 1951 vs 2000. The test assertion was correct; only the comment was stale. --- tests/hybrid-route-priority.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/hybrid-route-priority.test.ts b/tests/hybrid-route-priority.test.ts index e9f1bf75c..73a37a6c1 100644 --- a/tests/hybrid-route-priority.test.ts +++ b/tests/hybrid-route-priority.test.ts @@ -45,9 +45,9 @@ describe("compareHybridRoutePatterns", () => { it("lets a static-prefix Pages catch-all beat a bare App catch-all", () => { // /_sites/:slug* must beat /:slug*. `routePrecedence` reduces the // static-prefix score by 50 per segment, so the Pages route scores - // 51 and the App route scores 100. The hand-copied client comparator - // missed this reduction and reversed the answer; the shared - // comparator (which delegates to `sortRoutes`) gets it right. + // 1951 and the App route scores 2000. The hand-copied client + // comparator missed this reduction and reversed the answer; the + // shared comparator (which delegates to `sortRoutes`) gets it right. expect(compareHybridRoutePatterns("/_sites/:slug*", true, "/:slug*", true)).toBe("pages"); }); From 6e456540e9c236758d1c12cd11cddc710c01b9ce Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 14 Jun 2026 02:29:23 +1000 Subject: [PATCH 05/23] test(use-params): fix direct-load single dynamic param to use /a instead of /a/b --- tests/e2e/use-params-app-pages/use-params.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/e2e/use-params-app-pages/use-params.spec.ts b/tests/e2e/use-params-app-pages/use-params.spec.ts index 8be586626..9ba4d1396 100644 --- a/tests/e2e/use-params-app-pages/use-params.spec.ts +++ b/tests/e2e/use-params-app-pages/use-params.spec.ts @@ -4,8 +4,11 @@ import { expect, test } from "@playwright/test"; test.describe("use-params", () => { test("should work for single dynamic param", async ({ page, baseURL }) => { - await page.goto(`${baseURL}/a/b`); + await page.goto(`${baseURL}/a`); await expect(page.locator("#param-id")).toHaveText("a"); + + // Also verify that only the [id] layout (not [id]/[id2]) drives this page + await expect(page.locator("#param-id2")).toHaveCount(0); }); test("should work for nested dynamic params", async ({ page, baseURL }) => { From 9dbd7fa23a2d2111b55921ee17114abc83b66c4c Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 15:13:37 +0100 Subject: [PATCH 06/23] fix(router): compare hybrid routes structurally --- packages/vinext/src/routing/utils.ts | 34 +++++++++++-------- .../use-params-app-pages/use-params.spec.ts | 19 +++++++++++ .../app/account/[tab]/page.tsx | 3 ++ .../use-params-app-pages/app/page.tsx | 14 ++++++++ .../pages/[section]/details.tsx | 3 ++ tests/hybrid-client-route-owner.test.ts | 8 +++++ tests/hybrid-route-priority.test.ts | 9 +++++ 7 files changed, 75 insertions(+), 15 deletions(-) create mode 100644 tests/fixtures/use-params-app-pages/app/account/[tab]/page.tsx create mode 100644 tests/fixtures/use-params-app-pages/pages/[section]/details.tsx diff --git a/packages/vinext/src/routing/utils.ts b/packages/vinext/src/routing/utils.ts index 260100568..21785719c 100644 --- a/packages/vinext/src/routing/utils.ts +++ b/packages/vinext/src/routing/utils.ts @@ -108,8 +108,7 @@ export function sortRoutes(routes: T[]): T[] { * Centralised so the server's request handling and the client's link / * prefetch / programmatic-navigation paths all reach the same owner for * the same (pages pattern, app pattern) pair. Encapsulates the static / - * dynamic short-circuits and the `sortRoutes` call (which carries the - * static-prefix reduction and the Pages-first equal-pattern tiebreak). + * dynamic short-circuits and Next.js's segment-by-segment route ordering. * * Usage: * compareHybridRoutePatterns("/:slug", true, "/:slug", true) // → "pages" @@ -132,19 +131,24 @@ export function compareHybridRoutePatterns( if (!pagesIsDynamic) return appIsDynamic ? "pages" : "app"; // Pages is dynamic, App is static: App's literal always wins. if (!appIsDynamic) return "app"; - // Both dynamic: insert Pages first, then App, and let `sortRoutes` decide. - // The Pages-first insertion is the provider-order tiebreak — when - // `routePrecedence` produces equal scores, `sortRoutes`' lexicographic - // tiebreak also returns 0 for identical patterns, and the front element - // (Pages) is preserved by `Array.prototype.sort`. The static-prefix - // reduction inside `routePrecedence` makes - // `/_sites/:slug*` (51) beat `/:slug*` (100) and similar. - const sorted: { owner: "app" | "pages"; pattern: string }[] = [ - { owner: "pages", pattern: pagesPattern }, - { owner: "app", pattern: appPattern }, - ]; - sortRoutes(sorted); - return sorted[0].owner; + const pagesSegments = pagesPattern.split("/").filter(Boolean); + const appSegments = appPattern.split("/").filter(Boolean); + const segmentRank = (segment: string): number => { + if (!segment.startsWith(":")) return 0; + if (segment.endsWith("*")) return 3; + if (segment.endsWith("+")) return 2; + return 1; + }; + + for (let index = 0; index < Math.min(pagesSegments.length, appSegments.length); index++) { + const pagesRank = segmentRank(pagesSegments[index]); + const appRank = segmentRank(appSegments[index]); + if (pagesRank !== appRank) return pagesRank < appRank ? "pages" : "app"; + } + + // Matching dynamic routes with the same structural specificity retain + // provider order. Next.js registers Pages providers before App providers. + return "pages"; } // Matches literal delimiter characters and their percent-encoded equivalents. diff --git a/tests/e2e/use-params-app-pages/use-params.spec.ts b/tests/e2e/use-params-app-pages/use-params.spec.ts index 9ba4d1396..593e6a342 100644 --- a/tests/e2e/use-params-app-pages/use-params.spec.ts +++ b/tests/e2e/use-params-app-pages/use-params.spec.ts @@ -68,6 +68,25 @@ test.describe("use-params", () => { await expect(page.locator("#params")).toHaveText('"foobar"'); }); + test("earlier static App segment wins on document load", async ({ page, baseURL }) => { + await page.goto(`${baseURL}/account/details`); + await expect(page.locator("#route-owner")).toHaveText("app"); + }); + + test("earlier static App segment wins for Link navigation", async ({ page, baseURL }) => { + await page.goto(`${baseURL}/`); + await page.locator("#to-app-priority").click(); + await expect(page).toHaveURL(/\/account\/details$/); + await expect(page.locator("#route-owner")).toHaveText("app"); + }); + + test("earlier static App segment wins for router.push", async ({ page, baseURL }) => { + await page.goto(`${baseURL}/`); + await page.locator("#router-push-app-priority").click(); + await expect(page).toHaveURL(/\/account\/details$/); + await expect(page.locator("#route-owner")).toHaveText("app"); + }); + test("useRouter().prefetch does not issue an RSC request for a Pages-owned URL", async ({ page, baseURL, diff --git a/tests/fixtures/use-params-app-pages/app/account/[tab]/page.tsx b/tests/fixtures/use-params-app-pages/app/account/[tab]/page.tsx new file mode 100644 index 000000000..f46a6b536 --- /dev/null +++ b/tests/fixtures/use-params-app-pages/app/account/[tab]/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
app
; +} diff --git a/tests/fixtures/use-params-app-pages/app/page.tsx b/tests/fixtures/use-params-app-pages/app/page.tsx index ad566cb72..25cac22b9 100644 --- a/tests/fixtures/use-params-app-pages/app/page.tsx +++ b/tests/fixtures/use-params-app-pages/app/page.tsx @@ -40,6 +40,20 @@ export default function Page() { router.prefetch(/pages-dir/foobar) +
+ + To /account/details (App) + +
+
+ +
); } diff --git a/tests/fixtures/use-params-app-pages/pages/[section]/details.tsx b/tests/fixtures/use-params-app-pages/pages/[section]/details.tsx new file mode 100644 index 000000000..43d9b824b --- /dev/null +++ b/tests/fixtures/use-params-app-pages/pages/[section]/details.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
pages
; +} diff --git a/tests/hybrid-client-route-owner.test.ts b/tests/hybrid-client-route-owner.test.ts index 440dddabd..49522d32c 100644 --- a/tests/hybrid-client-route-owner.test.ts +++ b/tests/hybrid-client-route-owner.test.ts @@ -171,6 +171,14 @@ describe("resolveHybridClientRouteOwner", () => { expect(resolveHybridClientRouteOwner("/_sites/foo", "")).toBe("pages"); }); + it("prioritizes an earlier static App segment over a later static Pages segment", () => { + installWindow({ + app: [appRoute(["account", ":tab"])], + pages: [pagesRoute([":section", "details"])], + }); + expect(resolveHybridClientRouteOwner("/account/details", "")).toBe("app"); + }); + it("ignores the basePath prefix when matching", () => { installWindow({ app: [appRoute(["a"])], diff --git a/tests/hybrid-route-priority.test.ts b/tests/hybrid-route-priority.test.ts index 73a37a6c1..4011731ed 100644 --- a/tests/hybrid-route-priority.test.ts +++ b/tests/hybrid-route-priority.test.ts @@ -68,6 +68,15 @@ describe("compareHybridRoutePatterns", () => { "pages", ); }); + + it("prioritizes an earlier static App segment over a later static Pages segment", () => { + // Next.js sorts dynamic pathnames structurally, traversing a static child + // before a dynamic child at the first differing segment. + // Ported from Next.js: test/unit/page-route-sorter.test.ts + expect(compareHybridRoutePatterns("/:section/details", true, "/account/:tab", true)).toBe( + "app", + ); + }); }); describe("hybrid App Router + Pages Router route priority", () => { From 3eab594cb56c414987fe294868a5c5d0cb13a2a0 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 15:35:55 +0100 Subject: [PATCH 07/23] fix(router): reject hybrid route conflicts --- packages/vinext/src/index.ts | 15 +++- packages/vinext/src/routing/utils.ts | 19 +++++ .../src/server/hybrid-route-priority.ts | 34 +++++++++ tests/hybrid-client-route-owner.test.ts | 29 +++---- tests/hybrid-route-priority.test.ts | 76 ++++++++++++------- 5 files changed, 128 insertions(+), 45 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index edfb89f31..365d78ceb 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -108,7 +108,10 @@ import { type PagesPipelineDeps, type MiddlewareResult, } from "./server/pages-request-pipeline.js"; -import { pagesRouteHasPriorityOverAppRoute } from "./server/hybrid-route-priority.js"; +import { + pagesRouteHasPriorityOverAppRoute, + validateHybridRouteConflicts, +} from "./server/hybrid-route-priority.js"; import { proxyExternalRequest } from "./config/config-matchers.js"; import { detectPackageManager } from "./utils/project.js"; import { isUnknownRecord as isRecord } from "./utils/record.js"; @@ -2421,7 +2424,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { return null; }, - configResolved(config) { + async configResolved(config) { // Provide the resolved config to the Sass-aware CSS Modules Loader so // it can call Vite's `preprocessCSS` when processing SCSS files // referenced by `composes: className from './file.module.scss'`. @@ -2430,6 +2433,14 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // preprocessor options and `css.modules` settings are in place. sassComposesLoader.setResolvedConfig(config); + if (hasAppDir && hasPagesDir) { + const [appRoutes, pageRoutes] = await Promise.all([ + appRouter(appDir, nextConfig?.pageExtensions, fileMatcher), + pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher), + ]); + validateHybridRouteConflicts(pageRoutes, appRoutes); + } + // When the user sets `ssr.external: true`, strip React entries from // `environments.ssr.resolve.noExternal`. @vitejs/plugin-rsc populates // this list via crawlFrameworkPkgs, but `noExternal` overrides diff --git a/packages/vinext/src/routing/utils.ts b/packages/vinext/src/routing/utils.ts index 21785719c..5537fe70d 100644 --- a/packages/vinext/src/routing/utils.ts +++ b/packages/vinext/src/routing/utils.ts @@ -122,6 +122,21 @@ export function compareHybridRoutePatterns( appPattern: string, appIsDynamic: boolean, ): "app" | "pages" { + const normalizeStructure = (pattern: string): string => + pattern + .split("/") + .filter(Boolean) + .map((segment) => { + if (!segment.startsWith(":")) return segment; + if (segment.endsWith("*")) return ":*"; + if (segment.endsWith("+")) return ":+"; + return ":"; + }) + .join("/"); + if (normalizeStructure(pagesPattern) === normalizeStructure(appPattern)) { + throw new Error(`Conflicting app and page routes found for "${pagesPattern}"`); + } + // Static-only paths: if Pages is static and App is also static, both have // a literal route, but the App's catch-all is irrelevant — App still owns // the literal hit (Next.js registers App providers after Pages but a @@ -146,6 +161,10 @@ export function compareHybridRoutePatterns( if (pagesRank !== appRank) return pagesRank < appRank ? "pages" : "app"; } + if (pagesSegments.length !== appSegments.length) { + return pagesSegments.length < appSegments.length ? "pages" : "app"; + } + // Matching dynamic routes with the same structural specificity retain // provider order. Next.js registers Pages providers before App providers. return "pages"; diff --git a/packages/vinext/src/server/hybrid-route-priority.ts b/packages/vinext/src/server/hybrid-route-priority.ts index f41cb8380..50e5f7c13 100644 --- a/packages/vinext/src/server/hybrid-route-priority.ts +++ b/packages/vinext/src/server/hybrid-route-priority.ts @@ -12,6 +12,40 @@ export type HybridRouteMatch = { params: Record; }; +function normalizeHybridRouteStructure(pattern: string): string { + return pattern + .split("/") + .filter(Boolean) + .map((segment) => { + if (!segment.startsWith(":")) return segment; + if (segment.endsWith("*")) return ":*"; + if (segment.endsWith("+")) return ":+"; + return ":"; + }) + .join("/"); +} + +export function validateHybridRouteConflicts( + pagesRoutes: readonly HybridRoutePriorityRoute[], + appRoutes: readonly HybridRoutePriorityRoute[], +): void { + const pagesByStructure = new Map( + pagesRoutes.map((route) => [normalizeHybridRouteStructure(route.pattern), route.pattern]), + ); + const conflicts = appRoutes.flatMap((route) => { + const pagesPattern = pagesByStructure.get(normalizeHybridRouteStructure(route.pattern)); + return pagesPattern === undefined ? [] : [[pagesPattern, route.pattern] as const]; + }); + if (conflicts.length === 0) return; + + const message = `Conflicting app and page file${conflicts.length === 1 ? " was" : "s were"} found, please remove the conflicting files to continue:`; + throw new Error( + `${message}\n${conflicts + .map(([pagesPattern, appPattern]) => ` pages "${pagesPattern}" - app "${appPattern}"`) + .join("\n")}`, + ); +} + /** * Return whether a matched Pages Router route should own the request instead * of a matched App Router route. diff --git a/tests/hybrid-client-route-owner.test.ts b/tests/hybrid-client-route-owner.test.ts index 49522d32c..9b58515c9 100644 --- a/tests/hybrid-client-route-owner.test.ts +++ b/tests/hybrid-client-route-owner.test.ts @@ -126,29 +126,22 @@ describe("resolveHybridClientRouteOwner", () => { expect(resolveHybridClientRouteOwner("/about", "")).toBe("pages"); }); - it("lets an App static route win over an identical static Pages route", () => { - // Mirrors the server's "keeps the App route ahead of an identical - // static Pages route" assertion: both have identical static - // specificity, and the App literal wins by direct hit. + it("rejects an identical static App and Pages route", () => { installWindow({ app: [appRoute([], false)], pages: [pagesRoute([], false)], }); - expect(resolveHybridClientRouteOwner("/", "")).toBe("app"); + expect(() => resolveHybridClientRouteOwner("/", "")).toThrow("Conflicting app and page routes"); }); - it("uses Pages provider order as the tie-breaker for identical dynamic patterns", () => { - // The hand-copied client comparator that this resolver used to ship - // with returned 'app' for this case (it compared - // `routePrecedence(p) < routePrecedence(a)` and ties go to false). - // Delegating to the shared `compareHybridRoutePatterns` (which puts - // Pages first into `sortRoutes` and preserves order on ties) restores - // parity with the server. + it("rejects structurally identical dynamic App and Pages routes", () => { installWindow({ app: [appRoute([":slug"])], - pages: [pagesRoute([":slug"])], + pages: [pagesRoute([":id"])], }); - expect(resolveHybridClientRouteOwner("/anything", "")).toBe("pages"); + expect(() => resolveHybridClientRouteOwner("/anything", "")).toThrow( + "Conflicting app and page routes", + ); }); it("lets a static-prefix Pages catch-all beat a bare App catch-all", () => { @@ -179,6 +172,14 @@ describe("resolveHybridClientRouteOwner", () => { expect(resolveHybridClientRouteOwner("/account/details", "")).toBe("app"); }); + it("keeps an exact App dynamic route ahead of a Pages optional catch-all", () => { + installWindow({ + app: [appRoute([":section"])], + pages: [pagesRoute([":section", ":rest*"])], + }); + expect(resolveHybridClientRouteOwner("/foo", "")).toBe("app"); + }); + it("ignores the basePath prefix when matching", () => { installWindow({ app: [appRoute(["a"])], diff --git a/tests/hybrid-route-priority.test.ts b/tests/hybrid-route-priority.test.ts index 4011731ed..470a19d2e 100644 --- a/tests/hybrid-route-priority.test.ts +++ b/tests/hybrid-route-priority.test.ts @@ -3,6 +3,7 @@ import { compareHybridRoutePatterns } from "../packages/vinext/src/routing/utils import { pagesRouteHasPriorityOverAppRoute, resolveHybridRouteOwner, + validateHybridRouteConflicts, } from "../packages/vinext/src/server/hybrid-route-priority.js"; describe("compareHybridRoutePatterns", () => { @@ -27,19 +28,16 @@ describe("compareHybridRoutePatterns", () => { expect(compareHybridRoutePatterns("/about", false, "/:path+", true)).toBe("pages"); }); - it("keeps the App route ahead of an identical static Pages route", () => { - // App providers are registered after Pages providers, but identical - // static routes have identical specificity, and App's static literal - // wins by direct hit. - expect(compareHybridRoutePatterns("/", false, "/", false)).toBe("app"); + it("rejects an identical static App and Pages route", () => { + expect(() => compareHybridRoutePatterns("/", false, "/", false)).toThrow( + "Conflicting app and page routes", + ); }); - it("uses Pages provider order as the tie-breaker for identical dynamic patterns", () => { - // Next.js pushes Pages providers before App providers, then preserves - // provider order when merging dynamic matchers with the same pathname. - // `sortRoutes` returns 0 for equal patterns; Array.prototype.sort keeps - // the front element, and the front element is Pages. - expect(compareHybridRoutePatterns("/:slug", true, "/:slug", true)).toBe("pages"); + it("rejects structurally identical dynamic App and Pages routes", () => { + expect(() => compareHybridRoutePatterns("/:slug", true, "/:id", true)).toThrow( + "Conflicting app and page routes", + ); }); it("lets a static-prefix Pages catch-all beat a bare App catch-all", () => { @@ -77,6 +75,30 @@ describe("compareHybridRoutePatterns", () => { "app", ); }); + + it("keeps an exact App dynamic route ahead of a Pages optional catch-all", () => { + expect(compareHybridRoutePatterns("/:section/:rest*", true, "/:section", true)).toBe("app"); + }); +}); + +describe("validateHybridRouteConflicts", () => { + it("rejects identical static routes", () => { + expect(() => + validateHybridRouteConflicts( + [{ isDynamic: false, pattern: "/" }], + [{ isDynamic: false, pattern: "/" }], + ), + ).toThrow("Conflicting app and page file was found"); + }); + + it("rejects structurally identical dynamic routes with different param names", () => { + expect(() => + validateHybridRouteConflicts( + [{ isDynamic: true, pattern: "/:slug" }], + [{ isDynamic: true, pattern: "/:id" }], + ), + ).toThrow("Conflicting app and page file was found"); + }); }); describe("hybrid App Router + Pages Router route priority", () => { @@ -104,24 +126,22 @@ describe("hybrid App Router + Pages Router route priority", () => { ).toBe(false); }); - it("keeps the App route ahead of an identical static Pages route", () => { - expect( + it("rejects an identical static App and Pages route", () => { + expect(() => pagesRouteHasPriorityOverAppRoute( { isDynamic: false, pattern: "/" }, { isDynamic: false, pattern: "/" }, ), - ).toBe(false); + ).toThrow("Conflicting app and page routes"); }); - it("uses Pages provider order as the tie-breaker for identical dynamic patterns", () => { - // Next.js pushes Pages providers before App providers, then preserves - // provider order when merging dynamic matchers with the same pathname. - expect( + it("rejects structurally identical dynamic App and Pages routes", () => { + expect(() => pagesRouteHasPriorityOverAppRoute( { isDynamic: true, pattern: "/:slug" }, - { isDynamic: true, pattern: "/:slug" }, + { isDynamic: true, pattern: "/:id" }, ), - ).toBe(true); + ).toThrow("Conflicting app and page routes"); }); }); @@ -164,23 +184,21 @@ describe("resolveHybridRouteOwner", () => { ).toBe("app"); }); - it("lets an App static route win over an identical static Pages route", () => { - expect( + it("rejects an identical static App and Pages route", () => { + expect(() => resolveHybridRouteOwner( { route: { isDynamic: false, pattern: "/" }, params: {} }, { route: { isDynamic: false, pattern: "/" }, params: {} }, ), - ).toBe("app"); + ).toThrow("Conflicting app and page routes"); }); - it("uses Pages provider order as the tie-breaker for identical dynamic patterns", () => { - // Next.js pushes Pages providers before App providers, so identical - // dynamic patterns go to Pages. - expect( + it("rejects structurally identical dynamic App and Pages routes", () => { + expect(() => resolveHybridRouteOwner( { route: { isDynamic: true, pattern: "/:slug" }, params: { slug: "x" } }, - { route: { isDynamic: true, pattern: "/:slug" }, params: { slug: "x" } }, + { route: { isDynamic: true, pattern: "/:id" }, params: { id: "x" } }, ), - ).toBe("pages"); + ).toThrow("Conflicting app and page routes"); }); }); From db321bb3907587f3552231f5f5255f191ecb54c0 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 16:04:46 +0100 Subject: [PATCH 08/23] fix(router): refresh hybrid route ownership --- packages/vinext/src/index.ts | 74 ++++++++++++++++++- packages/vinext/src/routing/utils.ts | 13 +--- packages/vinext/src/server/app-rsc-handler.ts | 37 ++++------ .../src/server/hybrid-route-priority.ts | 47 ++++++------ tests/app-rsc-handler.test.ts | 29 ++++++++ .../pages/{[section] => [id]}/details.tsx | 0 tests/hybrid-client-route-owner.test.ts | 6 +- tests/hybrid-route-priority.test.ts | 40 +++++++--- 8 files changed, 169 insertions(+), 77 deletions(-) rename tests/fixtures/use-params-app-pages/pages/{[section] => [id]}/details.tsx (100%) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 365d78ceb..f1645fe0d 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2433,12 +2433,23 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // preprocessor options and `css.modules` settings are in place. sassComposesLoader.setResolvedConfig(config); - if (hasAppDir && hasPagesDir) { + if (config.command === "build" && hasAppDir && hasPagesDir) { const [appRoutes, pageRoutes] = await Promise.all([ appRouter(appDir, nextConfig?.pageExtensions, fileMatcher), pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher), ]); - validateHybridRouteConflicts(pageRoutes, appRoutes); + validateHybridRouteConflicts( + pageRoutes.map((route) => ({ + ...route, + sourcePath: path.relative(root, route.filePath), + })), + appRoutes + .filter((route) => route.pagePath !== null) + .map((route) => ({ + ...route, + sourcePath: route.pagePath === null ? null : path.relative(root, route.pagePath), + })), + ); } // When the user sets `ssr.external: true`, strip React entries from @@ -3113,12 +3124,56 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } } + function invalidateHybridClientEntries() { + if (!hasAppDir || !hasPagesDir) return; + for (const env of Object.values(server.environments)) { + for (const id of [RESOLVED_CLIENT_ENTRY, RESOLVED_APP_BROWSER_ENTRY]) { + const mod = env.moduleGraph.getModuleById(id); + if (mod) env.moduleGraph.invalidateModule(mod); + } + } + server.ws.send({ type: "full-reload" }); + } + function invalidateAppRoutingModules() { invalidateAppRouteCache(); invalidateRscEntryModule(); invalidateRootParamsModule(); } + let hybridRouteValidation: Promise = Promise.resolve(); + function revalidateHybridRoutes() { + if (!hasAppDir || !hasPagesDir) return; + hybridRouteValidation = hybridRouteValidation + .catch(() => {}) + .then(async () => { + const [appRoutes, pageRoutes] = await Promise.all([ + appRouter(appDir, nextConfig?.pageExtensions, fileMatcher), + pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher), + ]); + validateHybridRouteConflicts( + pageRoutes.map((route) => ({ + ...route, + sourcePath: path.relative(root, route.filePath), + })), + appRoutes + .filter((route) => route.pagePath !== null) + .map((route) => ({ + ...route, + sourcePath: + route.pagePath === null ? null : path.relative(root, route.pagePath), + })), + ); + }) + .catch((error) => { + const err = error instanceof Error ? error : new Error(String(error)); + server.ws.send({ + type: "error", + err: { message: err.message, stack: err.stack ?? err.message }, + }); + }); + } + let appRouteTypeGeneration: Promise | null = null; let appRouteTypeGenerationPending = false; @@ -3154,6 +3209,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } regenerateAppRouteTypes(); + revalidateHybridRoutes(); // Node throws on unhandled 'error' events on sockets. When a browser // drops the connection mid-response (common in dev: HMR triggers a @@ -3168,21 +3224,35 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }); server.watcher.on("add", (filePath: string) => { + let routeChanged = false; if (hasPagesDir && filePath.startsWith(pagesDir) && pageExtensions.test(filePath)) { invalidateRouteCache(pagesDir); + routeChanged = true; } if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) { invalidateAppRoutingModules(); regenerateAppRouteTypes(); + routeChanged = true; + } + if (routeChanged) { + invalidateHybridClientEntries(); + revalidateHybridRoutes(); } }); server.watcher.on("unlink", (filePath: string) => { + let routeChanged = false; if (hasPagesDir && filePath.startsWith(pagesDir) && pageExtensions.test(filePath)) { invalidateRouteCache(pagesDir); + routeChanged = true; } if (hasAppDir && shouldInvalidateAppRouteFile(appDir, filePath, fileMatcher)) { invalidateAppRoutingModules(); regenerateAppRouteTypes(); + routeChanged = true; + } + if (routeChanged) { + invalidateHybridClientEntries(); + revalidateHybridRoutes(); } }); diff --git a/packages/vinext/src/routing/utils.ts b/packages/vinext/src/routing/utils.ts index 5537fe70d..d16b53ccf 100644 --- a/packages/vinext/src/routing/utils.ts +++ b/packages/vinext/src/routing/utils.ts @@ -122,18 +122,7 @@ export function compareHybridRoutePatterns( appPattern: string, appIsDynamic: boolean, ): "app" | "pages" { - const normalizeStructure = (pattern: string): string => - pattern - .split("/") - .filter(Boolean) - .map((segment) => { - if (!segment.startsWith(":")) return segment; - if (segment.endsWith("*")) return ":*"; - if (segment.endsWith("+")) return ":+"; - return ":"; - }) - .join("/"); - if (normalizeStructure(pagesPattern) === normalizeStructure(appPattern)) { + if (pagesPattern === appPattern) { throw new Error(`Conflicting app and page routes found for "${pagesPattern}"`); } diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index c11767f63..8c2ff5fc7 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -667,6 +667,22 @@ async function handleAppRscRequest( if (serverActionResponse) return serverActionResponse; let match = preActionMatch; + const pagesFallbackEligible = match === null || match.route.isDynamic; + const pagesFallbackResponse = pagesFallbackEligible + ? await options.renderPagesFallback?.({ + appRouteMatch: match ?? null, + isRscRequest, + middlewareContext, + pathname: cleanPathname, + request, + url, + }) + : null; + if (pagesFallbackResponse) { + options.clearRequestContext(); + return pagesFallbackResponse; + } + if (!match || match.route.isDynamic) { const afterFilesRewrite = await applyRewrite( { @@ -687,27 +703,6 @@ async function handleAppRscRequest( } } - // Hybrid ownership: a static App route always wins over any Pages route - // (see `pagesRouteHasPriorityOverAppRoute`). When we have matched a static - // App route, the Pages fallback cannot own this request, so skip eagerly - // loading the Pages entry on every cold-start. The bridge still handles the - // `match === null` case (no App match) and the dynamic-App case below. - const pagesFallbackEligible = match === null || match.route.isDynamic; - const pagesFallbackResponse = pagesFallbackEligible - ? await options.renderPagesFallback?.({ - appRouteMatch: match ?? null, - isRscRequest, - middlewareContext, - pathname: cleanPathname, - request, - url, - }) - : null; - if (pagesFallbackResponse) { - options.clearRequestContext(); - return pagesFallbackResponse; - } - if (!match) { const fallbackRewrite = await applyRewrite( { diff --git a/packages/vinext/src/server/hybrid-route-priority.ts b/packages/vinext/src/server/hybrid-route-priority.ts index 50e5f7c13..bede394b8 100644 --- a/packages/vinext/src/server/hybrid-route-priority.ts +++ b/packages/vinext/src/server/hybrid-route-priority.ts @@ -1,8 +1,10 @@ import { compareHybridRoutePatterns } from "../routing/utils.js"; +import { validateRoutePatterns } from "../routing/route-validation.js"; export type HybridRoutePriorityRoute = { isDynamic: boolean; pattern: string; + sourcePath?: string | null; }; export type HybridOwner = "app" | "pages"; @@ -12,38 +14,31 @@ export type HybridRouteMatch = { params: Record; }; -function normalizeHybridRouteStructure(pattern: string): string { - return pattern - .split("/") - .filter(Boolean) - .map((segment) => { - if (!segment.startsWith(":")) return segment; - if (segment.endsWith("*")) return ":*"; - if (segment.endsWith("+")) return ":+"; - return ":"; - }) - .join("/"); -} - export function validateHybridRouteConflicts( pagesRoutes: readonly HybridRoutePriorityRoute[], appRoutes: readonly HybridRoutePriorityRoute[], ): void { - const pagesByStructure = new Map( - pagesRoutes.map((route) => [normalizeHybridRouteStructure(route.pattern), route.pattern]), - ); - const conflicts = appRoutes.flatMap((route) => { - const pagesPattern = pagesByStructure.get(normalizeHybridRouteStructure(route.pattern)); - return pagesPattern === undefined ? [] : [[pagesPattern, route.pattern] as const]; + const pagesByPattern = new Map(pagesRoutes.map((route) => [route.pattern, route])); + const conflicts = appRoutes.flatMap((appRoute) => { + const pagesRoute = pagesByPattern.get(appRoute.pattern); + return pagesRoute === undefined ? [] : [[pagesRoute, appRoute] as const]; }); - if (conflicts.length === 0) return; + if (conflicts.length > 0) { + const message = `Conflicting app and page file${conflicts.length === 1 ? " was" : "s were"} found, please remove the conflicting files to continue:`; + throw new Error( + `${message}\n${conflicts + .map( + ([pagesRoute, appRoute]) => + ` "${pagesRoute.sourcePath ?? pagesRoute.pattern}" - "${appRoute.sourcePath ?? appRoute.pattern}"`, + ) + .join("\n")}`, + ); + } - const message = `Conflicting app and page file${conflicts.length === 1 ? " was" : "s were"} found, please remove the conflicting files to continue:`; - throw new Error( - `${message}\n${conflicts - .map(([pagesPattern, appPattern]) => ` pages "${pagesPattern}" - app "${appPattern}"`) - .join("\n")}`, - ); + validateRoutePatterns([ + ...pagesRoutes.map((route) => route.pattern), + ...appRoutes.map((route) => route.pattern), + ]); } /** diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index 61c84ce99..0fbbe2de2 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -965,6 +965,35 @@ describe("createAppRscHandler", () => { ); }); + it("lets a static Pages route win before afterFiles rewrites", async () => { + const dynamicRoute = createPageRoute({ + isDynamic: true, + pattern: "/:path+", + routeSegments: ["[...path]"], + }); + const renderPagesFallback = vi.fn(async () => new Response("pages:/about", { status: 200 })); + const handler = createHandler({ + configHeaders: [], + configRewrites: { + beforeFiles: [], + afterFiles: [{ source: "/about", destination: "/rewritten" }], + fallback: [], + }, + matchRoute: () => ({ params: { path: ["about"] }, route: dynamicRoute }), + renderPagesFallback, + }); + + const response = await handler(new Request("https://example.test/docs/about"), null); + + expect(await response.text()).toBe("pages:/about"); + expect(renderPagesFallback).toHaveBeenCalledWith( + expect.objectContaining({ + pathname: "/about", + appRouteMatch: expect.objectContaining({ route: dynamicRoute }), + }), + ); + }); + it("serves public files before route matching and clears request context", async () => { const clearRequestContext = vi.fn(); const matchRoute = vi.fn(() => null); diff --git a/tests/fixtures/use-params-app-pages/pages/[section]/details.tsx b/tests/fixtures/use-params-app-pages/pages/[id]/details.tsx similarity index 100% rename from tests/fixtures/use-params-app-pages/pages/[section]/details.tsx rename to tests/fixtures/use-params-app-pages/pages/[id]/details.tsx diff --git a/tests/hybrid-client-route-owner.test.ts b/tests/hybrid-client-route-owner.test.ts index 9b58515c9..de5ed5cd7 100644 --- a/tests/hybrid-client-route-owner.test.ts +++ b/tests/hybrid-client-route-owner.test.ts @@ -134,14 +134,12 @@ describe("resolveHybridClientRouteOwner", () => { expect(() => resolveHybridClientRouteOwner("/", "")).toThrow("Conflicting app and page routes"); }); - it("rejects structurally identical dynamic App and Pages routes", () => { + it("retains Pages provider order after merged route validation", () => { installWindow({ app: [appRoute([":slug"])], pages: [pagesRoute([":id"])], }); - expect(() => resolveHybridClientRouteOwner("/anything", "")).toThrow( - "Conflicting app and page routes", - ); + expect(resolveHybridClientRouteOwner("/anything", "")).toBe("pages"); }); it("lets a static-prefix Pages catch-all beat a bare App catch-all", () => { diff --git a/tests/hybrid-route-priority.test.ts b/tests/hybrid-route-priority.test.ts index 470a19d2e..8ba6b1581 100644 --- a/tests/hybrid-route-priority.test.ts +++ b/tests/hybrid-route-priority.test.ts @@ -34,10 +34,8 @@ describe("compareHybridRoutePatterns", () => { ); }); - it("rejects structurally identical dynamic App and Pages routes", () => { - expect(() => compareHybridRoutePatterns("/:slug", true, "/:id", true)).toThrow( - "Conflicting app and page routes", - ); + it("retains Pages provider order after merged route validation", () => { + expect(compareHybridRoutePatterns("/:slug", true, "/:id", true)).toBe("pages"); }); it("lets a static-prefix Pages catch-all beat a bare App catch-all", () => { @@ -91,13 +89,31 @@ describe("validateHybridRouteConflicts", () => { ).toThrow("Conflicting app and page file was found"); }); - it("rejects structurally identical dynamic routes with different param names", () => { + it("uses the Next.js slug-name error for structurally identical dynamic routes", () => { expect(() => validateHybridRouteConflicts( [{ isDynamic: true, pattern: "/:slug" }], [{ isDynamic: true, pattern: "/:id" }], ), - ).toThrow("Conflicting app and page file was found"); + ).toThrow("different slug names for the same dynamic path"); + }); + + it("rejects cross-router apex and optional catch-all collisions", () => { + expect(() => + validateHybridRouteConflicts( + [{ isDynamic: false, pattern: "/" }], + [{ isDynamic: true, pattern: "/:all*" }], + ), + ).toThrow("same specificity as a optional catch-all route"); + }); + + it("reports exact conflict source files", () => { + expect(() => + validateHybridRouteConflicts( + [{ isDynamic: false, pattern: "/", sourcePath: "pages/index.tsx" }], + [{ isDynamic: false, pattern: "/", sourcePath: "app/page.tsx" }], + ), + ).toThrow('"pages/index.tsx" - "app/page.tsx"'); }); }); @@ -135,13 +151,13 @@ describe("hybrid App Router + Pages Router route priority", () => { ).toThrow("Conflicting app and page routes"); }); - it("rejects structurally identical dynamic App and Pages routes", () => { - expect(() => + it("retains Pages provider order after merged route validation", () => { + expect( pagesRouteHasPriorityOverAppRoute( { isDynamic: true, pattern: "/:slug" }, { isDynamic: true, pattern: "/:id" }, ), - ).toThrow("Conflicting app and page routes"); + ).toBe(true); }); }); @@ -193,12 +209,12 @@ describe("resolveHybridRouteOwner", () => { ).toThrow("Conflicting app and page routes"); }); - it("rejects structurally identical dynamic App and Pages routes", () => { - expect(() => + it("retains Pages provider order after merged route validation", () => { + expect( resolveHybridRouteOwner( { route: { isDynamic: true, pattern: "/:slug" }, params: { slug: "x" } }, { route: { isDynamic: true, pattern: "/:id" }, params: { id: "x" } }, ), - ).toThrow("Conflicting app and page routes"); + ).toBe("pages"); }); }); From 9cae882a3f27b98f7f138718538f83ea06944794 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 17:07:27 +0100 Subject: [PATCH 09/23] fix(router): preserve hybrid routing lifecycle --- packages/vinext/src/entries/app-rsc-entry.ts | 4 +- packages/vinext/src/index.ts | 50 +++++++++++----- .../vinext/src/server/app-pages-bridge.ts | 4 ++ packages/vinext/src/server/app-rsc-handler.ts | 57 +++++++++++-------- .../src/shims/internal/app-route-detection.ts | 3 +- packages/vinext/src/shims/router.ts | 4 +- tests/app-pages-bridge.test.ts | 38 +++++++++++++ tests/app-router-next-config-codegen.test.ts | 2 +- tests/app-rsc-handler.test.ts | 46 +++++++++++++++ ...ages-router-app-prefetch-detection.test.ts | 27 +++++++++ 10 files changed, 193 insertions(+), 42 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 96e5cc28a..274a9ba21 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -999,9 +999,9 @@ export default __createAppRscHandler({ }, ${ hasPagesDir - ? `async renderPagesFallback({ appRouteMatch, isRscRequest, middlewareContext, pathname, request, url }) { + ? `async renderPagesFallback({ appRouteMatch, isRscRequest, matchKind, middlewareContext, pathname, request, url }) { return __renderPagesFallback( - { appRouteMatch, isRscRequest, middlewareContext, pathname, request, url }, + { appRouteMatch, isRscRequest, matchKind, middlewareContext, pathname, request, url }, { loadPagesEntry() { return import.meta.viteRsc.loadModule("ssr", "index"); diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index f1645fe0d..c7ec7ec6a 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -2434,20 +2434,21 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { sassComposesLoader.setResolvedConfig(config); if (config.command === "build" && hasAppDir && hasPagesDir) { - const [appRoutes, pageRoutes] = await Promise.all([ + const [appRoutes, pageRoutes, apiRoutes] = await Promise.all([ appRouter(appDir, nextConfig?.pageExtensions, fileMatcher), pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher), + apiRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher), ]); validateHybridRouteConflicts( - pageRoutes.map((route) => ({ + [...pageRoutes, ...apiRoutes].map((route) => ({ ...route, sourcePath: path.relative(root, route.filePath), })), appRoutes - .filter((route) => route.pagePath !== null) + .filter((route) => route.pagePath !== null || route.routePath !== null) .map((route) => ({ ...route, - sourcePath: route.pagePath === null ? null : path.relative(root, route.pagePath), + sourcePath: path.relative(root, route.pagePath ?? route.routePath!), })), ); } @@ -3135,6 +3136,14 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { server.ws.send({ type: "full-reload" }); } + function invalidatePagesServerEntry() { + for (const env of Object.values(server.environments)) { + const mod = env.moduleGraph.getModuleById(RESOLVED_SERVER_ENTRY); + if (mod) env.moduleGraph.invalidateModule(mod); + } + pagesRunner?.clearCache(); + } + function invalidateAppRoutingModules() { invalidateAppRouteCache(); invalidateRscEntryModule(); @@ -3142,35 +3151,48 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } let hybridRouteValidation: Promise = Promise.resolve(); + let hybridRouteValidationError: Error | null = null; + function sendHybridRouteValidationError(error: Error) { + server.ws.send({ + type: "error", + err: { message: error.message, stack: error.stack ?? error.message }, + }); + } + server.ws.on("connection", () => { + if (hybridRouteValidationError) + sendHybridRouteValidationError(hybridRouteValidationError); + }); function revalidateHybridRoutes() { if (!hasAppDir || !hasPagesDir) return; hybridRouteValidation = hybridRouteValidation .catch(() => {}) .then(async () => { - const [appRoutes, pageRoutes] = await Promise.all([ + const [appRoutes, pageRoutes, apiRoutes] = await Promise.all([ appRouter(appDir, nextConfig?.pageExtensions, fileMatcher), pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher), + apiRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher), ]); validateHybridRouteConflicts( - pageRoutes.map((route) => ({ + [...pageRoutes, ...apiRoutes].map((route) => ({ ...route, sourcePath: path.relative(root, route.filePath), })), appRoutes - .filter((route) => route.pagePath !== null) + .filter((route) => route.pagePath !== null || route.routePath !== null) .map((route) => ({ ...route, - sourcePath: - route.pagePath === null ? null : path.relative(root, route.pagePath), + sourcePath: path.relative(root, route.pagePath ?? route.routePath!), })), ); + if (hybridRouteValidationError) { + hybridRouteValidationError = null; + server.ws.send({ type: "full-reload" }); + } }) .catch((error) => { const err = error instanceof Error ? error : new Error(String(error)); - server.ws.send({ - type: "error", - err: { message: err.message, stack: err.stack ?? err.message }, - }); + hybridRouteValidationError = err; + sendHybridRouteValidationError(err); }); } @@ -3235,6 +3257,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { routeChanged = true; } if (routeChanged) { + invalidatePagesServerEntry(); invalidateHybridClientEntries(); revalidateHybridRoutes(); } @@ -3251,6 +3274,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { routeChanged = true; } if (routeChanged) { + invalidatePagesServerEntry(); invalidateHybridClientEntries(); revalidateHybridRoutes(); } diff --git a/packages/vinext/src/server/app-pages-bridge.ts b/packages/vinext/src/server/app-pages-bridge.ts index 62cdb52a4..7fd5dbb75 100644 --- a/packages/vinext/src/server/app-pages-bridge.ts +++ b/packages/vinext/src/server/app-pages-bridge.ts @@ -60,6 +60,7 @@ type RenderPagesFallbackDependencies = { type RenderPagesFallbackOptions = { appRouteMatch?: AppRouteMatch | null; isRscRequest: boolean; + matchKind?: "dynamic" | "static"; middlewareContext: AppMiddlewareContext; pathname?: string; request: Request; @@ -76,6 +77,7 @@ export async function renderPagesFallback( const { appRouteMatch = null, isRscRequest, + matchKind, middlewareContext, pathname = options.url.pathname, request, @@ -141,6 +143,8 @@ export async function renderPagesFallback( ? (pagesEntry.matchPageRoute?.(pagesUrl, pagesRequest) ?? null) : null; if (hasPageMatcher && pageMatch === null) return null; + if (pageMatch !== null && matchKind === "static" && pageMatch.route.isDynamic) return null; + if (pageMatch !== null && matchKind === "dynamic" && !pageMatch.route.isDynamic) return null; if ( appRouteMatch !== null && (pageMatch === null || !pagesRouteHasPriorityOverAppRoute(pageMatch.route, appRouteMatch.route)) diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 8c2ff5fc7..99a5a5e1c 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -213,18 +213,12 @@ type RenderNotFoundOptions = { scriptNonce?: string; }; -type RenderPagesFallbackRouteMatch = { - route: { - isDynamic: boolean; - pattern: string; - }; -}; - type RenderPagesFallbackOptions = { - appRouteMatch?: RenderPagesFallbackRouteMatch | null; + appRouteMatch?: { route: { isDynamic: boolean; pattern: string } } | null; isRscRequest: boolean; + matchKind?: "dynamic" | "static"; middlewareContext: AppRscMiddlewareContext; - pathname: string; + pathname?: string; request: Request; url: URL; }; @@ -667,22 +661,22 @@ async function handleAppRscRequest( if (serverActionResponse) return serverActionResponse; let match = preActionMatch; - const pagesFallbackEligible = match === null || match.route.isDynamic; - const pagesFallbackResponse = pagesFallbackEligible - ? await options.renderPagesFallback?.({ - appRouteMatch: match ?? null, - isRscRequest, - middlewareContext, - pathname: cleanPathname, - request, - url, - }) - : null; - if (pagesFallbackResponse) { + const staticPagesFallbackResponse = + match === null || match.route.isDynamic + ? await options.renderPagesFallback?.({ + appRouteMatch: match ?? null, + isRscRequest, + matchKind: "static", + middlewareContext, + pathname: cleanPathname, + request, + url, + }) + : null; + if (staticPagesFallbackResponse) { options.clearRequestContext(); - return pagesFallbackResponse; + return staticPagesFallbackResponse; } - if (!match || match.route.isDynamic) { const afterFilesRewrite = await applyRewrite( { @@ -703,6 +697,23 @@ async function handleAppRscRequest( } } + const dynamicPagesFallbackEligible = match === null || match.route.isDynamic; + const dynamicPagesFallbackResponse = dynamicPagesFallbackEligible + ? await options.renderPagesFallback?.({ + appRouteMatch: match ?? null, + isRscRequest, + matchKind: "dynamic", + middlewareContext, + pathname: cleanPathname, + request, + url, + }) + : null; + if (dynamicPagesFallbackResponse) { + options.clearRequestContext(); + return dynamicPagesFallbackResponse; + } + if (!match) { const fallbackRewrite = await applyRewrite( { diff --git a/packages/vinext/src/shims/internal/app-route-detection.ts b/packages/vinext/src/shims/internal/app-route-detection.ts index 20c9fc024..e2e83fa1e 100644 --- a/packages/vinext/src/shims/internal/app-route-detection.ts +++ b/packages/vinext/src/shims/internal/app-route-detection.ts @@ -38,6 +38,7 @@ import type { VinextLinkPrefetchRoute } from "../../client/vinext-next-data.js"; import { createRouteTrieCache, matchRouteWithTrie } from "../../routing/route-matching.js"; import { stripBasePath, removeTrailingSlash } from "../../utils/base-path.js"; import { getLocalePathPrefix } from "../../utils/domain-locale.js"; +import { resolveHybridClientRouteOwner } from "./hybrid-client-route-owner.js"; const appRouteTrieCache = createRouteTrieCache(); @@ -130,7 +131,7 @@ export function matchesAppRoute(href: string, basePath: string): boolean { */ export function markAppRouteDetectedOnPrefetch(href: string, basePath: string): void { if (typeof window === "undefined") return; - if (!matchesAppRoute(href, basePath)) return; + if (resolveHybridClientRouteOwner(href, basePath) !== "app") return; const rawPathname = resolveSameOriginPathname(href, basePath); if (rawPathname === null) return; diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index f46c47568..0e5afc335 100644 --- a/packages/vinext/src/shims/router.ts +++ b/packages/vinext/src/shims/router.ts @@ -36,8 +36,8 @@ import { buildPagesDataHref } from "./internal/pages-data-url.js"; import { getPagesRouterComponentsMap, markAppRouteDetectedOnPrefetch, - matchesAppRoute, } from "./internal/app-route-detection.js"; +import { resolveHybridClientRouteOwner } from "./internal/hybrid-client-route-owner.js"; import { dedupedPagesDataFetch } from "./internal/pages-data-fetch-dedup.js"; import { installWindowNext, type PagesRouterPublicInstance } from "../client/window-next.js"; import { isUnknownRecord } from "../utils/record.js"; @@ -2079,7 +2079,7 @@ async function performNavigation( appPathNorm !== null ? getPagesRouterComponentsMap()[appPathNorm] : undefined; const appRouteDetected = (appPathEntry !== undefined && "__appRouter" in appPathEntry && appPathEntry.__appRouter) || - matchesAppRoute(resolved, __basePath); + resolveHybridClientRouteOwner(resolved, __basePath) === "app"; if (appRouteDetected) { if (mode === "push") window.location.assign(full); else window.location.replace(full); diff --git a/tests/app-pages-bridge.test.ts b/tests/app-pages-bridge.test.ts index 562d2f421..bec209475 100644 --- a/tests/app-pages-bridge.test.ts +++ b/tests/app-pages-bridge.test.ts @@ -199,6 +199,44 @@ describe("renderPagesFallback", () => { expect(await res!.text()).toBe("page-response"); }); + it("filters static and dynamic Pages matches by ownership phase", async () => { + const renderPage = vi.fn(() => new Response("page")); + const request = new Request("http://localhost/blog/hello"); + const url = new URL(request.url); + const deps = { + ...defaultDeps, + loadPagesEntry: () => ({ + matchPageRoute: () => ({ route: { isDynamic: true, pattern: "/blog/:slug" } }), + renderPage, + }), + }; + + expect( + await renderPagesFallback( + { + isRscRequest: false, + matchKind: "static", + middlewareContext: { headers: null, requestHeaders: null, status: null }, + request, + url, + }, + deps, + ), + ).toBeNull(); + expect( + await renderPagesFallback( + { + isRscRequest: false, + matchKind: "dynamic", + middlewareContext: { headers: null, requestHeaders: null, status: null }, + request, + url, + }, + deps, + ), + ).not.toBeNull(); + }); + it("appends the middleware draft cookie to an API fallback response (#1520)", async () => { const handleApiRoute = vi.fn((_req: Request, _url: string) => new Response("api-response")); const deps = { diff --git a/tests/app-router-next-config-codegen.test.ts b/tests/app-router-next-config-codegen.test.ts index 4607cf051..2067a7129 100644 --- a/tests/app-router-next-config-codegen.test.ts +++ b/tests/app-router-next-config-codegen.test.ts @@ -145,7 +145,7 @@ describe("App Router next.config.js features (generateRscEntry)", () => { expect(code).toContain("server/app-pages-bridge.js"); expect(code).toContain("return __renderPagesFallback("); expect(code).toContain( - "{ appRouteMatch, isRscRequest, middlewareContext, pathname, request, url }", + "{ appRouteMatch, isRscRequest, matchKind, middlewareContext, pathname, request, url }", ); expect(code).toContain('return import.meta.viteRsc.loadModule("ssr", "index");'); expect(code).toContain("buildRequestHeaders: __buildRequestHeadersFromMiddlewareResponse"); diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index 0fbbe2de2..6cfb83614 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -988,12 +988,58 @@ describe("createAppRscHandler", () => { expect(await response.text()).toBe("pages:/about"); expect(renderPagesFallback).toHaveBeenCalledWith( expect.objectContaining({ + matchKind: "static", pathname: "/about", appRouteMatch: expect.objectContaining({ route: dynamicRoute }), }), ); }); + it("runs afterFiles rewrites before dynamic Pages route ownership", async () => { + const appDynamicRoute = createPageRoute({ + isDynamic: true, + pattern: "/:slug", + routeSegments: ["[slug]"], + }); + const appDestinationRoute = createPageRoute({ + pattern: "/destination", + routeSegments: ["destination"], + }); + const renderPagesFallback = vi.fn(async ({ matchKind }) => + matchKind === "dynamic" ? new Response("pages-dynamic", { status: 200 }) : null, + ); + const dispatchMatchedPage = vi.fn( + async ({ route }) => new Response(`app:${route.pattern}`, { status: 200 }), + ); + const handler = createHandler({ + configHeaders: [], + configRewrites: { + beforeFiles: [], + afterFiles: [{ source: "/legacy", destination: "/destination" }], + fallback: [], + }, + dispatchMatchedPage, + matchRoute: (pathname): ReturnType => { + if (pathname === "/legacy") { + return { params: { slug: "legacy" }, route: appDynamicRoute }; + } + if (pathname === "/destination") return { params: {}, route: appDestinationRoute }; + return null; + }, + renderPagesFallback, + }); + + const response = await handler(new Request("https://example.test/docs/legacy"), null); + + expect(await response.text()).toBe("app:/destination"); + expect(renderPagesFallback).toHaveBeenCalledWith( + expect.objectContaining({ matchKind: "static", pathname: "/legacy" }), + ); + expect(renderPagesFallback).not.toHaveBeenCalledWith( + expect.objectContaining({ matchKind: "dynamic", pathname: "/legacy" }), + ); + }); + it("serves public files before route matching and clears request context", async () => { const clearRequestContext = vi.fn(); const matchRoute = vi.fn(() => null); diff --git a/tests/pages-router-app-prefetch-detection.test.ts b/tests/pages-router-app-prefetch-detection.test.ts index be8672bd8..27414f255 100644 --- a/tests/pages-router-app-prefetch-detection.test.ts +++ b/tests/pages-router-app-prefetch-detection.test.ts @@ -49,6 +49,11 @@ type FakeWindow = { patternParts: string[]; isDynamic: boolean; }>; + __VINEXT_PAGES_LINK_PREFETCH_ROUTES__?: Array<{ + canPrefetchLoadingShell: boolean; + patternParts: string[]; + isDynamic: boolean; + }>; __VINEXT_LOCALES__?: string[]; __VINEXT_DEFAULT_LOCALE__?: string; next?: unknown; @@ -272,4 +277,26 @@ describe("Pages Router records app routes as detected on prefetch", () => { expect(matchesAppRoute("https://example.com/about", "")).toBe(false); }); + + it("does not mark or hard-navigate a Pages-owned overlap", async () => { + const fakeWindow = installFakeBrowserGlobals([ + { canPrefetchLoadingShell: false, patternParts: [":path+"], isDynamic: true }, + ]); + fakeWindow.__VINEXT_PAGES_LINK_PREFETCH_ROUTES__ = [ + { + canPrefetchLoadingShell: false, + patternParts: ["pages-dir", ":dynamic"], + isDynamic: true, + }, + ]; + const { markAppRouteDetectedOnPrefetch } = + await import("../packages/vinext/src/shims/internal/app-route-detection.js"); + const routerModule = await import("../packages/vinext/src/shims/router.js"); + + markAppRouteDetectedOnPrefetch("/pages-dir/foobar", ""); + expect(routerModule.default.components["/pages-dir/foobar"]).toBeUndefined(); + + void routerModule.default.push("/pages-dir/foobar"); + expect(fakeWindow.location.assign).not.toHaveBeenCalled(); + }); }); From 46d833b71b60a3b7e04ca23e247e256f4b71aa95 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 17:22:11 +0100 Subject: [PATCH 10/23] fix(router): recheck pages routes after rewrites --- packages/vinext/src/server/app-rsc-handler.ts | 44 ++++++++++------ tests/app-rsc-handler.test.ts | 50 +++++++++++++++++++ 2 files changed, 78 insertions(+), 16 deletions(-) diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 99a5a5e1c..4076393b6 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -661,22 +661,26 @@ async function handleAppRscRequest( if (serverActionResponse) return serverActionResponse; let match = preActionMatch; - const staticPagesFallbackResponse = + const renderPagesForMatchKind = async ( + matchKind: "dynamic" | "static", + ): Promise => match === null || match.route.isDynamic - ? await options.renderPagesFallback?.({ + ? ((await options.renderPagesFallback?.({ appRouteMatch: match ?? null, isRscRequest, - matchKind: "static", + matchKind, middlewareContext, pathname: cleanPathname, request, url, - }) + })) ?? null) : null; + const staticPagesFallbackResponse = await renderPagesForMatchKind("static"); if (staticPagesFallbackResponse) { options.clearRequestContext(); return staticPagesFallbackResponse; } + let didAfterFilesRewrite = false; if (!match || match.route.isDynamic) { const afterFilesRewrite = await applyRewrite( { @@ -694,21 +698,19 @@ async function handleAppRscRequest( if (afterFilesRewrite) { cleanPathname = afterFilesRewrite; match = options.matchRoute(cleanPathname); + didAfterFilesRewrite = true; } } - const dynamicPagesFallbackEligible = match === null || match.route.isDynamic; - const dynamicPagesFallbackResponse = dynamicPagesFallbackEligible - ? await options.renderPagesFallback?.({ - appRouteMatch: match ?? null, - isRscRequest, - matchKind: "dynamic", - middlewareContext, - pathname: cleanPathname, - request, - url, - }) - : null; + if (didAfterFilesRewrite) { + const rewrittenStaticPagesResponse = await renderPagesForMatchKind("static"); + if (rewrittenStaticPagesResponse) { + options.clearRequestContext(); + return rewrittenStaticPagesResponse; + } + } + + const dynamicPagesFallbackResponse = await renderPagesForMatchKind("dynamic"); if (dynamicPagesFallbackResponse) { options.clearRequestContext(); return dynamicPagesFallbackResponse; @@ -731,6 +733,16 @@ async function handleAppRscRequest( if (fallbackRewrite) { cleanPathname = fallbackRewrite; match = options.matchRoute(cleanPathname); + const rewrittenStaticPagesResponse = await renderPagesForMatchKind("static"); + if (rewrittenStaticPagesResponse) { + options.clearRequestContext(); + return rewrittenStaticPagesResponse; + } + const rewrittenDynamicPagesResponse = await renderPagesForMatchKind("dynamic"); + if (rewrittenDynamicPagesResponse) { + options.clearRequestContext(); + return rewrittenDynamicPagesResponse; + } } } diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index 6cfb83614..d9c27babf 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -1040,6 +1040,56 @@ describe("createAppRscHandler", () => { ); }); + it("rechecks static Pages routes after an afterFiles rewrite", async () => { + const renderPagesFallback = vi.fn(async ({ matchKind, pathname }) => + matchKind === "static" && pathname === "/pages-static" + ? new Response("pages-static", { status: 200 }) + : null, + ); + const handler = createHandler({ + configHeaders: [], + configRewrites: { + beforeFiles: [], + afterFiles: [{ source: "/legacy", destination: "/pages-static" }], + fallback: [], + }, + matchRoute: () => null, + renderPagesFallback, + }); + + const response = await handler(new Request("https://example.test/docs/legacy"), null); + + expect(await response.text()).toBe("pages-static"); + }); + + it("rechecks static and dynamic Pages routes after a fallback rewrite", async () => { + const renderPagesFallback = vi.fn(async ({ matchKind, pathname }) => + pathname === "/pages-dynamic" && matchKind === "dynamic" + ? new Response("pages-dynamic", { status: 200 }) + : null, + ); + const handler = createHandler({ + configHeaders: [], + configRewrites: { + beforeFiles: [], + afterFiles: [], + fallback: [{ source: "/legacy", destination: "/pages-dynamic" }], + }, + matchRoute: () => null, + renderPagesFallback, + }); + + const response = await handler(new Request("https://example.test/docs/legacy"), null); + + expect(await response.text()).toBe("pages-dynamic"); + expect(renderPagesFallback).toHaveBeenCalledWith( + expect.objectContaining({ matchKind: "static", pathname: "/pages-dynamic" }), + ); + expect(renderPagesFallback).toHaveBeenCalledWith( + expect.objectContaining({ matchKind: "dynamic", pathname: "/pages-dynamic" }), + ); + }); + it("serves public files before route matching and clears request context", async () => { const clearRequestContext = vi.fn(); const matchRoute = vi.fn(() => null); From 63c64f4a4951e402c682dda407711cda91d6c8aa Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 17:47:59 +0100 Subject: [PATCH 11/23] fix(router): preserve rewritten pages queries --- .../vinext/src/server/app-pages-bridge.ts | 4 +- packages/vinext/src/server/app-rsc-handler.ts | 15 ++++-- tests/app-pages-bridge.test.ts | 41 +++++++++++++++++ tests/app-rsc-handler.test.ts | 46 +++++++++++++++++++ 4 files changed, 101 insertions(+), 5 deletions(-) diff --git a/packages/vinext/src/server/app-pages-bridge.ts b/packages/vinext/src/server/app-pages-bridge.ts index 7fd5dbb75..8e47fa86b 100644 --- a/packages/vinext/src/server/app-pages-bridge.ts +++ b/packages/vinext/src/server/app-pages-bridge.ts @@ -112,7 +112,7 @@ export async function renderPagesFallback( pagesRequest = new Request(request.url, pagesRequestInit); } - const pagesUrl = decodePathParams(pathname) + (url.search || ""); + const pagesUrl = decodePathParams(pathname) + (pathname.includes("?") ? "" : url.search || ""); const pagesPathname = pathname; if (pagesPathname.startsWith("/api/") || pagesPathname === "/api") { if (typeof pagesEntry.handleApiRoute !== "function") return null; @@ -121,6 +121,8 @@ export async function renderPagesFallback( ? (pagesEntry.matchApiRoute?.(pagesUrl, pagesRequest) ?? null) : null; if (hasApiMatcher && apiMatch === null) return null; + if (apiMatch !== null && matchKind === "static" && apiMatch.route.isDynamic) return null; + if (apiMatch !== null && matchKind === "dynamic" && !apiMatch.route.isDynamic) return null; if (appRouteMatch !== null) { if ( apiMatch === null || diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 4076393b6..f0af6adaa 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -32,6 +32,7 @@ import { import { pickRootParams, setRootParams, type RootParams } from "vinext/shims/root-params"; import { createRequestContext, runWithRequestContext } from "vinext/shims/unified-request-context"; import { flattenErrorCauses } from "../utils/error-cause.js"; +import { mergeRewriteQuery } from "../utils/query.js"; import { hasBasePath } from "../utils/base-path.js"; import { applyAppMiddleware, type AppMiddlewareContext } from "./app-middleware.js"; import { mergeMiddlewareResponseHeaders } from "./app-page-response.js"; @@ -414,6 +415,7 @@ async function handleAppRscRequest( clientReuseManifest, } = normalized; let { pathname, cleanPathname } = normalized; + let resolvedUrl = cleanPathname + url.search; // Canonical (external) pathname the user requested. Middleware rewrites and // next.config.js rewrites mutate `cleanPathname` so internal route matching // can find the destination page, but hooks like `usePathname()` must reflect @@ -552,7 +554,10 @@ async function handleAppRscRequest( matchPathname(cleanPathname), ); if (beforeFilesRewrite instanceof Response) return beforeFilesRewrite; - if (beforeFilesRewrite) cleanPathname = beforeFilesRewrite; + if (beforeFilesRewrite) { + resolvedUrl = mergeRewriteQuery(resolvedUrl, beforeFilesRewrite); + cleanPathname = resolvedUrl.split("?", 1)[0]; + } if (isImageOptimizationPath(cleanPathname)) { const imageRedirect = resolveDevImageRedirect( @@ -670,7 +675,7 @@ async function handleAppRscRequest( isRscRequest, matchKind, middlewareContext, - pathname: cleanPathname, + pathname: resolvedUrl, request, url, })) ?? null) @@ -696,7 +701,8 @@ async function handleAppRscRequest( ); if (afterFilesRewrite instanceof Response) return afterFilesRewrite; if (afterFilesRewrite) { - cleanPathname = afterFilesRewrite; + resolvedUrl = mergeRewriteQuery(resolvedUrl, afterFilesRewrite); + cleanPathname = resolvedUrl.split("?", 1)[0]; match = options.matchRoute(cleanPathname); didAfterFilesRewrite = true; } @@ -731,7 +737,8 @@ async function handleAppRscRequest( ); if (fallbackRewrite instanceof Response) return fallbackRewrite; if (fallbackRewrite) { - cleanPathname = fallbackRewrite; + resolvedUrl = mergeRewriteQuery(resolvedUrl, fallbackRewrite); + cleanPathname = resolvedUrl.split("?", 1)[0]; match = options.matchRoute(cleanPathname); const rewrittenStaticPagesResponse = await renderPagesForMatchKind("static"); if (rewrittenStaticPagesResponse) { diff --git a/tests/app-pages-bridge.test.ts b/tests/app-pages-bridge.test.ts index bec209475..8b0532f6a 100644 --- a/tests/app-pages-bridge.test.ts +++ b/tests/app-pages-bridge.test.ts @@ -237,6 +237,47 @@ describe("renderPagesFallback", () => { ).not.toBeNull(); }); + it("filters static and dynamic Pages API matches by ownership phase", async () => { + const handleApiRoute = vi.fn(() => new Response("api")); + const request = new Request("http://localhost/api/posts/hello"); + const url = new URL(request.url); + const deps = { + ...defaultDeps, + loadPagesEntry: () => ({ + handleApiRoute, + matchApiRoute: () => ({ route: { isDynamic: true, pattern: "/api/posts/:slug" } }), + }), + }; + + expect( + await renderPagesFallback( + { + isRscRequest: false, + matchKind: "static", + middlewareContext: { headers: null, requestHeaders: null, status: null }, + request, + url, + }, + deps, + ), + ).toBeNull(); + expect(handleApiRoute).not.toHaveBeenCalled(); + + expect( + await renderPagesFallback( + { + isRscRequest: false, + matchKind: "dynamic", + middlewareContext: { headers: null, requestHeaders: null, status: null }, + request, + url, + }, + deps, + ), + ).not.toBeNull(); + expect(handleApiRoute).toHaveBeenCalledTimes(1); + }); + it("appends the middleware draft cookie to an API fallback response (#1520)", async () => { const handleApiRoute = vi.fn((_req: Request, _url: string) => new Response("api-response")); const deps = { diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index d9c27babf..d9ace4af3 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -1090,6 +1090,52 @@ describe("createAppRscHandler", () => { ); }); + it.each(["beforeFiles", "afterFiles", "fallback"] as const)( + "preserves and overrides query parameters for %s rewrites to Pages routes", + async (rewritePhase) => { + const renderPagesFallback = vi.fn(async ({ pathname }) => + pathname.startsWith("/pages?") ? new Response("pages", { status: 200 }) : null, + ); + const handler = createHandler({ + configHeaders: [], + configRewrites: { + beforeFiles: + rewritePhase === "beforeFiles" + ? [{ source: "/legacy", destination: "/pages?dest=2&same=new" }] + : [], + afterFiles: + rewritePhase === "afterFiles" + ? [{ source: "/legacy", destination: "/pages?dest=2&same=new" }] + : [], + fallback: + rewritePhase === "fallback" + ? [{ source: "/legacy", destination: "/pages?dest=2&same=new" }] + : [], + }, + matchRoute: () => null, + renderPagesFallback, + }); + + const response = await handler( + new Request("https://example.test/docs/legacy?keep=1&same=old"), + null, + ); + + expect(await response.text()).toBe("pages"); + const rewrittenCall = renderPagesFallback.mock.calls.find(([options]) => + options.pathname.startsWith("/pages?"), + ); + expect(rewrittenCall).toBeDefined(); + const rewrittenUrl = new URL(rewrittenCall![0].pathname, "https://example.test"); + expect(rewrittenUrl.pathname).toBe("/pages"); + expect(Object.fromEntries(rewrittenUrl.searchParams)).toEqual({ + dest: "2", + keep: "1", + same: "new", + }); + }, + ); + it("serves public files before route matching and clears request context", async () => { const clearRequestContext = vi.fn(); const matchRoute = vi.fn(() => null); From 5571ddb3af3e9435ad2864c18efbc0e3a2cf410d Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 18:26:31 +0100 Subject: [PATCH 12/23] fix(router): preserve rewritten route ownership --- .../vinext/src/entries/app-browser-entry.ts | 9 ++ .../vinext/src/entries/pages-client-entry.ts | 3 + packages/vinext/src/index.ts | 9 +- .../vinext/src/server/app-pages-bridge.ts | 6 +- packages/vinext/src/server/app-rsc-handler.ts | 17 ++-- .../internal/hybrid-client-route-owner.ts | 92 ++++++++++++++++++- packages/vinext/src/shims/link.tsx | 5 +- packages/vinext/src/shims/navigation.ts | 6 +- packages/vinext/src/shims/router.ts | 2 +- tests/app-pages-bridge.test.ts | 58 ++++++++++++ tests/app-rsc-handler.test.ts | 77 +++++++++++++++- tests/entry-templates.test.ts | 11 +++ tests/hybrid-client-route-owner.test.ts | 62 ++++++++++++- 13 files changed, 338 insertions(+), 19 deletions(-) diff --git a/packages/vinext/src/entries/app-browser-entry.ts b/packages/vinext/src/entries/app-browser-entry.ts index a4c6a5d96..6c18dd296 100644 --- a/packages/vinext/src/entries/app-browser-entry.ts +++ b/packages/vinext/src/entries/app-browser-entry.ts @@ -5,6 +5,7 @@ import type { } from "../client/vinext-next-data.js"; import type { AppRoute } from "../routing/app-router.js"; import type { RouteManifest } from "../routing/app-route-graph.js"; +import type { NextRewrite } from "../config/next-config.js"; /** * Generate the virtual browser entry module. @@ -17,6 +18,12 @@ export function generateBrowserEntry( routes: readonly AppRoute[] = [], routeManifest: RouteManifest | null = null, pagesPrefetchRoutes: readonly VinextPagesLinkPrefetchRoute[] = [], + rewrites: { afterFiles: NextRewrite[]; beforeFiles: NextRewrite[]; fallback: NextRewrite[] } = { + afterFiles: [], + beforeFiles: [], + fallback: [], + }, + hasMiddleware = false, ): string { const entryPath = resolveRuntimeEntryModule("app-browser-entry"); const navigationRuntimePath = resolveClientRuntimeModule("navigation-runtime"); @@ -32,6 +39,8 @@ window.__VINEXT_LINK_PREFETCH_ROUTES__ = ${JSON.stringify(prefetchRoutes)}; // entry must also expose the Pages manifest (the Pages client entry does // the same — whichever entry runs first emits both globals). window.__VINEXT_PAGES_LINK_PREFETCH_ROUTES__ = ${JSON.stringify(pagesPrefetchRoutes)}; +window.__VINEXT_CLIENT_REWRITES__ = ${JSON.stringify(rewrites)}; +window.__VINEXT_HAS_MIDDLEWARE__ = ${JSON.stringify(hasMiddleware)}; registerNavigationRuntimeBootstrap({ routeManifest: ${buildRouteManifestExpression(routeManifest)} }); diff --git a/packages/vinext/src/entries/pages-client-entry.ts b/packages/vinext/src/entries/pages-client-entry.ts index f30974e29..44a4a1deb 100644 --- a/packages/vinext/src/entries/pages-client-entry.ts +++ b/packages/vinext/src/entries/pages-client-entry.ts @@ -45,6 +45,7 @@ export async function generateClientEntry( fileMatcher: ReturnType, options: { appPrefetchRoutes?: readonly VinextLinkPrefetchRoute[]; + hasMiddleware?: boolean; instrumentationClientPath?: string | null; } = {}, ): Promise { @@ -155,6 +156,8 @@ window.__VINEXT_LINK_PREFETCH_ROUTES__ = ${JSON.stringify(appPrefetchRoutes)}; // instead of issuing an RSC request). Set here AND in app-browser-entry.ts // so whichever entry runs first emits the Pages manifest. window.__VINEXT_PAGES_LINK_PREFETCH_ROUTES__ = ${JSON.stringify(pagesPrefetchRoutes)}; +window.__VINEXT_CLIENT_REWRITES__ = ${JSON.stringify(nextConfig.rewrites)}; +window.__VINEXT_HAS_MIDDLEWARE__ = ${JSON.stringify(options.hasMiddleware ?? false)}; async function hydrate() { const nextData = window.__NEXT_DATA__; diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index c7ec7ec6a..81c0adab0 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -886,6 +886,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { : []; return _generateClientEntry(pagesDir, nextConfig, fileMatcher, { appPrefetchRoutes, + hasMiddleware: middlewarePath !== null, instrumentationClientPath, }); } @@ -2741,7 +2742,13 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { }), ) : []; - return generateBrowserEntry(graph.routes, graph.routeManifest, pagesPrefetchRoutes); + return generateBrowserEntry( + graph.routes, + graph.routeManifest, + pagesPrefetchRoutes, + nextConfig.rewrites, + middlewarePath !== null, + ); } if (id.startsWith(RESOLVED_VIRTUAL_GOOGLE_FONTS + "?")) { return generateGoogleFontsVirtualModule(id, _fontGoogleShimPath); diff --git a/packages/vinext/src/server/app-pages-bridge.ts b/packages/vinext/src/server/app-pages-bridge.ts index 8e47fa86b..d385d9f53 100644 --- a/packages/vinext/src/server/app-pages-bridge.ts +++ b/packages/vinext/src/server/app-pages-bridge.ts @@ -112,8 +112,10 @@ export async function renderPagesFallback( pagesRequest = new Request(request.url, pagesRequestInit); } - const pagesUrl = decodePathParams(pathname) + (pathname.includes("?") ? "" : url.search || ""); - const pagesPathname = pathname; + const queryIndex = pathname.indexOf("?"); + const pagesPathname = queryIndex === -1 ? pathname : pathname.slice(0, queryIndex); + const pagesSearch = queryIndex === -1 ? url.search || "" : pathname.slice(queryIndex); + const pagesUrl = decodePathParams(pagesPathname) + pagesSearch; if (pagesPathname.startsWith("/api/") || pagesPathname === "/api") { if (typeof pagesEntry.handleApiRoute !== "function") return null; const hasApiMatcher = typeof pagesEntry.matchApiRoute === "function"; diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index f0af6adaa..180972e9a 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -416,6 +416,7 @@ async function handleAppRscRequest( } = normalized; let { pathname, cleanPathname } = normalized; let resolvedUrl = cleanPathname + url.search; + const getResolvedSearchParams = () => new URL(resolvedUrl, url).searchParams; // Canonical (external) pathname the user requested. Middleware rewrites and // next.config.js rewrites mutate `cleanPathname` so internal route matching // can find the destination page, but hooks like `usePathname()` must reflect @@ -604,11 +605,12 @@ async function handleAppRscRequest( if (isRscRequest) { stripRscCacheBustingSearchParam(url); + resolvedUrl = cleanPathname + url.search; } options.setNavigationContext({ pathname: canonicalPathname, - searchParams: url.searchParams, + searchParams: getResolvedSearchParams(), params: {}, }); @@ -661,7 +663,7 @@ async function handleAppRscRequest( middlewareContext, mountedSlotsHeader, request, - searchParams: url.searchParams, + searchParams: getResolvedSearchParams(), }); if (serverActionResponse) return serverActionResponse; @@ -794,15 +796,18 @@ async function handleAppRscRequest( ? prerenderRouteParamsPayload.params : null; const renderParams = prerenderRouteParams ?? params; + const resolvedSearchParams = getResolvedSearchParams(); options.setNavigationContext({ pathname: canonicalPathname, - searchParams: url.searchParams, + searchParams: resolvedSearchParams, params: renderParams, }); const rootParams = pickRootParams(renderParams, route.rootParamNames); setRootParams(rootParams); if (route.routeHandler) { + const routeHandlerUrl = new URL(request.url); + routeHandlerUrl.search = resolvedSearchParams.toString(); setCurrentFetchSoftTags( buildPageCacheTags(cleanPathname, [], [...route.routeSegments], "route"), ); @@ -814,9 +819,9 @@ async function handleAppRscRequest( // object (always `{}` for non-dynamic) so `useParams()` etc. still see // an object shape; only the user-facing handler context surfaces null. params: route.isDynamic ? renderParams : null, - request, + request: new Request(routeHandlerUrl, request), route, - searchParams: url.searchParams, + searchParams: resolvedSearchParams, }); } @@ -839,7 +844,7 @@ async function handleAppRscRequest( request, route, scriptNonce, - searchParams: url.searchParams, + searchParams: resolvedSearchParams, renderMode, }); diff --git a/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts b/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts index 39afd16cd..3c446b6e2 100644 --- a/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts +++ b/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts @@ -16,6 +16,13 @@ */ import { createRouteTrieCache, matchRouteWithTrie } from "../../routing/route-matching.js"; import { compareHybridRoutePatterns } from "../../routing/utils.js"; +import { + isExternalUrl, + matchConfigPattern, + matchRewrite, + type RequestContext, +} from "../../config/config-matchers.js"; +import type { NextRewrite } from "../../config/next-config.js"; import { stripBasePath } from "../../utils/base-path.js"; import { getLocalePathPrefix } from "../../utils/domain-locale.js"; import type { @@ -23,16 +30,61 @@ import type { VinextPagesLinkPrefetchRoute, } from "../../client/vinext-next-data.js"; -export type HybridClientOwner = "app" | "pages"; +export type HybridClientOwner = "app" | "document" | "pages"; declare global { // oxlint-disable-next-line typescript-eslint/consistent-type-definitions interface Window { __VINEXT_LINK_PREFETCH_ROUTES__?: VinextLinkPrefetchRoute[]; __VINEXT_PAGES_LINK_PREFETCH_ROUTES__?: VinextPagesLinkPrefetchRoute[]; + __VINEXT_CLIENT_REWRITES__?: { + afterFiles: NextRewrite[]; + beforeFiles: NextRewrite[]; + fallback: NextRewrite[]; + }; + __VINEXT_HAS_MIDDLEWARE__?: boolean; } } +function resolveClientRewrite( + href: string, + basePath: string, + rewrites: readonly NextRewrite[], +): { kind: "document" } | { href: string; kind: "rewrite" } | null { + const pathname = resolveSameOriginPathname(href, basePath); + if (pathname === null) return null; + const url = new URL(href, window.location.href); + if ( + rewrites.some( + (rewrite) => + (rewrite.has?.length || rewrite.missing?.length) && + matchConfigPattern(pathname, rewrite.source) !== null, + ) + ) { + return { kind: "document" }; + } + const context: RequestContext = { + cookies: {}, + headers: new Headers(), + host: url.hostname, + query: url.searchParams, + }; + const rewritten = matchRewrite( + pathname, + rewrites.filter((rewrite) => !rewrite.has?.length && !rewrite.missing?.length), + context, + { + basePath, + hadBasePath: basePath + ? url.pathname === basePath || url.pathname.startsWith(`${basePath}/`) + : true, + }, + ); + if (rewritten === null) return null; + if (isExternalUrl(rewritten)) return { kind: "document" }; + return { href: rewritten, kind: "rewrite" }; +} + const appRouteTrieCache = createRouteTrieCache(); const pagesRouteTrieCache = createRouteTrieCache(); @@ -110,9 +162,43 @@ export function resolveHybridClientRouteOwner( const appRoutes = window.__VINEXT_LINK_PREFETCH_ROUTES__; const pagesRoutes = window.__VINEXT_PAGES_LINK_PREFETCH_ROUTES__; + const rewrites = window.__VINEXT_CLIENT_REWRITES__; - const appMatch = appRoutes ? matchAppRoute(href, basePath, appRoutes) : null; - const pagesMatch = pagesRoutes ? matchPagesRoute(href, basePath, pagesRoutes) : null; + if (window.__VINEXT_HAS_MIDDLEWARE__) return "document"; + + if (rewrites) { + const beforeFilesRewrite = resolveClientRewrite(href, basePath, rewrites.beforeFiles); + if (beforeFilesRewrite?.kind === "document") return "document"; + if (beforeFilesRewrite?.kind === "rewrite") href = beforeFilesRewrite.href; + } + + let appMatch = appRoutes ? matchAppRoute(href, basePath, appRoutes) : null; + let pagesMatch = pagesRoutes ? matchPagesRoute(href, basePath, pagesRoutes) : null; + + if ( + rewrites && + (appMatch === null || appMatch.isDynamic) && + (pagesMatch === null || pagesMatch.isDynamic) + ) { + const afterFilesRewrite = resolveClientRewrite(href, basePath, rewrites.afterFiles); + if (afterFilesRewrite?.kind === "document") return "document"; + if (afterFilesRewrite?.kind === "rewrite") { + href = afterFilesRewrite.href; + appMatch = appRoutes ? matchAppRoute(href, basePath, appRoutes) : null; + pagesMatch = pagesRoutes ? matchPagesRoute(href, basePath, pagesRoutes) : null; + } + } + + if (rewrites && appMatch === null && pagesMatch === null) { + const fallbackRewrite = resolveClientRewrite(href, basePath, rewrites.fallback); + if (fallbackRewrite?.kind === "document") return "document"; + if (fallbackRewrite?.kind === "rewrite") { + appMatch = appRoutes ? matchAppRoute(fallbackRewrite.href, basePath, appRoutes) : null; + pagesMatch = pagesRoutes + ? matchPagesRoute(fallbackRewrite.href, basePath, pagesRoutes) + : null; + } + } if (appMatch === null && pagesMatch === null) return null; if (pagesMatch === null) return "app"; diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index 6a3cc0491..1ca11c2bd 100644 --- a/packages/vinext/src/shims/link.tsx +++ b/packages/vinext/src/shims/link.tsx @@ -398,7 +398,8 @@ function prefetchUrl(href: string, mode: LinkPrefetchMode, priority: "low" | "hi // stream is never consumed (the click path now hard-navigates to // Pages) and would also race the request the browser will issue on // the actual navigation. - if (resolveHybridClientRouteOwner(prefetchHref, __basePath) === "pages") { + const hybridOwner = resolveHybridClientRouteOwner(prefetchHref, __basePath); + if (hybridOwner === "pages" || hybridOwner === "document") { return; } const autoPrefetch = @@ -1001,7 +1002,7 @@ const Link = forwardRef(function Link( // and a stale `useLinkStatus` indicator at worst. if ( getNavigationRuntime()?.functions.navigate && - resolveHybridClientRouteOwner(navigateHref, __basePath) === "pages" + ["pages", "document"].includes(resolveHybridClientRouteOwner(navigateHref, __basePath) ?? "") ) { if (replace) { window.location.replace(absoluteFullHref); diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index daf3bdc00..35c3d6ac6 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -1785,7 +1785,8 @@ export async function navigateClientSide( // requests) or render the App catch-all's path array. This is the // programmatic equivalent of the link click / prefetch check in // `link.tsx`. - if (resolveHybridClientRouteOwner(normalizedHref, __basePath) === "pages") { + const hybridOwner = resolveHybridClientRouteOwner(normalizedHref, __basePath); + if (hybridOwner === "pages" || hybridOwner === "document") { const fullHref = toBrowserNavigationHref(normalizedHref, window.location.href, __basePath); notifyAppRouterTransitionStart(fullHref, mode); if (mode === "push") { @@ -2026,7 +2027,8 @@ const _appRouter: AppRouterInstance = { // an unusable cache entry. The matching `push`/`replace` call will // hard-navigate via `window.location`, so a no-op here is correct — // the document prefetch the link shim emits on hover still runs. - if (resolveHybridClientRouteOwner(prefetchHref, __basePath) === "pages") { + const hybridOwner = resolveHybridClientRouteOwner(prefetchHref, __basePath); + if (hybridOwner === "pages" || hybridOwner === "document") { return; } diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index 0e5afc335..6d33f9098 100644 --- a/packages/vinext/src/shims/router.ts +++ b/packages/vinext/src/shims/router.ts @@ -2079,7 +2079,7 @@ async function performNavigation( appPathNorm !== null ? getPagesRouterComponentsMap()[appPathNorm] : undefined; const appRouteDetected = (appPathEntry !== undefined && "__appRouter" in appPathEntry && appPathEntry.__appRouter) || - resolveHybridClientRouteOwner(resolved, __basePath) === "app"; + ["app", "document"].includes(resolveHybridClientRouteOwner(resolved, __basePath) ?? ""); if (appRouteDetected) { if (mode === "push") window.location.assign(full); else window.location.replace(full); diff --git a/tests/app-pages-bridge.test.ts b/tests/app-pages-bridge.test.ts index 8b0532f6a..80dc60351 100644 --- a/tests/app-pages-bridge.test.ts +++ b/tests/app-pages-bridge.test.ts @@ -278,6 +278,64 @@ describe("renderPagesFallback", () => { expect(handleApiRoute).toHaveBeenCalledTimes(1); }); + it("decodes rewritten page paths without decoding their query", async () => { + const matchPageRoute = vi.fn(() => ({ + route: { isDynamic: false, pattern: "/café" }, + })); + const renderPage = vi.fn(() => new Response("page")); + const request = new Request("http://localhost/legacy?original=1"); + const url = new URL(request.url); + + await renderPagesFallback( + { + isRscRequest: false, + middlewareContext: { headers: null, requestHeaders: null, status: null }, + pathname: "/caf%C3%A9?value=hello%20world", + request, + url, + }, + { + ...defaultDeps, + loadPagesEntry: () => ({ matchPageRoute, renderPage }), + }, + ); + + expect(matchPageRoute).toHaveBeenCalledWith("/café?value=hello%20world", request); + expect(renderPage).toHaveBeenCalledWith( + request, + "/café?value=hello%20world", + {}, + undefined, + null, + ); + }); + + it("decodes rewritten API paths without decoding their query", async () => { + const matchApiRoute = vi.fn(() => ({ + route: { isDynamic: false, pattern: "/api/café" }, + })); + const handleApiRoute = vi.fn(() => new Response("api")); + const request = new Request("http://localhost/api/legacy?original=1"); + const url = new URL(request.url); + + await renderPagesFallback( + { + isRscRequest: false, + middlewareContext: { headers: null, requestHeaders: null, status: null }, + pathname: "/api/caf%C3%A9?value=hello%20world", + request, + url, + }, + { + ...defaultDeps, + loadPagesEntry: () => ({ handleApiRoute, matchApiRoute }), + }, + ); + + expect(matchApiRoute).toHaveBeenCalledWith("/api/café?value=hello%20world", request); + expect(handleApiRoute).toHaveBeenCalledWith(request, "/api/café?value=hello%20world"); + }); + it("appends the middleware draft cookie to an API fallback response (#1520)", async () => { const handleApiRoute = vi.fn((_req: Request, _url: string) => new Response("api-response")); const deps = { diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index d9ace4af3..5905274f4 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -870,7 +870,10 @@ describe("createAppRscHandler", () => { } : null, ); - const dispatchMatchedPage = vi.fn(async () => new Response("rewritten", { status: 200 })); + const dispatchMatchedPage = vi.fn( + async (_options: Parameters[0]) => + new Response("rewritten", { status: 200 }), + ); const handler = createHandler({ configHeaders: [], configRewrites: { @@ -892,6 +895,78 @@ describe("createAppRscHandler", () => { ); }); + it("propagates rewritten query parameters to App pages", async () => { + const setNavigationContext = vi.fn(); + let pageOptions: Parameters[0] | undefined; + const dispatchMatchedPage = vi.fn( + async (options: Parameters[0]) => { + pageOptions = options; + return new Response("rewritten", { status: 200 }); + }, + ); + const handler = createHandler({ + configHeaders: [], + configRewrites: { + beforeFiles: [{ source: "/legacy", destination: "/about?destination=2&same=new" }], + afterFiles: [], + fallback: [], + }, + dispatchMatchedPage, + setNavigationContext, + }); + + await handler(new Request("https://example.test/docs/legacy?original=1&same=old"), null); + + expect(Object.fromEntries(pageOptions!.searchParams)).toEqual({ + destination: "2", + original: "1", + same: "new", + }); + expect(Object.fromEntries(setNavigationContext.mock.lastCall![0].searchParams)).toEqual({ + destination: "2", + original: "1", + same: "new", + }); + }); + + it("propagates rewritten query parameters to App route handlers", async () => { + const route = createPageRoute({ + page: null, + pattern: "/api/static", + routeHandler: { GET: () => new Response("route") }, + routeSegments: ["api", "static"], + }); + const dispatchMatchedRouteHandler = vi.fn( + async (_options: Parameters[0]) => + new Response("route"), + ); + const handler = createHandler({ + configHeaders: [], + configRewrites: { + beforeFiles: [{ source: "/legacy", destination: "/api/static?destination=2&same=new" }], + afterFiles: [], + fallback: [], + }, + dispatchMatchedRouteHandler, + matchRoute: (pathname) => (pathname === "/api/static" ? { params: {}, route } : null), + }); + + await handler(new Request("https://example.test/docs/legacy?original=1&same=old"), null); + + const routeHandlerOptions = dispatchMatchedRouteHandler.mock.lastCall?.[0]; + expect(Object.fromEntries(routeHandlerOptions!.searchParams)).toEqual({ + destination: "2", + original: "1", + same: "new", + }); + expect(new URL(routeHandlerOptions!.request.url).pathname).toBe("/docs/legacy"); + expect(Object.fromEntries(new URL(routeHandlerOptions!.request.url).searchParams)).toEqual({ + destination: "2", + original: "1", + same: "new", + }); + }); + it("does not let afterFiles rewrites override non-dynamic app routes", async () => { const routes = { "/about": createPageRoute({ pattern: "/about", routeSegments: ["about"] }), diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index a0bba00e4..4aed44e64 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -123,6 +123,17 @@ const minimalAppRoutes: AppRoute[] = [ // ── App Router manifest construction ───────────────────────────────── describe("App Router generated manifest construction", () => { + it("embeds client rewrite rules in the App browser entry", () => { + const code = generateBrowserEntry([], null, [], { + afterFiles: [], + beforeFiles: [{ source: "/legacy", destination: "/about" }], + fallback: [], + }); + + expect(code).toContain('window.__VINEXT_CLIENT_REWRITES__ = {"afterFiles":[],"beforeFiles"'); + expect(code).toContain('"source":"/legacy","destination":"/about"'); + }); + it("embeds the Link auto-prefetch route manifest in the browser entry", () => { const code = generateBrowserEntry([ ...minimalAppRoutes, diff --git a/tests/hybrid-client-route-owner.test.ts b/tests/hybrid-client-route-owner.test.ts index de5ed5cd7..a0da8ebbd 100644 --- a/tests/hybrid-client-route-owner.test.ts +++ b/tests/hybrid-client-route-owner.test.ts @@ -22,6 +22,7 @@ import type { VinextLinkPrefetchRoute, VinextPagesLinkPrefetchRoute, } from "../packages/vinext/src/client/vinext-next-data.js"; +import type { NextRewrite } from "../packages/vinext/src/config/next-config.js"; import { resolveHybridClientRouteOwner } from "../packages/vinext/src/shims/internal/hybrid-client-route-owner.js"; const APP_BASE = "http://localhost/"; @@ -29,13 +30,19 @@ const APP_BASE = "http://localhost/"; type WindowState = { app: VinextLinkPrefetchRoute[]; pages: VinextPagesLinkPrefetchRoute[]; + rewrites?: { + afterFiles: NextRewrite[]; + beforeFiles: NextRewrite[]; + fallback: NextRewrite[]; + }; }; -function installWindow({ app, pages }: WindowState): void { +function installWindow({ app, pages, rewrites }: WindowState): void { (globalThis as any).window = { location: { href: APP_BASE, origin: "http://localhost" }, __VINEXT_LINK_PREFETCH_ROUTES__: app, __VINEXT_PAGES_LINK_PREFETCH_ROUTES__: pages, + __VINEXT_CLIENT_REWRITES__: rewrites, }; } @@ -97,6 +104,59 @@ describe("resolveHybridClientRouteOwner", () => { expect(resolveHybridClientRouteOwner("/b/foobar", "")).toBe("pages"); }); + it.each(["beforeFiles", "afterFiles", "fallback"] as const)( + "resolves %s rewrites before choosing the route owner", + (rewritePhase) => { + installWindow({ + app: [appRoute(["app-destination"], false)], + pages: [pagesRoute(["pages-destination"], false)], + rewrites: { + beforeFiles: + rewritePhase === "beforeFiles" + ? [{ source: "/source", destination: "/app-destination" }] + : [], + afterFiles: + rewritePhase === "afterFiles" + ? [{ source: "/source", destination: "/app-destination" }] + : [], + fallback: + rewritePhase === "fallback" + ? [{ source: "/source", destination: "/app-destination" }] + : [], + }, + }); + + expect(resolveHybridClientRouteOwner("/source", "")).toBe("app"); + }, + ); + + it("does not guess ownership for conditional client rewrites", () => { + installWindow({ + app: [appRoute(["app-destination"], false)], + pages: [], + rewrites: { + afterFiles: [], + beforeFiles: [ + { + source: "/source", + destination: "/app-destination", + has: [{ type: "header", key: "x-test", value: "1" }], + }, + ], + fallback: [], + }, + }); + + expect(resolveHybridClientRouteOwner("/source", "")).toBe("document"); + }); + + it("defers middleware-owned navigation to a document request", () => { + installWindow({ app: [appRoute(["source"], false)], pages: [] }); + (globalThis as any).window.__VINEXT_HAS_MIDDLEWARE__ = true; + + expect(resolveHybridClientRouteOwner("/source", "")).toBe("document"); + }); + it("lets a more specific Pages dynamic route beat an App root catch-all", () => { // Mirrors the server test of the same name. /pages-dir/:dynamic // (score 51) beats /:path+ (score 1000). From 15257ea8c80000a2ec6ec1698b401b35d5c58258 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 18:55:40 +0100 Subject: [PATCH 13/23] fix(router): resolve client rewrites sequentially --- .../vinext/src/entries/app-browser-entry.ts | 2 - .../vinext/src/entries/pages-client-entry.ts | 2 - packages/vinext/src/index.ts | 2 - packages/vinext/src/server/app-rsc-handler.ts | 5 +- .../internal/hybrid-client-route-owner.ts | 69 +++++++++---------- tests/app-rsc-handler.test.ts | 51 ++++++++++++++ tests/hybrid-client-route-owner.test.ts | 25 +++++-- 7 files changed, 106 insertions(+), 50 deletions(-) diff --git a/packages/vinext/src/entries/app-browser-entry.ts b/packages/vinext/src/entries/app-browser-entry.ts index 6c18dd296..2403dcb9f 100644 --- a/packages/vinext/src/entries/app-browser-entry.ts +++ b/packages/vinext/src/entries/app-browser-entry.ts @@ -23,7 +23,6 @@ export function generateBrowserEntry( beforeFiles: [], fallback: [], }, - hasMiddleware = false, ): string { const entryPath = resolveRuntimeEntryModule("app-browser-entry"); const navigationRuntimePath = resolveClientRuntimeModule("navigation-runtime"); @@ -40,7 +39,6 @@ window.__VINEXT_LINK_PREFETCH_ROUTES__ = ${JSON.stringify(prefetchRoutes)}; // the same — whichever entry runs first emits both globals). window.__VINEXT_PAGES_LINK_PREFETCH_ROUTES__ = ${JSON.stringify(pagesPrefetchRoutes)}; window.__VINEXT_CLIENT_REWRITES__ = ${JSON.stringify(rewrites)}; -window.__VINEXT_HAS_MIDDLEWARE__ = ${JSON.stringify(hasMiddleware)}; registerNavigationRuntimeBootstrap({ routeManifest: ${buildRouteManifestExpression(routeManifest)} }); diff --git a/packages/vinext/src/entries/pages-client-entry.ts b/packages/vinext/src/entries/pages-client-entry.ts index 44a4a1deb..6a6091c91 100644 --- a/packages/vinext/src/entries/pages-client-entry.ts +++ b/packages/vinext/src/entries/pages-client-entry.ts @@ -45,7 +45,6 @@ export async function generateClientEntry( fileMatcher: ReturnType, options: { appPrefetchRoutes?: readonly VinextLinkPrefetchRoute[]; - hasMiddleware?: boolean; instrumentationClientPath?: string | null; } = {}, ): Promise { @@ -157,7 +156,6 @@ window.__VINEXT_LINK_PREFETCH_ROUTES__ = ${JSON.stringify(appPrefetchRoutes)}; // so whichever entry runs first emits the Pages manifest. window.__VINEXT_PAGES_LINK_PREFETCH_ROUTES__ = ${JSON.stringify(pagesPrefetchRoutes)}; window.__VINEXT_CLIENT_REWRITES__ = ${JSON.stringify(nextConfig.rewrites)}; -window.__VINEXT_HAS_MIDDLEWARE__ = ${JSON.stringify(options.hasMiddleware ?? false)}; async function hydrate() { const nextData = window.__NEXT_DATA__; diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 81c0adab0..cd11db740 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -886,7 +886,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { : []; return _generateClientEntry(pagesDir, nextConfig, fileMatcher, { appPrefetchRoutes, - hasMiddleware: middlewarePath !== null, instrumentationClientPath, }); } @@ -2747,7 +2746,6 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { graph.routeManifest, pagesPrefetchRoutes, nextConfig.rewrites, - middlewarePath !== null, ); } if (id.startsWith(RESOLVED_VIRTUAL_GOOGLE_FONTS + "?")) { diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 180972e9a..7346f91ca 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -532,6 +532,7 @@ async function handleAppRscRequest( if (middlewareResult.search !== null) { url.search = middlewareResult.search; } + resolvedUrl = cleanPathname + url.search; } const scriptNonce = getScriptNonceFromHeaderSources(request.headers, middlewareContext.headers); @@ -605,7 +606,9 @@ async function handleAppRscRequest( if (isRscRequest) { stripRscCacheBustingSearchParam(url); - resolvedUrl = cleanPathname + url.search; + const resolved = new URL(resolvedUrl, url); + stripRscCacheBustingSearchParam(resolved); + resolvedUrl = resolved.pathname + resolved.search; } options.setNavigationContext({ diff --git a/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts b/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts index 3c446b6e2..5e6ae75a4 100644 --- a/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts +++ b/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts @@ -18,13 +18,14 @@ import { createRouteTrieCache, matchRouteWithTrie } from "../../routing/route-ma import { compareHybridRoutePatterns } from "../../routing/utils.js"; import { isExternalUrl, - matchConfigPattern, matchRewrite, + parseCookies, type RequestContext, } from "../../config/config-matchers.js"; import type { NextRewrite } from "../../config/next-config.js"; import { stripBasePath } from "../../utils/base-path.js"; import { getLocalePathPrefix } from "../../utils/domain-locale.js"; +import { mergeRewriteQuery } from "../../utils/query.js"; import type { VinextLinkPrefetchRoute, VinextPagesLinkPrefetchRoute, @@ -42,7 +43,6 @@ declare global { beforeFiles: NextRewrite[]; fallback: NextRewrite[]; }; - __VINEXT_HAS_MIDDLEWARE__?: boolean; } } @@ -50,39 +50,38 @@ function resolveClientRewrite( href: string, basePath: string, rewrites: readonly NextRewrite[], + continueAfterMatch = false, ): { kind: "document" } | { href: string; kind: "rewrite" } | null { - const pathname = resolveSameOriginPathname(href, basePath); - if (pathname === null) return null; - const url = new URL(href, window.location.href); - if ( - rewrites.some( - (rewrite) => - (rewrite.has?.length || rewrite.missing?.length) && - matchConfigPattern(pathname, rewrite.source) !== null, - ) - ) { - return { kind: "document" }; - } - const context: RequestContext = { - cookies: {}, - headers: new Headers(), - host: url.hostname, - query: url.searchParams, + const initialUrl = new URL(href, window.location.href); + const basePathState = { + basePath, + hadBasePath: basePath + ? initialUrl.pathname === basePath || initialUrl.pathname.startsWith(`${basePath}/`) + : true, }; - const rewritten = matchRewrite( - pathname, - rewrites.filter((rewrite) => !rewrite.has?.length && !rewrite.missing?.length), - context, - { - basePath, - hadBasePath: basePath - ? url.pathname === basePath || url.pathname.startsWith(`${basePath}/`) - : true, - }, - ); - if (rewritten === null) return null; - if (isExternalUrl(rewritten)) return { kind: "document" }; - return { href: rewritten, kind: "rewrite" }; + let currentHref = href; + let matched = false; + + for (const rewrite of rewrites) { + const pathname = resolveSameOriginPathname(currentHref, basePath); + if (pathname === null) return null; + const url = new URL(currentHref, window.location.href); + const headers = new Headers({ "user-agent": globalThis.navigator?.userAgent ?? "" }); + const context: RequestContext = { + cookies: parseCookies(globalThis.document?.cookie ?? ""), + headers, + host: url.hostname, + query: url.searchParams, + }; + const rewritten = matchRewrite(pathname, [rewrite], context, basePathState); + if (rewritten === null) continue; + if (isExternalUrl(rewritten)) return { kind: "document" }; + currentHref = mergeRewriteQuery(currentHref, rewritten); + matched = true; + if (!continueAfterMatch) break; + } + + return matched ? { href: currentHref, kind: "rewrite" } : null; } const appRouteTrieCache = createRouteTrieCache(); @@ -164,10 +163,8 @@ export function resolveHybridClientRouteOwner( const pagesRoutes = window.__VINEXT_PAGES_LINK_PREFETCH_ROUTES__; const rewrites = window.__VINEXT_CLIENT_REWRITES__; - if (window.__VINEXT_HAS_MIDDLEWARE__) return "document"; - if (rewrites) { - const beforeFilesRewrite = resolveClientRewrite(href, basePath, rewrites.beforeFiles); + const beforeFilesRewrite = resolveClientRewrite(href, basePath, rewrites.beforeFiles, true); if (beforeFilesRewrite?.kind === "document") return "document"; if (beforeFilesRewrite?.kind === "rewrite") href = beforeFilesRewrite.href; } diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index 5905274f4..d1c8b7970 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -591,6 +591,32 @@ describe("createAppRscHandler", () => { expect(dispatchMatchedPage).not.toHaveBeenCalled(); }); + it("propagates middleware rewrite query parameters to App pages", async () => { + let pageOptions: Parameters[0] | undefined; + const handler = createHandler({ + configHeaders: [], + dispatchMatchedPage: async (options) => { + pageOptions = options; + return new Response("page"); + }, + middlewareModule: { + default: () => + new Response(null, { + headers: { + "x-middleware-rewrite": "https://example.test/docs/about?destination=2&same=new", + }, + }), + }, + }); + + await handler(new Request("https://example.test/docs/source?original=1&same=old"), null); + + expect(Object.fromEntries(pageOptions!.searchParams)).toEqual({ + destination: "2", + same: "new", + }); + }); + it("does not duplicate additive config headers on non-redirect middleware responses", async () => { const handler = createHandler({ configHeaders: [ @@ -861,6 +887,31 @@ describe("createAppRscHandler", () => { expect(context?.searchParams.has("_rsc")).toBe(false); }); + it("preserves beforeFiles destination query while stripping the RSC cache key", async () => { + const headers = createRscRequestHeaders(); + const rscUrl = await createRscRequestUrl("/docs/legacy?original=1", headers); + let pageOptions: Parameters[0] | undefined; + const handler = createHandler({ + configHeaders: [], + configRewrites: { + beforeFiles: [{ source: "/legacy", destination: "/about?destination=2" }], + afterFiles: [], + fallback: [], + }, + dispatchMatchedPage: async (options) => { + pageOptions = options; + return new Response("page"); + }, + }); + + await handler(new Request(`https://example.test${rscUrl}`, { headers }), null); + + expect(Object.fromEntries(pageOptions!.searchParams)).toEqual({ + destination: "2", + original: "1", + }); + }); + it("runs beforeFiles rewrites before route matching", async () => { const matchRoute = vi.fn((pathname: string) => pathname === "/about" diff --git a/tests/hybrid-client-route-owner.test.ts b/tests/hybrid-client-route-owner.test.ts index a0da8ebbd..430737bf1 100644 --- a/tests/hybrid-client-route-owner.test.ts +++ b/tests/hybrid-client-route-owner.test.ts @@ -130,7 +130,7 @@ describe("resolveHybridClientRouteOwner", () => { }, ); - it("does not guess ownership for conditional client rewrites", () => { + it("evaluates conditional client rewrites against browser-visible context", () => { installWindow({ app: [appRoute(["app-destination"], false)], pages: [], @@ -140,21 +140,32 @@ describe("resolveHybridClientRouteOwner", () => { { source: "/source", destination: "/app-destination", - has: [{ type: "header", key: "x-test", value: "1" }], + has: [{ type: "query", key: "preview", value: "1" }], }, ], fallback: [], }, }); - expect(resolveHybridClientRouteOwner("/source", "")).toBe("document"); + expect(resolveHybridClientRouteOwner("/source", "")).toBeNull(); + expect(resolveHybridClientRouteOwner("/source?preview=1", "")).toBe("app"); }); - it("defers middleware-owned navigation to a document request", () => { - installWindow({ app: [appRoute(["source"], false)], pages: [] }); - (globalThis as any).window.__VINEXT_HAS_MIDDLEWARE__ = true; + it("applies every beforeFiles rewrite before choosing ownership", () => { + installWindow({ + app: [], + pages: [pagesRoute(["pages-destination"], false)], + rewrites: { + afterFiles: [], + beforeFiles: [ + { source: "/source", destination: "/intermediate?first=1" }, + { source: "/intermediate", destination: "/pages-destination?second=2" }, + ], + fallback: [], + }, + }); - expect(resolveHybridClientRouteOwner("/source", "")).toBe("document"); + expect(resolveHybridClientRouteOwner("/source?original=1", "")).toBe("pages"); }); it("lets a more specific Pages dynamic route beat an App root catch-all", () => { From a937f0578136bfaa67e450772701ac3c2085983f Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 19:17:37 +0100 Subject: [PATCH 14/23] fix(router): apply rewrite phases sequentially --- packages/vinext/src/server/app-rsc-handler.ts | 133 +++++++++++------- .../internal/hybrid-client-route-owner.ts | 23 +-- tests/app-rsc-handler.test.ts | 100 +++++++++++++ tests/hybrid-client-route-owner.test.ts | 29 ++++ 4 files changed, 221 insertions(+), 64 deletions(-) diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 7346f91ca..58ea4172b 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -335,6 +335,19 @@ async function applyRewrite( return rewritten; } +function requestContextForResolvedUrl( + requestContext: RequestContext, + resolvedUrl: string, + baseUrl: URL, +): RequestContext { + return { + cookies: requestContext.cookies, + headers: requestContext.headers, + host: requestContext.host, + query: new URL(resolvedUrl, baseUrl).searchParams, + }; +} + function applyConfigHeadersToMiddlewareRedirect( response: Response, options: { @@ -543,22 +556,26 @@ async function handleAppRscRequest( // itself continues to use the un-prefixed `cleanPathname` because App // Router files live under `app/...` with no locale segment. See issue // #1336 item 4 / pages-i18n.normalizeDefaultLocalePathname. - const beforeFilesRewrite = await applyRewrite( - { - basePathState, - clearRequestContext: options.clearRequestContext, - // Forward the `_rsc`-stripped request so external rewrite proxies never - // receive the internal RSC transport query (same invariant as middleware). - request: userlandRequest, - requestContext: postMiddlewareRequestContext, - rewrites: options.configRewrites.beforeFiles, - }, - matchPathname(cleanPathname), - ); - if (beforeFilesRewrite instanceof Response) return beforeFilesRewrite; - if (beforeFilesRewrite) { - resolvedUrl = mergeRewriteQuery(resolvedUrl, beforeFilesRewrite); - cleanPathname = resolvedUrl.split("?", 1)[0]; + for (const rewrite of options.configRewrites.beforeFiles) { + const beforeFilesRewrite = await applyRewrite( + { + basePathState, + clearRequestContext: options.clearRequestContext, + request: userlandRequest, + requestContext: requestContextForResolvedUrl( + postMiddlewareRequestContext, + resolvedUrl, + url, + ), + rewrites: [rewrite], + }, + matchPathname(cleanPathname), + ); + if (beforeFilesRewrite instanceof Response) return beforeFilesRewrite; + if (beforeFilesRewrite) { + resolvedUrl = mergeRewriteQuery(resolvedUrl, beforeFilesRewrite); + cleanPathname = resolvedUrl.split("?", 1)[0]; + } } if (isImageOptimizationPath(cleanPathname)) { @@ -690,34 +707,38 @@ async function handleAppRscRequest( options.clearRequestContext(); return staticPagesFallbackResponse; } - let didAfterFilesRewrite = false; if (!match || match.route.isDynamic) { - const afterFilesRewrite = await applyRewrite( - { - basePathState, - clearRequestContext: options.clearRequestContext, - // Forward the `_rsc`-stripped request so external rewrite proxies never - // receive the internal RSC transport query (same invariant as middleware). - request: userlandRequest, - requestContext: postMiddlewareRequestContext, - rewrites: options.configRewrites.afterFiles, - }, - matchPathname(cleanPathname), - ); - if (afterFilesRewrite instanceof Response) return afterFilesRewrite; - if (afterFilesRewrite) { + for (const rewrite of options.configRewrites.afterFiles) { + const afterFilesRewrite = await applyRewrite( + { + basePathState, + clearRequestContext: options.clearRequestContext, + request: userlandRequest, + requestContext: requestContextForResolvedUrl( + postMiddlewareRequestContext, + resolvedUrl, + url, + ), + rewrites: [rewrite], + }, + matchPathname(cleanPathname), + ); + if (afterFilesRewrite instanceof Response) return afterFilesRewrite; + if (!afterFilesRewrite) continue; resolvedUrl = mergeRewriteQuery(resolvedUrl, afterFilesRewrite); cleanPathname = resolvedUrl.split("?", 1)[0]; match = options.matchRoute(cleanPathname); - didAfterFilesRewrite = true; - } - } - - if (didAfterFilesRewrite) { - const rewrittenStaticPagesResponse = await renderPagesForMatchKind("static"); - if (rewrittenStaticPagesResponse) { - options.clearRequestContext(); - return rewrittenStaticPagesResponse; + const rewrittenStaticPagesResponse = await renderPagesForMatchKind("static"); + if (rewrittenStaticPagesResponse) { + options.clearRequestContext(); + return rewrittenStaticPagesResponse; + } + const rewrittenDynamicPagesResponse = await renderPagesForMatchKind("dynamic"); + if (rewrittenDynamicPagesResponse) { + options.clearRequestContext(); + return rewrittenDynamicPagesResponse; + } + if (match) break; } } @@ -728,20 +749,23 @@ async function handleAppRscRequest( } if (!match) { - const fallbackRewrite = await applyRewrite( - { - basePathState, - clearRequestContext: options.clearRequestContext, - // Forward the `_rsc`-stripped request so external rewrite proxies never - // receive the internal RSC transport query (same invariant as middleware). - request: userlandRequest, - requestContext: postMiddlewareRequestContext, - rewrites: options.configRewrites.fallback, - }, - matchPathname(cleanPathname), - ); - if (fallbackRewrite instanceof Response) return fallbackRewrite; - if (fallbackRewrite) { + for (const rewrite of options.configRewrites.fallback) { + const fallbackRewrite = await applyRewrite( + { + basePathState, + clearRequestContext: options.clearRequestContext, + request: userlandRequest, + requestContext: requestContextForResolvedUrl( + postMiddlewareRequestContext, + resolvedUrl, + url, + ), + rewrites: [rewrite], + }, + matchPathname(cleanPathname), + ); + if (fallbackRewrite instanceof Response) return fallbackRewrite; + if (!fallbackRewrite) continue; resolvedUrl = mergeRewriteQuery(resolvedUrl, fallbackRewrite); cleanPathname = resolvedUrl.split("?", 1)[0]; match = options.matchRoute(cleanPathname); @@ -755,6 +779,7 @@ async function handleAppRscRequest( options.clearRequestContext(); return rewrittenDynamicPagesResponse; } + if (match) break; } } diff --git a/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts b/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts index 5e6ae75a4..6f1bfd7ed 100644 --- a/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts +++ b/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts @@ -177,23 +177,26 @@ export function resolveHybridClientRouteOwner( (appMatch === null || appMatch.isDynamic) && (pagesMatch === null || pagesMatch.isDynamic) ) { - const afterFilesRewrite = resolveClientRewrite(href, basePath, rewrites.afterFiles); - if (afterFilesRewrite?.kind === "document") return "document"; - if (afterFilesRewrite?.kind === "rewrite") { + for (const rewrite of rewrites.afterFiles) { + const afterFilesRewrite = resolveClientRewrite(href, basePath, [rewrite]); + if (afterFilesRewrite?.kind === "document") return "document"; + if (afterFilesRewrite?.kind !== "rewrite") continue; href = afterFilesRewrite.href; appMatch = appRoutes ? matchAppRoute(href, basePath, appRoutes) : null; pagesMatch = pagesRoutes ? matchPagesRoute(href, basePath, pagesRoutes) : null; + if (appMatch || pagesMatch) break; } } if (rewrites && appMatch === null && pagesMatch === null) { - const fallbackRewrite = resolveClientRewrite(href, basePath, rewrites.fallback); - if (fallbackRewrite?.kind === "document") return "document"; - if (fallbackRewrite?.kind === "rewrite") { - appMatch = appRoutes ? matchAppRoute(fallbackRewrite.href, basePath, appRoutes) : null; - pagesMatch = pagesRoutes - ? matchPagesRoute(fallbackRewrite.href, basePath, pagesRoutes) - : null; + for (const rewrite of rewrites.fallback) { + const fallbackRewrite = resolveClientRewrite(href, basePath, [rewrite]); + if (fallbackRewrite?.kind === "document") return "document"; + if (fallbackRewrite?.kind !== "rewrite") continue; + href = fallbackRewrite.href; + appMatch = appRoutes ? matchAppRoute(href, basePath, appRoutes) : null; + pagesMatch = pagesRoutes ? matchPagesRoute(href, basePath, pagesRoutes) : null; + if (appMatch || pagesMatch) break; } } diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index d1c8b7970..039820336 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -617,6 +617,43 @@ describe("createAppRscHandler", () => { }); }); + it("evaluates config rewrite conditions against middleware rewrite queries", async () => { + let pageOptions: Parameters[0] | undefined; + const handler = createHandler({ + configHeaders: [], + configRewrites: { + beforeFiles: [ + { + source: "/intermediate", + destination: "/about?destination=2", + has: [{ type: "query", key: "stage", value: "1" }], + }, + ], + afterFiles: [], + fallback: [], + }, + dispatchMatchedPage: async (options) => { + pageOptions = options; + return new Response("page"); + }, + middlewareModule: { + default: () => + new Response(null, { + headers: { + "x-middleware-rewrite": "https://example.test/docs/intermediate?stage=1", + }, + }), + }, + }); + + await handler(new Request("https://example.test/docs/source"), null); + + expect(Object.fromEntries(pageOptions!.searchParams)).toEqual({ + destination: "2", + stage: "1", + }); + }); + it("does not duplicate additive config headers on non-redirect middleware responses", async () => { const handler = createHandler({ configHeaders: [ @@ -980,6 +1017,69 @@ describe("createAppRscHandler", () => { }); }); + it("applies sequential beforeFiles rewrites with accumulated query conditions", async () => { + let pageOptions: Parameters[0] | undefined; + const handler = createHandler({ + configHeaders: [], + configRewrites: { + beforeFiles: [ + { source: "/source", destination: "/intermediate?preview=1" }, + { + source: "/intermediate", + destination: "/about?destination=2", + has: [{ type: "query", key: "preview", value: "1" }], + }, + ], + afterFiles: [], + fallback: [], + }, + dispatchMatchedPage: async (options) => { + pageOptions = options; + return new Response("page"); + }, + }); + + await handler(new Request("https://example.test/docs/source?original=1"), null); + + expect(Object.fromEntries(pageOptions!.searchParams)).toEqual({ + destination: "2", + original: "1", + preview: "1", + }); + }); + + it.each(["afterFiles", "fallback"] as const)( + "continues through unmatched %s rewrite destinations", + async (rewritePhase) => { + const handler = createHandler({ + configHeaders: [], + configRewrites: { + beforeFiles: [], + afterFiles: + rewritePhase === "afterFiles" + ? [ + { source: "/source", destination: "/intermediate" }, + { source: "/intermediate", destination: "/about" }, + ] + : [], + fallback: + rewritePhase === "fallback" + ? [ + { source: "/source", destination: "/intermediate" }, + { source: "/intermediate", destination: "/about" }, + ] + : [], + }, + matchRoute: (pathname) => + pathname === "/about" ? { params: {}, route: createPageRoute() } : null, + }); + + const response = await handler(new Request("https://example.test/docs/source"), null); + + expect(response.status).toBe(200); + }, + ); + it("propagates rewritten query parameters to App route handlers", async () => { const route = createPageRoute({ page: null, diff --git a/tests/hybrid-client-route-owner.test.ts b/tests/hybrid-client-route-owner.test.ts index 430737bf1..af32aba20 100644 --- a/tests/hybrid-client-route-owner.test.ts +++ b/tests/hybrid-client-route-owner.test.ts @@ -168,6 +168,35 @@ describe("resolveHybridClientRouteOwner", () => { expect(resolveHybridClientRouteOwner("/source?original=1", "")).toBe("pages"); }); + it.each(["afterFiles", "fallback"] as const)( + "continues through unmatched %s rewrite destinations", + (rewritePhase) => { + installWindow({ + app: [appRoute(["app-destination"], false)], + pages: [], + rewrites: { + afterFiles: + rewritePhase === "afterFiles" + ? [ + { source: "/source", destination: "/intermediate" }, + { source: "/intermediate", destination: "/app-destination" }, + ] + : [], + beforeFiles: [], + fallback: + rewritePhase === "fallback" + ? [ + { source: "/source", destination: "/intermediate" }, + { source: "/intermediate", destination: "/app-destination" }, + ] + : [], + }, + }); + + expect(resolveHybridClientRouteOwner("/source", "")).toBe("app"); + }, + ); + it("lets a more specific Pages dynamic route beat an App root catch-all", () => { // Mirrors the server test of the same name. /pages-dir/:dynamic // (score 51) beats /:path+ (score 1000). From a77be8a5f6535dbcba46a123e9b6d1a2ac5f79c9 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 19:55:26 +0100 Subject: [PATCH 15/23] fix(router): preserve rewrite params and endpoints --- .../vinext/src/client/vinext-next-data.ts | 2 ++ packages/vinext/src/config/config-matchers.ts | 36 ++++++++++++++++--- .../vinext/src/entries/app-browser-entry.ts | 9 +++++ packages/vinext/src/index.ts | 31 ++++++++++------ .../internal/hybrid-client-route-owner.ts | 1 + tests/app-rsc-handler.test.ts | 30 ++++++++++++++++ tests/hybrid-client-route-owner.test.ts | 17 +++++++++ tests/shims.test.ts | 33 +++++++++++++++++ 8 files changed, 145 insertions(+), 14 deletions(-) diff --git a/packages/vinext/src/client/vinext-next-data.ts b/packages/vinext/src/client/vinext-next-data.ts index efd63c689..4e17bd257 100644 --- a/packages/vinext/src/client/vinext-next-data.ts +++ b/packages/vinext/src/client/vinext-next-data.ts @@ -10,6 +10,7 @@ import { isUnknownRecord } from "../utils/record.js"; export type VinextLinkPrefetchRoute = { canPrefetchLoadingShell: boolean; + documentOnly?: boolean; isDynamic: boolean; patternParts: string[]; }; @@ -26,6 +27,7 @@ export type VinextLinkPrefetchRoute = { */ export type VinextPagesLinkPrefetchRoute = { canPrefetchLoadingShell: false; + documentOnly?: boolean; isDynamic: boolean; patternParts: string[]; }; diff --git a/packages/vinext/src/config/config-matchers.ts b/packages/vinext/src/config/config-matchers.ts index d7e3a4f82..64bb9d284 100644 --- a/packages/vinext/src/config/config-matchers.ts +++ b/packages/vinext/src/config/config-matchers.ts @@ -1089,16 +1089,44 @@ export function matchRewrite( ? collectConditionParams(rewrite.has, rewrite.missing, ctx) : _emptyParams(); if (!conditionParams) continue; + const destinationParams = { ...params, ...conditionParams }; // Collapse protocol-relative URLs (e.g. //evil.com from decoded %2F in catch-all params). - return substituteAndSanitizeDestination(rewrite.destination, { - ...params, - ...conditionParams, - }); + const destination = substituteAndSanitizeDestination(rewrite.destination, destinationParams); + return appendRewriteParamsToQuery(destination, rewrite.destination, destinationParams); } } return null; } +function destinationReferencesParam(destination: string, key: string): boolean { + const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`:${escapedKey}(?:[+*])?(?![A-Za-z0-9_])`).test(destination); +} + +function appendRewriteParamsToQuery( + destination: string, + destinationTemplate: string, + params: Record, +): string { + const templatePath = destinationTemplate.split(/[?#]/, 1)[0]; + if (Object.keys(params).some((key) => destinationReferencesParam(templatePath, key))) { + return destination; + } + + const hashIndex = destination.indexOf("#"); + const beforeHash = hashIndex === -1 ? destination : destination.slice(0, hashIndex); + const hash = hashIndex === -1 ? "" : destination.slice(hashIndex); + const queryIndex = beforeHash.indexOf("?"); + const destinationQuery = queryIndex === -1 ? "" : beforeHash.slice(queryIndex + 1); + const destinationKeys = new Set(new URLSearchParams(destinationQuery).keys()); + const additions = Object.entries(params) + .filter(([key]) => !destinationKeys.has(key)) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`); + if (additions.length === 0) return destination; + const separator = queryIndex === -1 ? "?" : destinationQuery ? "&" : ""; + return `${beforeHash}${separator}${additions.join("&")}${hash}`; +} + /** * Substitute all matched route params into a redirect/rewrite destination. * diff --git a/packages/vinext/src/entries/app-browser-entry.ts b/packages/vinext/src/entries/app-browser-entry.ts index 2403dcb9f..8e7d4d4ef 100644 --- a/packages/vinext/src/entries/app-browser-entry.ts +++ b/packages/vinext/src/entries/app-browser-entry.ts @@ -56,6 +56,15 @@ export function isLinkPrefetchRoute(route: AppRoute): boolean { return route.routePath === null && route.layouts.length > 0; } +export function toDocumentOnlyAppRoute(route: AppRoute): VinextLinkPrefetchRoute { + return { + canPrefetchLoadingShell: false, + documentOnly: true, + patternParts: [...route.patternParts], + isDynamic: route.isDynamic, + }; +} + /** Project an `AppRoute` down to the public `VinextLinkPrefetchRoute` shape. */ export function toLinkPrefetchRoute(route: AppRoute): VinextLinkPrefetchRoute { return { diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index cd11db740..26597afef 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -51,6 +51,7 @@ import { import { generateBrowserEntry, isLinkPrefetchRoute, + toDocumentOnlyAppRoute, toLinkPrefetchRoute, } from "./entries/app-browser-entry.js"; import { @@ -880,9 +881,9 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // with `{ __appRouter: true }`. See `pages-client-entry.ts` and issue // #1526 for the Next.js parity rationale. const appPrefetchRoutes = hasAppDir - ? (await appRouter(appDir, nextConfig?.pageExtensions, fileMatcher)) - .filter(isLinkPrefetchRoute) - .map(toLinkPrefetchRoute) + ? (await appRouter(appDir, nextConfig?.pageExtensions, fileMatcher)).map((route) => + isLinkPrefetchRoute(route) ? toLinkPrefetchRoute(route) : toDocumentOnlyAppRoute(route), + ) : []; return _generateClientEntry(pagesDir, nextConfig, fileMatcher, { appPrefetchRoutes, @@ -2733,13 +2734,23 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // route manifest so a user who lands on an App page can still // see Pages ownership from a `` click. const pagesPrefetchRoutes = hasPagesDir - ? (await pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher)).map( - (route) => ({ - canPrefetchLoadingShell: false as const, - isDynamic: route.isDynamic, - patternParts: [...route.patternParts], - }), - ) + ? [ + ...(await pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher)).map( + (route) => ({ + canPrefetchLoadingShell: false as const, + isDynamic: route.isDynamic, + patternParts: [...route.patternParts], + }), + ), + ...(await apiRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher)).map( + (route) => ({ + canPrefetchLoadingShell: false as const, + documentOnly: true, + isDynamic: route.isDynamic, + patternParts: [...route.patternParts], + }), + ), + ] : []; return generateBrowserEntry( graph.routes, diff --git a/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts b/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts index 6f1bfd7ed..ff023930b 100644 --- a/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts +++ b/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts @@ -201,6 +201,7 @@ export function resolveHybridClientRouteOwner( } if (appMatch === null && pagesMatch === null) return null; + if (appMatch?.documentOnly || pagesMatch?.documentOnly) return "document"; if (pagesMatch === null) return "app"; if (appMatch === null) return "pages"; return compareHybridRoutePatterns( diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index 039820336..c689f1a6d 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -1048,6 +1048,36 @@ describe("createAppRscHandler", () => { }); }); + it("exposes unused rewrite source params through App searchParams", async () => { + let pageOptions: Parameters[0] | undefined; + const handler = createHandler({ + configHeaders: [], + configRewrites: { + beforeFiles: [ + { + source: "/source/:section/:name", + destination: "/about?first=:section&second=:name", + }, + ], + afterFiles: [], + fallback: [], + }, + dispatchMatchedPage: async (options) => { + pageOptions = options; + return new Response("page"); + }, + }); + + await handler(new Request("https://example.test/docs/source/hello/world"), null); + + expect(Object.fromEntries(pageOptions!.searchParams)).toEqual({ + first: "hello", + name: "world", + second: "world", + section: "hello", + }); + }); + it.each(["afterFiles", "fallback"] as const)( "continues through unmatched %s rewrite destinations", async (rewritePhase) => { diff --git a/tests/hybrid-client-route-owner.test.ts b/tests/hybrid-client-route-owner.test.ts index af32aba20..fea741818 100644 --- a/tests/hybrid-client-route-owner.test.ts +++ b/tests/hybrid-client-route-owner.test.ts @@ -74,6 +74,13 @@ const pagesRoute = (patternParts: string[], isDynamic = true): VinextPagesLinkPr patternParts, }); +const documentRoute = (patternParts: string[], isDynamic = true): VinextLinkPrefetchRoute => ({ + canPrefetchLoadingShell: false, + documentOnly: true, + isDynamic, + patternParts, +}); + describe("resolveHybridClientRouteOwner", () => { it("returns null when neither router has a matching manifest", () => { installWindow({ app: [], pages: [] }); @@ -104,6 +111,16 @@ describe("resolveHybridClientRouteOwner", () => { expect(resolveHybridClientRouteOwner("/b/foobar", "")).toBe("pages"); }); + it("returns document ownership for App route handlers and Pages API routes", () => { + installWindow({ + app: [documentRoute(["app-api"], false)], + pages: [{ ...pagesRoute(["api", ":slug"]), documentOnly: true }], + }); + + expect(resolveHybridClientRouteOwner("/app-api", "")).toBe("document"); + expect(resolveHybridClientRouteOwner("/api/test", "")).toBe("document"); + }); + it.each(["beforeFiles", "afterFiles", "fallback"] as const)( "resolves %s rewrites before choosing the route owner", (rewritePhase) => { diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 1e01b12d4..5fbe380ee 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -11015,6 +11015,39 @@ describe("matchRewrite with external URLs", () => { }); expect(result).toBe("/home?authorized=yes&path=docs/intro"); }); + + it("appends source params not referenced by the rewrite destination", async () => { + const { matchRewrite } = await import("../packages/vinext/src/config/config-matchers.js"); + const result = matchRewrite( + "/query-rewrite/hello/world", + [ + { + source: "/query-rewrite/:section/:name", + destination: "/with-params?first=:section&second=:name", + }, + ], + emptyCtx, + ); + + expect(result).toBe("/with-params?first=hello&second=world§ion=hello&name=world"); + }); + + it("appends unused condition captures to the rewrite destination query", async () => { + const { matchRewrite } = await import("../packages/vinext/src/config/config-matchers.js"); + const result = matchRewrite( + "/source", + [ + { + source: "/source", + destination: "/target", + has: [{ type: "query", key: "preview", value: "(?draft)" }], + }, + ], + { ...emptyCtx, query: new URLSearchParams("preview=draft") }, + ); + + expect(result).toBe("/target?mode=draft"); + }); }); describe("matchRedirect destination param substitution", () => { From df150922b154e7e5fcbaa8a55a4c4c5b078f6c37 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 20:34:47 +0100 Subject: [PATCH 16/23] fix(router): hand off endpoint navigations --- .../vinext/src/entries/app-browser-entry.ts | 6 ++--- packages/vinext/src/entries/app-rsc-entry.ts | 4 +-- .../vinext/src/entries/pages-client-entry.ts | 8 ++++-- .../vinext/src/server/app-pages-bridge.ts | 4 ++- packages/vinext/src/server/app-rsc-handler.ts | 4 +++ .../internal/hybrid-client-route-owner.ts | 9 ++++--- tests/app-pages-bridge.test.ts | 19 ++++++++++++++ tests/app-router-next-config-codegen.test.ts | 2 +- tests/app-rsc-handler.test.ts | 26 +++++++++++++++++++ tests/hybrid-client-route-owner.test.ts | 14 ++++++++++ 10 files changed, 83 insertions(+), 13 deletions(-) diff --git a/packages/vinext/src/entries/app-browser-entry.ts b/packages/vinext/src/entries/app-browser-entry.ts index 8e7d4d4ef..99a85e104 100644 --- a/packages/vinext/src/entries/app-browser-entry.ts +++ b/packages/vinext/src/entries/app-browser-entry.ts @@ -26,9 +26,9 @@ export function generateBrowserEntry( ): string { const entryPath = resolveRuntimeEntryModule("app-browser-entry"); const navigationRuntimePath = resolveClientRuntimeModule("navigation-runtime"); - const prefetchRoutes: VinextLinkPrefetchRoute[] = routes - .filter(isLinkPrefetchRoute) - .map(toLinkPrefetchRoute); + const prefetchRoutes: VinextLinkPrefetchRoute[] = routes.map((route) => + isLinkPrefetchRoute(route) ? toLinkPrefetchRoute(route) : toDocumentOnlyAppRoute(route), + ); return `import { registerNavigationRuntimeBootstrap } from ${JSON.stringify(navigationRuntimePath)}; diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 274a9ba21..3a513ee04 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -999,9 +999,9 @@ export default __createAppRscHandler({ }, ${ hasPagesDir - ? `async renderPagesFallback({ appRouteMatch, isRscRequest, matchKind, middlewareContext, pathname, request, url }) { + ? `async renderPagesFallback({ allowRscDocumentFallback, appRouteMatch, isRscRequest, matchKind, middlewareContext, pathname, request, url }) { return __renderPagesFallback( - { appRouteMatch, isRscRequest, matchKind, middlewareContext, pathname, request, url }, + { allowRscDocumentFallback, appRouteMatch, isRscRequest, matchKind, middlewareContext, pathname, request, url }, { loadPagesEntry() { return import.meta.viteRsc.loadModule("ssr", "index"); diff --git a/packages/vinext/src/entries/pages-client-entry.ts b/packages/vinext/src/entries/pages-client-entry.ts index 6a6091c91..b475ee6be 100644 --- a/packages/vinext/src/entries/pages-client-entry.ts +++ b/packages/vinext/src/entries/pages-client-entry.ts @@ -10,6 +10,7 @@ * Extracted from index.ts. */ import { + apiRouter, pagesRouter, patternToNextFormat as pagesPatternToNextFormat, type Route, @@ -49,12 +50,15 @@ export async function generateClientEntry( } = {}, ): Promise { const pageRoutes = await pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher); + const apiRoutes = await apiRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher); const appFilePath = findFileWithExts(pagesDir, "_app", fileMatcher); const hasApp = appFilePath !== null; const appPrefetchRoutes = options.appPrefetchRoutes ?? []; - const pagesPrefetchRoutes: VinextPagesLinkPrefetchRoute[] = - pageRoutes.map(toPagesLinkPrefetchRoute); + const pagesPrefetchRoutes: VinextPagesLinkPrefetchRoute[] = [ + ...pageRoutes.map(toPagesLinkPrefetchRoute), + ...apiRoutes.map((route) => ({ ...toPagesLinkPrefetchRoute(route), documentOnly: true })), + ]; const instrumentationClientPath = options.instrumentationClientPath ?? null; // Build a map of route pattern -> dynamic import. diff --git a/packages/vinext/src/server/app-pages-bridge.ts b/packages/vinext/src/server/app-pages-bridge.ts index d385d9f53..57aed8aba 100644 --- a/packages/vinext/src/server/app-pages-bridge.ts +++ b/packages/vinext/src/server/app-pages-bridge.ts @@ -58,6 +58,7 @@ type RenderPagesFallbackDependencies = { }; type RenderPagesFallbackOptions = { + allowRscDocumentFallback?: boolean; appRouteMatch?: AppRouteMatch | null; isRscRequest: boolean; matchKind?: "dynamic" | "static"; @@ -75,6 +76,7 @@ export async function renderPagesFallback( dependencies: RenderPagesFallbackDependencies, ): Promise { const { + allowRscDocumentFallback = false, appRouteMatch = null, isRscRequest, matchKind, @@ -91,7 +93,7 @@ export async function renderPagesFallback( getDraftModeCookieHeader, } = dependencies; - if (isRscRequest) return null; + if (isRscRequest && !allowRscDocumentFallback) return null; const pagesEntry = await loadPagesEntry(); diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 58ea4172b..76a536e0a 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -215,6 +215,7 @@ type RenderNotFoundOptions = { }; type RenderPagesFallbackOptions = { + allowRscDocumentFallback?: boolean; appRouteMatch?: { route: { isDynamic: boolean; pattern: string } } | null; isRscRequest: boolean; matchKind?: "dynamic" | "static"; @@ -519,6 +520,7 @@ async function handleAppRscRequest( requestHeaders: null, status: null, }; + let didMiddlewareRewrite = false; if (options.middlewareModule) { const middlewareResult = await applyAppMiddleware({ @@ -542,6 +544,7 @@ async function handleAppRscRequest( } cleanPathname = middlewareResult.cleanPathname; + didMiddlewareRewrite = cleanPathname !== normalized.cleanPathname; if (middlewareResult.search !== null) { url.search = middlewareResult.search; } @@ -694,6 +697,7 @@ async function handleAppRscRequest( match === null || match.route.isDynamic ? ((await options.renderPagesFallback?.({ appRouteMatch: match ?? null, + allowRscDocumentFallback: didMiddlewareRewrite, isRscRequest, matchKind, middlewareContext, diff --git a/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts b/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts index ff023930b..89c57fcf2 100644 --- a/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts +++ b/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts @@ -201,13 +201,14 @@ export function resolveHybridClientRouteOwner( } if (appMatch === null && pagesMatch === null) return null; - if (appMatch?.documentOnly || pagesMatch?.documentOnly) return "document"; - if (pagesMatch === null) return "app"; - if (appMatch === null) return "pages"; - return compareHybridRoutePatterns( + if (pagesMatch === null) return appMatch!.documentOnly ? "document" : "app"; + if (appMatch === null) return pagesMatch.documentOnly ? "document" : "pages"; + const owner = compareHybridRoutePatterns( patternFromParts(pagesMatch.patternParts), pagesMatch.isDynamic, patternFromParts(appMatch.patternParts), appMatch.isDynamic, ); + const winningRoute = owner === "app" ? appMatch : pagesMatch; + return winningRoute.documentOnly ? "document" : owner; } diff --git a/tests/app-pages-bridge.test.ts b/tests/app-pages-bridge.test.ts index 80dc60351..751a89c6e 100644 --- a/tests/app-pages-bridge.test.ts +++ b/tests/app-pages-bridge.test.ts @@ -49,6 +49,25 @@ describe("renderPagesFallback", () => { expect(loadPagesEntry).not.toHaveBeenCalled(); }); + it("allows middleware-rewritten RSC requests to return a Pages document", async () => { + const renderPage = vi.fn( + () => new Response("pages", { headers: { "content-type": "text/html" } }), + ); + const response = await renderPagesFallback( + { + allowRscDocumentFallback: true, + isRscRequest: true, + middlewareContext: { headers: null, requestHeaders: null, status: null }, + pathname: "/pages", + request: new Request("http://localhost/source"), + url: new URL("http://localhost/source"), + }, + { ...defaultDeps, loadPagesEntry: () => ({ renderPage }) }, + ); + + expect(await response!.text()).toBe("pages"); + }); + it("rebuilds request when middleware request headers are present", async () => { const handleApiRoute = vi.fn((_req: Request, _url: string) => new Response("api")); const deps = { diff --git a/tests/app-router-next-config-codegen.test.ts b/tests/app-router-next-config-codegen.test.ts index 2067a7129..62a151b7c 100644 --- a/tests/app-router-next-config-codegen.test.ts +++ b/tests/app-router-next-config-codegen.test.ts @@ -145,7 +145,7 @@ describe("App Router next.config.js features (generateRscEntry)", () => { expect(code).toContain("server/app-pages-bridge.js"); expect(code).toContain("return __renderPagesFallback("); expect(code).toContain( - "{ appRouteMatch, isRscRequest, matchKind, middlewareContext, pathname, request, url }", + "{ allowRscDocumentFallback, appRouteMatch, isRscRequest, matchKind, middlewareContext, pathname, request, url }", ); expect(code).toContain('return import.meta.viteRsc.loadModule("ssr", "index");'); expect(code).toContain("buildRequestHeaders: __buildRequestHeadersFromMiddlewareResponse"); diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index c689f1a6d..bf950d58b 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -654,6 +654,32 @@ describe("createAppRscHandler", () => { }); }); + it("allows middleware-rewritten RSC requests to hand off to Pages HTML", async () => { + const headers = createRscRequestHeaders(); + const rscUrl = await createRscRequestUrl("/docs/source", headers); + const renderPagesFallback = vi.fn(async ({ allowRscDocumentFallback, pathname }) => + allowRscDocumentFallback && pathname === "/pages" + ? new Response("pages", { headers: { "content-type": "text/html" } }) + : null, + ); + const handler = createHandler({ + configHeaders: [], + matchRoute: () => null, + middlewareModule: { + default: () => + new Response(null, { + headers: { "x-middleware-rewrite": "https://example.test/docs/pages" }, + }), + }, + renderPagesFallback, + }); + + const response = await handler(new Request(`https://example.test${rscUrl}`, { headers }), null); + + expect(response.headers.get("content-type")).toBe("text/html"); + expect(await response.text()).toBe("pages"); + }); + it("does not duplicate additive config headers on non-redirect middleware responses", async () => { const handler = createHandler({ configHeaders: [ diff --git a/tests/hybrid-client-route-owner.test.ts b/tests/hybrid-client-route-owner.test.ts index fea741818..e440e4aa4 100644 --- a/tests/hybrid-client-route-owner.test.ts +++ b/tests/hybrid-client-route-owner.test.ts @@ -121,6 +121,20 @@ describe("resolveHybridClientRouteOwner", () => { expect(resolveHybridClientRouteOwner("/api/test", "")).toBe("document"); }); + it("applies document ownership only after choosing the most specific route", () => { + installWindow({ + app: [appRoute(["api", "settings"], false)], + pages: [{ ...pagesRoute(["api", ":slug"]), documentOnly: true }], + }); + expect(resolveHybridClientRouteOwner("/api/settings", "")).toBe("app"); + + installWindow({ + app: [documentRoute(["api", ":slug"])], + pages: [pagesRoute(["api", "settings"], false)], + }); + expect(resolveHybridClientRouteOwner("/api/settings", "")).toBe("pages"); + }); + it.each(["beforeFiles", "afterFiles", "fallback"] as const)( "resolves %s rewrites before choosing the route owner", (rewritePhase) => { From f43ef021c3f15fef64a3d3319d0a0ccf202c4753 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 13 Jun 2026 21:05:08 +0100 Subject: [PATCH 17/23] fix(router): preserve rewrite fragment params --- packages/vinext/src/config/config-matchers.ts | 4 ++-- tests/shims.test.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/config/config-matchers.ts b/packages/vinext/src/config/config-matchers.ts index 64bb9d284..1b72b6002 100644 --- a/packages/vinext/src/config/config-matchers.ts +++ b/packages/vinext/src/config/config-matchers.ts @@ -1108,8 +1108,8 @@ function appendRewriteParamsToQuery( destinationTemplate: string, params: Record, ): string { - const templatePath = destinationTemplate.split(/[?#]/, 1)[0]; - if (Object.keys(params).some((key) => destinationReferencesParam(templatePath, key))) { + const templateWithoutQuery = destinationTemplate.replace(/\?[^#]*/, ""); + if (Object.keys(params).some((key) => destinationReferencesParam(templateWithoutQuery, key))) { return destination; } diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 5fbe380ee..73318b2bd 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -11048,6 +11048,17 @@ describe("matchRewrite with external URLs", () => { expect(result).toBe("/target?mode=draft"); }); + + it("does not append rewrite params consumed by the destination fragment", async () => { + const { matchRewrite } = await import("../packages/vinext/src/config/config-matchers.js"); + const result = matchRewrite( + "/docs/500", + [{ source: "/docs/:code", destination: "/status#:code" }], + emptyCtx, + ); + + expect(result).toBe("/status#500"); + }); }); describe("matchRedirect destination param substitution", () => { From 2395fde47023570245c2d4b807bb553d735b086d Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 01:31:29 +0100 Subject: [PATCH 18/23] test(e2e): avoid hybrid fixture route conflicts --- .../pages/{index.tsx => pages-index.tsx} | 1 - .../front-redirect-issue.spec.ts | 2 +- tests/e2e/cloudflare-dev/middleware.spec.ts | 5 ----- tests/e2e/cloudflare-dev/pages-router.spec.ts | 20 ++++++------------- 4 files changed, 7 insertions(+), 21 deletions(-) rename examples/app-router-cloudflare/pages/{index.tsx => pages-index.tsx} (52%) diff --git a/examples/app-router-cloudflare/pages/index.tsx b/examples/app-router-cloudflare/pages/pages-index.tsx similarity index 52% rename from examples/app-router-cloudflare/pages/index.tsx rename to examples/app-router-cloudflare/pages/pages-index.tsx index a016be350..c5ed14f3d 100644 --- a/examples/app-router-cloudflare/pages/index.tsx +++ b/examples/app-router-cloudflare/pages/pages-index.tsx @@ -1,4 +1,3 @@ -// Ensure app router works when a pages directory is present export default function Page() { return
pages index
; } diff --git a/tests/e2e/app-front-redirect-issue/front-redirect-issue.spec.ts b/tests/e2e/app-front-redirect-issue/front-redirect-issue.spec.ts index ac3f5b126..497eac586 100644 --- a/tests/e2e/app-front-redirect-issue/front-redirect-issue.spec.ts +++ b/tests/e2e/app-front-redirect-issue/front-redirect-issue.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test"; // Ported from Next.js: test/e2e/app-dir/front-redirect-issue/front-redirect-issue.test.ts // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/front-redirect-issue/front-redirect-issue.test.ts -const BASE = "http://localhost:4186"; +const BASE = "http://localhost:4188"; test.describe("app dir - front redirect issue", () => { test("should redirect with a single bootstrap hydration", async ({ page }) => { diff --git a/tests/e2e/cloudflare-dev/middleware.spec.ts b/tests/e2e/cloudflare-dev/middleware.spec.ts index 93b757281..1df01557a 100644 --- a/tests/e2e/cloudflare-dev/middleware.spec.ts +++ b/tests/e2e/cloudflare-dev/middleware.spec.ts @@ -71,17 +71,12 @@ test.describe("middleware.ts with @cloudflare/vite-plugin", () => { }); test("root route runs inside the Cloudflare Worker", async ({ request }) => { - // / is matched by both pages/index.tsx (stub) and app/page.tsx (App Router). // The connect handler must call next() so the Cloudflare plugin dispatches // the request to app/page.tsx inside the Worker. - // - // "vinext on Cloudflare Workers" — app/page.tsx rendered in the Worker - // "pages index" — pages stub rendered by the connect handler const res = await request.get(`${BASE}/`); expect(res.status()).toBe(200); const body = await res.text(); expect(body).toContain("vinext on Cloudflare Workers"); - expect(body).not.toContain("pages index"); }); }); diff --git a/tests/e2e/cloudflare-dev/pages-router.spec.ts b/tests/e2e/cloudflare-dev/pages-router.spec.ts index 9db83e081..55741fbcf 100644 --- a/tests/e2e/cloudflare-dev/pages-router.spec.ts +++ b/tests/e2e/cloudflare-dev/pages-router.spec.ts @@ -19,13 +19,9 @@ * * ## How we assert "served by the Worker" * - * The app-router-cloudflare example has two competing handlers for /: - * - pages/index.tsx → "
pages index
" (Pages Router stub) - * - app/page.tsx → "vinext on Cloudflare Workers" (App Router, Worker) - * - * The Worker entry dispatches via the RSC entry, which serves app/page.tsx. - * If the connect handler intercepted the request first, pages/index.tsx would - * be rendered — without any Cloudflare runtime. + * The app-router-cloudflare example exposes a dedicated Pages route at + * /pages-index. If the host connect handler intercepted the request, it would + * render in Node instead of passing through the Cloudflare Worker entry. * * Note: Pages Router API routes on Cloudflare Workers are covered by the * cloudflare-pages-router e2e suite (wrangler dev, not vite dev), which @@ -37,17 +33,13 @@ import { test, expect } from "@playwright/test"; const BASE = "http://localhost:4178"; test.describe("Pages Router routes on Cloudflare Workers (vite dev)", () => { - test("root route is served by the Worker, not intercepted by the connect handler", async ({ + test("Pages route is served by the Worker, not intercepted by the connect handler", async ({ request, }) => { - // pages/index.tsx and app/page.tsx both match /. - // The Worker entry dispatches via the RSC entry, which serves app/page.tsx. - // If the connect handler intercepts first, pages/index.tsx is rendered instead. - const res = await request.get(`${BASE}/`); + const res = await request.get(`${BASE}/pages-index`); expect(res.status()).toBe(200); const body = await res.text(); - expect(body).toContain("vinext on Cloudflare Workers"); - expect(body).not.toContain("pages index"); + expect(body).toContain("pages index"); }); }); From 0215e47aff585005502935cab0829f6c4cab0a27 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 01:46:09 +0100 Subject: [PATCH 19/23] test(pages): await async config validation --- tests/pages-router.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 2c82005e9..e43117eec 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -2397,7 +2397,7 @@ describe("Plugin config", () => { { command: "serve", mode: "development" }, ); - expect(() => + await expect( configPlugin.configResolved({ command: "serve", configFile: false, @@ -2408,7 +2408,7 @@ describe("Plugin config", () => { { name: "vite:react-refresh" }, ], }), - ).toThrow("Duplicate @vitejs/plugin-react detected"); + ).rejects.toThrow("Duplicate @vitejs/plugin-react detected"); }); it("adds resolve.dedupe for React packages to prevent dual instance errors", async () => { From e691ff24500db8bb6c44c229b69f27eb0d9f2bc3 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 01:55:54 +0100 Subject: [PATCH 20/23] test(hybrid): avoid duplicate page fixtures --- .../pages/{about.tsx => pages-about.tsx} | 2 +- .../pages/{index.tsx => pages-home.tsx} | 2 +- tests/prerender.test.ts | 20 +++++++++++-------- 3 files changed, 14 insertions(+), 10 deletions(-) rename tests/fixtures/cf-app-basic/pages/{about.tsx => pages-about.tsx} (75%) rename tests/fixtures/cf-app-basic/pages/{index.tsx => pages-home.tsx} (75%) diff --git a/tests/fixtures/cf-app-basic/pages/about.tsx b/tests/fixtures/cf-app-basic/pages/pages-about.tsx similarity index 75% rename from tests/fixtures/cf-app-basic/pages/about.tsx rename to tests/fixtures/cf-app-basic/pages/pages-about.tsx index fc96716e0..9990ade55 100644 --- a/tests/fixtures/cf-app-basic/pages/about.tsx +++ b/tests/fixtures/cf-app-basic/pages/pages-about.tsx @@ -4,7 +4,7 @@ export default function About() { return (

About (Pages)

- Home + Home
); } diff --git a/tests/fixtures/cf-app-basic/pages/index.tsx b/tests/fixtures/cf-app-basic/pages/pages-home.tsx similarity index 75% rename from tests/fixtures/cf-app-basic/pages/index.tsx rename to tests/fixtures/cf-app-basic/pages/pages-home.tsx index a17614b11..1bf042a66 100644 --- a/tests/fixtures/cf-app-basic/pages/index.tsx +++ b/tests/fixtures/cf-app-basic/pages/pages-home.tsx @@ -4,7 +4,7 @@ export default function Home() { return (

CF Hybrid Home (Pages)

- About + About
); } diff --git a/tests/prerender.test.ts b/tests/prerender.test.ts index 3ca0fd9c1..f86077189 100644 --- a/tests/prerender.test.ts +++ b/tests/prerender.test.ts @@ -1328,19 +1328,23 @@ describe("Cloudflare Workers hybrid build (cf-app-basic)", () => { // ── Pages Router ──────────────────────────────────────────────────────────── describe("prerenderPages — pages router via prod server HTTP", () => { - it("renders static index page", () => { - const r = findRoute(allResults, "/"); - expect(r).toMatchObject({ route: "/", status: "rendered", revalidate: false }); + it("renders static Pages home", () => { + const r = findRoute(allResults, "/pages-home"); + expect(r).toMatchObject({ route: "/pages-home", status: "rendered", revalidate: false }); if (r?.status === "rendered") { - expect(r.outputFiles).toContain("index.html"); + expect(r.outputFiles).toContain("pages-home.html"); } }); - it("renders static about page", () => { - const r = findRoute(allResults, "/about"); - expect(r).toMatchObject({ route: "/about", status: "rendered", revalidate: false }); + it("renders static Pages about", () => { + const r = findRoute(allResults, "/pages-about"); + expect(r).toMatchObject({ + route: "/pages-about", + status: "rendered", + revalidate: false, + }); if (r?.status === "rendered") { - expect(r.outputFiles).toContain("about.html"); + expect(r.outputFiles).toContain("pages-about.html"); } }); From 9c5dc467122f75f1c1eae79aa0f553ea764c8604 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 01:59:37 +0100 Subject: [PATCH 21/23] chore(router): clarify hybrid priority semantics --- packages/vinext/src/index.ts | 7 ++- packages/vinext/src/routing/utils.ts | 6 +- .../src/server/hybrid-route-priority.ts | 35 ----------- .../src/server/pages-request-pipeline.ts | 2 + tests/hybrid-route-priority.test.ts | 61 +------------------ 5 files changed, 12 insertions(+), 99 deletions(-) diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index b85428198..b86c7a7a1 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -3924,7 +3924,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // and an app/ dir exists, defer to the RSC plugin (app routes live // there). If both routers match, apply Next.js's merged route // precedence before choosing which plugin owns the request. - const renderMatch = matchRoute(pipelineResult.resolvedUrl.split("?")[0], routes); + const resolvedPathname = pipelineResult.resolvedUrl + .split("#", 1)[0] + .split("?", 1)[0]; + const renderMatch = matchRoute(resolvedPathname, routes); if (hasAppDir && appDir) { if (!renderMatch) { return next(); @@ -3934,7 +3937,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { nextConfig?.pageExtensions, fileMatcher, ); - const appMatch = matchAppRoute(pipelineResult.resolvedUrl, appRoutes); + const appMatch = matchAppRoute(resolvedPathname, appRoutes); if ( appMatch && !pagesRouteHasPriorityOverAppRoute(renderMatch.route, appMatch.route) diff --git a/packages/vinext/src/routing/utils.ts b/packages/vinext/src/routing/utils.ts index d16b53ccf..333953376 100644 --- a/packages/vinext/src/routing/utils.ts +++ b/packages/vinext/src/routing/utils.ts @@ -107,8 +107,10 @@ export function sortRoutes(routes: T[]): T[] { * * Centralised so the server's request handling and the client's link / * prefetch / programmatic-navigation paths all reach the same owner for - * the same (pages pattern, app pattern) pair. Encapsulates the static / - * dynamic short-circuits and Next.js's segment-by-segment route ordering. + * the same (pages pattern, app pattern) pair. This intentionally implements + * Next.js's segment-tree ordering directly instead of vinext's broader + * `sortRoutes()` score heuristic. It only arbitrates two routes that already + * matched the same URL; each router's own trie ordering remains unchanged. * * Usage: * compareHybridRoutePatterns("/:slug", true, "/:slug", true) // → "pages" diff --git a/packages/vinext/src/server/hybrid-route-priority.ts b/packages/vinext/src/server/hybrid-route-priority.ts index bede394b8..1be6981c1 100644 --- a/packages/vinext/src/server/hybrid-route-priority.ts +++ b/packages/vinext/src/server/hybrid-route-priority.ts @@ -7,13 +7,6 @@ export type HybridRoutePriorityRoute = { sourcePath?: string | null; }; -export type HybridOwner = "app" | "pages"; - -export type HybridRouteMatch = { - route: R; - params: Record; -}; - export function validateHybridRouteConflicts( pagesRoutes: readonly HybridRoutePriorityRoute[], appRoutes: readonly HybridRoutePriorityRoute[], @@ -67,31 +60,3 @@ export function pagesRouteHasPriorityOverAppRoute( ) === "pages" ); } - -/** - * Compare two already-matched routes (one from each router) and decide which - * router should own the request. - * - * Returns the owning router, or `null` when both routers missed. This is the - * shape the client-side link/prefetch pipeline needs: a single answer it can - * switch on to choose between an RSC navigation (App) and a document/Pages - * navigation (Pages). - * - * Centralises the same `pagesRouteHasPriorityOverAppRoute` comparison the - * server uses so client navigations, prefetch detection, and direct document - * loads all reach the same answer for the same route pair. - */ -export function resolveHybridRouteOwner( - appMatch: HybridRouteMatch | null, - pagesMatch: HybridRouteMatch | null, -): HybridOwner | null { - if (appMatch === null && pagesMatch === null) return null; - if (appMatch === null) return "pages"; - if (pagesMatch === null) return "app"; - return compareHybridRoutePatterns( - pagesMatch.route.pattern, - pagesMatch.route.isDynamic, - appMatch.route.pattern, - appMatch.route.isDynamic, - ); -} diff --git a/packages/vinext/src/server/pages-request-pipeline.ts b/packages/vinext/src/server/pages-request-pipeline.ts index 862697b56..5f7366ac6 100644 --- a/packages/vinext/src/server/pages-request-pipeline.ts +++ b/packages/vinext/src/server/pages-request-pipeline.ts @@ -411,6 +411,8 @@ export async function runPagesRequest( } // Step 9: beforeFiles rewrites + // Next.js server-utils.ts applies every beforeFiles rule in sequence and + // continues afterFiles/fallback rules until a destination resolves. let configRewriteFired = false; for (const rewrite of configRewrites.beforeFiles ?? []) { const rewritten = matchRewrite( diff --git a/tests/hybrid-route-priority.test.ts b/tests/hybrid-route-priority.test.ts index 8ba6b1581..6cc7125c4 100644 --- a/tests/hybrid-route-priority.test.ts +++ b/tests/hybrid-route-priority.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { compareHybridRoutePatterns } from "../packages/vinext/src/routing/utils.js"; import { pagesRouteHasPriorityOverAppRoute, - resolveHybridRouteOwner, validateHybridRouteConflicts, } from "../packages/vinext/src/server/hybrid-route-priority.js"; @@ -43,7 +42,7 @@ describe("compareHybridRoutePatterns", () => { // static-prefix score by 50 per segment, so the Pages route scores // 1951 and the App route scores 2000. The hand-copied client // comparator missed this reduction and reversed the answer; the - // shared comparator (which delegates to `sortRoutes`) gets it right. + // shared comparator's Next.js-style segment ordering gets it right. expect(compareHybridRoutePatterns("/_sites/:slug*", true, "/:slug*", true)).toBe("pages"); }); @@ -160,61 +159,3 @@ describe("hybrid App Router + Pages Router route priority", () => { ).toBe(true); }); }); - -describe("resolveHybridRouteOwner", () => { - it("returns null when neither router matched", () => { - expect(resolveHybridRouteOwner(null, null)).toBeNull(); - }); - - it("returns the matched router when only one router matched", () => { - const matched = { - route: { isDynamic: true, pattern: "/a" }, - params: { id: "1" }, - }; - expect(resolveHybridRouteOwner(matched, null)).toBe("app"); - expect(resolveHybridRouteOwner(null, matched)).toBe("pages"); - }); - - it("lets a more specific Pages dynamic route beat an App root catch-all", () => { - // /pages-dir/[dynamic] owns /pages-dir/foobar ahead of app/[...path]. - expect( - resolveHybridRouteOwner( - { - route: { isDynamic: true, pattern: "/:path+" }, - params: { path: ["pages-dir", "foobar"] }, - }, - { - route: { isDynamic: true, pattern: "/pages-dir/:dynamic" }, - params: { dynamic: "foobar" }, - }, - ), - ).toBe("pages"); - }); - - it("lets an App static route own the request when Pages only has a catch-all", () => { - expect( - resolveHybridRouteOwner( - { route: { isDynamic: false, pattern: "/dashboard" }, params: {} }, - { route: { isDynamic: true, pattern: "/:path+" }, params: { path: "dashboard" } }, - ), - ).toBe("app"); - }); - - it("rejects an identical static App and Pages route", () => { - expect(() => - resolveHybridRouteOwner( - { route: { isDynamic: false, pattern: "/" }, params: {} }, - { route: { isDynamic: false, pattern: "/" }, params: {} }, - ), - ).toThrow("Conflicting app and page routes"); - }); - - it("retains Pages provider order after merged route validation", () => { - expect( - resolveHybridRouteOwner( - { route: { isDynamic: true, pattern: "/:slug" }, params: { slug: "x" } }, - { route: { isDynamic: true, pattern: "/:id" }, params: { id: "x" } }, - ), - ).toBe("pages"); - }); -}); From 8538c43ec9ac4b4a7aa1b1c3009629b736e606cc Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 02:12:38 +0100 Subject: [PATCH 22/23] refactor(router): remove duplicate app route matcher --- .../src/shims/internal/app-route-detection.ts | 19 --------------- ...ages-router-app-prefetch-detection.test.ts | 23 +++++++++++-------- 2 files changed, 14 insertions(+), 28 deletions(-) diff --git a/packages/vinext/src/shims/internal/app-route-detection.ts b/packages/vinext/src/shims/internal/app-route-detection.ts index e2e83fa1e..ac0fe0f5b 100644 --- a/packages/vinext/src/shims/internal/app-route-detection.ts +++ b/packages/vinext/src/shims/internal/app-route-detection.ts @@ -35,13 +35,10 @@ * Issue: https://github.com/cloudflare/vinext/issues/1526 */ import type { VinextLinkPrefetchRoute } from "../../client/vinext-next-data.js"; -import { createRouteTrieCache, matchRouteWithTrie } from "../../routing/route-matching.js"; import { stripBasePath, removeTrailingSlash } from "../../utils/base-path.js"; import { getLocalePathPrefix } from "../../utils/domain-locale.js"; import { resolveHybridClientRouteOwner } from "./hybrid-client-route-owner.js"; -const appRouteTrieCache = createRouteTrieCache(); - declare global { // oxlint-disable-next-line typescript-eslint/consistent-type-definitions interface Window { @@ -101,22 +98,6 @@ function resolveSameOriginPathname(href: string, basePath: string): string | nul return pathname.length === localePrefixLength ? "/" : pathname.slice(localePrefixLength); } -/** - * Returns true when the prefetch href matches any route in the App Router - * prefetch manifest (static or dynamic). Returns false when the manifest is - * absent (Pages-Router-only build), the URL is external, or no route matches. - */ -export function matchesAppRoute(href: string, basePath: string): boolean { - if (typeof window === "undefined") return false; - const routes = window.__VINEXT_LINK_PREFETCH_ROUTES__; - if (!routes || routes.length === 0) return false; - - const pathname = resolveSameOriginPathname(href, basePath); - if (pathname === null) return false; - - return matchRouteWithTrie(pathname, routes, appRouteTrieCache) !== null; -} - /** * Record `components[pathname] = { __appRouter: true }` on the shared * Pages Router map when the href matches an App Router route. No-op when the diff --git a/tests/pages-router-app-prefetch-detection.test.ts b/tests/pages-router-app-prefetch-detection.test.ts index 27414f255..b16001a7b 100644 --- a/tests/pages-router-app-prefetch-detection.test.ts +++ b/tests/pages-router-app-prefetch-detection.test.ts @@ -235,7 +235,7 @@ describe("Pages Router records app routes as detected on prefetch", () => { ["query and hash", "/about?from=pages#details", ["about"]], ["interception target", "/photos/123", ["photos", ":id"]], ])("synchronously detects %s destinations", async (_label, href, patternParts) => { - const fakeWindow = installFakeBrowserGlobals([ + installFakeBrowserGlobals([ { canPrefetchLoadingShell: false, patternParts, @@ -243,11 +243,16 @@ describe("Pages Router records app routes as detected on prefetch", () => { }, ]); - const { matchesAppRoute } = + const { getPagesRouterComponentsMap, markAppRouteDetectedOnPrefetch } = await import("../packages/vinext/src/shims/internal/app-route-detection.js"); - expect(matchesAppRoute(href, "")).toBe(true); - expect(fakeWindow.location.assign).not.toHaveBeenCalled(); + markAppRouteDetectedOnPrefetch(href, ""); + + expect( + getPagesRouterComponentsMap()[new URL(href, "http://localhost").pathname.replace(/\/$/, "")], + ).toEqual({ + __appRouter: true, + }); }); it("strips basePath and locale prefixes before matching App routes", async () => { @@ -257,11 +262,10 @@ describe("Pages Router records app routes as detected on prefetch", () => { fakeWindow.__VINEXT_LOCALES__ = ["en", "fr"]; fakeWindow.__VINEXT_DEFAULT_LOCALE__ = "en"; - const { matchesAppRoute, markAppRouteDetectedOnPrefetch } = + const { markAppRouteDetectedOnPrefetch } = await import("../packages/vinext/src/shims/internal/app-route-detection.js"); - expect(matchesAppRoute("/docs/fr/about?from=pages#details", "/docs")).toBe(true); - markAppRouteDetectedOnPrefetch("/docs/fr/about", "/docs"); + markAppRouteDetectedOnPrefetch("/docs/fr/about?from=pages#details", "/docs"); const routerModule = await import("../packages/vinext/src/shims/router.js"); expect(routerModule.default.components["/about"]).toEqual({ __appRouter: true }); @@ -272,10 +276,11 @@ describe("Pages Router records app routes as detected on prefetch", () => { { canPrefetchLoadingShell: false, patternParts: ["about"], isDynamic: false }, ]); - const { matchesAppRoute } = + const { getPagesRouterComponentsMap, markAppRouteDetectedOnPrefetch } = await import("../packages/vinext/src/shims/internal/app-route-detection.js"); - expect(matchesAppRoute("https://example.com/about", "")).toBe(false); + markAppRouteDetectedOnPrefetch("https://example.com/about", ""); + expect(getPagesRouterComponentsMap()).toEqual({}); }); it("does not mark or hard-navigate a Pages-owned overlap", async () => { From b251bced7f8b1ba9d7362168380119d0c6604e1f Mon Sep 17 00:00:00 2001 From: James Date: Sun, 14 Jun 2026 02:17:26 +0100 Subject: [PATCH 23/23] docs(router): clarify client hybrid comparator --- .../vinext/src/shims/internal/hybrid-client-route-owner.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts b/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts index 89c57fcf2..fa1b8a4f2 100644 --- a/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts +++ b/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts @@ -89,8 +89,9 @@ const pagesRouteTrieCache = createRouteTrieCache() /** * Build a `/`-joined pattern from a manifest's `patternParts`. Mirrors the - * server-side route-graph shape (`{ pattern: string }`) so the same - * `sortRoutes` algorithm can score both Pages and App patterns. The + * server-side route-graph shape (`{ pattern: string }`) so the + * `compareHybridRoutePatterns` segment-rank comparator can score both Pages + * and App patterns. The * `patternParts` array never includes an empty string for the static `/` * route (the App catch-all handles the bare path), so the simple join is * safe for everything the route trie actually matches.