Files
cloudflare__vinext/tests/entry-templates.test.ts
T
Nathan Nguyen a3d2f92152 fix(router): honor hybrid pages route priority (#1997)
* fix(router): honor hybrid pages route priority

* fix(router): share hybrid owner decision with client navigation

PR #1997 fixed the server-side route ownership for direct document loads,
but the same invariant broke for client-side soft navigations and the
matching prefetch path:

- navigateClientSide() always delegated to the App runtime's RSC fetch,
  even when the Pages route had higher priority. renderPagesFallback()
  short-circuits RSC requests with null, so the App catch-all won.
- prefetchUrl() prefetched an RSC stream for any URL that matched an App
  route, again ignoring Pages ownership.
- The Pages entry was loaded on every hybrid request, even when a static
  App route had already matched (Pages can never win in that case).

Expose the Pages route manifest on the client via a new
__VINEXT_PAGES_LINK_PREFETCH_ROUTES__ window global emitted by both the
App and Pages browser entries. The link shim consults a shared
resolveHybridClientRouteOwner helper that mirrors the server-side
pagesRouteHasPriorityOverAppRoute comparison. When Pages owns the URL,
the click handler issues a window.location navigation and the prefetch
path returns early.

Gate the renderPagesFallback call behind a static-App-route check in
handleAppRscRequest: when a static App route matches, the bridge
cannot win, so skip the eager Pages entry load. Centralise the
comparison in a new resolveHybridRouteOwner helper so server and
client reach the same answer for the same (URL, route pair).

Adds the missing client-navigation coverage to the use-params e2e
fixture (Link from /app/ to /pages-dir/foobar) and unit tests for
the shared owner decision.

* fix(router): mirror server hybrid owner decision on the client

PR review flagged two split-brain bugs in the previous hybrid
ownership fix: a hand-copied client comparator and a Link-only
ownership gate that left programmatic App Router navigations on
the wrong path.

The hand-copied routePrecedence in hybrid-client-route-owner.ts
omitted the static-prefix reduction that lives in
routing/utils.ts#routePrecedence, and used a strict-less-than
comparison that returned App for identical dynamic patterns. The
server returns Pages for both cases (Pages providers sort ahead
of App providers, and routePrecedence subtracts 50 per static
prefix segment). The split produced a real ownership disagreement
on overlapping patterns like /_sites/:slug* (Pages) vs /:slug*
(App).

Add a shared compareHybridRoutePatterns to routing/utils.ts as
the single source of truth: the static/dynamic short-circuits plus
a sortRoutes call (which carries the static-prefix reduction and
the Pages-first equal-pattern tiebreak). The server
pagesRouteHasPriorityOverAppRoute and the client
resolveHybridClientRouteOwner both delegate to it, so they
cannot diverge. Drop the hand-copied routePrecedence entirely.

Wire the ownership check at the App navigation runtime boundary
so useRouter().push, useRouter().replace, gesturePush, and form
submits all get the same hard-nav contract as the Link click
handler. Specifically:

- navigateClientSide: after same-origin normalization, if Pages
  owns the URL, hard-navigate via window.location and return
  (matching the existing external-URL branch).
- _appRouter.prefetch: short-circuit RSC URL construction for
  Pages-owned targets so we do not warm an unusable cache entry.

Centralise the existing two inline hard-nav branches in
navigateClientSide into a hardNavigateTo helper for clarity.

Tests:

- Direct unit tests for compareHybridRoutePatterns covering
  identical-dynamic tiebreak, static-prefix dynamic overlap,
  static-prefix catch-all overlap, and infix-static bonus.
- Direct unit tests for resolveHybridClientRouteOwner mirroring
  the server assertions plus a basePath-stripping test.
- e2e: useRouter().push('/pages-dir/foobar') from an App page
  resolves to the Pages document.
- e2e: useRouter().prefetch('/pages-dir/foobar') issues zero
  RSC requests for the target URL.

* docs(router): fix stale score in static-prefix catch-all test comment

The hand-copied comparator note quoted the dynamic-segment scores
(51 / 1000) for the optional-catch-all example (/_sites/:slug*
vs /:slug*). The current optional-catch-all scoring actually
produces 1951 vs 2000. The test assertion was correct; only the
comment was stale.

* test(use-params): fix direct-load single dynamic param to use /a instead of /a/b

* fix(router): compare hybrid routes structurally

* fix(router): reject hybrid route conflicts

* fix(router): refresh hybrid route ownership

* fix(router): preserve hybrid routing lifecycle

* fix(router): recheck pages routes after rewrites

* fix(router): preserve rewritten pages queries

* fix(router): preserve rewritten route ownership

* fix(router): resolve client rewrites sequentially

* fix(router): apply rewrite phases sequentially

* fix(router): preserve rewrite params and endpoints

* fix(router): hand off endpoint navigations

* fix(router): preserve rewrite fragment params

* test(e2e): avoid hybrid fixture route conflicts

* test(pages): await async config validation

* test(hybrid): avoid duplicate page fixtures

* chore(router): clarify hybrid priority semantics

* refactor(router): remove duplicate app route matcher

* docs(router): clarify client hybrid comparator

---------

Co-authored-by: James <james@eli.cx>
2026-06-15 00:22:00 +01:00

1045 lines
41 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 { 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: "/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":["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");
expect(imports.match(/\/tmp\/test\/app\/layout\.tsx/g)).toHaveLength(1);
// All page modules are lazy loaders (including the dynamic "/dashboard/:id"
// page and intercepting pages); only shared modules
// (layouts/templates/boundaries) and global-error stay eager `import * as`.
expect(imports).toContain('const load_0 = () => import("/tmp/test/app/page.tsx");');
expect(imports).toContain(
'const load_1 = () => import("/tmp/test/app/dashboard/[id]/page.tsx");',
);
expect(imports).toContain(
'const load_2 = () => import("/tmp/test/app/dashboard/[id]/route.ts");',
);
expect(imports).toContain(
'const load_3 = () => import("/tmp/test/app/dashboard/@modal/(.)photos/[photoId]/page.tsx");',
);
expect(imports).toContain('import * as mod_15 from "/tmp/test/app/global-error.tsx";');
expect(manifest.rootNotFoundVar).toBe("mod_1");
expect(manifest.rootForbiddenVar).toBe("mod_2");
expect(manifest.rootUnauthorizedVar).toBe("mod_3");
expect(manifest.rootLayoutVars).toEqual(["mod_0"]);
expect(manifest.globalErrorVar).toBe("mod_15");
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_1");
expect(dynamicRouteEntry).toContain("routeHandler: null");
expect(dynamicRouteEntry).toContain("__loadRouteHandler: load_2");
expect(dynamicRouteEntry).toContain("layouts: [mod_0, mod_4]");
expect(dynamicRouteEntry).toContain('"modal:/tmp/test/app/dashboard/@modal": {');
expect(dynamicRouteEntry).toContain("interceptLayouts: [mod_14]");
expect(dynamicRouteEntry).toContain("page: null");
expect(dynamicRouteEntry).toContain("__pageLoader: load_3");
expect(dynamicRouteEntry).toContain('params: ["photoId"]');
expect(manifest.generateStaticParamsEntries).toEqual([
' "/dashboard/:id": __createAppPrerenderStaticParamsResolver([{ load: load_1 }], ["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,
});
// The "/server" page is a static route, so it is lazy-loaded (load_0) and
// the eager `import * as mod_N` numbering starts at the root layout.
expect(manifest.rootLayoutVars).toEqual(["mod_0"]);
expect(manifest.rootNotFoundVar).toBe("mod_1");
expect(manifest.rootForbiddenVar).toBe("mod_2");
expect(manifest.rootUnauthorizedVar).toBe("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([mod_0?.generateStaticParams], ["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([mod_0?.generateStaticParams], ["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("app-rsc-handler.js");
expect(code).toContain("export default __createAppRscHandler({");
expect(code).not.toContain("computeRscCacheBustingSearchParam(");
});
it("generateRscEntry passes page-slot dynamic stale time 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(
"parallelPages: Object.values(route.slots ?? {}).map((slot) => slot.page)",
);
expect(code).toContain("dynamicStaleTimeSeconds: __segmentConfig.dynamicStaleTimeSeconds");
});
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, {
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);
expect(code).toContain("const __loadGlobalNotFoundModule = null;");
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("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,
);
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 });
}
});
// 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 });
}
});
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, { wrapWithRouterContext } from "next/router";');
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("await Router.replace(");
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("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 });
}
});
});