Skip to content

Commit 2b733f0

Browse files
committed
[Manifest] Drop async chunk CSS from the Vite manifest
Async (non-entry) chunk CSS is loaded at runtime with its lazily-imported chunk and never looked up via asset(), so its manifest entry was only a byproduct. Keeping it diverged from Rsbuild (which omits it) and collided when two chunks shared a name (e.g. a local map_controller controller and the @symfony/ux-leaflet-map one both produced build/map_controller.css, one overwriting the other). bundleToGraph now keeps entry CSS (rendered via entrypoints.json, keyed by a unique entry name) and imported assets, and drops async chunk CSS. Closes #18.
1 parent 5236b3e commit 2b733f0

6 files changed

Lines changed: 97 additions & 28 deletions

File tree

assets/src/collectors/vite.ts

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,40 +10,47 @@ type ViteOutputChunk = Rollup.OutputChunk & { viteMetadata?: ViteChunkMetadata }
1010
export function bundleToGraph(bundle: Rollup.OutputBundle, root: string): NormalizedGraph {
1111
const entryPoints: Record<string, EntryFiles> = {};
1212
const assets: AssetEntry[] = [];
13-
// File names of the CSS emitted for any chunk (entry or lazily-imported). It stays keyed by its
14-
// logical name (e.g. `app.css`, `map_controller.css`), matching Rsbuild's chunk-name keying;
15-
// Vite reports its `originalFileNames` as the importing JS module, so the source-path branch
16-
// below (for imported images/fonts) must not apply to it.
17-
const chunkCss = new Set<string>();
13+
// Entry CSS stays in the manifest, keyed by its logical name (e.g. `app.css`, matching Rsbuild's
14+
// chunk-name keying). Async (non-entry) chunk CSS does not: it loads at runtime with its lazily
15+
// imported chunk, never via `asset()`, so a manifest entry would only be a byproduct that diverges
16+
// from Rsbuild and collides when two chunks share a name. Both kinds report `originalFileNames` as
17+
// the importing JS, so neither must reach the source-path branch (that is for imported images/fonts).
18+
const entryCss = new Set<string>();
19+
const asyncCss = new Set<string>();
1820

1921
for (const file of Object.values(bundle)) {
2022
if (file.type !== 'chunk') continue;
2123
const chunk = file as ViteOutputChunk;
2224
const css = chunk.viteMetadata ? [...chunk.viteMetadata.importedCss] : [];
23-
for (const name of css) chunkCss.add(name);
24-
if (!chunk.isEntry) continue;
25-
entryPoints[chunk.name] = {
26-
js: [chunk.fileName],
27-
css,
28-
preload: [...chunk.imports],
29-
dynamic: [...chunk.dynamicImports],
30-
};
31-
assets.push({ logicalName: `${chunk.name}.js`, fileName: chunk.fileName });
25+
if (chunk.isEntry) {
26+
for (const name of css) entryCss.add(name);
27+
entryPoints[chunk.name] = {
28+
js: [chunk.fileName],
29+
css,
30+
preload: [...chunk.imports],
31+
dynamic: [...chunk.dynamicImports],
32+
};
33+
assets.push({ logicalName: `${chunk.name}.js`, fileName: chunk.fileName });
34+
} else {
35+
for (const name of css) asyncCss.add(name);
36+
}
3237
}
3338

3439
for (const file of Object.values(bundle)) {
3540
if (file.type !== 'asset') continue;
36-
assets.push({ logicalName: assetLogicalName(file, root, chunkCss), fileName: file.fileName });
41+
// Drop async-only chunk CSS (see above); entry CSS (also referenced by an entry) is kept.
42+
if (asyncCss.has(file.fileName) && !entryCss.has(file.fileName)) continue;
43+
assets.push({ logicalName: assetLogicalName(file, root, entryCss), fileName: file.fileName });
3744
}
3845

3946
return { entryPoints, assets };
4047
}
4148

42-
function assetLogicalName(file: Rollup.OutputAsset, root: string, chunkCss: Set<string>): string {
49+
function assetLogicalName(file: Rollup.OutputAsset, root: string, entryCss: Set<string>): string {
4350
// Imported assets (images, fonts) get their source path relative to the project root, so the
4451
// manifest key matches Rsbuild's `sourceFilename` and same-basename files in different folders
45-
// stay distinct. Chunk CSS and assets with no source path fall back to the basename.
46-
const original = chunkCss.has(file.fileName) ? undefined : file.originalFileNames[0];
52+
// stay distinct. Entry CSS and assets with no source path fall back to the basename.
53+
const original = entryCss.has(file.fileName) ? undefined : file.originalFileNames[0];
4754
if (original) return slash(relative(root, resolve(root, original)));
4855
return file.names[0] ?? file.fileName;
4956
}

assets/test/collectors/vite.test.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -109,26 +109,36 @@ describe('bundleToGraph', () => {
109109
expect(graph.assets.some((a) => a.logicalName === 'assets/app.js')).toBe(false);
110110
});
111111

112-
it('keeps async (non-entry) chunk CSS keyed by name, not its importing module path', () => {
112+
it('drops async (non-entry) chunk CSS from the manifest (byproduct; avoids same-name collisions)', () => {
113113
const bundle = {
114114
'app-a1b2.js': chunk({ fileName: 'app-a1b2.js', name: 'app', isEntry: true }),
115-
// A lazily-imported controller: its chunk is not an entry, but it still pulls in CSS whose
116-
// originalFileNames points at the importing JS (here inside node_modules).
117-
'map-x.js': {
118-
...chunk({ fileName: 'map-x.js', name: 'map_controller', isEntry: false }),
119-
viteMetadata: { importedCss: new Set(['map-c.css']), importedAssets: new Set() },
115+
// Two lazily-imported controllers sharing a name (a local one and a package one) both emit
116+
// CSS named `map_controller.css`. Keeping them would collide on a single manifest key; they
117+
// load at runtime with their chunk, never via asset(), so drop them entirely.
118+
'localMap.js': {
119+
...chunk({ fileName: 'localMap.js', name: 'map_controller', isEntry: false }),
120+
viteMetadata: { importedCss: new Set(['localMap.css']), importedAssets: new Set() },
120121
},
121-
'map-c.css': asset(
122-
'map-c.css',
122+
'pkgMap.js': {
123+
...chunk({ fileName: 'pkgMap.js', name: 'map_controller', isEntry: false }),
124+
viteMetadata: { importedCss: new Set(['pkgMap.css']), importedAssets: new Set() },
125+
},
126+
'localMap.css': asset(
127+
'localMap.css',
128+
['map_controller.css'],
129+
['/app/assets/controllers/map_controller.js']
130+
),
131+
'pkgMap.css': asset(
132+
'pkgMap.css',
123133
['map_controller.css'],
124134
['/app/node_modules/@x/ux-map/dist/map_controller.js']
125135
),
126136
} as unknown as Rollup.OutputBundle;
127137

128138
const graph = bundleToGraph(bundle, '/app');
129139

130-
expect(graph.assets).toContainEqual({ logicalName: 'map_controller.css', fileName: 'map-c.css' });
131-
expect(graph.assets.some((a) => a.logicalName.includes('node_modules'))).toBe(false);
140+
expect(graph.assets.some((a) => a.fileName === 'localMap.css' || a.fileName === 'pkgMap.css')).toBe(false);
141+
expect(graph.assets.some((a) => a.logicalName === 'map_controller.css')).toBe(false);
132142
});
133143
});
134144

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import('./widget.js').then((m) => console.log(m.w));
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.widget { color: blue; }
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import './widget.css';
2+
export const w = 1;
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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+
// The entry lazily imports a module that pulls in its own CSS. That CSS ships as an async chunk
11+
// stylesheet, loaded at runtime with the chunk (never via asset()), so it must not appear in
12+
// manifest.json — Rsbuild already omits it, and keeping it in Vite caused a divergence and a
13+
// same-name collision (see the collector unit tests).
14+
const fixture = join(import.meta.dirname, '../fixtures/async-css');
15+
16+
describe('async chunk CSS is kept out of the manifest (Vite/Rsbuild parity)', () => {
17+
it('vite omits the async chunk CSS but still emits the file', async () => {
18+
const out = mkdtempSync(join(tmpdir(), 'ups-async-vite-'));
19+
await build({
20+
root: fixture,
21+
logLevel: 'silent',
22+
build: { emptyOutDir: true, rollupOptions: { input: { app: join(fixture, 'app.js') } } },
23+
plugins: [SymfonyVite({ outputPath: out, publicPath: '/build/' })],
24+
});
25+
26+
const manifest = JSON.parse(readFileSync(join(out, 'manifest.json'), 'utf8'));
27+
expect(Object.keys(manifest).some((k) => k.includes('widget'))).toBe(false);
28+
// The stylesheet is still emitted to disk, it just has no manifest key.
29+
expect(readdirSync(out).some((f) => f.endsWith('.css'))).toBe(true);
30+
}, 30_000);
31+
32+
it('rsbuild also omits the async chunk CSS', async () => {
33+
const out = mkdtempSync(join(tmpdir(), 'ups-async-rsbuild-'));
34+
const rsbuild = await createRsbuild({
35+
cwd: fixture,
36+
rsbuildConfig: {
37+
mode: 'production',
38+
source: { entry: { app: join(fixture, 'app.js') } },
39+
plugins: [SymfonyRsbuild({ outputPath: out, publicPath: '/build/' })],
40+
},
41+
});
42+
await rsbuild.build();
43+
44+
const manifest = JSON.parse(readFileSync(join(out, 'manifest.json'), 'utf8'));
45+
expect(Object.keys(manifest).some((k) => k.includes('widget'))).toBe(false);
46+
expect(existsSync(join(out, 'manifest.json'))).toBe(true);
47+
}, 60_000);
48+
});

0 commit comments

Comments
 (0)