Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(css): allow scoping css to importers exports #16058

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
45 changes: 38 additions & 7 deletions packages/vite/src/node/plugins/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1029,6 +1042,24 @@ export function getEmptyChunkReplacer(
)
}

function isCssScopeToRendered(
cssScopeTo: Record<string, string[]>,
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
Expand Down
4 changes: 2 additions & 2 deletions packages/vite/src/node/plugins/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
}
}

Expand Down
25 changes: 25 additions & 0 deletions packages/vite/types/metadata.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]>
}
}
}
6 changes: 6 additions & 0 deletions playground/css/__tests__/css.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
2 changes: 2 additions & 0 deletions playground/css/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,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 @@ -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))
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'
}
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'
}
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'
}
7 changes: 7 additions & 0 deletions playground/css/treeshake-scoped/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<h1>treeshake-scoped</h1>
<p class="scoped-another">Imported scoped CSS</p>

<script type="module">
import { d } from './index.js'
document.querySelector('.scoped-another').classList.add(d())
</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'
32 changes: 32 additions & 0 deletions playground/css/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,41 @@ 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: {
input: {
index: path.resolve(__dirname, './index.html'),
treeshakeScoped: path.resolve(
__dirname,
'./treeshake-scoped/index.html',
),
},
output: {
manualChunks(id) {
if (id.includes('manual-chunk.css')) {
Expand Down
20 changes: 16 additions & 4 deletions playground/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand All @@ -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 {
Expand Down