Skip to content

Commit 43fbbf9

Browse files
authored
test(html): add tests for getCssFilesForChunk (#22016)
1 parent 130ef31 commit 43fbbf9

2 files changed

Lines changed: 227 additions & 38 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { describe, expect, test } from 'vitest'
2+
import type { OutputBundle, OutputChunk } from 'rolldown'
3+
import { getCssFilesForChunk } from '../plugins/html'
4+
5+
function createChunk(
6+
fileName: string,
7+
imports: string[],
8+
importedCss: string[],
9+
): OutputChunk {
10+
return {
11+
type: 'chunk',
12+
fileName,
13+
imports,
14+
viteMetadata: { importedCss: new Set(importedCss) },
15+
} as unknown as OutputChunk
16+
}
17+
18+
function createBundle(...chunks: OutputChunk[]): OutputBundle {
19+
const bundle: Record<string, OutputChunk> = {}
20+
for (const chunk of chunks) {
21+
bundle[chunk.fileName] = chunk
22+
}
23+
return bundle as unknown as OutputBundle
24+
}
25+
26+
describe('getCssFilesForChunk', () => {
27+
test('single chunk with own CSS', () => {
28+
const chunk = createChunk('entry.js', [], ['style.css'])
29+
const bundle = createBundle(chunk)
30+
const cache = new Map<OutputChunk, string[]>()
31+
expect(getCssFilesForChunk(chunk, bundle, cache)).toStrictEqual([
32+
'style.css',
33+
])
34+
})
35+
36+
test('chunk with no CSS returns empty array', () => {
37+
const chunk = createChunk('entry.js', [], [])
38+
const bundle = createBundle(chunk)
39+
const cache = new Map<OutputChunk, string[]>()
40+
expect(getCssFilesForChunk(chunk, bundle, cache)).toStrictEqual([])
41+
})
42+
43+
test('imported chunk CSS comes before own CSS', () => {
44+
const dep = createChunk('dep.js', [], ['dep.css'])
45+
const entry = createChunk('entry.js', ['dep.js'], ['entry.css'])
46+
const bundle = createBundle(entry, dep)
47+
const cache = new Map<OutputChunk, string[]>()
48+
expect(getCssFilesForChunk(entry, bundle, cache)).toStrictEqual([
49+
'dep.css',
50+
'entry.css',
51+
])
52+
})
53+
54+
test('deep import chain preserves order', () => {
55+
const c = createChunk('c.js', [], ['c.css'])
56+
const b = createChunk('b.js', ['c.js'], ['b.css'])
57+
const a = createChunk('a.js', ['b.js'], ['a.css'])
58+
const bundle = createBundle(a, b, c)
59+
const cache = new Map<OutputChunk, string[]>()
60+
expect(getCssFilesForChunk(a, bundle, cache)).toStrictEqual([
61+
'c.css',
62+
'b.css',
63+
'a.css',
64+
])
65+
})
66+
67+
test('cache is populated and used on second call', () => {
68+
const dep = createChunk('dep.js', [], ['dep.css'])
69+
const entry = createChunk('entry.js', ['dep.js'], ['entry.css'])
70+
const bundle = createBundle(entry, dep)
71+
const cache = new Map<OutputChunk, string[]>()
72+
73+
const result = getCssFilesForChunk(entry, bundle, cache)
74+
expect(result).toStrictEqual(['dep.css', 'entry.css'])
75+
expect(cache.has(dep)).toBe(true)
76+
expect(cache.has(entry)).toBe(true)
77+
78+
expect(getCssFilesForChunk(entry, bundle, cache)).toStrictEqual(result)
79+
})
80+
81+
test('shared dependency CSS is output for each entry point', () => {
82+
const shared = createChunk('shared.js', [], ['shared.css'])
83+
const entryA = createChunk('a.js', ['shared.js'], ['a.css'])
84+
const entryB = createChunk('b.js', ['shared.js'], ['b.css'])
85+
const bundle = createBundle(entryA, entryB, shared)
86+
const cache = new Map<OutputChunk, string[]>()
87+
expect(getCssFilesForChunk(entryA, bundle, cache)).toStrictEqual([
88+
'shared.css',
89+
'a.css',
90+
])
91+
expect(getCssFilesForChunk(entryB, bundle, cache)).toStrictEqual([
92+
'shared.css',
93+
'b.css',
94+
])
95+
})
96+
97+
test('diamond dependency deduplicates CSS and preserves order', () => {
98+
// A
99+
// / \
100+
// B C
101+
// \ /
102+
// D
103+
const d = createChunk('d.js', [], ['d.css'])
104+
const b = createChunk('b.js', ['d.js'], ['b.css'])
105+
const c = createChunk('c.js', ['d.js'], ['c.css'])
106+
const a = createChunk('a.js', ['b.js', 'c.js'], ['a.css'])
107+
const bundle = createBundle(a, b, c, d)
108+
const cache = new Map<OutputChunk, string[]>()
109+
expect(getCssFilesForChunk(a, bundle, cache)).toStrictEqual([
110+
'd.css',
111+
'b.css',
112+
'c.css',
113+
'a.css',
114+
])
115+
})
116+
117+
test('multiple shared dependencies with different CSS', () => {
118+
const shared1 = createChunk('shared1.js', [], ['shared1.css'])
119+
const shared2 = createChunk('shared2.js', [], ['shared2.css'])
120+
const entryA = createChunk('a.js', ['shared1.js', 'shared2.js'], ['a.css'])
121+
const entryB = createChunk('b.js', ['shared2.js', 'shared1.js'], ['b.css'])
122+
const bundle = createBundle(entryA, entryB, shared1, shared2)
123+
const cache = new Map<OutputChunk, string[]>()
124+
expect(getCssFilesForChunk(entryA, bundle, cache)).toStrictEqual([
125+
'shared1.css',
126+
'shared2.css',
127+
'a.css',
128+
])
129+
expect(getCssFilesForChunk(entryB, bundle, cache)).toStrictEqual([
130+
'shared2.css',
131+
'shared1.css',
132+
'b.css',
133+
])
134+
})
135+
136+
test('cache from one entry does not corrupt results for another with overlapping subgraph', () => {
137+
// entryA entryB
138+
// / \ |
139+
// mid1 mid2 mid2
140+
// | | |
141+
// leaf shared shared
142+
const shared = createChunk('shared.js', [], ['shared.css'])
143+
const leaf = createChunk('leaf.js', [], ['leaf.css'])
144+
const mid1 = createChunk('mid1.js', ['leaf.js'], ['mid1.css'])
145+
const mid2 = createChunk('mid2.js', ['shared.js'], ['mid2.css'])
146+
const entryA = createChunk('a.js', ['mid1.js', 'mid2.js'], ['a.css'])
147+
const entryB = createChunk('b.js', ['mid2.js'], ['b.css'])
148+
const bundle = createBundle(entryA, entryB, mid1, mid2, leaf, shared)
149+
const cache = new Map<OutputChunk, string[]>()
150+
expect(getCssFilesForChunk(entryA, bundle, cache)).toStrictEqual([
151+
'leaf.css',
152+
'mid1.css',
153+
'shared.css',
154+
'mid2.css',
155+
'a.css',
156+
])
157+
expect(getCssFilesForChunk(entryB, bundle, cache)).toStrictEqual([
158+
'shared.css',
159+
'mid2.css',
160+
'b.css',
161+
])
162+
})
163+
164+
test('circular imports do not cause infinite loop', () => {
165+
const a = createChunk('a.js', ['b.js'], ['a.css'])
166+
const b = createChunk('b.js', ['a.js'], ['b.css'])
167+
const bundle = createBundle(a, b)
168+
const cache = new Map<OutputChunk, string[]>()
169+
expect(getCssFilesForChunk(a, bundle, cache)).toStrictEqual([
170+
'b.css',
171+
'a.css',
172+
])
173+
})
174+
})

packages/vite/src/node/plugins/html.ts

Lines changed: 53 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,57 @@ function handleParseError(
346346
parseError.frame
347347
}
348348

349+
/**
350+
* Collects CSS files for a chunk by traversing its imports depth-first,
351+
* using a cache to avoid re-analyzing chunks while still returning the
352+
* correct files when the same chunk is reached via different entry points.
353+
*/
354+
export function getCssFilesForChunk(
355+
chunk: OutputChunk,
356+
bundle: OutputBundle,
357+
analyzedImportedCssFiles: Map<OutputChunk, string[]>,
358+
seenChunks: Set<string> = new Set(),
359+
seenCss: Set<string> = new Set(),
360+
): string[] {
361+
if (seenChunks.has(chunk.fileName)) {
362+
return []
363+
}
364+
seenChunks.add(chunk.fileName)
365+
366+
if (analyzedImportedCssFiles.has(chunk)) {
367+
const files = analyzedImportedCssFiles.get(chunk)!
368+
const additionals = files.filter((file) => !seenCss.has(file))
369+
additionals.forEach((file) => seenCss.add(file))
370+
return additionals
371+
}
372+
373+
const files: string[] = []
374+
chunk.imports.forEach((file) => {
375+
const importee = bundle[file]
376+
if (importee?.type === 'chunk') {
377+
files.push(
378+
...getCssFilesForChunk(
379+
importee,
380+
bundle,
381+
analyzedImportedCssFiles,
382+
seenChunks,
383+
seenCss,
384+
),
385+
)
386+
}
387+
})
388+
analyzedImportedCssFiles.set(chunk, files)
389+
390+
chunk.viteMetadata!.importedCss.forEach((file) => {
391+
if (!seenCss.has(file)) {
392+
seenCss.add(file)
393+
files.push(file)
394+
}
395+
})
396+
397+
return files
398+
}
399+
349400
/**
350401
* Compiles index.html into an entry js module
351402
*/
@@ -820,48 +871,12 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
820871
},
821872
})
822873

823-
const getCssFilesForChunk = (
824-
chunk: OutputChunk,
825-
seenChunks: Set<string> = new Set(),
826-
seenCss: Set<string> = new Set(),
827-
): string[] => {
828-
if (seenChunks.has(chunk.fileName)) {
829-
return []
830-
}
831-
seenChunks.add(chunk.fileName)
832-
833-
if (analyzedImportedCssFiles.has(chunk)) {
834-
const files = analyzedImportedCssFiles.get(chunk)!
835-
const additionals = files.filter((file) => !seenCss.has(file))
836-
additionals.forEach((file) => seenCss.add(file))
837-
return additionals
838-
}
839-
840-
const files: string[] = []
841-
chunk.imports.forEach((file) => {
842-
const importee = bundle[file]
843-
if (importee?.type === 'chunk') {
844-
files.push(...getCssFilesForChunk(importee, seenChunks, seenCss))
845-
}
846-
})
847-
analyzedImportedCssFiles.set(chunk, files)
848-
849-
chunk.viteMetadata!.importedCss.forEach((file) => {
850-
if (!seenCss.has(file)) {
851-
seenCss.add(file)
852-
files.push(file)
853-
}
854-
})
855-
856-
return files
857-
}
858-
859874
const getCssTagsForChunk = (
860875
chunk: OutputChunk,
861876
toOutputPath: (filename: string) => string,
862877
) =>
863-
getCssFilesForChunk(chunk).map((file) =>
864-
toStyleSheetLinkTag(file, toOutputPath),
878+
getCssFilesForChunk(chunk, bundle, analyzedImportedCssFiles).map(
879+
(file) => toStyleSheetLinkTag(file, toOutputPath),
865880
)
866881

867882
for (const [normalizedId, html] of processedHtml(this)) {

0 commit comments

Comments
 (0)