From 9191d5adcdd4d129affdf5482659120e03a3d003 Mon Sep 17 00:00:00 2001 From: mattcompiles Date: Wed, 21 Sep 2022 10:11:10 +1000 Subject: [PATCH] Improve performance of transformCss with large amounts of class names (#837) --- .changeset/ninety-windows-marry.md | 7 +++ packages/css/package.json | 2 +- packages/css/src/ahocorasick.d.ts | 7 +++ packages/css/src/transformCss.test.ts | 43 +++++++++++++++ packages/css/src/transformCss.ts | 78 +++++++++++++++++++++------ pnpm-lock.yaml | 17 +++--- 6 files changed, 126 insertions(+), 28 deletions(-) create mode 100644 .changeset/ninety-windows-marry.md create mode 100644 packages/css/src/ahocorasick.d.ts diff --git a/.changeset/ninety-windows-marry.md b/.changeset/ninety-windows-marry.md new file mode 100644 index 000000000..4e2101de8 --- /dev/null +++ b/.changeset/ninety-windows-marry.md @@ -0,0 +1,7 @@ +--- +'@vanilla-extract/css': patch +--- + +Improve performance of selector transforms + +This issue occured on M1 Macs due to performance issues with large regex patterns. \ No newline at end of file diff --git a/packages/css/package.json b/packages/css/package.json index c8dcf18d7..f9f41b999 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -109,13 +109,13 @@ "dependencies": { "@emotion/hash": "^0.8.0", "@vanilla-extract/private": "^1.0.3", + "ahocorasick": "1.0.2", "chalk": "^4.1.1", "css-what": "^5.0.1", "cssesc": "^3.0.0", "csstype": "^3.0.7", "deep-object-diff": "^1.1.0", "deepmerge": "^4.2.2", - "escape-string-regexp": "^4.0.0", "media-query-parser": "^2.0.2", "outdent": "^0.8.0" }, diff --git a/packages/css/src/ahocorasick.d.ts b/packages/css/src/ahocorasick.d.ts new file mode 100644 index 000000000..8e556eb77 --- /dev/null +++ b/packages/css/src/ahocorasick.d.ts @@ -0,0 +1,7 @@ +declare module 'ahocorasick' { + export default class Ahocorasick { + constructor(searchTerms: Array); + + search(input: string): Array<[endIndex: number, matches: Array]>; + } +} diff --git a/packages/css/src/transformCss.test.ts b/packages/css/src/transformCss.test.ts index 35ac38bbf..06a4f4569 100644 --- a/packages/css/src/transformCss.test.ts +++ b/packages/css/src/transformCss.test.ts @@ -1,10 +1,13 @@ import { setFileScope, endFileScope } from './fileScope'; import { createVar } from './vars'; import { transformCss } from './transformCss'; +import { style } from './style'; setFileScope('test'); const testVar = createVar(); +const style1 = style({}); +const style2 = style({}); describe('transformCss', () => { it('should escape class names', () => { @@ -1564,4 +1567,44 @@ describe('transformCss', () => { }); }); +it('should handle multiple references to the same locally scoped selector', () => { + expect( + transformCss({ + composedClassLists: [], + localClassNames: [style1, style2, '_1g1ptzo1', '_1g1ptzo10'], + cssObjs: [ + { + type: 'local', + selector: style1, + rule: { + selectors: { + [`${style2} &:before, ${style2} &:after`]: { + background: 'black', + }, + + [`_1g1ptzo1_1g1ptzo10 ${style1}`]: { + background: 'blue', + }, + + [`_1g1ptzo10_1g1ptzo1 ${style1}`]: { + background: 'blue', + }, + }, + }, + }, + ], + }).join('\n'), + ).toMatchInlineSnapshot(` + ".skkcyc2 .skkcyc1:before, .skkcyc2 .skkcyc1:after { + background: black; + } + ._1g1ptzo1._1g1ptzo10 .skkcyc1 { + background: blue; + } + ._1g1ptzo10._1g1ptzo1 .skkcyc1 { + background: blue; + }" + `); +}); + endFileScope(); diff --git a/packages/css/src/transformCss.ts b/packages/css/src/transformCss.ts index 77557c9dd..d8ae72f15 100644 --- a/packages/css/src/transformCss.ts +++ b/packages/css/src/transformCss.ts @@ -1,6 +1,8 @@ +import './ahocorasick.d'; + import { getVarName } from '@vanilla-extract/private'; import cssesc from 'cssesc'; -import escapeStringRegexp from 'escape-string-regexp'; +import AhoCorasick from 'ahocorasick'; import type { CSS, @@ -78,6 +80,18 @@ function dashify(str: string) { .toLowerCase(); } +function replaceBetweenIndexes( + target: string, + startIndex: number, + endIndex: number, + replacement: string, +) { + const start = target.slice(0, startIndex); + const end = target.slice(endIndex); + + return `${start}${replacement}${end}`; +} + const DOUBLE_SPACE = ' '; const specialKeys = [ @@ -100,7 +114,8 @@ class Stylesheet { currConditionalRuleset: ConditionalRuleset | undefined; fontFaceRules: Array; keyframesRules: Array; - localClassNameRegex: RegExp | null; + localClassNamesMap: Map; + localClassNamesSearch: AhoCorasick; composedClassLists: Array<{ identifier: string; regex: RegExp }>; constructor( @@ -111,10 +126,10 @@ class Stylesheet { this.conditionalRulesets = [new ConditionalRuleset()]; this.fontFaceRules = []; this.keyframesRules = []; - this.localClassNameRegex = - localClassNames.length > 0 - ? RegExp(`(${localClassNames.map(escapeStringRegexp).join('|')})`, 'g') - : null; + this.localClassNamesMap = new Map( + localClassNames.map((localClassName) => [localClassName, localClassName]), + ); + this.localClassNamesSearch = new AhoCorasick(localClassNames); // Class list compositions should be priortized by Newer > Older // Therefore we reverse the array as they are added in sequence @@ -252,6 +267,12 @@ class Stylesheet { }; } + transformClassname(identifier: string) { + return `.${cssesc(identifier, { + isIdentifier: true, + })}`; + } + transformSelector(selector: string) { // Map class list compositions to single identifiers let transformedSelector = selector; @@ -263,18 +284,41 @@ class Stylesheet { }); } - return this.localClassNameRegex - ? transformedSelector.replace( - this.localClassNameRegex, - (_, className, index) => { - if (index > 0 && transformedSelector[index - 1] === '.') { - return className; - } + if (this.localClassNamesMap.has(transformedSelector)) { + return this.transformClassname(transformedSelector); + } - return `.${cssesc(className, { isIdentifier: true })}`; - }, - ) - : transformedSelector; + const results = this.localClassNamesSearch.search(transformedSelector); + + let lastReplaceIndex = transformedSelector.length; + + // Perform replacements backwards to simplify index handling + for (let i = results.length - 1; i >= 0; i--) { + const [endIndex, [firstMatch]] = results[i]; + const startIndex = endIndex - firstMatch.length + 1; + + if (startIndex >= lastReplaceIndex) { + // Class names can be substrings of other class names + // e.g. '_1g1ptzo1' and '_1g1ptzo10' + // If the startIndex >= lastReplaceIndex, then + // this is the case and this replace should be skipped + continue; + } + + lastReplaceIndex = startIndex; + + // If class names already starts with a '.' then skip + if (transformedSelector[startIndex - 1] !== '.') { + transformedSelector = replaceBetweenIndexes( + transformedSelector, + startIndex, + endIndex + 1, + this.transformClassname(firstMatch), + ); + } + } + + return transformedSelector; } transformSelectors( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d884dcd68..c95354fa3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,25 +181,25 @@ importers: '@emotion/hash': ^0.8.0 '@types/cssesc': ^3.0.0 '@vanilla-extract/private': ^1.0.3 + ahocorasick: 1.0.2 chalk: ^4.1.1 css-what: ^5.0.1 cssesc: ^3.0.0 csstype: ^3.0.7 deep-object-diff: ^1.1.0 deepmerge: ^4.2.2 - escape-string-regexp: ^4.0.0 media-query-parser: ^2.0.2 outdent: ^0.8.0 dependencies: '@emotion/hash': 0.8.0 '@vanilla-extract/private': link:../private + ahocorasick: 1.0.2 chalk: 4.1.2 css-what: 5.1.0 cssesc: 3.0.0 csstype: 3.0.10 deep-object-diff: 1.1.0 deepmerge: 4.2.2 - escape-string-regexp: 4.0.0 media-query-parser: 2.0.2 outdent: 0.8.0 devDependencies: @@ -4378,6 +4378,10 @@ packages: - supports-color dev: false + /ahocorasick/1.0.2: + resolution: {integrity: sha512-hCOfMzbFx5IDutmWLAt6MZwOUjIfSM9G9FyVxytmE4Rs/5YDPWQrD/+IR1w+FweD9H2oOZEnv36TmkjhNURBVA==} + dev: false + /ajv-errors/1.0.1_ajv@6.12.6: resolution: {integrity: sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==} peerDependencies: @@ -4624,7 +4628,7 @@ packages: loader-utils: 1.4.0 make-dir: 3.1.0 schema-utils: 2.7.1 - webpack: 5.64.2_webpack-cli@4.9.1 + webpack: 5.64.2_esbuild@0.11.23 /babel-loader/8.2.3_webpack@5.64.2: resolution: {integrity: sha512-n4Zeta8NC3QAsuyiizu0GkmRcQ6clkV9WFUnUf1iXP//IeSKbWjofW3UHyZVwlOB4y039YQKefawyTn64Zwbuw==} @@ -6569,11 +6573,6 @@ packages: engines: {node: '>=8'} dev: false - /escape-string-regexp/4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - dev: false - /escodegen/2.0.0: resolution: {integrity: sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==} engines: {node: '>=6.0'} @@ -12395,7 +12394,6 @@ packages: source-map: 0.6.1 terser: 5.10.0 webpack: 5.64.2_esbuild@0.11.23 - dev: false /terser-webpack-plugin/5.2.5_webpack@5.64.2: resolution: {integrity: sha512-3luOVHku5l0QBeYS8r4CdHYWEGMmIj3H1U64jgkdZzECcSOJAyJ9TjuqcQZvw1Y+4AOBN9SeYJPJmFn2cM4/2g==} @@ -13476,7 +13474,6 @@ packages: - '@swc/core' - esbuild - uglify-js - dev: false /webpack/5.64.2_webpack-cli@4.9.1: resolution: {integrity: sha512-4KGc0+Ozi0aS3EaLNRvEppfZUer+CaORKqL6OBjDLZOPf9YfN8leagFzwe6/PoBdHFxc/utKArl8LMC0Ivtmdg==}