mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
0b7721dba6
* fix(build): minify server build environments by default Vite only minifies the client environment; non-client (rsc/ssr and the Cloudflare worker) environments default build.minify to false, so the deployed worker and SSR renderer ship unminified. Add a build-only configEnvironment default that sets minify: true for server environments, yielding to any explicit user/plugin minify setting (including false). * fix(build): patch route classification before minify; build fixtures unminified for output introspection Minifying server environments by default exposed two problems: 1. Real bug: build-time route classification ran in `generateBundle`, but rolldown's minifier renames the top-level `__VINEXT_CLASS` function and mangles its `routeIdx` parameter before that hook runs, so the stub regex never matched and the patch silently no-op'd — every route fell back to the Layer 3 runtime probe in production. Move the injection to a `renderChunk` hook with `order: "pre"` so it runs before minification; the patched body is then minified along with the rest of the chunk (the runtime calls it by reference, not by name). 2. Test artifacts: three build tests introspect emitted server chunks for readable identifiers (registerConfiguredCacheAdapters, __VINEXT_CLASS) or depend on unminified chunking (MDX frontmatter leak check). These are test-only concerns — the runtime behavior is unchanged. Build their fixtures with `build.minify: false` (a user-overridable default) so the assertions stay deterministic. * fix(build): drop now-unused RouteClassificationChunk export (knip) The renderChunk refactor removed the external consumer of this type; keep it as a local type so knip passes. * test(build): guard minified classification path; fix stale generateBundle refs Re-add real regression coverage that the route-classification injector runs before minification (the production default). The prior fix made three tests pass by building with minify:false, which is exactly the config where the old post-minify generateBundle code also passed — so the tests no longer guarded the bug. - classification integration: add a production-default (minify on) suite that asserts minify-robust signals (injected switch dispatch shape + the module-graph reason literal, which is build-time-only). Verified it FAILS when the injector is reverted to a post-minify generateBundle hook. - cache adapters: assert the minify-safe LOCAL_ADAPTER_MARKER under the default minified build instead of grepping the renamed registerConfiguredCacheAdapters symbol. - tsconfig path alias (#659): run minified; the old not.toContain('title: "Second Post"') was a quote-escaping artifact (frontmatter renders as MDX content identically in both builds, not a minify leak) — replaced with a real parse-error/raw-YAML guard. - update stale generateBundle references to renderChunk in injector error strings, app-rsc-entry stubs, and index.ts comments; note the per-chunk patch assumption near the renderChunk hook. * test(build): run classification tests against minified output (no minify:false) Previously the dispatch-logic suites built the fixture with minify:false so they could regex-extract and eval the readable __VINEXT_CLASS function — but that is the one config where the original post-minify-hook bug also passes, so it masked the regression. Rewrite the extractors to be name-agnostic: derive the (mangled) dispatch function name from its property-keyed call site (__buildTimeClassifications/ __buildTimeReasons — property keys survive minification) and eval the body (string-literal values like "static" also survive). All suites now build with vinext's production defaults (minify on). Verified by reproducing the bug (disabling the patch): the minified tests fail with "was not patched". * refactor(build): rename buildGenerateBundleReplacement; fix stale generateBundle doc refs Address review nits: the classification dispatch builder still carried the old hook name (it now runs in renderChunk, not generateBundle). Rename it to buildClassificationReplacement (parallel to buildReasonsReplacement) and fix two stale 'generateBundle' doc comments (route-classification-manifest.ts:203, layout-classification.ts:130) to say renderChunk.
400 lines
14 KiB
TypeScript
400 lines
14 KiB
TypeScript
/**
|
|
* Tests for the build-time layout classification manifest helpers.
|
|
*
|
|
* These helpers bridge the classifier (src/build/layout-classification.ts)
|
|
* with the RSC entry codegen so that the per-layout static/dynamic
|
|
* classifications computed at build time actually reach the runtime probe in
|
|
* app-page-execution.ts.
|
|
*/
|
|
import fsp from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import vm from "node:vm";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
|
|
import {
|
|
buildClassificationReplacement,
|
|
buildReasonsReplacement,
|
|
collectRouteClassificationManifest,
|
|
} from "../packages/vinext/src/build/route-classification-manifest.js";
|
|
import type { ClassificationReason } from "../packages/vinext/src/build/layout-classification-types.js";
|
|
|
|
type MinimalAppRoute = {
|
|
pattern: string;
|
|
pagePath: string | null;
|
|
routePath: string | null;
|
|
layouts: string[];
|
|
templates: string[];
|
|
parallelSlots: [];
|
|
loadingPath: null;
|
|
errorPath: null;
|
|
layoutErrorPaths: (string | null)[];
|
|
notFoundPath: null;
|
|
notFoundPaths: (string | null)[];
|
|
forbiddenPaths: (string | null)[];
|
|
forbiddenPath: null;
|
|
unauthorizedPaths: (string | null)[];
|
|
unauthorizedPath: null;
|
|
routeSegments: string[];
|
|
layoutTreePositions: number[];
|
|
isDynamic: boolean;
|
|
params: string[];
|
|
patternParts: string[];
|
|
};
|
|
|
|
function makeRoute(partial: Partial<MinimalAppRoute> & { layouts: string[] }): MinimalAppRoute {
|
|
return {
|
|
pattern: "/",
|
|
pagePath: null,
|
|
routePath: null,
|
|
templates: [],
|
|
parallelSlots: [],
|
|
loadingPath: null,
|
|
errorPath: null,
|
|
layoutErrorPaths: partial.layouts.map(() => null),
|
|
notFoundPath: null,
|
|
notFoundPaths: partial.layouts.map(() => null),
|
|
forbiddenPaths: partial.layouts.map(() => null),
|
|
forbiddenPath: null,
|
|
unauthorizedPaths: partial.layouts.map(() => null),
|
|
unauthorizedPath: null,
|
|
routeSegments: [],
|
|
layoutTreePositions: partial.layouts.map((_, idx) => idx),
|
|
isDynamic: false,
|
|
params: [],
|
|
patternParts: [],
|
|
...partial,
|
|
};
|
|
}
|
|
|
|
let tmpDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-classification-manifest-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fsp.rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function writeLayout(name: string, source: string): Promise<string> {
|
|
const file = path.join(tmpDir, name);
|
|
await fsp.writeFile(file, source, "utf8");
|
|
return file;
|
|
}
|
|
|
|
describe("collectRouteClassificationManifest", () => {
|
|
it("reads force-dynamic layouts as dynamic", async () => {
|
|
const layout = await writeLayout(
|
|
"layout-dyn.tsx",
|
|
`export const dynamic = "force-dynamic";\nexport default function L({children}){return children;}`,
|
|
);
|
|
const routes = [makeRoute({ pattern: "/", layouts: [layout] })];
|
|
|
|
const manifest = collectRouteClassificationManifest(routes);
|
|
|
|
expect(manifest.routes[0].layer1.get(0)).toBe("dynamic");
|
|
expect(manifest.routes[0].layer1Reasons.get(0)).toEqual({
|
|
layer: "segment-config",
|
|
key: "dynamic",
|
|
value: "force-dynamic",
|
|
});
|
|
});
|
|
|
|
it("reads force-static layouts as static", async () => {
|
|
const layout = await writeLayout(
|
|
"layout-static.tsx",
|
|
`export const dynamic = "force-static";\nexport default function L({children}){return children;}`,
|
|
);
|
|
const routes = [makeRoute({ pattern: "/", layouts: [layout] })];
|
|
|
|
const manifest = collectRouteClassificationManifest(routes);
|
|
|
|
expect(manifest.routes[0].layer1.get(0)).toBe("static");
|
|
});
|
|
|
|
it("leaves layouts without segment config unclassified", async () => {
|
|
const layout = await writeLayout(
|
|
"layout-plain.tsx",
|
|
`export default function L({children}){return <div>{children}</div>;}`,
|
|
);
|
|
const routes = [makeRoute({ pattern: "/", layouts: [layout] })];
|
|
|
|
const manifest = collectRouteClassificationManifest(routes);
|
|
|
|
expect(manifest.routes[0].layer1.has(0)).toBe(false);
|
|
});
|
|
|
|
it("reads revalidate = 0 as dynamic", async () => {
|
|
const layout = await writeLayout(
|
|
"layout-reval0.tsx",
|
|
`export const revalidate = 0;\nexport default function L({children}){return children;}`,
|
|
);
|
|
const routes = [makeRoute({ pattern: "/", layouts: [layout] })];
|
|
|
|
const manifest = collectRouteClassificationManifest(routes);
|
|
|
|
expect(manifest.routes[0].layer1.get(0)).toBe("dynamic");
|
|
});
|
|
|
|
it("throws when a layout file is missing, naming the route and path", () => {
|
|
const missingPath = path.join(tmpDir, "nonexistent", "layout.tsx");
|
|
const routes = [makeRoute({ pattern: "/blog", layouts: [missingPath] })];
|
|
|
|
expect(() => collectRouteClassificationManifest(routes)).toThrow(/\/blog/);
|
|
expect(() => collectRouteClassificationManifest(routes)).toThrow(/nonexistent/);
|
|
});
|
|
});
|
|
|
|
describe("buildClassificationReplacement", () => {
|
|
function evalDispatch(source: string): (routeIdx: number) => unknown {
|
|
// The helper returns a function expression suitable for:
|
|
// function __VINEXT_CLASS(routeIdx) { return (<replacement>)(routeIdx); }
|
|
// Use vm.runInThisContext so the resulting Map instances share their
|
|
// prototype with the test process — `instanceof Map` would otherwise
|
|
// fail across v8 contexts.
|
|
const fn: unknown = vm.runInThisContext(`(${source})`);
|
|
if (typeof fn !== "function") {
|
|
throw new Error("buildClassificationReplacement did not produce a function expression");
|
|
}
|
|
return (routeIdx: number) => Reflect.apply(fn, null, [routeIdx]);
|
|
}
|
|
|
|
function makeManifest(
|
|
entries: Array<{ layer1?: Array<[number, "static" | "dynamic"]> }>,
|
|
): Parameters<typeof buildClassificationReplacement>[0] {
|
|
return {
|
|
routes: entries.map((e, idx) => {
|
|
const layer1 = new Map(e.layer1 ?? []);
|
|
const layer1Reasons = new Map<number, ClassificationReason>();
|
|
for (const [layoutIdx, kind] of layer1) {
|
|
layer1Reasons.set(layoutIdx, {
|
|
layer: "segment-config",
|
|
key: "dynamic",
|
|
value: kind === "dynamic" ? "force-dynamic" : "force-static",
|
|
});
|
|
}
|
|
return {
|
|
pattern: `/route-${idx}`,
|
|
layoutPaths: [],
|
|
layer1,
|
|
layer1Reasons,
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
it("returns a function expression that evaluates to a dispatch function", () => {
|
|
const manifest = makeManifest([{ layer1: [[0, "dynamic"]] }]);
|
|
const replacement = buildClassificationReplacement(manifest, new Map());
|
|
|
|
const dispatch = evalDispatch(replacement);
|
|
|
|
expect(typeof dispatch).toBe("function");
|
|
const result = dispatch(0);
|
|
expect(result).toBeInstanceOf(Map);
|
|
});
|
|
|
|
function asMap(value: unknown): Map<unknown, unknown> {
|
|
if (!(value instanceof Map)) {
|
|
throw new Error(`Expected Map, got ${String(value)}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
it("merges Layer 1 and Layer 2 into the dispatch function's Map", () => {
|
|
const manifest = makeManifest([{ layer1: [[0, "dynamic"]] }]);
|
|
const layer2: Parameters<typeof buildClassificationReplacement>[1] = new Map([
|
|
[0, new Map([[1, { layer: "module-graph", result: "static" }]])],
|
|
]);
|
|
const replacement = buildClassificationReplacement(manifest, layer2);
|
|
|
|
const dispatch = evalDispatch(replacement);
|
|
const result = asMap(dispatch(0));
|
|
|
|
expect(result.get(0)).toBe("dynamic");
|
|
expect(result.get(1)).toBe("static");
|
|
});
|
|
|
|
it("preserves Layer 1 priority over Layer 2 for the same layout index", () => {
|
|
const manifest = makeManifest([{ layer1: [[0, "dynamic"]] }]);
|
|
// Layer 2 proves "static" for index 0 — but Layer 1 said "dynamic", so
|
|
// Layer 1 must win. This guards against the classifier silently demoting
|
|
// a force-dynamic layout because the module graph happened to be clean.
|
|
const layer2: Parameters<typeof buildClassificationReplacement>[1] = new Map([
|
|
[0, new Map([[0, { layer: "module-graph", result: "static" }]])],
|
|
]);
|
|
const replacement = buildClassificationReplacement(manifest, layer2);
|
|
|
|
const dispatch = evalDispatch(replacement);
|
|
const result = asMap(dispatch(0));
|
|
|
|
expect(result.get(0)).toBe("dynamic");
|
|
});
|
|
|
|
it("returns null from dispatch for unknown route indices", () => {
|
|
const manifest = makeManifest([{ layer1: [[0, "dynamic"]] }]);
|
|
const replacement = buildClassificationReplacement(manifest, new Map());
|
|
|
|
const dispatch = evalDispatch(replacement);
|
|
|
|
expect(dispatch(999)).toBeNull();
|
|
});
|
|
|
|
it("returns null from dispatch for routes with no classifications", () => {
|
|
// Route 0 has Layer 1 data; route 1 has nothing in Layer 1 or Layer 2.
|
|
// A route with no merged entries should fall through to the default case.
|
|
const manifest = makeManifest([{ layer1: [[0, "dynamic"]] }, { layer1: [] }]);
|
|
const replacement = buildClassificationReplacement(manifest, new Map());
|
|
|
|
const dispatch = evalDispatch(replacement);
|
|
|
|
expect(dispatch(0)).toBeInstanceOf(Map);
|
|
expect(dispatch(1)).toBeNull();
|
|
});
|
|
|
|
it("throws when layer1 and layer1Reasons are out of sync", () => {
|
|
// This invariant is enforced in mergeLayersForRoute: every entry in layer1
|
|
// must have a corresponding entry in layer1Reasons. collectRouteClassificationManifest
|
|
// always populates them in lockstep, but callers constructing RouteManifestEntry
|
|
// manually could violate this.
|
|
const brokenManifest: Parameters<typeof buildClassificationReplacement>[0] = {
|
|
routes: [
|
|
{
|
|
pattern: "/broken",
|
|
layoutPaths: [],
|
|
layer1: new Map([[0, "dynamic"]]),
|
|
layer1Reasons: new Map(), // missing reason for layoutIdx 0
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(() => buildClassificationReplacement(brokenManifest, new Map())).toThrow(
|
|
/Layer 1 decision without a reason/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("buildReasonsReplacement", () => {
|
|
function evalDispatch(source: string): (routeIdx: number) => unknown {
|
|
const fn: unknown = vm.runInThisContext(`(${source})`);
|
|
if (typeof fn !== "function") {
|
|
throw new Error("buildReasonsReplacement did not produce a function expression");
|
|
}
|
|
return (routeIdx: number) => Reflect.apply(fn, null, [routeIdx]);
|
|
}
|
|
|
|
function asMap(value: unknown): Map<unknown, unknown> {
|
|
if (!(value instanceof Map)) {
|
|
throw new Error(`Expected Map, got ${String(value)}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function makeManifest(
|
|
entries: Array<{ layer1?: Array<[number, "static" | "dynamic"]> }>,
|
|
): Parameters<typeof buildReasonsReplacement>[0] {
|
|
return {
|
|
routes: entries.map((e, idx) => {
|
|
const layer1 = new Map(e.layer1 ?? []);
|
|
const layer1Reasons = new Map<number, ClassificationReason>();
|
|
for (const [layoutIdx, kind] of layer1) {
|
|
layer1Reasons.set(layoutIdx, {
|
|
layer: "segment-config",
|
|
key: "dynamic",
|
|
value: kind === "dynamic" ? "force-dynamic" : "force-static",
|
|
});
|
|
}
|
|
return {
|
|
pattern: `/route-${idx}`,
|
|
layoutPaths: [],
|
|
layer1,
|
|
layer1Reasons,
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
it("returns segment-config reasons for Layer 1 decisions", () => {
|
|
const manifest = makeManifest([{ layer1: [[0, "dynamic"]] }]);
|
|
const replacement = buildReasonsReplacement(manifest, new Map());
|
|
|
|
const dispatch = evalDispatch(replacement);
|
|
const result = asMap(dispatch(0));
|
|
|
|
expect(result.get(0)).toEqual({
|
|
layer: "segment-config",
|
|
key: "dynamic",
|
|
value: "force-dynamic",
|
|
});
|
|
});
|
|
|
|
it("preserves Infinity in segment-config reasons", () => {
|
|
const manifest = {
|
|
routes: [
|
|
{
|
|
pattern: "/route-0",
|
|
layoutPaths: [],
|
|
layer1: new Map<number, "static" | "dynamic">([[0, "static"]]),
|
|
layer1Reasons: new Map<number, ClassificationReason>([
|
|
[0, { layer: "segment-config", key: "revalidate", value: Infinity }],
|
|
]),
|
|
},
|
|
],
|
|
};
|
|
const replacement = buildReasonsReplacement(manifest, new Map());
|
|
|
|
const dispatch = evalDispatch(replacement);
|
|
const result = asMap(dispatch(0));
|
|
|
|
expect(result.get(0)).toEqual({
|
|
layer: "segment-config",
|
|
key: "revalidate",
|
|
value: Infinity,
|
|
});
|
|
});
|
|
|
|
it("returns module-graph reasons for Layer 2 static decisions", () => {
|
|
const manifest = makeManifest([{ layer1: [] }]);
|
|
const layer2: Parameters<typeof buildReasonsReplacement>[1] = new Map([
|
|
[0, new Map([[3, { layer: "module-graph", result: "static" }]])],
|
|
]);
|
|
const replacement = buildReasonsReplacement(manifest, layer2);
|
|
|
|
const dispatch = evalDispatch(replacement);
|
|
const result = asMap(dispatch(0));
|
|
|
|
expect(result.get(3)).toEqual({
|
|
layer: "module-graph",
|
|
result: "static",
|
|
});
|
|
});
|
|
|
|
it("preserves Layer 1 reason priority over Layer 2 for the same layout index", () => {
|
|
const manifest = makeManifest([{ layer1: [[0, "dynamic"]] }]);
|
|
const layer2: Parameters<typeof buildReasonsReplacement>[1] = new Map([
|
|
[0, new Map([[0, { layer: "module-graph", result: "static" }]])],
|
|
]);
|
|
const replacement = buildReasonsReplacement(manifest, layer2);
|
|
|
|
const dispatch = evalDispatch(replacement);
|
|
const result = asMap(dispatch(0));
|
|
|
|
// Layer 1 wins — the reason carried must be the segment-config reason,
|
|
// not the module-graph reason.
|
|
expect(result.get(0)).toEqual({
|
|
layer: "segment-config",
|
|
key: "dynamic",
|
|
value: "force-dynamic",
|
|
});
|
|
});
|
|
|
|
it("returns null for routes with no classifications", () => {
|
|
const manifest = makeManifest([{ layer1: [[0, "dynamic"]] }, { layer1: [] }]);
|
|
const replacement = buildReasonsReplacement(manifest, new Map());
|
|
|
|
const dispatch = evalDispatch(replacement);
|
|
|
|
expect(dispatch(1)).toBeNull();
|
|
});
|
|
});
|