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/packages/vinext/src/client/vinext-next-data.ts b/packages/vinext/src/client/vinext-next-data.ts index fc8aea3cb..4e17bd257 100644 --- a/packages/vinext/src/client/vinext-next-data.ts +++ b/packages/vinext/src/client/vinext-next-data.ts @@ -10,6 +10,24 @@ import { isUnknownRecord } from "../utils/record.js"; export type VinextLinkPrefetchRoute = { canPrefetchLoadingShell: boolean; + documentOnly?: boolean; + isDynamic: boolean; + 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; + documentOnly?: boolean; isDynamic: boolean; patternParts: string[]; }; diff --git a/packages/vinext/src/entries/app-browser-entry.ts b/packages/vinext/src/entries/app-browser-entry.ts index 62aaedbd9..99a85e104 100644 --- a/packages/vinext/src/entries/app-browser-entry.ts +++ b/packages/vinext/src/entries/app-browser-entry.ts @@ -1,7 +1,11 @@ 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"; +import type { NextRewrite } from "../config/next-config.js"; /** * Generate the virtual browser entry module. @@ -13,16 +17,28 @@ import type { RouteManifest } from "../routing/app-route-graph.js"; export function generateBrowserEntry( routes: readonly AppRoute[] = [], routeManifest: RouteManifest | null = null, + pagesPrefetchRoutes: readonly VinextPagesLinkPrefetchRoute[] = [], + rewrites: { afterFiles: NextRewrite[]; beforeFiles: NextRewrite[]; fallback: NextRewrite[] } = { + afterFiles: [], + beforeFiles: [], + fallback: [], + }, ): 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)}; 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)}; +window.__VINEXT_CLIENT_REWRITES__ = ${JSON.stringify(rewrites)}; registerNavigationRuntimeBootstrap({ routeManifest: ${buildRouteManifestExpression(routeManifest)} }); @@ -40,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/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 80ffffaa5..4ee0c802b 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -1042,9 +1042,9 @@ export default __createAppRscHandler({ }, ${ hasPagesDir - ? `async renderPagesFallback({ isRscRequest, middlewareContext, request, url }) { + ? `async renderPagesFallback({ allowRscDocumentFallback, appRouteMatch, isRscRequest, matchKind, middlewareContext, pathname, request, url }) { return __renderPagesFallback( - { isRscRequest, middlewareContext, 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/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-client-entry.ts b/packages/vinext/src/entries/pages-client-entry.ts index 230161d9a..7ea03b854 100644 --- a/packages/vinext/src/entries/pages-client-entry.ts +++ b/packages/vinext/src/entries/pages-client-entry.ts @@ -10,16 +10,36 @@ * Extracted from index.ts. */ import { + apiRouter, pagesRouter, patternToNextFormat as pagesPatternToNextFormat, type Route, } 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, @@ -30,10 +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), + ...apiRoutes.map((route) => ({ ...toPagesLinkPrefetchRoute(route), documentOnly: true })), + ]; const instrumentationClientPath = options.instrumentationClientPath ?? null; // Build a map of route pattern -> dynamic import. @@ -129,6 +154,12 @@ 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)}; +window.__VINEXT_CLIENT_REWRITES__ = ${JSON.stringify(nextConfig.rewrites)}; async function hydrate() { const nextData = window.__NEXT_DATA__; diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index c55979ee2..f38c12862 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 799af2a96..16359cc32 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, @@ -46,6 +51,7 @@ import { import { generateBrowserEntry, isLinkPrefetchRoute, + toDocumentOnlyAppRoute, toLinkPrefetchRoute, } from "./entries/app-browser-entry.js"; import { @@ -103,6 +109,10 @@ import { type PagesPipelineDeps, type MiddlewareResult, } from "./server/pages-request-pipeline.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"; @@ -843,9 +853,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, @@ -2390,7 +2400,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'`. @@ -2399,6 +2409,26 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { // preprocessor options and `css.modules` settings are in place. sassComposesLoader.setResolvedConfig(config); + if (config.command === "build" && hasAppDir && hasPagesDir) { + const [appRoutes, pageRoutes, apiRoutes] = await Promise.all([ + appRouter(appDir, nextConfig?.pageExtensions, fileMatcher), + pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher), + apiRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher), + ]); + validateHybridRouteConflicts( + [...pageRoutes, ...apiRoutes].map((route) => ({ + ...route, + sourcePath: path.relative(root, route.filePath), + })), + appRoutes + .filter((route) => route.pagePath !== null || route.routePath !== null) + .map((route) => ({ + ...route, + sourcePath: path.relative(root, route.pagePath ?? route.routePath!), + })), + ); + } + // 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 @@ -2684,7 +2714,34 @@ 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], + }), + ), + ...(await apiRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher)).map( + (route) => ({ + canPrefetchLoadingShell: false as const, + documentOnly: true, + isDynamic: route.isDynamic, + patternParts: [...route.patternParts], + }), + ), + ] + : []; + return generateBrowserEntry( + graph.routes, + graph.routeManifest, + pagesPrefetchRoutes, + nextConfig.rewrites, + ); } if (id.startsWith(RESOLVED_VIRTUAL_GOOGLE_FONTS + "?")) { return generateGoogleFontsVirtualModule(id, _fontGoogleShimPath); @@ -3068,12 +3125,77 @@ 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 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(); invalidateRootParamsModule(); } + 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, apiRoutes] = await Promise.all([ + appRouter(appDir, nextConfig?.pageExtensions, fileMatcher), + pagesRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher), + apiRouter(pagesDir, nextConfig?.pageExtensions, fileMatcher), + ]); + validateHybridRouteConflicts( + [...pageRoutes, ...apiRoutes].map((route) => ({ + ...route, + sourcePath: path.relative(root, route.filePath), + })), + appRoutes + .filter((route) => route.pagePath !== null || route.routePath !== null) + .map((route) => ({ + ...route, + 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)); + hybridRouteValidationError = err; + sendHybridRouteValidationError(err); + }); + } + let appRouteTypeGeneration: Promise | null = null; let appRouteTypeGenerationPending = false; @@ -3109,6 +3231,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 @@ -3123,21 +3246,37 @@ 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) { + invalidatePagesServerEntry(); + 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) { + invalidatePagesServerEntry(); + invalidateHybridClientEntries(); + revalidateHybridRoutes(); } }); @@ -3750,6 +3889,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(); @@ -3785,11 +3938,28 @@ 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. - const renderMatch = matchRoute(pipelineResult.resolvedUrl.split("?")[0], routes); - if (!renderMatch && hasAppDir) { - return next(); + // there). If both routers match, apply Next.js's merged route + // precedence before choosing which plugin owns the request. + const resolvedPathname = pipelineResult.resolvedUrl + .split("#", 1)[0] + .split("?", 1)[0]; + const renderMatch = matchRoute(resolvedPathname, routes); + if (hasAppDir && appDir) { + if (!renderMatch) { + return next(); + } + const appRoutes = await appRouter( + appDir, + nextConfig?.pageExtensions, + fileMatcher, + ); + const appMatch = matchAppRoute(resolvedPathname, appRoutes); + if ( + appMatch && + !pagesRouteHasPriorityOverAppRoute(renderMatch.route, appMatch.route) + ) { + return next(); + } } const handler = createSSRHandler( server, diff --git a/packages/vinext/src/routing/utils.ts b/packages/vinext/src/routing/utils.ts index 2c4faaf18..333953376 100644 --- a/packages/vinext/src/routing/utils.ts +++ b/packages/vinext/src/routing/utils.ts @@ -97,6 +97,70 @@ 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. 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" + * 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" { + if (pagesPattern === 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 + // 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"; + 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"; + } + + 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"; +} + // 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/app-pages-bridge.ts b/packages/vinext/src/server/app-pages-bridge.ts index 1cb222b55..57aed8aba 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,12 @@ type RenderPagesFallbackDependencies = { }; type RenderPagesFallbackOptions = { + allowRscDocumentFallback?: boolean; + appRouteMatch?: AppRouteMatch | null; isRscRequest: boolean; + matchKind?: "dynamic" | "static"; middlewareContext: AppMiddlewareContext; + pathname?: string; request: Request; url: URL; }; @@ -54,7 +75,16 @@ export async function renderPagesFallback( options: RenderPagesFallbackOptions, dependencies: RenderPagesFallbackDependencies, ): Promise { - const { isRscRequest, middlewareContext, request, url } = options; + const { + allowRscDocumentFallback = false, + appRouteMatch = null, + isRscRequest, + matchKind, + middlewareContext, + pathname = options.url.pathname, + request, + url, + } = options; const { loadPagesEntry, buildRequestHeaders, @@ -63,7 +93,7 @@ export async function renderPagesFallback( getDraftModeCookieHeader, } = dependencies; - if (isRscRequest) return null; + if (isRscRequest && !allowRscDocumentFallback) return null; const pagesEntry = await loadPagesEntry(); @@ -84,10 +114,27 @@ export async function renderPagesFallback( pagesRequest = new Request(request.url, pagesRequestInit); } - const pagesUrl = decodePathParams(url.pathname) + (url.search || ""); - const pagesPathname = url.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"; + const apiMatch = hasApiMatcher + ? (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 || + !pagesRouteHasPriorityOverAppRoute(apiMatch.route, appRouteMatch.route) + ) { + return null; + } + } const pagesApiResponse = await pagesEntry.handleApiRoute(pagesRequest, pagesUrl); const draftCookie = getDraftModeCookieHeader(); return applyDraftModeCookie( @@ -97,6 +144,19 @@ 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 (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)) + ) { + return null; + } const pagesRes = await pagesEntry.renderPage( pagesRequest, pagesUrl, @@ -104,7 +164,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 e9d560ece..02ce80d46 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -43,6 +43,7 @@ import { resolveInvalidRscCacheBustingRequest, stripRscCacheBustingSearchParam, stripRscSuffix, + VINEXT_RSC_CACHE_BUSTING_SEARCH_PARAM, } from "./app-rsc-cache-busting.js"; import { finalizeAppRscResponse } from "./app-rsc-response-finalizer.js"; import { normalizeRscRequest } from "./app-rsc-request-normalization.js"; @@ -231,8 +232,12 @@ type RenderNotFoundOptions = { }; type RenderPagesFallbackOptions = { + allowRscDocumentFallback?: boolean; + appRouteMatch?: { route: { isDynamic: boolean; pattern: string } } | null; isRscRequest: boolean; + matchKind?: "dynamic" | "static"; middlewareContext: AppRscMiddlewareContext; + pathname?: string; request: Request; url: URL; }; @@ -354,18 +359,21 @@ async function applyRewrite( return rewritten; } -function applyInternalRewriteDestination(rewritten: string, url: URL): string { - const merged = mergeRewriteQuery(`${url.pathname}${url.search}`, rewritten); - const hashIndex = merged.indexOf("#"); - const beforeHash = hashIndex === -1 ? merged : merged.slice(0, hashIndex); - const queryIndex = beforeHash.indexOf("?"); - if (queryIndex === -1) { - url.search = ""; - return beforeHash; - } +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, + }; +} - url.search = beforeHash.slice(queryIndex); - return beforeHash.slice(0, queryIndex); +function pathnameForResolvedUrl(resolvedUrl: string): string { + return resolvedUrl.split("#", 1)[0].split("?", 1)[0]; } function applyConfigHeadersToMiddlewareRedirect( @@ -458,6 +466,8 @@ async function handleAppRscRequest( clientReuseManifest, } = 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 @@ -546,6 +556,7 @@ async function handleAppRscRequest( requestHeaders: null, status: null, }; + let didMiddlewareRewrite = false; if (options.middlewareModule) { const middlewareResult = await applyAppMiddleware({ @@ -569,9 +580,11 @@ async function handleAppRscRequest( } cleanPathname = middlewareResult.cleanPathname; + didMiddlewareRewrite = cleanPathname !== normalized.cleanPathname; if (middlewareResult.search !== null) { url.search = middlewareResult.search; } + resolvedUrl = cleanPathname + url.search; } const scriptNonce = getScriptNonceFromHeaderSources(request.headers, middlewareContext.headers); @@ -582,20 +595,29 @@ 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, - // External RSC rewrites must forward the validated `_rsc` token so the - // destination server can validate the request without the original URL. - request: normalizedUserlandRequest, - requestContext: postMiddlewareRequestContext, - rewrites: options.configRewrites.beforeFiles, - }, - matchPathname(cleanPathname), - ); - if (beforeFilesRewrite instanceof Response) return beforeFilesRewrite; - if (beforeFilesRewrite) cleanPathname = applyInternalRewriteDestination(beforeFilesRewrite, url); + for (const rewrite of options.configRewrites.beforeFiles) { + const beforeFilesRewrite = await applyRewrite( + { + basePathState, + clearRequestContext: options.clearRequestContext, + // External RSC rewrites must forward the validated `_rsc` token so the + // destination server can validate the request without the original URL. + request: normalizedUserlandRequest, + requestContext: requestContextForResolvedUrl( + postMiddlewareRequestContext, + resolvedUrl, + url, + ), + rewrites: [rewrite], + }, + matchPathname(cleanPathname), + ); + if (beforeFilesRewrite instanceof Response) return beforeFilesRewrite; + if (beforeFilesRewrite) { + resolvedUrl = mergeRewriteQuery(resolvedUrl, beforeFilesRewrite); + cleanPathname = pathnameForResolvedUrl(resolvedUrl); + } + } if (isImageOptimizationPath(cleanPathname)) { const imageRedirect = resolveDevImageRedirect( @@ -641,10 +663,13 @@ async function handleAppRscRequest( } stripRscCacheBustingSearchParam(url); + const resolved = new URL(resolvedUrl, url); + stripRscCacheBustingSearchParam(resolved); + resolvedUrl = resolved.pathname + resolved.search + resolved.hash; options.setNavigationContext({ pathname: canonicalPathname, - searchParams: url.searchParams, + searchParams: getResolvedSearchParams(), params: {}, }); @@ -697,46 +722,106 @@ async function handleAppRscRequest( middlewareContext, mountedSlotsHeader, request, - searchParams: url.searchParams, + searchParams: getResolvedSearchParams(), }); if (serverActionResponse) return serverActionResponse; let match = preActionMatch; + const renderPagesForMatchKind = async ( + matchKind: "dynamic" | "static", + ): Promise => + match === null || match.route.isDynamic + ? ((await options.renderPagesFallback?.({ + appRouteMatch: match ?? null, + allowRscDocumentFallback: didMiddlewareRewrite, + isRscRequest, + matchKind, + middlewareContext, + pathname: resolvedUrl, + request, + url, + })) ?? null) + : null; + const staticPagesFallbackResponse = await renderPagesForMatchKind("static"); + if (staticPagesFallbackResponse) { + options.clearRequestContext(); + return staticPagesFallbackResponse; + } if (!match || match.route.isDynamic) { - const afterFilesRewrite = await applyRewrite( - { - basePathState, - clearRequestContext: options.clearRequestContext, - // External RSC rewrites must forward the validated `_rsc` token. - request: normalizedUserlandRequest, - requestContext: postMiddlewareRequestContext, - rewrites: options.configRewrites.afterFiles, - }, - matchPathname(cleanPathname), - ); - if (afterFilesRewrite instanceof Response) return afterFilesRewrite; - if (afterFilesRewrite) { - cleanPathname = applyInternalRewriteDestination(afterFilesRewrite, url); + for (const rewrite of options.configRewrites.afterFiles) { + const afterFilesRewrite = await applyRewrite( + { + basePathState, + clearRequestContext: options.clearRequestContext, + // External RSC rewrites must forward the validated `_rsc` token. + request: normalizedUserlandRequest, + requestContext: requestContextForResolvedUrl( + postMiddlewareRequestContext, + resolvedUrl, + url, + ), + rewrites: [rewrite], + }, + matchPathname(cleanPathname), + ); + if (afterFilesRewrite instanceof Response) return afterFilesRewrite; + if (!afterFilesRewrite) continue; + resolvedUrl = mergeRewriteQuery(resolvedUrl, afterFilesRewrite); + cleanPathname = pathnameForResolvedUrl(resolvedUrl); 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; + } + if (match) break; } } + const dynamicPagesFallbackResponse = await renderPagesForMatchKind("dynamic"); + if (dynamicPagesFallbackResponse) { + options.clearRequestContext(); + return dynamicPagesFallbackResponse; + } + if (!match) { - const fallbackRewrite = await applyRewrite( - { - basePathState, - clearRequestContext: options.clearRequestContext, - // External RSC rewrites must forward the validated `_rsc` token. - request: normalizedUserlandRequest, - requestContext: postMiddlewareRequestContext, - rewrites: options.configRewrites.fallback, - }, - matchPathname(cleanPathname), - ); - if (fallbackRewrite instanceof Response) return fallbackRewrite; - if (fallbackRewrite) { - cleanPathname = applyInternalRewriteDestination(fallbackRewrite, url); + for (const rewrite of options.configRewrites.fallback) { + const fallbackRewrite = await applyRewrite( + { + basePathState, + clearRequestContext: options.clearRequestContext, + // External RSC rewrites must forward the validated `_rsc` token. + request: normalizedUserlandRequest, + requestContext: requestContextForResolvedUrl( + postMiddlewareRequestContext, + resolvedUrl, + url, + ), + rewrites: [rewrite], + }, + matchPathname(cleanPathname), + ); + if (fallbackRewrite instanceof Response) return fallbackRewrite; + if (!fallbackRewrite) continue; + resolvedUrl = mergeRewriteQuery(resolvedUrl, fallbackRewrite); + cleanPathname = pathnameForResolvedUrl(resolvedUrl); 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; + } + if (match) break; } } @@ -753,17 +838,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, @@ -792,6 +866,7 @@ async function handleAppRscRequest( const prerenderRouteParams = prerenderRouteParamsMatch?.params ?? null; const isPrerenderFallbackShell = prerenderRouteParamsMatch?.kind === "fallback-shell"; const renderParams = prerenderRouteParams ?? params; + const resolvedSearchParams = getResolvedSearchParams(); const runtimeFallbackShells = options.cacheComponents === true && request.method === "GET" && @@ -809,7 +884,7 @@ async function handleAppRscRequest( : []; options.setNavigationContext({ pathname: canonicalPathname, - searchParams: url.searchParams, + searchParams: resolvedSearchParams, params: renderParams, }); const rootParams = pickRootParams(renderParams, route.rootParamNames); @@ -826,6 +901,14 @@ async function handleAppRscRequest( const routeHandlerRequest = isEdgeRouteHandler(route.routeHandler) ? userlandRequest : normalizedUserlandRequest; + const routeHandlerUrl = new URL(routeHandlerRequest.url); + const internalRscValues = isEdgeRouteHandler(route.routeHandler) + ? [] + : routeHandlerUrl.searchParams.getAll(VINEXT_RSC_CACHE_BUSTING_SEARCH_PARAM); + routeHandlerUrl.search = resolvedSearchParams.toString(); + for (const internalRscValue of internalRscValues) { + routeHandlerUrl.searchParams.append(VINEXT_RSC_CACHE_BUSTING_SEARCH_PARAM, internalRscValue); + } return options.dispatchMatchedRouteHandler({ cleanPathname, middlewareContext, @@ -834,9 +917,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: routeHandlerRequest, + request: new Request(routeHandlerUrl, routeHandlerRequest), route, - searchParams: url.searchParams, + searchParams: resolvedSearchParams, }); } @@ -869,7 +952,7 @@ async function handleAppRscRequest( request, route, scriptNonce, - searchParams: url.searchParams, + searchParams: resolvedSearchParams, renderMode, }); 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..1be6981c1 --- /dev/null +++ b/packages/vinext/src/server/hybrid-route-priority.ts @@ -0,0 +1,62 @@ +import { compareHybridRoutePatterns } from "../routing/utils.js"; +import { validateRoutePatterns } from "../routing/route-validation.js"; + +export type HybridRoutePriorityRoute = { + isDynamic: boolean; + pattern: string; + sourcePath?: string | null; +}; + +export function validateHybridRouteConflicts( + pagesRoutes: readonly HybridRoutePriorityRoute[], + appRoutes: readonly HybridRoutePriorityRoute[], +): void { + 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) { + 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")}`, + ); + } + + validateRoutePatterns([ + ...pagesRoutes.map((route) => route.pattern), + ...appRoutes.map((route) => route.pattern), + ]); +} + +/** + * 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. 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; + return ( + compareHybridRoutePatterns( + pagesRoute.pattern, + pagesRoute.isDynamic, + appRoute.pattern, + appRoute.isDynamic, + ) === "pages" + ); +} diff --git a/packages/vinext/src/server/pages-request-pipeline.ts b/packages/vinext/src/server/pages-request-pipeline.ts index 526c41284..15502d505 100644 --- a/packages/vinext/src/server/pages-request-pipeline.ts +++ b/packages/vinext/src/server/pages-request-pipeline.ts @@ -363,7 +363,12 @@ export async function runPagesRequest( { preserveCredentialHeaders: isExternalUrl(resolvedUrl) }, ); request = postMwReq; - let resolvedPathname = resolvedUrl.split("?")[0]; + const pathnameForResolvedUrl = (value: string): string => value.split("#", 1)[0].split("?", 1)[0]; + const rewriteRequestContext = (): RequestContext => ({ + ...postMwReqCtx, + query: new URL(resolvedUrl, url).searchParams, + }); + let resolvedPathname = pathnameForResolvedUrl(resolvedUrl); const matchResolvedPathname = (p: string): string => i18nConfig ? normalizeDefaultLocalePathname(p, i18nConfig, { hostname: requestHostname }) : p; @@ -408,22 +413,23 @@ 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; - if (configRewrites.beforeFiles?.length) { - for (const rewrite of configRewrites.beforeFiles) { - const rewritten = matchRewrite( - matchResolvedPathname(resolvedPathname), - [rewrite], - postMwReqCtx, - basePathState, - ); - if (!rewritten) continue; + for (const rewrite of configRewrites.beforeFiles ?? []) { + const rewritten = matchRewrite( + matchResolvedPathname(resolvedPathname), + [rewrite], + rewriteRequestContext(), + basePathState, + ); + if (rewritten) { if (isExternalUrl(rewritten)) { // Bare proxy — no middleware-header merge (see Step 8 asymmetry note). return { type: "response", response: await proxyExternal(request, rewritten) }; } resolvedUrl = mergeRewriteQuery(resolvedUrl, rewritten); - resolvedPathname = resolvedUrl.split("?")[0]; + resolvedPathname = pathnameForResolvedUrl(resolvedUrl); configRewriteFired = true; } } @@ -474,25 +480,29 @@ export async function runPagesRequest( } // Step 12: afterFiles rewrites - const pageMatch = deps.matchPageRoute ? deps.matchPageRoute(resolvedPathname, request) : null; + let pageMatch = deps.matchPageRoute ? deps.matchPageRoute(resolvedPathname, request) : null; // matchPageRoute is a route-table scan; only re-run it below if afterFiles // actually rewrote resolvedPathname (the common case leaves it unchanged). let resolvedPathnameChanged = false; - if ((!pageMatch || pageMatch.route.isDynamic) && configRewrites.afterFiles?.length) { - const rewritten = matchRewrite( - matchResolvedPathname(resolvedPathname), - configRewrites.afterFiles, - postMwReqCtx, - basePathState, - ); - if (rewritten) { - if (isExternalUrl(rewritten)) { - // Bare proxy — no middleware-header merge (see Step 8 asymmetry note). - return { type: "response", response: await proxyExternal(request, rewritten) }; + if (!pageMatch || pageMatch.route.isDynamic) { + for (const rewrite of configRewrites.afterFiles ?? []) { + const rewritten = matchRewrite( + matchResolvedPathname(resolvedPathname), + [rewrite], + rewriteRequestContext(), + basePathState, + ); + if (rewritten) { + if (isExternalUrl(rewritten)) { + // Bare proxy — no middleware-header merge (see Step 8 asymmetry note). + return { type: "response", response: await proxyExternal(request, rewritten) }; + } + resolvedUrl = mergeRewriteQuery(resolvedUrl, rewritten); + resolvedPathname = pathnameForResolvedUrl(resolvedUrl); + resolvedPathnameChanged = true; + pageMatch = deps.matchPageRoute ? deps.matchPageRoute(resolvedPathname, request) : null; + if (pageMatch) break; } - resolvedUrl = mergeRewriteQuery(resolvedUrl, rewritten); - resolvedPathname = resolvedUrl.split("?")[0]; - resolvedPathnameChanged = true; } } @@ -512,19 +522,16 @@ export async function runPagesRequest( // Step 13: Render + fallback rewrites if (typeof deps.renderPage === "function") { // Reuse the Step 12 match unless afterFiles changed the pathname. - let renderPageMatch = resolvedPathnameChanged - ? deps.matchPageRoute - ? deps.matchPageRoute(resolvedPathname, request) - : null - : pageMatch; + let renderPageMatch = pageMatch; if ((isDataReq || isDataRequest) && !renderPageMatch && configRewrites.fallback?.length) { - const fallbackRewrite = matchRewrite( - matchResolvedPathname(resolvedPathname), - configRewrites.fallback, - postMwReqCtx, - basePathState, - ); - if (fallbackRewrite) { + for (const rewrite of configRewrites.fallback) { + const fallbackRewrite = matchRewrite( + matchResolvedPathname(resolvedPathname), + [rewrite], + rewriteRequestContext(), + basePathState, + ); + if (!fallbackRewrite) continue; if (isExternalUrl(fallbackRewrite)) { return { type: "response", @@ -532,11 +539,12 @@ export async function runPagesRequest( }; } resolvedUrl = mergeRewriteQuery(resolvedUrl, fallbackRewrite); - resolvedPathname = resolvedUrl.split("?")[0]; + resolvedPathname = pathnameForResolvedUrl(resolvedUrl); renderPageMatch = deps.matchPageRoute ? deps.matchPageRoute(resolvedPathname, request) : null; refreshDataRewriteHeader(); + if (renderPageMatch) break; } } // A data request must not defer-render the error page or run fallback rewrites. @@ -570,13 +578,14 @@ export async function runPagesRequest( // Fallback rewrites if 404 + deferred let matchedFallbackRewrite = false; if (response.status === 404 && shouldDeferErrorPageOnMiss && configRewrites.fallback?.length) { - const fallbackRewrite = matchRewrite( - matchResolvedPathname(resolvedPathname), - configRewrites.fallback, - postMwReqCtx, - basePathState, - ); - if (fallbackRewrite) { + for (const rewrite of configRewrites.fallback) { + const fallbackRewrite = matchRewrite( + matchResolvedPathname(resolvedPathname), + [rewrite], + rewriteRequestContext(), + basePathState, + ); + if (!fallbackRewrite) continue; if (isExternalUrl(fallbackRewrite)) { // Bare proxy — no middleware-header merge (see Step 8 asymmetry note). return { @@ -584,13 +593,11 @@ export async function runPagesRequest( response: await proxyExternal(request, fallbackRewrite), }; } - response = await deps.renderPage( - request, - mergeRewriteQuery(resolvedUrl, fallbackRewrite), - undefined, - stagedHeaders, - ); + resolvedUrl = mergeRewriteQuery(resolvedUrl, fallbackRewrite); + resolvedPathname = pathnameForResolvedUrl(resolvedUrl); + response = await deps.renderPage(request, resolvedUrl, undefined, stagedHeaders); matchedFallbackRewrite = true; + if (response.status !== 404) break; } } @@ -622,18 +629,21 @@ export async function runPagesRequest( : null : pageMatch; if (!devPageMatch && configRewrites.fallback?.length) { - const fallbackRewrite = matchRewrite( - matchResolvedPathname(resolvedPathname), - configRewrites.fallback, - postMwReqCtx, - basePathState, - ); - if (fallbackRewrite) { + for (const rewrite of configRewrites.fallback) { + const fallbackRewrite = matchRewrite( + matchResolvedPathname(resolvedPathname), + [rewrite], + rewriteRequestContext(), + basePathState, + ); + if (!fallbackRewrite) continue; if (isExternalUrl(fallbackRewrite)) { // Bare proxy — no middleware-header merge (see Step 8 asymmetry note). return { type: "response", response: await proxyExternal(request, fallbackRewrite) }; } resolvedUrl = mergeRewriteQuery(resolvedUrl, fallbackRewrite); + resolvedPathname = pathnameForResolvedUrl(resolvedUrl); + if (deps.matchPageRoute?.(resolvedPathname, request)) break; } } refreshDataRewriteHeader(); diff --git a/packages/vinext/src/shims/internal/app-route-detection.ts b/packages/vinext/src/shims/internal/app-route-detection.ts index 20c9fc024..ac0fe0f5b 100644 --- a/packages/vinext/src/shims/internal/app-route-detection.ts +++ b/packages/vinext/src/shims/internal/app-route-detection.ts @@ -35,11 +35,9 @@ * 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"; - -const appRouteTrieCache = createRouteTrieCache(); +import { resolveHybridClientRouteOwner } from "./hybrid-client-route-owner.js"; declare global { // oxlint-disable-next-line typescript-eslint/consistent-type-definitions @@ -100,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 @@ -130,7 +112,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/internal/hybrid-client-route-owner.ts b/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts new file mode 100644 index 000000000..fa1b8a4f2 --- /dev/null +++ b/packages/vinext/src/shims/internal/hybrid-client-route-owner.ts @@ -0,0 +1,215 @@ +/** + * Client-side resolver that decides whether a URL should be soft-navigated + * (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. + * + * 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 { compareHybridRoutePatterns } from "../../routing/utils.js"; +import { + isExternalUrl, + 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, +} from "../../client/vinext-next-data.js"; + +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[]; + }; + } +} + +function resolveClientRewrite( + href: string, + basePath: string, + rewrites: readonly NextRewrite[], + continueAfterMatch = false, +): { kind: "document" } | { href: string; kind: "rewrite" } | null { + const initialUrl = new URL(href, window.location.href); + const basePathState = { + basePath, + hadBasePath: basePath + ? initialUrl.pathname === basePath || initialUrl.pathname.startsWith(`${basePath}/`) + : true, + }; + 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(); +const pagesRouteTrieCache = createRouteTrieCache(); + +/** + * Build a `/`-joined pattern from a manifest's `patternParts`. Mirrors 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. + */ +function patternFromParts(parts: readonly string[]): string { + return "/" + parts.join("/"); +} + +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 rewrites = window.__VINEXT_CLIENT_REWRITES__; + + if (rewrites) { + const beforeFilesRewrite = resolveClientRewrite(href, basePath, rewrites.beforeFiles, true); + 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) + ) { + 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) { + 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; + } + } + + if (appMatch === null && pagesMatch === null) return null; + 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/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx index cf60ac630..3008d3436 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, @@ -391,6 +392,17 @@ 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. + const hybridOwner = resolveHybridClientRouteOwner(prefetchHref, __basePath); + if (hybridOwner === "pages" || hybridOwner === "document") { + return; + } const autoPrefetch = mode === "auto" ? resolveAutoAppRoutePrefetch(prefetchHref) @@ -977,6 +989,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 && + ["pages", "document"].includes(resolveHybridClientRouteOwner(navigateHref, __basePath) ?? "") + ) { + 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/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 80a00995e..1847f9103 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -54,6 +54,7 @@ import { assertSafeNavigationUrl } from "./url-safety.js"; import { markPprFallbackShellDynamicBoundary } from "./ppr-fallback-shell.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, @@ -1760,6 +1761,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. */ @@ -1788,17 +1802,34 @@ 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`. + const hybridOwner = resolveHybridClientRouteOwner(normalizedHref, __basePath); + if (hybridOwner === "pages" || hybridOwner === "document") { + 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); stageAppNavigationFailureTarget(fullHref); // Match Next.js: App Router reports navigation start before dispatching, @@ -1841,11 +1872,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; } @@ -2027,6 +2054,17 @@ 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. + const hybridOwner = resolveHybridClientRouteOwner(prefetchHref, __basePath); + if (hybridOwner === "pages" || hybridOwner === "document") { + 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/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index 3c8f5530e..9ada50ab7 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"; @@ -2298,7 +2298,7 @@ async function performNavigation( appPathNorm !== null ? getPagesRouterComponentsMap()[appPathNorm] : undefined; const appRouteDetected = (appPathEntry !== undefined && "__appRouter" in appPathEntry && appPathEntry.__appRouter) || - matchesAppRoute(resolved, __basePath); + ["app", "document"].includes(resolveHybridClientRouteOwner(resolved, __basePath) ?? ""); if (appRouteDetected) { if (mode === "push") window.location.assign(full); else window.location.replace(full); diff --git a/playwright.config.ts b/playwright.config.ts index b9e1f7549..3cdded984 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, + }, + }, "ppr-impact-demo": { testDir: "./tests/e2e/ppr-impact-demo", use: { baseURL: "http://localhost:4187" }, @@ -241,12 +253,12 @@ const projectServers = { }, "app-front-redirect-issue": { testDir: "./tests/e2e/app-front-redirect-issue", - use: { baseURL: "http://localhost:4186" }, + use: { baseURL: "http://localhost:4188" }, server: { command: - "(test -e node_modules || test -L node_modules || ln -s ../../../fixtures/app-basic/node_modules node_modules) && npx vp run vinext#build && NEXT_DEPLOYMENT_ID=vinext-front-redirect-e2e node ../../../../packages/vinext/dist/cli.js build && NEXT_DEPLOYMENT_ID=vinext-front-redirect-e2e node ../../../../packages/vinext/dist/cli.js start --port 4186", + "(test -e node_modules || test -L node_modules || ln -s ../../../fixtures/app-basic/node_modules node_modules) && npx vp run vinext#build && NEXT_DEPLOYMENT_ID=vinext-front-redirect-e2e node ../../../../packages/vinext/dist/cli.js build && NEXT_DEPLOYMENT_ID=vinext-front-redirect-e2e node ../../../../packages/vinext/dist/cli.js start --port 4188", cwd: "./tests/e2e/app-front-redirect-issue/fixture", - port: 4186, + port: 4188, reuseExistingServer: !process.env.CI, timeout: 60_000, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99305a6fe..fdbf672e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1586,6 +1586,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-pages-bridge.test.ts b/tests/app-pages-bridge.test.ts index 562d2f421..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 = { @@ -199,6 +218,143 @@ 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("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("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-router-next-config-codegen.test.ts b/tests/app-router-next-config-codegen.test.ts index cebeeddb6..62a151b7c 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( + "{ allowRscDocumentFallback, appRouteMatch, isRscRequest, matchKind, 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/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index f60f45323..c2211a363 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -592,6 +592,95 @@ 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("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("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: [ @@ -1008,6 +1097,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" @@ -1017,7 +1131,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: { @@ -1039,6 +1156,171 @@ 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("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("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) => { + 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, + 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"] }), @@ -1112,6 +1394,215 @@ 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({ + 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("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.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.each(["beforeFiles", "afterFiles", "fallback"] as const)( + "excludes rewrite fragments from %s route matching", + async (rewritePhase) => { + const matchRoute = vi.fn((pathname: string) => + pathname === "/about" + ? { + params: {}, + route: createPageRoute(), + } + : null, + ); + const handler = createHandler({ + configHeaders: [], + configRewrites: { + beforeFiles: + rewritePhase === "beforeFiles" + ? [{ source: "/legacy/:code", destination: "/about#:code" }] + : [], + afterFiles: + rewritePhase === "afterFiles" + ? [{ source: "/legacy/:code", destination: "/about#:code" }] + : [], + fallback: + rewritePhase === "fallback" + ? [{ source: "/legacy/:code", destination: "/about#:code" }] + : [], + }, + matchRoute, + }); + + const response = await handler(new Request("https://example.test/docs/legacy/500"), null); + + expect(response.status).toBe(200); + expect(matchRoute).toHaveBeenCalledWith("/about"); + expect(matchRoute).not.toHaveBeenCalledWith("/about#500"); + }, + ); + 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/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"); }); }); 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..593e6a342 --- /dev/null +++ b/tests/e2e/use-params-app-pages/use-params.spec.ts @@ -0,0 +1,127 @@ +// 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`); + 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 }) => { + 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("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("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("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, + }) => { + // 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(); + await page.locator("a").hover(); + await expect(page.locator("#random")).toHaveText(initialRandom ?? ""); + }); +}); diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index 255a2dd20..7488eddce 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/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/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/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/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..25cac22b9 --- /dev/null +++ b/tests/fixtures/use-params-app-pages/app/page.tsx @@ -0,0 +1,59 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; + +export default function Page() { + const router = useRouter(); + return ( + <> +
+ + To /a + +
+
+ + To /a/b + +
+
+ + To /pages-dir/foobar (Pages) + +
+
+ +
+
+ +
+
+ + To /account/details (App) + +
+
+ +
+ + ); +} 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/[id]/details.tsx b/tests/fixtures/use-params-app-pages/pages/[id]/details.tsx new file mode 100644 index 000000000..43d9b824b --- /dev/null +++ b/tests/fixtures/use-params-app-pages/pages/[id]/details.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
pages
; +} 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-client-route-owner.test.ts b/tests/hybrid-client-route-owner.test.ts new file mode 100644 index 000000000..e440e4aa4 --- /dev/null +++ b/tests/hybrid-client-route-owner.test.ts @@ -0,0 +1,322 @@ +/** + * 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 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/"; + +type WindowState = { + app: VinextLinkPrefetchRoute[]; + pages: VinextPagesLinkPrefetchRoute[]; + rewrites?: { + afterFiles: NextRewrite[]; + beforeFiles: NextRewrite[]; + fallback: NextRewrite[]; + }; +}; + +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, + }; +} + +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, +}); + +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: [] }); + 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("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("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) => { + 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("evaluates conditional client rewrites against browser-visible context", () => { + installWindow({ + app: [appRoute(["app-destination"], false)], + pages: [], + rewrites: { + afterFiles: [], + beforeFiles: [ + { + source: "/source", + destination: "/app-destination", + has: [{ type: "query", key: "preview", value: "1" }], + }, + ], + fallback: [], + }, + }); + + expect(resolveHybridClientRouteOwner("/source", "")).toBeNull(); + expect(resolveHybridClientRouteOwner("/source?preview=1", "")).toBe("app"); + }); + + 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?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). + 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("rejects an identical static App and Pages route", () => { + installWindow({ + app: [appRoute([], false)], + pages: [pagesRoute([], false)], + }); + expect(() => resolveHybridClientRouteOwner("/", "")).toThrow("Conflicting app and page routes"); + }); + + it("retains Pages provider order after merged route validation", () => { + installWindow({ + app: [appRoute([":slug"])], + pages: [pagesRoute([":id"])], + }); + 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("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("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"])], + 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 new file mode 100644 index 000000000..6cc7125c4 --- /dev/null +++ b/tests/hybrid-route-priority.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; +import { compareHybridRoutePatterns } from "../packages/vinext/src/routing/utils.js"; +import { + pagesRouteHasPriorityOverAppRoute, + validateHybridRouteConflicts, +} 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("rejects an identical static App and Pages route", () => { + expect(() => compareHybridRoutePatterns("/", false, "/", false)).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", () => { + // /_sites/:slug* must beat /:slug*. `routePrecedence` reduces the + // 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's Next.js-style segment ordering 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", + ); + }); + + 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", + ); + }); + + 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("uses the Next.js slug-name error for structurally identical dynamic routes", () => { + expect(() => + validateHybridRouteConflicts( + [{ isDynamic: true, pattern: "/:slug" }], + [{ isDynamic: true, pattern: "/:id" }], + ), + ).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"'); + }); +}); + +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("rejects an identical static App and Pages route", () => { + expect(() => + pagesRouteHasPriorityOverAppRoute( + { isDynamic: false, pattern: "/" }, + { isDynamic: false, pattern: "/" }, + ), + ).toThrow("Conflicting app and page routes"); + }); + + it("retains Pages provider order after merged route validation", () => { + expect( + pagesRouteHasPriorityOverAppRoute( + { isDynamic: true, pattern: "/:slug" }, + { isDynamic: true, pattern: "/:id" }, + ), + ).toBe(true); + }); +}); diff --git a/tests/pages-request-pipeline.test.ts b/tests/pages-request-pipeline.test.ts index 27f67053e..3548c239c 100644 --- a/tests/pages-request-pipeline.test.ts +++ b/tests/pages-request-pipeline.test.ts @@ -377,6 +377,36 @@ describe("beforeFiles rewrites", () => { ); }); + it("applies chained beforeFiles rewrites with accumulated query conditions", async () => { + const renderPage = makeRenderPage(200); + const result = await runPagesRequest( + makeRequest("/from?keep=1"), + baseDeps({ + configRewrites: { + beforeFiles: [ + { source: "/from", destination: "/middle?stage=1" }, + { + source: "/middle", + destination: "/to", + has: [{ type: "query", key: "stage", value: "1" }], + }, + ], + afterFiles: [], + fallback: [], + }, + renderPage, + }), + ); + + expect(result.type).toBe("response"); + expect(renderPage).toHaveBeenCalledWith( + expect.any(Request), + "/to?keep=1&stage=1", + undefined, + expect.any(Headers), + ); + }); + it("applies every matching beforeFiles rewrite in sequence", async () => { const renderPage = makeRenderPage(200); const result = await runPagesRequest( @@ -405,6 +435,27 @@ describe("beforeFiles rewrites", () => { ); expect(result.response.headers.get("x-nextjs-rewrite")).toBe("/destination?first=1&second=2"); }); + + it("excludes beforeFiles fragments from Pages route matching", async () => { + const matchPageRoute = vi.fn((pathname: string) => + pathname === "/to" ? { route: { isDynamic: false } } : null, + ); + await runPagesRequest( + makeRequest("/from"), + baseDeps({ + configRewrites: { + beforeFiles: [{ source: "/from", destination: "/to#section" }], + afterFiles: [], + fallback: [], + }, + matchPageRoute, + renderPage: makeRenderPage(200), + }), + ); + + expect(matchPageRoute).toHaveBeenCalledWith("/to", expect.any(Request)); + expect(matchPageRoute).not.toHaveBeenCalledWith("/to#section", expect.any(Request)); + }); }); // 10. Out-of-basePath reject when basePath: "/base" and hadBasePath: false and no configRewrite fired @@ -585,6 +636,36 @@ describe("afterFiles rewrites", () => { expect.any(Headers), ); }); + + it("continues afterFiles rewrites until a Pages destination resolves", async () => { + const renderPage = makeRenderPage(200); + const matchPageRoute = vi.fn((pathname: string) => + pathname === "/resolved" ? { route: { isDynamic: false } } : null, + ); + await runPagesRequest( + makeRequest("/from"), + baseDeps({ + configRewrites: { + beforeFiles: [], + afterFiles: [ + { source: "/from", destination: "/missing" }, + { source: "/missing", destination: "/resolved#section" }, + ], + fallback: [], + }, + matchPageRoute, + renderPage, + }), + ); + + expect(matchPageRoute).toHaveBeenCalledWith("/resolved", expect.any(Request)); + expect(renderPage).toHaveBeenCalledWith( + expect.any(Request), + "/resolved#section", + undefined, + expect.any(Headers), + ); + }); }); // 14. Render intent when renderPage absent → {type:"render"} @@ -677,6 +758,38 @@ describe("fallback rewrites on 404", () => { expect(result.response.status).toBe(200); expect(callCount).toBe(2); }); + + it("continues fallback rewrites after an unresolved destination", async () => { + const renderPage = vi.fn( + async (_req: Request, resolvedUrl: string) => + new Response(resolvedUrl, { status: resolvedUrl.startsWith("/resolved") ? 200 : 404 }), + ); + const result = await runPagesRequest( + makeRequest("/from"), + baseDeps({ + matchPageRoute: vi.fn().mockReturnValue(null), + configRewrites: { + beforeFiles: [], + afterFiles: [], + fallback: [ + { source: "/from", destination: "/missing" }, + { source: "/missing", destination: "/resolved#section" }, + ], + }, + renderPage, + }), + ); + + expect(result.type).toBe("response"); + if (result.type !== "response") return; + expect(result.response.status).toBe(200); + expect(renderPage).toHaveBeenLastCalledWith( + expect.any(Request), + "/resolved#section", + undefined, + expect.any(Headers), + ); + }); }); // 17. shouldDeferErrorPageOnMiss: 404 → no fallback → deferred error page re-render diff --git a/tests/pages-router-app-prefetch-detection.test.ts b/tests/pages-router-app-prefetch-detection.test.ts index be8672bd8..b16001a7b 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; @@ -230,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, @@ -238,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 () => { @@ -252,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 }); @@ -267,9 +276,32 @@ 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"); + + markAppRouteDetectedOnPrefetch("https://example.com/about", ""); + expect(getPagesRouterComponentsMap()).toEqual({}); + }); + + 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"); - expect(matchesAppRoute("https://example.com/about", "")).toBe(false); + 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(); }); }); diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 3003c5e86..86d85904f 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -2384,8 +2384,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(); } @@ -2656,7 +2666,7 @@ describe("Plugin config", () => { { command: "serve", mode: "development" }, ); - expect(() => + await expect( configPlugin.configResolved({ command: "serve", configFile: false, @@ -2667,7 +2677,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 () => { diff --git a/tests/prerender.test.ts b/tests/prerender.test.ts index ac9a4af71..090ad1749 100644 --- a/tests/prerender.test.ts +++ b/tests/prerender.test.ts @@ -1542,19 +1542,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"); } }); diff --git a/tests/shims.test.ts b/tests/shims.test.ts index b8abba4c4..33ec50d9b 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -11211,6 +11211,50 @@ 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"); + }); + + 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", () => {