diff --git a/crates/perry-codegen/src/expr/static_field_meta.rs b/crates/perry-codegen/src/expr/static_field_meta.rs index a391d5029f..12db3a835c 100644 --- a/crates/perry-codegen/src/expr/static_field_meta.rs +++ b/crates/perry-codegen/src/expr/static_field_meta.rs @@ -431,6 +431,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let cap_len = captured_args.len().to_string(); let mut lowered_caps: Vec = Vec::with_capacity(captured_args.len()); let mut caps_arr = ctx.block().call(I64, "js_array_alloc", &[(I32, &cap_len)]); + // #6523: these capture loads are Perry-internal materialization + // at the class's DEFINITION site, same as the + // `RegisterClassCaptures` snapshot loads above (#6052). A + // captured `const` declared AFTER the class (bundled semver's + // `class Comparator` + trailing debug/require consts) is still + // in its dead zone here — legal JS, since TDZ applies at + // method-call time. Without the suppression window the checked + // box read threw "Cannot access undefined before + // initialization" while merely DEFINING the class. Suppressed + // loads snapshot `undefined`; the #6037 refresh statements + // re-register the live values right after each captured + // binding's initializer runs. + ctx.block().call_void("js_tdz_suppress_begin", &[]); for arg in captured_args { let v = lower_expr(ctx, arg)?; caps_arr = ctx.block().call( @@ -440,6 +453,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ); lowered_caps.push(v); } + ctx.block().call_void("js_tdz_suppress_end", &[]); let caps_box = nanbox_pointer_inline(ctx.block(), &caps_arr); let key_idx = ctx.strings.intern("__perry_ctor_caps"); let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); diff --git a/crates/perry-hir/src/lower_decl/block.rs b/crates/perry-hir/src/lower_decl/block.rs index 2c3c35f351..ba00552f81 100644 --- a/crates/perry-hir/src/lower_decl/block.rs +++ b/crates/perry-hir/src/lower_decl/block.rs @@ -417,29 +417,53 @@ fn cic_decl(d: &ast::Decl, in_cl: bool, out: &mut std::collections::HashSet { - if let Some(b) = &f.function.body { - b.stmts.iter().for_each(|st| cic_stmt(st, true, out)); - } - } + ast::Decl::Fn(f) => cic_function(&f.function, out), ast::Decl::Class(c) => cic_class(&c.class, in_cl, out), _ => {} } } +/// Param patterns (defaults evaluate at CALL time) + body of a closure-scoped +/// `ast::Function` — nested fn declarations/expressions and class methods all +/// share this traversal. +fn cic_function(f: &ast::Function, out: &mut std::collections::HashSet) { + for p in &f.params { + cic_pat(&p.pat, true, out); + } + if let Some(b) = &f.body { + b.stmts.iter().for_each(|st| cic_stmt(st, true, out)); + } +} + fn cic_class(c: &ast::Class, in_cl: bool, out: &mut std::collections::HashSet) { if let Some(sc) = &c.super_class { cic_expr(sc, in_cl, out); } for m in &c.body { match m { - ast::ClassMember::Method(mm) => { - if let Some(b) = &mm.function.body { - b.stmts.iter().for_each(|st| cic_stmt(st, true, out)); + ast::ClassMember::Method(mm) => cic_function(&mm.function, out), + ast::ClassMember::PrivateMethod(mm) => cic_function(&mm.function, out), + // #6523: the CONSTRUCTOR body runs at `new` time, not at class + // definition — a binding it references that is declared AFTER the + // class (`class C { constructor(){ a() } } const a = ...`) must be + // pre-registered as a forward-captured lexical exactly like a + // method-body reference. This arm was missing, so such refs never + // got a box: `collect_method_captures` dropped them (not in + // `ctx.locals` at the class decl) and the ref fell through to the + // global lookup — "a is not defined" at construction (bundled + // semver's Comparator debug/constant pattern). + ast::ClassMember::Constructor(ctor) => { + for p in &ctor.params { + match p { + ast::ParamOrTsParamProp::Param(p) => cic_pat(&p.pat, true, out), + ast::ParamOrTsParamProp::TsParamProp(tp) => { + if let ast::TsParamPropParam::Assign(a) = &tp.param { + cic_expr(&a.right, true, out); + } + } + } } - } - ast::ClassMember::PrivateMethod(mm) => { - if let Some(b) = &mm.function.body { + if let Some(b) = &ctor.body { b.stmts.iter().for_each(|st| cic_stmt(st, true, out)); } } @@ -453,6 +477,9 @@ fn cic_class(c: &ast::Class, in_cl: bool, out: &mut std::collections::HashSet { + sb.body.stmts.iter().for_each(|st| cic_stmt(st, true, out)); + } _ => {} } } @@ -477,14 +504,7 @@ fn cic_expr(e: &ast::Expr, in_cl: bool, out: &mut std::collections::HashSet cic_expr(ex, true, out), } } - Fn(f) => { - for p in &f.function.params { - cic_pat(&p.pat, true, out); - } - if let Some(b) = &f.function.body { - b.stmts.iter().for_each(|st| cic_stmt(st, true, out)); - } - } + Fn(f) => cic_function(&f.function, out), Class(c) => cic_class(&c.class, in_cl, out), Array(a) => a .elems diff --git a/test-files/test_gap_class_forward_capture_6523.ts b/test-files/test_gap_class_forward_capture_6523.ts new file mode 100644 index 0000000000..f36d275cf2 --- /dev/null +++ b/test-files/test_gap_class_forward_capture_6523.ts @@ -0,0 +1,115 @@ +// #6523: class members referencing a lexical binding (`const`/`let`) declared +// AFTER the class. Legal JS — TDZ applies at method-CALL time, and the +// bindings are initialized before any member runs — but Perry's class-capture +// machinery broke this two ways: +// +// A. Constructor-only references were invisible to the forward-capture +// pre-pass (`cic_class` had no Constructor arm), so the binding never got +// a box, `collect_method_captures` dropped it, and the reference fell +// through to the global lookup — "a is not defined" at `new` time. +// B. When the binding WAS pre-registered (also referenced by a method or +// closure), the #6465 `ClassExprFresh` decl-site capture snapshot did a +// CHECKED TDZ box read with no suppression window, so merely DEFINING the +// class threw "Cannot access ... before initialization" (bundled semver's +// `Comparator` — a Next.js standalone server died at boot). +// +// The observable is byte-for-byte identical to `node --experimental-strip-types`. + +// --- A: forward consts referenced ONLY from the constructor ----------------- +(function factoryA(e: any) { + const s = Symbol("x"); + class Comparator { + max: number; + static get ANY() { + return s; + } + constructor(v: string) { + a("dbg", v); + this.max = n; + } + } + e.exports = Comparator; + const a = function (tag: string, v: string) { + console.log("A: debug", tag, v); + }; + const n = 16; + const c = new Comparator("x"); + console.log("A: max =", c.max); + console.log("A: ANY =", String(Comparator.ANY)); +})({ exports: {} }); + +// --- B: the bundled-semver shape — methods + ctor + static getter reference +// --- a debug fn and sibling "require" consts declared BELOW the class ------- +(function factoryB(module_: any) { + const s = Symbol("SemVer ANY"); + class Comparator { + value: string = ""; + semver: any; + static get ANY() { + return s; + } + constructor(comp: string) { + debug("comparator", comp); + this.parse(comp); + if (this.semver === s) { + this.value = ""; + } else { + this.value = this.operator + this.semver; + } + debug("comp", this.value); + } + operator: string = ""; + parse(comp: string) { + const m = comp.match(re[COMPARATOR]); + if (!m) { + this.semver = s; + return; + } + this.operator = m[1] !== undefined ? m[1] : ""; + this.semver = m[2] !== undefined ? m[2] : s; + } + test(version: string) { + debug("Comparator.test", version, this.value); + return this.semver === s || version === this.semver; + } + } + module_.exports = Comparator; + const debug = (...args: any[]) => { + console.log("B: debug:", ...args); + }; + const re: RegExp[] = []; + const COMPARATOR = 0; + re[COMPARATOR] = /^(>=|<=|>|<|=)?(.+)$/; + + // Same-module construction (static `new` path, live cap args). + const gte = new Comparator(">=1.2.3"); + console.log("B: operator =", gte.operator, "semver =", gte.semver); + console.log("B: test =", gte.test("1.2.3")); + // Construction through the escaped class VALUE (dynamic path — replays the + // ctor with the decl-site/refreshed capture environment). + const Exported = module_.exports; + const any = new Exported(""); + console.log("B: any.value =", JSON.stringify(any.value)); + console.log("B: any.test =", any.test("9.9.9")); + console.log("B: ANY is s =", Exported.ANY === s); +})({ exports: {} }); + +// --- regression guard: capture declared BEFORE the class, reassigned after — +// --- the refresh must keep tracking (pre-existing behavior) ----------------- +(function factoryC() { + let x = 1; + class C { + m() { + return x; + } + } + x = 2; + console.log("C: m =", new C().m()); +})(); + +// --- regression guard: plain closures capturing forward consts keep working - +(function factoryD() { + const f = () => k * 2; + const k = 21; + console.log("D: f =", f()); +})();