Skip to content

Commit 8fcd3ca

Browse files
committed
Only check selectors containing base apply candidates for circular dependencies
When given a two rule like `html.dark .a, .b { … }` and `html.dark .c { @apply b }` we would see `.dark` in both the base rule and the rule being applied and consider it a circular dependency. However, the selectors `html.dark .a` and `.b` are considered on their own and is therefore do not introduce a circular dependency. This better matches the user’s mental model that the selectors are just two definitions sharing the same properties.
1 parent 9221914 commit 8fcd3ca

File tree

2 files changed

+115
-2
lines changed

2 files changed

+115
-2
lines changed

src/lib/expandApplyAtRules.js

+27-2
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,30 @@ import escapeClassName from '../util/escapeClassName'
88
/** @typedef {Map<string, [any, import('postcss').Rule[]]>} ApplyCache */
99

1010
function extractClasses(node) {
11-
let classes = new Set()
11+
/** @type {Map<string, Set<string>>} */
12+
let groups = new Map()
13+
1214
let container = postcss.root({ nodes: [node.clone()] })
1315

1416
container.walkRules((rule) => {
1517
parser((selectors) => {
1618
selectors.walkClasses((classSelector) => {
19+
let parentSelector = classSelector.parent.toString()
20+
21+
let classes = groups.get(parentSelector)
22+
if (!classes) {
23+
groups.set(parentSelector, (classes = new Set()))
24+
}
25+
1726
classes.add(classSelector.value)
1827
})
1928
}).processSync(rule.selector)
2029
})
2130

22-
return Array.from(classes)
31+
let normalizedGroups = Array.from(groups.values(), (classes) => Array.from(classes))
32+
let classes = normalizedGroups.flat()
33+
34+
return Object.assign(classes, { groups: normalizedGroups })
2335
}
2436

2537
function extractBaseCandidates(candidates, separator) {
@@ -353,10 +365,23 @@ function processApply(root, context, localCache) {
353365
let siblings = []
354366

355367
for (let [applyCandidate, important, rules] of candidates) {
368+
let potentialApplyCandidates = [
369+
applyCandidate,
370+
...extractBaseCandidates([applyCandidate], context.tailwindConfig.separator),
371+
]
372+
356373
for (let [meta, node] of rules) {
357374
let parentClasses = extractClasses(parent)
358375
let nodeClasses = extractClasses(node)
359376

377+
// 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`
378+
// So we've split them into groups
379+
nodeClasses = nodeClasses.groups
380+
.filter((classList) =>
381+
classList.some((className) => potentialApplyCandidates.includes(className))
382+
)
383+
.flat()
384+
360385
// Add base utility classes from the @apply node to the list of
361386
// classes to check whether it intersects and therefore results in a
362387
// circular dependency or not.

tests/apply.test.js

+88
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,94 @@ it('should throw when trying to apply an indirect circular dependency with a mod
658658
})
659659
})
660660

661+
it('should not throw when the circular dependency is part of a different selector (1)', () => {
662+
let config = {
663+
content: [{ raw: html`<div class="c"></div>` }],
664+
plugins: [],
665+
}
666+
667+
let input = css`
668+
@tailwind utilities;
669+
670+
@layer utilities {
671+
html.dark .a,
672+
.b {
673+
color: red;
674+
}
675+
}
676+
677+
html.dark .c {
678+
@apply b;
679+
}
680+
`
681+
682+
return run(input, config).then((result) => {
683+
expect(result.css).toMatchFormattedCss(css`
684+
html.dark .c {
685+
color: red;
686+
}
687+
`)
688+
})
689+
})
690+
691+
it('should not throw when the circular dependency is part of a different selector (2)', () => {
692+
let config = {
693+
content: [{ raw: html`<div class="c"></div>` }],
694+
plugins: [],
695+
}
696+
697+
let input = css`
698+
@tailwind utilities;
699+
700+
@layer utilities {
701+
html.dark .a,
702+
.b {
703+
color: red;
704+
}
705+
}
706+
707+
html.dark .c {
708+
@apply hover:b;
709+
}
710+
`
711+
712+
return run(input, config).then((result) => {
713+
expect(result.css).toMatchFormattedCss(css`
714+
html.dark .c:hover {
715+
color: red;
716+
}
717+
`)
718+
})
719+
})
720+
721+
it('should throw when the circular dependency is part of the same selector', () => {
722+
let config = {
723+
content: [{ raw: html`<div class="c"></div>` }],
724+
plugins: [],
725+
}
726+
727+
let input = css`
728+
@tailwind utilities;
729+
730+
@layer utilities {
731+
html.dark .a,
732+
html.dark .b {
733+
color: red;
734+
}
735+
}
736+
737+
html.dark .c {
738+
@apply hover:b;
739+
}
740+
`
741+
742+
return run(input, config).catch((err) => {
743+
expect(err.reason).toBe(
744+
'You cannot `@apply` the `hover:b` utility here because it creates a circular dependency.'
745+
)
746+
})
747+
})
748+
661749
it('rules with vendor prefixes are still separate when optimizing defaults rules', () => {
662750
let config = {
663751
experimental: { optimizeUniversalDefaults: true },

0 commit comments

Comments
 (0)