Skip to content

Commit

Permalink
feat(css): allow scoping css to importers exports (#19418)
Browse files Browse the repository at this point in the history
Co-authored-by: bluwy <[email protected]>
  • Loading branch information
sapphi-red and bluwy authored Feb 21, 2025
1 parent f6926ca commit 3ebd838
Show file tree
Hide file tree
Showing 22 changed files with 241 additions and 8 deletions.
18 changes: 18 additions & 0 deletions packages/vite/src/node/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,24 @@ export interface Plugin<A = any> extends RollupPlugin<A> {
>
}

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> = T extends ObjectHook<infer H> ? H : T

export type PluginWithRequiredHook<K extends keyof Plugin> = Plugin & {
Expand Down
93 changes: 85 additions & 8 deletions packages/vite/src/node/plugins/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -439,12 +439,69 @@ export function cssPlugin(config: ResolvedConfig): 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: Map<string, string> = new Map<string, string>()
const styles = createStyleContentMap()
// 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 @@ -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
Expand All @@ -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',
}
},

Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down
20 changes: 20 additions & 0 deletions playground/css/__tests__/css.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
},
)
2 changes: 2 additions & 0 deletions playground/css/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ <h1>CSS</h1>
<pre class="imported-css-glob"></pre>
<pre class="imported-css-globEager"></pre>

<p class="scoped">Imported scoped CSS</p>

<p class="postcss">
<span class="nesting">PostCSS nesting plugin: this should be pink</span>
</p>
Expand Down
3 changes: 3 additions & 0 deletions playground/css/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
3 changes: 3 additions & 0 deletions playground/css/treeshake-scoped/a-scoped.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.treeshake-scoped-a {
color: red;
}
5 changes: 5 additions & 0 deletions playground/css/treeshake-scoped/a.js
Original file line number Diff line number Diff line change
@@ -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'
}
7 changes: 7 additions & 0 deletions playground/css/treeshake-scoped/another.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<h1>treeshake-scoped (another)</h1>
<p class="scoped-another">Imported scoped CSS</p>

<script type="module">
import { b } from './barrel/index.js'
document.querySelector('.scoped-another').classList.add(b())
</script>
3 changes: 3 additions & 0 deletions playground/css/treeshake-scoped/b-scoped.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.treeshake-scoped-b {
color: red;
}
5 changes: 5 additions & 0 deletions playground/css/treeshake-scoped/b.js
Original file line number Diff line number Diff line change
@@ -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'
}
4 changes: 4 additions & 0 deletions playground/css/treeshake-scoped/barrel/a-scoped.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.treeshake-scoped-barrel-a {
text-decoration-line: underline;
text-decoration-color: red;
}
5 changes: 5 additions & 0 deletions playground/css/treeshake-scoped/barrel/a.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import './a-scoped.css'

export function a() {
return 'treeshake-scoped-barrel-a'
}
4 changes: 4 additions & 0 deletions playground/css/treeshake-scoped/barrel/b-scoped.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.treeshake-scoped-barrel-b {
text-decoration-line: underline;
text-decoration-color: red;
}
5 changes: 5 additions & 0 deletions playground/css/treeshake-scoped/barrel/b.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import './b-scoped.css'

export function b() {
return 'treeshake-scoped-barrel-b'
}
2 changes: 2 additions & 0 deletions playground/css/treeshake-scoped/barrel/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './a'
export * from './b'
3 changes: 3 additions & 0 deletions playground/css/treeshake-scoped/c-scoped.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.treeshake-scoped-c {
color: red;
}
10 changes: 10 additions & 0 deletions playground/css/treeshake-scoped/c.js
Original file line number Diff line number Diff line change
@@ -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'
}
3 changes: 3 additions & 0 deletions playground/css/treeshake-scoped/d-scoped.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.treeshake-scoped-d {
color: red;
}
5 changes: 5 additions & 0 deletions playground/css/treeshake-scoped/d.js
Original file line number Diff line number Diff line change
@@ -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'
}
8 changes: 8 additions & 0 deletions playground/css/treeshake-scoped/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<h1>treeshake-scoped</h1>
<p class="scoped-index">Imported scoped CSS</p>

<script type="module">
import { d } from './index.js'
import { a } from './barrel/index.js'
document.querySelector('.scoped-index').classList.add(d(), a())
</script>
4 changes: 4 additions & 0 deletions playground/css/treeshake-scoped/index.js
Original file line number Diff line number Diff line change
@@ -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'
37 changes: 37 additions & 0 deletions playground/css/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down

0 comments on commit 3ebd838

Please sign in to comment.