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)) {