-
-
Notifications
You must be signed in to change notification settings - Fork 7.5k
feat(css): allow scoping css to importers exports #19418
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
Changes from 9 commits
a97dde2
83d1dd4
ec1bb0e
371ef79
a4110b8
4cc87b9
c7bbf7e
ac0a5bf
1cc25ca
fd8d75b
3b50c0d
e0a7001
1732664
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -323,6 +323,22 @@ export interface Plugin<A = any> extends RollupPlugin<A> { | |
| > | ||
| } | ||
|
|
||
| export interface CustomPluginOptionsVite { | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I moved it here because it seemed
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 🤔
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Made a PR here 👍 |
||
| /** | ||
| * 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'] | ||
| * ``` | ||
| * | ||
| * @experimental | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added |
||
| */ | ||
| 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 & { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -439,12 +439,61 @@ export function cssPlugin(config: ResolvedConfig): Plugin { | |
| } | ||
| } | ||
|
|
||
| const createStyleContentMap = () => { | ||
| const contents = new Map<string, string>() // css id -> css content | ||
| const scopedIds = new Set<string>() // whether that id of css is scoped | ||
sapphi-red marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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) { | ||
| const rels = [...(relations.get(id) ?? [])] | ||
| // sort to get a deterministic output | ||
| rels.sort((a, b) => (a.id > b.id ? 1 : -1)) | ||
bluwy marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| return rels.map(({ id, exp }) => ({ | ||
| content: contents.get(id) ?? '', | ||
| exp, | ||
| })) | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 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>() | ||
|
|
@@ -588,9 +637,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 | ||
|
|
@@ -612,7 +667,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', | ||
| } | ||
| }, | ||
|
|
||
|
|
@@ -623,15 +679,24 @@ 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
sapphi-red marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| for (const { exp, content } of styles.getContentsScopedTo(id)) { | ||
| 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 | ||
|
|
@@ -726,13 +791,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) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| .treeshake-scoped-a { | ||
| color: red; | ||
| } |
| 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' | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| <h1>treeshake-scoped (another)</h1> | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)).
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| .treeshake-scoped-b { | ||
| color: red; | ||
| } |
| 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' | ||
| } |
| 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; | ||
| } |
| 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' | ||
| } |
| 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; | ||
| } |
| 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' | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export * from './a' | ||
| export * from './b' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| .treeshake-scoped-c { | ||
| color: red; | ||
| } |
| 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' | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| .treeshake-scoped-d { | ||
| color: red; | ||
| } |
| 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' | ||
| } |
| 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> |
| 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' |
Uh oh!
There was an error while loading. Please reload this page.