Skip to content

Commit 4ab2a7f

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 4ab2a7f

File tree

2 files changed

+107
-2
lines changed

2 files changed

+107
-2
lines changed

src/lib/expandApplyAtRules.js

Lines changed: 22 additions & 2 deletions
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,18 @@ function processApply(root, context, localCache) {
353365
let siblings = []
354366

355367
for (let [applyCandidate, important, rules] of candidates) {
368+
let potentialApplyCandidates = [applyCandidate, ...extractBaseCandidates([applyCandidate], context.tailwindConfig.separator)]
369+
356370
for (let [meta, node] of rules) {
357371
let parentClasses = extractClasses(parent)
358372
let nodeClasses = extractClasses(node)
359373

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

tests/apply.test.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,91 @@ 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, .b {
672+
color: red;
673+
}
674+
}
675+
676+
html.dark .c {
677+
@apply b;
678+
}
679+
`
680+
681+
return run(input, config).then((result) => {
682+
expect(result.css).toMatchFormattedCss(css`
683+
html.dark .c {
684+
color: red;
685+
}
686+
`)
687+
})
688+
})
689+
690+
it('should not throw when the circular dependency is part of a different selector (2)', () => {
691+
let config = {
692+
content: [{ raw: html`<div class="c"></div>` }],
693+
plugins: [],
694+
}
695+
696+
let input = css`
697+
@tailwind utilities;
698+
699+
@layer utilities {
700+
html.dark .a, .b {
701+
color: red;
702+
}
703+
}
704+
705+
html.dark .c {
706+
@apply hover:b;
707+
}
708+
`
709+
710+
return run(input, config).then((result) => {
711+
expect(result.css).toMatchFormattedCss(css`
712+
html.dark .c:hover {
713+
color: red;
714+
}
715+
`)
716+
})
717+
})
718+
719+
it('should throw when the circular dependency is part of the same selector', () => {
720+
let config = {
721+
content: [{ raw: html`<div class="c"></div>` }],
722+
plugins: [],
723+
}
724+
725+
let input = css`
726+
@tailwind utilities;
727+
728+
@layer utilities {
729+
html.dark .a, html.dark .b {
730+
color: red;
731+
}
732+
}
733+
734+
html.dark .c {
735+
@apply hover:b;
736+
}
737+
`
738+
739+
return run(input, config).catch((err) => {
740+
expect(err.reason).toBe(
741+
'You cannot `@apply` the `hover:b` utility here because it creates a circular dependency.'
742+
)
743+
})
744+
})
745+
661746
it('rules with vendor prefixes are still separate when optimizing defaults rules', () => {
662747
let config = {
663748
experimental: { optimizeUniversalDefaults: true },

0 commit comments

Comments
 (0)