Skip to content

Commit 3ebd838

Browse files
sapphi-redbluwy
andauthored
feat(css): allow scoping css to importers exports (#19418)
Co-authored-by: bluwy <[email protected]>
1 parent f6926ca commit 3ebd838

File tree

22 files changed

+241
-8
lines changed

22 files changed

+241
-8
lines changed

packages/vite/src/node/plugin.ts

+18
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,24 @@ export interface Plugin<A = any> extends RollupPlugin<A> {
323323
>
324324
}
325325

326+
export interface CustomPluginOptionsVite {
327+
/**
328+
* If this is a CSS Rollup module, you can scope to its importer's exports
329+
* so that if those exports are treeshaken away, the CSS module will also
330+
* be treeshaken.
331+
*
332+
* The "importerId" must import the CSS Rollup module statically.
333+
*
334+
* Example config if the CSS id is `/src/App.vue?vue&type=style&lang.css`:
335+
* ```js
336+
* cssScopeTo: ['/src/App.vue', 'default']
337+
* ```
338+
*
339+
* @experimental
340+
*/
341+
cssScopeTo?: [importerId: string, exportName: string | undefined]
342+
}
343+
326344
export type HookHandler<T> = T extends ObjectHook<infer H> ? H : T
327345

328346
export type PluginWithRequiredHook<K extends keyof Plugin> = Plugin & {

packages/vite/src/node/plugins/css.ts

+85-8
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ import {
5454
SPECIAL_QUERY_RE,
5555
} from '../constants'
5656
import type { ResolvedConfig } from '../config'
57-
import type { Plugin } from '../plugin'
57+
import type { CustomPluginOptionsVite, Plugin } from '../plugin'
5858
import { checkPublicFile } from '../publicDir'
5959
import {
6060
arraify,
@@ -439,12 +439,69 @@ export function cssPlugin(config: ResolvedConfig): Plugin {
439439
}
440440
}
441441

442+
const createStyleContentMap = () => {
443+
const contents = new Map<string, string>() // css id -> css content
444+
const scopedIds = new Set<string>() // ids of css that are scoped
445+
const relations = new Map<
446+
/* the id of the target for which css is scoped to */ string,
447+
Array<{
448+
/** css id */ id: string
449+
/** export name */ exp: string | undefined
450+
}>
451+
>()
452+
453+
return {
454+
putContent(
455+
id: string,
456+
content: string,
457+
scopeTo: CustomPluginOptionsVite['cssScopeTo'] | undefined,
458+
) {
459+
contents.set(id, content)
460+
if (scopeTo) {
461+
const [scopedId, exp] = scopeTo
462+
if (!relations.has(scopedId)) {
463+
relations.set(scopedId, [])
464+
}
465+
relations.get(scopedId)!.push({ id, exp })
466+
scopedIds.add(id)
467+
}
468+
},
469+
hasContentOfNonScoped(id: string) {
470+
return !scopedIds.has(id) && contents.has(id)
471+
},
472+
getContentOfNonScoped(id: string) {
473+
if (scopedIds.has(id)) return
474+
return contents.get(id)
475+
},
476+
hasContentsScopedTo(id: string) {
477+
return (relations.get(id) ?? [])?.length > 0
478+
},
479+
getContentsScopedTo(id: string, importedIds: readonly string[]) {
480+
const values = (relations.get(id) ?? []).map(
481+
({ id, exp }) =>
482+
[
483+
id,
484+
{
485+
content: contents.get(id) ?? '',
486+
exp,
487+
},
488+
] as const,
489+
)
490+
const styleIdToValue = new Map(values)
491+
// get a sorted output by import order to make output deterministic
492+
return importedIds
493+
.filter((id) => styleIdToValue.has(id))
494+
.map((id) => styleIdToValue.get(id)!)
495+
},
496+
}
497+
}
498+
442499
/**
443500
* Plugin applied after user plugins
444501
*/
445502
export function cssPostPlugin(config: ResolvedConfig): Plugin {
446503
// styles initialization in buildStart causes a styling loss in watch
447-
const styles: Map<string, string> = new Map<string, string>()
504+
const styles = createStyleContentMap()
448505
// queue to emit css serially to guarantee the files are emitted in a deterministic order
449506
let codeSplitEmitQueue = createSerialPromiseQueue<string>()
450507
const urlEmitQueue = createSerialPromiseQueue<unknown>()
@@ -588,9 +645,15 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
588645

589646
// build CSS handling ----------------------------------------------------
590647

648+
const cssScopeTo = (
649+
this.getModuleInfo(id)?.meta?.vite as
650+
| CustomPluginOptionsVite
651+
| undefined
652+
)?.cssScopeTo
653+
591654
// record css
592655
if (!inlined) {
593-
styles.set(id, css)
656+
styles.putContent(id, css, cssScopeTo)
594657
}
595658

596659
let code: string
@@ -612,7 +675,8 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
612675
map: { mappings: '' },
613676
// avoid the css module from being tree-shaken so that we can retrieve
614677
// it in renderChunk()
615-
moduleSideEffects: modulesCode || inlined ? false : 'no-treeshake',
678+
moduleSideEffects:
679+
modulesCode || inlined || cssScopeTo ? false : 'no-treeshake',
616680
}
617681
},
618682

@@ -623,15 +687,28 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
623687
let isPureCssChunk = chunk.exports.length === 0
624688
const ids = Object.keys(chunk.modules)
625689
for (const id of ids) {
626-
if (styles.has(id)) {
690+
if (styles.hasContentOfNonScoped(id)) {
627691
// ?transform-only is used for ?url and shouldn't be included in normal CSS chunks
628692
if (!transformOnlyRE.test(id)) {
629-
chunkCSS += styles.get(id)
693+
chunkCSS += styles.getContentOfNonScoped(id)
630694
// a css module contains JS, so it makes this not a pure css chunk
631695
if (cssModuleRE.test(id)) {
632696
isPureCssChunk = false
633697
}
634698
}
699+
} else if (styles.hasContentsScopedTo(id)) {
700+
const renderedExports = chunk.modules[id]!.renderedExports
701+
const importedIds = this.getModuleInfo(id)?.importedIds ?? []
702+
// If this module has scoped styles, check for the rendered exports
703+
// and include the corresponding CSS.
704+
for (const { exp, content } of styles.getContentsScopedTo(
705+
id,
706+
importedIds,
707+
)) {
708+
if (exp === undefined || renderedExports.includes(exp)) {
709+
chunkCSS += content
710+
}
711+
}
635712
} else if (!isJsChunkEmpty) {
636713
// if the module does not have a style, then it's not a pure css chunk.
637714
// this is true because in the `transform` hook above, only modules
@@ -726,13 +803,13 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
726803
path.basename(originalFileName),
727804
'.css',
728805
)
729-
if (!styles.has(id)) {
806+
if (!styles.hasContentOfNonScoped(id)) {
730807
throw new Error(
731808
`css content for ${JSON.stringify(id)} was not found`,
732809
)
733810
}
734811

735-
let cssContent = styles.get(id)!
812+
let cssContent = styles.getContentOfNonScoped(id)!
736813

737814
cssContent = resolveAssetUrlsInCss(cssContent, cssAssetName)
738815

playground/css/__tests__/css.spec.ts

+20
Original file line numberDiff line numberDiff line change
@@ -499,3 +499,23 @@ test.runIf(isBuild)('CSS modules should be treeshaken if not used', () => {
499499
const css = findAssetFile(/\.css$/, undefined, undefined, true)
500500
expect(css).not.toContain('treeshake-module-b')
501501
})
502+
503+
test.runIf(isBuild)('Scoped CSS via cssScopeTo should be treeshaken', () => {
504+
const css = findAssetFile(/\.css$/, undefined, undefined, true)
505+
expect(css).not.toContain('treeshake-module-b')
506+
expect(css).not.toContain('treeshake-module-c')
507+
})
508+
509+
test.runIf(isBuild)(
510+
'Scoped CSS via cssScopeTo should be bundled separately',
511+
() => {
512+
const scopedIndexCss = findAssetFile(/treeshakeScoped-[-\w]{8}\.css$/)
513+
expect(scopedIndexCss).toContain('treeshake-scoped-barrel-a')
514+
expect(scopedIndexCss).not.toContain('treeshake-scoped-barrel-b')
515+
const scopedAnotherCss = findAssetFile(
516+
/treeshakeScopedAnother-[-\w]{8}\.css$/,
517+
)
518+
expect(scopedAnotherCss).toContain('treeshake-scoped-barrel-b')
519+
expect(scopedAnotherCss).not.toContain('treeshake-scoped-barrel-a')
520+
},
521+
)

playground/css/index.html

+2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ <h1>CSS</h1>
1818
<pre class="imported-css-glob"></pre>
1919
<pre class="imported-css-globEager"></pre>
2020

21+
<p class="scoped">Imported scoped CSS</p>
22+
2123
<p class="postcss">
2224
<span class="nesting">PostCSS nesting plugin: this should be pink</span>
2325
</p>

playground/css/main.js

+3
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ appendLinkStylesheet(urlCss)
1414
import rawCss from './raw-imported.css?raw'
1515
text('.raw-imported-css', rawCss)
1616

17+
import { cUsed, a as treeshakeScopedA } from './treeshake-scoped/index.js'
18+
document.querySelector('.scoped').classList.add(treeshakeScopedA(), cUsed())
19+
1720
import mod from './mod.module.css'
1821
document.querySelector('.modules').classList.add(mod['apply-color'])
1922
text('.modules-code', JSON.stringify(mod, null, 2))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.treeshake-scoped-a {
2+
color: red;
3+
}

playground/css/treeshake-scoped/a.js

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import './a-scoped.css' // should be treeshaken away if `a` is not used
2+
3+
export default function a() {
4+
return 'treeshake-scoped-a'
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<h1>treeshake-scoped (another)</h1>
2+
<p class="scoped-another">Imported scoped CSS</p>
3+
4+
<script type="module">
5+
import { b } from './barrel/index.js'
6+
document.querySelector('.scoped-another').classList.add(b())
7+
</script>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.treeshake-scoped-b {
2+
color: red;
3+
}

playground/css/treeshake-scoped/b.js

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import './b-scoped.css' // should be treeshaken away if `b` is not used
2+
3+
export default function b() {
4+
return 'treeshake-scoped-b'
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.treeshake-scoped-barrel-a {
2+
text-decoration-line: underline;
3+
text-decoration-color: red;
4+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import './a-scoped.css'
2+
3+
export function a() {
4+
return 'treeshake-scoped-barrel-a'
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.treeshake-scoped-barrel-b {
2+
text-decoration-line: underline;
3+
text-decoration-color: red;
4+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import './b-scoped.css'
2+
3+
export function b() {
4+
return 'treeshake-scoped-barrel-b'
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './a'
2+
export * from './b'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.treeshake-scoped-c {
2+
color: red;
3+
}

playground/css/treeshake-scoped/c.js

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import './c-scoped.css' // should be treeshaken away if `b` is not used
2+
3+
export default function c() {
4+
return 'treeshake-scoped-c'
5+
}
6+
7+
export function cUsed() {
8+
// used but does not depend on scoped css
9+
return 'c-used'
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.treeshake-scoped-d {
2+
color: red;
3+
}

playground/css/treeshake-scoped/d.js

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import './d-scoped.css' // should be treeshaken away if `d` is not used
2+
3+
export default function d() {
4+
return 'treeshake-scoped-d'
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<h1>treeshake-scoped</h1>
2+
<p class="scoped-index">Imported scoped CSS</p>
3+
4+
<script type="module">
5+
import { d } from './index.js'
6+
import { a } from './barrel/index.js'
7+
document.querySelector('.scoped-index').classList.add(d(), a())
8+
</script>
+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { default as a } from './a.js'
2+
export { default as b } from './b.js'
3+
export { default as c, cUsed } from './c.js'
4+
export { default as d } from './d.js'

playground/css/vite.config.js

+37
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,46 @@ globalThis.window = {}
1111
globalThis.location = new URL('http://localhost/')
1212

1313
export default defineConfig({
14+
plugins: [
15+
{
16+
// Emulate a UI framework component where a framework module would import
17+
// scoped CSS files that should treeshake if the default export is not used.
18+
name: 'treeshake-scoped-css',
19+
enforce: 'pre',
20+
async resolveId(id, importer) {
21+
if (!importer || !id.endsWith('-scoped.css')) return
22+
23+
const resolved = await this.resolve(id, importer)
24+
if (!resolved) return
25+
26+
return {
27+
...resolved,
28+
meta: {
29+
vite: {
30+
cssScopeTo: [
31+
importer,
32+
resolved.id.includes('barrel') ? undefined : 'default',
33+
],
34+
},
35+
},
36+
}
37+
},
38+
},
39+
],
1440
build: {
1541
cssTarget: 'chrome61',
1642
rollupOptions: {
43+
input: {
44+
index: path.resolve(__dirname, './index.html'),
45+
treeshakeScoped: path.resolve(
46+
__dirname,
47+
'./treeshake-scoped/index.html',
48+
),
49+
treeshakeScopedAnother: path.resolve(
50+
__dirname,
51+
'./treeshake-scoped/another.html',
52+
),
53+
},
1754
output: {
1855
manualChunks(id) {
1956
if (id.includes('manual-chunk.css')) {

0 commit comments

Comments
 (0)