Store assignment expressions into module lets declared without an initializer

A module `let` declared without an initializer whose type uses its null
pointer as the TDZ sentinel (a function, for example) is initialized when
its declaration runs, while its slot is still empty. markUninitializedWrites
marked the assignment statements that run after the declaration as
initializing, but not assignments in expression position. Those ran the
pointer check and threw "ReferenceError: Cannot access 'g' before
initialization" where Node stores the value, so the deferred-resolver
pattern `let resolve!: ...; new Promise((r) => (resolve = r))` failed, as
did `(h) => (g = h)` and `const r = (g = h)`.

An assignment expression to such a binding now becomes an initializing
`assign` whose value a temp holds and yields. Writes that can run before
the declaration keep the flag check, so they still throw Node's
ReferenceError.
This commit is contained in:
Chris Tate
2026-10-22 03:41:43 -05:00
parent 8e3c115cec
commit 144bfb2724
4 changed files with 96 additions and 4 deletions
@@ -463,16 +463,38 @@ export function checkCycleBindingAccesses(lowerer: Lowerer, mod: IrModule): void
/** Writes to a binding declared without an initializer that run after its
* declaration store into an initialized binding whose slot may still be
* empty: the pointer guard must not refuse them. Writes that can run
* earlier carry the flag check rewriteFunction inserted. */
* earlier carry the flag check rewriteFunction inserted. An assignment in
* expression position (`(f = v)`) becomes an initializing `assign` whose
* value a temp holds and yields. */
function markUninitializedWrites(lowerer: Lowerer, mod: IrModule): void {
const ids = new Set<string>();
for (const [id, flag] of lowerer.cycleInitFlags) if (flag.uninitialized) ids.add(id);
if (ids.size === 0) return;
const mark = (s: IrStmt): IrStmt =>
s.kind === "assign" && !s.initializes && ids.has(s.localId) ? { ...s, initializes: true } : s;
const rewriteExpr = (e: IrExpr): IrExpr => mapExprChildren(e, rewriteExpr, rewriteStmt);
const rewriteStmt = (s: IrStmt): IrStmt => mark(mapStmtChildren(s, rewriteExpr, rewriteStmt));
for (const fn of mod.functions) fn.body = fn.body.map(rewriteStmt);
for (const fn of mod.functions) {
let temps = 0;
const markExpr = (e: IrExpr): IrExpr => {
if (e.kind !== "assignExpr" || !ids.has(e.localId)) return e;
const t = `%uninitWrite.${temps++}`;
fn.locals.push({ id: t, name: "%uninitWrite", type: e.value.type, mutable: false });
const value: IrExpr = { kind: "varRef", localId: t, type: e.value.type, loc: e.loc };
return {
kind: "seqExpr",
stmts: [
{ kind: "varDecl", localId: t, init: e.value, loc: e.loc },
{ kind: "assign", localId: e.localId, value, initializes: true, loc: e.loc },
],
result: value,
type: e.type,
loc: e.loc,
};
};
const rewriteExpr = (e: IrExpr): IrExpr =>
markExpr(mapExprChildren(e, rewriteExpr, rewriteStmt));
const rewriteStmt = (s: IrStmt): IrStmt => mark(mapStmtChildren(s, rewriteExpr, rewriteStmt));
fn.body = fn.body.map(rewriteStmt);
}
}
function rewriteFunction(
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7109-uninitialized-let-assignment-expressions.ts"
],
"diags": []
}
@@ -0,0 +1,63 @@
// A module `let` declared without an initializer is initialized (to
// undefined) when its declaration runs. Assignments in expression
// position store into it like assignment statements do.
// The deferred-resolver pattern: an expression-bodied executor.
let resolve!: (value: number) => void;
const pending = new Promise<number>((r) => (resolve = r));
resolve(1);
console.log("resolved", await pending);
// An expression-bodied setter, and the assignment's value.
let handler!: (n: number) => string;
const setHandler = (h: (n: number) => string) => (handler = h);
const returned = setHandler((n) => `handled ${n}`);
console.log(returned(2), handler(3));
// The value held in a binding first.
let next!: () => string;
const install = (f: () => string) => {
const r = (next = f);
return r;
};
console.log(install(() => "installed")(), next());
// Chained, conditional and argument positions.
let first!: (n: number) => number;
let second!: (n: number) => number;
first = second = (n) => n + 1;
console.log("chained", first(1), second(2));
let picked!: () => string;
const choose = (flag: boolean) => (flag ? (picked = () => "yes") : (picked = () => "no"));
choose(false);
console.log("conditional", picked());
const call = (f: () => string) => f();
let passed!: () => string;
console.log("argument", call((passed = () => "passed")), passed());
// Reassignment releases the earlier function and keeps the new one.
let counter!: () => number;
const make = (start: number) => {
let n = start;
return (counter = () => ++n);
};
make(10);
make(20);
console.log("reassigned", counter(), counter());
// An optional binding behaves the same way.
let maybe: ((s: string) => string) | undefined;
const setMaybe = (f: (s: string) => string) => (maybe = f);
console.log("optional", setMaybe((s) => s.toUpperCase())("a"), maybe?.("b"));
// A write that runs before the declaration still throws.
function early(): () => number {
return (late = () => 1);
}
try {
early();
} catch (e) {
console.log((e as Error).name, (e as Error).message);
}
let late!: () => number;
console.log("after declaration", early()(), late());
@@ -0,0 +1 @@
{ "plain": 3300, "san": 4500 }