From a97dde24b7e42dadcfee78d64d17a6b74e4d053a Mon Sep 17 00:00:00 2001 From: bluwy Date: Thu, 29 Feb 2024 17:37:14 +0800 Subject: [PATCH 01/12] feat(css): allow scoping css to importers exports --- packages/vite/src/node/plugins/css.ts | 45 +++++++++++++++++--- packages/vite/src/node/plugins/resolve.ts | 4 +- packages/vite/types/metadata.d.ts | 25 +++++++++++ playground/css/__tests__/css.spec.ts | 6 +++ playground/css/index.html | 2 + playground/css/main.js | 3 ++ playground/css/treeshake-scoped/a-scoped.css | 3 ++ playground/css/treeshake-scoped/a.js | 5 +++ playground/css/treeshake-scoped/b-scoped.css | 3 ++ playground/css/treeshake-scoped/b.js | 5 +++ playground/css/treeshake-scoped/c-scoped.css | 3 ++ playground/css/treeshake-scoped/c.js | 10 +++++ playground/css/treeshake-scoped/index.js | 3 ++ playground/css/vite.config.js | 25 +++++++++++ playground/test-utils.ts | 20 +++++++-- 15 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 playground/css/treeshake-scoped/a-scoped.css create mode 100644 playground/css/treeshake-scoped/a.js create mode 100644 playground/css/treeshake-scoped/b-scoped.css create mode 100644 playground/css/treeshake-scoped/b.js create mode 100644 playground/css/treeshake-scoped/c-scoped.css create mode 100644 playground/css/treeshake-scoped/c.js create mode 100644 playground/css/treeshake-scoped/index.js diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index f656c8c8aed4fd..7fbdb8d54b74bd 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -546,20 +546,33 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { } }, - async renderChunk(code, chunk, opts) { + async renderChunk(code, chunk, opts, meta) { let chunkCSS = '' let isPureCssChunk = true const ids = Object.keys(chunk.modules) for (const id of ids) { 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.get(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 + } + + // 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?.cssScopeTo + if ( + cssScopeTo && + !isCssScopeToRendered(cssScopeTo, Object.values(meta.chunks)) + ) { + 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 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 @@ -1029,6 +1042,24 @@ export function getEmptyChunkReplacer( ) } +function isCssScopeToRendered( + cssScopeTo: Record, + chunks: RenderedChunk[], +) { + for (const moduleId in cssScopeTo) { + const exports = cssScopeTo[moduleId] + // Find the chunk that renders this `moduleId` and get the rendered module + const renderedModule = chunks.find((c) => c.moduleIds.includes(moduleId)) + ?.modules[moduleId] + + if (renderedModule?.renderedExports.some((e) => exports.includes(e))) { + return true + } + } + + return false +} + interface CSSAtImportResolvers { css: ResolveFn sass: ResolveFn diff --git a/packages/vite/src/node/plugins/resolve.ts b/packages/vite/src/node/plugins/resolve.ts index eee6ba0f92d742..68e78c6dae38a3 100644 --- a/packages/vite/src/node/plugins/resolve.ts +++ b/packages/vite/src/node/plugins/resolve.ts @@ -214,8 +214,8 @@ export function resolvePlugin(resolveOptions: InternalResolveOptions): Plugin { ) { options.isFromTsImporter = true } else { - const moduleLang = this.getModuleInfo(importer)?.meta?.vite?.lang - options.isFromTsImporter = moduleLang && isTsRequest(`.${moduleLang}`) + const lang = this.getModuleInfo(importer)?.meta?.vite?.lang + options.isFromTsImporter = lang != null && isTsRequest(`.${lang}`) } } diff --git a/packages/vite/types/metadata.d.ts b/packages/vite/types/metadata.d.ts index d6925c5a6f2f93..062118cc85d6c1 100644 --- a/packages/vite/types/metadata.d.ts +++ b/packages/vite/types/metadata.d.ts @@ -7,4 +7,29 @@ declare module 'rollup' { export interface RenderedChunk { viteMetadata?: ChunkMetadata } + + export interface CustomPluginOptions { + vite?: { + /** + * The language for this module, e.g. `ts`, `tsx`, etc. + * Used to identify if this module should resolve its `*.js` imports + * to TypeScript files. + */ + lang?: string + /** + * 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. If multiple importers and exports are passed, if at least + * one of them are bundled (and not treeshaken), then the CSS will also be bundled. + * + * Example config if the CSS id is `/src/App.vue?vue&type=style&lang.css`: + * ```js + * cssScopeTo: { + * '/src/App.vue': ['default'] + * } + * ``` + */ + cssScopeTo?: Record + } + } } diff --git a/playground/css/__tests__/css.spec.ts b/playground/css/__tests__/css.spec.ts index 89226a8fbd5ba1..db4f09be18c8f0 100644 --- a/playground/css/__tests__/css.spec.ts +++ b/playground/css/__tests__/css.spec.ts @@ -533,3 +533,9 @@ test.runIf(isBuild)('manual chunk path', async () => { findAssetFile(/dir\/dir2\/manual-chunk-[-\w]{8}\.css$/), ).not.toBeUndefined() }) + +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') +}) diff --git a/playground/css/index.html b/playground/css/index.html index 508744160526de..acedfb6e6b1296 100644 --- a/playground/css/index.html +++ b/playground/css/index.html @@ -19,6 +19,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 8b3eb488fe813b..3a0d5e6d0253d8 100644 --- a/playground/css/main.js +++ b/playground/css/main.js @@ -12,6 +12,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/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/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/index.js b/playground/css/treeshake-scoped/index.js new file mode 100644 index 00000000000000..bb14966457cf8c --- /dev/null +++ b/playground/css/treeshake-scoped/index.js @@ -0,0 +1,3 @@ +export { default as a } from './a.js' +export { default as b } from './b.js' +export { default as c, cUsed } from './c.js' diff --git a/playground/css/vite.config.js b/playground/css/vite.config.js index 5ac9d448a2734a..b2032259fd139d 100644 --- a/playground/css/vite.config.js +++ b/playground/css/vite.config.js @@ -10,6 +10,31 @@ 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]: ['default'], + }, + }, + }, + } + }, + }, + ], build: { cssTarget: 'chrome61', rollupOptions: { diff --git a/playground/test-utils.ts b/playground/test-utils.ts index b5fe29d2a24ae8..2916c350d12f5f 100644 --- a/playground/test-utils.ts +++ b/playground/test-utils.ts @@ -156,6 +156,7 @@ export function findAssetFile( match: string | RegExp, base = '', assets = 'assets', + matchAll = false, ): string { const assetsDir = path.join(testDir, 'dist', base, assets) let files: string[] @@ -167,10 +168,21 @@ export function findAssetFile( } throw e } - const file = files.find((file) => { - return file.match(match) - }) - return file ? fs.readFileSync(path.resolve(assetsDir, file), 'utf-8') : '' + if (matchAll) { + const matchedFiles = files.filter((file) => file.match(match)) + return matchedFiles.length + ? matchedFiles + .map((file) => + fs.readFileSync(path.resolve(assetsDir, file), 'utf-8'), + ) + .join('') + : '' + } else { + const matchedFile = files.find((file) => file.match(match)) + return matchedFile + ? fs.readFileSync(path.resolve(assetsDir, matchedFile), 'utf-8') + : '' + } } export function readManifest(base = ''): Manifest { From 83d1dd4b2e31faf8e67079b3b4f302a08c686963 Mon Sep 17 00:00:00 2001 From: bluwy Date: Fri, 1 Mar 2024 17:40:31 +0800 Subject: [PATCH 02/12] chore: multi entrypoint demo --- playground/css/treeshake-scoped/d-scoped.css | 3 +++ playground/css/treeshake-scoped/d.js | 5 +++++ playground/css/treeshake-scoped/index.html | 7 +++++++ playground/css/treeshake-scoped/index.js | 1 + playground/css/vite.config.js | 7 +++++++ 5 files changed, 23 insertions(+) create mode 100644 playground/css/treeshake-scoped/d-scoped.css create mode 100644 playground/css/treeshake-scoped/d.js create mode 100644 playground/css/treeshake-scoped/index.html 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..bc0f2547b0bec8 --- /dev/null +++ b/playground/css/treeshake-scoped/index.html @@ -0,0 +1,7 @@ +

treeshake-scoped

+

Imported scoped CSS

+ + diff --git a/playground/css/treeshake-scoped/index.js b/playground/css/treeshake-scoped/index.js index bb14966457cf8c..93bea696056968 100644 --- a/playground/css/treeshake-scoped/index.js +++ b/playground/css/treeshake-scoped/index.js @@ -1,3 +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 b2032259fd139d..e97cd0fb7f45b5 100644 --- a/playground/css/vite.config.js +++ b/playground/css/vite.config.js @@ -38,6 +38,13 @@ export default defineConfig({ build: { cssTarget: 'chrome61', rollupOptions: { + input: { + main: path.resolve(__dirname, './index.html'), + treeshakeScoped: path.resolve( + __dirname, + './treeshake-scoped/index.html', + ), + }, output: { manualChunks(id) { if (id.includes('manual-chunk.css')) { From ec1bb0e639fafdf57f030fa0069175c12478b512 Mon Sep 17 00:00:00 2001 From: bluwy Date: Fri, 1 Mar 2024 17:45:47 +0800 Subject: [PATCH 03/12] chore: fix test --- playground/css/vite.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playground/css/vite.config.js b/playground/css/vite.config.js index e97cd0fb7f45b5..7fb5fb5ddc4b28 100644 --- a/playground/css/vite.config.js +++ b/playground/css/vite.config.js @@ -39,7 +39,7 @@ export default defineConfig({ cssTarget: 'chrome61', rollupOptions: { input: { - main: path.resolve(__dirname, './index.html'), + index: path.resolve(__dirname, './index.html'), treeshakeScoped: path.resolve( __dirname, './treeshake-scoped/index.html', From a4110b84c40789f360bb89b1b09c8e9a97eaaa8b Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Thu, 13 Feb 2025 18:01:11 +0900 Subject: [PATCH 04/12] feat: attach the styles to the scoped file instead of the actual file --- packages/vite/src/node/index.ts | 7 +- packages/vite/src/node/plugin.ts | 14 ++++ packages/vite/src/node/plugins/css.ts | 75 +++++++++---------- packages/vite/types/metadata.d.ts | 25 ------- playground/css/treeshake-scoped/another.html | 7 ++ .../css/treeshake-scoped/barrel/a-scoped.css | 4 + playground/css/treeshake-scoped/barrel/a.js | 5 ++ .../css/treeshake-scoped/barrel/b-scoped.css | 4 + playground/css/treeshake-scoped/barrel/b.js | 5 ++ .../css/treeshake-scoped/barrel/index.js | 2 + playground/css/treeshake-scoped/index.html | 3 +- playground/css/vite.config.js | 11 ++- 12 files changed, 93 insertions(+), 69 deletions(-) create mode 100644 playground/css/treeshake-scoped/another.html create mode 100644 playground/css/treeshake-scoped/barrel/a-scoped.css create mode 100644 playground/css/treeshake-scoped/barrel/a.js create mode 100644 playground/css/treeshake-scoped/barrel/b-scoped.css create mode 100644 playground/css/treeshake-scoped/barrel/b.js create mode 100644 playground/css/treeshake-scoped/barrel/index.js diff --git a/packages/vite/src/node/index.ts b/packages/vite/src/node/index.ts index ee5e4e9c820d28..a6e7259d07c682 100644 --- a/packages/vite/src/node/index.ts +++ b/packages/vite/src/node/index.ts @@ -66,7 +66,12 @@ export type { DevEnvironmentOptions, ResolvedDevEnvironmentOptions, } from './config' -export type { Plugin, PluginOption, HookHandler } from './plugin' +export type { + Plugin, + PluginOption, + HookHandler, + CustomPluginOptionsVite, +} from './plugin' export type { Environment } from './environment' export type { FilterPattern } from './utils' export type { CorsOptions, CorsOrigin, CommonServerOptions } from './http' diff --git a/packages/vite/src/node/plugin.ts b/packages/vite/src/node/plugin.ts index 033b907d98706b..cca29d82ed0320 100644 --- a/packages/vite/src/node/plugin.ts +++ b/packages/vite/src/node/plugin.ts @@ -323,6 +323,20 @@ 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. + * + * Example config if the CSS id is `/src/App.vue?vue&type=style&lang.css`: + * ```js + * cssScopeTo: ['/src/App.vue', 'default'] + * ``` + */ + cssScopeTo?: [string, 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 0e8c1faed19312..08b3c0f0fa7545 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, @@ -445,6 +445,7 @@ export function cssPlugin(config: ResolvedConfig): Plugin { export function cssPostPlugin(config: ResolvedConfig): Plugin { // styles initialization in buildStart causes a styling loss in watch const styles: Map = new Map() + const scopedStyles = new Map>() // queue to emit css serially to guarantee the files are emitted in a deterministic order let codeSplitEmitQueue = createSerialPromiseQueue() const urlEmitQueue = createSerialPromiseQueue() @@ -607,16 +608,33 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { code = '' } + const cssScopeTo = ( + this.getModuleInfo(id)?.meta?.vite as + | CustomPluginOptionsVite + | undefined + )?.cssScopeTo + if (cssScopeTo) { + const [file, exp] = cssScopeTo + if (!scopedStyles.has(file)) { + scopedStyles.set(file, new Map()) + } + if (!scopedStyles.get(file)!.has(exp)) { + scopedStyles.get(file)!.set(exp, []) + } + scopedStyles.get(file)!.get(exp)!.push(css) + } + return { code, 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', } }, - async renderChunk(code, chunk, opts, meta) { + async renderChunk(code, chunk, opts) { let chunkCSS = '' // the chunk is empty if it's a dynamic entry chunk that only contains a CSS import const isJsChunkEmpty = code === '' && !chunk.isEntry @@ -625,26 +643,23 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { for (const id of ids) { if (styles.has(id)) { // ?transform-only is used for ?url and shouldn't be included in normal CSS chunks - if (transformOnlyRE.test(id)) { - continue - } - - // 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?.cssScopeTo - if ( - cssScopeTo && - !isCssScopeToRendered(cssScopeTo, Object.values(meta.chunks)) - ) { - continue + if (!transformOnlyRE.test(id)) { + chunkCSS += styles.get(id) + // a css module contains JS, so it makes this not a pure css chunk + if (cssModuleRE.test(id)) { + isPureCssChunk = false + } } - - // a css module contains JS, so it makes this not a pure css chunk - if (cssModuleRE.test(id)) { - isPureCssChunk = false + } else if (scopedStyles.has(id)) { + const renderedExports = chunk.modules[id]!.renderedExports + // If this module is has a scoped style, check for the rendered exports + // and include the corresponding CSS. + for (const [exp, styles] of scopedStyles.get(id)!) { + if (exp === undefined || renderedExports.includes(exp)) { + // TODO: do we need to care the order? + chunkCSS += styles.join('') + } } - - 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 @@ -1147,24 +1162,6 @@ export function getEmptyChunkReplacer( ) } -function isCssScopeToRendered( - cssScopeTo: Record, - chunks: RenderedChunk[], -) { - for (const moduleId in cssScopeTo) { - const exports = cssScopeTo[moduleId] - // Find the chunk that renders this `moduleId` and get the rendered module - const renderedModule = chunks.find((c) => c.moduleIds.includes(moduleId)) - ?.modules[moduleId] - - if (renderedModule?.renderedExports.some((e) => exports.includes(e))) { - return true - } - } - - return false -} - interface CSSAtImportResolvers { css: ResolveIdFn sass: ResolveIdFn diff --git a/packages/vite/types/metadata.d.ts b/packages/vite/types/metadata.d.ts index 062118cc85d6c1..d6925c5a6f2f93 100644 --- a/packages/vite/types/metadata.d.ts +++ b/packages/vite/types/metadata.d.ts @@ -7,29 +7,4 @@ declare module 'rollup' { export interface RenderedChunk { viteMetadata?: ChunkMetadata } - - export interface CustomPluginOptions { - vite?: { - /** - * The language for this module, e.g. `ts`, `tsx`, etc. - * Used to identify if this module should resolve its `*.js` imports - * to TypeScript files. - */ - lang?: string - /** - * 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. If multiple importers and exports are passed, if at least - * one of them are bundled (and not treeshaken), then the CSS will also be bundled. - * - * Example config if the CSS id is `/src/App.vue?vue&type=style&lang.css`: - * ```js - * cssScopeTo: { - * '/src/App.vue': ['default'] - * } - * ``` - */ - cssScopeTo?: Record - } - } } 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/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/index.html b/playground/css/treeshake-scoped/index.html index bc0f2547b0bec8..0779c5e223332e 100644 --- a/playground/css/treeshake-scoped/index.html +++ b/playground/css/treeshake-scoped/index.html @@ -3,5 +3,6 @@

treeshake-scoped

diff --git a/playground/css/vite.config.js b/playground/css/vite.config.js index e6ea8312d89674..17cba9b54e3de2 100644 --- a/playground/css/vite.config.js +++ b/playground/css/vite.config.js @@ -27,9 +27,10 @@ export default defineConfig({ ...resolved, meta: { vite: { - cssScopeTo: { - [importer]: ['default'], - }, + cssScopeTo: [ + importer, + resolved.id.includes('barrel') ? undefined : 'default', + ], }, }, } @@ -45,6 +46,10 @@ export default defineConfig({ __dirname, './treeshake-scoped/index.html', ), + treeshakeScopedAnother: path.resolve( + __dirname, + './treeshake-scoped/another.html', + ), }, output: { manualChunks(id) { From 4cc87b931e186fbcd0a1e94fb49efa22d5a4a538 Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Thu, 13 Feb 2025 18:04:24 +0900 Subject: [PATCH 05/12] chore: remove format by newer prettier --- .../landing/1. hero-section/HeroDiagram.vue | 15 +++++---------- .../FeatureInstantServerStart.vue | 3 +-- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/docs/.vitepress/theme/components/landing/1. hero-section/HeroDiagram.vue b/docs/.vitepress/theme/components/landing/1. hero-section/HeroDiagram.vue index b192bc197fbeb8..f76e7092220094 100644 --- a/docs/.vitepress/theme/components/landing/1. hero-section/HeroDiagram.vue +++ b/docs/.vitepress/theme/components/landing/1. hero-section/HeroDiagram.vue @@ -520,8 +520,7 @@ onMounted(() => { bottom: 0; transform: translate3d(0, 0, 0) scale(1); transition: transform 0.3s ease-in-out; - background: - linear-gradient( + background: linear-gradient( 130deg, rgba(61, 61, 61, 0.3) 0%, rgba(61, 61, 61, 0) 40% @@ -714,8 +713,7 @@ onMounted(() => { opacity: 0.1; } - background: - url('/noise.png'), + background: url('/noise.png'), radial-gradient( circle at right center, rgb(86, 50, 119) 0%, @@ -731,8 +729,7 @@ onMounted(() => { ); @media (min-width: 1024px) { - background: - url('/noise.png'), + background: url('/noise.png'), radial-gradient( circle at right center, rgba(75, 41, 105, 0.5) 0%, @@ -750,8 +747,7 @@ onMounted(() => { } @media (min-width: 1500px) { - background: - url('/noise.png'), + background: url('/noise.png'), radial-gradient( circle at right center, rgba(75, 41, 105, 0.5) 0%, @@ -769,8 +765,7 @@ onMounted(() => { } @media (min-width: 1800px) { - background: - url('/noise.png'), + background: url('/noise.png'), radial-gradient( circle at right center, rgba(75, 41, 105, 0.5) 0%, diff --git a/docs/.vitepress/theme/components/landing/2. feature-section/FeatureInstantServerStart.vue b/docs/.vitepress/theme/components/landing/2. feature-section/FeatureInstantServerStart.vue index 6b12c1c4223431..5e44b3bb760830 100644 --- a/docs/.vitepress/theme/components/landing/2. feature-section/FeatureInstantServerStart.vue +++ b/docs/.vitepress/theme/components/landing/2. feature-section/FeatureInstantServerStart.vue @@ -342,8 +342,7 @@ onUnmounted(() => { bottom: 0; height: 100%; border-radius: 12px 0 0 12px; - background: - url('/noise.png'), + background: url('/noise.png'), radial-gradient( ellipse 140% 80% at 96% bottom, #13b351 0%, From c7bbf7e0024066b55b4b26959831c7a975b1e722 Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Tue, 18 Feb 2025 14:51:37 +0900 Subject: [PATCH 06/12] refactor: extract styleContentMap --- packages/vite/src/node/plugins/css.ts | 91 +++++++++++++++++++-------- 1 file changed, 64 insertions(+), 27 deletions(-) diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index 08b3c0f0fa7545..372889e541692c 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -439,13 +439,61 @@ export function cssPlugin(config: ResolvedConfig): Plugin { } } +const createStyleContentMap = () => { + const contents = new Map() // css id -> css content + const scopedIds = new Set() // whether that id of css is 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) { + const rels = [...(relations.get(id) ?? [])] + // sort to get a deterministic output + rels.sort((a, b) => (a.id > b.id ? 1 : -1)) + return rels.map(({ id, exp }) => ({ + content: contents.get(id) ?? '', + exp, + })) + }, + } +} + /** * 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 scopedStyles = 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() @@ -589,9 +637,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 @@ -608,22 +662,6 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { code = '' } - const cssScopeTo = ( - this.getModuleInfo(id)?.meta?.vite as - | CustomPluginOptionsVite - | undefined - )?.cssScopeTo - if (cssScopeTo) { - const [file, exp] = cssScopeTo - if (!scopedStyles.has(file)) { - scopedStyles.set(file, new Map()) - } - if (!scopedStyles.get(file)!.has(exp)) { - scopedStyles.get(file)!.set(exp, []) - } - scopedStyles.get(file)!.get(exp)!.push(css) - } - return { code, map: { mappings: '' }, @@ -641,23 +679,22 @@ 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 (scopedStyles.has(id)) { + } else if (styles.hasContentsScopedTo(id)) { const renderedExports = chunk.modules[id]!.renderedExports // If this module is has a scoped style, check for the rendered exports // and include the corresponding CSS. - for (const [exp, styles] of scopedStyles.get(id)!) { + for (const { exp, content } of styles.getContentsScopedTo(id)) { if (exp === undefined || renderedExports.includes(exp)) { - // TODO: do we need to care the order? - chunkCSS += styles.join('') + chunkCSS += content } } } else if (!isJsChunkEmpty) { @@ -754,13 +791,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) From ac0a5bfd1c558786bac33f1136812439d787a09d Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Tue, 18 Feb 2025 15:26:19 +0900 Subject: [PATCH 07/12] test: add test for barrel files --- playground/css/__tests__/css.spec.ts | 14 ++++++++++++++ playground/css/treeshake-scoped/index.html | 4 ++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/playground/css/__tests__/css.spec.ts b/playground/css/__tests__/css.spec.ts index 2ead1005746b19..ee565ab31aea3a 100644 --- a/playground/css/__tests__/css.spec.ts +++ b/playground/css/__tests__/css.spec.ts @@ -505,3 +505,17 @@ test.runIf(isBuild)('Scoped CSS via cssScopeTo should be treeshaken', () => { 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/treeshake-scoped/index.html b/playground/css/treeshake-scoped/index.html index 0779c5e223332e..1e3ca61c50fc8e 100644 --- a/playground/css/treeshake-scoped/index.html +++ b/playground/css/treeshake-scoped/index.html @@ -1,8 +1,8 @@

treeshake-scoped

-

Imported scoped CSS

+

Imported scoped CSS

From 1cc25cad850b9bca14645584deeada3b988db00f Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Thu, 20 Feb 2025 14:01:13 +0900 Subject: [PATCH 08/12] chore: add comment --- packages/vite/src/node/plugin.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/vite/src/node/plugin.ts b/packages/vite/src/node/plugin.ts index cca29d82ed0320..12c2065ac17959 100644 --- a/packages/vite/src/node/plugin.ts +++ b/packages/vite/src/node/plugin.ts @@ -333,8 +333,10 @@ export interface CustomPluginOptionsVite { * ```js * cssScopeTo: ['/src/App.vue', 'default'] * ``` + * + * @experimental */ - cssScopeTo?: [string, string | undefined] + cssScopeTo?: [importerId: string, exportName: string | undefined] } export type HookHandler = T extends ObjectHook ? H : T From fd8d75b2ba2aa1b628ca98ef766087e83b56737c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BF=A0=20/=20green?= Date: Thu, 20 Feb 2025 17:09:27 +0900 Subject: [PATCH 09/12] =?UTF-8?q?chore:=20update=20comments,=20thanks=20bl?= =?UTF-8?q?u=20=F0=9F=92=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bjorn Lu --- packages/vite/src/node/plugins/css.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index 372889e541692c..5302d5d9988582 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -441,7 +441,7 @@ export function cssPlugin(config: ResolvedConfig): Plugin { const createStyleContentMap = () => { const contents = new Map() // css id -> css content - const scopedIds = new Set() // whether that id of css is scoped + 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<{ @@ -690,7 +690,7 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { } } else if (styles.hasContentsScopedTo(id)) { const renderedExports = chunk.modules[id]!.renderedExports - // If this module is has a scoped style, check for the rendered exports + // If this module has scoped styles, check for the rendered exports // and include the corresponding CSS. for (const { exp, content } of styles.getContentsScopedTo(id)) { if (exp === undefined || renderedExports.includes(exp)) { From 3b50c0d1fa8001c7f1216442a22f237dc737e06a Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Thu, 20 Feb 2025 17:10:17 +0900 Subject: [PATCH 10/12] chore: dont export CustomPluginOptionsVite for now --- packages/vite/src/node/index.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/vite/src/node/index.ts b/packages/vite/src/node/index.ts index a6e7259d07c682..ee5e4e9c820d28 100644 --- a/packages/vite/src/node/index.ts +++ b/packages/vite/src/node/index.ts @@ -66,12 +66,7 @@ export type { DevEnvironmentOptions, ResolvedDevEnvironmentOptions, } from './config' -export type { - Plugin, - PluginOption, - HookHandler, - CustomPluginOptionsVite, -} from './plugin' +export type { Plugin, PluginOption, HookHandler } from './plugin' export type { Environment } from './environment' export type { FilterPattern } from './utils' export type { CorsOptions, CorsOrigin, CommonServerOptions } from './http' From e0a70016a75b6542c5ebcc550b6055c5c1a76016 Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Thu, 20 Feb 2025 17:29:23 +0900 Subject: [PATCH 11/12] fix: sort styles by import order --- packages/vite/src/node/plugins/css.ts | 30 +++++++++++++++++++-------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index 5302d5d9988582..3834788611fc2e 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -476,14 +476,22 @@ const createStyleContentMap = () => { hasContentsScopedTo(id: string) { return (relations.get(id) ?? [])?.length > 0 }, - getContentsScopedTo(id: string) { - const rels = [...(relations.get(id) ?? [])] - // sort to get a deterministic output - rels.sort((a, b) => (a.id > b.id ? 1 : -1)) - return rels.map(({ id, exp }) => ({ - content: contents.get(id) ?? '', - exp, - })) + 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)!) }, } } @@ -690,9 +698,13 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { } } 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)) { + for (const { exp, content } of styles.getContentsScopedTo( + id, + importedIds, + )) { if (exp === undefined || renderedExports.includes(exp)) { chunkCSS += content } From 173266440cd969fce11247b923dc4d2aa5b0759a Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Thu, 20 Feb 2025 17:39:55 +0900 Subject: [PATCH 12/12] chore: add comment --- packages/vite/src/node/plugin.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/vite/src/node/plugin.ts b/packages/vite/src/node/plugin.ts index 12c2065ac17959..aeb83d37094327 100644 --- a/packages/vite/src/node/plugin.ts +++ b/packages/vite/src/node/plugin.ts @@ -329,6 +329,8 @@ export interface CustomPluginOptionsVite { * 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']