Skip to content

Commit

Permalink
Use local user css cache for apply (#7524)
Browse files Browse the repository at this point in the history
* Fix context reuse test

* Don't update files with at-apply when content changes

* Prevent at-apply directives from creating new contexts

* Rework apply to use local postcss root

We were storing user CSS in the context so we could use it with apply. The problem is that this CSS does not get updated on save unless it has a tailwind directive in it resulting in stale apply caches. This could result in either stale generation or errors about missing classes.

* Don’t build local cache unless `@apply` is used

* Update changelog
  • Loading branch information
thecrypticace committed Feb 25, 2022
1 parent f84ee8b commit 910b655
Show file tree
Hide file tree
Showing 9 changed files with 340 additions and 91 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Allow default ring color to be a function ([#7587](https://github.com/tailwindlabs/tailwindcss/pull/7587))
- Preserve source maps for generated CSS ([#7588](https://github.com/tailwindlabs/tailwindcss/pull/7588))
- Split box shadows on top-level commas only ([#7479](https://github.com/tailwindlabs/tailwindcss/pull/7479))
- Use local user css cache for `@apply` ([#7524](https://github.com/tailwindlabs/tailwindcss/pull/7524))

### Changed

Expand Down
175 changes: 171 additions & 4 deletions src/lib/expandApplyAtRules.js
Expand Up @@ -5,6 +5,8 @@ import { resolveMatches } from './generateRules'
import bigSign from '../util/bigSign'
import escapeClassName from '../util/escapeClassName'

/** @typedef {Map<string, [any, import('postcss').Rule[]]>} ApplyCache */

function extractClasses(node) {
let classes = new Set()
let container = postcss.root({ nodes: [node.clone()] })
Expand Down Expand Up @@ -35,6 +37,131 @@ function prefix(context, selector) {
return typeof prefix === 'function' ? prefix(selector) : prefix + selector
}

function* pathToRoot(node) {
yield node
while (node.parent) {
yield node.parent
node = node.parent
}
}

/**
* Only clone the node itself and not its children
*
* @param {*} node
* @param {*} overrides
* @returns
*/
function shallowClone(node, overrides = {}) {
let children = node.nodes
node.nodes = []

let tmp = node.clone(overrides)

node.nodes = children

return tmp
}

/**
* Clone just the nodes all the way to the top that are required to represent
* this singular rule in the tree.
*
* For example, if we have CSS like this:
* ```css
* @media (min-width: 768px) {
* @supports (display: grid) {
* .foo {
* display: grid;
* grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
* }
* }
*
* @supports (backdrop-filter: blur(1px)) {
* .bar {
* backdrop-filter: blur(1px);
* }
* }
*
* .baz {
* color: orange;
* }
* }
* ```
*
* And we're cloning `.bar` it'll return a cloned version of what's required for just that single node:
*
* ```css
* @media (min-width: 768px) {
* @supports (backdrop-filter: blur(1px)) {
* .bar {
* backdrop-filter: blur(1px);
* }
* }
* }
* ```
*
* @param {import('postcss').Node} node
*/
function nestedClone(node) {
for (let parent of pathToRoot(node)) {
if (node === parent) {
continue
}

if (parent.type === 'root') {
break
}

node = shallowClone(parent, {
nodes: [node],
})
}

return node
}

/**
* @param {import('postcss').Root} root
*/
function buildLocalApplyCache(root, context) {
/** @type {ApplyCache} */
let cache = new Map()

let highestOffset = context.layerOrder.user >> 4n

root.walkRules((rule, idx) => {
// Ignore rules generated by Tailwind
for (let node of pathToRoot(rule)) {
if (node.raws.tailwind?.layer !== undefined) {
return
}
}

// Clone what's required to represent this singular rule in the tree
let container = nestedClone(rule)

for (let className of extractClasses(rule)) {
let list = cache.get(className) || []
cache.set(className, list)

list.push([
{
layer: 'user',
sort: BigInt(idx) + highestOffset,
important: false,
},
container,
])
}
})

return cache
}

/**
* @returns {ApplyCache}
*/
function buildApplyCache(applyCandidates, context) {
for (let candidate of applyCandidates) {
if (context.notClassCache.has(candidate) || context.applyClassCache.has(candidate)) {
Expand Down Expand Up @@ -62,6 +189,43 @@ function buildApplyCache(applyCandidates, context) {
return context.applyClassCache
}

/**
* Build a cache only when it's first used
*
* @param {() => ApplyCache} buildCacheFn
* @returns {ApplyCache}
*/
function lazyCache(buildCacheFn) {
let cache = null

return {
get: (name) => {
cache = cache || buildCacheFn()

return cache.get(name)
},
has: (name) => {
cache = cache || buildCacheFn()

return cache.has(name)
},
}
}

/**
* Take a series of multiple caches and merge
* them so they act like one large cache
*
* @param {ApplyCache[]} caches
* @returns {ApplyCache}
*/
function combineCaches(caches) {
return {
get: (name) => caches.flatMap((cache) => cache.get(name) || []),
has: (name) => caches.some((cache) => cache.has(name)),
}
}

function extractApplyCandidates(params) {
let candidates = params.split(/[\s\t\n]+/g)

Expand All @@ -72,7 +236,7 @@ function extractApplyCandidates(params) {
return [candidates, false]
}

function processApply(root, context) {
function processApply(root, context, localCache) {
let applyCandidates = new Set()

// Collect all @apply rules and candidates
Expand All @@ -90,7 +254,7 @@ function processApply(root, context) {
// Start the @apply process if we have rules with @apply in them
if (applies.length > 0) {
// Fill up some caches!
let applyClassCache = buildApplyCache(applyCandidates, context)
let applyClassCache = combineCaches([localCache, buildApplyCache(applyCandidates, context)])

/**
* When we have an apply like this:
Expand Down Expand Up @@ -302,12 +466,15 @@ function processApply(root, context) {
}

// Do it again, in case we have other `@apply` rules
processApply(root, context)
processApply(root, context, localCache)
}
}

export default function expandApplyAtRules(context) {
return (root) => {
processApply(root, context)
// Build a cache of the user's CSS so we can use it to resolve classes used by @apply
let localCache = lazyCache(() => buildLocalApplyCache(root, context))

processApply(root, context, localCache)
}
}
30 changes: 25 additions & 5 deletions src/lib/expandTailwindAtRules.js
Expand Up @@ -204,17 +204,29 @@ export default function expandTailwindAtRules(context) {
// Replace any Tailwind directives with generated CSS

if (layerNodes.base) {
layerNodes.base.before(cloneNodes([...baseNodes, ...defaultNodes], layerNodes.base.source))
layerNodes.base.before(
cloneNodes([...baseNodes, ...defaultNodes], layerNodes.base.source, {
layer: 'base',
})
)
layerNodes.base.remove()
}

if (layerNodes.components) {
layerNodes.components.before(cloneNodes([...componentNodes], layerNodes.components.source))
layerNodes.components.before(
cloneNodes([...componentNodes], layerNodes.components.source, {
layer: 'components',
})
)
layerNodes.components.remove()
}

if (layerNodes.utilities) {
layerNodes.utilities.before(cloneNodes([...utilityNodes], layerNodes.utilities.source))
layerNodes.utilities.before(
cloneNodes([...utilityNodes], layerNodes.utilities.source, {
layer: 'utilities',
})
)
layerNodes.utilities.remove()
}

Expand All @@ -234,10 +246,18 @@ export default function expandTailwindAtRules(context) {
})

if (layerNodes.variants) {
layerNodes.variants.before(cloneNodes(variantNodes, layerNodes.variants.source))
layerNodes.variants.before(
cloneNodes(variantNodes, layerNodes.variants.source, {
layer: 'variants',
})
)
layerNodes.variants.remove()
} else if (variantNodes.length > 0) {
root.append(cloneNodes(variantNodes, root.source))
root.append(
cloneNodes(variantNodes, root.source, {
layer: 'variants',
})
)
}

// If we've got a utility layer and no utilities are generated there's likely something wrong
Expand Down
20 changes: 0 additions & 20 deletions src/lib/setupContextUtils.js
Expand Up @@ -230,17 +230,6 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
// Preserved for backwards compatibility but not used in v3.0+
return []
},
addUserCss(userCss) {
for (let [identifier, rule] of withIdentifiers(userCss)) {
let offset = offsets.user++

if (!context.candidateRuleMap.has(identifier)) {
context.candidateRuleMap.set(identifier, [])
}

context.candidateRuleMap.get(identifier).push([{ sort: offset, layer: 'user' }, rule])
}
},
addBase(base) {
for (let [identifier, rule] of withIdentifiers(base)) {
let prefixedIdentifier = prefixIdentifier(identifier, {})
Expand Down Expand Up @@ -521,15 +510,6 @@ function collectLayerPlugins(root) {
}
})

root.walkRules((rule) => {
// At this point it is safe to include all the left-over css from the
// user's css file. This is because the `@tailwind` and `@layer` directives
// will already be handled and will be removed from the css tree.
layerPlugins.push(function ({ addUserCss }) {
addUserCss(rule, { respectPrefix: false })
})
})

return layerPlugins
}

Expand Down
6 changes: 3 additions & 3 deletions src/lib/setupTrackingContext.js
Expand Up @@ -112,7 +112,7 @@ function resolveChangedFiles(candidateFiles, fileModifiedMap) {
// source path), or set up a new one (including setting up watchers and registering
// plugins) then return it
export default function setupTrackingContext(configOrPath) {
return ({ tailwindDirectives, registerDependency, applyDirectives }) => {
return ({ tailwindDirectives, registerDependency }) => {
return (root, result) => {
let [tailwindConfig, userConfigPath, tailwindConfigHash, configDependencies] =
getTailwindConfig(configOrPath)
Expand All @@ -125,7 +125,7 @@ export default function setupTrackingContext(configOrPath) {
// being part of this trigger too, but it's tough because it's impossible
// for a layer in one file to end up in the actual @tailwind rule in
// another file since independent sources are effectively isolated.
if (tailwindDirectives.size > 0 || applyDirectives.size > 0) {
if (tailwindDirectives.size > 0) {
// Add current css file as a context dependencies.
contextDependencies.add(result.opts.from)

Expand Down Expand Up @@ -153,7 +153,7 @@ export default function setupTrackingContext(configOrPath) {
// We may want to think about `@layer` being part of this trigger too, but it's tough
// because it's impossible for a layer in one file to end up in the actual @tailwind rule
// in another file since independent sources are effectively isolated.
if (tailwindDirectives.size > 0 || applyDirectives.size > 0) {
if (tailwindDirectives.size > 0) {
let fileModifiedMap = getFileModifiedMap(context)

// Add template paths as postcss dependencies.
Expand Down
9 changes: 8 additions & 1 deletion src/util/cloneNodes.js
@@ -1,4 +1,4 @@
export default function cloneNodes(nodes, source) {
export default function cloneNodes(nodes, source = undefined, raws = undefined) {
return nodes.map((node) => {
let cloned = node.clone()

Expand All @@ -12,6 +12,13 @@ export default function cloneNodes(nodes, source) {
}
}

if (raws !== undefined) {
cloned.raws.tailwind = {
...cloned.raws.tailwind,
...raws,
}
}

return cloned
})
}

0 comments on commit 910b655

Please sign in to comment.