Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions packages/vite/src/node/__tests__/html.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, OutputChunk> = {}
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<OutputChunk, string[]>()
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<OutputChunk, string[]>()
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<OutputChunk, string[]>()
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<OutputChunk, string[]>()
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<OutputChunk, string[]>()

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<OutputChunk, string[]>()
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<OutputChunk, string[]>()
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<OutputChunk, string[]>()
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<OutputChunk, string[]>()
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<OutputChunk, string[]>()
expect(getCssFilesForChunk(a, bundle, cache)).toStrictEqual([
'b.css',
'a.css',
])
})
})
91 changes: 53 additions & 38 deletions packages/vite/src/node/plugins/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OutputChunk, string[]>,
seenChunks: Set<string> = new Set(),
seenCss: Set<string> = 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
*/
Expand Down Expand Up @@ -820,48 +871,12 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
},
})

const getCssFilesForChunk = (
chunk: OutputChunk,
seenChunks: Set<string> = new Set(),
seenCss: Set<string> = 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)) {
Expand Down
Loading