Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions crates/perry-codegen/src/expr/static_field_meta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
let cap_len = captured_args.len().to_string();
let mut lowered_caps: Vec<String> = 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(
Expand All @@ -440,6 +453,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
);
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);
Expand Down
58 changes: 39 additions & 19 deletions crates/perry-hir/src/lower_decl/block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,29 +417,53 @@ fn cic_decl(d: &ast::Decl, in_cl: bool, out: &mut std::collections::HashSet<Stri
}
}),
// A nested function declaration's body is a closure scope.
ast::Decl::Fn(f) => {
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<String>) {
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<String>) {
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));
}
}
Expand All @@ -453,6 +477,9 @@ fn cic_class(c: &ast::Class, in_cl: bool, out: &mut std::collections::HashSet<St
cic_expr(v, true, out);
}
}
ast::ClassMember::StaticBlock(sb) => {
sb.body.stmts.iter().for_each(|st| cic_stmt(st, true, out));
}
_ => {}
}
}
Expand All @@ -477,14 +504,7 @@ fn cic_expr(e: &ast::Expr, in_cl: bool, out: &mut std::collections::HashSet<Stri
ast::BlockStmtOrExpr::Expr(ex) => 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
Expand Down
115 changes: 115 additions & 0 deletions test-files/test_gap_class_forward_capture_6523.ts
Original file line number Diff line number Diff line change
@@ -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());
})();
Loading