Skip to content

Commit a702f33

Browse files
committed
bug #67 [Vite] Skip pruned CSS-only chunks in preload and dynamic (Kocal)
This PR was merged into the main branch. Discussion ---------- [Vite] Skip pruned CSS-only chunks in preload and dynamic | Q | A | -------------- | --- | Bug fix? | yes | New feature? | no | Deprecations? | no | Documentation? | no | Issues | - | License | MIT A bare CSS dynamic import (`import('some.css')`, e.g. a lazy Stimulus controller with an `autoimport` stylesheet) makes rolldown-vite emit a CSS-only chunk: its JS is emptied and never written to disk, but the name still shows up in the entry's `imports`/`dynamicImports`. The Vite collector forwarded those names into `entrypoints.json`, so `dynamic`/`preload` pointed at a `.js` that does not exist and, with SRI enabled, the build crashed reading it. `emittedChunks()` now filters those lists down to names backed by a real, non-empty chunk. Rspack is unaffected. Tests: a Vite integration repro (with SRI) mirrored on Rsbuild, plus a collector unit test. Commits ------- 9bf1deb [Vite] Skip pruned CSS-only chunks in preload and dynamic
2 parents d5b1bdd + 9bf1deb commit a702f33

5 files changed

Lines changed: 108 additions & 2 deletions

File tree

assets/src/collectors/vite.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ export function bundleToGraph(bundle: Rollup.OutputBundle, root: string): Normal
2828
entryPoints[chunk.name] = {
2929
js: [chunk.fileName],
3030
css,
31-
preload: [...chunk.imports],
32-
dynamic: [...chunk.dynamicImports],
31+
preload: emittedChunks(chunk.imports, bundle),
32+
dynamic: emittedChunks(chunk.dynamicImports, bundle),
3333
};
3434
assets.push({ logicalName: `${chunk.name}.js`, fileName: chunk.fileName });
3535
} else if (chunk.viteMetadata) {
@@ -47,6 +47,17 @@ export function bundleToGraph(bundle: Rollup.OutputBundle, root: string): Normal
4747
return { entryPoints, assets };
4848
}
4949

50+
// A bare `import('x.css')` yields a CSS-only proxy chunk whose JS is empty or whitespace (`''` under
51+
// rolldown-vite, `'\n'` under Rollup/Vite 7) and is never written to disk, yet its name still shows up in
52+
// the entry's imports/dynamicImports. Keep only names backed by a chunk that has real JS, so preload and
53+
// SRI never reference a file that was never written.
54+
function emittedChunks(names: readonly string[], bundle: Rollup.OutputBundle): string[] {
55+
return names.filter((name) => {
56+
const output = bundle[name];
57+
return output?.type === 'chunk' && output.code.trim() !== '';
58+
});
59+
}
60+
5061
// Collect an entry's CSS: its own `importedCss` plus that of every statically-imported chunk, reached
5162
// transitively. Static imports only — dynamic-import CSS loads with its chunk and stays out of the entry.
5263
function collectEntryCss(entry: ViteOutputChunk, bundle: Rollup.OutputBundle): string[] {

assets/test/collectors/vite.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ function chunk(partial: Partial<Rollup.OutputChunk> & { fileName: string; name:
77
type: 'chunk',
88
imports: [],
99
dynamicImports: [],
10+
code: 'export {};',
1011
...partial,
1112
};
1213
}
@@ -30,6 +31,7 @@ describe('bundleToGraph', () => {
3031
},
3132
'admin-99.js': chunk({ fileName: 'admin-99.js', name: 'admin', isEntry: true }),
3233
'vendor-e5.js': chunk({ fileName: 'vendor-e5.js', name: 'vendor', isEntry: false }),
34+
'lazy-x.js': chunk({ fileName: 'lazy-x.js', name: 'lazy-x', isEntry: false }),
3335
'app-c3.css': asset('app-c3.css', ['app.css']),
3436
} as unknown as Rollup.OutputBundle;
3537

@@ -45,6 +47,27 @@ describe('bundleToGraph', () => {
4547
expect(graph.entryPoints.vendor).toBeUndefined();
4648
});
4749

50+
it('drops a CSS-only dynamic import whose chunk was pruned to empty JS', () => {
51+
// `import('x.css')` yields a chunk rolldown-vite empties (code === '') and never writes; its name
52+
// still shows in dynamicImports. It must be dropped, and its async CSS kept out of the manifest.
53+
const bundle = {
54+
'app.js': {
55+
...chunk({ fileName: 'app.js', name: 'app', isEntry: true, dynamicImports: ['lazy-css.js'] }),
56+
viteMetadata: { importedCss: new Set<string>(), importedAssets: new Set() },
57+
},
58+
'lazy-css.js': {
59+
...chunk({ fileName: 'lazy-css.js', name: 'lazy-css', isEntry: false, code: '' }),
60+
viteMetadata: { importedCss: new Set(['lazy-css.css']), importedAssets: new Set() },
61+
},
62+
'lazy-css.css': asset('lazy-css.css', ['lazy-css.css']),
63+
} as unknown as Rollup.OutputBundle;
64+
65+
const graph = bundleToGraph(bundle, '/app');
66+
67+
expect(graph.entryPoints.app.dynamic).toEqual([]);
68+
expect(graph.assets.some((a) => a.fileName === 'lazy-css.css')).toBe(false);
69+
});
70+
4871
it('collects entry CSS from a facade chunk that only re-imports the real chunk', () => {
4972
// Rollup emits a thin *facade* entry (e.g. when the entry uses top-level await) that just re-imports
5073
// the real chunk; the CSS then rides on that statically-imported chunk, not the facade itself.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// A CSS-only dynamic import: rolldown-vite prunes the async JS chunk but still lists its name in
2+
// dynamicImports. Mirrors a lazy UX Stimulus controller whose autoimport is a stylesheet.
3+
window.addEventListener('DOMContentLoaded', () => {
4+
import('./lazy.css');
5+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.lazy {
2+
color: rebeccapurple;
3+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { existsSync, mkdtempSync, readdirSync, readFileSync } from 'node:fs';
2+
import { tmpdir } from 'node:os';
3+
import { join } from 'node:path';
4+
import { createRsbuild } from '@rsbuild/core';
5+
import { build } from 'vite';
6+
import { describe, expect, it } from 'vitest';
7+
import SymfonyRsbuild from '../../src/rsbuild';
8+
import SymfonyVite from '../../src/vite';
9+
10+
// A CSS-only dynamic import (`import('x.css')`) makes rolldown-vite prune the async JS chunk while still
11+
// listing its name in dynamicImports. The collector must not carry that phantom into `dynamic`, or SRI
12+
// (integrityFromDisk) crashes reading a file that was never written. Common in the wild: a lazy UX
13+
// Stimulus controller whose autoimport is a stylesheet.
14+
const fixture = join(import.meta.dirname, '../fixtures/dynamic-css');
15+
16+
function referenced(app: { js: string[]; css: string[]; preload: string[]; dynamic: string[] }): string[] {
17+
return [...app.js, ...app.css, ...app.preload, ...app.dynamic];
18+
}
19+
20+
describe('CSS-only dynamic import leaves no phantom in `dynamic` (Vite/Rsbuild parity)', () => {
21+
it('vite: integrity build succeeds and every referenced file exists on disk', async () => {
22+
const out = mkdtempSync(join(tmpdir(), 'ups-dyncss-vite-'));
23+
await build({
24+
root: fixture,
25+
logLevel: 'silent',
26+
build: { emptyOutDir: true, rollupOptions: { input: { app: join(fixture, 'app.js') } } },
27+
plugins: [SymfonyVite({ outputPath: out, publicPath: '/build/', integrity: { enabled: true } })],
28+
});
29+
30+
const entry = JSON.parse(readFileSync(join(out, 'entrypoints.json'), 'utf8'));
31+
for (const ref of referenced(entry.entryPoints.app)) {
32+
expect(existsSync(join(out, ref.replace(/^build\//, '')))).toBe(true);
33+
}
34+
for (const ref of Object.keys(entry.integrity ?? {})) {
35+
expect(existsSync(join(out, ref.replace(/^build\//, '')))).toBe(true);
36+
}
37+
// the stylesheet is still emitted, it just carries no phantom JS reference
38+
expect(readdirSync(out).some((f) => f.endsWith('.css'))).toBe(true);
39+
}, 30_000);
40+
41+
it('rsbuild: integrity build succeeds and every referenced file exists on disk', async () => {
42+
const out = mkdtempSync(join(tmpdir(), 'ups-dyncss-rsbuild-'));
43+
const rsbuild = await createRsbuild({
44+
cwd: fixture,
45+
rsbuildConfig: {
46+
mode: 'production',
47+
source: { entry: { app: join(fixture, 'app.js') } },
48+
plugins: [
49+
SymfonyRsbuild({
50+
outputPath: out,
51+
publicPath: '/build/',
52+
integrity: { enabled: true, algorithms: ['sha384'] },
53+
}),
54+
],
55+
},
56+
});
57+
await rsbuild.build();
58+
59+
const entry = JSON.parse(readFileSync(join(out, 'entrypoints.json'), 'utf8'));
60+
for (const ref of referenced(entry.entryPoints.app)) {
61+
expect(existsSync(join(out, ref.replace(/^build\//, '')))).toBe(true);
62+
}
63+
}, 60_000);
64+
});

0 commit comments

Comments
 (0)