Skip to content

Commit ccc8e8b

Browse files
committed
fix #2619: bug with single-use substitutions
1 parent 4608721 commit ccc8e8b

4 files changed

Lines changed: 145 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,49 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
* Fix minifier correctness bug with single-use substitutions ([#2619](https://github.com/evanw/esbuild/issues/2619))
6+
7+
When minification is enabled, esbuild will attempt to eliminate variables that are only used once in certain cases. For example, esbuild minifies this code:
8+
9+
```js
10+
function getEmailForUser(name) {
11+
let users = db.table('users');
12+
let user = users.find({ name });
13+
let email = user?.get('email');
14+
return email;
15+
}
16+
```
17+
18+
into this code:
19+
20+
```js
21+
function getEmailForUser(e){return db.table("users").find({name:e})?.get("email")}
22+
```
23+
24+
However, this transformation had a bug where esbuild did not correctly consider the "read" part of binary read-modify-write assignment operators. For example, it's incorrect to minify the following code into `bar += fn()` because the call to `fn()` might modify `bar`:
25+
26+
```js
27+
const foo = fn();
28+
bar += foo;
29+
```
30+
31+
In addition to fixing this correctness bug, this release also improves esbuild's output in the case where all values being skipped over are primitives:
32+
33+
```js
34+
function toneMapLuminance(r, g, b) {
35+
let hdr = luminance(r, g, b)
36+
let decay = 1 / (1 + hdr)
37+
return 1 - decay
38+
}
39+
```
40+
41+
Previous releases of esbuild didn't substitute these single-use variables here, but esbuild will now minify this to the following code starting with this release:
42+
43+
```js
44+
function toneMapLuminance(e,n,a){return 1-1/(1+luminance(e,n,a))}
45+
```
46+
347
## 0.15.11
448
549
* Fix various edge cases regarding template tags and `this` ([#2610](https://github.com/evanw/esbuild/issues/2610))

internal/js_parser/js_parser.go

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8467,17 +8467,30 @@ func (p *parser) substituteSingleUseSymbolInExpr(
84678467
return expr, status
84688468
}
84698469
} else if !p.exprCanBeRemovedIfUnused(e.Left) {
8470-
// Do not reorder past a side effect
8470+
// Do not reorder past a side effect in an assignment target, as that may
8471+
// change the replacement value. For example, "fn()" may change "a" here:
8472+
//
8473+
// let a = 1;
8474+
// foo[fn()] = a;
8475+
//
8476+
return expr, substituteFailure
8477+
} else if e.Op.BinaryAssignTarget() == js_ast.AssignTargetUpdate && !replacementCanBeRemoved {
8478+
// If this is a read-modify-write assignment and the replacement has side
8479+
// effects, don't reorder it past the assignment target. The assignment
8480+
// target is being read so it may be changed by the side effect. For
8481+
// example, "fn()" may change "foo" here:
8482+
//
8483+
// let a = fn();
8484+
// foo += a;
8485+
//
84718486
return expr, substituteFailure
84728487
}
84738488

8474-
// Do not substitute our unconditionally-executed value into a branching
8475-
// short-circuit operator unless the value itself has no side effects
8476-
if replacementCanBeRemoved || !e.Op.IsShortCircuit() {
8477-
if value, status := p.substituteSingleUseSymbolInExpr(e.Right, ref, replacement, replacementCanBeRemoved); status != substituteContinue {
8478-
e.Right = value
8479-
return expr, status
8480-
}
8489+
// If we get here then it should be safe to attempt to substitute the
8490+
// replacement past the left operand into the right operand.
8491+
if value, status := p.substituteSingleUseSymbolInExpr(e.Right, ref, replacement, replacementCanBeRemoved); status != substituteContinue {
8492+
e.Right = value
8493+
return expr, status
84818494
}
84828495

84838496
case *js_ast.EIf:
@@ -8612,6 +8625,11 @@ func (p *parser) substituteSingleUseSymbolInExpr(
86128625
return expr, substituteContinue
86138626
}
86148627

8628+
// We can always reorder past primitive values
8629+
if isPrimitiveLiteral(expr.Data) {
8630+
return expr, substituteContinue
8631+
}
8632+
86158633
// Otherwise we should stop trying to substitute past this point
86168634
return expr, substituteFailure
86178635
}

internal/js_parser/js_parser_test.go

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4219,6 +4219,26 @@ func TestMangleInlineLocals(t *testing.T) {
42194219
check("let x = 1; return void x", "let x = 1;")
42204220
check("let x = 1; return typeof x", "return typeof 1;")
42214221

4222+
// Check substituting a side-effect free value into normal binary operators
4223+
check("let x = 1; return x + 2", "return 1 + 2;")
4224+
check("let x = 1; return 2 + x", "return 2 + 1;")
4225+
check("let x = 1; return x + arg0", "return 1 + arg0;")
4226+
check("let x = 1; return arg0 + x", "return arg0 + 1;")
4227+
check("let x = 1; return x + fn()", "return 1 + fn();")
4228+
check("let x = 1; return fn() + x", "let x = 1;\nreturn fn() + x;")
4229+
check("let x = 1; return x + undef", "return 1 + undef;")
4230+
check("let x = 1; return undef + x", "let x = 1;\nreturn undef + x;")
4231+
4232+
// Check substituting a value with side-effects into normal binary operators
4233+
check("let x = fn(); return x + 2", "return fn() + 2;")
4234+
check("let x = fn(); return 2 + x", "return 2 + fn();")
4235+
check("let x = fn(); return x + arg0", "return fn() + arg0;")
4236+
check("let x = fn(); return arg0 + x", "let x = fn();\nreturn arg0 + x;")
4237+
check("let x = fn(); return x + fn2()", "return fn() + fn2();")
4238+
check("let x = fn(); return fn2() + x", "let x = fn();\nreturn fn2() + x;")
4239+
check("let x = fn(); return x + undef", "return fn() + undef;")
4240+
check("let x = fn(); return undef + x", "let x = fn();\nreturn undef + x;")
4241+
42224242
// Cannot substitute into mutating unary operators
42234243
check("let x = 1; ++x", "let x = 1;\n++x;")
42244244
check("let x = 1; --x", "let x = 1;\n--x;")
@@ -4236,7 +4256,7 @@ func TestMangleInlineLocals(t *testing.T) {
42364256
check("let x = 1; arg0 += x", "arg0 += 1;")
42374257
check("let x = 1; arg0 ||= x", "arg0 ||= 1;")
42384258
check("let x = fn(); arg0 = x", "arg0 = fn();")
4239-
check("let x = fn(); arg0 += x", "arg0 += fn();")
4259+
check("let x = fn(); arg0 += x", "let x = fn();\narg0 += x;")
42404260
check("let x = fn(); arg0 ||= x", "let x = fn();\narg0 ||= x;")
42414261

42424262
// Cannot substitute past mutating binary operators when the left operand has side effects
@@ -4247,12 +4267,6 @@ func TestMangleInlineLocals(t *testing.T) {
42474267
check("let x = fn(); y.z += x", "let x = fn();\ny.z += x;")
42484268
check("let x = fn(); y.z ||= x", "let x = fn();\ny.z ||= x;")
42494269

4250-
// Cannot substitute code without side effects past non-mutating binary operators when the left operand has side effects
4251-
check("let x = 1; fn() + x", "let x = 1;\nfn() + x;")
4252-
4253-
// Cannot substitute code with side effects past non-mutating binary operators
4254-
check("let x = y(); arg0 + x", "let x = y();\narg0 + x;")
4255-
42564270
// Can substitute code without side effects into branches
42574271
check("let x = arg0; return x ? y : z;", "return arg0 ? y : z;")
42584272
check("let x = arg0; return arg1 ? x : y;", "return arg1 ? arg0 : y;")
@@ -4410,6 +4424,15 @@ func TestMangleInlineLocals(t *testing.T) {
44104424
check("let x = arg0[foo]; (0, x)()", "let x = arg0[foo];\nx();")
44114425
check("let x = arg0?.foo; (0, x)()", "let x = arg0?.foo;\nx();")
44124426
check("let x = arg0?.[foo]; (0, x)()", "let x = arg0?.[foo];\nx();")
4427+
4428+
// Explicitly allow reordering calls that are both marked as "/* @__PURE__ */".
4429+
// This happens because only two expressions that are free from side-effects
4430+
// can be freely reordered, and marking something as "/* @__PURE__ */" tells
4431+
// us that it has no side effects.
4432+
check("let x = arg0(); arg1() + x", "let x = arg0();\narg1() + x;")
4433+
check("let x = arg0(); /* @__PURE__ */ arg1() + x", "let x = arg0();\n/* @__PURE__ */ arg1() + x;")
4434+
check("let x = /* @__PURE__ */ arg0(); arg1() + x", "let x = /* @__PURE__ */ arg0();\narg1() + x;")
4435+
check("let x = /* @__PURE__ */ arg0(); /* @__PURE__ */ arg1() + x", "/* @__PURE__ */ arg1() + /* @__PURE__ */ arg0();")
44134436
}
44144437

44154438
func TestTrimCodeInDeadControlFlow(t *testing.T) {

scripts/js-api-tests.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5044,6 +5044,51 @@ let transformTests = {
50445044
assert.strictEqual(result, 3)
50455045
},
50465046

5047+
async singleUseExpressionSubstitution({ esbuild }) {
5048+
function run(code) {
5049+
try {
5050+
return JSON.stringify(new Function(code)())
5051+
} catch (error) {
5052+
return error + ''
5053+
}
5054+
}
5055+
let bugs = ''
5056+
for (let input of [
5057+
`let fn = () => { throw new Error }; let x = undef; return fn() + x`,
5058+
`let fn = () => { throw new Error }; let x = fn(); return undef + x`,
5059+
5060+
`let fn = () => arg0 = 0; let x = fn(); return arg0 + x`,
5061+
`let fn = () => arg0 = 0; let x = fn(); return arg0 = x`,
5062+
`let fn = () => arg0 = 0; let x = fn(); return arg0 += x`,
5063+
`let fn = () => arg0 = 0; let x = fn(); return arg0 ||= x`,
5064+
`let fn = () => arg0 = 0; let x = fn(); return arg0 &&= x`,
5065+
5066+
`let fn = () => arg0 = 0; let obj = [1]; let x = arg0; return obj[fn()] + x`,
5067+
`let fn = () => arg0 = 0; let obj = [1]; let x = arg0; return obj[fn()] = x`,
5068+
`let fn = () => arg0 = 0; let obj = [1]; let x = arg0; return obj[fn()] += x`,
5069+
`let fn = () => arg0 = 0; let obj = [1]; let x = arg0; return obj[fn()] ||= x`,
5070+
`let fn = () => arg0 = 0; let obj = [1]; let x = arg0; return obj[fn()] &&= x`,
5071+
5072+
`let obj = { get y() { arg0 = 0; return 1 } }; let x = obj.y; return arg0 + x`,
5073+
`let obj = { get y() { arg0 = 0; return 1 } }; let x = arg0; return obj.y + x`,
5074+
5075+
`let x = undef; return arg0 || x`,
5076+
`let x = undef; return arg0 && x`,
5077+
`let x = undef; return arg0 ? x : 1`,
5078+
`let x = undef; return arg0 ? 1 : x`,
5079+
5080+
`let fn = () => { throw new Error }; let x = fn(); return arg0 || x`,
5081+
`let fn = () => { throw new Error }; let x = fn(); return arg0 && x`,
5082+
`let fn = () => { throw new Error }; let x = fn(); return arg0 ? x : 1`,
5083+
`let fn = () => { throw new Error }; let x = fn(); return arg0 ? 1 : x`,
5084+
]) {
5085+
input = `function f(arg0) { ${input} } return f(123)`
5086+
const { code: minified } = await esbuild.transform(input, { minify: true })
5087+
if (run(input) !== run(minified)) bugs += '\n ' + input
5088+
}
5089+
if (bugs !== '') throw new Error('Single-use expression substitution bugs:' + bugs)
5090+
},
5091+
50475092
async platformNode({ esbuild }) {
50485093
const { code } = await esbuild.transform(`export let foo = 123`, { format: 'cjs', platform: 'node' })
50495094
assert(code.slice(code.indexOf('let foo')), `let foo = 123;

0 commit comments

Comments
 (0)