Skip to content

fix(restructure): keep universal-subject selectors from reordering across same-specificity typed selectors#494

Open
spokodev wants to merge 1 commit into
css:masterfrom
spokodev:fix/restructure-universal-subject-cascade
Open

fix(restructure): keep universal-subject selectors from reordering across same-specificity typed selectors#494
spokodev wants to merge 1 commit into
css:masterfrom
spokodev:fix/restructure-universal-subject-cascade

Conversation

@spokodev

Copy link
Copy Markdown

Problem

The restructurer can change the meaning of valid CSS by reordering two rules across an intervening rule that targets the same element at equal specificity, flipping the cascade winner.

IN : div .x{color:red}.y span{color:green}div .x{color:red}
OUT: .y span{color:green}div .x{color:red}

That output is correct (the merged div .x stays after .y span, so red still wins). But on v5.0.5 / current main the same input drops the third rule entirely:

OUT (v5.0.5): div .x{color:red}.y span{color:green}

For <div class="y"><span class="x"></span></div>, the span matches both div .x and .y span, each with specificity (0,1,1). Per the CSS Cascade, Order of Appearance (§6.4.4, https://www.w3.org/TR/css-cascade-4/#cascade-order), the later rule wins, so the element computes color: red. The dropped-rule output computes color: green. Browser-verified red -> green.

minify(css, { restructure: false }) keeps all three rules, which isolates the issue to the restructurer.

Root cause

processSelector builds compareMarker = specificity + (",<tagName>" if the subject has a type selector). A Combinator resets tagName to *, so:

  • div .x -> subject .x, no type -> marker 0,1,1
  • .y span -> subject span -> marker 0,1,1,span

hasSimilarSelectors compares markers for exact equality. The differing ,tagName suffix is a fast-path that assumes two equal-specificity selectors with different subject element types can never match the same element. That assumption does not hold for a universal subject: .x (no type) can match a <span>, so div .x and .y span can match the same element and must be treated as conflicting. Because they were not, the restructurer skipped past .y span and merged the two div .x rules across it.

Fix

processSelector now also records, per simple selector, the specificity-only key (marker without the ,tagName suffix) and whether the subject is universal. hasSimilarSelectors reports a collision when either subject is universal and the specificity (plus scope) matches, in addition to the existing exact-marker equality.

compareMarker itself is unchanged, so the merge-eligibility logic in 7-mergeRuleset and 8-restructRuleset is untouched; only the skip/reorder gate gains the universal-subject awareness. Legitimate merges are preserved: two equal-specificity selectors with distinct concrete subject types (e.g. .y span vs .z em) still group, and the bug-family inputs now reorder into a cascade-preserving form instead of dropping a rule.

@keyframes selectors keep their by-value comparison (the fast-path is held inert there).

Tests

  • Added cases to fixtures/similarSelectors.json (div .x vs .y span, both orderings), which drive test/hasSimilarSelectors.js. They fail on current main (false, expected true) and pass with the fix.
  • Full suite green: 539 passing, 4 pending, lint clean.

Related

Issue #457 reports another restructurer cascade flip, but with a different mechanism (universal-subject attribute selectors merged at the declaration level). This fix targets the universal-subject marker collision in the skip/reorder gate and does not change the #457 output, so the two are separate.

…ross same-specificity typed selectors

The restructurer treats two equal-specificity selectors with different
subject element types as non-conflicting, using the `,tagName` suffix of
compareMarker as a fast-path. That assumption breaks when a selector has a
universal subject (no type selector on its subject), because such a
selector matches an element of any type and can collide with a
same-specificity typed-subject selector.

As a result the restructurer could reorder/merge two rules across an
intervening rule that targets the same element at equal specificity,
flipping the cascade winner.

hasSimilarSelectors now also reports a collision when either subject is
universal and the specificity (plus scope) matches, so the restructurer
stops at such a rule instead of skipping past it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant