diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index 82f060b89b..69bac95d3d 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -2,7 +2,6 @@ import type { ViteDevServer } from "vite"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { Route } from "../routing/pages-router.js"; import { matchRoute, patternToNextFormat } from "../routing/pages-router.js"; -import { normalizeStaticPathname, type StaticPathsEntry } from "../routing/route-pattern.js"; import type { ModuleImporter } from "./instrumentation.js"; import { importModule, reportRequestError } from "./instrumentation.js"; import type { NextI18nConfig } from "../config/next-config.js"; @@ -58,6 +57,11 @@ import { import { buildDefaultPagesNotFoundResponse } from "./pages-default-404.js"; import { buildPagesReadinessNextData } from "./pages-readiness.js"; import { resolvePagesPageMethodResponse } from "./pages-page-method.js"; +import { + getPagesRouteParams, + matchesPagesStaticPath, + type PagesStaticPathsEntry, +} from "./pages-page-data.js"; import { createPagesDevModuleUrl } from "./pages-dev-module-url.js"; import { isSerializableProps } from "./pages-serializable-props.js"; import { @@ -720,30 +724,12 @@ export function createSSRHandler( }); const fallback = pathsResult?.fallback ?? false; - // Only allow paths explicitly listed in getStaticPaths. Next.js - // accepts `paths` as Array; the - // shared `StaticPathsEntry` type and `normalizeStaticPathname` - // helper in `../routing/route-pattern.ts` reference the upstream - // implementation. - type DevStaticPathsEntry = Exclude; - const paths: Array = pathsResult?.paths ?? []; - const currentPathname = normalizeStaticPathname(url); - const isValidPath = paths.some((p) => { - if (typeof p === "string") { - return normalizeStaticPathname(p) === currentPathname; - } - const entryParams = p.params; - if (entryParams === undefined || entryParams === null) { - return false; - } - return Object.entries(entryParams).every(([key, val]) => { - const actual = params[key]; - if (Array.isArray(val)) { - return Array.isArray(actual) && val.join("/") === actual.join("/"); - } - return String(val) === String(actual); - }); - }); + const paths: PagesStaticPathsEntry[] = pathsResult?.paths ?? []; + const routePattern = patternToNextFormat(route.pattern); + const routeParams = getPagesRouteParams(routePattern); + const isValidPath = paths.some((pathEntry) => + matchesPagesStaticPath(pathEntry, params, routeParams, url), + ); if (fallback === false && !isValidPath) { if (isDataReq) { diff --git a/packages/vinext/src/server/pages-page-data.ts b/packages/vinext/src/server/pages-page-data.ts index 1a8b2300c0..ff6a279c0f 100644 --- a/packages/vinext/src/server/pages-page-data.ts +++ b/packages/vinext/src/server/pages-page-data.ts @@ -41,7 +41,12 @@ type PagesRedirectResult = { // `string | string[]` shape used at build time. The shared // `normalizeStaticPathname` helper from `../routing/route-pattern.js` is used // to canonicalize the string-entry comparison. -type PagesStaticPathsEntry = string | { params?: Record; locale?: string }; +export type PagesStaticPathsEntry = + | string + | { + params?: Record; + locale?: string; + }; type PagesStaticPathsResult = { fallback?: boolean | "blocking"; @@ -450,9 +455,37 @@ function buildPagesRedirectResponse( * entry with a missing `params` key, return false rather than throwing — the * caller will respond with a 404 just like Next.js does for unlisted paths. */ -function matchesPagesStaticPath( +export type PagesRouteParam = { + key: string; + repeat: boolean; + optional: boolean; +}; + +export function getPagesRouteParams(routePattern: string): PagesRouteParam[] { + return routePattern + .split("/") + .map((segment) => { + const optionalCatchAll = segment.match(/^\[\[\.\.\.(.+)\]\]$/); + if (optionalCatchAll) { + return { key: optionalCatchAll[1], repeat: true, optional: true }; + } + const requiredCatchAll = segment.match(/^\[\.\.\.(.+)\]$/); + if (requiredCatchAll) { + return { key: requiredCatchAll[1], repeat: true, optional: false }; + } + const dynamic = segment.match(/^\[(.+)\]$/); + if (dynamic) { + return { key: dynamic[1], repeat: false, optional: false }; + } + return null; + }) + .filter((param): param is PagesRouteParam => param !== null); +} + +export function matchesPagesStaticPath( pathEntry: PagesStaticPathsEntry, params: Record, + routeParams: PagesRouteParam[], routeUrl: string, ): boolean { if (typeof pathEntry === "string") { @@ -462,9 +495,32 @@ function matchesPagesStaticPath( if (entryParams === undefined || entryParams === null) { return false; } - return Object.entries(entryParams).every(([key, value]) => { + + return routeParams.every(({ key, repeat, optional }) => { + if (!Object.hasOwn(entryParams, key)) { + return false; + } + + let value = entryParams[key]; + // Mirrors Next.js build/static-paths/pages.ts: optional catch-all values + // explicitly returned as null, undefined, or false normalize to []. + if (optional && (value === null || value === undefined || value === false)) { + value = []; + } + + if (repeat) { + if (!Array.isArray(value) || (!optional && value.length === 0)) { + return false; + } + } else if (typeof value !== "string") { + return false; + } + const actual = params[key]; if (Array.isArray(value)) { + if (optional && value.length === 0 && actual === undefined) { + return true; + } return Array.isArray(actual) && value.join("/") === actual.join("/"); } return String(value) === String(actual); @@ -628,8 +684,9 @@ export async function resolvePagesPageData( }); const fallback = pathsResult?.fallback ?? false; const paths = pathsResult?.paths ?? []; + const routeParams = getPagesRouteParams(options.routePattern); const isValidPath = paths.some((pathEntry) => - matchesPagesStaticPath(pathEntry, options.params, options.routeUrl), + matchesPagesStaticPath(pathEntry, options.params, routeParams, options.routeUrl), ); if (fallback === false && !isValidPath) { diff --git a/tests/e2e/pages-router-prod/production.spec.ts b/tests/e2e/pages-router-prod/production.spec.ts index 5caddab736..46b6a82807 100644 --- a/tests/e2e/pages-router-prod/production.spec.ts +++ b/tests/e2e/pages-router-prod/production.spec.ts @@ -60,6 +60,25 @@ test.describe("Pages Router Production Build", () => { expect(content).toContain("hello-world"); }); + test("navigates to a seeded optional catch-all root", async ({ page }) => { + // Ported from Next.js: test/e2e/prerender.test.ts + // https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/prerender.test.ts + await page.goto(`${BASE}/`); + await page.evaluate(() => { + (window as typeof window & { didTransition?: number }).didTransition = 1; + }); + + await page.locator("#optional-root").click(); + + await expect(page.locator("#home")).toBeVisible(); + await expect(page.locator("#catchall")).toHaveText("Catch all: []"); + expect( + await page.evaluate( + () => (window as typeof window & { didTransition?: number }).didTransition, + ), + ).toBe(1); + }); + test("import.meta.url uses source file URLs on the server and browser", async ({ page, request, diff --git a/tests/fixtures/pages-basic/pages/catchall-optional/[[...slug]].tsx b/tests/fixtures/pages-basic/pages/catchall-optional/[[...slug]].tsx new file mode 100644 index 0000000000..f9150167fa --- /dev/null +++ b/tests/fixtures/pages-basic/pages/catchall-optional/[[...slug]].tsx @@ -0,0 +1,27 @@ +import Link from "next/link"; + +export default function OptionalCatchAll({ slug }: { slug: string[] }) { + return ( +
+

Catch all: [{slug.join(", ")}]

+ + Go home + +
+ ); +} + +export async function getStaticPaths() { + return { + paths: [{ params: { slug: [] } }, { params: { slug: ["value"] } }], + fallback: false, + }; +} + +export async function getStaticProps({ params }: { params: { slug?: string[] } }) { + return { + props: { + slug: params.slug ?? [], + }, + }; +} diff --git a/tests/fixtures/pages-basic/pages/index.tsx b/tests/fixtures/pages-basic/pages/index.tsx index cbd05ce922..f5ae529a51 100644 --- a/tests/fixtures/pages-basic/pages/index.tsx +++ b/tests/fixtures/pages-basic/pages/index.tsx @@ -10,6 +10,9 @@ export default function Home() {

Hello, vinext!

This is a Pages Router app running on Vite.

Go to About + + Go to optional catch-all root + ); } diff --git a/tests/fixtures/pages-basic/pages/mixed-catchall/[category]/[[...slug]].tsx b/tests/fixtures/pages-basic/pages/mixed-catchall/[category]/[[...slug]].tsx new file mode 100644 index 0000000000..9fa68d0f35 --- /dev/null +++ b/tests/fixtures/pages-basic/pages/mixed-catchall/[category]/[[...slug]].tsx @@ -0,0 +1,28 @@ +export default function MixedCatchAll({ category, slug }: { category: string; slug: string[] }) { + return ( +
+

Category: {category}

+

Slug: [{slug.join(", ")}]

+
+ ); +} + +export async function getStaticPaths() { + return { + paths: [{ params: { slug: [] } }, { params: { category: "guides", slug: [] } }], + fallback: false, + }; +} + +export async function getStaticProps({ + params, +}: { + params: { category: string; slug?: string[] }; +}) { + return { + props: { + category: params.category, + slug: params.slug ?? [], + }, + }; +} diff --git a/tests/pages-page-data.test.ts b/tests/pages-page-data.test.ts index afc0a231a9..f85a8f37a8 100644 --- a/tests/pages-page-data.test.ts +++ b/tests/pages-page-data.test.ts @@ -227,6 +227,116 @@ describe("pages page data", () => { expect(result).toEqual({ kind: "notFound" }); }); + it.each([ + ["null", null], + ["undefined", undefined], + ["false", false], + ["empty array", []], + ])("accepts optional catch-all %s at the route root", async (_label, slug) => { + const result = await resolvePagesPageData( + createOptions({ + pageModule: { + async getStaticPaths() { + return { + fallback: false, + paths: [{ params: { slug } }], + }; + }, + async getStaticProps() { + return { props: { slug: [] } }; + }, + }, + params: {}, + query: {}, + route: { isDynamic: true }, + routePattern: "/catchall-optional/[[...slug]]", + routeUrl: "/catchall-optional", + }), + ); + + expect(result).toMatchObject({ + kind: "render", + pageProps: { slug: [] }, + }); + }); + + it("requires every dynamic key for mixed required and optional catch-all routes", async () => { + const result = await resolvePagesPageData( + createOptions({ + pageModule: { + async getStaticPaths() { + return { + fallback: false, + paths: [{ params: { slug: [] } }], + }; + }, + }, + params: { category: "guides" }, + query: { category: "guides" }, + route: { isDynamic: true }, + routePattern: "/[category]/[[...slug]]", + routeUrl: "/guides", + }), + ); + + expect(result).toEqual({ kind: "notFound" }); + }); + + it("accepts mixed required and empty optional catch-all params", async () => { + const result = await resolvePagesPageData( + createOptions({ + pageModule: { + async getStaticPaths() { + return { + fallback: false, + paths: [{ params: { category: "guides", slug: false } }], + }; + }, + async getStaticProps() { + return { props: { category: "guides", slug: [] } }; + }, + }, + params: { category: "guides" }, + query: { category: "guides" }, + route: { isDynamic: true }, + routePattern: "/[category]/[[...slug]]", + routeUrl: "/guides", + }), + ); + + expect(result).toMatchObject({ + kind: "render", + pageProps: { category: "guides", slug: [] }, + }); + }); + + it.each([ + ["null", null], + ["undefined", undefined], + ["false", false], + ["empty array", []], + ])("rejects required catch-all %s", async (_label, slug) => { + const result = await resolvePagesPageData( + createOptions({ + pageModule: { + async getStaticPaths() { + return { + fallback: false, + paths: [{ params: { slug } }], + }; + }, + }, + params: { slug: ["guide"] }, + query: { slug: ["guide"] }, + route: { isDynamic: true }, + routePattern: "/docs/[...slug]", + routeUrl: "/docs/guide", + }), + ); + + expect(result).toEqual({ kind: "notFound" }); + }); + it("runs page getInitialProps with the original request URL and asPath", async () => { const result = await resolvePagesPageData( createOptions({ diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index cae8becaed..ff258e3353 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -1350,6 +1350,27 @@ describe("Pages Router integration", () => { expect(res.status).toBe(404); }); + it("renders an empty optional catch-all path from getStaticPaths in dev", async () => { + const res = await fetch(`${baseUrl}/catchall-optional`); + expect(res.status).toBe(200); + + const html = await res.text(); + expect(html).toMatch(/Catch all: \[(?:)?\]/); + }); + + it("requires mixed route params while accepting an empty optional catch-all in dev", async () => { + const res = await fetch(`${baseUrl}/mixed-catchall/guides`); + expect(res.status).toBe(200); + + const html = await res.text(); + expect(html).toContain("Category:"); + expect(html).toContain("guides"); + expect(html).toMatch(/Slug: \[(?:)?\]/); + + const unlistedRes = await fetch(`${baseUrl}/mixed-catchall/unlisted`); + expect(unlistedRes.status).toBe(404); + }); + it("renders pre-listed paths with getStaticPaths fallback: blocking", async () => { const res = await fetch(`${baseUrl}/articles/1`); expect(res.status).toBe(200);