Files
cloudflare__vinext/tests/entry-templates.test.ts
T
Divanshu Chauhan (divkix) b4c829d6a2 feat(interception): sibling-style interception routes (#1364 Part C) (#1804)
* test(interception): add failing sibling-interception graph tests

* feat(routing): discover sibling-style interception markers

* feat(routing): emit sibling interception manifest facts

* feat(app-router): serialize sibling intercepts in route manifest

* feat(app-router): match sibling intercepts in route matcher

* feat(app-router): render sibling intercept as full page response

* test: add siblingIntercepts field to AppRoute test fixtures

* test(interception): add sibling interception fixtures

* fix(app-router): emit null proof for sibling intercepts to bypass slot-validation

* test(interception): add sibling interception e2e specs

* fix(interception): correct (...)→(..)(..) marker in interception-from-root fixture

* fix(interception): resolve sourceRouteIndex to slot-owner route in intercept lookup

When a route inherits a @slot from an ancestor via discoverInheritedParallelSlots,
the inherited slot's interceptingRoutes are copied from the ancestor. Previously,
createInterceptLookup used routeIndex (the inheriting descendant's index) as
sourceRouteIndex, causing resolveAppPageInterceptState to see sourceRoute ===
currentRoute → kind='current-route' instead of kind='source-route'.

Fix: build a patternToIndex map and resolve sourceRouteIndex via the intercept's
sourceMatchPattern (which names the actual slot-owner route), falling back to
routeIndex only when no match is found.

* fix(interception): use locale-prefixed href so middleware interception fires correctly

* fix(interception): address codex review issues in sibling intercept implementation

- Pass effectiveParams to resolveAppPageHead so generateMetadata/generateViewport
  sees the intercepted route's params instead of the source route's params
- Rename SIBLING_INTERCEPT_SLOT_NAME from __page to __vinext_sibling_intercept
  to prevent collision with a user @__page parallel route directory
- Restore middleware-rewrite fixture link without locale prefix so the
  interception-dynamic-segment-middleware spec exercises the intended
  middleware rewrite path

* fix(test): update slotId assertions and restore locale-prefixed fixture link

- Update test assertions from slot:__page to slot:__vinext_sibling_intercept
  following the rename in commit 2248b8fe
- Restore locale-prefixed href in interception-mw fixture so the
  interception-dynamic-segment-middleware E2E tests pass (reverts the
  accidental revert from 2248b8fe back to the working state from 02e80c26)

* fix(interception): address review findings on sibling interception PR

- Restore locale-less href in interception-mw fixture and fix the
  underlying middleware-rewrite interception gap: add a Referer-based
  fallback in app-rsc-handler so interception fires when middleware
  rewrites the URL before the server matches it (the client-side manifest
  check sees the pre-rewrite target and skips setting the interception
  context header; the Referer carries the source page the server needs)
- Apply interceptLayouts for sibling intercepts in buildPageElements so
  a layout.tsx under the interception marker dir wraps the intercepting
  page, matching Next.js segment-layout wrapping behaviour; add fixture
  layout and E2E assertion
- Add unit test proving findOwnerRouteForDir ancestor walk attaches a
  sibling intercept to the nearest ancestor route when the marker's
  parent dir has no page.tsx (not dropped silently)

* fix(interception): send current pathname as fallback interception context

The client-side manifest check compares the pre-middleware target URL
against declared interception patterns. When middleware rewrites the URL
(e.g. /foo/p/1 → /en/foo/p/1), the pre-rewrite URL has fewer segments
and doesn't match the pattern, so no X-Vinext-Interception-Context
header is sent and the server cannot fire interception.

Fix: when the manifest check yields no match and there is no prior
intercepted navigation in browser state, fall back to sending the
current page pathname as the interception context. This mirrors Next.js
sending Next-URL on every RSC navigation. The server's findIntercept is
double-gated on both source and target matching, so false positives
cannot occur. The previousNextUrl is also populated so back/forward
traversal can restore intercepted state.

Revert the Referer-based server-side fallback (unreliable across
environments) in favour of this explicit browser-side fix.

* fix(interception): gate middleware-rewrite fallback on declared source pattern

The previous fix sent window.location.pathname as fallback interception
context on every unmatched navigation, which broke prefetch cache reuse:
a prefetch stored with null context wouldn't be found when the navigation
used a non-null context key.

Refine the fallback to only fire when the current page URL matches at
least one sourcePatternParts declared in the route manifest — i.e., the
current page is a known interception source. This preserves prefetch
cache reuse for ordinary navigations while still enabling middleware-
rewrite interception for pages that declare an interception relationship.

* fix(interception): support middleware-rewrite targets in fallback context and planner

The previous fallback-context gate only checked whether the current page
is a declared interception source; it did not verify the target URL
could plausibly be a middleware-rewritten version of the declared target
pattern.  This meant the client sent interception context when it
shouldn't (pre-middleware target had no static anchors in common with the
pattern) and the planner hard-navigated when the proof target matched
only after the rewrite.

Three coordinated fixes:

1. Client fallback gate (app-browser-entry / app-browser-interception-context):
   replace the broad source-only check with matchRoutePatternWithOptionalDynamicSegments
   on the target side — the pre-middleware URL must still share every
   static anchor of the declared target pattern, in order, while allowing
   dynamic segments to be absent.

2. Planner interception proof (navigation-planner):
   findRouteManifestInterceptionForProof now accepts a proof whose target
   matched URL equals the pre-rewrite canonical pathname (shorter than the
   full pattern) when matchRoutePatternWithOptionalDynamicSegments passes.
   Exact pattern match still skips the targetRouteId guard; the relaxed
   path skips it too so the middleware-added locale segment does not
   invalidate an otherwise valid proof.

3. displayPathname threading (app-rsc-handler / app-rsc-entry / app-page-element-builder):
   the handler now passes canonicalPathname as displayPathname separately
   from cleanPathname.  buildPageElements uses cleanPathname for all
   internal routing work (slot param extraction, slot overrides) and
   displayPathname only for render identity — keeping the browser-visible
   URL in RSC metadata without affecting internal matching.

* fix(interception): guard siblingInterceptElement construction and document reserved slot name

* fix(interception): surface missing default export on sibling intercept pages

A sibling-style interception whose intercepting page module is missing
its `default` export previously fell back to the source route's page
component (`effectivePageModule?.default ?? PageComponent`), silently
rendering a *different* page than the one requested.

Resolve the effective component from the intercepting page only and
extend the existing no-default-export guard to cover sibling intercepts,
so a missing export surfaces the same explicit "Page has no default
export" error the source/slot paths already produce. For a normal
request this is identical to the previous behavior since
`effectivePageModule === pageModule`.

Addresses bonk review feedback on #1804.

* test(app-router): guard canonical pathname commit on soft-nav config rewrites

The `displayPathname = canonicalPathname` change feeds the RSC payload
identity the client planner uses to commit a navigation, so it affects
every rewritten route, not just interceptions. Existing coverage
(nextjs-compat/hooks.test.ts) only exercises the hard-nav (SSR) path.

Add an e2e that soft-navigates (client-side) to an afterFiles-rewritten
route and asserts the committed URL and `usePathname()` stay canonical
(pre-rewrite), with a marker check proving no full page reload occurred.

Addresses bonk review feedback on #1804.

* fix(interception): restore __pageLoader on slot intercept lookup entries

The main merge auto-resolved incorrectly: it moved the `__pageLoader`
field that #1738 added to the *slot* intercept push block onto the new
*sibling* push block instead (the two `interceptLookup.push({...})` calls
are textually similar). As a result slot intercept lookup entries lost
`__pageLoader`, so resolveAppPageInterceptState never loaded the lazy
intercepting page (`page` stayed null) and the modal slot fell back to
default.tsx — breaking every slot interception.

Restore `__pageLoader: intercept.__pageLoader` on the slot push block.
The sibling block already carries it (see merge commit), and both
intercept types declare the optional field.

Fixes the app-router-dev-server intercept tests and the interception
e2e suites that regressed after merging main.

---------

Co-authored-by: James Anderson <james@eli.cx>
2026-06-08 19:01:29 +01:00

990 lines
39 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 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("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 });
}
});
});