diff --git a/src/lib/expandApplyAtRules.js b/src/lib/expandApplyAtRules.js index 65fe7950f0a1..0a59e20d8d62 100644 --- a/src/lib/expandApplyAtRules.js +++ b/src/lib/expandApplyAtRules.js @@ -8,18 +8,30 @@ import escapeClassName from '../util/escapeClassName' /** @typedef {Map} ApplyCache */ function extractClasses(node) { - let classes = new Set() + /** @type {Map>} */ + let groups = new Map() + let container = postcss.root({ nodes: [node.clone()] }) container.walkRules((rule) => { parser((selectors) => { selectors.walkClasses((classSelector) => { + let parentSelector = classSelector.parent.toString() + + let classes = groups.get(parentSelector) + if (! classes) { + groups.set(parentSelector, classes = new Set()) + } + classes.add(classSelector.value) }) }).processSync(rule.selector) }) - return Array.from(classes) + let normalizedGroups = Array.from(groups.values(), classes => Array.from(classes)) + let classes = normalizedGroups.flat() + + return Object.assign(classes, { groups: normalizedGroups }) } function extractBaseCandidates(candidates, separator) { @@ -353,10 +365,18 @@ function processApply(root, context, localCache) { let siblings = [] for (let [applyCandidate, important, rules] of candidates) { + let potentialApplyCandidates = [applyCandidate, ...extractBaseCandidates([applyCandidate], context.tailwindConfig.separator)] + for (let [meta, node] of rules) { let parentClasses = extractClasses(parent) let nodeClasses = extractClasses(node) + // When we encounter a rule like `.dark .a, .b { … }` we only want to be left with `[.dark, .a]` if the base applyCandidate is `.a` or with `[.b]` if the base applyCandidate is `.b` + // So we've split them into groups + nodeClasses = nodeClasses.groups + .filter(classList => classList.some(className => potentialApplyCandidates.includes(className))) + .flat() + // Add base utility classes from the @apply node to the list of // classes to check whether it intersects and therefore results in a // circular dependency or not. diff --git a/tests/apply.test.js b/tests/apply.test.js index 7f149617e53a..9841d58266d9 100644 --- a/tests/apply.test.js +++ b/tests/apply.test.js @@ -658,6 +658,91 @@ it('should throw when trying to apply an indirect circular dependency with a mod }) }) +it('should not throw when the circular dependency is part of a different selector (1)', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], + } + + let input = css` + @tailwind utilities; + + @layer utilities { + html.dark .a, .b { + color: red; + } + } + + html.dark .c { + @apply b; + } + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + html.dark .c { + color: red; + } + `) + }) +}) + +it('should not throw when the circular dependency is part of a different selector (2)', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], + } + + let input = css` + @tailwind utilities; + + @layer utilities { + html.dark .a, .b { + color: red; + } + } + + html.dark .c { + @apply hover:b; + } + ` + + return run(input, config).then((result) => { + expect(result.css).toMatchFormattedCss(css` + html.dark .c:hover { + color: red; + } + `) + }) +}) + +it('should throw when the circular dependency is part of the same selector', () => { + let config = { + content: [{ raw: html`
` }], + plugins: [], + } + + let input = css` + @tailwind utilities; + + @layer utilities { + html.dark .a, html.dark .b { + color: red; + } + } + + html.dark .c { + @apply hover:b; + } + ` + + return run(input, config).catch((err) => { + expect(err.reason).toBe( + 'You cannot `@apply` the `hover:b` utility here because it creates a circular dependency.' + ) + }) +}) + it('rules with vendor prefixes are still separate when optimizing defaults rules', () => { let config = { experimental: { optimizeUniversalDefaults: true },