When a selector list contains both compatible and incompatible selectors for the configured targets, Lightning CSS appears to split the selector list into multiple rules. However, if the compatible selector can be merged into a previous rule before the incompatible selector rule is materialized, the incompatible selector may be dropped entirely.
This can silently remove styles for newer selectors such as :has() or :focus-visible.
I reproduced this with Lightning CSS 1.32.0 with rust crate 1.0.0-alpha.71.
Minimal Reproduction
import { transform } from "lightningcss";
const css = `
.a:last-child {
border-bottom: none;
}
.a:last-child,
.a:has(+ .b) {
display: flex;
}
`;
for (const [label, targets] of [
["chrome 104", { chrome: 104 << 16 }],
["chrome 105", { chrome: 105 << 16 }],
]) {
const out = transform({
filename: "x.css",
code: Buffer.from(css),
minify: true,
targets,
}).code.toString();
console.log(label, out);
}
Actual Output
For chrome 104:
.a:last-child{border-bottom:none;display:flex}
The .a:has(+.b) rule is missing.
For chrome 105:
.a:last-child{border-bottom:none}.a:last-child,.a:has(+.b){display:flex}
The selector is preserved once :has() is compatible with the target.
Expected Output
For chrome 104, I would expect the incompatible selector to be preserved as a separate rule, similar to the behavior when there is no previous merge candidate:
.a:last-child{border-bottom:none;display:flex}
.a:has(+.b){display:flex}
Or equivalently:
.a:last-child{border-bottom:none}
.a:last-child{display:flex}
.a:has(+.b){display:flex}
The key point is that .a:has(+.b) should not be dropped.
Additional Reproduction With :focus-visible
This does not appear to be specific to :has().
import { transform } from "lightningcss";
const css = `
.a:hover {
color: blue;
}
.a:hover,
.a:focus-visible {
color: red;
}
`;
for (const [label, targets] of [
["chrome 85", { chrome: 85 << 16 }],
["chrome 86", { chrome: 86 << 16 }],
]) {
const out = transform({
filename: "x.css",
code: Buffer.from(css),
minify: true,
targets,
}).code.toString();
console.log(label, out);
}
Actual output for chrome 85:
The .a:focus-visible selector is dropped.
Actual output for chrome 86:
.a:hover{color:#00f}.a:hover,.a:focus-visible{color:red}
Suspected Cause
From reading the source, the order seems to be:
CssRuleList::minify detects an incompatible selector list and partitions selectors into compatible and incompatible selectors.
- The compatible selector remains in the current rule.
merge_style_rules may merge the current rule into the previous rule.
- During merge, declarations are drained from the current rule into the previous rule.
- Only after that, incompatible selector rules are created by cloning the current rule.
- Since the current rule's declarations have already been drained, the incompatible clone is empty and later skipped by
rule.is_empty().
So the bug seems to be an optimization ordering issue: incompatible selector rules depend on the original declarations, but those declarations can be consumed by merging before the incompatible rules are generated.
A possible fix would be to materialize incompatible selector rules before merging, or keep a pre-merge clone/snapshot of the rule declarations for incompatible selectors.
When a selector list contains both compatible and incompatible selectors for the configured targets, Lightning CSS appears to split the selector list into multiple rules. However, if the compatible selector can be merged into a previous rule before the incompatible selector rule is materialized, the incompatible selector may be dropped entirely.
This can silently remove styles for newer selectors such as
:has()or:focus-visible.I reproduced this with Lightning CSS
1.32.0with rust crate1.0.0-alpha.71.Minimal Reproduction
Actual Output
For
chrome 104:The
.a:has(+.b)rule is missing.For
chrome 105:The selector is preserved once
:has()is compatible with the target.Expected Output
For
chrome 104, I would expect the incompatible selector to be preserved as a separate rule, similar to the behavior when there is no previous merge candidate:Or equivalently:
The key point is that
.a:has(+.b)should not be dropped.Additional Reproduction With
:focus-visibleThis does not appear to be specific to
:has().Actual output for
chrome 85:The
.a:focus-visibleselector is dropped.Actual output for
chrome 86:Suspected Cause
From reading the source, the order seems to be:
CssRuleList::minifydetects an incompatible selector list and partitions selectors into compatible and incompatible selectors.merge_style_rulesmay merge the current rule into the previous rule.rule.is_empty().So the bug seems to be an optimization ordering issue: incompatible selector rules depend on the original declarations, but those declarations can be consumed by merging before the incompatible rules are generated.
A possible fix would be to materialize incompatible selector rules before merging, or keep a pre-merge clone/snapshot of the rule declarations for incompatible selectors.