diff --git a/.gitignore b/.gitignore index ca77e406f..6167575ed 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ index.html package-lock.json yarn-error.log + +# Perf related files +isolate*.log \ No newline at end of file diff --git a/perf/.gitignore b/perf/.gitignore new file mode 100644 index 000000000..bc7a91cc4 --- /dev/null +++ b/perf/.gitignore @@ -0,0 +1,2 @@ +output*.css +v8.json \ No newline at end of file diff --git a/perf/fixture.css b/perf/fixture.css new file mode 100644 index 000000000..8de93d6fb --- /dev/null +++ b/perf/fixture.css @@ -0,0 +1,61 @@ +@tailwind base; + +@tailwind components; + +@tailwind utilities; + + +.btn-1-xl { + @apply sm:space-x-0; + @apply xl:space-x-0; + @apply sm:space-x-1; + @apply xl:space-x-1; + @apply sm:space-y-0; + @apply xl:space-y-0; + @apply sm:space-y-1; + @apply xl:space-y-1; +} +.btn-2-xl { + @apply sm:space-x-0; + @apply xl:space-x-0; + @apply sm:space-x-1; + @apply xl:space-x-1; + @apply sm:space-y-0; + @apply xl:space-y-0; + @apply sm:space-y-1; + @apply xl:space-y-1; + @apply btn-1-xl; +} +.btn-3-xl { + @apply sm:space-x-0; + @apply xl:space-x-0; + @apply sm:space-x-1; + @apply xl:space-x-1; + @apply sm:space-y-0; + @apply xl:space-y-0; + @apply sm:space-y-1; + @apply xl:space-y-1; + @apply btn-2-xl; +} +.btn-4-xl { + @apply sm:space-x-0; + @apply xl:space-x-0; + @apply sm:space-x-1; + @apply xl:space-x-1; + @apply sm:space-y-0; + @apply xl:space-y-0; + @apply sm:space-y-1; + @apply xl:space-y-1; + @apply btn-3-xl; +} +.btn-5-xl { + @apply sm:space-x-0; + @apply xl:space-x-0; + @apply sm:space-x-1; + @apply xl:space-x-1; + @apply sm:space-y-0; + @apply xl:space-y-0; + @apply sm:space-y-1; + @apply xl:space-y-1; + @apply btn-4-xl; +} diff --git a/perf/script.sh b/perf/script.sh new file mode 100755 index 000000000..cad09f2f3 --- /dev/null +++ b/perf/script.sh @@ -0,0 +1,15 @@ +# Cleanup existing perf stuff +rm isolate-*.log + +# Ensure we use the latest build version +npm run babelify + +# Run Tailwind on the big fixture file & profile it +node --prof lib/cli.js build ./perf/fixture.css -c ./perf/tailwind.config.js -o ./perf/output.css + +# Generate flame graph +node --prof-process --preprocess -j isolate*.log > ./perf/v8.json + +# Now visit: https://mapbox.github.io/flamebearer/ +# And drag that v8.json file in there! +# You can put "./lib" in the search box which will highlight all our code in green. \ No newline at end of file diff --git a/perf/tailwind.config.js b/perf/tailwind.config.js new file mode 100644 index 000000000..7fb2125aa --- /dev/null +++ b/perf/tailwind.config.js @@ -0,0 +1,28 @@ +module.exports = { + future: 'all', + experimental: 'all', + purge: [], + theme: { + extend: {}, + }, + variants: [ + 'responsive', + 'motion-safe', + 'motion-reduce', + 'group-hover', + 'group-focus', + 'hover', + 'focus-within', + 'focus-visible', + 'focus', + 'active', + 'visited', + 'disabled', + 'checked', + 'first', + 'last', + 'odd', + 'even', + ], + plugins: [], +} diff --git a/src/flagged/applyComplexClasses.js b/src/flagged/applyComplexClasses.js index 9b1d7e8f7..fe1ba1217 100644 --- a/src/flagged/applyComplexClasses.js +++ b/src/flagged/applyComplexClasses.js @@ -8,6 +8,7 @@ import substituteResponsiveAtRules from '../lib/substituteResponsiveAtRules' import convertLayerAtRulesToControlComments from '../lib/convertLayerAtRulesToControlComments' import substituteScreenAtRules from '../lib/substituteScreenAtRules' import prefixSelector from '../util/prefixSelector' +import { useMemo } from '../util/useMemo' function hasAtRule(css, atRule) { let foundAtRule = false @@ -20,26 +21,39 @@ function hasAtRule(css, atRule) { return foundAtRule } -function generateRulesFromApply({ rule, utilityName: className, classPosition }, replaceWith) { - const processedSelectors = rule.selectors.map(selector => { - const processor = selectorParser(selectors => { - let i = 0 - selectors.walkClasses(c => { - if (c.value === className && classPosition === i) { - c.replaceWith(selectorParser.attribute({ attribute: '__TAILWIND-APPLY-PLACEHOLDER__' })) - } - i++ - }) - }) +function cloneWithoutChildren(node) { + if (node.type === 'atrule') { + return postcss.atRule({ name: node.name, params: node.params }) + } + if (node.type === 'rule') { + return postcss.rule({ name: node.name, selectors: node.selectors }) + } + + const clone = node.clone() + clone.removeAll() + return clone +} + +const tailwindApplyPlaceholder = selectorParser.attribute({ + attribute: '__TAILWIND-APPLY-PLACEHOLDER__', +}) + +function generateRulesFromApply({ rule, utilityName: className, classPosition }, replaceWith) { + const parser = selectorParser(selectors => { + let i = 0 + selectors.walkClasses(c => { + if (classPosition === i++ && c.value === className) { + c.replaceWith(tailwindApplyPlaceholder) + } + }) + }) + + const processedSelectors = rule.selectors.map(selector => { // You could argue we should make this replacement at the AST level, but if we believe // the placeholder string is safe from collisions then it is safe to do this is a simple // string replacement, and much, much faster. - const processedSelector = processor - .processSync(selector) - .replace('[__TAILWIND-APPLY-PLACEHOLDER__]', replaceWith) - - return processedSelector + return parser.processSync(selector).replace('[__TAILWIND-APPLY-PLACEHOLDER__]', replaceWith) }) const cloned = rule.clone() @@ -47,8 +61,8 @@ function generateRulesFromApply({ rule, utilityName: className, classPosition }, let parent = rule.parent while (parent && parent.type !== 'root') { - const parentClone = parent.clone() - parentClone.removeAll() + const parentClone = cloneWithoutChildren(parent) + parentClone.append(current) current.parent = parentClone current = parentClone @@ -59,19 +73,21 @@ function generateRulesFromApply({ rule, utilityName: className, classPosition }, return current } -function extractUtilityNames(selector) { - const processor = selectorParser(selectors => { - let classes = [] +const extractUtilityNamesParser = selectorParser(selectors => { + let classes = [] + selectors.walkClasses(c => classes.push(c.value)) + return classes +}) - selectors.walkClasses(c => { - classes.push(c) - }) +const extractUtilityNames = useMemo( + selector => extractUtilityNamesParser.transformSync(selector), + selector => selector +) - return classes.map(c => c.value) - }) - - return processor.transformSync(selector) -} +const cloneRuleWithParent = useMemo( + rule => rule.clone({ parent: rule.parent }), + rule => rule +) function buildUtilityMap(css) { let index = 0 @@ -89,8 +105,9 @@ function buildUtilityMap(css) { index, utilityName, classPosition: i, - rule: rule.clone({ parent: rule.parent }), - containsApply: hasAtRule(rule, 'apply'), + get rule() { + return cloneRuleWithParent(rule) + }, }) index++ }) @@ -136,15 +153,11 @@ function mergeAdjacentRules(initialRule, rulesToInsert) { function makeExtractUtilityRules(css, config) { const utilityMap = buildUtilityMap(css) - const orderUtilityMap = _.fromPairs( - _.flatMap(_.toPairs(utilityMap), ([_utilityName, utilities]) => { - return utilities.map(utility => { - return [utility.index, utility] - }) - }) - ) - return function(utilityNames, rule) { - return _.flatMap(utilityNames, utilityName => { + + return function extractUtilityRules(utilityNames, rule) { + const combined = [] + + utilityNames.forEach(utilityName => { if (utilityMap[utilityName] === undefined) { // Look for prefixed utility in case the user has goofed const prefixedUtility = prefixSelector(config.prefix, `.${utilityName}`).slice(1) @@ -160,17 +173,18 @@ function makeExtractUtilityRules(css, config) { { word: utilityName } ) } - return utilityMap[utilityName].map(({ index }) => index) + + combined.push(...utilityMap[utilityName]) }) - .sort((a, b) => a - b) - .map(i => orderUtilityMap[i]) + + return combined.sort((a, b) => a.index - b.index) } } function processApplyAtRules(css, lookupTree, config) { const extractUtilityRules = makeExtractUtilityRules(lookupTree, config) - while (hasAtRule(css, 'apply')) { + do { css.walkRules(rule => { const applyRules = [] @@ -229,13 +243,29 @@ function processApplyAtRules(css, lookupTree, config) { rule.remove() } }) - } + + // We already know that we have at least 1 @apply rule. Otherwise this + // function would not have been called. Therefore we can execute this code + // at least once. This also means that in the best case scenario we only + // call this 2 times, instead of 3 times. + // 1st time -> before we call this function + // 2nd time -> when we check if we have to do this loop again (because do {} while (check)) + // .. instead of + // 1st time -> before we call this function + // 2nd time -> when we check the first time (because while (check) do {}) + // 3rd time -> when we re-check to see if we should do this loop again + } while (hasAtRule(css, 'apply')) return css } export default function applyComplexClasses(config, getProcessedPlugins) { return function(css) { + // We can stop already when we don't have any @apply rules. Vue users: you're welcome! + if (!hasAtRule(css, 'apply')) { + return css + } + // Tree already contains @tailwind rules, don't prepend default Tailwind tree if (hasAtRule(css, 'tailwind')) { return processApplyAtRules(css, css, config) @@ -261,7 +291,7 @@ export default function applyComplexClasses(config, getProcessedPlugins) { ) .then(result => { // Prepend Tailwind's generated classes to the tree so they are available for `@apply` - const lookupTree = _.tap(css.clone(), tree => tree.prepend(result.root)) + const lookupTree = _.tap(result.root, tree => tree.append(css.clone())) return processApplyAtRules(css, lookupTree, config) }) } diff --git a/src/lib/substituteVariantsAtRules.js b/src/lib/substituteVariantsAtRules.js index 578d57343..1cf189559 100644 --- a/src/lib/substituteVariantsAtRules.js +++ b/src/lib/substituteVariantsAtRules.js @@ -6,14 +6,14 @@ import prefixSelector from '../util/prefixSelector' function generatePseudoClassVariant(pseudoClass, selectorPrefix = pseudoClass) { return generateVariantFunction(({ modifySelectors, separator }) => { - return modifySelectors(({ selector }) => { - return selectorParser(selectors => { - selectors.walkClasses(sel => { - sel.value = `${selectorPrefix}${separator}${sel.value}` - sel.parent.insertAfter(sel, selectorParser.pseudo({ value: `:${pseudoClass}` })) - }) - }).processSync(selector) + const parser = selectorParser(selectors => { + selectors.walkClasses(sel => { + sel.value = `${selectorPrefix}${separator}${sel.value}` + sel.parent.insertAfter(sel, selectorParser.pseudo({ value: `:${pseudoClass}` })) + }) }) + + return modifySelectors(({ selector }) => parser.processSync(selector)) }) } @@ -24,13 +24,12 @@ function ensureIncludesDefault(variants) { const defaultVariantGenerators = config => ({ default: generateVariantFunction(() => {}), 'motion-safe': generateVariantFunction(({ container, separator, modifySelectors }) => { - const modified = modifySelectors(({ selector }) => { - return selectorParser(selectors => { - selectors.walkClasses(sel => { - sel.value = `motion-safe${separator}${sel.value}` - }) - }).processSync(selector) + const parser = selectorParser(selectors => { + selectors.walkClasses(sel => { + sel.value = `motion-safe${separator}${sel.value}` + }) }) + const modified = modifySelectors(({ selector }) => parser.processSync(selector)) const mediaQuery = postcss.atRule({ name: 'media', params: '(prefers-reduced-motion: no-preference)', @@ -39,42 +38,42 @@ const defaultVariantGenerators = config => ({ container.append(mediaQuery) }), 'motion-reduce': generateVariantFunction(({ container, separator, modifySelectors }) => { - const modified = modifySelectors(({ selector }) => { - return selectorParser(selectors => { - selectors.walkClasses(sel => { - sel.value = `motion-reduce${separator}${sel.value}` - }) - }).processSync(selector) + const parser = selectorParser(selectors => { + selectors.walkClasses(sel => { + sel.value = `motion-reduce${separator}${sel.value}` + }) + }) + const modified = modifySelectors(({ selector }) => parser.processSync(selector)) + const mediaQuery = postcss.atRule({ + name: 'media', + params: '(prefers-reduced-motion: reduce)', }) - const mediaQuery = postcss.atRule({ name: 'media', params: '(prefers-reduced-motion: reduce)' }) mediaQuery.append(modified) container.append(mediaQuery) }), 'group-hover': generateVariantFunction(({ modifySelectors, separator }) => { - return modifySelectors(({ selector }) => { - return selectorParser(selectors => { - selectors.walkClasses(sel => { - sel.value = `group-hover${separator}${sel.value}` - sel.parent.insertBefore( - sel, - selectorParser().astSync(prefixSelector(config.prefix, '.group:hover ')) - ) - }) - }).processSync(selector) + const parser = selectorParser(selectors => { + selectors.walkClasses(sel => { + sel.value = `group-hover${separator}${sel.value}` + sel.parent.insertBefore( + sel, + selectorParser().astSync(prefixSelector(config.prefix, '.group:hover ')) + ) + }) }) + return modifySelectors(({ selector }) => parser.processSync(selector)) }), 'group-focus': generateVariantFunction(({ modifySelectors, separator }) => { - return modifySelectors(({ selector }) => { - return selectorParser(selectors => { - selectors.walkClasses(sel => { - sel.value = `group-focus${separator}${sel.value}` - sel.parent.insertBefore( - sel, - selectorParser().astSync(prefixSelector(config.prefix, '.group:focus ')) - ) - }) - }).processSync(selector) + const parser = selectorParser(selectors => { + selectors.walkClasses(sel => { + sel.value = `group-focus${separator}${sel.value}` + sel.parent.insertBefore( + sel, + selectorParser().astSync(prefixSelector(config.prefix, '.group:focus ')) + ) + }) }) + return modifySelectors(({ selector }) => parser.processSync(selector)) }), hover: generatePseudoClassVariant('hover'), 'focus-within': generatePseudoClassVariant('focus-within'), diff --git a/src/util/buildSelectorVariant.js b/src/util/buildSelectorVariant.js index a7c938f45..77c0b680d 100644 --- a/src/util/buildSelectorVariant.js +++ b/src/util/buildSelectorVariant.js @@ -1,15 +1,21 @@ import parser from 'postcss-selector-parser' import tap from 'lodash/tap' +import { useMemo } from './useMemo' -export default function buildSelectorVariant(selector, variantName, separator, onError = () => {}) { - return parser(selectors => { - tap(selectors.first.filter(({ type }) => type === 'class').pop(), classSelector => { - if (classSelector === undefined) { - onError('Variant cannot be generated because selector contains no classes.') - return - } +const buildSelectorVariant = useMemo( + (selector, variantName, separator, onError = () => {}) => { + return parser(selectors => { + tap(selectors.first.filter(({ type }) => type === 'class').pop(), classSelector => { + if (classSelector === undefined) { + onError('Variant cannot be generated because selector contains no classes.') + return + } - classSelector.value = `${variantName}${separator}${classSelector.value}` - }) - }).processSync(selector) -} + classSelector.value = `${variantName}${separator}${classSelector.value}` + }) + }).processSync(selector) + }, + (selector, variantName, separator) => [selector, variantName, separator].join('||') +) + +export default buildSelectorVariant diff --git a/src/util/generateVariantFunction.js b/src/util/generateVariantFunction.js index 67b8fe230..8de4e4867 100644 --- a/src/util/generateVariantFunction.js +++ b/src/util/generateVariantFunction.js @@ -1,6 +1,16 @@ import _ from 'lodash' import postcss from 'postcss' import selectorParser from 'postcss-selector-parser' +import { useMemo } from './useMemo' + +const classNameParser = selectorParser(selectors => { + return selectors.first.filter(({ type }) => type === 'class').pop().value +}) + +const getClassNameFromSelector = useMemo( + selector => classNameParser.transformSync(selector), + selector => selector +) export default function generateVariantFunction(generator) { return (container, config) => { @@ -18,12 +28,10 @@ export default function generateVariantFunction(generator) { } rule.selectors = rule.selectors.map(selector => { - const className = selectorParser(selectors => { - return selectors.first.filter(({ type }) => type === 'class').pop().value - }).transformSync(selector) - return modifierFunction({ - className, + get className() { + return getClassNameFromSelector(selector) + }, selector, }) }) diff --git a/src/util/useMemo.js b/src/util/useMemo.js new file mode 100644 index 000000000..a70c397f0 --- /dev/null +++ b/src/util/useMemo.js @@ -0,0 +1,16 @@ +export function useMemo(cb, keyResolver) { + const cache = new Map() + + return (...args) => { + const key = keyResolver(...args) + + if (cache.has(key)) { + return cache.get(key) + } + + const result = cb(...args) + cache.set(key, result) + + return result + } +}