From 5bce16fd32130864f3ede497cbf934f55ceee531 Mon Sep 17 00:00:00 2001 From: Yarchik Date: Thu, 25 Jun 2026 19:13:26 +0100 Subject: [PATCH] fix(restructure): keep universal-subject selectors from reordering across 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. --- fixtures/similarSelectors.json | 4 +++- lib/restructure/prepare/index.js | 5 +++++ lib/restructure/prepare/processSelector.js | 10 ++++++++++ lib/restructure/utils.js | 13 +++++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/fixtures/similarSelectors.json b/fixtures/similarSelectors.json index 0ac50d46..19abd8cd 100644 --- a/fixtures/similarSelectors.json +++ b/fixtures/similarSelectors.json @@ -1,4 +1,6 @@ [ { "rule1": "ul#nav, .a .b, ul.a.b {}", "rule2": "div > a, .c .d, li.active {}", "expected": true }, - { "rule1": "ul#nav, .a .b, ul.a.b {}", "rule2": "div > a, .c .d .e, li.active {}", "expected": false } + { "rule1": "ul#nav, .a .b, ul.a.b {}", "rule2": "div > a, .c .d .e, li.active {}", "expected": false }, + { "rule1": "div .x {}", "rule2": ".y span {}", "expected": true }, + { "rule1": ".y span {}", "rule2": "div .x {}", "expected": true } ] diff --git a/lib/restructure/prepare/index.js b/lib/restructure/prepare/index.js index fc08082e..5b29dce1 100644 --- a/lib/restructure/prepare/index.js +++ b/lib/restructure/prepare/index.js @@ -29,6 +29,11 @@ export default function prepare(ast, options) { node.block.children.forEach(function(rule) { rule.prelude.children.forEach(function(simpleselector) { simpleselector.compareMarker = simpleselector.id; + // keyframe selectors are compared by value (id); keep the + // universal-subject fast-path inert so they only match by + // exact equality, as before + simpleselector.compareMarkerSpecificity = simpleselector.id; + simpleselector.compareMarkerUniversal = false; }); }); } diff --git a/lib/restructure/prepare/processSelector.js b/lib/restructure/prepare/processSelector.js index 55a4fed8..1b165867 100644 --- a/lib/restructure/prepare/processSelector.js +++ b/lib/restructure/prepare/processSelector.js @@ -85,6 +85,16 @@ export default function processSelector(node, usageData) { simpleSelector.compareMarker += ':' + scope; } + // The compareMarker without the subject type (specificity, plus scope). + // A selector with a universal subject (no type selector on its subject, + // i.e. tagName === '*') can match an element of any type, so it may + // collide with a same-specificity selector that has a concrete subject + // type. This key lets hasSimilarSelectors detect that case, which a plain + // compareMarker equality misses (see fixtures/similarSelectors.json). + simpleSelector.compareMarkerSpecificity = null; // pre-init to avoid multiple hidden class + simpleSelector.compareMarkerSpecificity = simpleSelector.compareMarker; + simpleSelector.compareMarkerUniversal = tagName === '*'; + if (tagName !== '*') { simpleSelector.compareMarker += ',' + tagName; } diff --git a/lib/restructure/utils.js b/lib/restructure/utils.js index 792cc59b..c258862f 100644 --- a/lib/restructure/utils.js +++ b/lib/restructure/utils.js @@ -109,6 +109,19 @@ export function hasSimilarSelectors(selectors1, selectors2) { return true; } + // The subject-type suffix of compareMarker is a fast-path that + // assumes two equal-specificity selectors with different subject + // types can't match the same element. That doesn't hold when either + // subject is universal (no type selector): such a selector matches an + // element of any type, so it may collide with a same-specificity + // typed-subject selector (e.g. `div .x` vs `.y span`). Treat those as + // similar so the restructurer won't reorder across them and flip the + // cascade winner. + if ((cursor1.data.compareMarkerUniversal || cursor2.data.compareMarkerUniversal) && + cursor1.data.compareMarkerSpecificity === cursor2.data.compareMarkerSpecificity) { + return true; + } + cursor2 = cursor2.next; }