diff --git a/.changeset/fresh-tools-refuse.md b/.changeset/fresh-tools-refuse.md new file mode 100644 index 000000000..c539ba79b --- /dev/null +++ b/.changeset/fresh-tools-refuse.md @@ -0,0 +1,5 @@ +--- +'@compiled/webpack-loader': minor +--- + +**BREAKING** Compiled now takes control of the entire CSS pipeline. This means any CSS declared inside or outside of Compiled will be sorted and potentially hoisted if unsafe. diff --git a/.changeset/large-pans-retire.md b/.changeset/large-pans-retire.md new file mode 100644 index 000000000..66eeec127 --- /dev/null +++ b/.changeset/large-pans-retire.md @@ -0,0 +1,5 @@ +--- +'@compiled/css': patch +--- + +Sort now can remove unstable atomic rules with the `removeUnstableRules` option set to `true`. diff --git a/.changeset/silent-planets-invent.md b/.changeset/silent-planets-invent.md new file mode 100644 index 000000000..1ffaba55c --- /dev/null +++ b/.changeset/silent-planets-invent.md @@ -0,0 +1,5 @@ +--- +'@compiled/webpack-loader': patch +--- + +Compiled now supports async chunked CSS. When components are code split and have unique styles only used in that chunk, its styles will be in their own style sheet. diff --git a/.changeset/tame-trains-draw.md b/.changeset/tame-trains-draw.md new file mode 100644 index 000000000..daaabe64d --- /dev/null +++ b/.changeset/tame-trains-draw.md @@ -0,0 +1,5 @@ +--- +'@compiled/css': minor +--- + +**BREAKING** Sort now returns an object with `css` and any found `unstableRules` when the `removeUnstableRules` option is `true`. diff --git a/packages/css/src/plugins/remove-unstable-rules.tsx b/packages/css/src/plugins/remove-unstable-rules.tsx new file mode 100644 index 000000000..86de2b5c6 --- /dev/null +++ b/packages/css/src/plugins/remove-unstable-rules.tsx @@ -0,0 +1,24 @@ +import { plugin } from 'postcss'; + +/** + * Removes unstable atomic rules from the style sheet. + */ +export const removeUnstableRules = plugin<{ callback: (sheet: string) => void }>( + 'remove-unstable-rules', + (opts) => { + return (root) => { + root.each((node) => { + switch (node.type) { + case 'rule': + if (node.selector.includes(':')) { + opts?.callback(node.toString()); + node.remove(); + } + + default: + break; + } + }); + }; + } +); diff --git a/packages/css/src/sort.tsx b/packages/css/src/sort.tsx index 17123d29e..cb7343a69 100644 --- a/packages/css/src/sort.tsx +++ b/packages/css/src/sort.tsx @@ -1,6 +1,12 @@ import postcss from 'postcss'; +import { toBoolean } from '@compiled/utils'; import { sortAtomicStyleSheet } from './plugins/sort-atomic-style-sheet'; import { mergeDuplicateAtRules } from './plugins/merge-duplicate-at-rules'; +import { removeUnstableRules } from './plugins/remove-unstable-rules'; + +interface SortOpts { + removeUnstableRules?: boolean; +} /** * Sorts an atomic style sheet. @@ -8,10 +14,21 @@ import { mergeDuplicateAtRules } from './plugins/merge-duplicate-at-rules'; * @param stylesheet * @returns */ -export function sort(stylesheet: string): string { - const result = postcss([mergeDuplicateAtRules(), sortAtomicStyleSheet()]).process(stylesheet, { +export function sort( + stylesheet: string, + opts: SortOpts = { removeUnstableRules: false } +): { css: string; unstableRules: string[] } { + const unstableRules: string[] = []; + const result = postcss( + [ + opts.removeUnstableRules && + removeUnstableRules({ callback: (sheet) => unstableRules.push(sheet) }), + mergeDuplicateAtRules(), + sortAtomicStyleSheet(), + ].filter(toBoolean) + ).process(stylesheet, { from: undefined, }); - return result.css; + return { css: result.css, unstableRules }; } diff --git a/packages/webpack-loader/src/__tests__/extract-plugin.test.tsx b/packages/webpack-loader/src/__tests__/extract-plugin.test.tsx index b24ee0ee0..d99bd4e71 100644 --- a/packages/webpack-loader/src/__tests__/extract-plugin.test.tsx +++ b/packages/webpack-loader/src/__tests__/extract-plugin.test.tsx @@ -1,50 +1,76 @@ import { bundle } from './utils/webpack'; describe('CompiledExtractPlugin', () => { - const assetName = 'static/compiled-css.css'; + const getCSSAssets = (assets: Record) => { + return Object.keys(assets) + .filter((name) => name.endsWith('.css')) + .reduce( + (acc, name) => + Object.assign(acc, { + [name]: assets[name], + }), + {} + ); + }; it('should extract styles from a single file into a style sheet', async () => { const actual = await bundle(require.resolve('./fixtures/single.js')); - expect(actual[assetName]).toMatchInlineSnapshot(` - "._1wyb1fwx{font-size:12px} - " + expect(getCSSAssets(actual)).toMatchInlineSnapshot(` + Object { + "static/main.css": "._1wyb1fwx{font-size:12px} + ", + } `); }); it('should extract styles from multiple files into a style sheet', async () => { const actual = await bundle(require.resolve('./fixtures/multiple.js')); - expect(actual[assetName]).toMatchInlineSnapshot(` - " + expect(getCSSAssets(actual)).toMatchInlineSnapshot(` + Object { + "static/main.css": " ._syaz5scu{color:red} ._syazmu8g{color:blueviolet} ._19itgh5a{border:2px solid orange} - ._syazruxl{color:orange} - ._f8pjruxl:focus{color:orange} - ._f8pj1cnh:focus{color:purple}._30l31gy6:hover{color:yellow} + ._syazruxl{color:orange}._f8pjruxl:focus{color:orange} + ._f8pj1cnh:focus{color:purple} + ._30l31gy6:hover{color:yellow} ._30l313q2:hover{color:blue} - " + @media screen{._43475scu{color:red}} + ", + } `); }); - it('should extract styles from an async chunk', async () => { + it('should chunk safe style declarations', async () => { const actual = await bundle(require.resolve('./fixtures/async.js')); - // Only generate one CSS bundle - expect(Object.keys(actual)).toMatchInlineSnapshot(` - Array [ - "bundle.js", - "298.bundle.js", - "static/compiled-css.css", - "298.bundle.js.LICENSE.txt", - ] - `); // Extract the styles into said bundle - expect(actual[assetName]).toMatchInlineSnapshot(` - "._19itgh5a{border:2px solid orange} + expect(getCSSAssets(actual)).toMatchInlineSnapshot(` + Object { + "static/696.css": "._19itgh5a{border:2px solid orange} + ._syazruxl{color:orange} + ", + "static/main.css": "._syazmu8g{color:blueviolet} + ", + } + `); + }); + + it('should hoist and sort chunked style declaration', async () => { + const actual = await bundle(require.resolve('./fixtures/async-sort.js')); + + expect(getCSSAssets(actual)).toMatchInlineSnapshot(` + Object { + "static/569.css": "._syaz5scu{color:red} + ._19itgh5a{border:2px solid orange} ._syazruxl{color:orange} - " + @media screen{._43475scu{color:red}} + ", + "static/main.css": "._syazmu8g{color:blueviolet} + ._f8pjruxl:focus{color:orange}._f8pj1cnh:focus{color:purple}._30l31gy6:hover{color:yellow}._30l313q2:hover{color:blue}", + } `); }); @@ -61,8 +87,9 @@ describe('CompiledExtractPlugin', () => { it('should extract from a pre-built babel files', async () => { const actual = await bundle(require.resolve('./fixtures/babel.js')); - expect(actual[assetName]).toMatchInlineSnapshot(` - "._19pk1ul9{margin-top:30px} + expect(getCSSAssets(actual)).toMatchInlineSnapshot(` + Object { + "static/main.css": "._19pk1ul9{margin-top:30px} ._19bvftgi{padding-left:8px} ._n3tdftgi{padding-bottom:8px} ._u5f3ftgi{padding-right:8px} @@ -70,15 +97,17 @@ describe('CompiledExtractPlugin', () => { ._19itlf8h{border:2px solid blue} ._1wyb1ul9{font-size:30px} ._syaz13q2{color:blue} - " + ", + } `); }); it('should find bindings', async () => { const actual = await bundle(require.resolve('./fixtures/binding-not-found.tsx')); - expect(actual[assetName]).toMatchInlineSnapshot(` - "._syaz1r31{color:currentColor} + expect(getCSSAssets(actual)).toMatchInlineSnapshot(` + Object { + "static/main.css": "._syaz1r31{color:currentColor} ._ajmmnqa1{-webkit-text-decoration-style:solid;text-decoration-style:solid} ._1hmsglyw{-webkit-text-decoration-line:none;text-decoration-line:none} ._4bfu1r31{-webkit-text-decoration-color:currentColor;text-decoration-color:currentColor} @@ -101,17 +130,20 @@ describe('CompiledExtractPlugin', () => { ._4cvr1h6o{align-items:center} ._1e0c1txw{display:flex} ._4t3i1jdh{height:9rem} - " + ", + } `); }); it('should extract important', async () => { const actual = await bundle(require.resolve('./fixtures/important-styles.js')); - expect(actual[assetName]).toMatchInlineSnapshot(` - "._syaz13q2{color:blue} + expect(getCSSAssets(actual)).toMatchInlineSnapshot(` + Object { + "static/main.css": "._syaz13q2{color:blue} ._1wybc038{font-size:12!important} - " + ", + } `); }); }); diff --git a/packages/webpack-loader/src/__tests__/fixtures/async-sort.js b/packages/webpack-loader/src/__tests__/fixtures/async-sort.js new file mode 100644 index 000000000..80f6673d2 --- /dev/null +++ b/packages/webpack-loader/src/__tests__/fixtures/async-sort.js @@ -0,0 +1,8 @@ +import '@compiled/react'; +import { blueviolet } from './imports/colors'; + +import('./multiple'); + +const Component = () =>
; + +export default Component; diff --git a/packages/webpack-loader/src/__tests__/fixtures/async.js b/packages/webpack-loader/src/__tests__/fixtures/async.js index 5143d3197..2a4fcdc2f 100644 --- a/packages/webpack-loader/src/__tests__/fixtures/async.js +++ b/packages/webpack-loader/src/__tests__/fixtures/async.js @@ -1 +1,8 @@ +import '@compiled/react'; +import { blueviolet } from './imports/colors'; + +const Component = () =>
; + import('./imports/css-prop'); + +export default Component; diff --git a/packages/webpack-loader/src/__tests__/fixtures/multiple.js b/packages/webpack-loader/src/__tests__/fixtures/multiple.js index ddfc328d6..25027ad2e 100644 --- a/packages/webpack-loader/src/__tests__/fixtures/multiple.js +++ b/packages/webpack-loader/src/__tests__/fixtures/multiple.js @@ -4,6 +4,10 @@ import { blueviolet, blue, orange, purple, red, yellow } from './imports/colors' import { Orange } from './imports/css-prop'; export const Blue = styled.span` + @media screen { + color: red; + } + color: ${blueviolet}; :focus { @@ -18,11 +22,11 @@ export const Blue = styled.span` export const Red = styled.span` color: ${red}; - :focus { - color: ${orange}; - } - :hover { color: ${yellow}; } + + :focus { + color: ${orange}; + } `; diff --git a/packages/webpack-loader/src/__tests__/utils/webpack.tsx b/packages/webpack-loader/src/__tests__/utils/webpack.tsx index e3ab64f62..de7d646f8 100644 --- a/packages/webpack-loader/src/__tests__/utils/webpack.tsx +++ b/packages/webpack-loader/src/__tests__/utils/webpack.tsx @@ -37,7 +37,7 @@ export function bundle( }, }, { - loader: '@compiled/webpack-loader', + loader: require.resolve('../../compiled-loader'), options: { importReact: false, extract, diff --git a/packages/webpack-loader/src/extract-plugin.tsx b/packages/webpack-loader/src/extract-plugin.tsx index 9449a6242..aa4e56754 100644 --- a/packages/webpack-loader/src/extract-plugin.tsx +++ b/packages/webpack-loader/src/extract-plugin.tsx @@ -1,7 +1,7 @@ import { sort } from '@compiled/css'; import { toBoolean, createError } from '@compiled/utils'; import type { Compiler, Compilation } from 'webpack'; -import type { CompiledExtractPluginOptions } from './types'; +import type { CompiledExtractPluginOptions, LoaderOpts } from './types'; import { getAssetSourceContents, getNormalModuleHook, @@ -13,56 +13,18 @@ export const pluginName = 'CompiledExtractPlugin'; export const styleSheetName = 'compiled-css'; /** - * Returns CSS Assets that we're interested in. + * Returns CSS Assets from the current compilation. * - * @param options * @param assets - * @returns */ const getCSSAssets = (assets: Compilation['assets']) => { return Object.keys(assets) .filter((assetName) => { - return assetName.endsWith(`${styleSheetName}.css`); + return assetName.endsWith('.css'); }) .map((assetName) => ({ name: assetName, source: assets[assetName], info: {} })); }; -/** - * Set a cache group to force all CompiledCSS found to be in a single style sheet. - * We do this to simplify the sorting story for now. Later on we can investigate - * hoisting only unstable styles into the parent style sheet from async chunks. - * - * @param compiler - */ -const forceCSSIntoOneStyleSheet = (compiler: Compiler) => { - const cacheGroup = { - compiledCSS: { - name: styleSheetName, - type: 'css/mini-extract', - chunks: 'all', - // We merge only CSS from Compiled. - test: /css-loader\/extract\.css$/, - enforce: true, - }, - }; - - if (!compiler.options.optimization) { - compiler.options.optimization = {}; - } - - if (!compiler.options.optimization.splitChunks) { - compiler.options.optimization.splitChunks = { - cacheGroups: {}, - }; - } - - if (!compiler.options.optimization.splitChunks.cacheGroups) { - compiler.options.optimization.splitChunks.cacheGroups = {}; - } - - Object.assign(compiler.options.optimization.splitChunks.cacheGroups, cacheGroup); -}; - /** * Pushes a new loader onto the compiler. * The loader will be applied to all JS files found in node modules that import `@compiled/react`. @@ -108,26 +70,36 @@ export class CompiledExtractPlugin { const { RawSource } = getSources(compiler); pushNodeModulesExtractLoader(compiler, this.#options); - forceCSSIntoOneStyleSheet(compiler); compiler.hooks.compilation.tap(pluginName, (compilation) => { - getNormalModuleHook(compiler, compilation).tap(pluginName, (loaderContext) => { + getNormalModuleHook(compiler, compilation).tap(pluginName, (loaderContext: LoaderOpts) => { // We add some information here to tell loaders that the plugin has been configured. // Bundling will throw if this is missing (i.e. consumers did not setup correctly). - (loaderContext as any)[pluginName] = true; + loaderContext[pluginName] = true; }); getOptimizeAssetsHook(compiler, compilation).tap(pluginName, (assets) => { - const cssAssets = getCSSAssets(assets); - if (cssAssets.length === 0) { + const CSSAssets = getCSSAssets(assets); + if (CSSAssets.length === 0) { return; } - const [asset] = cssAssets; - const contents = getAssetSourceContents(asset.source); - const newSource = new RawSource(sort(contents)); + const hoistedStyles: string[] = []; + const [entry, ...chunks] = CSSAssets; + + chunks.forEach((asset) => { + const contents = getAssetSourceContents(asset.source); + const { css, unstableRules } = sort(contents, { removeUnstableRules: true }); + const newSource = new RawSource(css); + + hoistedStyles.push(...unstableRules); + compilation.updateAsset(asset.name, newSource, asset.info); + }); - compilation.updateAsset(asset.name, newSource, asset.info); + const contents = getAssetSourceContents(entry.source); + const { css } = sort(contents + hoistedStyles.join('')); + const newSource = new RawSource(css); + compilation.updateAsset(entry.name, newSource, entry.info); }); }); } diff --git a/packages/webpack-loader/src/types.tsx b/packages/webpack-loader/src/types.tsx index dad8afba2..bec0962ba 100644 --- a/packages/webpack-loader/src/types.tsx +++ b/packages/webpack-loader/src/types.tsx @@ -27,7 +27,14 @@ export interface CompiledLoaderOptions { nonce?: string; } -export interface LoaderThis { +export interface LoaderOpts { + /** + * When set confirms that the extract plugin has been configured. + */ + [pluginName]?: true; +} + +export interface LoaderThis extends LoaderOpts { /** * Query param passed to the loader. * @@ -81,11 +88,6 @@ export interface LoaderThis { * @param error */ emitError(error: Error): void; - - /** - * When set confirms that the extract plugin has been configured. - */ - [pluginName]?: true; } export interface CompiledExtractPluginOptions {