Skip to content

Commit

Permalink
fix #3052: replace top-level & css with :scope
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Apr 16, 2023
1 parent f0704ba commit a4e19a7
Show file tree
Hide file tree
Showing 4 changed files with 51 additions and 9 deletions.
21 changes: 21 additions & 0 deletions CHANGELOG.md
@@ -1,5 +1,26 @@
# Changelog

## Unreleased

* Fix CSS nesting transform for top-level `&` ([#3052](https://github.com/evanw/esbuild/issues/3052))

Previously esbuild could crash with a stack overflow when lowering CSS nesting rules with a top-level `&`, such as in the code below. This happened because esbuild's CSS nesting transform didn't handle top-level `&`, causing esbuild to inline the top-level selector into itself. This release handles top-level `&` by replacing it with [the `:scope` pseudo-class](https://drafts.csswg.org/selectors-4/#the-scope-pseudo):

```css
/* Original code */
&,
a {
.b {
color: red;
}
}

/* New output (with --target=chrome90) */
:is(:scope, a) .b {
color: red;
}
```

## 0.17.16

* Fix CSS nesting transform for triple-nested rules that start with a combinator ([#3046](https://github.com/evanw/esbuild/issues/3046))
Expand Down
16 changes: 8 additions & 8 deletions internal/bundler_tests/snapshots/snapshots_css.txt
Expand Up @@ -254,22 +254,22 @@ a ~ b {

---------- /out/toplevel-ampersand-twice.css ----------
/* toplevel-ampersand-twice.css */
&,
& {
:scope,
:scope {
color: red;
}

---------- /out/toplevel-ampersand-first.css ----------
/* toplevel-ampersand-first.css */
&,
:scope,
a {
color: red;
}

---------- /out/toplevel-ampersand-second.css ----------
/* toplevel-ampersand-second.css */
a,
& {
:scope {
color: red;
}

Expand Down Expand Up @@ -318,16 +318,16 @@ a,
---------- /out/media-ampersand-twice.css ----------
/* media-ampersand-twice.css */
@media screen {
&,
& {
:scope,
:scope {
color: red;
}
}

---------- /out/media-ampersand-first.css ----------
/* media-ampersand-first.css */
@media screen {
&,
:scope,
a {
color: red;
}
Expand All @@ -337,7 +337,7 @@ a,
/* media-ampersand-second.css */
@media screen {
a,
& {
:scope {
color: red;
}
}
Expand Down
20 changes: 19 additions & 1 deletion internal/css_parser/css_nesting.go
Expand Up @@ -8,6 +8,12 @@ import (
func lowerNestingInRule(rule css_ast.Rule, results []css_ast.Rule) []css_ast.Rule {
switch r := rule.Data.(type) {
case *css_ast.RSelector:
scope := css_ast.ComplexSelector{
Selectors: []css_ast.CompoundSelector{{
SubclassSelectors: []css_ast.SS{&css_ast.SSPseudoClass{Name: "scope"}},
}},
}

// Filter out pseudo elements because they are ignored by nested style
// rules. This is because pseudo-elements are not valid within :is():
// https://www.w3.org/TR/selectors-4/#matches-pseudo. This restriction
Expand All @@ -17,7 +23,19 @@ func lowerNestingInRule(rule css_ast.Rule, results []css_ast.Rule) []css_ast.Rul
n := 0
for _, sel := range selectors {
if !sel.UsesPseudoElement() {
selectors[n] = sel
// Top-level "&" should be replaced with ":scope" to avoid recursion.
// From https://www.w3.org/TR/css-nesting-1/#nest-selector:
//
// "When used in the selector of a nested style rule, the nesting
// selector represents the elements matched by the parent rule. When
// used in any other context, it represents the same elements as
// :scope in that context (unless otherwise defined)."
//
substituted := make([]css_ast.CompoundSelector, 0, len(sel.Selectors))
for _, x := range sel.Selectors {
substituted = substituteAmpersandsInCompoundSelector(x, scope, substituted)
}
selectors[n] = css_ast.ComplexSelector{Selectors: substituted}
n++
}
}
Expand Down
3 changes: 3 additions & 0 deletions internal/css_parser/css_parser_test.go
Expand Up @@ -904,6 +904,9 @@ func TestNestedSelector(t *testing.T) {
expectPrintedLower(t, "div { :where(.foo + &) { color: red } }", ":where(.foo + div) {\n color: red;\n}\n")
expectPrintedLower(t, ".xy { :where(&, span:is(.foo &)) { color: red } }", ":where(.xy, span:is(.foo .xy)) {\n color: red;\n}\n")
expectPrintedLower(t, "div { :where(&, span:is(.foo &)) { color: red } }", ":where(div, span:is(.foo div)) {\n color: red;\n}\n")
expectPrintedLower(t, "&, a { color: red }", ":scope,\na {\n color: red;\n}\n")
expectPrintedLower(t, "&, a { .b { color: red } }", ":is(:scope, a) .b {\n color: red;\n}\n")
expectPrintedLower(t, "&, a { .b { .c { color: red } } }", ":is(:scope, a) .b .c {\n color: red;\n}\n")
expectPrintedLower(t, ".foo { @media screen {} }", "")
expectPrintedLower(t, ".foo { @media screen { color: red } }", "@media screen {\n .foo {\n color: red;\n }\n}\n")
expectPrintedLower(t, ".foo { @media screen { &:hover { color: red } } }", "@media screen {\n .foo:hover {\n color: red;\n }\n}\n")
Expand Down

0 comments on commit a4e19a7

Please sign in to comment.