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
139 changes: 46 additions & 93 deletions packages/vite/src/node/plugins/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
OutputAsset,
OutputChunk,
RenderedChunk,
RenderedModule,
RollupError,
SourceMapInput,
} from 'rollup'
Expand Down Expand Up @@ -452,69 +453,12 @@ export function cssPlugin(config: ResolvedConfig): Plugin {
return plugin
}

const createStyleContentMap = () => {
const contents = new Map<string, string>() // css id -> css content
const scopedIds = new Set<string>() // ids of css that are scoped
const relations = new Map<
/* the id of the target for which css is scoped to */ string,
Array<{
/** css id */ id: string
/** export name */ exp: string | undefined
}>
>()

return {
putContent(
id: string,
content: string,
scopeTo: CustomPluginOptionsVite['cssScopeTo'] | undefined,
) {
contents.set(id, content)
if (scopeTo) {
const [scopedId, exp] = scopeTo
if (!relations.has(scopedId)) {
relations.set(scopedId, [])
}
relations.get(scopedId)!.push({ id, exp })
scopedIds.add(id)
}
},
hasContentOfNonScoped(id: string) {
return !scopedIds.has(id) && contents.has(id)
},
getContentOfNonScoped(id: string) {
if (scopedIds.has(id)) return
return contents.get(id)
},
hasContentsScopedTo(id: string) {
return (relations.get(id) ?? [])?.length > 0
},
getContentsScopedTo(id: string, importedIds: readonly string[]) {
const values = (relations.get(id) ?? []).map(
({ id, exp }) =>
[
id,
{
content: contents.get(id) ?? '',
exp,
},
] as const,
)
const styleIdToValue = new Map(values)
// get a sorted output by import order to make output deterministic
return importedIds
.filter((id) => styleIdToValue.has(id))
.map((id) => styleIdToValue.get(id)!)
},
}
}

/**
* Plugin applied after user plugins
*/
export function cssPostPlugin(config: ResolvedConfig): Plugin {
// styles initialization in buildStart causes a styling loss in watch
const styles = createStyleContentMap()
const styles = new Map<string, string>()
// queue to emit css serially to guarantee the files are emitted in a deterministic order
let codeSplitEmitQueue = createSerialPromiseQueue<string>()
const urlEmitQueue = createSerialPromiseQueue<unknown>()
Expand Down Expand Up @@ -663,19 +607,9 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {

// build CSS handling ----------------------------------------------------

const cssScopeTo =
// NOTE: `this.getModuleInfo` can be undefined when the plugin is called directly
// adding `?.` temporary to avoid unocss from breaking
// TODO: remove `?.` after `this.getModuleInfo` in Vite 7
(
this.getModuleInfo?.(id)?.meta?.vite as
| CustomPluginOptionsVite
| undefined
)?.cssScopeTo

// record css
if (!inlined) {
styles.putContent(id, css, cssScopeTo)
styles.set(id, css)
}

let code: string
Expand All @@ -697,41 +631,49 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
map: { mappings: '' },
// avoid the css module from being tree-shaken so that we can retrieve
// it in renderChunk()
moduleSideEffects:
modulesCode || inlined || cssScopeTo ? false : 'no-treeshake',
moduleSideEffects: modulesCode || inlined ? false : 'no-treeshake',
}
},
},

async renderChunk(code, chunk, opts) {
async renderChunk(code, chunk, opts, meta) {
let chunkCSS = ''
const renderedModules = Object.fromEntries(
Object.values(meta.chunks).flatMap((chunk) =>
Object.entries(chunk.modules),
),
)
// the chunk is empty if it's a dynamic entry chunk that only contains a CSS import
const isJsChunkEmpty = code === '' && !chunk.isEntry
let isPureCssChunk = chunk.exports.length === 0
const ids = Object.keys(chunk.modules)
for (const id of ids) {
if (styles.hasContentOfNonScoped(id)) {
if (styles.has(id)) {
// ?transform-only is used for ?url and shouldn't be included in normal CSS chunks
if (!transformOnlyRE.test(id)) {
chunkCSS += styles.getContentOfNonScoped(id)
// a css module contains JS, so it makes this not a pure css chunk
if (cssModuleRE.test(id)) {
isPureCssChunk = false
}
if (transformOnlyRE.test(id)) {
continue
}
} else if (styles.hasContentsScopedTo(id)) {
const renderedExports = chunk.modules[id]!.renderedExports
const importedIds = this.getModuleInfo(id)?.importedIds ?? []
// If this module has scoped styles, check for the rendered exports
// and include the corresponding CSS.
for (const { exp, content } of styles.getContentsScopedTo(
id,
importedIds,
)) {
if (exp === undefined || renderedExports.includes(exp)) {
chunkCSS += content
}

// If this CSS is scoped to its importers exports, check if those importers exports
// are rendered in the chunks. If they are not, we can skip bundling this CSS.
const cssScopeTo = (
this.getModuleInfo(id)?.meta?.vite as
| CustomPluginOptionsVite
| undefined
)?.cssScopeTo
if (
cssScopeTo &&
!isCssScopeToRendered(cssScopeTo, renderedModules)
) {
continue
}

// a css module contains JS, so it makes this not a pure css chunk
if (cssModuleRE.test(id)) {
isPureCssChunk = false
}

chunkCSS += styles.get(id)
} else if (!isJsChunkEmpty) {
// if the module does not have a style, then it's not a pure css chunk.
// this is true because in the `transform` hook above, only modules
Expand Down Expand Up @@ -826,13 +768,13 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
path.basename(originalFileName),
'.css',
)
if (!styles.hasContentOfNonScoped(id)) {
if (!styles.has(id)) {
throw new Error(
`css content for ${JSON.stringify(id)} was not found`,
)
}

let cssContent = styles.getContentOfNonScoped(id)!
let cssContent = styles.get(id)!

cssContent = resolveAssetUrlsInCss(cssContent, cssAssetName)

Expand Down Expand Up @@ -1201,6 +1143,17 @@ export function cssAnalysisPlugin(config: ResolvedConfig): Plugin {
}
}

function isCssScopeToRendered(
cssScopeTo: Exclude<CustomPluginOptionsVite['cssScopeTo'], undefined>,
renderedModules: Record<string, RenderedModule | undefined>,
) {
const [importerId, exp] = cssScopeTo
const importer = renderedModules[importerId]
return (
importer && (exp === undefined || importer.renderedExports.includes(exp))
)
}

/**
* Create a replacer function that takes code and replaces given pure CSS chunk imports
* @param pureCssChunkNames The chunks that only contain pure CSS and should be replaced
Expand Down
19 changes: 6 additions & 13 deletions playground/css/__tests__/css.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
editFile,
findAssetFile,
getBg,
getBgColor,
getColor,
isBuild,
page,
Expand Down Expand Up @@ -506,16 +507,8 @@ test.runIf(isBuild)('Scoped CSS via cssScopeTo should be treeshaken', () => {
expect(css).not.toMatch(/\btreeshake-scoped-c\b/)
})

test.runIf(isBuild)(
'Scoped CSS via cssScopeTo should be bundled separately',
() => {
const scopedIndexCss = findAssetFile(/treeshakeScoped-[-\w]{8}\.css$/)
expect(scopedIndexCss).toContain('treeshake-scoped-barrel-a')
expect(scopedIndexCss).not.toContain('treeshake-scoped-barrel-b')
const scopedAnotherCss = findAssetFile(
/treeshakeScopedAnother-[-\w]{8}\.css$/,
)
expect(scopedAnotherCss).toContain('treeshake-scoped-barrel-b')
expect(scopedAnotherCss).not.toContain('treeshake-scoped-barrel-a')
},
)
test('Scoped CSS should have a correct order', async () => {
await page.goto(viteTestUrl + '/treeshake-scoped/')
expect(await getColor('.treeshake-scoped-order')).toBe('red')
expect(await getBgColor('.treeshake-scoped-order')).toBe('blue')
})
7 changes: 0 additions & 7 deletions playground/css/treeshake-scoped/another.html

This file was deleted.

4 changes: 0 additions & 4 deletions playground/css/treeshake-scoped/barrel/a-scoped.css

This file was deleted.

5 changes: 0 additions & 5 deletions playground/css/treeshake-scoped/barrel/a.js

This file was deleted.

4 changes: 0 additions & 4 deletions playground/css/treeshake-scoped/barrel/b-scoped.css

This file was deleted.

5 changes: 0 additions & 5 deletions playground/css/treeshake-scoped/barrel/b.js

This file was deleted.

2 changes: 0 additions & 2 deletions playground/css/treeshake-scoped/barrel/index.js

This file was deleted.

8 changes: 6 additions & 2 deletions playground/css/treeshake-scoped/index.html
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
<h1>treeshake-scoped</h1>
<p class="scoped-index">Imported scoped CSS</p>
<p class="treeshake-scoped-order">
scoped CSS order (this should be red text with blue background)
</p>

<script type="module">
import { d } from './index.js'
import { a } from './barrel/index.js'
document.querySelector('.scoped-index').classList.add(d(), a())
import order from './order/a.js'
document.querySelector('.scoped-index').classList.add(d())
document.querySelector('.treeshake-scoped-order').classList.add(order())
</script>
4 changes: 4 additions & 0 deletions playground/css/treeshake-scoped/order/a-scoped.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.treeshake-scoped-order {
color: red;
background: red;
}
7 changes: 7 additions & 0 deletions playground/css/treeshake-scoped/order/a.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import './before.css'
import './a-scoped.css'
import './after.css'

export default function a() {
return 'treeshake-scoped-order-a'
}
4 changes: 4 additions & 0 deletions playground/css/treeshake-scoped/order/after.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.treeshake-scoped-order {
color: red;
background: blue;
}
3 changes: 3 additions & 0 deletions playground/css/treeshake-scoped/order/before.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.treeshake-scoped-order {
color: blue;
}
4 changes: 0 additions & 4 deletions playground/css/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,6 @@ export default defineConfig({
__dirname,
'./treeshake-scoped/index.html',
),
treeshakeScopedAnother: path.resolve(
__dirname,
'./treeshake-scoped/another.html',
),
},
output: {
manualChunks(id) {
Expand Down
5 changes: 4 additions & 1 deletion playground/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,10 @@ export async function getBgColor(
el: string | ElementHandle | Locator,
): Promise<string> {
el = await toEl(el)
return el.evaluate((el) => getComputedStyle(el as Element).backgroundColor)
const rgb = await el.evaluate(
(el) => getComputedStyle(el as Element).backgroundColor,
)
return hexToNameMap[rgbToHex(rgb)] ?? rgb
}

export function readFile(filename: string): string {
Expand Down