Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 11 additions & 25 deletions packages/vinext/src/server/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string | { params, locale? }>; the
// shared `StaticPathsEntry` type and `normalizeStaticPathname`
// helper in `../routing/route-pattern.ts` reference the upstream
// implementation.
type DevStaticPathsEntry = Exclude<StaticPathsEntry, null | undefined>;
const paths: Array<DevStaticPathsEntry> = 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) {
Expand Down
65 changes: 61 additions & 4 deletions packages/vinext/src/server/pages-page-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>; locale?: string };
export type PagesStaticPathsEntry =
| string
| {
params?: Record<string, unknown>;
locale?: string;
};

type PagesStaticPathsResult = {
fallback?: boolean | "blocking";
Expand Down Expand Up @@ -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<string, unknown>,
routeParams: PagesRouteParam[],
routeUrl: string,
): boolean {
if (typeof pathEntry === "string") {
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
19 changes: 19 additions & 0 deletions tests/e2e/pages-router-prod/production.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions tests/fixtures/pages-basic/pages/catchall-optional/[[...slug]].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Link from "next/link";

export default function OptionalCatchAll({ slug }: { slug: string[] }) {
return (
<main>
<p id="catchall">Catch all: [{slug.join(", ")}]</p>
<Link href="/" id="home">
Go home
</Link>
</main>
);
}

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 ?? [],
},
};
}
3 changes: 3 additions & 0 deletions tests/fixtures/pages-basic/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ export default function Home() {
<h1>Hello, vinext!</h1>
<p>This is a Pages Router app running on Vite.</p>
<Link href="/about">Go to About</Link>
<Link href="/catchall-optional/[[...slug]]" as="/catchall-optional" id="optional-root">
Go to optional catch-all root
</Link>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export default function MixedCatchAll({ category, slug }: { category: string; slug: string[] }) {
return (
<main>
<p id="category">Category: {category}</p>
<p id="slug">Slug: [{slug.join(", ")}]</p>
</main>
);
}

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 ?? [],
},
};
}
110 changes: 110 additions & 0 deletions tests/pages-page-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
21 changes: 21 additions & 0 deletions tests/pages-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading