Initialize a forward-captured binding's box when its statement lowers again

A binding that a function declared earlier captures is pre-declared as a
TDZ box, and its declaration's assign initializes the box. The declaration
dropped the pre-declaration before lowering its initializer. When the
initializer failed with a degradable type (a cast whose type does not
compile) and the statement commit point lowered the statement again, the
retry no longer saw the box: it declared a new local and initialized that,
and a later call of the function threw ReferenceError.

The pre-declaration is now dropped after the initializer lowers, for
variable, class and generic-function declarations.
This commit is contained in:
Chris Tate
2026-10-25 23:21:20 -05:00
parent b6af8bc0a0
commit 766a732f8e
4 changed files with 43 additions and 4 deletions
@@ -1772,8 +1772,8 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt
const symbol = lowerer.checker.getSymbolAtLocation(stmt.name);
const pre = symbol ? lowerer.ctx.tdzPredeclared.get(symbol) : undefined;
if (pre) {
lowerer.ctx.tdzPredeclared.delete(symbol!);
const value = lowerer.coerceToExpected(lowerer.lowerClassExpression(stmt), DYN);
lowerer.ctx.tdzPredeclared.delete(symbol!);
return { kind: "assign", localId: pre.id, value, initializes: true, loc: locOf(stmt) };
}
const type: IrType = { kind: "classval", className: info.def.name };
@@ -5789,8 +5789,8 @@ export function lowerVarDecl(
const symbol = lowerer.checker.getSymbolAtLocation(decl.name);
const pre = symbol ? lowerer.ctx.tdzPredeclared.get(symbol) : undefined;
if (pre) {
lowerer.ctx.tdzPredeclared.delete(symbol!);
const value = genericBindingStoredValue(lowerer, decl, info);
lowerer.ctx.tdzPredeclared.delete(symbol!);
return { kind: "assign", localId: pre.id, value, initializes: true, loc: locOf(decl) };
}
return genericBindingPresenceStore(info, decl);
@@ -5953,11 +5953,13 @@ export function lowerVarDecl(
// A forward-captured binding pre-declared as a TDZ box (an earlier
// function in this scope captured it — predeclareForwardCapture): the
// binding and its scope-entry varDecl already exist; the source
// declaration is the one initializing `assign` into the shared box.
// declaration is the one initializing `assign` into the shared box. The
// box stays pre-declared until the initializer lowers: a statement the
// commit point lowers again (localized failure) initializes the same box.
const pre = declSymbol ? lowerer.ctx.tdzPredeclared.get(declSymbol) : undefined;
if (pre && decl.initializer) {
lowerer.ctx.tdzPredeclared.delete(declSymbol!);
const init = lowerer.lowerExprExpecting(decl.initializer, pre.type);
lowerer.ctx.tdzPredeclared.delete(declSymbol!);
return { kind: "assign", localId: pre.id, value: init, initializes: true, loc: locOf(decl) };
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/localized-failure-forward-capture.ts"
],
"diags": []
}
@@ -0,0 +1,30 @@
// Localized failure, stage L4, at a forward-captured binding: functions
// declared before a const capture it, and the const's initializer has an
// expression whose type does not compile. The statement lowers again with
// that expression degraded, and it initializes the binding the functions
// captured: before the declaration a call throws ReferenceError, after it
// the call reads the value.
type Hook = Map<string, (x: number) => string> | AbortSignal | (() => number) | Set<string>;
interface Plugin {
name: string;
hook: Hook;
}
function load(text: string): unknown {
return JSON.parse(text);
}
function main(): void {
function nameOf(): string {
return plugin.name;
}
function attempt(): string {
try {
return nameOf();
} catch (e) {
return e instanceof ReferenceError ? "ReferenceError" : "other";
}
}
console.log(attempt());
const plugin = { name: (load('{"name":"tags","hook":null}') as Plugin).name };
console.log(attempt(), nameOf().length);
}
main();
@@ -0,0 +1 @@
{ "plain": 3500 }