From 5764d0e3e81f31f337eceb130d7524b09628364b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 4 Sep 2026 13:28:47 +0200 Subject: [PATCH 1/2] fix(hir): let a declarator's own initializer forward-capture its binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A closure created inside a `let`/`const` declarator's initializer that references the binding that declarator introduces threw `ReferenceError: is not defined`. Node only has a TDZ window here, and the closure body does not run until after initialization, so shapes like `const off = ev.on(() => off())` and `const { unmount } = await render({ onDone: () => unmount() })` are legal. `pre_register_forward_captured_lets` recorded a declarator's initializer into `seen_closure_refs` only after deciding whether that declarator's own bindings were forward-captured. That ordering serves the later-declarator case and nothing else, so the self-referential shape was never pre-registered and the reference fell through to `js_global_get_or_throw_unresolved`. Recording before the decision is a strict superset — later declarators still see the same refs. Reachable from every declarator form: the destructuring path has no pre-registration of its own, and `ast_expr_contains_function_expr` (which guards the simple-binding path) does not descend through `await`. Found in claude-code's `install` subcommand, which is exactly this shape; the uncaught ReferenceError there dropped the trailing newline and the exit code (`B60_install_badtarget`, #9575). Closes #9718 --- changelog.d/9720-initializer-self-binding.md | 39 +++++++++ crates/perry-hir/src/lower_decl/block.rs | 47 ++++++----- .../test_gap_9718_initializer_self_binding.ts | 81 +++++++++++++++++++ 3 files changed, 146 insertions(+), 21 deletions(-) create mode 100644 changelog.d/9720-initializer-self-binding.md create mode 100644 test-files/test_gap_9718_initializer_self_binding.ts diff --git a/changelog.d/9720-initializer-self-binding.md b/changelog.d/9720-initializer-self-binding.md new file mode 100644 index 0000000000..7bdea7e1d0 --- /dev/null +++ b/changelog.d/9720-initializer-self-binding.md @@ -0,0 +1,39 @@ +**A closure in a `let`/`const` declarator's own initializer can now see the +binding that declarator introduces** — `const off = ev.on(() => off())`, +`const { unmount } = await render({ onDone: () => unmount() })`. It previously +threw `ReferenceError: is not defined` (#9718). + +The function-body forward-capture pre-pass (`pre_register_forward_captured_lets`) +decides whether a `let`/`const` binding needs a boxed, TDZ-seeded forward +declaration by asking whether any closure *seen so far* references its name. It +recorded a declarator's own initializer into that set only **after** making that +decision — which is all the later-declarator case needs (`let z = (w) => { … O … }, +O = setTimeout(z, K)`, the minified `new Promise` executor shape), but left the +self-referential shape unregistered. The reference then fell through to +`js_global_get_or_throw_unresolved`. Recording the initializer's closure refs +before the decision is a strict superset: later declarators still see them. + +The hole was reachable from every declarator form, because the pre-pass is what +the destructuring path relies on — `destructuring/var_decl.rs`'s own +`is_function_expr_init` pre-registration covers only simple `Pat::Ident` +bindings, and its `ast_expr_contains_function_expr` scan does not descend +through `await`. So `const O = await mk({ cb: () => O() })` (plain binding +behind an `await`), every object/array/nested pattern, and `{ key }` shorthand +all failed; only a closure created *after* the declaration worked. + +Found in claude-code: the `install` subcommand is + +```js +let { unmount: O } = await eB(el(wMA, { onDone: (w, $) => { O(), q(w, $) }, … })) +``` + +so `claude install ` threw an uncaught `ReferenceError: O is not +defined` mid-teardown, losing the final newline and the exit code — perry exited +0 where node exits 1 (the `B60_install_badtarget` divergence carried in #9575). + +`test-files/test_gap_9718_initializer_self_binding.ts` pins all of it against +node: plain binding, object pattern, `{ key }` shorthand, array pattern, nested +pattern, `let` and `const`, with and without `await`, plus a multi-declarator +statement so the reordered scan's pre-existing earlier-refs-later behavior stays +covered. Unpatched, 7 of its 12 lines are `ReferenceError`; patched, the output +is byte-identical to node 26.5.1. diff --git a/crates/perry-hir/src/lower_decl/block.rs b/crates/perry-hir/src/lower_decl/block.rs index c2315806a2..b20d746865 100644 --- a/crates/perry-hir/src/lower_decl/block.rs +++ b/crates/perry-hir/src/lower_decl/block.rs @@ -178,6 +178,32 @@ pub(crate) fn pre_register_forward_captured_lets( ast::VarDeclKind::Let | ast::VarDeclKind::Const ) { for decl in &var_decl.decls { + // A closure in a declarator's OWN initializer can + // reference the binding that declarator introduces: + // `const off = ev.on(() => off())` + // `const { unmount: O } = await render({ onDone: () => O() })` + // (claude-code's `install` subcommand, #9718). The + // closure body does not run until after initialization, + // so this is legal — node only has a TDZ window here. + // Record this declarator's closure refs BEFORE deciding + // whether its own bindings are forward-captured; doing + // it only afterwards (which is all the LATER-declarator + // case below needs) left the self-referential shape + // unregistered, so the reference fell through to + // `js_global_get_or_throw_unresolved` and threw + // `ReferenceError: is not defined`. + // + // Recording early is a superset of recording late: later + // declarators of the same declaration still see these + // refs, which is what the intra-declaration case wants: + // `let z = (w) => { … O … }, O = setTimeout(z, K);` + // (the minified `new Promise` executor shape — without + // it the `resolve` never fires and the awaiting caller + // hangs). Cross-statement forward-refs are handled by + // the trailing `cic_stmt`. + if let Some(init) = &decl.init { + cic_expr(init, false, &mut seen_closure_refs); + } let mut binding_idents: Vec<(String, u32)> = Vec::new(); collect_pat_forward_idents(&decl.name, &mut binding_idents); for (name, span_lo) in binding_idents { @@ -213,27 +239,6 @@ pub(crate) fn pre_register_forward_captured_lets( } } } - // A closure in an EARLIER declarator of THIS same - // `let`/`const` can forward-reference a name bound by a LATER - // declarator in the SAME declaration: - // `let z = (w) => { … O … Y … A … }, - // Y = () => z(false), - // A = () => clearTimeout(O), - // O = setTimeout(z, K);` - // (the minified `new Promise` executor shape). Record this - // declarator's closure refs NOW so the later declarators are - // seen as forward-captured too — `seen_closure_refs` is - // otherwise only updated by the trailing `cic_stmt` AFTER the - // whole declaration, so intra-declaration forward-refs were - // missed: the later names never got pre-registered, so the - // ref fell through to `js_global_get_or_throw_unresolved` and - // the closure captured a global instead of the local box — - // e.g. a `new Promise` `resolve` that never fires, hanging - // the awaiting caller. Cross-statement forward-refs were - // already handled by the trailing `cic_stmt`. - if let Some(init) = &decl.init { - cic_expr(init, false, &mut seen_closure_refs); - } } } else { // `var` bindings are already predefined + boxed by diff --git a/test-files/test_gap_9718_initializer_self_binding.ts b/test-files/test_gap_9718_initializer_self_binding.ts new file mode 100644 index 0000000000..0caa1e3b20 --- /dev/null +++ b/test-files/test_gap_9718_initializer_self_binding.ts @@ -0,0 +1,81 @@ +// #9718: a closure created INSIDE a `let`/`const` declarator's initializer may +// reference the binding that declarator introduces. The closure body does not +// run until after initialization, so node resolves it normally — only the TDZ +// window between entering the declaration and completing it is off limits. +// +// Perry pre-registered such a binding only when the reference came from an +// EARLIER statement or an EARLIER declarator of the same declaration, never +// from the declarator's own initializer, so the reference fell through to the +// unresolved-global path and threw `ReferenceError: is not defined`. +// claude-code's `install` subcommand is exactly this shape: +// let { unmount: O } = await render(el({ onDone: (a, b) => { O(), done(a, b) } })) +// +// Every declarator form is covered: plain binding, object pattern, `{ key }` +// shorthand, array pattern, nested pattern, `let` and `const`, with and +// without `await`, plus a multi-declarator statement (the earlier-declarator +// case that already worked, to pin that this did not regress). + +type Handle = { u: () => string }; + +// Hands the callback to a later turn, then resolves — so the callback always +// runs after the declaration has completed, exactly as in the real bundle. +function render(cb: () => string): Promise { + return new Promise((resolve) => { + pending.push(cb); + resolve({ u: () => "handle" }); + }); +} +function renderSync(cb: () => string): Handle { + pending.push(cb); + return { u: () => "handle" }; +} +const pending: (() => string)[] = []; + +function report(label: string, run: () => string): void { + try { + console.log(label + ": " + run()); + } catch (e) { + console.log(label + ": THREW " + ((e as Error) && (e as Error).name) + " " + ((e as Error) && (e as Error).message)); + } +} + +async function main(): Promise { + // 1. plain binding, awaited initializer + const a = await render(() => a.u() + "/plain-await"); + // 2. object pattern, awaited initializer + const { u: b } = await render(() => b() + "/obj-await"); + // 3. object pattern, synchronous initializer + const { u: c } = renderSync(() => c() + "/obj-sync"); + // 4. array pattern, awaited initializer + const [d] = await render(() => d.u() + "/array-await").then((h) => [h] as [Handle]); + // 5. `let` rather than `const` + let { u: e } = await render(() => e() + "/let-obj-await"); + // 6. `{ key }` shorthand pattern + const { u } = await render(() => u() + "/shorthand-await"); + // 7. nested pattern + const { inner: { u: g } } = await Promise.resolve({ inner: { u: () => "handle" } as Handle }) + .then((v) => { pending.push(() => g() + "/nested-await"); return v; }); + // 8. multi-declarator: an EARLIER declarator's closure references a LATER one + // (already worked before #9718 — pinned so the reordered scan keeps it) + const h = () => i() + "/earlier-refs-later", + i = (): string => "handle"; + + report("plain-await", () => pending[0]!()); + report("obj-await", () => pending[1]!()); + report("obj-sync", () => pending[2]!()); + report("array-await", () => pending[3]!()); + report("let-obj-await", () => pending[4]!()); + report("shorthand-await", () => pending[5]!()); + report("nested-await", () => pending[6]!()); + report("earlier-refs-later", h); + + // The declaration is complete by the time these run, so the direct calls + // must agree with what the closures saw. + report("direct-plain", () => a.u()); + report("direct-obj", () => b()); + report("direct-array", () => d.u()); + report("direct-nested", () => g()); + console.log("count=" + pending.length + " c=" + c() + " e=" + e() + " u=" + u() + " i=" + i()); +} + +void main(); From ea938ffdd5687d2a848532a2dd0bede24c765ef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 4 Sep 2026 13:36:49 +0200 Subject: [PATCH 2/2] test(gap): pin the self-recursive shapes the old scan ordering served The reordered initializer scan is a superset, not a replacement: a self-recursive arrow, a named function expression, and a binding whose initializer both creates a closure over it and is called immediately all passed before this change and must keep passing. Verified identical on both arms of the same-commit A/B. --- test-files/test_gap_9718_initializer_self_binding.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test-files/test_gap_9718_initializer_self_binding.ts b/test-files/test_gap_9718_initializer_self_binding.ts index 0caa1e3b20..ce7661c6e9 100644 --- a/test-files/test_gap_9718_initializer_self_binding.ts +++ b/test-files/test_gap_9718_initializer_self_binding.ts @@ -69,6 +69,16 @@ async function main(): Promise { report("nested-await", () => pending[6]!()); report("earlier-refs-later", h); + // The shapes the pre-pass's original ordering existed for. They passed + // before this fix and must keep passing: moving the initializer scan earlier + // is a superset, not a replacement. + const fact = (n: number): number => (n <= 1 ? 1 : n * fact(n - 1)); + const fib = function rec(n: number): number { return n < 2 ? n : rec(n - 1) + rec(n - 2); }; + const off = renderSync(() => off.u() + "/init-call-result"); + console.log("self-recursive-arrow=" + fact(5)); + console.log("named-fn-expr-recursion=" + fib(10)); + report("init-call-result", () => pending[7]!()); + // The declaration is complete by the time these run, so the direct calls // must agree with what the closures saw. report("direct-plain", () => a.u());