diff --git a/packages/vite/src/node/plugin.ts b/packages/vite/src/node/plugin.ts index 033b907d98706b..aeb83d37094327 100644 --- a/packages/vite/src/node/plugin.ts +++ b/packages/vite/src/node/plugin.ts @@ -323,6 +323,24 @@ export interface Plugin extends RollupPlugin { > } +export interface CustomPluginOptionsVite { + /** + * If this is a CSS Rollup module, you can scope to its importer's exports + * so that if those exports are treeshaken away, the CSS module will also + * be treeshaken. + * + * The "importerId" must import the CSS Rollup module statically. + * + * Example config if the CSS id is `/src/App.vue?vue&type=style&lang.css`: + * ```js + * cssScopeTo: ['/src/App.vue', 'default'] + * ``` + * + * @experimental + */ + cssScopeTo?: [importerId: string, exportName: string | undefined] +} + export type HookHandler = T extends ObjectHook ? H : T export type PluginWithRequiredHook = Plugin & { diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index 1be8eadad7b65e..3834788611fc2e 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -54,7 +54,7 @@ import { SPECIAL_QUERY_RE, } from '../constants' import type { ResolvedConfig } from '../config' -import type { Plugin } from '../plugin' +import type { CustomPluginOptionsVite, Plugin } from '../plugin' import { checkPublicFile } from '../publicDir' import { arraify, @@ -439,12 +439,69 @@ export function cssPlugin(config: ResolvedConfig): 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: Map = new Map() + const styles = createStyleContentMap() // queue to emit css serially to guarantee the files are emitted in a deterministic order let codeSplitEmitQueue = createSerialPromiseQueue() const urlEmitQueue = createSerialPromiseQueue() @@ -588,9 +645,15 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { // build CSS handling ---------------------------------------------------- + const cssScopeTo = ( + this.getModuleInfo(id)?.meta?.vite as + | CustomPluginOptionsVite + | undefined + )?.cssScopeTo + // record css if (!inlined) { - styles.set(id, css) + styles.putContent(id, css, cssScopeTo) } let code: string @@ -612,7 +675,8 @@ 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 ? false : 'no-treeshake', + moduleSideEffects: + modulesCode || inlined || cssScopeTo ? false : 'no-treeshake', } }, @@ -623,15 +687,28 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { let isPureCssChunk = chunk.exports.length === 0 const ids = Object.keys(chunk.modules) for (const id of ids) { - if (styles.has(id)) { + if (styles.hasContentOfNonScoped(id)) { // ?transform-only is used for ?url and shouldn't be included in normal CSS chunks if (!transformOnlyRE.test(id)) { - chunkCSS += styles.get(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 } } + } 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 + } + } } 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 @@ -726,13 +803,13 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { path.basename(originalFileName), '.css', ) - if (!styles.has(id)) { + if (!styles.hasContentOfNonScoped(id)) { throw new Error( `css content for ${JSON.stringify(id)} was not found`, ) } - let cssContent = styles.get(id)! + let cssContent = styles.getContentOfNonScoped(id)! cssContent = resolveAssetUrlsInCss(cssContent, cssAssetName) diff --git a/playground/css/__tests__/css.spec.ts b/playground/css/__tests__/css.spec.ts index 7403418e114f75..ee565ab31aea3a 100644 --- a/playground/css/__tests__/css.spec.ts +++ b/playground/css/__tests__/css.spec.ts @@ -499,3 +499,23 @@ test.runIf(isBuild)('CSS modules should be treeshaken if not used', () => { const css = findAssetFile(/\.css$/, undefined, undefined, true) expect(css).not.toContain('treeshake-module-b') }) + +test.runIf(isBuild)('Scoped CSS via cssScopeTo should be treeshaken', () => { + const css = findAssetFile(/\.css$/, undefined, undefined, true) + expect(css).not.toContain('treeshake-module-b') + expect(css).not.toContain('treeshake-module-c') +}) + +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') + }, +) diff --git a/playground/css/index.html b/playground/css/index.html index edbcbd4f5fda23..3a20c70201a163 100644 --- a/playground/css/index.html +++ b/playground/css/index.html @@ -18,6 +18,8 @@

CSS


   

 
+  

Imported scoped CSS

+

PostCSS nesting plugin: this should be pink

diff --git a/playground/css/main.js b/playground/css/main.js index d3e97a7d2525e9..aab2b499ec01b6 100644 --- a/playground/css/main.js +++ b/playground/css/main.js @@ -14,6 +14,9 @@ appendLinkStylesheet(urlCss) import rawCss from './raw-imported.css?raw' text('.raw-imported-css', rawCss) +import { cUsed, a as treeshakeScopedA } from './treeshake-scoped/index.js' +document.querySelector('.scoped').classList.add(treeshakeScopedA(), cUsed()) + import mod from './mod.module.css' document.querySelector('.modules').classList.add(mod['apply-color']) text('.modules-code', JSON.stringify(mod, null, 2)) diff --git a/playground/css/treeshake-scoped/a-scoped.css b/playground/css/treeshake-scoped/a-scoped.css new file mode 100644 index 00000000000000..e18cbb887f4637 --- /dev/null +++ b/playground/css/treeshake-scoped/a-scoped.css @@ -0,0 +1,3 @@ +.treeshake-scoped-a { + color: red; +} diff --git a/playground/css/treeshake-scoped/a.js b/playground/css/treeshake-scoped/a.js new file mode 100644 index 00000000000000..819b7d3cf84e1d --- /dev/null +++ b/playground/css/treeshake-scoped/a.js @@ -0,0 +1,5 @@ +import './a-scoped.css' // should be treeshaken away if `a` is not used + +export default function a() { + return 'treeshake-scoped-a' +} diff --git a/playground/css/treeshake-scoped/another.html b/playground/css/treeshake-scoped/another.html new file mode 100644 index 00000000000000..9500963ec7abee --- /dev/null +++ b/playground/css/treeshake-scoped/another.html @@ -0,0 +1,7 @@ +

treeshake-scoped (another)

+

Imported scoped CSS

+ + diff --git a/playground/css/treeshake-scoped/b-scoped.css b/playground/css/treeshake-scoped/b-scoped.css new file mode 100644 index 00000000000000..9792a332519a81 --- /dev/null +++ b/playground/css/treeshake-scoped/b-scoped.css @@ -0,0 +1,3 @@ +.treeshake-scoped-b { + color: red; +} diff --git a/playground/css/treeshake-scoped/b.js b/playground/css/treeshake-scoped/b.js new file mode 100644 index 00000000000000..798ec76741c429 --- /dev/null +++ b/playground/css/treeshake-scoped/b.js @@ -0,0 +1,5 @@ +import './b-scoped.css' // should be treeshaken away if `b` is not used + +export default function b() { + return 'treeshake-scoped-b' +} diff --git a/playground/css/treeshake-scoped/barrel/a-scoped.css b/playground/css/treeshake-scoped/barrel/a-scoped.css new file mode 100644 index 00000000000000..4c63425a3083ed --- /dev/null +++ b/playground/css/treeshake-scoped/barrel/a-scoped.css @@ -0,0 +1,4 @@ +.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 new file mode 100644 index 00000000000000..11e780a7fa917e --- /dev/null +++ b/playground/css/treeshake-scoped/barrel/a.js @@ -0,0 +1,5 @@ +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 new file mode 100644 index 00000000000000..2a7c35d0650e45 --- /dev/null +++ b/playground/css/treeshake-scoped/barrel/b-scoped.css @@ -0,0 +1,4 @@ +.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 new file mode 100644 index 00000000000000..ac023513c3de8a --- /dev/null +++ b/playground/css/treeshake-scoped/barrel/b.js @@ -0,0 +1,5 @@ +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 new file mode 100644 index 00000000000000..630314aa27d554 --- /dev/null +++ b/playground/css/treeshake-scoped/barrel/index.js @@ -0,0 +1,2 @@ +export * from './a' +export * from './b' diff --git a/playground/css/treeshake-scoped/c-scoped.css b/playground/css/treeshake-scoped/c-scoped.css new file mode 100644 index 00000000000000..8901f7303dc9d6 --- /dev/null +++ b/playground/css/treeshake-scoped/c-scoped.css @@ -0,0 +1,3 @@ +.treeshake-scoped-c { + color: red; +} diff --git a/playground/css/treeshake-scoped/c.js b/playground/css/treeshake-scoped/c.js new file mode 100644 index 00000000000000..8a7e2fb89dbaa2 --- /dev/null +++ b/playground/css/treeshake-scoped/c.js @@ -0,0 +1,10 @@ +import './c-scoped.css' // should be treeshaken away if `b` is not used + +export default function c() { + return 'treeshake-scoped-c' +} + +export function cUsed() { + // used but does not depend on scoped css + return 'c-used' +} diff --git a/playground/css/treeshake-scoped/d-scoped.css b/playground/css/treeshake-scoped/d-scoped.css new file mode 100644 index 00000000000000..83c0b0ed176271 --- /dev/null +++ b/playground/css/treeshake-scoped/d-scoped.css @@ -0,0 +1,3 @@ +.treeshake-scoped-d { + color: red; +} diff --git a/playground/css/treeshake-scoped/d.js b/playground/css/treeshake-scoped/d.js new file mode 100644 index 00000000000000..7581688476cf56 --- /dev/null +++ b/playground/css/treeshake-scoped/d.js @@ -0,0 +1,5 @@ +import './d-scoped.css' // should be treeshaken away if `d` is not used + +export default function d() { + return 'treeshake-scoped-d' +} diff --git a/playground/css/treeshake-scoped/index.html b/playground/css/treeshake-scoped/index.html new file mode 100644 index 00000000000000..1e3ca61c50fc8e --- /dev/null +++ b/playground/css/treeshake-scoped/index.html @@ -0,0 +1,8 @@ +

treeshake-scoped

+

Imported scoped CSS

+ + diff --git a/playground/css/treeshake-scoped/index.js b/playground/css/treeshake-scoped/index.js new file mode 100644 index 00000000000000..93bea696056968 --- /dev/null +++ b/playground/css/treeshake-scoped/index.js @@ -0,0 +1,4 @@ +export { default as a } from './a.js' +export { default as b } from './b.js' +export { default as c, cUsed } from './c.js' +export { default as d } from './d.js' diff --git a/playground/css/vite.config.js b/playground/css/vite.config.js index 115db67dcaaa53..17cba9b54e3de2 100644 --- a/playground/css/vite.config.js +++ b/playground/css/vite.config.js @@ -11,9 +11,46 @@ globalThis.window = {} globalThis.location = new URL('http://localhost/') export default defineConfig({ + plugins: [ + { + // Emulate a UI framework component where a framework module would import + // scoped CSS files that should treeshake if the default export is not used. + name: 'treeshake-scoped-css', + enforce: 'pre', + async resolveId(id, importer) { + if (!importer || !id.endsWith('-scoped.css')) return + + const resolved = await this.resolve(id, importer) + if (!resolved) return + + return { + ...resolved, + meta: { + vite: { + cssScopeTo: [ + importer, + resolved.id.includes('barrel') ? undefined : 'default', + ], + }, + }, + } + }, + }, + ], build: { cssTarget: 'chrome61', rollupOptions: { + input: { + index: path.resolve(__dirname, './index.html'), + treeshakeScoped: path.resolve( + __dirname, + './treeshake-scoped/index.html', + ), + treeshakeScopedAnother: path.resolve( + __dirname, + './treeshake-scoped/another.html', + ), + }, output: { manualChunks(id) { if (id.includes('manual-chunk.css')) {