Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved it here because it seemed CustomPluginOptions was changed to a type from an interface.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this might probably be unintentional from Rollup side due to rollup/rollup#5591 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made a PR here 👍
rollup/rollup#5850

/**
* 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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added @experimental for now. The plan is to release this in 6.2 and stabilize it in 6.3 or 6.4.

*/
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think RenderedModule.renderedExports isn't implemented yet on rolldown. Should we check with them if this is a simple addition on rolldown side?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, good catch. Let me ask them.

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>
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bluwy This file and the files in the barrel directory covers this case (#16058 (comment)).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading the implementation, I believe you have the scoped CSS side effects set to false to fix this, but keep track of the scoped css styles map separately to bring it back? That's a neat trick. I guess a limitation of this is that it's harder to scope to multiple modules like you mentioned, but I think it's fine for now.

<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