Skip to content

Incompatible selectors can be dropped after rule merging when targets require selector splitting #1260

@Sczlog

Description

@Sczlog

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:

.a:hover{color:red}

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:

  1. CssRuleList::minify detects an incompatible selector list and partitions selectors into compatible and incompatible selectors.
  2. The compatible selector remains in the current rule.
  3. merge_style_rules may merge the current rule into the previous rule.
  4. During merge, declarations are drained from the current rule into the previous rule.
  5. Only after that, incompatible selector rules are created by cloning the current rule.
  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions