Repository navigation
Description
Summary
With --minify and a target that requires lowering logical assignment operators (es2020 or below), esbuild mis-compiles a write-only local variable used with ||=: the let declaration is removed as dead code and the read side is folded to void 0, but the lowered assignment survives — renamed to a fresh identifier that is never declared. In ESM strict mode this throws ReferenceError at runtime.
Reproduces on 0.25.12 and on 0.28.1 (latest at time of writing). Does not reproduce at target=es2021+ (no ||= lowering) or without minification.
Minimal reproduction
in.mjs:
export class H {
requestMode(e, i) {
let r;
(P => (P[P.NOT_RECOGNIZED = 0] = "NOT_RECOGNIZED", P[P.SET = 1] = "SET"))(r ||= {});
return i ? this.a() : this.b();
}
a() { return 1; }
b() { return 2; }
}$ esbuild in.mjs --minify --format=esm --target=es2020
Actual output (broken)
class u{requestMode(E,e){return(r=>(r[r.NOT_RECOGNIZED=0]="NOT_RECOGNIZED",r[r.SET=1]="SET"))(void 0||(s={})),e?this.a():this.b()}a(){return 1}b(){return 2}}export{u as H};void 0 || (s = {}) — s is assigned but has no declaration anywhere. Running it:
$ node --input-type=module -e "import { H } from './broken.mjs'; new H().requestMode(12, false);"
ReferenceError: s is not defined
Expected
One of:
- keep the binding:
let t; ... (t || (t = {}))(what esbuild correctly emits when the variable is read elsewhere), or - drop the entire dead construct:
(P => ...)({}).
For comparison, --target=es2021 (no ||= lowering) is correct:
class u{requestMode(s,r){let t;return(e=>(e[e.NOT_RECOGNIZED=0]="NOT_RECOGNIZED",e[e.SET=1]="SET"))(t||={}),r?this.a():this.b()}...}Root-cause hypothesis
At target≤es2020 the lowering pass rewrites r ||= {} to r || (r = {}). Then the minify DCE pass sees let r where r is never read (write-only): it folds the read r || to void 0 || and deletes the let r declaration, but keeps the assignment r = {} (a side effect) — and symbol renaming then mints a fresh identifier (s) for a binding that no longer exists. The two passes disagree about whether the variable is dead.
The trigger requires all three:
target ≤ es2020(so||=is lowered into read + write),--minify(DCE + rename),- the variable is write-only after the
||=(any later read ofrkeeps the declaration and the bug disappears — verified by addingreturn r/r.SETreads).
Real-world impact
This is the root cause of xtermjs/xterm.js#5800. @xterm/xterm@6.0.0's published lib/xterm.mjs (itself esbuild-minified) contains exactly this pattern in InputHandler.requestMode — a const enum compiled to an IIFE whose enum object is write-only:
requestMode(e,i){let r;(P=>(P[P.NOT_RECOGNIZED=0]="NOT_RECOGNIZED",...))(r||={});let n=...}Every Vite production build (default build.target: 'modules' → es2020; default esbuild minify) that bundles @xterm/xterm@6.0.0 emits the broken void 0 || (i = {}) form. The first DECRQM query (CSI ? Ps $ p, sent on startup by vim, nvim, htop, less, opencode, …) throws inside the parser, which permanently stalls xterm's write queue — rendering freezes while keyboard input keeps flowing to the PTY, so the app looks "half alive". Several downstream projects hit this independently (Pane, Wails apps, our Tauri 2 app on Windows/WebView2, Vite 5 & 6).
I verified the minimal Vite repro: a fresh vite@6.0.3 project with only @xterm/xterm@6.0.0 produces a dist bundle containing requestMode(e,t){(g=>...)(void 0||(i={}))} — byte-identical failure shape to the isolated CLI repro above. Rollup alone does not corrupt the pattern; esbuild alone does.
Environment
- esbuild 0.25.12 (broken), 0.28.1 (broken)
- Windows 11 x64, Node 22 (platform-independent; also reported on macOS/Linux downstream)
Activity
fix evanw#4508, close evanw#4510: minify bug with `||=`
Update dependency esbuild to v0.28.2 (#2)
Opaz@charlie: fix(build): vim freezes the terminal — esbuild miscompi…
fix(build): minify with terser — esbuild miscompiles xterm's DECRQM h…
Opaz@charlie: fix(build): vim freezes the terminal — esbuild miscompi…
fix: 打包版终端 vim 打开即卡死——构建目标提到 es2022 避开 esbuild 二次压缩缺陷