mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
eca6d3fe83
* feat(router): lazy-load App Router page and route-handler modules
Statically-imported route modules were all evaluated at Worker startup.
For typical pages this is cheap, but routes with expensive module-level
initialization make startup scale linearly and can exceed Cloudflare's
~400ms startup CPU budget. Measured on real Workers: 150 pages with heavy
module-init went from ~414-467ms startup (eager) to ~17ms (lazy); typical
pages are unaffected (~0.01ms/route either way).
The RSC manifest now emits page modules of static routes and all
route-handler modules as `() => import()` thunks instead of eager
`import * as`, so they are code-split out of the entry's top-level
evaluation and loaded on demand for the matched route only.
ensureAppRouteModulesLoaded() hydrates a route's lazy modules onto the
synchronous page/routeHandler fields before any consumer reads them; it is
idempotent and dedups concurrent loads. Hydration is invoked at the central
match point and at every mid-flight route lookup that reads modules before
buildPageElement (server-action redirect/rerender targets, interception and
ISR revalidation source routes).
Dynamic-route pages stay eager because their generateStaticParams is
referenced in the module-level generateStaticParamsMap; making those lazy
needs a prerender-resolver rework and is tracked as follow-up.
* fix(router): retry failed lazy route-module imports instead of caching the rejection
Addresses /bigbonk review feedback on the lazy route-module loader:
- ensureAppRouteModulesLoaded no longer caches a rejected dynamic import().
On failure it clears __loading (leaving __loaded false) and re-throws, so
the current request still sees the error but the next request retries —
matching the eager model's per-isolate retry semantics instead of wedging
the route into a permanent 500 and risking an unhandled rejection.
- Document why the getLazyLoaderVar import() codegen is a CodeQL false
positive (trusted filesystem-scan path, same trust model as the eager
getImportVar import * as).
- Add a rejection-path unit test covering retry-after-failure.
* docs(router): clarify why route handlers can always be lazy-loaded
Reword the getLazyLoaderVar comment per /bigbonk re-review: route handlers
are lazy because they are never sourced into generateStaticParamsMap (which
only reads layouts + page), not because they 'have no generateStaticParams'
(Next.js route handlers can export it for prerendering — a separate gap).
* feat(router): lazy-load all page modules, including dynamic routes
The previous cut kept dynamic-route pages (and anything nested under a
dynamic segment) eager, because their generateStaticParams was referenced
synchronously in the module-level generateStaticParamsMap. That gutted the
benefit for a large class of apps: anything with a dynamic root segment
(i18n `[locale]`, multi-tenant `[org]`) made every route dynamic, so
nothing was lazy — and the heavy `[slug]` page (the prime heavy-module-init
candidate) stayed eager.
Now ALL page modules are lazy. generateStaticParamsMap embeds lazy `{ load }`
page sources instead of `mod.generateStaticParams`, and
createAppPrerenderStaticParamsResolver imports them on demand at prerender
time (already async). It returns the same null "no static params" sentinel
the prerender driver relies on (skip / output:export error), so SSG
behavior is unchanged. Layout generateStaticParams sources stay eager.
The eager set is now just genuinely-shared modules: layouts, templates,
boundaries, intercepts, and global-error.
Verified on real Cloudflare: 150 heavy pages under a dynamic `[locale]`
root went from eager (~450ms startup) to ~22ms, and SSG via the `[locale]`
layout's generateStaticParams still prerenders correctly.
Tests: prerender + prerender-route-params + prerender-endpoints + features +
prod/dev server + interception + actions all green; new
app-prerender-static-params unit tests cover the lazy-source path and null
sentinel; entry-templates updated for all-lazy pages and `{ load }` sources.
* refactor(router): preserve source order in prerender static-params resolver
Per /bigbonk re-review: the resolver partitioned sources into
[...eagerFns, ...lazyFns], which only matched the intended composition order
because the codegen happens to emit layout (eager) sources before the page
(lazy) source. Resolve sources in their original declared order instead —
each source maps to its eager function or its awaited lazy function, then
non-functions are dropped — so composition order no longer depends on the
emitter's append order. Added a unit test with a lazy source ordered before
an eager one.
977 lines
38 KiB
TypeScript
977 lines
38 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: [],
|
|
},
|
|
{
|
|
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: [],
|
|
},
|
|
{
|
|
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"],
|
|
},
|
|
{
|
|
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: [],
|
|
},
|
|
];
|
|
|
|
// ── 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: [],
|
|
},
|
|
{
|
|
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"],
|
|
},
|
|
{
|
|
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: [],
|
|
},
|
|
]);
|
|
|
|
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: [],
|
|
},
|
|
{
|
|
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"],
|
|
},
|
|
] 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); only shared modules (layouts/templates/boundaries/intercepts) 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(
|
|
'import * as mod_14 from "/tmp/test/app/dashboard/@modal/(.)photos/[photoId]/page.tsx";',
|
|
);
|
|
expect(imports).toContain('import * as mod_16 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_16");
|
|
|
|
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_15]");
|
|
expect(dynamicRouteEntry).toContain("page: mod_14");
|
|
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: [],
|
|
},
|
|
] 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"],
|
|
},
|
|
] 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"],
|
|
},
|
|
] 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 });
|
|
}
|
|
});
|
|
});
|