diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 6695708f7..13897ef2a 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -579,21 +579,6 @@ async function resolveAppWarmPaths(options: { const loadingShellPaths: string[] = []; for (const pathname of options.paths) { const appMatch = matchAppRoute(pathname, appRoutes); - if (!appMatch) continue; - // The trie returns the exact object from appRoutes. Its public matcher type - // exposes the shared AppRoute fields, so recover the graph-owned metadata - // here without rescanning the route table for every concrete path. - const matchedAppRoute = appMatch.route as (typeof appRoutes)[number]; - - const appRenderEntryPath = getAppRouteRenderEntryPath(matchedAppRoute); - if (!appRenderEntryPath) continue; - if ( - classifyAppRoute(appRenderEntryPath, matchedAppRoute.routePath, matchedAppRoute.isDynamic) - .type === "api" - ) { - continue; - } - // Pages Router i18n prefixes are routing metadata rather than part of the // filesystem route. Production strips them before matching Pages/API // routes, while the App Router still matches the original pathname. @@ -606,7 +591,25 @@ async function resolveAppWarmPaths(options: { // rather than becoming a Pages API request after normalization. const isPagesApiRequest = pathname === "/api" || pathname.startsWith("/api/"); const pagesMatch = matchRoute(pagesPathname, isPagesApiRequest ? apiRoutes : pageRoutes); - if (pagesMatch && pagesRouteHasPriorityOverAppRoute(pagesMatch.route, matchedAppRoute)) { + if ( + pagesMatch && + (!appMatch || pagesRouteHasPriorityOverAppRoute(pagesMatch.route, appMatch.route)) + ) { + if (!isPagesApiRequest) htmlPaths.push(pathname); + continue; + } + if (!appMatch) continue; + + // The trie returns the exact object from appRoutes. Its public matcher type + // exposes the shared AppRoute fields, so recover the graph-owned metadata + // here without rescanning the route table for every concrete path. + const matchedAppRoute = appMatch.route as (typeof appRoutes)[number]; + const appRenderEntryPath = getAppRouteRenderEntryPath(matchedAppRoute); + if (!appRenderEntryPath) continue; + if ( + classifyAppRoute(appRenderEntryPath, matchedAppRoute.routePath, matchedAppRoute.isDynamic) + .type === "api" + ) { continue; } @@ -775,23 +778,21 @@ export async function emitPrerenderPathManifest( const configuredPagesWarmPaths = discoveredPagesPaths.filter( (pathname) => !excludedWarmPathSet.has(pathname), ); + const configuredCandidatePaths = paths.filter((pathname) => !excludedWarmPathSet.has(pathname)); const appOwnedWarmPaths = appDir ? await resolveAppWarmPaths({ appDir, i18n: config.i18n, pagesDir, pageExtensions: config.pageExtensions, - paths: discoveredAppPaths.filter((pathname) => !excludedWarmPathSet.has(pathname)), + paths: configuredCandidatePaths, }) : { htmlPaths: discoveredAppPaths, loadingShellPaths: discoveredLoadingShellPaths, rscPaths: discoveredAppPaths, }; - const warmPathSet = new Set([...appOwnedWarmPaths.htmlPaths, ...configuredPagesWarmPaths]); - const warmPaths = paths.filter( - (pathname) => !excludedWarmPathSet.has(pathname) && warmPathSet.has(pathname), - ); + const warmPaths = appDir ? appOwnedWarmPaths.htmlPaths : configuredPagesWarmPaths; const manifest: PrerenderPathManifest = { ...(config.basePath ? { basePath: config.basePath } : {}), diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 7ed1893a4..b1bc41e2e 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -387,7 +387,7 @@ describe("prerender path manifest", () => { responseVary: "verbatim", }); - expect(manifest?.paths).toEqual(["/pages-dir/static", "/specific/value"]); + expect(manifest?.paths).toEqual(["/pages-dir/static", "/pages-dir/foobar", "/specific/value"]); expect(manifest?.rscPaths).toEqual(["/pages-dir/static", "/specific/value"]); expect(manifest?.loadingShellPaths).toEqual(["/specific/value"]); expect(manifest?.pagesPaths).toEqual([]); @@ -487,6 +487,43 @@ describe("prerender path manifest", () => { expect(manifest?.pagesPaths).toEqual(["/about", "/fr/about"]); }); + it("resolves Pages-discovered warm paths to their runtime App owner", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile("app/health/route.ts", "export function GET() { return new Response('ok'); }\n"); + writeFile("app/specific/[id]/page.tsx", "export default function Page() { return null; }\n"); + writeFile( + "pages/[...path].tsx", + [ + "export function getStaticPaths() { return { paths: [], fallback: false }; }", + "export function getStaticProps() { return { props: {}, revalidate: 60 }; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + vi.mocked(fetch).mockImplementation(async (input) => { + const rawUrl = + input instanceof URL ? input.href : typeof input === "string" ? input : input.url; + const url = new URL(rawUrl); + if (url.pathname === "/__vinext/prerender/pages-static-paths") { + return Response.json({ fallback: false, paths: ["/health", "/specific/value"] }); + } + return new Response("null", { headers: { "content-type": "application/json" } }); + }); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await emitPrerenderPathManifest({ + root: tmpDir, + responseVary: "verbatim", + }); + + expect(manifest?.paths).toEqual(["/specific/value"]); + expect(manifest?.rscPaths).toEqual(["/specific/value"]); + expect(manifest?.pagesPaths).toEqual(["/health", "/specific/value"]); + }); + it("fails path discovery when generateStaticParams discovery aborts", async () => { vi.stubGlobal( "fetch",