Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
7 changes: 6 additions & 1 deletion packages/vite/src/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
14 changes: 14 additions & 0 deletions packages/vite/src/node/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,20 @@ 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.
*
* 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]
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 changed the interface to the one I felt easier to implement by proposal for now.
@bluwy What was the reason of your interface? I'm wondering the usage of allowing multiple files here.

Copy link
Member

Choose a reason for hiding this comment

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

I don't have a concrete usecase. Was thinking that maybe there's some sort of unocss-like tool that generates scoped css derived from multiple modules somehow that could benefit from this. So I futureproofed it as a Record.

}

export type HookHandler<T> = T extends ObjectHook<infer H> ? H : T

export type PluginWithRequiredHook<K extends keyof Plugin> = Plugin & {
Expand Down
32 changes: 30 additions & 2 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 @@ -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<string, string> = new Map<string, string>()
const scopedStyles = new Map<string, Map<string | undefined, string[]>>()
// 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 @@ -607,12 +608,29 @@ 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',
}
},

Expand All @@ -632,6 +650,16 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
isPureCssChunk = false
}
}
} else if (scopedStyles.has(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.

// 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('')
}
}
} 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
6 changes: 6 additions & 0 deletions playground/css/__tests__/css.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,3 +499,9 @@ 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')
})
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-another">Imported scoped CSS</p>

<script type="module">
import { d } from './index.js'
import { a } from './barrel/index.js'
document.querySelector('.scoped-another').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