diff --git a/packages/vite/src/node/__tests__/html.spec.ts b/packages/vite/src/node/__tests__/html.spec.ts
new file mode 100644
index 00000000000000..30df8e18bba545
--- /dev/null
+++ b/packages/vite/src/node/__tests__/html.spec.ts
@@ -0,0 +1,174 @@
+import { describe, expect, test } from 'vitest'
+import type { OutputBundle, OutputChunk } from 'rolldown'
+import { getCssFilesForChunk } from '../plugins/html'
+
+function createChunk(
+ fileName: string,
+ imports: string[],
+ importedCss: string[],
+): OutputChunk {
+ return {
+ type: 'chunk',
+ fileName,
+ imports,
+ viteMetadata: { importedCss: new Set(importedCss) },
+ } as unknown as OutputChunk
+}
+
+function createBundle(...chunks: OutputChunk[]): OutputBundle {
+ const bundle: Record = {}
+ for (const chunk of chunks) {
+ bundle[chunk.fileName] = chunk
+ }
+ return bundle as unknown as OutputBundle
+}
+
+describe('getCssFilesForChunk', () => {
+ test('single chunk with own CSS', () => {
+ const chunk = createChunk('entry.js', [], ['style.css'])
+ const bundle = createBundle(chunk)
+ const cache = new Map()
+ expect(getCssFilesForChunk(chunk, bundle, cache)).toStrictEqual([
+ 'style.css',
+ ])
+ })
+
+ test('chunk with no CSS returns empty array', () => {
+ const chunk = createChunk('entry.js', [], [])
+ const bundle = createBundle(chunk)
+ const cache = new Map()
+ expect(getCssFilesForChunk(chunk, bundle, cache)).toStrictEqual([])
+ })
+
+ test('imported chunk CSS comes before own CSS', () => {
+ const dep = createChunk('dep.js', [], ['dep.css'])
+ const entry = createChunk('entry.js', ['dep.js'], ['entry.css'])
+ const bundle = createBundle(entry, dep)
+ const cache = new Map()
+ expect(getCssFilesForChunk(entry, bundle, cache)).toStrictEqual([
+ 'dep.css',
+ 'entry.css',
+ ])
+ })
+
+ test('deep import chain preserves order', () => {
+ const c = createChunk('c.js', [], ['c.css'])
+ const b = createChunk('b.js', ['c.js'], ['b.css'])
+ const a = createChunk('a.js', ['b.js'], ['a.css'])
+ const bundle = createBundle(a, b, c)
+ const cache = new Map()
+ expect(getCssFilesForChunk(a, bundle, cache)).toStrictEqual([
+ 'c.css',
+ 'b.css',
+ 'a.css',
+ ])
+ })
+
+ test('cache is populated and used on second call', () => {
+ const dep = createChunk('dep.js', [], ['dep.css'])
+ const entry = createChunk('entry.js', ['dep.js'], ['entry.css'])
+ const bundle = createBundle(entry, dep)
+ const cache = new Map()
+
+ const result = getCssFilesForChunk(entry, bundle, cache)
+ expect(result).toStrictEqual(['dep.css', 'entry.css'])
+ expect(cache.has(dep)).toBe(true)
+ expect(cache.has(entry)).toBe(true)
+
+ expect(getCssFilesForChunk(entry, bundle, cache)).toStrictEqual(result)
+ })
+
+ test('shared dependency CSS is output for each entry point', () => {
+ const shared = createChunk('shared.js', [], ['shared.css'])
+ const entryA = createChunk('a.js', ['shared.js'], ['a.css'])
+ const entryB = createChunk('b.js', ['shared.js'], ['b.css'])
+ const bundle = createBundle(entryA, entryB, shared)
+ const cache = new Map()
+ expect(getCssFilesForChunk(entryA, bundle, cache)).toStrictEqual([
+ 'shared.css',
+ 'a.css',
+ ])
+ expect(getCssFilesForChunk(entryB, bundle, cache)).toStrictEqual([
+ 'shared.css',
+ 'b.css',
+ ])
+ })
+
+ test('diamond dependency deduplicates CSS and preserves order', () => {
+ // A
+ // / \
+ // B C
+ // \ /
+ // D
+ const d = createChunk('d.js', [], ['d.css'])
+ const b = createChunk('b.js', ['d.js'], ['b.css'])
+ const c = createChunk('c.js', ['d.js'], ['c.css'])
+ const a = createChunk('a.js', ['b.js', 'c.js'], ['a.css'])
+ const bundle = createBundle(a, b, c, d)
+ const cache = new Map()
+ expect(getCssFilesForChunk(a, bundle, cache)).toStrictEqual([
+ 'd.css',
+ 'b.css',
+ 'c.css',
+ 'a.css',
+ ])
+ })
+
+ test('multiple shared dependencies with different CSS', () => {
+ const shared1 = createChunk('shared1.js', [], ['shared1.css'])
+ const shared2 = createChunk('shared2.js', [], ['shared2.css'])
+ const entryA = createChunk('a.js', ['shared1.js', 'shared2.js'], ['a.css'])
+ const entryB = createChunk('b.js', ['shared2.js', 'shared1.js'], ['b.css'])
+ const bundle = createBundle(entryA, entryB, shared1, shared2)
+ const cache = new Map()
+ expect(getCssFilesForChunk(entryA, bundle, cache)).toStrictEqual([
+ 'shared1.css',
+ 'shared2.css',
+ 'a.css',
+ ])
+ expect(getCssFilesForChunk(entryB, bundle, cache)).toStrictEqual([
+ 'shared2.css',
+ 'shared1.css',
+ 'b.css',
+ ])
+ })
+
+ test('cache from one entry does not corrupt results for another with overlapping subgraph', () => {
+ // entryA entryB
+ // / \ |
+ // mid1 mid2 mid2
+ // | | |
+ // leaf shared shared
+ const shared = createChunk('shared.js', [], ['shared.css'])
+ const leaf = createChunk('leaf.js', [], ['leaf.css'])
+ const mid1 = createChunk('mid1.js', ['leaf.js'], ['mid1.css'])
+ const mid2 = createChunk('mid2.js', ['shared.js'], ['mid2.css'])
+ const entryA = createChunk('a.js', ['mid1.js', 'mid2.js'], ['a.css'])
+ const entryB = createChunk('b.js', ['mid2.js'], ['b.css'])
+ const bundle = createBundle(entryA, entryB, mid1, mid2, leaf, shared)
+ const cache = new Map()
+ expect(getCssFilesForChunk(entryA, bundle, cache)).toStrictEqual([
+ 'leaf.css',
+ 'mid1.css',
+ 'shared.css',
+ 'mid2.css',
+ 'a.css',
+ ])
+ expect(getCssFilesForChunk(entryB, bundle, cache)).toStrictEqual([
+ 'shared.css',
+ 'mid2.css',
+ 'b.css',
+ ])
+ })
+
+ test('circular imports do not cause infinite loop', () => {
+ const a = createChunk('a.js', ['b.js'], ['a.css'])
+ const b = createChunk('b.js', ['a.js'], ['b.css'])
+ const bundle = createBundle(a, b)
+ const cache = new Map()
+ expect(getCssFilesForChunk(a, bundle, cache)).toStrictEqual([
+ 'b.css',
+ 'a.css',
+ ])
+ })
+})
diff --git a/packages/vite/src/node/plugins/html.ts b/packages/vite/src/node/plugins/html.ts
index 02305b45387c38..8adab33e6f9874 100644
--- a/packages/vite/src/node/plugins/html.ts
+++ b/packages/vite/src/node/plugins/html.ts
@@ -346,6 +346,57 @@ function handleParseError(
parseError.frame
}
+/**
+ * Collects CSS files for a chunk by traversing its imports depth-first,
+ * using a cache to avoid re-analyzing chunks while still returning the
+ * correct files when the same chunk is reached via different entry points.
+ */
+export function getCssFilesForChunk(
+ chunk: OutputChunk,
+ bundle: OutputBundle,
+ analyzedImportedCssFiles: Map,
+ seenChunks: Set = new Set(),
+ seenCss: Set = new Set(),
+): string[] {
+ if (seenChunks.has(chunk.fileName)) {
+ return []
+ }
+ seenChunks.add(chunk.fileName)
+
+ if (analyzedImportedCssFiles.has(chunk)) {
+ const files = analyzedImportedCssFiles.get(chunk)!
+ const additionals = files.filter((file) => !seenCss.has(file))
+ additionals.forEach((file) => seenCss.add(file))
+ return additionals
+ }
+
+ const files: string[] = []
+ chunk.imports.forEach((file) => {
+ const importee = bundle[file]
+ if (importee?.type === 'chunk') {
+ files.push(
+ ...getCssFilesForChunk(
+ importee,
+ bundle,
+ analyzedImportedCssFiles,
+ seenChunks,
+ seenCss,
+ ),
+ )
+ }
+ })
+ analyzedImportedCssFiles.set(chunk, files)
+
+ chunk.viteMetadata!.importedCss.forEach((file) => {
+ if (!seenCss.has(file)) {
+ seenCss.add(file)
+ files.push(file)
+ }
+ })
+
+ return files
+}
+
/**
* Compiles index.html into an entry js module
*/
@@ -820,48 +871,12 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
},
})
- const getCssFilesForChunk = (
- chunk: OutputChunk,
- seenChunks: Set = new Set(),
- seenCss: Set = new Set(),
- ): string[] => {
- if (seenChunks.has(chunk.fileName)) {
- return []
- }
- seenChunks.add(chunk.fileName)
-
- if (analyzedImportedCssFiles.has(chunk)) {
- const files = analyzedImportedCssFiles.get(chunk)!
- const additionals = files.filter((file) => !seenCss.has(file))
- additionals.forEach((file) => seenCss.add(file))
- return additionals
- }
-
- const files: string[] = []
- chunk.imports.forEach((file) => {
- const importee = bundle[file]
- if (importee?.type === 'chunk') {
- files.push(...getCssFilesForChunk(importee, seenChunks, seenCss))
- }
- })
- analyzedImportedCssFiles.set(chunk, files)
-
- chunk.viteMetadata!.importedCss.forEach((file) => {
- if (!seenCss.has(file)) {
- seenCss.add(file)
- files.push(file)
- }
- })
-
- return files
- }
-
const getCssTagsForChunk = (
chunk: OutputChunk,
toOutputPath: (filename: string) => string,
) =>
- getCssFilesForChunk(chunk).map((file) =>
- toStyleSheetLinkTag(file, toOutputPath),
+ getCssFilesForChunk(chunk, bundle, analyzedImportedCssFiles).map(
+ (file) => toStyleSheetLinkTag(file, toOutputPath),
)
for (const [normalizedId, html] of processedHtml(this)) {