Skip to content

Commit a3fcf70

Browse files
committed
fix #3096: css transform bug with nested selectors
1 parent c19689a commit a3fcf70

File tree

4 files changed

+91
-12
lines changed

4 files changed

+91
-12
lines changed

Diff for: CHANGELOG.md

+37
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,43 @@
22

33
## Unreleased
44

5+
* Fix CSS transform bugs with nested selectors that start with a combinator ([#3096](https://github.com/evanw/esbuild/issues/3096))
6+
7+
This release fixes several bugs regarding transforming nested CSS into non-nested CSS for older browsers. The bugs were due to lack of test coverage for nested selectors with more than one compound selector where they all start with the same combinator. Here's what some problematic cases look like before and after these fixes:
8+
9+
```css
10+
/* Original code */
11+
.foo {
12+
> &a,
13+
> &b {
14+
color: red;
15+
}
16+
}
17+
.bar {
18+
> &a,
19+
+ &b {
20+
color: green;
21+
}
22+
}
23+
24+
/* Old output (with --target=chrome90) */
25+
.foo :is(> .fooa, > .foob) {
26+
color: red;
27+
}
28+
.bar :is(> .bara, + .barb) {
29+
color: green;
30+
}
31+
32+
/* New output (with --target=chrome90) */
33+
.foo > :is(a.foo, b.foo) {
34+
color: red;
35+
}
36+
.bar > a.bar,
37+
.bar + b.bar {
38+
color: green;
39+
}
40+
```
41+
542
* Avoid removing unrecognized directives from the directive prologue when minifying ([#3115](https://github.com/evanw/esbuild/issues/3115))
643

744
The [directive prologue](https://262.ecma-international.org/6.0/#sec-directive-prologues-and-the-use-strict-directive) in JavaScript is a sequence of top-level string expressions that come before your code. The only directives that JavaScript engines currently recognize are `use strict` and sometimes `use asm`. However, the people behind React have made up their own directive for their own custom dialect of JavaScript. Previously esbuild only preserved the `use strict` directive when minifying, although you could still write React JavaScript with esbuild using something like `--banner:js="'your directive here';"`. With this release, you can now put arbitrary directives in the entry point and esbuild will preserve them in its minified output:

Diff for: internal/css_ast/css_ast.go

+10-5
Original file line numberDiff line numberDiff line change
@@ -598,11 +598,14 @@ type ComplexSelector struct {
598598
Selectors []CompoundSelector
599599
}
600600

601-
func (s ComplexSelector) AppendToTokens(tokens []Token) []Token {
601+
func (s ComplexSelector) AppendToTokensWithoutLeadingCombinator(tokens []Token) []Token {
602602
for i, sel := range s.Selectors {
603603
if n := len(tokens); i > 0 && n > 0 {
604604
tokens[n-1].Whitespace |= WhitespaceAfter
605605
}
606+
if i == 0 {
607+
sel.Combinator = 0
608+
}
606609
tokens = sel.AppendToTokens(tokens)
607610
}
608611
return tokens
@@ -714,14 +717,16 @@ func (sel CompoundSelector) AppendToTokens(tokens []Token) []Token {
714717
}
715718
}
716719

717-
if sel.HasNestingSelector {
718-
tokens = append(tokens, Token{Kind: css_lexer.TDelimAmpersand, Text: "&"})
719-
}
720-
721720
if sel.TypeSelector != nil {
722721
tokens = sel.TypeSelector.AppendToTokens(tokens)
723722
}
724723

724+
// Put this after the type selector in case it's substituted for ":is()"
725+
// ".foo { > &a, > &b {} }" => ".foo > :is(b.foo, c.foo) {}" (we don't want to get ".foo > :is(.foob, .fooc) {}" instead)
726+
if sel.HasNestingSelector {
727+
tokens = append(tokens, Token{Kind: css_lexer.TDelimAmpersand, Text: "&"})
728+
}
729+
725730
for _, ss := range sel.SubclassSelectors {
726731
tokens = ss.AppendToTokens(tokens)
727732
}

Diff for: internal/css_parser/css_nesting.go

+28-7
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ func lowerNestingInRuleWithContext(rule css_ast.Rule, context *lowerNestingConte
108108
// Pass 1: Canonicalize and analyze our selectors
109109
canUseGroupDescendantCombinator := true // Can we do "parent «space» :is(...selectors)"?
110110
canUseGroupSubSelector := true // Can we do "parent«nospace»:is(...selectors)"?
111+
var commonLeadingCombinator uint8
111112
for i := range r.Selectors {
112113
sel := &r.Selectors[i]
113114

@@ -119,6 +120,17 @@ func lowerNestingInRuleWithContext(rule css_ast.Rule, context *lowerNestingConte
119120
// Are all children of the form "& «something»"?
120121
if len(sel.Selectors) < 2 || !sel.Selectors[0].IsSingleAmpersand() {
121122
canUseGroupDescendantCombinator = false
123+
} else {
124+
// If all children are of the form "& «COMBINATOR» «something»", is «COMBINATOR» the same in all cases?
125+
var combinator uint8
126+
if len(sel.Selectors) >= 2 {
127+
combinator = sel.Selectors[1].Combinator
128+
}
129+
if i == 0 {
130+
commonLeadingCombinator = combinator
131+
} else if commonLeadingCombinator != combinator {
132+
canUseGroupDescendantCombinator = false
133+
}
122134
}
123135

124136
// Are all children of the form "&«something»"?
@@ -130,6 +142,7 @@ func lowerNestingInRuleWithContext(rule css_ast.Rule, context *lowerNestingConte
130142
// Try to apply simplifications for shorter output
131143
if canUseGroupDescendantCombinator {
132144
// "& a, & b {}" => "& :is(a, b) {}"
145+
// "& > a, & > b {}" => "& > :is(a, b) {}"
133146
for i := range r.Selectors {
134147
sel := &r.Selectors[i]
135148
sel.Selectors = sel.Selectors[1:]
@@ -139,6 +152,7 @@ func lowerNestingInRuleWithContext(rule css_ast.Rule, context *lowerNestingConte
139152
r.Selectors = []css_ast.ComplexSelector{merged}
140153
} else if canUseGroupSubSelector {
141154
// "&a, &b {}" => "&:is(a, b) {}"
155+
// "> &a, > &b {}" => "> &:is(a, b) {}"
142156
for i := range r.Selectors {
143157
sel := &r.Selectors[i]
144158
sel.Selectors[0].HasNestingSelector = false
@@ -212,27 +226,29 @@ func substituteAmpersandsInCompoundSelector(sel css_ast.CompoundSelector, replac
212226

213227
// Convert the replacement to a single compound selector
214228
var single css_ast.CompoundSelector
215-
if len(replacement.Selectors) == 1 || len(results) == 0 {
229+
if sel.Combinator == 0 && (len(replacement.Selectors) == 1 || len(results) == 0) {
216230
// ".foo { :hover & {} }" => ":hover .foo {}"
217231
// ".foo .bar { &:hover {} }" => ".foo .bar:hover {}"
218232
last := len(replacement.Selectors) - 1
219233
results = append(results, replacement.Selectors[:last]...)
220234
single = replacement.Selectors[last]
235+
sel.Combinator = single.Combinator
236+
} else if len(replacement.Selectors) == 1 {
237+
// ".foo { > &:hover {} }" => ".foo > .foo:hover {}"
238+
single = replacement.Selectors[0]
221239
} else {
222240
// ".foo .bar { :hover & {} }" => ":hover :is(.foo .bar) {}"
241+
// ".foo .bar { > &:hover {} }" => ".foo .bar > :is(.foo .bar):hover {}"
223242
single = css_ast.CompoundSelector{
224243
SubclassSelectors: []css_ast.SS{&css_ast.SSPseudoClass{
225244
Name: "is",
226-
Args: replacement.AppendToTokens(nil),
245+
Args: replacement.AppendToTokensWithoutLeadingCombinator(nil),
227246
}},
228247
}
229248
}
230249

231250
var subclassSelectorPrefix []css_ast.SS
232251

233-
// Copy over the combinator, if any
234-
sel.Combinator = single.Combinator
235-
236252
// Insert the type selector
237253
if single.TypeSelector != nil {
238254
if sel.TypeSelector != nil {
@@ -281,7 +297,7 @@ func substituteAmpersandsInTokens(tokens []css_ast.Token, replacement css_ast.Co
281297
}
282298

283299
var results []css_ast.Token
284-
replacementTokens := replacement.AppendToTokens(nil)
300+
replacementTokens := replacement.AppendToTokensWithoutLeadingCombinator(nil)
285301
for _, t := range tokens {
286302
if t.Kind != css_lexer.TDelimAmpersand {
287303
results = append(results, t)
@@ -332,16 +348,21 @@ func multipleComplexSelectorsToSingleComplexSelector(selectors []css_ast.Complex
332348

333349
// This must be non-nil so we print ":is()" instead of ":is"
334350
tokens := []css_ast.Token{}
351+
var leadingCombinator uint8
335352

336353
for i, sel := range selectors {
337354
if i > 0 {
338355
tokens = append(tokens, css_ast.Token{Kind: css_lexer.TComma, Text: ",", Whitespace: css_ast.WhitespaceAfter})
339356
}
340-
tokens = sel.AppendToTokens(tokens)
357+
358+
// "> a, > b" => "> :is(a, b)" (the caller should have already checked that all leading combinators are the same)
359+
leadingCombinator = sel.Selectors[0].Combinator
360+
tokens = sel.AppendToTokensWithoutLeadingCombinator(tokens)
341361
}
342362

343363
return css_ast.ComplexSelector{
344364
Selectors: []css_ast.CompoundSelector{{
365+
Combinator: leadingCombinator,
345366
SubclassSelectors: []css_ast.SS{&css_ast.SSPseudoClass{
346367
Name: "is",
347368
Args: tokens,

Diff for: internal/css_parser/css_parser_test.go

+16
Original file line numberDiff line numberDiff line change
@@ -907,6 +907,22 @@ func TestNestedSelector(t *testing.T) {
907907
expectPrintedLower(t, "&, a { color: red }", ":scope,\na {\n color: red;\n}\n")
908908
expectPrintedLower(t, "&, a { .b { color: red } }", ":is(:scope, a) .b {\n color: red;\n}\n")
909909
expectPrintedLower(t, "&, a { .b { .c { color: red } } }", ":is(:scope, a) .b .c {\n color: red;\n}\n")
910+
expectPrintedLower(t, "a { > b, > c { color: red } }", "a > :is(b, c) {\n color: red;\n}\n")
911+
expectPrintedLower(t, "a { > b, + c { color: red } }", "a > b,\na + c {\n color: red;\n}\n")
912+
expectPrintedLower(t, "a { & > b, & > c { color: red } }", "a > :is(b, c) {\n color: red;\n}\n")
913+
expectPrintedLower(t, "a { & > b, & + c { color: red } }", "a > b,\na + c {\n color: red;\n}\n")
914+
expectPrintedLower(t, "a { > &b, > &c { color: red } }", "a > :is(b:is(a), c:is(a)) {\n color: red;\n}\n")
915+
expectPrintedLower(t, "a { > &b, + &c { color: red } }", "a > a:is(b),\na + a:is(c) {\n color: red;\n}\n")
916+
expectPrintedLower(t, "a { > &.b, > &.c { color: red } }", "a > :is(a.b, a.c) {\n color: red;\n}\n")
917+
expectPrintedLower(t, "a { > &.b, + &.c { color: red } }", "a > a.b,\na + a.c {\n color: red;\n}\n")
918+
expectPrintedLower(t, ".a { > &b, > &c { color: red } }", ".a > :is(b.a, c.a) {\n color: red;\n}\n")
919+
expectPrintedLower(t, ".a { > &b, + &c { color: red } }", ".a > b.a,\n.a + c.a {\n color: red;\n}\n")
920+
expectPrintedLower(t, ".a { > &.b, > &.c { color: red } }", ".a > :is(.a.b, .a.c) {\n color: red;\n}\n")
921+
expectPrintedLower(t, ".a { > &.b, + &.c { color: red } }", ".a > .a.b,\n.a + .a.c {\n color: red;\n}\n")
922+
expectPrintedLower(t, "~ .a { > &.b, > &.c { color: red } }", "~ .a > :is(.a.b, .a.c) {\n color: red;\n}\n")
923+
expectPrintedLower(t, "~ .a { > &.b, + &.c { color: red } }", "~ .a > .a.b,\n~ .a + .a.c {\n color: red;\n}\n")
924+
expectPrintedLower(t, ".foo .bar { > &.a, > &.b { color: red } }", ".foo .bar > :is(.foo .bar.a, .foo .bar.b) {\n color: red;\n}\n")
925+
expectPrintedLower(t, ".foo .bar { > &.a, + &.b { color: red } }", ".foo .bar > :is(.foo .bar).a,\n.foo .bar + :is(.foo .bar).b {\n color: red;\n}\n")
910926
expectPrintedLower(t, ".foo { @media screen {} }", "")
911927
expectPrintedLower(t, ".foo { @media screen { color: red } }", "@media screen {\n .foo {\n color: red;\n }\n}\n")
912928
expectPrintedLower(t, ".foo { @media screen { &:hover { color: red } } }", "@media screen {\n .foo:hover {\n color: red;\n }\n}\n")

0 commit comments

Comments
 (0)