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 of r keeps the declaration and the bug disappears — verified by adding return r / r.SET reads).
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)
Summary
With
--minifyand a target that requires lowering logical assignment operators (es2020or below), esbuild mis-compiles a write-only local variable used with||=: theletdeclaration is removed as dead code and the read side is folded tovoid 0, but the lowered assignment survives — renamed to a fresh identifier that is never declared. In ESM strict mode this throwsReferenceErrorat runtime.Reproduces on
0.25.12and on0.28.1(latest at time of writing). Does not reproduce attarget=es2021+ (no||=lowering) or without minification.Minimal reproduction
in.mjs:Actual output (broken)
void 0 || (s = {})—sis assigned but has no declaration anywhere. Running it:Expected
One of:
let t; ... (t || (t = {}))(what esbuild correctly emits when the variable is read elsewhere), or(P => ...)({}).For comparison,
--target=es2021(no||=lowering) is correct:Root-cause hypothesis
At
target≤es2020the lowering pass rewritesr ||= {}tor || (r = {}). Then the minify DCE pass seeslet rwhereris never read (write-only): it folds the readr ||tovoid 0 ||and deletes thelet rdeclaration, but keeps the assignmentr = {}(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),||=(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 publishedlib/xterm.mjs(itself esbuild-minified) contains exactly this pattern inInputHandler.requestMode— aconst enumcompiled to an IIFE whose enum object is write-only:Every Vite production build (default
build.target: 'modules'→ es2020; default esbuild minify) that bundles@xterm/xterm@6.0.0emits the brokenvoid 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.3project with only@xterm/xterm@6.0.0produces adistbundle containingrequestMode(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