Skip to content

minify + target≤es2020: lowered ||= on a write-only local emits assignment to an undeclared variable (runtime ReferenceError) #4508

Description

@Khellendros97

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:

  1. target ≤ es2020 (so ||= is lowered into read + write),
  2. --minify (DCE + rename),
  3. 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)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions