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 JsParser → JsCompiler.Compile → JsVm.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.
A function with a captured
let/constdeclared directly inside acatchblock or aswitchcase throws a falseReferenceError: Cannot access a lexical binding before initializationwhen 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 wheninBlockis true. TheBlockStatementarm passesinBlock: true(line 1005). Switch-case consequents recurse withinBlockleft false (lines 1036-1039), and catch handler body statements do too (lines 1040-1044).So a captured
let/constin a catch block or switch case is preallocated as a function-frame TDZ cell (InitCellLocalTdz) that is never initialized — at emit timeHoistLexicalName(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) insrc/Starling.Js/Runtime/JsVm.cs:830-842.Repro
Re-verified twice on 2026-06-10 on the real engine (via
JsParser→JsCompiler.Compile→JsVm.Run), clean on node:Switch variant: an outer
var n, thenswitch(1){case 99: let n=5; (()=>n)();}, thenreturn ()=>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
publishTrackcatch poisons 38 reads of the nametin that function.Fix
Real fix: pass
inBlock: truewhen the pre-pass descends into switch-case consequents and catch handler bodies, mirroring theBlockStatementarm. Block-scoped lexicals then defer toHoistLexicalNameat 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 TDZReferenceErrorfrom an export getter — that downstream symptom is how this was found.