Files
cloudflare__vinext/tests/cache-adapters-build.test.ts
T
James Anderson 0b7721dba6 perf(build): minify server build environments by default (#1777)
* 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.
2026-06-06 22:55:04 +01:00

204 lines
6.7 KiB
TypeScript

/**
* Verifies that a `cache.data` / `cache.cdn` adapter pointing at a LOCAL file by
* absolute path — i.e. what `require.resolve("./my-adapter")` yields in a user's
* vite config — resolves and bundles into the Cloudflare worker. A bare relative
* specifier would have no on-disk anchor (the registration module is virtual),
* so absolute paths are the supported way to reference local adapters.
*
* This is a real Cloudflare build, so it also proves nothing throws at build
* time: the descriptor is inert config data, and the adapter is only invoked at
* request time.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { createBuilder } from "vite";
import { afterEach, describe, expect, it } from "vite-plus/test";
import vinext from "../packages/vinext/src/index.js";
const tmpDirs: string[] = [];
const workerEntryPath = path
.resolve(import.meta.dirname, "../packages/vinext/src/server/app-router-entry.ts")
.replace(/\\/g, "/");
const cfPluginPath = path.resolve(
import.meta.dirname,
"./fixtures/cf-app-basic/node_modules/@cloudflare/vite-plugin/dist/index.mjs",
);
type CloudflarePluginFactory = (opts?: {
viteEnvironment?: { name: string; childEnvironments?: string[] };
}) => import("vite").Plugin;
function writeFixtureFile(root: string, filePath: string, content: string) {
const absPath = path.join(root, filePath);
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, content);
}
function readTextFilesRecursive(root: string): string {
let output = "";
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
const entryPath = path.join(root, entry.name);
if (entry.isDirectory()) {
output += readTextFilesRecursive(entryPath);
continue;
}
if (!entry.name.endsWith(".js")) continue;
output += fs.readFileSync(entryPath, "utf-8");
}
return output;
}
function writeCloudflareAppFixture(root: string, name: string) {
fs.symlinkSync(
path.resolve(import.meta.dirname, "../node_modules"),
path.join(root, "node_modules"),
"junction",
);
writeFixtureFile(
root,
"package.json",
JSON.stringify({ name, private: true, type: "module" }, null, 2),
);
writeFixtureFile(
root,
"wrangler.jsonc",
`{
"name": ${JSON.stringify(name)},
"compatibility_date": "2026-02-12",
"compatibility_flags": ["nodejs_compat"],
"main": "./worker/index.ts",
"assets": { "not_found_handling": "none", "binding": "ASSETS" }
}
`,
);
writeFixtureFile(
root,
"tsconfig.json",
JSON.stringify(
{
compilerOptions: {
target: "ES2022",
module: "ESNext",
moduleResolution: "bundler",
jsx: "react-jsx",
strict: true,
skipLibCheck: true,
types: ["vite/client", "@vitejs/plugin-rsc/types"],
},
include: ["app", "*.ts", "*.tsx"],
},
null,
2,
),
);
writeFixtureFile(
root,
"app/layout.tsx",
`export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
`,
);
writeFixtureFile(
root,
"app/page.tsx",
`export default function HomePage() {
return <main>home</main>;
}
`,
);
writeFixtureFile(
root,
"mdx-components.tsx",
`export function useMDXComponents(components: Record<string, unknown>) {
return components;
}
`,
);
writeFixtureFile(
root,
"worker/index.ts",
`import handler from ${JSON.stringify(workerEntryPath)};\n\nexport default handler;\n`,
);
}
const LOCAL_ADAPTER_MARKER = "__VINEXT_LOCAL_DATA_ADAPTER_MARKER__";
describe("config-driven cache adapter — local file by absolute path", () => {
afterEach(() => {
for (const dir of tmpDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("resolves and bundles a require.resolve-style absolute adapter path", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-cache-adapter-build-"));
tmpDirs.push(root);
writeCloudflareAppFixture(root, "vinext-cache-adapter-build");
// A local adapter module the user would reference via
// `require.resolve("./cache/my-data-adapter")` → an ABSOLUTE path.
writeFixtureFile(
root,
"cache/my-data-adapter.ts",
`// A custom adapter module: default-exports a factory ({ env, options }) => CacheHandler.
const createAdapter = () => {
const store = new Map();
// The marker is a live property of the returned (escaping) handler, so it
// survives tree-shaking/minification and proves this module was bundled.
return {
adapterMarker: "${LOCAL_ADAPTER_MARKER}",
async get(key) { return store.get(key) ?? null; },
async set(key, data) { store.set(key, data); },
async revalidateTag() {},
};
};
export default createAdapter;
`,
);
// This absolute path is exactly what require.resolve("./cache/my-data-adapter")
// produces in a vite.config (modulo extension resolution).
const adapterAbsPath = path.join(root, "cache/my-data-adapter.ts").replace(/\\/g, "/");
const { cloudflare } = (await import(pathToFileURL(cfPluginPath).href)) as {
cloudflare: CloudflarePluginFactory;
};
const builder = await createBuilder({
root,
configFile: false,
plugins: [
vinext({ appDir: root, cache: { data: { adapter: adapterAbsPath } } }),
cloudflare({ viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] } }),
],
logLevel: "silent",
// Build with vinext's production default (server minification ON) so this
// test covers the real shipping path. The assertion below keys off the
// adapter's `LOCAL_ADAPTER_MARKER` string literal, whose contents survive
// minification verbatim (only identifiers are mangled) — so it remains a
// valid "the adapter module was bundled" signal under minify. We
// intentionally do NOT grep for the readable `registerConfiguredCacheAdapters`
// function name: minify renames it (harmlessly — it is called by reference,
// never by name), so that grep would be a minify-off-only proxy that no
// longer reflects production.
});
// Build completing at all proves the absolute-path import resolved and that
// the inert descriptor did not require any Workers context at build time.
await builder.buildApp();
const buildOutput = readTextFilesRecursive(path.join(root, "dist"));
// Minify-safe: the marker is a string literal in the escaping handler, so
// its presence proves the local adapter module was bundled even though the
// build ran minified (the production default).
expect(buildOutput).toContain(LOCAL_ADAPTER_MARKER);
}, 60_000);
});