From f9c957ef72ee42ad1e163d2d17686a2f55bc259a Mon Sep 17 00:00:00 2001 From: Liam Ma Date: Mon, 20 Feb 2023 14:01:44 +1100 Subject: [PATCH] Compress atomic class names (#1408) * Update 'ax' to accept short class name * Update 'ax' flow type and up the size limit * Add benchmark test to test ax(compressed class names) * Change the format of compress class names * Restore Flow type * Compress class names * Update snapshot * chore(deps): update dependency @types/node to ^18.11.19 (#1407) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Turn off compressing class names if stylesheet extraction is off * parcel integration * Add changeset * Replace rather than insert class name in the sheet and make ClassNames to support conditional CSS * add 'generateCompressionMap' * Update changelog * Fix spelling mistake * Allow uppercase in class-name-generator * chore(deps): update dependency css-what to >=5.1.0 (#1409) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * chore(deps): update webpack packages (#1411) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Fix transparent and currentcolor not being treated as a color (#1412) * chore(deps): update dependency css-what to >=6.1.0 (#1414) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Version Packages (#1413) Co-authored-by: github-actions[bot] * chore(deps): update dependency nth-check to >=2.1.1 (#1415) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * chore(deps): update dependency prettier to ^2.8.4 (#1416) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * chore(deps): update dependency @types/node to ^18.13.0 (#1417) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * Bump node version to v18 (#1392) * Bump node version to v18 * Bump import jsx * Bump nvmrc node version from 18.12 to 18.14 * Add changeset for PR #1392 --------- Co-authored-by: Grant Wong * chore(deps): update parcel packages (#1390) * chore(deps): update parcel packages * Add dummy generic type so optimizer works with parcel v2.8.0+ Parcel v2.8.0 adds a BundleConfigType generic type to Optimizer (https://github.com/parcel-bundler/parcel/pull/8370) as part of their `loadBundleConfig` method. We use a dummy type because we don't need this functionality currently. * Update snapshot tests for Parcel 2.8.3 --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Grant Wong * chore(deps): update eslint packages (#1420) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * chore(deps): update dependency jest to v29 (#1384) * chore(deps): update dependency jest to v29 * Update snapshots and node env for some tests --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Jake Lane * Add Grant Wong as codeowner (#1421) Co-authored-by: Grant Wong <2908767+dddlr@users.noreply.github.com> * Replace rather than insert class name in the sheet and make ClassNames to support conditional CSS * Add prefix option and avoid 'ad' * Add comment to ax benchmark * Make classNameCompressionMap a separate file in parcel example app * Remove default reservedClassNames * Export generateCompressionMap * Update comment --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Jake Lane Co-authored-by: atlas-dst-bot <81662413+atlas-dst-bot@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: Grant Wong Co-authored-by: Grant Wong <2908767+dddlr@users.noreply.github.com> --- .changeset/grumpy-cows-look.md | 10 + .eslintrc.js | 1 + examples/parcel/.compiledcssrc | 24 -- .../parcel/class-name-compression-map.json | 18 ++ examples/parcel/compiledcss.js | 18 ++ .../webpack/class-name-compression-map.json | 27 +++ examples/webpack/webpack.config.js | 3 + .../babel-plugin/src/__tests__/index.test.ts | 156 ++++++++++++ .../src/__tests__/jsx-automatic.test.ts | 2 +- .../class-names/__tests__/behaviour.test.ts | 101 ++++++-- .../tagged-template-expression.test.ts | 2 +- .../babel-plugin/src/class-names/index.ts | 11 +- packages/babel-plugin/src/types.ts | 8 + .../src/utils/build-styled-component.ts | 10 +- .../src/utils/compress-class-names-for-ax.ts | 16 ++ .../src/utils/transform-css-items.ts | 13 +- .../generate-compression-map.test.ts | 224 ++++++++++++++++++ packages/css/src/generate-compression-map.ts | 70 ++++++ packages/css/src/index.ts | 1 + packages/css/src/plugins/atomicify-rules.ts | 17 +- packages/css/src/transform.ts | 6 +- .../__tests__/class-name-generator.test.ts | 47 ++++ .../css/src/utils/class-name-generator.ts | 58 +++++ packages/parcel-transformer/src/index.ts | 1 + packages/parcel-transformer/src/types.ts | 8 + .../react/src/runtime/__perf__/ax.test.ts | 56 +++-- .../react/src/runtime/__tests__/ax.test.ts | 141 ++++++----- packages/react/src/runtime/ax.ts | 16 +- .../webpack-loader/src/compiled-loader.ts | 7 + packages/webpack-loader/src/types.ts | 8 + 30 files changed, 933 insertions(+), 147 deletions(-) create mode 100644 .changeset/grumpy-cows-look.md delete mode 100644 examples/parcel/.compiledcssrc create mode 100644 examples/parcel/class-name-compression-map.json create mode 100644 examples/parcel/compiledcss.js create mode 100644 examples/webpack/class-name-compression-map.json create mode 100644 packages/babel-plugin/src/utils/compress-class-names-for-ax.ts create mode 100644 packages/css/src/__tests__/generate-compression-map.test.ts create mode 100644 packages/css/src/generate-compression-map.ts create mode 100644 packages/css/src/utils/__tests__/class-name-generator.test.ts create mode 100644 packages/css/src/utils/class-name-generator.ts diff --git a/.changeset/grumpy-cows-look.md b/.changeset/grumpy-cows-look.md new file mode 100644 index 000000000..c4bb0580e --- /dev/null +++ b/.changeset/grumpy-cows-look.md @@ -0,0 +1,10 @@ +--- +'@compiled/parcel-transformer': minor +'@compiled/webpack-loader': minor +'@compiled/babel-plugin': minor +'@compiled/react': minor +'@compiled/css': minor +--- + +Add an option to compress class names based on "classNameCompressionMap", which is provided by library consumers. +Add a script to generate compressed class names. diff --git a/.eslintrc.js b/.eslintrc.js index 2f84ef972..f5e73914f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -2,6 +2,7 @@ module.exports = { root: true, ignorePatterns: [ 'dist', + 'build', 'flow-typed', '*.d.ts', 'babel-cjs.js', diff --git a/examples/parcel/.compiledcssrc b/examples/parcel/.compiledcssrc deleted file mode 100644 index d49ad0af9..000000000 --- a/examples/parcel/.compiledcssrc +++ /dev/null @@ -1,24 +0,0 @@ -{ - "importReact": false, - "extensions": [ - ".js", - ".jsx", - ".ts", - ".tsx", - ".customjsx" - ], - "parserBabelPlugins": [ - "typescript", - "jsx" - ], - "transformerBabelPlugins": [ - [ - "@babel/plugin-proposal-decorators", - { - "legacy": true - } - ] - ], - "extract": true, - "optimizeCss": false -} diff --git a/examples/parcel/class-name-compression-map.json b/examples/parcel/class-name-compression-map.json new file mode 100644 index 000000000..540145edd --- /dev/null +++ b/examples/parcel/class-name-compression-map.json @@ -0,0 +1,18 @@ +{ + "1wyb12am": "a", + "syaz5scu": "b", + "syazruxl": "c", + "k48pbfng": "d", + "30l35scu": "e", + "f8pj13q2": "f", + "1e0c1o8l": "g", + "ca0qftgi": "h", + "u5f3ftgi": "i", + "n3tdftgi": "j", + "19bvftgi": "k", + "19itak0l": "l", + "2rko1l7b": "m", + "syaz1aj3": "n", + "1p1dangw": "o", + "bfhkbf54": "p" +} diff --git a/examples/parcel/compiledcss.js b/examples/parcel/compiledcss.js new file mode 100644 index 000000000..d4a9209a1 --- /dev/null +++ b/examples/parcel/compiledcss.js @@ -0,0 +1,18 @@ +const classNameCompressionMap = require('./class-name-compression-map.json'); + +module.exports = { + importReact: false, + extensions: ['.js', '.jsx', '.ts', '.tsx', '.customjsx'], + parserBabelPlugins: ['typescript', 'jsx'], + transformerBabelPlugins: [ + [ + '@babel/plugin-proposal-decorators', + { + legacy: true, + }, + ], + ], + extract: true, + optimizeCss: false, + classNameCompressionMap: classNameCompressionMap, +}; diff --git a/examples/webpack/class-name-compression-map.json b/examples/webpack/class-name-compression-map.json new file mode 100644 index 000000000..4cda334b2 --- /dev/null +++ b/examples/webpack/class-name-compression-map.json @@ -0,0 +1,27 @@ +{ + "1wyb12am": "a", + "syaz32ev": "b", + "k48pbfng": "c", + "30l35scu": "d", + "f8pj13q2": "e", + "19itptrx": "f", + "1kt92a4o": "g", + "171dak0l": "h", + "1swkri7e": "i", + "1tjq14ap": "j", + "yzbc5scu": "k", + "19pk1ul9": "l", + "syaz13q2": "m", + "1wyb1ul9": "n", + "19itlf8h": "o", + "ca0q1vi7": "p", + "u5f31vi7": "q", + "n3td1vi7": "r", + "19bv1vi7": "s", + "k48p1fw0": "t", + "syaz1cnh": "u", + "19it1srw": "v", + "bfhk1j28": "w", + "syazruxl": "x", + "bfhkbf54": "y" +} diff --git a/examples/webpack/webpack.config.js b/examples/webpack/webpack.config.js index 1b01af03f..9e8edef48 100644 --- a/examples/webpack/webpack.config.js +++ b/examples/webpack/webpack.config.js @@ -7,6 +7,8 @@ const HtmlWebpackPlugin = require('html-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const webpack = require('webpack'); +const classNameCompressionMap = require('./class-name-compression-map.json'); + const extractCSS = process.env.EXTRACT_TO_CSS === 'true'; module.exports = { @@ -40,6 +42,7 @@ module.exports = { parserBabelPlugins: ['typescript', 'jsx'], transformerBabelPlugins: [['@babel/plugin-proposal-decorators', { legacy: true }]], optimizeCss: false, + classNameCompressionMap, }, }, ], diff --git a/packages/babel-plugin/src/__tests__/index.test.ts b/packages/babel-plugin/src/__tests__/index.test.ts index 9a594baa1..e0878dbf5 100644 --- a/packages/babel-plugin/src/__tests__/index.test.ts +++ b/packages/babel-plugin/src/__tests__/index.test.ts @@ -201,4 +201,160 @@ describe('babel plugin', () => { expect(actual).toInclude('c_MyDiv'); }); + + it('should compress class name for styled component', () => { + const actual = transform( + ` + import { styled } from '@compiled/react'; + + const MyDiv = styled.div\` + font-size: 12px; + \`; + `, + { + classNameCompressionMap: { + '1wyb1fwx': 'a', + }, + } + ); + + expect(actual).toIncludeMultiple(['.a{font-size:12px}', 'ax(["_1wyb_a", __cmplp.className])']); + }); + + it('should compress class name for css props', () => { + const actual = transform( + ` + import '@compiled/react'; + +
+ `, + { + classNameCompressionMap: { + '1wyb1fwx': 'a', + }, + } + ); + + expect(actual).toIncludeMultiple(['.a{font-size:12px}', 'ax(["_1wyb_a"])']); + }); + + it('should compress class name for ClassNames', () => { + const actual = transform( + ` + import { ClassNames } from '@compiled/react'; + + + {({ css }) => ( +
+ )} + + `, + { + classNameCompressionMap: { + '1wyb1fwx': 'a', + }, + } + ); + + expect(actual).toIncludeMultiple(['.a{font-size:12px}', 'className={ax(["_1wyb_a"])']); + }); + + it('should compress class names with atrules', () => { + const actual = transform( + ` + import '@compiled/react'; +
+ `, + { + classNameCompressionMap: { + pz521fwx: 'a', + }, + } + ); + + expect(actual).toIncludeMultiple([ + '@media (max-width:1250px){.a{font-size:12px}}', + 'ax(["_pz52_a"])', + ]); + }); + + it('should compress pseudo classes', () => { + const actual = transform( + ` + import '@compiled/react'; +
+ `, + { + classNameCompressionMap: { + '9h8h5scu': 'a', + e9151fwx: 'b', + }, + } + ); + + expect(actual).toIncludeMultiple([ + '.a:active{color:red}', + '.b:hover{font-size:12px}', + 'ax(["_e915_b _9h8h_a"])', + ]); + }); + + it('should compress nested selector', () => { + const actual = transform( + ` + import '@compiled/react'; +
div': { 'div div:hover': { fontSize: 12 } } }} /> + `, + { + classNameCompressionMap: { + '1jkf1fwx': 'a', + }, + } + ); + + expect(actual).toIncludeMultiple(['.a >div div div:hover{font-size:12px}', 'ax(["_1jkf_a"]']); + }); + + it('should compress conditional class names', () => { + const actual = transform( + ` + import '@compiled/react'; +
bar ? 14 : 16 }, () => foo ? { fontSize: 12 } : {}, baz && { fontSize: 20 }]} /> + `, + { + classNameCompressionMap: { + '1wyb19ub': 'a', + '1wyb1fwx': 'b', + }, + } + ); + + expect(actual).toIncludeMultiple([ + '.a{font-size:16}', + '.b{font-size:12px}', + 'bar ? "_1wyb1o8a" : "_1wyb_a"', + 'foo && "_1wyb_b"', + ]); + }); + + it('should compress class names according to the map', () => { + const actual = transform( + ` + import '@compiled/react'; +
+ `, + { + classNameCompressionMap: { + syaz5scu: 'a', + }, + } + ); + + expect(actual).toIncludeMultiple([ + '._19pk19bv{margin-top:10px}', + '.a{color:red}', + '._1wyb1fwx{font-size:12px}', + 'ax(["_1wyb1fwx _syaz_a _19pk19bv"]', + ]); + }); }); diff --git a/packages/babel-plugin/src/__tests__/jsx-automatic.test.ts b/packages/babel-plugin/src/__tests__/jsx-automatic.test.ts index 3fc90bb26..e594c86d5 100644 --- a/packages/babel-plugin/src/__tests__/jsx-automatic.test.ts +++ b/packages/babel-plugin/src/__tests__/jsx-automatic.test.ts @@ -49,7 +49,7 @@ describe('jsx automatic', () => { children: [_], }), _jsx("div", { - className: "_syaz13q2", + className: ax(["_syaz13q2"]), }), ], }); diff --git a/packages/babel-plugin/src/class-names/__tests__/behaviour.test.ts b/packages/babel-plugin/src/class-names/__tests__/behaviour.test.ts index 74469aa9d..27554cb07 100644 --- a/packages/babel-plugin/src/class-names/__tests__/behaviour.test.ts +++ b/packages/babel-plugin/src/class-names/__tests__/behaviour.test.ts @@ -29,7 +29,7 @@ describe('class names behaviour', () => { const ListItem = () => ( {[_]} - {
hello, world!
} + {
hello, world!
}
); " @@ -55,7 +55,7 @@ describe('class names behaviour', () => { {[_]} {(() => { - return
hello, world!
; + return
hello, world!
; })()}
); @@ -101,7 +101,7 @@ describe('class names behaviour', () => { const ListItem = () => ( {[_]} - {
hello, world!
} + {
hello, world!
}
); " @@ -165,14 +165,18 @@ describe('class names behaviour', () => { {[_, _2, _3, _4, _5]} { <> -
+
longhand object call expression
-
shorthand object call expression
-
+
+ shorthand object call expression +
+
longhand tagged template expression
-
shorthand tagged template expression
+
+ shorthand tagged template expression +
} @@ -241,7 +245,7 @@ describe('class names behaviour', () => { const ListItem = () => ( {[_]} - {
hello, world!
} + {
hello, world!
}
); " @@ -275,8 +279,8 @@ describe('class names behaviour', () => { {
hello, world! @@ -304,7 +308,7 @@ describe('class names behaviour', () => { const ListItem = () => ( {[_]} - {
hello, world!
} + {
hello, world!
}
); " @@ -343,7 +347,7 @@ describe('class names behaviour', () => { const ListItem = ({ children }) => ( {[_]} - {children("_1wybgktf")} + {children(ax(["_1wybgktf"]))} ); " @@ -361,7 +365,7 @@ describe('class names behaviour', () => { ); `); - expect(actual).toInclude(`
{ @@ -399,7 +403,7 @@ describe('class names behaviour', () => { style={{ "--_1ylxx6h": ix(color), }} - className={"_syaz1aj3"} + className={ax(["_syaz1aj3"])} /> } @@ -466,7 +470,7 @@ describe('class names behaviour', () => { {(() => { const { css: c, style: styl } = arg; return ( -
+
hello world
); @@ -476,4 +480,71 @@ describe('class names behaviour', () => { " `); }); + + it('should apply conditional logical expression object spread styles', () => { + const actual = transform(` + import { ClassNames } from '@compiled/react'; + + const ListItem = (props) => ( + + {({ css }) => (
hello, world!
)} +
+ ); + `); + + expect(actual).toInclude('className={ax([props.isPrimary && "_syaz13q2 _1wybgktf"])}'); + }); + + it('should apply array logical-based conditional css', () => { + const actual = transform( + ` + import { ClassNames } from '@compiled/react'; + + const ListItem = (props) => ( + + {({ css }) => (
hello, world!
)} +
+ ); + `, + { pretty: false } + ); + + expect(actual).toInclude( + 'className={ax(["_1wyb1ylp",(props.isPrimary||props.isMaybe)&&"_syaz13q2 _1wybgktf"])}' + ); + }); + + it('should apply array prop ternary-based inline conditional css', () => { + const actual = transform( + ` + import { ClassNames } from '@compiled/react'; + + const ListItem = (props) => ( + + {({ css }) => (
hello, world!
)} +
+ ); + `, + { pretty: false } + ); + + expect(actual).toInclude( + 'className={ax([props.isPrimary?"_bfhk1x77 _syaz11x8":"_bfhkbf54 _syaz5scu","_1wyb1fwx"])}' + ); + }); }); diff --git a/packages/babel-plugin/src/class-names/__tests__/tagged-template-expression.test.ts b/packages/babel-plugin/src/class-names/__tests__/tagged-template-expression.test.ts index 95b7d5fa4..525a5936f 100644 --- a/packages/babel-plugin/src/class-names/__tests__/tagged-template-expression.test.ts +++ b/packages/babel-plugin/src/class-names/__tests__/tagged-template-expression.test.ts @@ -15,7 +15,7 @@ describe('ClassNames used with a css tagged template expression', () => { expect(actual).toIncludeMultiple([ 'const _ = "._1wybgktf{font-size:20px}"', - 'className={"_1wybgktf"}', + 'className={ax(["_1wybgktf"])}', ]); }); diff --git a/packages/babel-plugin/src/class-names/index.ts b/packages/babel-plugin/src/class-names/index.ts index 1b989841f..74d4b6d59 100644 --- a/packages/babel-plugin/src/class-names/index.ts +++ b/packages/babel-plugin/src/class-names/index.ts @@ -1,13 +1,13 @@ import type { NodePath } from '@babel/core'; import * as t from '@babel/types'; -import { transformCss } from '@compiled/css'; import type { Metadata } from '../types'; import { buildCodeFrameError, pickFunctionBody } from '../utils/ast'; import { compiledTemplate } from '../utils/build-compiled-component'; import { buildCssVariables } from '../utils/build-css-variables'; -import { buildCss, getItemCss } from '../utils/css-builders'; +import { buildCss } from '../utils/css-builders'; import { resolveIdentifierComingFromDestructuring } from '../utils/resolve-binding'; +import { transformCssItems } from '../utils/transform-css-items'; import type { CSSOutput } from '../utils/types'; /** @@ -132,15 +132,12 @@ export const visitClassNamesPath = (path: NodePath, meta: Metadata } const builtCss = buildCss(styles, meta); - const { sheets, classNames } = transformCss( - builtCss.css.map((x) => getItemCss(x)).join(''), - meta.state.opts - ); + const { sheets, classNames } = transformCssItems(builtCss.css, meta); collectedVariables.push(...builtCss.variables); collectedSheets.push(...sheets); - path.replaceWith(t.stringLiteral(classNames.join(' '))); + path.replaceWith(t.callExpression(t.identifier('ax'), [t.arrayExpression(classNames)])); }, }); diff --git a/packages/babel-plugin/src/types.ts b/packages/babel-plugin/src/types.ts index 7216f9d40..fc72ed4a8 100644 --- a/packages/babel-plugin/src/types.ts +++ b/packages/babel-plugin/src/types.ts @@ -67,6 +67,14 @@ export interface PluginOptions { * Default to `false` */ addComponentName?: boolean; + + /** + * A map holds the key-value pairs between full Atomic class names and the compressed ones + * i.e. { '_aaaabbbb': 'a' } + * + * Default to `undefined` + */ + classNameCompressionMap?: { [index: string]: string }; } export interface State extends PluginPass { diff --git a/packages/babel-plugin/src/utils/build-styled-component.ts b/packages/babel-plugin/src/utils/build-styled-component.ts index 3faa4a8af..a0f38e2ce 100644 --- a/packages/babel-plugin/src/utils/build-styled-component.ts +++ b/packages/babel-plugin/src/utils/build-styled-component.ts @@ -16,6 +16,7 @@ import type { Metadata, Tag } from '../types'; import { pickFunctionBody } from './ast'; import { buildCssVariables } from './build-css-variables'; +import { compressClassNamesForAx } from './compress-class-names-for-ax'; import { getItemCss } from './css-builders'; import { hoistSheet } from './hoist-sheet'; import { applySelectors, transformCssItems } from './transform-css-items'; @@ -237,7 +238,14 @@ export const buildStyledComponent = (tag: Tag, cssOutput: CSSOutput, meta: Metad const sheets = [...uniqueUnconditionalCssOutput.sheets, ...conditionalCssOutput.sheets]; const classNames = [ - ...[t.stringLiteral(uniqueUnconditionalCssOutput.classNames.join(' '))], + ...[ + t.stringLiteral( + compressClassNamesForAx( + uniqueUnconditionalCssOutput.classNames, + meta.state.opts.classNameCompressionMap + ).join(' ') + ), + ], ...conditionalCssOutput.classNames, ]; diff --git a/packages/babel-plugin/src/utils/compress-class-names-for-ax.ts b/packages/babel-plugin/src/utils/compress-class-names-for-ax.ts new file mode 100644 index 000000000..c069374c0 --- /dev/null +++ b/packages/babel-plugin/src/utils/compress-class-names-for-ax.ts @@ -0,0 +1,16 @@ +/** + * Compress class names based on `classNameCompressionMap`. + * The compressed class name has a format of `_aaaa_a`, which is expected by `ax`. + * `aaaa` is the atomic group and `a` is the compressed name. + */ +export const compressClassNamesForAx = ( + classNames: string[], + classNameCompressionMap?: { [index: string]: string } +): string[] => { + if (!classNameCompressionMap) return classNames; + return classNames.map((className) => { + const compressedClassName = + classNameCompressionMap && classNameCompressionMap[className.slice(1)]; + return compressedClassName ? `_${className.slice(1, 5)}_${compressedClassName}` : className; + }); +}; diff --git a/packages/babel-plugin/src/utils/transform-css-items.ts b/packages/babel-plugin/src/utils/transform-css-items.ts index 2f0bce1ae..0134cc850 100644 --- a/packages/babel-plugin/src/utils/transform-css-items.ts +++ b/packages/babel-plugin/src/utils/transform-css-items.ts @@ -3,6 +3,7 @@ import { transformCss } from '@compiled/css'; import type { Metadata } from '../types'; +import { compressClassNamesForAx } from './compress-class-names-for-ax'; import { getItemCss } from './css-builders'; import type { CssItem } from './types'; @@ -64,13 +65,21 @@ const transformCssItem = ( classExpression: t.logicalExpression( item.operator, item.expression, - t.stringLiteral(logicalCss.classNames.join(' ')) + t.stringLiteral( + compressClassNamesForAx( + logicalCss.classNames, + meta.state.opts.classNameCompressionMap + ).join(' ') + ) ), }; default: const css = transformCss(getItemCss(item), meta.state.opts); - const className = css.classNames.join(' '); + const className = compressClassNamesForAx( + css.classNames, + meta.state.opts.classNameCompressionMap + ).join(' '); return { sheets: css.sheets, diff --git a/packages/css/src/__tests__/generate-compression-map.test.ts b/packages/css/src/__tests__/generate-compression-map.test.ts new file mode 100644 index 000000000..b03736db3 --- /dev/null +++ b/packages/css/src/__tests__/generate-compression-map.test.ts @@ -0,0 +1,224 @@ +import { generateCompressionMap as generate } from '../generate-compression-map'; + +describe('generate compression map', () => { + const baseCSS = ` + ._154i14e6{top:33px} + ._14tk72c6>div:not([role=group])>a{padding-left:18.6px} + ._14n4stnw._14n4stnw{position:absolute} + ._13h81y44 span[role=button]{padding-top:4px} + ._1di6k6hx:active, ._irr3k6hx:hover{background-color:var(--ds-background-neutral-subtle-hovered,#091e420a)} + ._1di6k6hx:active, ._jomrk6hx:focus, ._10j7k6hx:focus-within, ._irr3k6hx:hover{background-color:var(--ds-background-neutral-subtle-hovered,#091e420a)} + ._1gg2glyw>a:active, ._1o3iglyw>a[aria-current=page]{-webkit-text-decoration-line:none;text-decoration-line:none} + ._1iohnqa1:active, ._5goinqa1:focus, ._jf4cnqa1:hover{-webkit-text-decoration-style:solid;text-decoration-style:solid} + ._1iohnqa1:active, ._jf4cnqa1:hover, ._xatrnqa1:link, ._1726nqa1:visited{-webkit-text-decoration-style:solid;text-decoration-style:solid} + ._1iqunqa1._1iqunqa1:active, ._1ejunqa1._1ejunqa1:hover, ._1lwpnqa1._1lwpnqa1:visited{-webkit-text-decoration-style:solid;text-decoration-style:solid} + ._1iqunqa1._1iqunqa1:active, ._6xf7nqa1._6xf7nqa1:focus, ._1ejunqa1._1ejunqa1:hover, ._1lwpnqa1._1lwpnqa1:visited{-webkit-text-decoration-style:solid;text-decoration-style:solid} + ._1mb818uv>a:active, ._oga118uv>a[aria-current=page]{-webkit-text-decoration-color:initial;text-decoration-color:initial} + ._1n2onqa1>a:active, ._1k4fnqa1>a[aria-current=page]{-webkit-text-decoration-style:solid;text-decoration-style:solid} + ._1nrm18uv:active, ._1a3b18uv:focus, ._9oik18uv:hover{-webkit-text-decoration-color:initial;text-decoration-color:initial} + ._1nrm18uv:active, ._9oik18uv:hover, ._5bd618uv:link, ._1ydc18uv:visited{-webkit-text-decoration-color:initial;text-decoration-color:initial} + ._1ohyglyw:active, ._49pcglyw:focus, ._ra3xglyw:focus-visible, ._ksodglyw:hover{outline-style:none} + ._1ohyglyw:active, ._ksodglyw:hover, ._q4asglyw:link, ._tpgfglyw:visited{outline-style:none} + ._1oxgru3m:active{transition-duration:0s} + ._9h8h1e9r:active, ._f8pj1e9r:focus, ._30l31e9r:hover, ._10531e9r:visited{color:var(--ds-text-subtlest,#7a869a)} + @media screen and (min-width:1300px){._1jhpoyl8{max-width:10vw}} + @media (max-width:1199px){._11usglyw{display:none}} + @media (min--moz-device-pixel-ratio:2){._11y7oza4{max-width:510px}._11y7uu9g{max-width:840px}._l82t7vkz{border-left-width:1pc}._j7o07vkz{border-right-width:1pc}._1od57vkz._1od57vkz{border-left-width:1pc}._l82tgktf{border-left-width:20px}._j7o0gktf{border-right-width:20px}._yksp1ssb{width:50%}._1b421ssb{height:50%}._s8ks18ws{transform:scale(2)}._u1wz1nty{transform-origin:0 0}} + @media (min-width:1000px) and (max-width:1439px){._hnu8tcjq{display:block!important}} + @media (min-width:1200px){._jvpg11p5{display:grid}._1nwdwxkt{grid-template-columns:1fr 1fr}._1vlxckbl{grid-gap:3pc}._kz8c16xz{padding-top:6pc}._1jyu16xz{padding-right:6pc}._11et16xz{padding-bottom:6pc}._fgkv16xz{padding-left:6pc}._szna1wug{margin-top:auto}._13on1wug{margin-right:auto}._1f3k1wug{margin-bottom:auto}._inid1wug{margin-left:auto}._12wp9ac1{max-width:1400px}._jvpgglyw{display:none}} + @media (min-width:1440px) and (max-width:1919px){._pbi4tcjq{display:block!important}} + @media (min-width:1920px) and (max-width:2559px){._16b9tcjq{display:block!important}} + @media (min-width:2560px) and (max-width:2999px){._jmaqtcjq{display:block!important}} + @media (min-width:3000px){._1q5htcjq{display:block!important}} + @media (min-width:800px) and (max-width:999px){._11x1tcjq{display:block!important}} + @media (min-width:800px){._121jagmp{display:none!important}} + @media (prefers-reduced-motion:reduce){._1bumglyw{animation:none}._sedtglyw{transition:none}} + @media screen and (-webkit-min-device-pixel-ratio:0){._14kw1hna >textarea{word-break:break-word}._mc2h1hna{word-break:break-word}} + @media screen and (-webkit-transition){._14fy1hna{word-break:break-word}._1vdp1hna >textarea{word-break:break-word}} + @media screen and (max-height:400px){._17gjpfqs{position:static}} + @media screen and (min-width:1300px){._1jhpoyl8{max-width:10vw}} +`; + + const baseResult = { + '154i14e6': 'a', + '14tk72c6': 'b', + '14n4stnw': 'c', + '13h81y44': 'd', + '1di6k6hx': 'e', + irr3k6hx: 'f', + jomrk6hx: 'g', + '10j7k6hx': 'h', + '1gg2glyw': 'i', + '1o3iglyw': 'j', + '1iohnqa1': 'k', + '5goinqa1': 'l', + jf4cnqa1: 'm', + xatrnqa1: 'n', + '1726nqa1': 'o', + '1iqunqa1': 'p', + '1ejunqa1': 'q', + '1lwpnqa1': 'r', + '6xf7nqa1': 's', + '1mb818uv': 't', + oga118uv: 'u', + '1n2onqa1': 'v', + '1k4fnqa1': 'w', + '1nrm18uv': 'x', + '1a3b18uv': 'y', + '9oik18uv': 'z', + '5bd618uv': 'A', + '1ydc18uv': 'B', + '1ohyglyw': 'C', + '49pcglyw': 'D', + ra3xglyw: 'E', + ksodglyw: 'F', + q4asglyw: 'G', + tpgfglyw: 'H', + '1oxgru3m': 'I', + '9h8h1e9r': 'J', + f8pj1e9r: 'K', + '30l31e9r': 'L', + '10531e9r': 'M', + '1jhpoyl8': 'N', + '11usglyw': 'O', + '11y7oza4': 'P', + '11y7uu9g': 'Q', + l82t7vkz: 'R', + j7o07vkz: 'S', + '1od57vkz': 'T', + l82tgktf: 'U', + j7o0gktf: 'V', + yksp1ssb: 'W', + '1b421ssb': 'X', + s8ks18ws: 'Y', + u1wz1nty: 'Z', + hnu8tcjq: '_', + jvpg11p5: 'aa', + '1nwdwxkt': 'ba', + '1vlxckbl': 'ca', + kz8c16xz: 'da', + '1jyu16xz': 'ea', + '11et16xz': 'fa', + fgkv16xz: 'ga', + szna1wug: 'ha', + '13on1wug': 'ia', + '1f3k1wug': 'ja', + inid1wug: 'ka', + '12wp9ac1': 'la', + jvpgglyw: 'ma', + pbi4tcjq: 'na', + '16b9tcjq': 'oa', + jmaqtcjq: 'pa', + '1q5htcjq': 'qa', + '11x1tcjq': 'ra', + '121jagmp': 'sa', + '1bumglyw': 'ta', + sedtglyw: 'ua', + '14kw1hna': 'va', + mc2h1hna: 'wa', + '14fy1hna': 'xa', + '1vdp1hna': 'ya', + '17gjpfqs': 'za', + }; + it('should generate class names as expected', () => { + const result = generate(baseCSS); + expect(result).toStrictEqual(baseResult); + }); + + it('should generate class names with the old compression map', () => { + const oldCompressionMap: { [index: string]: string } = { + '17gjpfqs': 'a', + '1vdp1hna': 'b', + '14fy1hna': 'c', + }; + const result = generate(baseCSS, { oldClassNameCompressionMap: oldCompressionMap }); + for (const property in oldCompressionMap) { + expect(result).toHaveProperty(property, oldCompressionMap[property]); + } + }); + + it('should generate class names with prefix', () => { + const result = generate(baseCSS, { prefix: '_' }); + expect(result).toStrictEqual({ + '154i14e6': '_a', + '14tk72c6': '_b', + '14n4stnw': '_c', + '13h81y44': '_d', + '1di6k6hx': '_e', + irr3k6hx: '_f', + jomrk6hx: '_g', + '10j7k6hx': '_h', + '1gg2glyw': '_i', + '1o3iglyw': '_j', + '1iohnqa1': '_k', + '5goinqa1': '_l', + jf4cnqa1: '_m', + xatrnqa1: '_n', + '1726nqa1': '_o', + '1iqunqa1': '_p', + '1ejunqa1': '_q', + '1lwpnqa1': '_r', + '6xf7nqa1': '_s', + '1mb818uv': '_t', + oga118uv: '_u', + '1n2onqa1': '_v', + '1k4fnqa1': '_w', + '1nrm18uv': '_x', + '1a3b18uv': '_y', + '9oik18uv': '_z', + '5bd618uv': '_A', + '1ydc18uv': '_B', + '1ohyglyw': '_C', + '49pcglyw': '_D', + ra3xglyw: '_E', + ksodglyw: '_F', + q4asglyw: '_G', + tpgfglyw: '_H', + '1oxgru3m': '_I', + '9h8h1e9r': '_J', + f8pj1e9r: '_K', + '30l31e9r': '_L', + '10531e9r': '_M', + '1jhpoyl8': '_N', + '11usglyw': '_O', + '11y7oza4': '_P', + '11y7uu9g': '_Q', + l82t7vkz: '_R', + j7o07vkz: '_S', + '1od57vkz': '_T', + l82tgktf: '_U', + j7o0gktf: '_V', + yksp1ssb: '_W', + '1b421ssb': '_X', + s8ks18ws: '_Y', + u1wz1nty: '_Z', + hnu8tcjq: '__', + jvpg11p5: '_-', + '1nwdwxkt': '_0', + '1vlxckbl': '_1', + kz8c16xz: '_2', + '1jyu16xz': '_3', + '11et16xz': '_4', + fgkv16xz: '_5', + szna1wug: '_6', + '13on1wug': '_7', + '1f3k1wug': '_8', + inid1wug: '_9', + '12wp9ac1': '_aa', + jvpgglyw: '_ba', + pbi4tcjq: '_ca', + '16b9tcjq': '_da', + jmaqtcjq: '_ea', + '1q5htcjq': '_fa', + '11x1tcjq': '_ga', + '121jagmp': '_ha', + '1bumglyw': '_ia', + sedtglyw: '_ja', + '14kw1hna': '_ka', + mc2h1hna: '_la', + '14fy1hna': '_ma', + '1vdp1hna': '_na', + '17gjpfqs': '_oa', + }); + }); +}); diff --git a/packages/css/src/generate-compression-map.ts b/packages/css/src/generate-compression-map.ts new file mode 100644 index 000000000..4b56e20d4 --- /dev/null +++ b/packages/css/src/generate-compression-map.ts @@ -0,0 +1,70 @@ +import postcss from 'postcss'; +import selectorParser from 'postcss-selector-parser'; + +import { ClassNameGenerator } from './utils/class-name-generator'; + +const UNDERSCORE_UNICODE = 95; + +/** + * Generate a compression map, which is used by @compiled/babel-plugin to compress class names. + * The compression map looks like { 'aaaabbbb': 'a', 'bbbbcccc': 'b' } + * + * @param stylesheet css content i.e. `.aaaabbbb{font-size: 10px}` + * @param oldClassNameCompressionMap the previous compression map, which ensures the compression is deterministic. + * @returns newClassNameCompressionMap + */ +export const generateCompressionMap = ( + css: string, + opts?: { oldClassNameCompressionMap?: { [index: string]: string }; prefix?: string } +): undefined | { [index: string]: string } => { + const { oldClassNameCompressionMap, prefix } = opts || {}; + + let classNamesToCompress: string[] = []; + const classNameCompressionMap: { [index: string]: string } = {}; + const reservedClassNames: string[] = []; + + const selectorProcessor = selectorParser((selectors) => { + selectors.walkClasses((node: selectorParser.ClassName | selectorParser.Identifier) => { + // Only compress Atomic class names, which has the format of `_aaaabbbb`. + if (node.value.charCodeAt(0) === UNDERSCORE_UNICODE && node.value.length === 9) { + classNamesToCompress.push(node.value.slice(1)); + } + }); + }); + + const result = postcss([ + { + postcssPlugin: 'postcss-find-atomic-class-names', + Rule(ruleNode) { + selectorProcessor.process(ruleNode); + }, + }, + ]).process(css, { from: undefined }); + + // We need to access something to make the transformation happen. + result.css; + + // Remove duplicates + classNamesToCompress = Array.from(new Set(classNamesToCompress)); + + // Check if class name to compress already exists in oldClassNameCompressionMap + // If yes, re-use the compressed class name + if (oldClassNameCompressionMap) { + classNamesToCompress = classNamesToCompress.filter((className) => { + if (oldClassNameCompressionMap[className]) { + reservedClassNames.push(oldClassNameCompressionMap[className]); + classNameCompressionMap[className] = oldClassNameCompressionMap[className]; + return false; + } + return true; + }); + } + + const classNameGenerator = new ClassNameGenerator({ reservedClassNames, prefix }); + classNamesToCompress.forEach((className) => { + const newClassName = classNameGenerator.generateClassName(); + classNameCompressionMap[className] = newClassName; + }); + + return classNameCompressionMap; +}; diff --git a/packages/css/src/index.ts b/packages/css/src/index.ts index 7ffc2a5f2..69e3fcc41 100644 --- a/packages/css/src/index.ts +++ b/packages/css/src/index.ts @@ -6,3 +6,4 @@ export { BeforeInterpolation, } from './utils/css-affix-interpolation'; export { sort } from './sort'; +export { generateCompressionMap } from './generate-compression-map'; diff --git a/packages/css/src/plugins/atomicify-rules.ts b/packages/css/src/plugins/atomicify-rules.ts index cc4521367..9f3c423d7 100644 --- a/packages/css/src/plugins/atomicify-rules.ts +++ b/packages/css/src/plugins/atomicify-rules.ts @@ -3,6 +3,7 @@ import type { Plugin, ChildNode, Declaration, Container, Rule, AtRule } from 'po import { rule } from 'postcss'; interface PluginOpts { + classNameCompressionMap?: { [index: string]: string }; callback?: (className: string) => void; } @@ -76,20 +77,28 @@ const replaceNestingSelector = (selector: string, parentClassName: string) => { * @param node */ const buildAtomicSelector = (node: Declaration, opts: AtomicifyOpts) => { + const { classNameCompressionMap } = opts; const selectors: string[] = []; (opts.selectors || ['']).forEach((selector) => { const normalizedSelector = normalizeSelector(selector); - const className = atomicClassName(node, { + const fullClassName = atomicClassName(node, { ...opts, selectors: [normalizedSelector], }); - const replacedSelector = replaceNestingSelector(normalizedSelector, className); - selectors.push(replacedSelector); + const compressedClassName = + classNameCompressionMap && classNameCompressionMap[fullClassName.slice(1)]; + + if (compressedClassName) { + // Use compressed class name if compressedClassName is available + selectors.push(replaceNestingSelector(normalizedSelector, compressedClassName)); + } else { + selectors.push(replaceNestingSelector(normalizedSelector, fullClassName)); + } if (opts.callback) { - opts.callback(className); + opts.callback(fullClassName); } }); diff --git a/packages/css/src/transform.ts b/packages/css/src/transform.ts index 0502878d8..462245d4f 100644 --- a/packages/css/src/transform.ts +++ b/packages/css/src/transform.ts @@ -15,6 +15,7 @@ import { sortAtRulePseudos } from './plugins/sort-at-rule-pseudos'; interface TransformOpts { optimizeCss?: boolean; + classNameCompressionMap?: object; } /** @@ -38,7 +39,10 @@ export const transformCss = ( nested(), ...normalizeCSS(opts), expandShorthands(), - atomicifyRules({ callback: (className: string) => classNames.push(className) }), + atomicifyRules({ + classNameCompressionMap: opts.classNameCompressionMap, + callback: (className: string) => classNames.push(className), + }), sortAtRulePseudos(), ...(process.env.AUTOPREFIXER === 'off' ? [] : [autoprefixer()]), whitespace(), diff --git a/packages/css/src/utils/__tests__/class-name-generator.test.ts b/packages/css/src/utils/__tests__/class-name-generator.test.ts new file mode 100644 index 000000000..ddd729ef9 --- /dev/null +++ b/packages/css/src/utils/__tests__/class-name-generator.test.ts @@ -0,0 +1,47 @@ +import { ClassNameGenerator } from '../class-name-generator'; + +describe('ClassNameGenerator', () => { + it('should generate class names with minimal length', () => { + const generator = new ClassNameGenerator(); + Array.from(Array(27).keys()).forEach(() => { + const className = generator.generateClassName(); + expect(className.length).toBe(1); + }); + }); + + it('should skip reservedClassNames', () => { + const generator = new ClassNameGenerator({ reservedClassNames: ['a', 'b', 'c'] }); + const className = generator.generateClassName(); + expect(className).toBe('d'); + }); + + it('should not generate class names starting with a number if prefix is not given', () => { + const generator = new ClassNameGenerator(); + Array.from(Array(30).keys()).forEach(() => { + const className = generator.generateClassName(); + expect(className.charAt(0)).toMatch(/[^1-9]/); + }); + }); + + it('should prefix class names', () => { + const prefix = '_'; + const generator = new ClassNameGenerator({ prefix }); + expect(generator.generateClassName().startsWith(prefix)).toBeTrue(); + }); + + it('should throw an error if invalid prefix is given', () => { + expect(() => { + new ClassNameGenerator({ prefix: '-' }); + }).toThrowErrorMatchingInlineSnapshot( + `"'-' is an invalid prefix. The allowed prefix is [a-zA-Z_]"` + ); + }); + + it('should not generate class name which includes the word "ad"', () => { + const generator = new ClassNameGenerator({ prefix: 'a' }); + Array.from(Array(10).keys()).forEach(() => { + const className = generator.generateClassName(); + expect(className.toLocaleLowerCase().includes('ad')).toBeFalse(); + }); + }); +}); diff --git a/packages/css/src/utils/class-name-generator.ts b/packages/css/src/utils/class-name-generator.ts new file mode 100644 index 000000000..1dcf577a3 --- /dev/null +++ b/packages/css/src/utils/class-name-generator.ts @@ -0,0 +1,58 @@ +// CSS classes are case sensitive in non-quirk mode +// Spec: https://html.spec.whatwg.org/multipage/semantics-other.html#case-sensitivity-of-selectors +// CSS classes can contain only the characters [a-zA-Z0-9] and ISO 10646 characters U+00A0 and higher, plus the hyphen (-) and the underscore (_); they cannot start with a digit, two hyphens, or a hyphen followed by a digit. +// Spec: https://www.w3.org/TR/CSS21/syndata.html#characters +const acceptPrefixBase = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_'; +const acceptPrefix = acceptPrefixBase.split(''); +const acceptChars = `${acceptPrefixBase}-0123456789`.split(''); + +export class ClassNameGenerator { + newClassSize: number; + reservedClassNames: string[]; + prefix?: string; + constructor(opts: { reservedClassNames?: string[]; prefix?: string } = {}) { + this.newClassSize = 0; + this.reservedClassNames = opts.reservedClassNames || []; + this.prefix = opts.prefix; + + if (this.prefix && !acceptPrefix.includes(this.prefix)) { + throw new Error(`'${this.prefix}' is an invalid prefix. The allowed prefix is [a-zA-Z_]`); + } + } + generateClassName(): string { + const chars = []; + let rest = this.prefix + ? this.newClassSize + 1 + : (this.newClassSize - (this.newClassSize % acceptPrefix.length)) / acceptPrefix.length; + if (rest > 0) { + while (true) { + rest -= 1; + const m = rest % acceptChars.length; + const c = acceptChars[m]; + chars.push(c); + rest -= m; + if (rest === 0) { + break; + } + rest /= acceptChars.length; + } + } + const newClassName = `${ + this.prefix ? this.prefix : acceptPrefix[this.newClassSize % acceptPrefix.length] + }${chars.join('')}`; + + if (this.reservedClassNames && this.reservedClassNames.includes(newClassName)) { + this.newClassSize++; + return this.generateClassName(); + } + + // Avoid any class name which includes the word 'ad' to prevent adblocker from blocking the HTML element + if (newClassName.toLowerCase().includes('ad')) { + this.newClassSize++; + return this.generateClassName(); + } + + this.newClassSize++; + return newClassName; + } +} diff --git a/packages/parcel-transformer/src/index.ts b/packages/parcel-transformer/src/index.ts index 474012f15..f9b66bafa 100644 --- a/packages/parcel-transformer/src/index.ts +++ b/packages/parcel-transformer/src/index.ts @@ -121,6 +121,7 @@ export default new Transformer({ '@compiled/babel-plugin', { ...config, + classNameCompressionMap: config.extract && config.classNameCompressionMap, onIncludedFiles: (files: string[]) => includedFiles.push(...files), resolver: { // The resolver needs to be synchronous, as babel plugins must be synchronous diff --git a/packages/parcel-transformer/src/types.ts b/packages/parcel-transformer/src/types.ts index f518ac2cd..7bd764cd2 100644 --- a/packages/parcel-transformer/src/types.ts +++ b/packages/parcel-transformer/src/types.ts @@ -39,4 +39,12 @@ export interface ParcelTransformerOpts extends BabelPluginOpts { * Default to `false` */ addComponentName?: boolean; + + /** + * A map holds the key-value pairs between full Atomic class names and the compressed ones + * i.e. { '_aaaabbbb': 'a' } + * + * Default to `undefined` + */ + classNameCompressionMap?: { [index: string]: string }; } diff --git a/packages/react/src/runtime/__perf__/ax.test.ts b/packages/react/src/runtime/__perf__/ax.test.ts index d8b398ade..40e0117da 100644 --- a/packages/react/src/runtime/__perf__/ax.test.ts +++ b/packages/react/src/runtime/__perf__/ax.test.ts @@ -3,24 +3,26 @@ import { runBenchmark } from '@compiled/benchmark'; import { ax } from '../index'; describe('ax benchmark', () => { - it('completes with ax() string as the fastest', async () => { - const arr = [ - '_19itglyw', - '_2rko1l7b', - '_ca0qftgi', - '_u5f319bv', - '_n3tdftgi', - '_19bv19bv', - '_bfhk1mzw', - '_syazu67f', - '_k48p1nn1', - '_ect41kw7', - '_1wybdlk8', - '_irr3mlcl', - '_1di6vctu', - undefined, - ]; + const arr = [ + '_19itglyw', + '_2rko1l7b', + '_ca0qftgi', + '_u5f319bv', + '_n3tdftgi', + '_19bv19bv', + '_bfhk1mzw', + '_syazu67f', + '_k48p1nn1', + '_ect41kw7', + '_1wybdlk8', + '_irr3mlcl', + '_1di6vctu', + // `undefined` is an acceptable parameter so we want to include it in the test case. + // Example: ax(['aaaabbbb', foo() && "aaaacccc"]) + undefined, + ]; + it('completes with ax() string as the fastest', async () => { // Remove undefined and join the strings const str = arr.slice(0, -1).join(' '); @@ -39,4 +41,24 @@ describe('ax benchmark', () => { fastest: ['ax() string'], }); }, 30000); + + it('completes with ax() non-compressed class names as the fastest', async () => { + const arrWithCompressedClassNames = arr.map((item) => + item ? `${item.slice(0, 4)}_${item.slice(8)}` : item + ); + const benchmark = await runBenchmark('ax', [ + { + name: 'ax() array', + fn: () => ax(arr), + }, + { + name: 'ax() array with compressed class names', + fn: () => ax(arrWithCompressedClassNames), + }, + ]); + + expect(benchmark).toMatchObject({ + fastest: ['ax() array'], + }); + }, 30000); }); diff --git a/packages/react/src/runtime/__tests__/ax.test.ts b/packages/react/src/runtime/__tests__/ax.test.ts index e938e1ebf..a49e454d3 100644 --- a/packages/react/src/runtime/__tests__/ax.test.ts +++ b/packages/react/src/runtime/__tests__/ax.test.ts @@ -1,70 +1,81 @@ import ax from '../ax'; describe('ax', () => { - it('should join single classes together', () => { - const result = ax(['foo', 'bar']); - - expect(result).toEqual('foo bar'); - }); - - it('should join multi classes together', () => { - const result = ax(['foo baz', 'bar']); - - expect(result).toEqual('foo baz bar'); - }); - - it('should remove undefined', () => { - const result = ax(['foo', 'bar', undefined]); - - expect(result).toEqual('foo bar'); - }); - - it('should ensure the last atomic declaration of a single group wins', () => { - const result = ax(['_aaaabbbb', '_aaaacccc']); - - expect(result).toEqual('_aaaacccc'); - }); - - it('should ensure the last atomic declaration of many single groups wins', () => { - const result = ax(['_aaaabbbb', '_aaaacccc', '_aaaadddd', '_aaaaeeee']); - - expect(result).toEqual('_aaaaeeee'); - }); - - it('should ensure the last atomic declaration of a multi group wins', () => { - const result = ax(['_aaaabbbb _aaaacccc']); - - expect(result).toEqual('_aaaacccc'); - }); - - it('should ensure the last atomic declaration of many multi groups wins', () => { - const result = ax(['_aaaabbbb _aaaacccc _aaaadddd _aaaaeeee']); - - expect(result).toEqual('_aaaaeeee'); - }); - - it('should not remove any atomic declarations if there are no duplicate groups', () => { - const result = ax(['_aaaabbbb', '_bbbbcccc']); - - expect(result).toEqual('_aaaabbbb _bbbbcccc'); - }); - - it('should not apply conditional class', () => { - const isEnabled: boolean = (() => false)(); - const result = ax([isEnabled && 'foo', 'bar']); - - expect(result).toEqual('bar'); - }); - - it('should ignore non atomic declarations', () => { - const result = ax(['hello_there', 'hello_world']); - - expect(result).toEqual('hello_there hello_world'); - }); - - it('should ignore non atomic declarations when atomic declarations exist', () => { - const result = ax(['hello_there', 'hello_world', '_aaaabbbb']); - - expect(result).toEqual('hello_there hello_world _aaaabbbb'); + const isEnabled: boolean = (() => false)(); + + it.each([ + ['should handle empty array', [], undefined], + ['should handle array with undefined', [undefined], undefined], + ['should join single classes together', ['foo', 'bar'], 'foo bar'], + ['should join multi classes together', ['foo baz', 'bar'], 'foo baz bar'], + ['should remove undefined', ['foo', 'bar', undefined], 'foo bar'], + [ + 'should ensure the last atomic declaration of a single group wins', + ['_aaaabbbb', '_aaaacccc'], + '_aaaacccc', + ], + [ + 'should ensure the last atomic declaration of a single group with short class name wins', + ['_aaaabbbb', '_aaaacccc', '_aaaa_a'], + 'a', + ], + [ + 'should ensure the last atomic declaration of many single groups wins', + ['_aaaabbbb', '_aaaacccc', '_aaaadddd', '_aaaaeeee'], + '_aaaaeeee', + ], + [ + 'should ensure the last atomic declaration of many single groups with short class name wins', + ['_aaaabbbb', '_aaaacccc', '_aaaa_a', '_aaaa_b'], + 'b', + ], + [ + 'should ensure the last atomic declaration of a multi group wins', + ['_aaaabbbb _aaaacccc'], + '_aaaacccc', + ], + [ + 'should ensure the last atomic declaration of a multi group with short class name wins', + ['_aaaa_e', '_aaaabbbb _aaaacccc'], + '_aaaacccc', + ], + [ + 'should ensure the last atomic declaration of many multi groups wins', + ['_aaaabbbb _aaaacccc _aaaadddd _aaaaeeee'], + '_aaaaeeee', + ], + [ + 'should ensure the last atomic declaration of many multi groups with short class name wins', + ['_aaaabbbb', '_aaaa_a', '_bbbb_b', '_ddddcccc'], + 'a b _ddddcccc', + ], + [ + 'should not remove any atomic declarations if there are no duplicate groups', + ['_aaaabbbb', '_bbbbcccc'], + '_aaaabbbb _bbbbcccc', + ], + [ + 'should not remove any atomic declarations if there are short class name and no duplicate groups', + ['_eeee_e', '_aaaabbbb', '_bbbbcccc'], + 'e _aaaabbbb _bbbbcccc', + ], + ['should not apply conditional class', [isEnabled && 'foo', 'bar'], 'bar'], + [ + 'should ignore non atomic declarations', + ['hello_there', 'hello_world'], + 'hello_there hello_world', + ], + [ + 'should ignore non atomic declarations when atomic declarations exist', + ['hello_there', 'hello_world', '_aaaabbbb'], + 'hello_there hello_world _aaaabbbb', + ], + [ + 'should ignore non atomic declarations when atomic declarations with short class name exist', + ['hello_there', 'hello_world', '_aaaa_a'], + 'hello_there hello_world a', + ], + ])('%s', (_, params, result) => { + expect(result).toEqual(ax(params)); }); }); diff --git a/packages/react/src/runtime/ax.ts b/packages/react/src/runtime/ax.ts index fa92d0a78..92948ff58 100644 --- a/packages/react/src/runtime/ax.ts +++ b/packages/react/src/runtime/ax.ts @@ -28,10 +28,8 @@ const ATOMIC_GROUP_LENGTH = 5; * @param classes */ export default function ax(classNames: (string | undefined | false)[]): string | undefined { - if (classNames.length <= 1 && (!classNames[0] || classNames[0].indexOf(' ') === -1)) { - // short circuit if there's no custom class names. - return classNames[0] || undefined; - } + // short circuit if there's no class names. + if (classNames.length <= 1 && !classNames[0]) return undefined; const atomicGroups: Record = {}; @@ -45,11 +43,11 @@ export default function ax(classNames: (string | undefined | false)[]): string | for (let x = 0; x < groups.length; x++) { const atomic = groups[x]; - const atomicGroupName = atomic.slice( - 0, - atomic.charCodeAt(0) === UNDERSCORE_UNICODE ? ATOMIC_GROUP_LENGTH : undefined - ); - atomicGroups[atomicGroupName] = atomic; + const isAtomic = atomic.charCodeAt(0) === UNDERSCORE_UNICODE; + const isCompressed = isAtomic && atomic.charCodeAt(5) === UNDERSCORE_UNICODE; + + const atomicGroupName = isAtomic ? atomic.slice(0, ATOMIC_GROUP_LENGTH) : atomic; + atomicGroups[atomicGroupName] = isCompressed ? atomic.slice(ATOMIC_GROUP_LENGTH + 1) : atomic; } } diff --git a/packages/webpack-loader/src/compiled-loader.ts b/packages/webpack-loader/src/compiled-loader.ts index fe8119be9..5618fe9db 100644 --- a/packages/webpack-loader/src/compiled-loader.ts +++ b/packages/webpack-loader/src/compiled-loader.ts @@ -32,6 +32,7 @@ function getLoaderOptions(context: LoaderContext) { ssr = false, optimizeCss = true, addComponentName = false, + classNameCompressionMap = undefined, }: CompiledLoaderOptions = typeof context.getOptions === 'undefined' ? // Webpack v4 flow getOptions(context) @@ -75,6 +76,9 @@ function getLoaderOptions(context: LoaderContext) { addComponentName: { type: 'boolean', }, + classNameCompressionMap: { + type: 'object', + }, }, }); @@ -91,6 +95,7 @@ function getLoaderOptions(context: LoaderContext) { ssr, optimizeCss, addComponentName, + classNameCompressionMap, }; } @@ -163,6 +168,8 @@ export default async function compiledLoader( '@compiled/babel-plugin', { ...options, + // Turn off compressing class names if stylesheet extraction is off + classNameCompressionMap: options.extract && options.classNameCompressionMap, onIncludedFiles: (files: string[]) => includedFiles.push(...files), resolver: { // The resolver needs to be synchronous, as babel plugins must be synchronous diff --git a/packages/webpack-loader/src/types.ts b/packages/webpack-loader/src/types.ts index 92f4d9bc8..91913378b 100644 --- a/packages/webpack-loader/src/types.ts +++ b/packages/webpack-loader/src/types.ts @@ -78,6 +78,14 @@ export interface CompiledLoaderOptions { * Default to `false` */ addComponentName?: boolean; + + /** + * A map holds the key-value pairs between full Atomic class names and the compressed ones + * i.e. { '_aaaabbbb': 'a' } + * + * Default to `undefined` + */ + classNameCompressionMap?: object; } export interface CompiledExtractPluginOptions {