cloudflare/vinext

Public

mirrored from https://github.com/cloudflare/vinextAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v0.0.9

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

tests/build-optimization.test.ts

894lines · modecode

1/**
2 * Build optimization tests — verifies tree-shaking and chunking configuration
3 * is correctly applied to client builds.
4 *
5 * Tests the treeshake config, manualChunks function, and experimentalMinChunkSize
6 * to ensure large barrel-exporting libraries (e.g. mermaid) produce smaller bundles.
7 */
8import { describe, it, expect } from "vitest";
9import {
10 clientManualChunks,
11 clientTreeshakeConfig,
12 computeLazyChunks,
13} from "../packages/vinext/src/index.js";
14
15// ─── clientTreeshakeConfig ────────────────────────────────────────────────────
16
17describe("clientTreeshakeConfig", () => {
18 it("uses 'recommended' preset for safe defaults", () => {
19 expect(clientTreeshakeConfig.preset).toBe("recommended");
20 });
21
22 it("sets moduleSideEffects to 'no-external' for aggressive vendor DCE", () => {
23 // 'no-external' marks node_modules as side-effect-free (enabling DCE for
24 // barrel-heavy libraries) while preserving side effects for local modules
25 // (CSS imports, polyfills).
26 expect(clientTreeshakeConfig.moduleSideEffects).toBe("no-external");
27 });
28});
29
30// ─── clientManualChunks ───────────────────────────────────────────────────────
31
32describe("clientManualChunks", () => {
33 it("groups react into 'framework' chunk", () => {
34 expect(clientManualChunks("/node_modules/react/index.js")).toBe("framework");
35 });
36
37 it("groups react-dom into 'framework' chunk", () => {
38 expect(clientManualChunks("/node_modules/react-dom/client.js")).toBe("framework");
39 });
40
41 it("groups scheduler into 'framework' chunk", () => {
42 expect(clientManualChunks("/node_modules/scheduler/index.js")).toBe("framework");
43 });
44
45 it("returns undefined for other node_modules (Rollup default splitting)", () => {
46 expect(clientManualChunks("/node_modules/mermaid/dist/mermaid.js")).toBeUndefined();
47 expect(clientManualChunks("/node_modules/lodash-es/lodash.js")).toBeUndefined();
48 expect(clientManualChunks("/node_modules/@mui/material/index.js")).toBeUndefined();
49 expect(clientManualChunks("/node_modules/d3-selection/src/index.js")).toBeUndefined();
50 });
51
52 it("returns undefined for user source files", () => {
53 expect(clientManualChunks("/src/components/App.tsx")).toBeUndefined();
54 expect(clientManualChunks("/src/pages/index.tsx")).toBeUndefined();
55 });
56
57 it("handles pnpm-style nested node_modules paths", () => {
58 const pnpmPath = "/node_modules/.pnpm/react@19.0.0/node_modules/react/index.js";
59 expect(clientManualChunks(pnpmPath)).toBe("framework");
60 });
61
62 it("handles scoped package names correctly", () => {
63 // Scoped packages should not be grouped into framework
64 expect(clientManualChunks("/node_modules/@tanstack/react-query/index.js")).toBeUndefined();
65 });
66});
67
68// ─── optimizeDeps.exclude — prevents esbuild scanning virtual module imports ─
69
70describe("optimizeDeps.exclude for vinext", () => {
71 it("excludes vinext at top level for Pages Router builds", async () => {
72 const vinext = (await import("../packages/vinext/src/index.js")).default;
73 const plugins = vinext();
74
75 const mainPlugin = plugins.find(
76 (p: any) => p.name === "vinext:config" && typeof p.config === "function",
77 );
78 expect(mainPlugin).toBeDefined();
79
80 const os = await import("node:os");
81 const fsp = await import("node:fs/promises");
82 const path = await import("node:path");
83
84 const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-ts-test-optdeps-"));
85 const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
86 await fsp.symlink(rootNodeModules, path.join(tmpDir, "node_modules"), "junction");
87
88 await fsp.mkdir(path.join(tmpDir, "pages"), { recursive: true });
89 await fsp.writeFile(
90 path.join(tmpDir, "pages", "index.tsx"),
91 `export default function Home() { return <h1>Home</h1>; }`,
92 );
93 await fsp.writeFile(
94 path.join(tmpDir, "next.config.mjs"),
95 `export default {};`,
96 );
97
98 try {
99 const mockConfig = { root: tmpDir, build: {}, plugins: [] };
100 const result = await (mainPlugin as any).config(mockConfig, { command: "build" });
101
102 expect(result.optimizeDeps?.exclude).toContain("vinext");
103 } finally {
104 await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
105 }
106 }, 15000);
107
108 it("excludes vinext in all environments for App Router builds", async () => {
109 const vinext = (await import("../packages/vinext/src/index.js")).default;
110 const plugins = vinext();
111
112 const mainPlugin = plugins.find(
113 (p: any) => p.name === "vinext:config" && typeof p.config === "function",
114 );
115 expect(mainPlugin).toBeDefined();
116
117 const os = await import("node:os");
118 const fsp = await import("node:fs/promises");
119 const path = await import("node:path");
120
121 const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-ts-test-optdeps-app-"));
122 const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
123 await fsp.symlink(rootNodeModules, path.join(tmpDir, "node_modules"), "junction");
124
125 await fsp.mkdir(path.join(tmpDir, "app"), { recursive: true });
126 await fsp.writeFile(
127 path.join(tmpDir, "app", "layout.tsx"),
128 `export default function RootLayout({ children }: { children: React.ReactNode }) { return <html><body>{children}</body></html>; }`,
129 );
130 await fsp.writeFile(
131 path.join(tmpDir, "app", "page.tsx"),
132 `export default function Home() { return <h1>Home</h1>; }`,
133 );
134 await fsp.writeFile(
135 path.join(tmpDir, "next.config.mjs"),
136 `export default {};`,
137 );
138
139 try {
140 const mockConfig = { root: tmpDir, build: {}, plugins: [] };
141 const result = await (mainPlugin as any).config(mockConfig, { command: "build" });
142
143 // Top-level
144 expect(result.optimizeDeps?.exclude).toContain("vinext");
145 // Per-environment
146 expect(result.environments.rsc.optimizeDeps?.exclude).toContain("vinext");
147 expect(result.environments.ssr.optimizeDeps?.exclude).toContain("vinext");
148 expect(result.environments.client.optimizeDeps?.exclude).toContain("vinext");
149 } finally {
150 await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
151 }
152 }, 15000);
153});
154
155// ─── Treeshake config applied to Vite builds ──────────────────────────────────
156
157describe("treeshake config integration", () => {
158 it("plugin config hook applies treeshake to non-SSR builds", async () => {
159 const vinext = (await import("../packages/vinext/src/index.js")).default;
160 const plugins = vinext();
161
162 // Find the main vinext plugin (has a config hook)
163 const mainPlugin = plugins.find(
164 (p: any) => p.name === "vinext:config" && typeof p.config === "function",
165 );
166 expect(mainPlugin).toBeDefined();
167
168 // Simulate a client build config (no build.ssr)
169 const os = await import("node:os");
170 const fsp = await import("node:fs/promises");
171 const path = await import("node:path");
172
173 const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-ts-test-"));
174 const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
175 await fsp.symlink(rootNodeModules, path.join(tmpDir, "node_modules"), "junction");
176
177 await fsp.mkdir(path.join(tmpDir, "pages"), { recursive: true });
178 await fsp.writeFile(
179 path.join(tmpDir, "pages", "index.tsx"),
180 `export default function Home() { return <h1>Home</h1>; }`,
181 );
182 await fsp.writeFile(
183 path.join(tmpDir, "next.config.mjs"),
184 `export default {};`,
185 );
186
187 try {
188 const mockConfig = {
189 root: tmpDir,
190 build: {},
191 plugins: [],
192 };
193 const result = await (mainPlugin as any).config(mockConfig, { command: "build" });
194
195 // treeshake should be set on rollupOptions for non-SSR builds
196 expect(result.build.rollupOptions.treeshake).toEqual({
197 preset: "recommended",
198 moduleSideEffects: "no-external",
199 });
200 } finally {
201 await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
202 }
203 }, 15000);
204
205 it("plugin config hook does NOT apply treeshake to SSR builds", async () => {
206 const vinext = (await import("../packages/vinext/src/index.js")).default;
207 const plugins = vinext();
208
209 const mainPlugin = plugins.find(
210 (p: any) => p.name === "vinext:config" && typeof p.config === "function",
211 );
212 expect(mainPlugin).toBeDefined();
213
214 const os = await import("node:os");
215 const fsp = await import("node:fs/promises");
216 const path = await import("node:path");
217
218 const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-ts-test-ssr-"));
219 const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
220 await fsp.symlink(rootNodeModules, path.join(tmpDir, "node_modules"), "junction");
221
222 await fsp.mkdir(path.join(tmpDir, "pages"), { recursive: true });
223 await fsp.writeFile(
224 path.join(tmpDir, "pages", "index.tsx"),
225 `export default function Home() { return <h1>Home</h1>; }`,
226 );
227 await fsp.writeFile(
228 path.join(tmpDir, "next.config.mjs"),
229 `export default {};`,
230 );
231
232 try {
233 const mockConfig = {
234 root: tmpDir,
235 build: { ssr: "virtual:vinext-server-entry" },
236 plugins: [],
237 };
238 const result = await (mainPlugin as any).config(mockConfig, { command: "build" });
239
240 // treeshake should NOT be set for SSR builds
241 expect(result.build.rollupOptions.treeshake).toBeUndefined();
242 } finally {
243 await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
244 }
245 }, 15000);
246
247 it("multi-env build scopes treeshake to client environment only", async () => {
248 // In App Router builds (multi-env), treeshake must NOT be set globally
249 // (which would leak into RSC/SSR) — it should only appear on the client
250 // environment's rollupOptions.
251 const vinext = (await import("../packages/vinext/src/index.js")).default;
252 const plugins = vinext();
253
254 const mainPlugin = plugins.find(
255 (p: any) => p.name === "vinext:config" && typeof p.config === "function",
256 );
257 expect(mainPlugin).toBeDefined();
258
259 const os = await import("node:os");
260 const fsp = await import("node:fs/promises");
261 const path = await import("node:path");
262
263 const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-ts-test-multienv-"));
264 const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
265 await fsp.symlink(rootNodeModules, path.join(tmpDir, "node_modules"), "junction");
266
267 // Create an app/ directory to trigger multi-env mode (hasAppDir = true)
268 await fsp.mkdir(path.join(tmpDir, "app"), { recursive: true });
269 await fsp.writeFile(
270 path.join(tmpDir, "app", "layout.tsx"),
271 `export default function RootLayout({ children }: { children: React.ReactNode }) { return <html><body>{children}</body></html>; }`,
272 );
273 await fsp.writeFile(
274 path.join(tmpDir, "app", "page.tsx"),
275 `export default function Home() { return <h1>Home</h1>; }`,
276 );
277 await fsp.writeFile(
278 path.join(tmpDir, "next.config.mjs"),
279 `export default {};`,
280 );
281
282 try {
283 const mockConfig = {
284 root: tmpDir,
285 build: {},
286 plugins: [],
287 };
288 const result = await (mainPlugin as any).config(mockConfig, { command: "build" });
289
290 // Global rollupOptions should NOT have treeshake (would leak into RSC/SSR)
291 expect(result.build.rollupOptions.treeshake).toBeUndefined();
292
293 // Client environment should have treeshake
294 expect(result.environments.client.build.rollupOptions.treeshake).toEqual({
295 preset: "recommended",
296 moduleSideEffects: "no-external",
297 });
298
299 // RSC and SSR environments should NOT have treeshake
300 expect(result.environments.rsc.build?.rollupOptions?.treeshake).toBeUndefined();
301 expect(result.environments.ssr.build?.rollupOptions?.treeshake).toBeUndefined();
302 } finally {
303 await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
304 }
305 }, 15000);
306
307 it("client output config includes experimentalMinChunkSize", async () => {
308 const vinext = (await import("../packages/vinext/src/index.js")).default;
309 const plugins = vinext();
310
311 const mainPlugin = plugins.find(
312 (p: any) => p.name === "vinext:config" && typeof p.config === "function",
313 );
314 expect(mainPlugin).toBeDefined();
315
316 const os = await import("node:os");
317 const fsp = await import("node:fs/promises");
318 const path = await import("node:path");
319
320 const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-ts-test-mcs-"));
321 const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
322 await fsp.symlink(rootNodeModules, path.join(tmpDir, "node_modules"), "junction");
323
324 await fsp.mkdir(path.join(tmpDir, "pages"), { recursive: true });
325 await fsp.writeFile(
326 path.join(tmpDir, "pages", "index.tsx"),
327 `export default function Home() { return <h1>Home</h1>; }`,
328 );
329 await fsp.writeFile(
330 path.join(tmpDir, "next.config.mjs"),
331 `export default {};`,
332 );
333
334 try {
335 const mockConfig = {
336 root: tmpDir,
337 build: {},
338 plugins: [],
339 };
340 const result = await (mainPlugin as any).config(mockConfig, { command: "build" });
341
342 // For standalone client builds (non-SSR, non-multi-env),
343 // output config should include experimentalMinChunkSize
344 const output = result.build.rollupOptions.output;
345 expect(output).toBeDefined();
346 expect(output.experimentalMinChunkSize).toBe(10_000);
347 } finally {
348 await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
349 }
350 }, 15000);
351
352 it("App Router client env gets manifest: true when Cloudflare plugin is present", async () => {
353 // When deploying to Cloudflare Workers, the client environment must produce
354 // a build manifest (manifest.json) so the vinext:cloudflare-build plugin can
355 // read dynamicImports and compute lazy chunks. Without this, all chunks get
356 // modulepreloaded on every page, defeating code-splitting for React.lazy()
357 // and next/dynamic boundaries.
358 const vinext = (await import("../packages/vinext/src/index.js")).default;
359 const plugins = vinext();
360
361 const mainPlugin = plugins.find(
362 (p: any) => p.name === "vinext:config" && typeof p.config === "function",
363 );
364 expect(mainPlugin).toBeDefined();
365
366 const os = await import("node:os");
367 const fsp = await import("node:fs/promises");
368 const path = await import("node:path");
369
370 const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-ts-test-cf-manifest-"));
371 const rootNodeModules = path.resolve(import.meta.dirname, "../node_modules");
372 await fsp.symlink(rootNodeModules, path.join(tmpDir, "node_modules"), "junction");
373
374 // Create an app/ directory to trigger App Router multi-env mode
375 await fsp.mkdir(path.join(tmpDir, "app"), { recursive: true });
376 await fsp.writeFile(
377 path.join(tmpDir, "app", "layout.tsx"),
378 `export default function RootLayout({ children }: { children: React.ReactNode }) { return <html><body>{children}</body></html>; }`,
379 );
380 await fsp.writeFile(
381 path.join(tmpDir, "app", "page.tsx"),
382 `export default function Home() { return <h1>Home</h1>; }`,
383 );
384 await fsp.writeFile(
385 path.join(tmpDir, "next.config.mjs"),
386 `export default {};`,
387 );
388
389 try {
390 // Simulate having the Cloudflare plugin in the plugin list.
391 // The vinext config hook detects it by checking plugin names.
392 const fakeCloudflarePlugin = { name: "vite-plugin-cloudflare" };
393 const mockConfig = {
394 root: tmpDir,
395 build: {},
396 plugins: [fakeCloudflarePlugin],
397 };
398 const result = await (mainPlugin as any).config(mockConfig, { command: "build" });
399
400 // Client environment should have manifest: true for lazy chunk detection
401 expect(result.environments).toBeDefined();
402 expect(result.environments.client).toBeDefined();
403 expect(result.environments.client.build.manifest).toBe(true);
404
405 // Without Cloudflare plugin, manifest should NOT be set (standard App Router)
406 const resultNoCf = await (mainPlugin as any).config({
407 root: tmpDir,
408 build: {},
409 plugins: [],
410 }, { command: "build" });
411
412 expect(resultNoCf.environments.client.build.manifest).toBeUndefined();
413 } finally {
414 await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
415 }
416 }, 15000);
417});
418
419// ─── computeLazyChunks ────────────────────────────────────────────────────────
420
421describe("computeLazyChunks", () => {
422 it("returns empty array for manifest with only entry chunks", () => {
423 const manifest = {
424 "src/main.ts": {
425 file: "assets/main-abc123.js",
426 isEntry: true,
427 imports: [],
428 },
429 };
430 expect(computeLazyChunks(manifest)).toEqual([]);
431 });
432
433 it("excludes statically imported chunks from lazy set", () => {
434 const manifest = {
435 "src/main.ts": {
436 file: "assets/main-abc123.js",
437 isEntry: true,
438 imports: ["src/utils.ts"],
439 },
440 "src/utils.ts": {
441 file: "assets/utils-def456.js",
442 },
443 };
444 expect(computeLazyChunks(manifest)).toEqual([]);
445 });
446
447 it("identifies dynamically-imported-only chunks as lazy", () => {
448 const manifest = {
449 "src/main.ts": {
450 file: "assets/main-abc123.js",
451 isEntry: true,
452 imports: ["src/framework.ts"],
453 dynamicImports: ["src/mermaid.ts"],
454 },
455 "src/framework.ts": {
456 file: "assets/framework-abc.js",
457 },
458 "src/mermaid.ts": {
459 file: "assets/mermaid-NOHMQCX5.js",
460 isDynamicEntry: true,
461 },
462 };
463 const lazy = computeLazyChunks(manifest);
464 expect(lazy).toContain("assets/mermaid-NOHMQCX5.js");
465 expect(lazy).not.toContain("assets/main-abc123.js");
466 expect(lazy).not.toContain("assets/framework-abc.js");
467 });
468
469 it("handles transitive static imports from entry", () => {
470 // entry -> A -> B (all static) — none should be lazy
471 const manifest = {
472 "src/main.ts": {
473 file: "assets/main.js",
474 isEntry: true,
475 imports: ["src/a.ts"],
476 },
477 "src/a.ts": {
478 file: "assets/a.js",
479 imports: ["src/b.ts"],
480 },
481 "src/b.ts": {
482 file: "assets/b.js",
483 },
484 };
485 expect(computeLazyChunks(manifest)).toEqual([]);
486 });
487
488 it("handles transitive dynamic imports as lazy", () => {
489 // entry -> A (static) -> B (dynamic) -> C (static from B)
490 // B and C should be lazy (B is only reachable via dynamic import)
491 // But C is statically imported by B, which is a dynamic entry
492 // Since B is not an entry, C is only reachable through B which is lazy
493 const manifest = {
494 "src/main.ts": {
495 file: "assets/main.js",
496 isEntry: true,
497 imports: ["src/a.ts"],
498 },
499 "src/a.ts": {
500 file: "assets/a.js",
501 dynamicImports: ["src/b.ts"],
502 },
503 "src/b.ts": {
504 file: "assets/b.js",
505 isDynamicEntry: true,
506 imports: ["src/c.ts"],
507 },
508 "src/c.ts": {
509 file: "assets/c.js",
510 },
511 };
512 const lazy = computeLazyChunks(manifest);
513 expect(lazy).toContain("assets/b.js");
514 expect(lazy).toContain("assets/c.js");
515 expect(lazy).not.toContain("assets/main.js");
516 expect(lazy).not.toContain("assets/a.js");
517 });
518
519 it("does not mark CSS files as lazy", () => {
520 const manifest = {
521 "src/main.ts": {
522 file: "assets/main.js",
523 isEntry: true,
524 dynamicImports: ["src/lazy.ts"],
525 },
526 "src/lazy.ts": {
527 file: "assets/lazy.js",
528 isDynamicEntry: true,
529 css: ["assets/lazy-styles.css"],
530 },
531 };
532 const lazy = computeLazyChunks(manifest);
533 expect(lazy).toContain("assets/lazy.js");
534 // CSS files are never in the lazy list (only .js files)
535 expect(lazy).not.toContain("assets/lazy-styles.css");
536 });
537
538 it("handles chunk shared between static and dynamic paths as eager", () => {
539 // If a chunk is statically imported by one module AND dynamically by another,
540 // it should NOT be lazy (it's reachable statically)
541 const manifest = {
542 "src/main.ts": {
543 file: "assets/main.js",
544 isEntry: true,
545 imports: ["src/shared.ts"],
546 dynamicImports: ["src/lazy.ts"],
547 },
548 "src/shared.ts": {
549 file: "assets/shared.js",
550 },
551 "src/lazy.ts": {
552 file: "assets/lazy.js",
553 isDynamicEntry: true,
554 imports: ["src/shared.ts"],
555 },
556 };
557 const lazy = computeLazyChunks(manifest);
558 expect(lazy).toContain("assets/lazy.js");
559 expect(lazy).not.toContain("assets/shared.js");
560 expect(lazy).not.toContain("assets/main.js");
561 });
562
563 it("returns empty array for empty manifest", () => {
564 expect(computeLazyChunks({})).toEqual([]);
565 });
566
567 it("handles circular static imports without infinite loop", () => {
568 const manifest = {
569 "src/entry.ts": {
570 file: "assets/entry.js",
571 isEntry: true,
572 imports: ["src/a.ts"],
573 },
574 "src/a.ts": {
575 file: "assets/a.js",
576 imports: ["src/b.ts"],
577 },
578 "src/b.ts": {
579 file: "assets/b.js",
580 imports: ["src/a.ts"], // circular: b -> a -> b -> ...
581 },
582 };
583 const lazy = computeLazyChunks(manifest);
584 expect(lazy).toEqual([]);
585 // All three are statically reachable from entry
586 });
587
588 it("handles multiple entry points", () => {
589 const manifest = {
590 "src/main.ts": {
591 file: "assets/main.js",
592 isEntry: true,
593 imports: ["src/a.ts"],
594 },
595 "src/other-entry.ts": {
596 file: "assets/other.js",
597 isEntry: true,
598 imports: ["src/b.ts"],
599 },
600 "src/a.ts": {
601 file: "assets/a.js",
602 },
603 "src/b.ts": {
604 file: "assets/b.js",
605 dynamicImports: ["src/lazy.ts"],
606 },
607 "src/lazy.ts": {
608 file: "assets/lazy.js",
609 isDynamicEntry: true,
610 },
611 };
612 const lazy = computeLazyChunks(manifest);
613 expect(lazy).toContain("assets/lazy.js");
614 expect(lazy).not.toContain("assets/main.js");
615 expect(lazy).not.toContain("assets/other.js");
616 expect(lazy).not.toContain("assets/a.js");
617 expect(lazy).not.toContain("assets/b.js");
618 });
619
620 it("handles manifest with no entry chunks (all chunks marked lazy)", () => {
621 // If no chunks have isEntry, the BFS starts with an empty queue
622 // and all JS files should be classified as lazy
623 const manifest = {
624 "src/orphan.ts": {
625 file: "assets/orphan.js",
626 imports: [],
627 },
628 "src/other.ts": {
629 file: "assets/other.js",
630 },
631 };
632 const lazy = computeLazyChunks(manifest);
633 expect(lazy).toContain("assets/orphan.js");
634 expect(lazy).toContain("assets/other.js");
635 });
636
637 it("handles realistic mermaid-like scenario", () => {
638 // Simulates: client entry -> page (dynamic) -> streamdown (static from page)
639 // streamdown -> mermaid (dynamic via React.lazy)
640 // The page itself is dynamic from the entry (vinext pattern), but
641 // mermaid is dynamic from streamdown — mermaid should be lazy
642 const manifest = {
643 "virtual:vinext-client-entry": {
644 file: "assets/vinext-client-entry-abc.js",
645 isEntry: true,
646 imports: ["node_modules/react/index.js", "node_modules/react-dom/client.js"],
647 dynamicImports: ["src/pages/index.tsx", "src/pages/about.tsx"],
648 },
649 "node_modules/react/index.js": {
650 file: "assets/framework-xyz.js",
651 },
652 "node_modules/react-dom/client.js": {
653 file: "assets/framework-xyz.js", // same chunk (manualChunks)
654 },
655 "src/pages/index.tsx": {
656 file: "assets/index-page.js",
657 isDynamicEntry: true,
658 imports: ["node_modules/streamdown/index.js"],
659 dynamicImports: [],
660 },
661 "src/pages/about.tsx": {
662 file: "assets/about-page.js",
663 isDynamicEntry: true,
664 },
665 "node_modules/streamdown/index.js": {
666 file: "assets/streamdown-chunk.js",
667 dynamicImports: ["node_modules/mermaid/dist/mermaid.js"],
668 },
669 "node_modules/mermaid/dist/mermaid.js": {
670 file: "assets/mermaid-NOHMQCX5.js",
671 isDynamicEntry: true,
672 },
673 };
674 const lazy = computeLazyChunks(manifest);
675 // Mermaid should be lazy — only reachable through dynamic imports
676 expect(lazy).toContain("assets/mermaid-NOHMQCX5.js");
677 // Pages are dynamic from entry — they should also be lazy
678 expect(lazy).toContain("assets/index-page.js");
679 expect(lazy).toContain("assets/about-page.js");
680 // streamdown is statically imported by a page, but the page itself is
681 // dynamic from entry — so streamdown is also lazy
682 expect(lazy).toContain("assets/streamdown-chunk.js");
683 // Framework and entry should NOT be lazy
684 expect(lazy).not.toContain("assets/vinext-client-entry-abc.js");
685 expect(lazy).not.toContain("assets/framework-xyz.js");
686 });
687});
688
689// ─── collectAssetTags lazy filtering (integration) ────────────────────────────
690
691describe("collectAssetTags lazy chunk filtering", () => {
692 // collectAssetTags lives inside the generated virtual server entry and
693 // can't be imported directly. These tests verify the filtering behavior
694 // by simulating what collectAssetTags does: build a lazy set from
695 // computeLazyChunks output, then filter asset tags accordingly.
696
697 /**
698 * Simulates the collectAssetTags filtering logic:
699 * - Normalizes leading slashes from SSR manifest values
700 * - CSS files always get a <link rel="stylesheet"> tag
701 * - Non-lazy JS files get both modulepreload and script tags
702 * - Lazy JS files are skipped entirely
703 *
704 * Must match the actual collectAssetTags implementation in index.ts.
705 */
706 function simulateAssetTagFiltering(
707 ssrManifestFiles: string[],
708 lazyChunks: string[],
709 ): string[] {
710 const lazySet = new Set(lazyChunks);
711 const tags: string[] = [];
712 const seen = new Set<string>();
713
714 for (let tf of ssrManifestFiles) {
715 // Normalize: strip leading slash from SSR manifest values to avoid
716 // producing protocol-relative URLs (e.g. "//assets/chunk.js") and
717 // to ensure consistent matching against lazySet and seen set.
718 if (tf.startsWith("/")) tf = tf.slice(1);
719 if (seen.has(tf)) continue;
720 seen.add(tf);
721 if (tf.endsWith(".css")) {
722 tags.push(`<link rel="stylesheet" href="/${tf}" />`);
723 } else if (tf.endsWith(".js")) {
724 if (lazySet.has(tf)) continue;
725 tags.push(`<link rel="modulepreload" href="/${tf}" />`);
726 tags.push(`<script type="module" src="/${tf}" crossorigin></script>`);
727 }
728 }
729 return tags;
730 }
731
732 it("excludes lazy JS chunks from modulepreload and script tags", () => {
733 const buildManifest = {
734 "virtual:vinext-client-entry": {
735 file: "assets/entry.js",
736 isEntry: true,
737 imports: ["node_modules/react/index.js"],
738 dynamicImports: ["src/pages/index.tsx"],
739 },
740 "node_modules/react/index.js": {
741 file: "assets/framework.js",
742 },
743 "src/pages/index.tsx": {
744 file: "assets/page-index.js",
745 isDynamicEntry: true,
746 dynamicImports: ["node_modules/mermaid/dist/mermaid.js"],
747 },
748 "node_modules/mermaid/dist/mermaid.js": {
749 file: "assets/mermaid-big.js",
750 isDynamicEntry: true,
751 },
752 };
753
754 const lazyChunks = computeLazyChunks(buildManifest);
755
756 // SSR manifest for the index page would include these files
757 const ssrFiles = [
758 "assets/entry.js",
759 "assets/framework.js",
760 "assets/page-index.js",
761 "assets/mermaid-big.js",
762 ];
763
764 const tags = simulateAssetTagFiltering(ssrFiles, lazyChunks);
765
766 // Entry and framework should have modulepreload + script tags
767 expect(tags).toContain('<link rel="modulepreload" href="/assets/entry.js" />');
768 expect(tags).toContain('<script type="module" src="/assets/entry.js" crossorigin></script>');
769 expect(tags).toContain('<link rel="modulepreload" href="/assets/framework.js" />');
770
771 // Page chunk and mermaid are lazy — should have NO tags at all
772 expect(tags.join("\n")).not.toContain("page-index.js");
773 expect(tags.join("\n")).not.toContain("mermaid-big.js");
774 });
775
776 it("always includes CSS files even for lazy chunks", () => {
777 const buildManifest = {
778 "src/entry.ts": {
779 file: "assets/entry.js",
780 isEntry: true,
781 dynamicImports: ["src/lazy.ts"],
782 },
783 "src/lazy.ts": {
784 file: "assets/lazy.js",
785 isDynamicEntry: true,
786 css: ["assets/lazy.css"],
787 },
788 };
789
790 const lazyChunks = computeLazyChunks(buildManifest);
791 const ssrFiles = ["assets/entry.js", "assets/lazy.js", "assets/lazy.css"];
792 const tags = simulateAssetTagFiltering(ssrFiles, lazyChunks);
793
794 // CSS always included (prevents FOUC)
795 expect(tags).toContain('<link rel="stylesheet" href="/assets/lazy.css" />');
796 // Lazy JS excluded
797 expect(tags.join("\n")).not.toContain("lazy.js");
798 // Entry included
799 expect(tags).toContain('<link rel="modulepreload" href="/assets/entry.js" />');
800 });
801
802 it("includes all chunks when lazy list is empty", () => {
803 const buildManifest = {
804 "src/entry.ts": {
805 file: "assets/entry.js",
806 isEntry: true,
807 imports: ["src/utils.ts"],
808 },
809 "src/utils.ts": {
810 file: "assets/utils.js",
811 },
812 };
813
814 const lazyChunks = computeLazyChunks(buildManifest);
815 expect(lazyChunks).toEqual([]); // nothing is lazy
816
817 const ssrFiles = ["assets/entry.js", "assets/utils.js"];
818 const tags = simulateAssetTagFiltering(ssrFiles, lazyChunks);
819
820 // Both should be present
821 expect(tags).toContain('<link rel="modulepreload" href="/assets/entry.js" />');
822 expect(tags).toContain('<link rel="modulepreload" href="/assets/utils.js" />');
823 expect(tags).toContain('<script type="module" src="/assets/entry.js" crossorigin></script>');
824 expect(tags).toContain('<script type="module" src="/assets/utils.js" crossorigin></script>');
825 });
826
827 it("normalizes leading slashes from SSR manifest values", () => {
828 // Vite's SSR manifest values include a leading "/" (from joinUrlSegments
829 // with base="/"), e.g. "/assets/framework-AbCd.js". Without normalization,
830 // prepending "/" produces protocol-relative URLs "//assets/..." which
831 // browsers interpret as https://assets/... (wrong host).
832 const buildManifest = {
833 "src/entry.ts": {
834 file: "assets/entry.js",
835 isEntry: true,
836 imports: ["node_modules/react/index.js"],
837 dynamicImports: ["src/pages/index.tsx"],
838 },
839 "node_modules/react/index.js": {
840 file: "assets/framework.js",
841 },
842 "src/pages/index.tsx": {
843 file: "assets/page-index.js",
844 isDynamicEntry: true,
845 },
846 };
847
848 const lazyChunks = computeLazyChunks(buildManifest);
849
850 // Simulate SSR manifest values WITH leading slashes (real Vite output)
851 const ssrFilesWithLeadingSlash = [
852 "/assets/entry.js",
853 "/assets/framework.js",
854 "/assets/page-index.js",
855 ];
856
857 const tags = simulateAssetTagFiltering(ssrFilesWithLeadingSlash, lazyChunks);
858
859 // All URLs should have exactly one leading slash, not double
860 for (const tag of tags) {
861 expect(tag).not.toContain('href="//');
862 expect(tag).not.toContain('src="//');
863 }
864
865 // Entry and framework should be present with correct single-slash paths
866 expect(tags).toContain('<link rel="modulepreload" href="/assets/entry.js" />');
867 expect(tags).toContain('<script type="module" src="/assets/entry.js" crossorigin></script>');
868 expect(tags).toContain('<link rel="modulepreload" href="/assets/framework.js" />');
869
870 // Page chunk is lazy — should be excluded even with leading-slash input
871 expect(tags.join("\n")).not.toContain("page-index.js");
872 });
873
874 it("deduplicates entries when SSR manifest has leading slashes and client entry does not", () => {
875 // The client entry (from __VINEXT_CLIENT_ENTRY__) uses values without
876 // leading slashes ("assets/entry.js"), while SSR manifest values have
877 // them ("/assets/entry.js"). After normalization, both should resolve
878 // to the same key and the entry should appear only once.
879 const ssrFiles = [
880 "assets/entry.js", // added first (e.g. from client entry)
881 "/assets/entry.js", // same file from SSR manifest with leading slash
882 "/assets/framework.js",
883 ];
884
885 const tags = simulateAssetTagFiltering(ssrFiles, []);
886
887 // entry.js should appear exactly once in modulepreload tags
888 const entryPreloads = tags.filter((t) => t.includes("entry.js") && t.includes("modulepreload"));
889 expect(entryPreloads).toHaveLength(1);
890
891 // framework.js should also appear with correct path
892 expect(tags).toContain('<link rel="modulepreload" href="/assets/framework.js" />');
893 });
894});
895