Skip to content

Commit dfe0e1c

Browse files
committed
fix #4114: add a limit to css nesting expansion
1 parent a54916b commit dfe0e1c

File tree

3 files changed

+46
-1
lines changed

3 files changed

+46
-1
lines changed

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,28 @@
5252

5353
Some libraries have many files and also use the same legal comment text in all files. Previously esbuild would copy each legal comment to the output file. Starting with this release, legal comments duplicated across separate files will now be grouped in the output file by unique comment content.
5454

55+
* Add a limit to CSS nesting expansion ([#4114](https://github.com/evanw/esbuild/issues/4114))
56+
57+
With this release, esbuild will now fail with an error if there is too much CSS nesting expansion. This can happen when nested CSS is converted to CSS without nesting for older browsers as expanding CSS nesting is inherently exponential due to the resulting combinatorial explosion. The expansion limit is currently hard-coded and cannot be changed, but is extremely unlikely to trigger for real code. It exists to prevent esbuild from using too much time and/or memory. Here's an example:
58+
59+
```css
60+
a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{color:red}}}}}}}}}}}}}}}}}}}}
61+
```
62+
63+
Previously, transforming this file with `--target=safari1` took 5 seconds and generated 40mb of CSS. Trying to do that will now generate the following error instead:
64+
65+
```
66+
✘ [ERROR] CSS nesting is causing too much expansion
67+
68+
example.css:1:60:
69+
1 │ a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{color:red}}}}}}}}}}}}}}}}}}}}
70+
^
71+
72+
CSS nesting expansion was terminated because a rule was generated with 65536 selectors. This limit
73+
exists to prevent esbuild from using too much time and/or memory. Please change your CSS to use
74+
fewer levels of nesting.
75+
```
76+
5577
* Fix path resolution edge case ([#4144](https://github.com/evanw/esbuild/issues/4144))
5678

5779
This fixes an edge case where esbuild's path resolution algorithm could deviate from node's path resolution algorithm. It involves a confusing situation where a directory shares the same file name as a file (but without the file extension). See the linked issue for specific details. This appears to be a case where esbuild is correctly following [node's published resolution algorithm](https://nodejs.org/api/modules.html#all-together) but where node itself is doing something different. Specifically the step `LOAD_AS_FILE` appears to be skipped when the input ends with `..`. This release changes esbuild's behavior for this edge case to match node's behavior.

internal/css_parser/css_nesting.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ func (p *parser) lowerNestingInRule(rule css_ast.Rule, results []css_ast.Rule) [
4141
// Filter out pseudo elements because they are ignored by nested style
4242
// rules. This is because pseudo-elements are not valid within :is():
4343
// https://www.w3.org/TR/selectors-4/#matches-pseudo. This restriction
44-
// may be relaxed in the future, but this restriction hash shipped so
44+
// may be relaxed in the future, but this restriction has shipped so
4545
// we're stuck with it: https://github.com/w3c/csswg-drafts/issues/7433.
4646
//
4747
// Note: This is only for the parent selector list that is used to
@@ -109,6 +109,8 @@ type lowerNestingContext struct {
109109
func (p *parser) lowerNestingInRuleWithContext(rule css_ast.Rule, context *lowerNestingContext) css_ast.Rule {
110110
switch r := rule.Data.(type) {
111111
case *css_ast.RSelector:
112+
oldSelectorsLen := len(r.Selectors)
113+
112114
// "a { & b {} }" => "a b {}"
113115
// "a { &b {} }" => "a:is(b) {}"
114116
// "a { &:hover {} }" => "a:hover {}"
@@ -227,6 +229,16 @@ func (p *parser) lowerNestingInRuleWithContext(rule css_ast.Rule, context *lower
227229
r.Selectors = selectors
228230
}
229231

232+
// Put limits on the combinatorial explosion to avoid using too much time
233+
// and/or memory.
234+
if n := len(r.Selectors); n > oldSelectorsLen && n > 0xFFFF {
235+
p.log.AddErrorWithNotes(&p.tracker, logger.Range{Loc: rule.Loc}, "CSS nesting is causing too much expansion",
236+
[]logger.MsgData{{Text: fmt.Sprintf("CSS nesting expansion was terminated because a rule was generated with %d selectors. "+
237+
"This limit exists to prevent esbuild from using too much time and/or memory. "+
238+
"Please change your CSS to use fewer levels of nesting.", n)}})
239+
return css_ast.Rule{}
240+
}
241+
230242
// Lower all child rules using our newly substituted selector
231243
context.loweredRules = p.lowerNestingInRule(rule, context.loweredRules)
232244
return css_ast.Rule{}

scripts/js-api-tests.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5963,6 +5963,17 @@ class Foo {
59635963
assert.strictEqual(code, `div{color:#abcd}\n`)
59645964
},
59655965

5966+
async cssNestingExpansionLimit({ esbuild }) {
5967+
const css = `a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{a,b{color:red}}}}}}}}}}}}}}}}}}}}`
5968+
try {
5969+
await esbuild.transform(css, { loader: 'css', target: 'safari1' })
5970+
throw new Error('Expected a transform failure')
5971+
} catch (e) {
5972+
assert.strictEqual(e.errors.length, 1)
5973+
assert.strictEqual(e.errors[0].text, 'CSS nesting is causing too much expansion')
5974+
}
5975+
},
5976+
59665977
async cjs_require({ esbuild }) {
59675978
const { code } = await esbuild.transform(`const {foo} = require('path')`, {})
59685979
assert.strictEqual(code, `const { foo } = require("path");\n`)

0 commit comments

Comments
 (0)