Skip to content

Starling JS compiler: false ReferenceError for captured let/const declared in catch blocks or switch cases #73

Description

@codymullins

A function with a captured let/const declared directly inside a catch block or a switch case throws a false ReferenceError: Cannot access a lexical binding before initialization when other code in the same function reads a same-named outer or global binding. V8 (node) runs the same code clean. This is the surviving sibling of the loop-head temporal dead zone (TDZ) leak fixed in commit 166d7e6.

What happens

The Starling JS compiler's function-entry pre-pass PreallocateCapturedInStatement (src/Starling.Js/Bytecode/JsCompiler.cs) has a guard at line 998 that returns early for block-scoped lexicals — but only when inBlock is true. The BlockStatement arm passes inBlock: true (line 1005). Switch-case consequents recurse with inBlock left false (lines 1036-1039), and catch handler body statements do too (lines 1040-1044).

So a captured let/const in a catch block or switch case is preallocated as a function-frame TDZ cell (InitCellLocalTdz) that is never initialized — at emit time HoistLexicalName (lines 1239-1246) gives the block its own separate cell. The phantom sentinel cell then shadows any same-named outer binding for every other reference in the function. The throw site is the checked load (LoadUpvalueChecked/LoadLocalChecked) in src/Starling.Js/Runtime/JsVm.cs:830-842.

Repro

Re-verified twice on 2026-06-10 on the real engine (via JsParserJsCompiler.CompileJsVm.Run), clean on node:

var g = 'global-ok';
function factory() {
  try { } catch { let g = 1; (() => g)(); }
  return () => g;
}
globalThis.__r = factory()();   // Starling: false ReferenceError. node: works, __r === 'global-ok'.

Switch variant: an outer var n, then switch(1){case 99: let n=5; (()=>n)();}, then return ()=>n — same false throw. Control: the plain-block variant { let g = 1; (() => g)(); } works (that is the case 166d7e6 already fixed).

Real-world hit: x.com's GrokDrawer/Markdown chunk — a LiveKit publishTrack catch poisons 38 reads of the name t in that function.

Fix

Real fix: pass inBlock: true when the pre-pass descends into switch-case consequents and catch handler bodies, mirroring the BlockStatement arm. Block-scoped lexicals then defer to HoistLexicalName at block entry. This is the exact shape of the loop-head fix. No band-aid exists or is needed — this is a straight compiler bug.

Context

Found during the x.com/nasa boot investigation on 2026-06-10 (branch feat/js-stack-trampoline). False aborts like this are one class of "first evaluation died" causes that leave webpack modules half-evaluated yet cached. The cached partial module later resurfaces as a confusing TDZ ReferenceError from an export getter — that downstream symptom is how this was found.

Metadata

Metadata

Assignees

No one assigned

    Labels

    browserRelates to the browser subsystembugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions