Skip to content

Combine every & with each parent selector - #176

Merged
ai merged 1 commit into
postcss:mainfrom
wahidrizka:fix-multiple-ampersands
Oct 12, 2026
Merged

ai merged 1 commit into
postcss:mainfrom
wahidrizka:fix-multiple-ampersands

Conversation

@wahidrizka

Copy link
Copy Markdown
Contributor

A nested selector with more than one & gets the same parent selector for every &, so with a selector list as the parent some combinations are dropped:

.a, .b {
  & + & { margin-left: 8px }
}
/* postcss-nested 8.0.1 */
.a + .a, .b + .b { margin-left: 8px }
/* Sass 1.105.1 */
.a + .a, .a + .b, .b + .a, .b + .b {
  margin-left: 8px;
}

Native CSS nesting reads & as :is(.a, .b), so it matches the same four. In Chrome 154, with .a, .b, .a, .b siblings, the native rule and the Sass output style the last three, and the postcss-nested output styles none of them. The same happens with & ~ &, & & and three &.

mergeSelectors() already loops over the parent selectors for the first &. Now, when a selector has more than one & at its top level, every & after the first one takes each parent selector in turn. Selectors with one & keep the old path, so their output does not change.

To check it, I compared postcss-nested with Sass on generated nested rules that Sass accepts. On 2,537 rules whose selectors have two or three &, 1,440 gave a different set of selectors on main and none do now. On 4,000 more general rules (2,967 of them accepted by Sass), 178 more now match Sass and none stopped matching.

:not(&) with a selector list also differs from Sass (:not(.a) .x, :not(.b) .x instead of :not(.a, .b) .x, which matches different elements). I left it out; I can send it separately if you want.

The new test fails on main. pnpm test passes.

A nested selector with more than one & got the same parent selector for
every &, so `.a, .b { & + & {} }` gave `.a + .a, .b + .b` and dropped
`.a + .b` and `.b + .a`. Sass and native CSS nesting match all four.
@ai
ai merged commit 3f3cf1b into postcss:main Oct 12, 2026
3 checks passed
@ai

ai commented Oct 12, 2026

Copy link
Copy Markdown
Member

Seems like it is breaking changes. Let's combine all changes together.

:not(&) with a selector list also differs from Sass (:not(.a) .x, :not(.b) .x instead of :not(.a, .b) .x, which matches different elements). I left it out; I can send it separately if you want.

Tell me more about it.

And how CSS nesting is working in that case?

@wahidrizka

Copy link
Copy Markdown
Contributor Author

& inside :not() is replaced with one parent selector at a time, so a parent list turns into a list of negations:

.a, .b {
  .x:not(&) { color: red }
}
/* postcss-nested */
.x:not(.a), .x:not(.b) { color: red }
/* Sass 1.105.1 */
.x:not(.a, .b) {
  color: red;
}

Native CSS nesting replaces & with the parent list wrapped in :is() (spec), so the rule means .x:not(:is(.a, .b)): an .x that is neither .a nor .b. The Sass output means the same. The postcss-nested output means "not .a, or not .b", so it also matches an .x that is only .a or only .b.

In Chrome 154 with <p class="x">, <p class="x a">, <p class="x b"> and <p class="x a b">, the native rule and the Sass output style only the first one, and the postcss-nested output styles the first three. :not(&) > .x is the same: the postcss-nested output also styles an .x inside .a or .b.

:is(&), :where(&) and :has(&) style the same elements in all three, so of these only :not() changes which elements match. A fix would put the whole parent list inside :not(), as Sass does. If you want it in the same release, I can send a PR.

@ai

ai commented Oct 12, 2026

Copy link
Copy Markdown
Member

yes, please prepare PR

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.

2 participants