diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index 0fed6bca0ad827..f89f862681d5da 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -10,6 +10,7 @@ import type { OutputAsset, OutputChunk, RenderedChunk, + RenderedModule, RollupError, SourceMapInput, } from 'rollup' @@ -452,69 +453,12 @@ export function cssPlugin(config: ResolvedConfig): Plugin { return plugin } -const createStyleContentMap = () => { - const contents = new Map() // css id -> css content - const scopedIds = new Set() // 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() // queue to emit css serially to guarantee the files are emitted in a deterministic order let codeSplitEmitQueue = createSerialPromiseQueue() const urlEmitQueue = createSerialPromiseQueue() @@ -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 @@ -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 @@ -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) @@ -1201,6 +1143,17 @@ export function cssAnalysisPlugin(config: ResolvedConfig): Plugin { } } +function isCssScopeToRendered( + cssScopeTo: Exclude, + renderedModules: Record, +) { + 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 diff --git a/playground/css/__tests__/css.spec.ts b/playground/css/__tests__/css.spec.ts index d764a5b9b12cca..7bf3d12f0f928f 100644 --- a/playground/css/__tests__/css.spec.ts +++ b/playground/css/__tests__/css.spec.ts @@ -5,6 +5,7 @@ import { editFile, findAssetFile, getBg, + getBgColor, getColor, isBuild, page, @@ -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') +}) diff --git a/playground/css/treeshake-scoped/another.html b/playground/css/treeshake-scoped/another.html deleted file mode 100644 index 9500963ec7abee..00000000000000 --- a/playground/css/treeshake-scoped/another.html +++ /dev/null @@ -1,7 +0,0 @@ -

treeshake-scoped (another)

-

Imported scoped CSS

- - diff --git a/playground/css/treeshake-scoped/barrel/a-scoped.css b/playground/css/treeshake-scoped/barrel/a-scoped.css deleted file mode 100644 index 4c63425a3083ed..00000000000000 --- a/playground/css/treeshake-scoped/barrel/a-scoped.css +++ /dev/null @@ -1,4 +0,0 @@ -.treeshake-scoped-barrel-a { - text-decoration-line: underline; - text-decoration-color: red; -} diff --git a/playground/css/treeshake-scoped/barrel/a.js b/playground/css/treeshake-scoped/barrel/a.js deleted file mode 100644 index 11e780a7fa917e..00000000000000 --- a/playground/css/treeshake-scoped/barrel/a.js +++ /dev/null @@ -1,5 +0,0 @@ -import './a-scoped.css' - -export function a() { - return 'treeshake-scoped-barrel-a' -} diff --git a/playground/css/treeshake-scoped/barrel/b-scoped.css b/playground/css/treeshake-scoped/barrel/b-scoped.css deleted file mode 100644 index 2a7c35d0650e45..00000000000000 --- a/playground/css/treeshake-scoped/barrel/b-scoped.css +++ /dev/null @@ -1,4 +0,0 @@ -.treeshake-scoped-barrel-b { - text-decoration-line: underline; - text-decoration-color: red; -} diff --git a/playground/css/treeshake-scoped/barrel/b.js b/playground/css/treeshake-scoped/barrel/b.js deleted file mode 100644 index ac023513c3de8a..00000000000000 --- a/playground/css/treeshake-scoped/barrel/b.js +++ /dev/null @@ -1,5 +0,0 @@ -import './b-scoped.css' - -export function b() { - return 'treeshake-scoped-barrel-b' -} diff --git a/playground/css/treeshake-scoped/barrel/index.js b/playground/css/treeshake-scoped/barrel/index.js deleted file mode 100644 index 630314aa27d554..00000000000000 --- a/playground/css/treeshake-scoped/barrel/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export * from './a' -export * from './b' diff --git a/playground/css/treeshake-scoped/index.html b/playground/css/treeshake-scoped/index.html index 1e3ca61c50fc8e..d5e17c9a6bd772 100644 --- a/playground/css/treeshake-scoped/index.html +++ b/playground/css/treeshake-scoped/index.html @@ -1,8 +1,12 @@

treeshake-scoped

Imported scoped CSS

+

+ scoped CSS order (this should be red text with blue background) +

diff --git a/playground/css/treeshake-scoped/order/a-scoped.css b/playground/css/treeshake-scoped/order/a-scoped.css new file mode 100644 index 00000000000000..64b3725097079a --- /dev/null +++ b/playground/css/treeshake-scoped/order/a-scoped.css @@ -0,0 +1,4 @@ +.treeshake-scoped-order { + color: red; + background: red; +} diff --git a/playground/css/treeshake-scoped/order/a.js b/playground/css/treeshake-scoped/order/a.js new file mode 100644 index 00000000000000..2dfefad9c14b18 --- /dev/null +++ b/playground/css/treeshake-scoped/order/a.js @@ -0,0 +1,7 @@ +import './before.css' +import './a-scoped.css' +import './after.css' + +export default function a() { + return 'treeshake-scoped-order-a' +} diff --git a/playground/css/treeshake-scoped/order/after.css b/playground/css/treeshake-scoped/order/after.css new file mode 100644 index 00000000000000..af41d370d9c45f --- /dev/null +++ b/playground/css/treeshake-scoped/order/after.css @@ -0,0 +1,4 @@ +.treeshake-scoped-order { + color: red; + background: blue; +} diff --git a/playground/css/treeshake-scoped/order/before.css b/playground/css/treeshake-scoped/order/before.css new file mode 100644 index 00000000000000..d5e6bdb1ee3d36 --- /dev/null +++ b/playground/css/treeshake-scoped/order/before.css @@ -0,0 +1,3 @@ +.treeshake-scoped-order { + color: blue; +} diff --git a/playground/css/vite.config.js b/playground/css/vite.config.js index 17cba9b54e3de2..8adb5163e365b2 100644 --- a/playground/css/vite.config.js +++ b/playground/css/vite.config.js @@ -46,10 +46,6 @@ export default defineConfig({ __dirname, './treeshake-scoped/index.html', ), - treeshakeScopedAnother: path.resolve( - __dirname, - './treeshake-scoped/another.html', - ), }, output: { manualChunks(id) { diff --git a/playground/test-utils.ts b/playground/test-utils.ts index b2381eb51f69b7..14d82aa6bdf088 100644 --- a/playground/test-utils.ts +++ b/playground/test-utils.ts @@ -126,7 +126,10 @@ export async function getBgColor( el: string | ElementHandle | Locator, ): Promise { 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 {