From a4e19a7830618ece19ad68c18e345bb102016e7e Mon Sep 17 00:00:00 2001 From: Evan Wallace Date: Sun, 16 Apr 2023 14:55:29 -0400 Subject: [PATCH] fix #3052: replace top-level `&` css with `:scope` --- CHANGELOG.md | 21 +++++++++++++++++++ .../bundler_tests/snapshots/snapshots_css.txt | 16 +++++++------- internal/css_parser/css_nesting.go | 20 +++++++++++++++++- internal/css_parser/css_parser_test.go | 3 +++ 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c661758d80..4bf95ec72b1 100644 --- a/CHANGELOG.md +++ b/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)) diff --git a/internal/bundler_tests/snapshots/snapshots_css.txt b/internal/bundler_tests/snapshots/snapshots_css.txt index ce7e0d498ba..590618360c6 100644 --- a/internal/bundler_tests/snapshots/snapshots_css.txt +++ b/internal/bundler_tests/snapshots/snapshots_css.txt @@ -254,14 +254,14 @@ 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; } @@ -269,7 +269,7 @@ a { ---------- /out/toplevel-ampersand-second.css ---------- /* toplevel-ampersand-second.css */ a, -& { +:scope { color: red; } @@ -318,8 +318,8 @@ a, ---------- /out/media-ampersand-twice.css ---------- /* media-ampersand-twice.css */ @media screen { - &, - & { + :scope, + :scope { color: red; } } @@ -327,7 +327,7 @@ a, ---------- /out/media-ampersand-first.css ---------- /* media-ampersand-first.css */ @media screen { - &, + :scope, a { color: red; } @@ -337,7 +337,7 @@ a, /* media-ampersand-second.css */ @media screen { a, - & { + :scope { color: red; } } diff --git a/internal/css_parser/css_nesting.go b/internal/css_parser/css_nesting.go index 549c4367fa2..f767050d6ee 100644 --- a/internal/css_parser/css_nesting.go +++ b/internal/css_parser/css_nesting.go @@ -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 @@ -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++ } } diff --git a/internal/css_parser/css_parser_test.go b/internal/css_parser/css_parser_test.go index ae33d625eb3..71a9e09b247 100644 --- a/internal/css_parser/css_parser_test.go +++ b/internal/css_parser/css_parser_test.go @@ -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")