Files
cloudflare__vinext/tests/entry-templates.test.ts
T
James Anderson 8b122744ef fix(pages): align middleware data prefetch caching (#2451)
* fix(pages): align middleware data prefetch caching

* fix(pages): guard middleware prefetch data kind

* fix(dev): lazily classify pages data kind

* fix(pages): preserve legacy prefetch fallback

* fix(pages): reject unknown prefetch data kind
2026-07-02 21:33:32 +01:00

1549 lines
61 KiB
TypeScript

/**
* Behavioral tests for the App Router entry-template code generators.
*
* Tests focus on observable behavior (structured API outputs and error paths),
* not on the textual shape of the generated code.
*/
import path from "node:path";
import fs from "node:fs";
import os from "node:os";
import vm from "node:vm";
import { describe, it, expect } from "vite-plus/test";
import { generateBrowserEntry } from "../packages/vinext/src/entries/app-browser-entry.js";
import { buildAppRscManifestCode } from "../packages/vinext/src/entries/app-rsc-manifest.js";
import { generateRscEntry } from "../packages/vinext/src/entries/app-rsc-entry.js";
import { generateClientEntry } from "../packages/vinext/src/entries/pages-client-entry.js";
import { generateServerEntry } from "../packages/vinext/src/entries/pages-server-entry.js";
import { resolveNextConfig } from "../packages/vinext/src/config/next-config.js";
import { buildAppRouteGraph } from "../packages/vinext/src/routing/app-route-graph.js";
import { createValidFileMatcher } from "../packages/vinext/src/routing/file-matcher.js";
import type { AppRoute } from "../packages/vinext/src/routing/app-router.js";
import type { MetadataFileRoute } from "../packages/vinext/src/server/metadata-routes.js";
// ── Minimal App Router route fixtures ─────────────────────────────────
// Use stable absolute paths so tests don't depend on the machine.
const minimalAppRoutes: AppRoute[] = [
{
pattern: "/",
patternParts: [],
pagePath: "/tmp/test/app/page.tsx",
routePath: null,
layouts: ["/tmp/test/app/layout.tsx"],
templates: [],
parallelSlots: [],
loadingPath: null,
errorPath: null,
layoutErrorPaths: [null],
notFoundPath: null,
notFoundPaths: [null],
forbiddenPaths: [null],
forbiddenPath: null,
unauthorizedPaths: [null],
unauthorizedPath: null,
routeSegments: [],
templateTreePositions: [],
layoutTreePositions: [0],
isDynamic: false,
params: [],
siblingIntercepts: [],
},
{
pattern: "/about",
patternParts: ["about"],
pagePath: "/tmp/test/app/about/page.tsx",
routePath: null,
layouts: ["/tmp/test/app/layout.tsx"],
templates: [],
parallelSlots: [],
loadingPath: null,
errorPath: null,
layoutErrorPaths: [null],
notFoundPath: null,
notFoundPaths: [null],
forbiddenPaths: [null],
forbiddenPath: null,
unauthorizedPaths: [null],
unauthorizedPath: null,
routeSegments: ["about"],
templateTreePositions: [],
layoutTreePositions: [0],
isDynamic: false,
params: [],
siblingIntercepts: [],
},
{
pattern: "/blog/:slug",
patternParts: ["blog", ":slug"],
pagePath: "/tmp/test/app/blog/[slug]/page.tsx",
routePath: null,
layouts: ["/tmp/test/app/layout.tsx", "/tmp/test/app/blog/[slug]/layout.tsx"],
templates: [],
parallelSlots: [],
loadingPath: null,
errorPath: null,
layoutErrorPaths: [null, null],
notFoundPath: null,
notFoundPaths: [null, null],
forbiddenPaths: [null, null],
forbiddenPath: null,
unauthorizedPaths: [null, null],
unauthorizedPath: null,
routeSegments: ["blog", ":slug"],
templateTreePositions: [],
layoutTreePositions: [0, 1],
isDynamic: true,
params: ["slug"],
siblingIntercepts: [],
},
{
pattern: "/dashboard",
patternParts: ["dashboard"],
pagePath: "/tmp/test/app/dashboard/page.tsx",
routePath: null,
layouts: ["/tmp/test/app/layout.tsx", "/tmp/test/app/dashboard/layout.tsx"],
templates: ["/tmp/test/app/dashboard/template.tsx"],
parallelSlots: [],
loadingPath: "/tmp/test/app/dashboard/loading.tsx",
errorPath: "/tmp/test/app/dashboard/error.tsx",
layoutErrorPaths: [null, "/tmp/test/app/dashboard/error.tsx"],
notFoundPath: "/tmp/test/app/dashboard/not-found.tsx",
notFoundPaths: [null, "/tmp/test/app/dashboard/not-found.tsx"],
forbiddenPaths: [null, "/tmp/test/app/dashboard/forbidden.tsx"],
forbiddenPath: "/tmp/test/app/dashboard/forbidden.tsx",
unauthorizedPaths: [null, "/tmp/test/app/dashboard/unauthorized.tsx"],
unauthorizedPath: "/tmp/test/app/dashboard/unauthorized.tsx",
routeSegments: ["dashboard"],
templateTreePositions: [1],
layoutTreePositions: [0, 1],
isDynamic: false,
params: [],
siblingIntercepts: [],
},
];
// ── 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,
{
pattern: "/modal-host",
patternParts: ["modal-host"],
pagePath: null,
routePath: null,
layouts: ["/tmp/test/app/layout.tsx", "/tmp/test/app/modal-host/layout.tsx"],
templates: [],
parallelSlots: [],
loadingPath: null,
errorPath: null,
layoutErrorPaths: [null, null],
notFoundPath: null,
notFoundPaths: [null, null],
forbiddenPaths: [null, null],
forbiddenPath: null,
unauthorizedPaths: [null, null],
unauthorizedPath: null,
routeSegments: ["modal-host"],
templateTreePositions: [],
layoutTreePositions: [0, 1],
isDynamic: false,
params: [],
siblingIntercepts: [],
},
{
pattern: "/docs/:slug",
patternParts: ["docs", ":slug"],
pagePath: "/tmp/test/app/docs/[slug]/page.tsx",
routePath: null,
layouts: ["/tmp/test/app/layout.tsx"],
templates: [],
parallelSlots: [],
loadingPath: "/tmp/test/app/docs/[slug]/loading.tsx",
errorPath: null,
layoutErrorPaths: [null],
notFoundPath: null,
notFoundPaths: [null],
forbiddenPaths: [null],
forbiddenPath: null,
unauthorizedPaths: [null],
unauthorizedPath: null,
routeSegments: ["docs", ":slug"],
templateTreePositions: [],
layoutTreePositions: [0],
isDynamic: true,
params: ["slug"],
siblingIntercepts: [],
},
{
pattern: "/teams/:team/dashboard",
patternParts: ["teams", ":team", "dashboard"],
pagePath: "/tmp/test/app/teams/[team]/dashboard/page.tsx",
routePath: null,
layouts: ["/tmp/test/app/layout.tsx", "/tmp/test/app/teams/[team]/layout.tsx"],
templates: [],
parallelSlots: [
{
id: "slot:analytics:/teams/:team/dashboard",
key: "analytics:/tmp/test/app/teams/[team]/dashboard/@analytics",
name: "analytics",
ownerDir: "/tmp/test/app/teams/[team]/dashboard/@analytics",
ownerTreePath: "/teams/:team/dashboard",
hasPage: true,
pagePath: "/tmp/test/app/teams/[team]/dashboard/@analytics/page.tsx",
defaultPath: "/tmp/test/app/teams/[team]/dashboard/@analytics/default.tsx",
layoutPath: null,
loadingPath: null,
errorPath: null,
interceptingRoutes: [],
layoutIndex: 1,
routeSegments: ["teams", ":team", "dashboard", "@analytics"],
},
],
loadingPath: null,
errorPath: null,
layoutErrorPaths: [null, null],
notFoundPath: null,
notFoundPaths: [null, null],
forbiddenPaths: [null, null],
forbiddenPath: null,
unauthorizedPaths: [null, null],
unauthorizedPath: null,
routeSegments: ["teams", ":team", "dashboard"],
templateTreePositions: [],
layoutTreePositions: [0, 1],
isDynamic: true,
params: ["team"],
siblingIntercepts: [],
},
{
pattern: "/api",
patternParts: ["api"],
pagePath: null,
routePath: "/tmp/test/app/api/route.ts",
layouts: [],
templates: [],
parallelSlots: [],
loadingPath: null,
errorPath: null,
layoutErrorPaths: [],
notFoundPath: null,
notFoundPaths: [],
forbiddenPaths: [],
forbiddenPath: null,
unauthorizedPaths: [],
unauthorizedPath: null,
routeSegments: ["api"],
templateTreePositions: [],
layoutTreePositions: [],
isDynamic: false,
params: [],
siblingIntercepts: [],
},
]);
expect(code).toContain("import { registerNavigationRuntimeBootstrap } from ");
expect(code).toContain("window.__VINEXT_LINK_PREFETCH_ROUTES__ = ");
expect(code).toContain("registerNavigationRuntimeBootstrap({");
expect(code).toContain("routeManifest: null");
expect(code).toContain(
'{"canPrefetchLoadingShell":false,"patternParts":["about"],"isDynamic":false}',
);
expect(code).toContain(
'{"canPrefetchLoadingShell":false,"patternParts":["blog",":slug"],"isDynamic":true}',
);
expect(code).toContain(
'{"canPrefetchLoadingShell":true,"patternParts":["docs",":slug"],"isDynamic":true}',
);
expect(code).toContain(
'{"canPrefetchLoadingShell":false,"patternParts":["teams",":team","dashboard"],"isDynamic":true,"requiresDynamicNavigationRequest":true}',
);
expect(code).toContain(
'{"canPrefetchLoadingShell":false,"patternParts":["modal-host"],"isDynamic":false}',
);
expect(code).not.toContain(
'{"canPrefetchLoadingShell":false,"patternParts":["api"],"isDynamic":false}',
);
});
it("embeds the RouteManifest read model in the browser entry", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-browser-route-manifest-"));
const appDir = path.join(tmpDir, "app");
try {
fs.mkdirSync(path.join(appDir, "dashboard"), { recursive: true });
fs.writeFileSync(path.join(appDir, "layout.tsx"), "export default function Layout() {}\n");
fs.writeFileSync(path.join(appDir, "page.tsx"), "export default function Page() {}\n");
fs.writeFileSync(
path.join(appDir, "dashboard", "page.tsx"),
"export default function Page() {}\n",
);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const code = generateBrowserEntry(graph.routes, graph.routeManifest);
expect(code).toContain("registerNavigationRuntimeBootstrap({");
expect(code).toContain("graphVersion:");
expect(code).toContain("routes: new Map(");
expect(code).toContain("rootBoundaries: new Map(");
expect(code).toContain('"route:/dashboard"');
expect(code).toContain('"root-boundary:/"');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("constructs route module imports and route entries from the scanned app shape", () => {
const routes = [
{
pattern: "/",
patternParts: [],
pagePath: "/tmp/test/app/page.tsx",
routePath: null,
layouts: ["/tmp/test/app/layout.tsx"],
templates: [],
parallelSlots: [],
loadingPath: null,
errorPath: null,
layoutErrorPaths: [null],
notFoundPath: "/tmp/test/app/not-found.tsx",
notFoundPaths: ["/tmp/test/app/not-found.tsx"],
forbiddenPath: "/tmp/test/app/forbidden.tsx",
forbiddenPaths: ["/tmp/test/app/forbidden.tsx"],
unauthorizedPath: "/tmp/test/app/unauthorized.tsx",
unauthorizedPaths: ["/tmp/test/app/unauthorized.tsx"],
routeSegments: [],
templateTreePositions: [],
layoutTreePositions: [0],
isDynamic: false,
params: [],
siblingIntercepts: [],
},
{
ids: {
route: "route:/dashboard/:id",
page: "page:/dashboard/:id",
routeHandler: "route-handler:/dashboard/:id",
rootBoundary: "root-boundary:/",
layouts: ["layout:/", "layout:/dashboard"],
templates: ["template:/dashboard"],
slots: {
"modal:/tmp/test/app/dashboard/@modal": "slot:modal:/dashboard",
},
},
pattern: "/dashboard/:id",
patternParts: ["dashboard", ":id"],
pagePath: "/tmp/test/app/dashboard/[id]/page.tsx",
routePath: "/tmp/test/app/dashboard/[id]/route.ts",
layouts: ["/tmp/test/app/layout.tsx", "/tmp/test/app/dashboard/layout.tsx"],
templates: ["/tmp/test/app/dashboard/template.tsx"],
parallelSlots: [
{
id: "slot:modal:/dashboard",
key: "modal:/tmp/test/app/dashboard/@modal",
name: "modal",
ownerDir: "/tmp/test/app/dashboard/@modal",
ownerTreePath: "/dashboard",
hasPage: true,
pagePath: "/tmp/test/app/dashboard/@modal/page.tsx",
defaultPath: "/tmp/test/app/dashboard/@modal/default.tsx",
layoutPath: "/tmp/test/app/dashboard/@modal/layout.tsx",
loadingPath: "/tmp/test/app/dashboard/@modal/loading.tsx",
errorPath: "/tmp/test/app/dashboard/@modal/error.tsx",
interceptingRoutes: [
{
convention: ".",
targetPattern: "/photos/:photoId",
sourceMatchPattern: "/dashboard",
pagePath: "/tmp/test/app/dashboard/@modal/(.)photos/[photoId]/page.tsx",
layoutPaths: ["/tmp/test/app/dashboard/@modal/(.)photos/layout.tsx"],
params: ["photoId"],
},
],
layoutIndex: 1,
routeSegments: ["@modal"],
},
],
loadingPath: "/tmp/test/app/dashboard/loading.tsx",
errorPath: "/tmp/test/app/dashboard/error.tsx",
layoutErrorPaths: [null, "/tmp/test/app/dashboard/error.tsx"],
notFoundPath: "/tmp/test/app/dashboard/not-found.tsx",
notFoundPaths: ["/tmp/test/app/not-found.tsx", "/tmp/test/app/dashboard/not-found.tsx"],
forbiddenPath: null,
forbiddenPaths: ["/tmp/test/app/forbidden.tsx", null],
unauthorizedPath: null,
unauthorizedPaths: ["/tmp/test/app/unauthorized.tsx", null],
routeSegments: ["dashboard", "[id]"],
templateTreePositions: [1],
layoutTreePositions: [0, 1],
isDynamic: true,
params: ["id"],
rootParamNames: ["id"],
siblingIntercepts: [],
},
] satisfies AppRoute[];
const manifest = buildAppRscManifestCode({
routes,
metadataRoutes: [],
globalErrorPath: "/tmp/test/app/global-error.tsx",
});
const imports = manifest.imports.join("\n");
// The root layout stays eager (`import * as`, always needed for every
// render) and is additionally referenced by a lazy loader for the route's
// `layouts` array, so its path appears in both an eager and a lazy import.
expect(imports.match(/\/tmp\/test\/app\/layout\.tsx/g)).toHaveLength(2);
// Every per-route module — pages, route handlers, layouts, templates,
// boundaries and intercepting pages/layouts — is emitted as a lazy `() =>
// import()` loader. Only the always-needed root boundaries (root layout,
// not-found/forbidden/unauthorized) and global-error stay eager `import * as`.
expect(imports).toContain('const load_0 = () => import("/tmp/test/app/page.tsx");');
expect(imports).toContain(
'const load_5 = () => import("/tmp/test/app/dashboard/[id]/page.tsx");',
);
expect(imports).toContain(
'const load_6 = () => import("/tmp/test/app/dashboard/[id]/route.ts");',
);
expect(imports).toContain(
'const load_17 = () => import("/tmp/test/app/dashboard/@modal/(.)photos/[photoId]/page.tsx");',
);
expect(imports).toContain('import * as mod_4 from "/tmp/test/app/global-error.tsx";');
expect(manifest.rootNotFoundVar).toBe("mod_0");
expect(manifest.rootForbiddenVar).toBe("mod_1");
expect(manifest.rootUnauthorizedVar).toBe("mod_2");
expect(manifest.rootLayoutVars).toEqual(["mod_3"]);
expect(manifest.globalErrorVar).toBe("mod_4");
const dynamicRouteEntry = manifest.routeEntries[1];
expect(dynamicRouteEntry).toContain('"route":"route:/dashboard/:id"');
expect(dynamicRouteEntry).toContain(
'"modal:/tmp/test/app/dashboard/@modal":"slot:modal:/dashboard"',
);
expect(dynamicRouteEntry).toContain('id: "slot:modal:/dashboard"');
expect(dynamicRouteEntry).toContain('pattern: "/dashboard/:id"');
expect(dynamicRouteEntry).toContain("page: null");
expect(dynamicRouteEntry).toContain("__loadPage: load_5");
expect(dynamicRouteEntry).toContain("routeHandler: null");
expect(dynamicRouteEntry).toContain("__loadRouteHandler: load_6");
// Layouts are `null` placeholders hydrated on demand from `__loadLayouts`.
expect(dynamicRouteEntry).toContain("layouts: [null, null]");
expect(dynamicRouteEntry).toContain("__loadLayouts: [load_1, load_7]");
expect(dynamicRouteEntry).toContain('"modal:/tmp/test/app/dashboard/@modal": {');
expect(dynamicRouteEntry).toContain("interceptLayouts: [null]");
expect(dynamicRouteEntry).toContain("__loadInterceptLayouts: [load_18]");
expect(dynamicRouteEntry).toContain("page: null");
expect(dynamicRouteEntry).toContain("__pageLoader: load_17");
expect(dynamicRouteEntry).toContain('params: ["photoId"]');
expect(manifest.generateStaticParamsEntries).toEqual([
' "/dashboard/:id": __createAppPrerenderStaticParamsResolver([{ load: load_5 }], ["id"]),',
]);
});
it("derives route-miss root boundaries when the app has no root page", () => {
const routes = [
{
pattern: "/server",
patternParts: ["server"],
pagePath: "/tmp/test/app/server/page.tsx",
routePath: null,
layouts: ["/tmp/test/app/layout.tsx"],
templates: [],
parallelSlots: [],
loadingPath: null,
errorPath: null,
layoutErrorPaths: [null],
notFoundPath: "/tmp/test/app/not-found.tsx",
notFoundPaths: ["/tmp/test/app/not-found.tsx"],
forbiddenPath: null,
forbiddenPaths: ["/tmp/test/app/forbidden.tsx"],
unauthorizedPath: null,
unauthorizedPaths: ["/tmp/test/app/unauthorized.tsx"],
routeSegments: ["server"],
templateTreePositions: [],
layoutTreePositions: [0],
isDynamic: false,
params: [],
siblingIntercepts: [],
},
] satisfies AppRoute[];
const manifest = buildAppRscManifestCode({
routes,
metadataRoutes: [],
globalErrorPath: null,
});
// Every per-route module is lazy-loaded, so the eager `import * as mod_N`
// numbering starts at the always-eager root boundaries: not-found,
// forbidden, unauthorized, then the root layout.
expect(manifest.rootNotFoundVar).toBe("mod_0");
expect(manifest.rootForbiddenVar).toBe("mod_1");
expect(manifest.rootUnauthorizedVar).toBe("mod_2");
expect(manifest.rootLayoutVars).toEqual(["mod_3"]);
});
it("exposes layout-level generateStaticParams to App Router prerender", () => {
// Ported from Next.js: test/e2e/app-dir/app-root-params-getters/generate-static-params.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-root-params-getters/generate-static-params.test.ts
const routes = [
{
pattern: "/:lang/:locale/other/:slug",
patternParts: [":lang", ":locale", "other", ":slug"],
pagePath: "/tmp/test/app/[lang]/[locale]/other/[slug]/page.tsx",
routePath: null,
layouts: ["/tmp/test/app/[lang]/[locale]/layout.tsx"],
templates: [],
parallelSlots: [],
loadingPath: null,
errorPath: null,
layoutErrorPaths: [null],
notFoundPath: null,
notFoundPaths: [null],
forbiddenPath: null,
forbiddenPaths: [null],
unauthorizedPath: null,
unauthorizedPaths: [null],
routeSegments: ["[lang]", "[locale]", "other", "[slug]"],
templateTreePositions: [],
layoutTreePositions: [2],
isDynamic: true,
params: ["lang", "locale", "slug"],
rootParamNames: ["lang", "locale"],
siblingIntercepts: [],
},
] satisfies AppRoute[];
const manifest = buildAppRscManifestCode({
routes,
metadataRoutes: [],
globalErrorPath: null,
});
expect(manifest.generateStaticParamsEntries).toEqual([
' "/:lang/:locale": __createAppPrerenderStaticParamsResolver([{ load: load_1 }], ["lang","locale"]),',
' "/:lang/:locale/other/:slug": __createAppPrerenderStaticParamsResolver([{ load: load_0 }], ["lang","locale"]),',
]);
expect(manifest.rootParamNameEntries).toEqual([
' "/:lang/:locale/other/:slug": ["lang","locale"],',
' "/:lang/:locale": ["lang","locale"],',
]);
});
it("keys layout generateStaticParams with canonical decoded route patterns", () => {
const routes = [
{
pattern: "/:lang/docs v2/:section/:slug",
patternParts: [":lang", "docs v2", ":section", ":slug"],
pagePath: "/tmp/test/app/[lang]/docs%20v2/[section]/[slug]/page.tsx",
routePath: null,
layouts: ["/tmp/test/app/[lang]/docs%20v2/[section]/layout.tsx"],
templates: [],
parallelSlots: [],
loadingPath: null,
errorPath: null,
layoutErrorPaths: [null],
notFoundPath: null,
notFoundPaths: [null],
forbiddenPath: null,
forbiddenPaths: [null],
unauthorizedPath: null,
unauthorizedPaths: [null],
routeSegments: ["[lang]", "docs%20v2", "[section]", "[slug]"],
templateTreePositions: [],
layoutTreePositions: [3],
isDynamic: true,
params: ["lang", "section", "slug"],
rootParamNames: ["lang", "section"],
siblingIntercepts: [],
},
] satisfies AppRoute[];
const manifest = buildAppRscManifestCode({
routes,
metadataRoutes: [],
globalErrorPath: null,
});
expect(manifest.generateStaticParamsEntries).toEqual([
' "/:lang/docs v2/:section": __createAppPrerenderStaticParamsResolver([{ load: load_1 }], ["lang","section"]),',
' "/:lang/docs v2/:section/:slug": __createAppPrerenderStaticParamsResolver([{ load: load_0 }], ["lang","section"]),',
]);
expect(manifest.rootParamNameEntries).toEqual([
' "/:lang/docs v2/:section/:slug": ["lang","section"],',
' "/:lang/docs v2/:section": ["lang","section"],',
]);
});
it("emits a dynamic-import specifier for the global-not-found module when provided", () => {
// Mirrors how vinext scans `app/global-not-found.tsx` in
// packages/vinext/src/index.ts and threads it into the manifest so the
// generated RSC entry can hand it to createAppFallbackRenderer.
//
// The module is intentionally NOT registered as a static `import * as` —
// statically importing it co-locates global-not-found's CSS with the root
// layout's CSS in a single chunk, and the CSS minifier (lightningcss) then
// drops overlapping declarations as dead code, breaking the cascade for
// route-miss 404s. Emitting a JSON-encoded specifier lets the entry
// generator wrap the path in a dynamic `import()` for chunk isolation.
// See https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/global-not-found
// See Next.js test: test/e2e/app-dir/initial-css-order/initial-css-order.test.ts
const manifest = buildAppRscManifestCode({
routes: minimalAppRoutes,
metadataRoutes: [],
globalErrorPath: null,
globalNotFoundPath: "/tmp/test/app/global-not-found.tsx",
});
// Must NOT appear in the static imports — that would defeat the chunk
// isolation. The entry generator embeds it via `() => import(<specifier>)`.
expect(manifest.imports.join("\n")).not.toContain("global-not-found");
expect(manifest.globalNotFoundImportSpecifier).toBe('"/tmp/test/app/global-not-found.tsx"');
});
it("does not emit a global-not-found specifier when the path is absent", () => {
const manifest = buildAppRscManifestCode({
routes: minimalAppRoutes,
metadataRoutes: [],
globalErrorPath: null,
globalNotFoundPath: null,
});
expect(manifest.imports.join("\n")).not.toContain("global-not-found");
expect(manifest.globalNotFoundImportSpecifier).toBeNull();
});
it("serializes graph-minted ids without leaking the filesystem root", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-app-rsc-manifest-"));
const appDir = path.join(tmpDir, "app");
try {
fs.mkdirSync(path.join(appDir, "(marketing)", "blog", "[slug]", "@modal"), {
recursive: true,
});
fs.writeFileSync(path.join(appDir, "layout.tsx"), "export default function Layout() {}\n");
fs.writeFileSync(
path.join(appDir, "(marketing)", "layout.tsx"),
"export default function Layout() {}\n",
);
fs.writeFileSync(
path.join(appDir, "(marketing)", "blog", "[slug]", "layout.tsx"),
"export default function Layout() {}\n",
);
fs.writeFileSync(
path.join(appDir, "(marketing)", "blog", "[slug]", "template.tsx"),
"export default function Template() {}\n",
);
fs.writeFileSync(
path.join(appDir, "(marketing)", "blog", "[slug]", "page.tsx"),
"export default function Page() {}\n",
);
fs.writeFileSync(
path.join(appDir, "(marketing)", "blog", "[slug]", "@modal", "default.tsx"),
"export default function Default() {}\n",
);
const graph = await buildAppRouteGraph(appDir, createValidFileMatcher());
const manifest = buildAppRscManifestCode({
routes: graph.routes,
metadataRoutes: [],
globalErrorPath: null,
});
const routeEntry = manifest.routeEntries.find((entry) =>
entry.includes('pattern: "/blog/:slug"'),
);
expect(routeEntry).toBeDefined();
expect(routeEntry).not.toContain(appDir);
expect(routeEntry).toContain('"route":"route:/blog/:slug"');
expect(routeEntry).toContain('"page":"page:/blog/:slug"');
expect(routeEntry).toContain('"layout:/(marketing)/blog/[slug]"');
expect(routeEntry).toContain('"template:/(marketing)/blog/[slug]"');
expect(routeEntry).toContain(
'"modal@(marketing)/blog/[slug]/@modal":"slot:modal:/(marketing)/blog/[slug]"',
);
expect(routeEntry).toContain('id: "slot:modal:/(marketing)/blog/[slug]"');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("embeds static metadata files and imports dynamic metadata modules", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-app-rsc-manifest-"));
try {
const staticManifestPath = path.join(tmpDir, "manifest.webmanifest");
const dynamicOgPath = path.join(tmpDir, "blog", "[slug]", "opengraph-image.tsx");
fs.mkdirSync(path.dirname(dynamicOgPath), { recursive: true });
fs.writeFileSync(staticManifestPath, '{"name":"Vinext"}');
fs.writeFileSync(dynamicOgPath, "export default function Image() {}");
const manifest = buildAppRscManifestCode({
routes: minimalAppRoutes,
metadataRoutes: [
{
type: "manifest",
isDynamic: false,
filePath: staticManifestPath,
routePrefix: "",
routeSegments: [],
servedUrl: "/manifest.webmanifest",
contentType: "application/manifest+json",
},
{
type: "opengraph-image",
isDynamic: true,
filePath: dynamicOgPath,
routePrefix: "/blog/[slug]",
routeSegments: ["blog", "[slug]"],
servedUrl: "/blog/[slug]/opengraph-image",
contentType: "image/png",
},
],
globalErrorPath: null,
});
const entries = manifest.metaRouteEntries.join("\n");
expect(entries).toContain(
`fileDataBase64: ${JSON.stringify(Buffer.from('{"name":"Vinext"}').toString("base64"))}`,
);
// Dynamic metadata modules get imported and referenced with a generated name
expect(entries).toMatch(/module: mod_\d+/);
expect(manifest.imports.some((imp) => imp.includes("opengraph-image.tsx"))).toBe(true);
expect(entries).toContain('patternParts: ["blog",":slug","opengraph-image"]');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("throws a build-time error when a discovered static metadata file cannot be read", () => {
expect(() =>
buildAppRscManifestCode({
routes: minimalAppRoutes,
metadataRoutes: [
{
type: "manifest",
isDynamic: false,
filePath: "/tmp/test/app/missing-manifest.webmanifest",
routePrefix: "",
routeSegments: [],
servedUrl: "/manifest.webmanifest",
contentType: "application/manifest+json",
},
],
globalErrorPath: null,
}),
).toThrow("[vinext] Failed to read metadata route file");
});
});
// ── App Router entry template error paths ────────────────────────────
describe("App Router entry templates", () => {
it("installs server globals before App Router user modules are imported", () => {
const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false);
const globalsImportIndex = code.indexOf("/server-globals.js");
// The root page is a static route, so it is emitted as a lazy loader
// (`const load_N = () => import(...)`) rather than a static `import * as`.
const firstUserImportIndex = code.search(
/const load_\d+ = \(\) => import\("\/tmp\/test\/app\/page\.tsx"\);/,
);
expect(globalsImportIndex).toBeGreaterThanOrEqual(0);
expect(firstUserImportIndex).toBeGreaterThanOrEqual(0);
expect(globalsImportIndex).toBeLessThan(firstUserImportIndex);
});
it("generateRscEntry fails with a path-specific error when a static metadata file cannot be read", () => {
const metadataRoutes: MetadataFileRoute[] = [
{
type: "icon",
isDynamic: false,
filePath: "/tmp/test/app/missing-icon.png",
routePrefix: "",
servedUrl: "/icon.png",
contentType: "image/png",
},
];
expect(() =>
generateRscEntry("/tmp/test/app", minimalAppRoutes, null, metadataRoutes, null, "", false),
).toThrow("[vinext] Failed to read metadata route file /tmp/test/app/missing-icon.png");
});
it("generateRscEntry fails with a path-specific error when a dynamic metadata file hash cannot be read", () => {
const metadataRoutes: MetadataFileRoute[] = [
{
type: "icon",
isDynamic: true,
filePath: "/tmp/test/app/missing-icon.tsx",
routePrefix: "",
servedUrl: "/icon",
contentType: "image/png",
},
];
expect(() =>
generateRscEntry("/tmp/test/app", minimalAppRoutes, null, metadataRoutes, null, "", false),
).toThrow("[vinext] Failed to read metadata route file /tmp/test/app/missing-icon.tsx");
});
it("generateRscEntry fails with a path-specific error when static image dimensions cannot be read", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-entry-metadata-"));
const filePath = path.join(tmpDir, "icon.png");
fs.writeFileSync(filePath, "not a png");
const metadataRoutes: MetadataFileRoute[] = [
{
type: "icon",
isDynamic: false,
filePath,
routePrefix: "",
servedUrl: "/icon.png",
contentType: "image/png",
},
];
try {
expect(() =>
generateRscEntry("/tmp/test/app", minimalAppRoutes, null, metadataRoutes, null, "", false),
).toThrow(`[vinext] Failed to read metadata image dimensions for ${filePath} (/icon.png)`);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("generateRscEntry does not read image dimensions for static text metadata files", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-entry-metadata-"));
const filePath = path.join(tmpDir, "manifest.json");
fs.writeFileSync(filePath, JSON.stringify({ name: "test" }));
const metadataRoutes: MetadataFileRoute[] = [
{
type: "manifest",
isDynamic: false,
filePath,
routePrefix: "",
servedUrl: "/manifest.webmanifest",
contentType: "application/manifest+json",
},
];
try {
expect(() =>
generateRscEntry("/tmp/test/app", minimalAppRoutes, null, metadataRoutes, null, "", false),
).not.toThrow();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("generateRscEntry delegates App Router request handling to the typed helper", () => {
const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false);
expect(code).toContain('import { createAppRscHandler } from "vinext/server/app-rsc-handler";');
expect(code).toContain("export default createAppRscHandler({");
expect(code).not.toContain("computeRscCacheBustingSearchParam(");
});
it("generateRscEntry only includes the App middleware runtime when middleware exists", () => {
const withoutMiddleware = generateRscEntry(
"/tmp/test/app",
minimalAppRoutes,
null,
[],
null,
"",
false,
);
const withMiddleware = generateRscEntry(
"/tmp/test/app",
minimalAppRoutes,
"/tmp/test/middleware.ts",
[],
null,
"",
false,
);
expect(withoutMiddleware).not.toContain("app-middleware.js");
expect(withoutMiddleware).not.toContain("runMiddleware(");
expect(withMiddleware).toContain("app-middleware.js");
expect(withMiddleware).toContain("runMiddleware({ cleanPathname, context, hadBasePath");
expect(withMiddleware).toContain("return __applyAppMiddleware({");
expect(withMiddleware).toContain("hadBasePath,");
});
it("generateRscEntry only includes the PPR runtime when Cache Components is enabled", () => {
const withoutCacheComponents = generateRscEntry(
"/tmp/test/app",
minimalAppRoutes,
null,
[],
null,
"",
false,
);
const withCacheComponents = generateRscEntry(
"/tmp/test/app",
minimalAppRoutes,
null,
[],
null,
"",
false,
{ cacheComponents: true },
);
expect(withoutCacheComponents).not.toContain("app-page-ppr-runtime.js");
expect(withoutCacheComponents).not.toContain("createPprFallbackShells(route, params)");
expect(withoutCacheComponents).toContain("pprRuntime: undefined");
expect(withCacheComponents).toContain("app-page-ppr-runtime.js");
expect(withCacheComponents).toContain("createPprFallbackShells(route, params)");
expect(withCacheComponents).toContain("pprRuntime: __appPagePprRuntime");
});
it("generateRscEntry only includes metadata route response handling when routes exist", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-entry-metadata-runtime-"));
const filePath = path.join(tmpDir, "sitemap.ts");
fs.writeFileSync(filePath, "export default function sitemap() { return []; }");
try {
const withoutMetadataRoutes = generateRscEntry(
"/tmp/test/app",
minimalAppRoutes,
null,
[],
null,
"",
false,
);
const withMetadataRoutes = generateRscEntry(
"/tmp/test/app",
minimalAppRoutes,
null,
[
{
type: "sitemap",
isDynamic: true,
filePath,
routePrefix: "",
servedUrl: "/sitemap.xml",
contentType: "application/xml",
},
],
null,
"",
false,
);
expect(withoutMetadataRoutes).not.toContain("metadata-route-response.js");
expect(withoutMetadataRoutes).not.toContain("file-based-metadata.js");
expect(withoutMetadataRoutes).not.toContain("handleMetadataRouteRequest(cleanPathname)");
expect(withMetadataRoutes).toContain("metadata-route-response.js");
expect(withMetadataRoutes).toContain("file-based-metadata.js");
expect(withMetadataRoutes).toContain("applyFileBasedMetadata: __applyFileBasedMetadata");
expect(withMetadataRoutes).toContain("handleMetadataRouteRequest(cleanPathname)");
expect(withMetadataRoutes).toContain("await __loadMetadataRouteResponse()");
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("generateRscEntry defers route-handler and server-action runtimes", () => {
const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false);
expect(code).toContain('const __loadAppRouteHandlerDispatch = () => import("');
expect(code).toContain('const __loadAppServerActionExecution = () => import("');
expect(code).toContain("await __loadAppRouteHandlerDispatch()");
expect(code).toContain("await __loadAppServerActionExecution()");
expect(code).not.toMatch(/import \{\s*dispatchAppRouteHandler as __dispatchAppRouteHandler,/);
expect(code).not.toMatch(
/import \{\s*handleProgressiveServerActionRequest as __handleProgressiveServerActionRequest,/,
);
});
it("generateRscEntry omits server action imports when no server references were found", () => {
const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false, {
hasServerActions: false,
});
expect(code).toContain('from "@vitejs/plugin-rsc/react/rsc"');
expect(code).not.toContain("app-server-action-execution.js");
expect(code).not.toContain("decodeAction,");
expect(code).not.toContain("decodeFormState,");
expect(code).not.toContain("decodeReply,");
expect(code).not.toContain("loadServerAction,");
expect(code).not.toContain("createTemporaryReferenceSet,");
expect(code).not.toContain("handleProgressiveActionRequest({");
expect(code).not.toContain("handleServerActionRequest({");
});
it("generateRscEntry passes parallel route segment config into App page dispatch", () => {
// Ported from Next.js: test/e2e/app-dir/segment-cache/staleness/segment-cache-per-page-dynamic-stale-time.test.ts
const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false);
expect(code).toContain(
"parallelSegments: Object.values(route.slots ?? {}).flatMap((slot) => [",
);
expect(code).toContain(
"parallelPages: Object.values(route.slots ?? {}).map((slot) => slot.page ?? slot.default)",
);
expect(code).toContain("parallelBranches: Object.values(route.slots ?? {}).map((slot) => ({");
expect(code).toContain("paramNames: slot.slotParamNames");
expect(code).toContain("patternParts: slot.slotPatternParts");
expect(code).toContain("layout: slot.layout");
expect(code).toContain("configLayouts: slot.configLayouts");
expect(code).toContain("configLayoutTreePositions: slot.configLayoutTreePositions");
expect(code).toContain("routeSegments: slot.routeSegments");
expect(code).toContain("routePatternParts: route.patternParts");
expect(code).toContain("slot.page ?? slot.default");
expect(code).toContain("...(slot.configLayouts ?? [])");
expect(code).toContain("interceptLayoutSegments:");
expect(code).toContain("interceptBranchSegments:");
expect(code).toContain("dynamicStaleTimeSeconds: __segmentConfig.dynamicStaleTimeSeconds");
expect(code).toContain("? __isEdgeRuntime(__resolveRouteRuntime(__actionMatch.route))");
expect(code).toContain(
"const __isEdge = route ? __isEdgeRuntime(__resolveRouteRuntime(route))",
);
});
it("generateRscEntry threads globalNotFoundPath from config into the fallback renderer", () => {
// The generated entry's createAppFallbackRenderer call must receive a
// loader so route-miss 404s can render app/global-not-found.tsx standalone.
//
// The loader is a dynamic `import()` (not a static `import * as`) so the
// bundler emits global-not-found.tsx in its own JS+CSS chunk. Without that
// isolation, the CSS minifier (lightningcss) drops overlapping declarations
// between the root layout's CSS and global-not-found's CSS, breaking the
// cascade for route-miss 404s.
// See packages/vinext/src/entries/app-rsc-entry.ts and
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/global-not-found
// See Next.js test: test/e2e/app-dir/initial-css-order/initial-css-order.test.ts
const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false, {
globalNotFound: true,
globalNotFoundPath: "/tmp/test/app/global-not-found.tsx",
});
// Loader uses dynamic `import()` — NOT a static `import * as`.
expect(code).toContain('() => import("/tmp/test/app/global-not-found.tsx")');
expect(code).not.toContain('from "/tmp/test/app/global-not-found.tsx"');
// The renderer is wired with the loader binding (not just `null`).
expect(code).toContain("loadGlobalNotFoundModule: __loadGlobalNotFoundModule");
expect(code).not.toContain("const __loadGlobalNotFoundModule = null;");
});
it("generateRscEntry emits a null global-not-found loader when no path is provided", () => {
const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false, {
globalNotFound: true,
});
expect(code).toContain("const __loadGlobalNotFoundModule = null;");
expect(code).toContain("globalNotFoundEnabled: true");
expect(code).not.toContain("global-not-found.tsx");
});
it("generateRscEntry ignores global-not-found modules when the feature is disabled", () => {
const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false, {
globalNotFound: false,
globalNotFoundPath: "/tmp/test/app/global-not-found.tsx",
});
expect(code).toContain("const __loadGlobalNotFoundModule = null;");
expect(code).toContain("globalNotFoundEnabled: false");
expect(code).not.toContain("global-not-found.tsx");
});
it("generateRscEntry delegates React Flight preload hint normalization", () => {
const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false);
expect(code).toContain("createRscRenderer");
expect(code).toContain(
"const renderToReadableStream = createRscRenderer(_renderToReadableStream",
);
expect(code).not.toContain("const _hlFixRe =");
});
});
// ── Pages Router entry template runtime bootstrap ─────────────────────
describe("Pages Router entry template", () => {
it("reports trusted _next/data classification from URL normalization", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-data-entry-"));
const pagesDir = path.join(tmpDir, "pages");
const middlewarePath = path.join(tmpDir, "middleware.ts");
try {
fs.mkdirSync(pagesDir, { recursive: true });
fs.writeFileSync(
path.join(pagesDir, "index.tsx"),
"export default function Page() { return null; }",
);
fs.writeFileSync(
middlewarePath,
'export function middleware() { return new Response(null, { headers: { "x-middleware-next": "1" } }); }',
);
const code = await generateServerEntry(
pagesDir,
await resolveNextConfig({
basePath: "/root",
generateBuildId: () => "test-build-id",
}),
createValidFileMatcher(),
middlewarePath,
null,
);
expect(code).toContain("export function normalizeDataRequest(request)");
expect(code).toContain(
"vinextConfig.basePath,\n hasMiddleware && vinextConfig.trailingSlash",
);
expect(code).toContain("export const hasMiddleware = true");
expect(code).not.toContain('request.headers.get("x-nextjs-data")');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("installs server globals before Pages Router user modules are imported", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-entry-"));
const pagesDir = path.join(tmpDir, "pages");
try {
fs.mkdirSync(pagesDir, { recursive: true });
fs.writeFileSync(
path.join(pagesDir, "index.tsx"),
"export default function Page() { return null; }",
);
const code = await generateServerEntry(
pagesDir,
await resolveNextConfig({}),
createValidFileMatcher(),
null,
null,
);
expect(code).toContain("export const hasMiddleware = false");
const globalsImportIndex = code.indexOf("/server-globals.js");
const firstUserImportIndex = code.indexOf(
`import * as page_0 from ${JSON.stringify(path.join(pagesDir, "index.tsx"))}`,
);
expect(globalsImportIndex).toBeGreaterThanOrEqual(0);
expect(firstUserImportIndex).toBeGreaterThanOrEqual(0);
expect(globalsImportIndex).toBeLessThan(firstUserImportIndex);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("precomputes Pages route dataKind in the server entry", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-data-kind-entry-"));
const pagesDir = path.join(tmpDir, "pages");
try {
fs.mkdirSync(pagesDir, { recursive: true });
fs.writeFileSync(
path.join(pagesDir, "index.tsx"),
"export function getStaticProps() { return { props: {} }; } export default function Page() { return null; }",
);
fs.writeFileSync(
path.join(pagesDir, "ssr.tsx"),
"export function getServerSideProps() { return { props: {} }; } export default function Page() { return null; }",
);
fs.writeFileSync(
path.join(pagesDir, "plain.tsx"),
"export default function Page() { return null; }",
);
const code = await generateServerEntry(
pagesDir,
await resolveNextConfig({}),
createValidFileMatcher(),
null,
null,
);
expect(code).toContain('pattern: "/",');
expect(code).toContain('dataKind: "static"');
expect(code).toContain('pattern: "/ssr",');
expect(code).toContain('dataKind: "server"');
expect(code).toContain('pattern: "/plain",');
expect(code).toContain('dataKind: "none"');
expect(code).not.toContain("typeof page_");
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
// Refs #1474: Pages Router client entry must import the user's
// `instrumentation-client.ts` (at the project root) as a side-effect import
// before calling `hydrateRoot()`. Mirrors Next.js's `page-bootstrap.ts`
// which side-effect-imports `require-instrumentation-client` ahead of
// `initialize` / `hydrate` (see
// .nextjs-ref/packages/next/src/client/page-bootstrap.ts line 1).
//
// Ported from Next.js: test/e2e/instrumentation-client-hook/instrumentation-client-hook.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/instrumentation-client-hook/instrumentation-client-hook.test.ts
it("imports the user's instrumentation-client.ts before calling hydrateRoot()", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-client-entry-"));
const pagesDir = path.join(tmpDir, "pages");
const instrumentationClientPath = path.join(tmpDir, "instrumentation-client.ts");
try {
fs.mkdirSync(pagesDir, { recursive: true });
fs.writeFileSync(
path.join(pagesDir, "index.tsx"),
"export default function Page() { return null; }",
);
fs.writeFileSync(
instrumentationClientPath,
"(window as any).__INSTRUMENTATION_CLIENT_EXECUTED_AT = performance.now();",
);
const code = await generateClientEntry(
pagesDir,
await resolveNextConfig({}),
createValidFileMatcher(),
{ instrumentationClientPath },
);
// The user's `instrumentation-client.ts` must be imported as a
// side-effect import (no `from`, no `as`) so its top-level statements
// execute when the client entry module is evaluated.
const userImportIndex = code.indexOf(`import ${JSON.stringify(instrumentationClientPath)}`);
const hydrateRootIndex = code.indexOf("hydrateRoot(");
expect(userImportIndex).toBeGreaterThanOrEqual(0);
expect(hydrateRootIndex).toBeGreaterThanOrEqual(0);
expect(userImportIndex).toBeLessThan(hydrateRootIndex);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("omits the user instrumentation-client import when no file is present", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-client-entry-empty-"));
const pagesDir = path.join(tmpDir, "pages");
try {
fs.mkdirSync(pagesDir, { recursive: true });
fs.writeFileSync(
path.join(pagesDir, "index.tsx"),
"export default function Page() { return null; }",
);
const code = await generateClientEntry(
pagesDir,
await resolveNextConfig({}),
createValidFileMatcher(),
{ instrumentationClientPath: null },
);
// Sanity check: the entry still wires up hydration and the hooks alias.
expect(code).toContain("hydrateRoot(");
expect(code).toContain("vinext/instrumentation-client");
// No spurious bare imports referring to a non-existent project file.
expect(code).not.toMatch(/import "[^"]*instrumentation-client\.ts"/);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
// Ported from Next.js: test/e2e/middleware-rewrites/test/index.test.ts
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/middleware-rewrites/test/index.test.ts
it("emits only getStaticProps pages in the Pages SSG prefetch manifest", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-ssg-manifest-"));
const pagesDir = path.join(tmpDir, "pages");
try {
fs.mkdirSync(pagesDir, { recursive: true });
fs.writeFileSync(
path.join(pagesDir, "ssg.tsx"),
"export function getStaticProps() { return { props: {} }; } export default function Page() { return null; }",
);
fs.writeFileSync(
path.join(pagesDir, "dynamic.tsx"),
"export function getServerSideProps() { return { props: {} }; } export default function Page() { return null; }",
);
fs.writeFileSync(
path.join(pagesDir, "public-alias.tsx"),
"const loader = () => ({ props: {} }); export { loader as getStaticProps }; export default function Page() { return null; }",
);
fs.writeFileSync(
path.join(pagesDir, "local-alias.tsx"),
"const getStaticProps = () => ({ props: {} }); export { getStaticProps as loader }; export default function Page() { return null; }",
);
const code = await generateClientEntry(
pagesDir,
await resolveNextConfig({}),
createValidFileMatcher(),
);
expect(code).toContain('window.__VINEXT_PAGES_SSG_PATTERNS__ = ["/local-alias","/ssg"]');
expect(code).toContain('window.__VINEXT_PAGES_SSP_PATTERNS__ = ["/dynamic"]');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("embeds the Pages middleware matcher in the client entry", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-client-mw-matcher-"));
const pagesDir = path.join(tmpDir, "pages");
try {
fs.mkdirSync(pagesDir, { recursive: true });
fs.writeFileSync(
path.join(pagesDir, "index.tsx"),
"export default function Page() { return null; }",
);
const code = await generateClientEntry(
pagesDir,
await resolveNextConfig({}),
createValidFileMatcher(),
{ middlewareMatcher: ["/ssr", { source: "/api/:path*" }] },
);
expect(code).toContain(
'window.__VINEXT_MIDDLEWARE_MATCHER__ = ["/ssr",{"source":"/api/:path*"}]',
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("omits the React preamble when the React plugin is disabled", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-client-entry-preamble-"));
const pagesDir = path.join(tmpDir, "pages");
try {
fs.mkdirSync(pagesDir, { recursive: true });
fs.writeFileSync(
path.join(pagesDir, "index.tsx"),
"export default function Page() { return null; }",
);
const code = await generateClientEntry(
pagesDir,
await resolveNextConfig({}),
createValidFileMatcher(),
{ reactPreamble: false },
);
expect(code).not.toContain("@vitejs/plugin-react/preamble");
expect(code).toContain("hydrateRoot(");
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("hydrates _app with the full Pages props envelope", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-client-entry-props-"));
const pagesDir = path.join(tmpDir, "pages");
try {
fs.mkdirSync(pagesDir, { recursive: true });
fs.writeFileSync(
path.join(pagesDir, "_app.tsx"),
"export default function App({ Component, pageProps }) { return <Component {...pageProps} />; }",
);
fs.writeFileSync(
path.join(pagesDir, "index.tsx"),
"export default function Page() { return null; }",
);
const code = await generateClientEntry(
pagesDir,
await resolveNextConfig({}),
createValidFileMatcher(),
);
expect(code).toContain(
'const props = nextData.props && typeof nextData.props === "object" ? nextData.props : {};',
);
expect(code).toContain("const rawPageProps = props.pageProps;");
expect(code).toContain(
'const pageProps = rawPageProps && typeof rawPageProps === "object" ? rawPageProps : {};',
);
expect(code).toContain("import Router, {");
expect(code).toContain("wrapWithRouterContext,");
expect(code).toContain("_initializePagesRouterReadyFromNextData,");
expect(code).toContain('} from "next/router";');
expect(code).toContain("_initializePagesRouterReadyFromNextData(nextData);");
expect(code).toContain("router: Router,");
expect(code).toContain("pageProps: rawPageProps,");
expect(code).toContain("element = wrapWithRouterContext(element, resolveHydrationCommit);");
expect(code).toContain("await hydrationCommitted;");
expect(code).toContain("if (nextData.isFallback) {");
expect(code).toContain("const routeUrl = nextData.__vinext?.routeUrl;");
expect(code).toContain("await Router.replace(");
expect(code).toContain("routeUrl || currentUrl,");
expect(code).toContain("routeUrl ? currentUrl : undefined,");
expect(code).toContain("{ _h: 1, scroll: false },");
expect(code).not.toContain("function VinextHydrationMarker");
expect(code).not.toContain("React.createElement(VinextHydrationMarker");
expect(code).toContain("hydrateRoot(container, element, hydrateRootOptions)");
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("gracefully skips Pages Router initialization without __NEXT_DATA__", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-client-entry-no-data-"));
const pagesDir = path.join(tmpDir, "pages");
try {
fs.mkdirSync(pagesDir, { recursive: true });
fs.writeFileSync(
path.join(pagesDir, "index.tsx"),
"export default function Page() { return null; }",
);
const code = await generateClientEntry(
pagesDir,
await resolveNextConfig({}),
createValidFileMatcher(),
);
const initializationStart = code.indexOf(
'const nextDataElement = document.getElementById("__NEXT_DATA__");',
);
const initializationEnd = code.indexOf(" let hydrateRootOptions;", initializationStart);
const initializationCode = code.slice(initializationStart, initializationEnd);
const errors: string[] = [];
await expect(
vm.runInNewContext(`(async () => {${initializationCode}\n}\nawait hydrate();})()`, {
window: {},
document: { getElementById: () => null },
console: { error: (message: string) => errors.push(message) },
_initializePagesRouterReadyFromNextData: () => {
throw new Error("router readiness must not initialize without __NEXT_DATA__");
},
}),
).resolves.toBeUndefined();
expect(errors).toEqual(["[vinext] No __NEXT_DATA__ found"]);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("installs the dev error overlay before loading Pages Router modules", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-client-entry-overlay-"));
const pagesDir = path.join(tmpDir, "pages");
try {
fs.mkdirSync(pagesDir, { recursive: true });
fs.writeFileSync(
path.join(pagesDir, "index.tsx"),
"export default function Page() { return null; }",
);
const code = await generateClientEntry(
pagesDir,
await resolveNextConfig({}),
createValidFileMatcher(),
);
const overlayImportIndex = code.indexOf('await import("vinext/dev-error-overlay")');
const pageLoadIndex = code.indexOf("const pageModule = await loader()");
const hydrateRootIndex = code.indexOf("hydrateRoot(container, element, hydrateRootOptions)");
expect(overlayImportIndex).toBeGreaterThanOrEqual(0);
expect(pageLoadIndex).toBeGreaterThanOrEqual(0);
expect(hydrateRootIndex).toBeGreaterThanOrEqual(0);
expect(code).toContain("overlay.installDevErrorOverlay()");
expect(code).toContain("overlay.installViteHmrErrorHandler(import.meta.hot)");
expect(code).toContain("overlay.reportInitialDevServerErrors()");
expect(code).toContain("onCaughtError: overlay.devOnCaughtError");
expect(code).toContain("onUncaughtError: overlay.devOnUncaughtError");
expect(overlayImportIndex).toBeLessThan(pageLoadIndex);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
// Ported from Next.js: `reactStrictMode` wraps the client tree in
// <React.StrictMode> via the `process.env.__NEXT_STRICT_MODE` branch in
// .nextjs-ref/packages/next/src/client/index.tsx (around line 787). vinext
// signals this to its router shim via `window.__VINEXT_REACT_STRICT_MODE__`,
// which `wrapWithRouterContext` reads so the wrap is applied on the initial
// hydration AND every navigation render (Next.js's `doRender` closure runs
// for both). For the Pages Router the default is OFF —
// `reactStrictMode === null ? false` in
// .nextjs-ref/packages/next/src/build/define-env.ts — so the flag is `true`
// only when the option is explicitly `true`.
it("publishes the reactStrictMode flag to the client entry when reactStrictMode is true", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-client-entry-strict-"));
const pagesDir = path.join(tmpDir, "pages");
try {
fs.mkdirSync(pagesDir, { recursive: true });
fs.writeFileSync(
path.join(pagesDir, "index.tsx"),
"export default function Page() { return null; }",
);
const code = await generateClientEntry(
pagesDir,
await resolveNextConfig({ reactStrictMode: true }),
createValidFileMatcher(),
);
// The flag is set before hydrate() runs so wrapWithRouterContext sees it
// on the very first render.
const flagIndex = code.indexOf("window.__VINEXT_REACT_STRICT_MODE__ = true;");
const hydrateRootIndex = code.indexOf("hydrateRoot(container, element, hydrateRootOptions)");
expect(flagIndex).toBeGreaterThanOrEqual(0);
expect(hydrateRootIndex).toBeGreaterThanOrEqual(0);
expect(flagIndex).toBeLessThan(hydrateRootIndex);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("publishes a false reactStrictMode flag for the Pages Router by default or when disabled", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-pages-client-entry-no-strict-"));
const pagesDir = path.join(tmpDir, "pages");
try {
fs.mkdirSync(pagesDir, { recursive: true });
fs.writeFileSync(
path.join(pagesDir, "index.tsx"),
"export default function Page() { return null; }",
);
// Unset → Pages Router default is OFF (Next.js: `reactStrictMode === null ? false`).
const defaultCode = await generateClientEntry(
pagesDir,
await resolveNextConfig({}),
createValidFileMatcher(),
);
expect(defaultCode).toContain("window.__VINEXT_REACT_STRICT_MODE__ = false;");
expect(defaultCode).not.toContain("window.__VINEXT_REACT_STRICT_MODE__ = true;");
// Explicit false → also OFF.
const disabledCode = await generateClientEntry(
pagesDir,
await resolveNextConfig({ reactStrictMode: false }),
createValidFileMatcher(),
);
expect(disabledCode).toContain("window.__VINEXT_REACT_STRICT_MODE__ = false;");
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});