diff --git a/crates/perry-codegen/src/expr/static_field_meta.rs b/crates/perry-codegen/src/expr/static_field_meta.rs index 8024cf15ea..77dee06a3d 100644 --- a/crates/perry-codegen/src/expr/static_field_meta.rs +++ b/crates/perry-codegen/src/expr/static_field_meta.rs @@ -416,6 +416,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // inlining can't do once the class escapes its defining scope. if !captured_args.is_empty() { 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)]); for arg in captured_args { let v = lower_expr(ctx, arg)?; @@ -424,6 +425,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "js_array_push_f64", &[(I64, &caps_arr), (DOUBLE, &v)], ); + lowered_caps.push(v); } let caps_box = nanbox_pointer_inline(ctx.block(), &caps_arr); let key_idx = ctx.strings.intern("__perry_ctor_caps"); @@ -436,6 +438,39 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "js_object_set_field_by_name", &[(I64, &obj), (I64, &key_raw), (DOUBLE, &caps_box)], ); + // #685: ALSO register this evaluation's captures as the + // template's CLASS_CAPTURE_VALUES snapshot. The static blocks + // invoked below run compiled static-method bodies whose + // enclosing-scope reads resolve through + // `js_param_or_class_capture_value` — i.e. the name-keyed + // snapshot — not `__perry_ctor_caps` (that array only feeds + // constructor replay). Same write-right-before-use pattern + // (and same documented per-evaluation overwrite limitation) + // as the shared-template path's `RegisterClassCaptures`. + if template_cid != 0 { + let n = lowered_caps.len(); + let buf = ctx.func.alloca_entry_array(DOUBLE, n); + for (i, v) in lowered_caps.iter().enumerate() { + let slot = + ctx.block() + .gep(DOUBLE, &buf, &[(crate::types::I64, &i.to_string())]); + ctx.block().store(DOUBLE, v, &slot); + } + let ptr_reg = ctx.block().next_reg(); + ctx.block().emit_raw(format!( + "{} = getelementptr [{} x double], ptr {}, i64 0, i64 0", + ptr_reg, n, buf + )); + let len_str = n.to_string(); + ctx.block().call_void( + "js_class_register_capture_values", + &[ + (crate::types::I32, &tcid_str), + (crate::types::PTR, &ptr_reg), + (crate::types::I64, &len_str), + ], + ); + } } let obj_box = nanbox_pointer_inline(ctx.block(), &obj); for (key, init) in symbol_statics { @@ -447,6 +482,42 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { &[(DOUBLE, &obj_box), (DOUBLE, &k), (DOUBLE, &v)], ); } + // #685: run the class's `static { … }` blocks NOW — at the class + // expression's evaluation, with `this` = THIS fresh class object. + // The `ClassExprFresh` fast path previously never invoked them + // (they are also skipped by the module-init fallback when another + // evaluation site invokes them inline), so `return class { static + // { this.viaBlock = tag } }` factories produced objects whose + // blocks simply never ran. Arm the one-shot static-`this` + // override before each call so the compiled body's + // `js_static_this_resolve` prologue binds `this` to the fresh + // object (writes land as own properties of this evaluation's + // object, not the shared template). Blocks run after the named + // static fields above — the source interleaving of fields and + // blocks is not reproduced on this path (pre-existing limitation). + let block_fns: Vec = ctx + .classes + .get(template) + .map(|c| { + c.static_methods + .iter() + .filter(|m| m.name.starts_with("__perry_static_init_")) + .filter_map(|m| { + ctx.methods + .get(&( + template.clone(), + crate::codegen::static_method_registry_key(&m.name), + )) + .cloned() + }) + .collect() + }) + .unwrap_or_default(); + for fn_name in block_fns { + ctx.block() + .call_void("js_static_this_arm_value", &[(DOUBLE, &obj_box)]); + ctx.block().call(DOUBLE, &fn_name, &[]); + } Ok(obj_box) } // Issue #711 part 2: `.prototype = ` pattern. diff --git a/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs b/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs index 73a8134467..ae15d285ef 100644 --- a/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs +++ b/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs @@ -222,19 +222,27 @@ pub(crate) fn try_lower_static_dispatch( let prev_this = ctx.block() .call(DOUBLE, "js_implicit_this_set", &[(DOUBLE, &recv_box)]); - // Receiver-sensitive static `this` for plain class-ref receivers: - // `D.f()` resolving to a parent's body at compile time must run - // with `this === D` (the prologue's `js_static_this_resolve` - // consumes this one-shot arm). Dynamic-value receiver shapes - // (ClassExprFresh / factory Call / LocalGet) keep their prior - // implicit-this-only behavior to avoid disturbing effect's - // per-evaluation class-object statics. - let plain_class_receiver = - matches!(object, Expr::ClassRef(_) | Expr::ExternFuncRef { .. }); - if plain_class_receiver { - ctx.block() - .call_void("js_static_this_arm_value", &[(DOUBLE, &recv_box)]); - } + // Receiver-sensitive static `this`: arm the one-shot override with + // the ACTUAL receiver box so the callee prologue's + // `js_static_this_resolve` binds `this` to it (spec + // OrdinaryCallBindThis). This must cover the dynamic-value receiver + // shapes too (ClassExprFresh / factory `Call` / `LocalGet`), not + // just plain class-refs: the prologue consumes the armed override + // or falls back to the LEXICAL class-ref — it never reads implicit + // `this` — so the previous implicit-this-only treatment of these + // shapes silently bound `this` to the shared template. A class + // EXPRESSION's per-evaluation statics are OWN properties of the + // fresh heap class object (never written to the template's + // static-field globals), so `this.` inside the static body + // read `undefined` (#1787 criterion 1: `make(a).viaThis()` / + // `const C = make(a); C.viaThis()`). For a local that holds a + // plain ClassRef value this arms exactly the prologue's default — + // no behavior change — and for fresh objects it restores the + // receiver, matching the runtime dispatch tower + // (`js_class_static_method_call`), which has armed its receiver + // since the static-private-brand work. + ctx.block() + .call_void("js_static_this_arm_value", &[(DOUBLE, &recv_box)]); let arg_slices: Vec<(crate::types::LlvmType, &str)> = lowered.iter().map(|s| (DOUBLE, s.as_str())).collect(); let result = ctx.block().call(DOUBLE, &fn_name, &arg_slices); diff --git a/test-files/test_gap_class_expr_fresh_static_blocks_this.ts b/test-files/test_gap_class_expr_fresh_static_blocks_this.ts new file mode 100644 index 0000000000..c68c177c98 --- /dev/null +++ b/test-files/test_gap_class_expr_fresh_static_blocks_this.ts @@ -0,0 +1,57 @@ +// #685 / #1787: class-EXPRESSION statics on the fresh-object path. +// +// (1) `static { … }` blocks of a class expression returned from a factory +// must run at each evaluation, with `this` bound to THAT evaluation's +// fresh class object and the factory's captured locals visible. +// (2) A static METHOD called on the fresh class object (both the inline +// `make(x).m()` form and the const-bound `const C = make(x); C.m()` +// form) must bind `this` to the fresh object, so `this.` reads +// the per-evaluation own static field — not the shared template's +// static-field global (which the fresh path never writes). + +let log: string[] = []; +function makeExpr(tag: string) { + return class { + static viaBlock: string | null = null; + static { log.push("block:" + tag); } + static { this.viaBlock = tag; } + static ast = tag; + static viaThis() { return this.ast; } + }; +} + +const A = makeExpr("A"); +const B = makeExpr("B"); +console.log("blocks:", log.join(",")); +console.log("A.viaBlock:", A.viaBlock); +console.log("B.viaBlock:", B.viaBlock); + +// Static-method `this` binding — const-bound receiver. +console.log("A.viaThis():", A.viaThis()); +console.log("B.viaThis():", B.viaThis()); +// Inline factory-result receiver. +console.log("inline:", makeExpr("C").viaThis()); + +// `this === receiver` identity inside the static body. +function makeIdent() { + return class { + static self() { return this === (globalThis as any).__IDENT ? "same" : "different"; } + }; +} +const I = makeIdent(); +(globalThis as any).__IDENT = I; +console.log("identity:", I.self()); + +// Nested class DECLARATION block still runs once, in the factory (control). +log = []; +function makeDecl(tag: string) { + class D { static t: string; static { log.push("decl:" + tag); D.t = tag; } } + return D; +} +const D = makeDecl("D"); +console.log("decl blocks:", log.join(",")); +console.log("D.t:", D.t); + +// Top-level class-expression block still runs at module init (control). +const E = class { static { console.log("toplevel block ran"); } }; +void E;