Files
cloudflare__vinext/tests/app-page-head.test.ts
T
James Anderson fbf68d588e fix(app-router): restore cache and parallel route parity (#2261)
* fix(app-router): preserve searchParams cache variants

* test(app-router): cover RSC metadata cache variants

* fix(app-router): isolate metadata searchParams observation

* fix(app-router): preserve static ISR cache proofs

* fix(app-router): limit queryless cache probes

* fix(app-router): skip redundant cache proof probes

* fix(app-router): harden incomplete cache proofs

* fix(app-router): observe loading page execution

* fix(app-router): preserve exotic page exports

* fix(app-router): preserve client page ISR

* fix(app-router): avoid duplicate queryless page execution

* fix(app-router): observe viewport search params

* fix(app-router): clear force-static search params

* fix(app-router): isolate intercept static context

* fix(app-router): preserve intercept request context

* fix(app-router): isolate action static context

* fix(app-router): match static request API semantics

* fix(app-router): preserve dynamic segment constraints

* fix(app-router): apply slot-only segment config

* fix(app-router): preserve draft mode reads in static errors

* fix(app-router): include nested slot constraints

* fix(app-router): refresh slot layout constraints

* fix(app-router): render nested slot layouts

* fix(app-router): preserve nested slot params

* fix(app-router): preserve intercepted slot head

* fix(app-router): scope parallel layout params

* fix(app-router): preserve intercept layout parity

* fix(app-router): preserve intercept ancestor params

* fix(app-router): scope intercept source params

* fix(app-router): preserve intercept source route params

* test(app-router): stabilize parity validation
2026-06-22 23:17:41 +01:00

752 lines
24 KiB
TypeScript

import { describe, expect, it, vi } from "vite-plus/test";
import {
collectAppPageSearchParams,
resolveActiveParallelRouteHeadInputs,
resolveAppPageHead,
} from "../packages/vinext/src/server/app-page-head.js";
import type { AppPageParams } from "../packages/vinext/src/server/app-page-boundary.js";
describe("app page head resolution", () => {
it("reports whether the matched route has generated metadata", async () => {
// Ported from Next.js: test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
const staticResult = await resolveAppPageHead<Record<string, unknown>>({
layoutModules: [],
metadataRoutes: [],
pageModule: { metadata: { title: "static page" } },
params: {},
routePath: "/static",
});
const generatedResult = await resolveAppPageHead<Record<string, unknown>>({
layoutModules: [],
metadataRoutes: [],
pageModule: {
async generateMetadata() {
return { title: "generated page" };
},
},
params: {},
routePath: "/generated",
});
expect(staticResult.hasDynamicMetadata).toBe(false);
expect(generatedResult.hasDynamicMetadata).toBe(true);
});
it("collects repeated search params into a null-prototype object", () => {
const { hasSearchParams, pageSearchParams } = collectAppPageSearchParams(
new URLSearchParams("__proto__=safe&tag=a&tag=b"),
);
expect(hasSearchParams).toBe(true);
expect(Object.getPrototypeOf(pageSearchParams)).toBe(null);
expect(Reflect.get(pageSearchParams, "__proto__")).toBe("safe");
expect(pageSearchParams.tag).toEqual(["a", "b"]);
});
it("preserves query keys that collide with Object prototype names", async () => {
let generatedSearchParams: Record<string, unknown> | undefined;
const page = {
async generateMetadata(props: { searchParams?: Promise<Record<string, unknown>> }) {
generatedSearchParams = await props.searchParams;
return null;
},
};
const result = await resolveAppPageHead<Record<string, unknown>>({
layoutModules: [],
metadataRoutes: [],
pageModule: page,
params: {},
routePath: "/",
routeSegments: [],
searchParams: new URLSearchParams(
"constructor=ctor&toString=stringifier&__proto__=prototype",
),
});
expect(Reflect.get(result.pageSearchParams, "constructor")).toBe("ctor");
expect(Reflect.get(result.pageSearchParams, "toString")).toBe("stringifier");
expect(Reflect.get(result.pageSearchParams, "__proto__")).toBe("prototype");
expect(Reflect.get(generatedSearchParams ?? {}, "constructor")).toBe("ctor");
expect(Reflect.get(generatedSearchParams ?? {}, "toString")).toBe("stringifier");
expect(Reflect.get(generatedSearchParams ?? {}, "__proto__")).toBe("prototype");
});
it("resolves layout and page metadata with parent chaining and page-only search params", async () => {
const layoutSearchParamsSeen: unknown[] = [];
const layoutParamsSeen: unknown[] = [];
const pageParentImages: unknown[] = [];
const rootLayout = {
metadata: {
openGraph: {
images: ["/root-og.png"],
},
title: { default: "Root", template: "%s | Root" },
},
viewport: {
width: "device-width",
},
};
const nestedLayout = {
async generateMetadata(
props: { params?: Promise<Record<string, string | string[]>>; searchParams?: unknown },
parent: Promise<unknown>,
) {
layoutSearchParamsSeen.push(props.searchParams);
layoutParamsSeen.push(await props.params);
const parentMetadata = await parent;
const parentOpenGraph =
typeof parentMetadata === "object" && parentMetadata
? Reflect.get(parentMetadata, "openGraph")
: null;
const parentImages =
typeof parentOpenGraph === "object" && parentOpenGraph
? Reflect.get(parentOpenGraph, "images")
: [];
return {
openGraph: {
images: [...(Array.isArray(parentImages) ? parentImages : []), "/nested-og.png"],
},
};
},
};
const page = {
async generateMetadata(
props: { searchParams?: Promise<Record<string, string | string[]>> },
parent: Promise<unknown>,
) {
const searchParams = await props.searchParams;
const parentMetadata = await parent;
const parentOpenGraph =
typeof parentMetadata === "object" && parentMetadata
? Reflect.get(parentMetadata, "openGraph")
: null;
const parentImages =
typeof parentOpenGraph === "object" && parentOpenGraph
? Reflect.get(parentOpenGraph, "images")
: [];
pageParentImages.push(...(Array.isArray(parentImages) ? parentImages : []));
const tagValue = searchParams?.tag;
return {
description: `tag ${Array.isArray(tagValue) ? tagValue.join(",") : tagValue}`,
title: "Post",
};
},
viewport: {
initialScale: 1,
},
};
const result = await resolveAppPageHead<Record<string, unknown>>({
layoutModules: [rootLayout, nestedLayout],
layoutTreePositions: [0, 1],
metadataRoutes: [],
pageModule: page,
params: { slug: "post" },
routePath: "/blog/[slug]",
routeSegments: ["blog", "[slug]"],
searchParams: new URLSearchParams("tag=next&tag=vinext"),
});
expect(result.metadata).toEqual({
description: "tag next,vinext",
openGraph: {
description: "tag next,vinext",
images: ["/root-og.png", "/nested-og.png"],
title: "Post | Root",
},
title: "Post | Root",
twitter: {
card: "summary_large_image",
description: "tag next,vinext",
images: ["/root-og.png", "/nested-og.png"],
title: "Post | Root",
},
});
expect(result.viewport).toEqual({
initialScale: 1,
width: "device-width",
});
expect(result.pageSearchParams).toEqual({ tag: ["next", "vinext"] });
expect(result.hasSearchParams).toBe(true);
expect(layoutSearchParamsSeen).toEqual([undefined]);
expect(layoutParamsSeen).toEqual([{}]);
expect(pageParentImages).toEqual(["/root-og.png", "/nested-og.png"]);
});
it("keeps layout tree positions aligned when layout module slots are empty", async () => {
const nestedLayoutParamsSeen: unknown[] = [];
const rootLayout = {
metadata: {
title: "Root",
},
};
const nestedLayout = {
async generateMetadata(props: { params?: Promise<Record<string, string | string[]>> }) {
nestedLayoutParamsSeen.push(await props.params);
return {
description: "Nested",
};
},
};
const result = await resolveAppPageHead<Record<string, unknown>>({
layoutModules: [rootLayout, null, nestedLayout],
layoutTreePositions: [0, 2, 1],
metadataRoutes: [],
params: { slug: "post" },
routePath: "/blog/[slug]",
routeSegments: ["blog", "[slug]"],
});
expect(result.metadata).toEqual({
description: "Nested",
title: "Root",
});
expect(nestedLayoutParamsSeen).toEqual([{}]);
});
it("passes scoped params to layout metadata and full params/searchParams to page metadata", async () => {
// Ported from Next.js: test/e2e/app-dir/layout-params/layout-params.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/layout-params/layout-params.test.ts
const layoutParamCalls: AppPageParams[] = [];
let pageParams: AppPageParams | null = null;
let pageSearchParams: Record<string, string | string[]> = {};
const rootLayout = {
async generateMetadata({ params }: { params: Promise<AppPageParams> }) {
layoutParamCalls.push(await params);
return { title: "root" };
},
};
const categoryLayout = {
async generateMetadata({ params }: { params: Promise<AppPageParams> }) {
layoutParamCalls.push(await params);
return { description: "category" };
},
};
const page = {
async generateMetadata({
params,
searchParams,
}: {
params: Promise<AppPageParams>;
searchParams: Promise<Record<string, string | string[]>>;
}) {
pageParams = await params;
pageSearchParams = await searchParams;
return { keywords: ["page"] };
},
};
const result = await resolveAppPageHead<Record<string, unknown>>({
layoutModules: [rootLayout, categoryLayout],
layoutTreePositions: [1, 2],
metadataRoutes: [],
pageModule: page,
params: { category: "books", id: "dune" },
routePath: "/shop/[category]/[id]",
routeSegments: ["shop", "[category]", "[id]"],
searchParams: new URLSearchParams("tag=a&tag=b&q=hello"),
});
expect(layoutParamCalls).toEqual([{}, { category: "books" }]);
expect(pageParams).toEqual({ category: "books", id: "dune" });
expect({ ...pageSearchParams }).toEqual({
q: "hello",
tag: ["a", "b"],
});
expect(result.hasSearchParams).toBe(true);
expect(result.metadata).toMatchObject({
description: "category",
keywords: ["page"],
});
});
it("passes observed searchParams to primary and parallel page viewports", async () => {
// Ported from Next.js: test/e2e/app-dir/use-cache-search-params/
// app/search-params-used-generate-viewport/page.tsx
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/use-cache-search-params/app/search-params-used-generate-viewport/page.tsx
const observeParamAccess = vi.fn();
const viewportColors: string[] = [];
const createPageModule = () => ({
async generateViewport({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[]>>;
}) {
const query = await searchParams;
viewportColors.push(typeof query.color === "string" ? query.color : "missing");
return { themeColor: typeof query.color === "string" ? query.color : "black" };
},
});
const result = await resolveAppPageHead<Record<string, unknown>>({
layoutModules: [],
metadataRoutes: [],
pageModule: createPageModule(),
parallelRoutes: [{ pageModule: createPageModule(), routeSegments: ["dashboard"] }],
params: {},
routePath: "/dashboard",
routeSegments: ["dashboard"],
searchParams: new URLSearchParams("color=red"),
searchParamsObserver: { observeParamAccess },
});
expect(viewportColors).toEqual(["red", "red"]);
expect(observeParamAccess).toHaveBeenCalled();
expect(result.viewport.themeColor).toBe("red");
});
it("bubbles layout metadata errors", async () => {
await expect(
resolveAppPageHead<Record<string, unknown>>({
layoutModules: [
{
generateMetadata() {
throw new Error("layout metadata failed");
},
},
],
metadataRoutes: [],
params: {},
routePath: "/",
routeSegments: [],
}),
).rejects.toThrow("layout metadata failed");
});
it("bubbles layout viewport errors", async () => {
await expect(
resolveAppPageHead<Record<string, unknown>>({
layoutModules: [
{
generateViewport() {
throw new Error("layout viewport failed");
},
},
],
metadataRoutes: [],
params: {},
routePath: "/",
routeSegments: [],
}),
).rejects.toThrow("layout viewport failed");
});
it("includes active parallel route metadata in resolved head", async () => {
const slotParentDescriptions: unknown[] = [];
const rootLayout = {
metadata: {
description: "Root description",
title: "Root title",
},
};
const page = {
metadata: {
title: "Page title",
},
};
const slotPage = {
async generateMetadata(_props: unknown, parent: Promise<Record<string, unknown>>) {
const parentMetadata = await parent;
slotParentDescriptions.push(parentMetadata.description);
return {
openGraph: {
title: "Slot OG title",
},
};
},
};
const result = await resolveAppPageHead<Record<string, unknown>>({
layoutModules: [rootLayout],
layoutTreePositions: [0],
metadataRoutes: [],
pageModule: page,
parallelRoutes: [
{
pageModule: slotPage,
routeSegments: ["dashboard"],
},
],
params: {},
routePath: "/dashboard",
routeSegments: ["dashboard"],
});
expect(slotParentDescriptions).toEqual(["Root description"]);
expect(result.metadata).toEqual({
description: "Root description",
openGraph: {
description: "Root description",
title: "Slot OG title",
},
title: "Page title",
twitter: {
card: "summary",
description: "Root description",
title: "Slot OG title",
},
});
});
it("includes nested active parallel route layout metadata", async () => {
const result = await resolveAppPageHead<Record<string, unknown>>({
layoutModules: [],
metadataRoutes: [],
parallelRoutes: [
{
layoutModules: [
{ metadata: { description: "slot root" } },
{ metadata: { title: "nested slot layout" } },
],
pageModule: { metadata: { openGraph: { title: "slot page" } } },
routeSegments: ["dashboard"],
},
],
params: {},
routePath: "/dashboard",
routeSegments: ["dashboard"],
});
expect(result.metadata).toMatchObject({
description: "slot root",
title: "nested slot layout",
openGraph: { title: "slot page" },
});
});
it("uses mirrored slot params for parallel route metadata", () => {
const slotPage = {};
expect(
resolveActiveParallelRouteHeadInputs({
params: { primary: "value" },
routeSegments: ["dashboard"],
slotParams: { sidebar: { member: "alice" } },
slots: { sidebar: { page: slotPage, routeSegments: ["[member]"] } },
}),
).toEqual([
{
layoutModules: [],
layoutParams: [],
layoutTreePositions: [],
pageModule: slotPage,
params: { member: "alice" },
routeSegments: ["[member]"],
},
]);
});
it("keeps slot-root layout head inputs for intercepted slots", () => {
const slotLayout = { metadata: { description: "slot root" } };
const interceptLayout = { metadata: { title: "intercept" } };
const interceptPage = {};
expect(
resolveActiveParallelRouteHeadInputs({
interceptLayouts: [interceptLayout],
interceptBranchSegments: ["[photo]", "[comment]"],
interceptLayoutSegments: [["[photo]"]],
interceptPage,
interceptParams: { locale: "en", photo: "42", comment: "7" },
interceptSlotKey: "modal",
layoutTreePositions: [1],
params: { locale: "en" },
routeSegments: ["[locale]", "photos"],
slots: { modal: { layout: slotLayout, layoutIndex: 0 } },
}),
).toEqual([
{
layoutModules: [slotLayout, interceptLayout],
layoutParams: [{ locale: "en" }, { locale: "en", photo: "42" }],
layoutTreePositions: [0, 2],
pageModule: interceptPage,
params: { locale: "en", photo: "42", comment: "7" },
routeSegments: ["[locale]", "photos"],
},
]);
});
it("scopes parallel layout metadata params by tree position", async () => {
const seen: Record<string, unknown>[] = [];
const makeLayout = () => ({
async generateMetadata({ params }: { params: Promise<Record<string, unknown>> }) {
seen.push(await params);
return null;
},
});
await resolveAppPageHead<Record<string, unknown>>({
layoutModules: [],
metadataRoutes: [],
parallelRoutes: [
{
layoutModules: [makeLayout(), makeLayout()],
layoutTreePositions: [0, 1],
params: { owner: "root", team: "alpha", member: "bob" },
routeSegments: ["[team]", "[member]"],
},
],
params: {},
routePath: "/alpha/bob",
routeSegments: [],
});
expect(seen).toEqual([{ owner: "root" }, { owner: "root", team: "alpha" }]);
});
// Regression: a `generateMetadata` that does not declare the `parent`
// argument must NOT receive it. Matches Next.js, which omits the parent
// argument for cached `generateMetadata` functions that don't use it
// (resolve-metadata.ts `getResult` / `useCacheFunctionInfo.usedArgs[1]`).
// Passing the parent into a `'use cache'` function feeds it to the cache-key
// encoder (encodeReply), which throws on non-serializable values such as a
// `URL` `metadataBase` ("URL objects are not supported").
it("omits the parent argument for generateMetadata that does not declare it", async () => {
const receivedArgCounts: number[] = [];
const rootLayout = {
metadata: {
metadataBase: new URL("https://example.com"),
title: "Root",
},
};
const page = {
// Arity 0 — declares no `parent` parameter.
generateMetadata: async function () {
receivedArgCounts.push(arguments.length);
return { title: "Page" };
},
};
const result = await resolveAppPageHead<Record<string, unknown>>({
layoutModules: [rootLayout],
layoutTreePositions: [0],
metadataRoutes: [],
pageModule: page,
params: {},
routePath: "/",
routeSegments: [],
});
// Only `props` was passed (no parent).
expect(receivedArgCounts).toEqual([1]);
// metadataBase from the root layout still flows into the resolved metadata.
expect(result.metadata?.metadataBase).toBeInstanceOf(URL);
expect(String(result.metadata?.metadataBase)).toBe("https://example.com/");
expect(result.metadata?.title).toBe("Page");
});
it("still passes the parent argument to generateMetadata that declares it", async () => {
const receivedArgCounts: number[] = [];
const rootLayout = {
metadata: {
metadataBase: new URL("https://example.com"),
description: "Root description",
},
};
const page = {
// Arity 2 — declares `parent`, so it must receive it.
generateMetadata: async function (_props: unknown, parent: Promise<Record<string, unknown>>) {
receivedArgCounts.push(arguments.length);
const parentMetadata = await parent;
return { title: String(parentMetadata.description) };
},
};
const result = await resolveAppPageHead<Record<string, unknown>>({
layoutModules: [rootLayout],
layoutTreePositions: [0],
metadataRoutes: [],
pageModule: page,
params: {},
routePath: "/",
routeSegments: [],
});
expect(receivedArgCounts).toEqual([2]);
expect(result.metadata?.title).toBe("Root description");
});
it("keeps primary page title handling independent from active parallel route metadata", async () => {
const rootLayout = {
metadata: {
title: { default: "Root", template: "%s | Root" },
},
};
const page = {
metadata: {
description: "Primary page",
title: { default: "Page", template: "%s | Page" },
},
};
const slotLayout = {
metadata: {
title: { default: "Slot", template: "%s | Slot" },
},
};
const slotPage = {
metadata: {
openGraph: {
title: "Slot OG title",
},
title: "Slot page title",
},
};
const result = await resolveAppPageHead<Record<string, unknown>>({
layoutModules: [rootLayout],
layoutTreePositions: [0],
metadataRoutes: [],
pageModule: page,
parallelRoutes: [
{
layoutModules: [slotLayout],
pageModule: slotPage,
routeSegments: ["dashboard"],
},
],
params: {},
routePath: "/dashboard",
routeSegments: ["dashboard"],
});
expect(result.metadata).toEqual({
description: "Primary page",
openGraph: {
description: "Primary page",
title: "Slot OG title",
},
title: "Page | Root",
twitter: {
card: "summary",
description: "Primary page",
title: "Slot OG title",
},
});
});
it("uses parallel route slot page title when no primary page module is present", async () => {
// Ported from Next.js: test/e2e/app-dir/metadata-streaming-parallel-routes/metadata-streaming-parallel-routes.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/metadata-streaming-parallel-routes/metadata-streaming-parallel-routes.test.ts
//
// Reproduces:
// "should change metadata when navigating between two pages under a slot
// when children is not rendered"
//
// The route has no `pageModule` (the layout doesn't render children and there
// is no children-slot default to fill in). The active title must come from
// the parallel slot's page metadata.
const rootLayout = {
metadata: {
title: "Root",
},
};
const parallelLayout = {
metadata: {
title: "parallel-routes-no-children layout title",
},
};
const slotPage = {
metadata: {
title: "first page - @bar",
},
};
const result = await resolveAppPageHead<Record<string, unknown>>({
layoutModules: [rootLayout, parallelLayout],
layoutTreePositions: [0, 1],
metadataRoutes: [],
pageModule: null,
parallelRoutes: [
{
layoutModules: [],
pageModule: slotPage,
routeSegments: ["parallel-routes-no-children", "@bar", "first"],
},
],
params: {},
routePath: "/parallel-routes-no-children/first",
routeSegments: ["parallel-routes-no-children", "first"],
});
expect(result.metadata?.title).toBe("first page - @bar");
});
it("uses parallel layout title when neither primary page nor slot page set a title", async () => {
// Ported from Next.js: test/e2e/app-dir/metadata-streaming-parallel-routes/metadata-streaming-parallel-routes.test.ts
//
// Reproduces:
// "should still render metadata if children is not rendered in parallel
// routes layout"
//
// The route has only a `default.tsx` (no `metadata`) at the children slot and
// the parallel slots render their default fallbacks (no `metadata`). The
// active title must come from the parallel layout's metadata.
const rootLayout = {
metadata: {
title: "Root",
},
};
const parallelLayout = {
metadata: {
title: "parallel-routes-default layout title",
},
};
const defaultPage = {
// default.tsx with no metadata
};
const slotDefault = {
// @bar/default.tsx with no metadata
};
const result = await resolveAppPageHead<Record<string, unknown>>({
layoutModules: [rootLayout, parallelLayout],
layoutTreePositions: [0, 1],
metadataRoutes: [],
pageModule: defaultPage,
parallelRoutes: [
{
layoutModules: [],
pageModule: slotDefault,
routeSegments: ["parallel-routes-default"],
},
],
params: {},
routePath: "/parallel-routes-default",
routeSegments: ["parallel-routes-default"],
});
expect(result.metadata?.title).toBe("parallel-routes-default layout title");
});
it("bubbles active parallel page metadata errors", async () => {
await expect(
resolveAppPageHead<Record<string, unknown>>({
layoutModules: [],
layoutTreePositions: [],
metadataRoutes: [],
pageModule: null,
parallelRoutes: [
{
pageModule: {
generateMetadata() {
throw new Error("slot metadata failed");
},
},
routeSegments: ["dashboard"],
},
],
params: {},
routePath: "/dashboard",
routeSegments: ["dashboard"],
}),
).rejects.toThrow("slot metadata failed");
});
});