From 4992974e2a4fdee36bfaaec8d2b6ddb232085896 Mon Sep 17 00:00:00 2001 From: Ralic Lo Date: Fri, 13 Nov 2026 19:22:38 +0800 Subject: [PATCH 1/2] fix(wasm): a class without a constructor initializes its fields and runs its parent's On the web target a class's field initializers are emitted only inside `compile_class_constructor`, which runs only for a class that declares a constructor, and nothing calls a parent constructor for a derived class that declares none. So `class X { tag = "a" }` gave `new X().tag === undefined`, and `class B extends A {}` never ran `A`'s constructor. Such a class now gets the constructor TypeScript gives it: `constructor() {}` for a base class with field initializers, and for a derived class one that forwards the nearest ancestor constructor's parameters to the parent's and then runs its own initializers, in JS order. `synthesized_ctors` decides which classes get one; `ctor_param_count` is read by registration, the function section and the code section, so their order stays aligned. A chain that reaches a constructor with a rest parameter is left as it was. The field-initializer loop moves unchanged into `emit_field_initializers`. `class_without_constructor_initializes_fields_and_runs_the_parent_constructor` prints `undefined` for every value without the synthesis, and `NaN` for the field read from the parent if the initializers run before the parent. Refs #14595. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CUQanVsnitcrmYLAWWPc3p --- .../14616-wasm-synthesized-constructors.md | 5 + crates/perry-codegen-wasm/src/emit/compile.rs | 14 +- .../perry-codegen-wasm/src/emit/function.rs | 172 ++++++++++++++++-- .../src/emit/module_emitter.rs | 4 + crates/perry-codegen-wasm/src/emit/tests.rs | 28 +++ 5 files changed, 200 insertions(+), 23 deletions(-) create mode 100644 changelog.d/14616-wasm-synthesized-constructors.md diff --git a/changelog.d/14616-wasm-synthesized-constructors.md b/changelog.d/14616-wasm-synthesized-constructors.md new file mode 100644 index 00000000000..7a49594270a --- /dev/null +++ b/changelog.d/14616-wasm-synthesized-constructors.md @@ -0,0 +1,5 @@ +`--target web`: a class that declares no constructor dropped its field +initializers (`class X { tag = "a" }` gave `new X().tag === undefined`), and a +derived class without one never ran its parent's constructor. Such a class now +gets the constructor TypeScript gives it: the parent's constructor runs with +the arguments, then the class's own field initializers. diff --git a/crates/perry-codegen-wasm/src/emit/compile.rs b/crates/perry-codegen-wasm/src/emit/compile.rs index a0de26e9afe..3331c51546e 100644 --- a/crates/perry-codegen-wasm/src/emit/compile.rs +++ b/crates/perry-codegen-wasm/src/emit/compile.rs @@ -776,6 +776,7 @@ impl WasmModuleEmitter { } // Register class constructors, methods, and static methods + self.synthesized_ctors = Self::synthesized_ctors(modules); for (_, module) in modules { for class in &module.classes { // Record parent class relationship @@ -784,8 +785,7 @@ impl WasmModuleEmitter { .insert(class.name.clone(), parent.clone()); } // Constructor: params = this + declared params, returns f64 (this) - if let Some(ctor) = &class.constructor { - let param_count = 1 + ctor.params.len(); + if let Some(param_count) = self.ctor_param_count(class) { let params = vec![ValType::I64; param_count]; let results = vec![ValType::I64]; let type_idx = self.get_type_idx(params, results); @@ -1137,8 +1137,7 @@ impl WasmModuleEmitter { // Class constructors, methods, static methods, getters, setters for (_, module) in modules { for class in &module.classes { - if let Some(ctor) = &class.constructor { - let param_count = 1 + ctor.params.len(); + if let Some(param_count) = self.ctor_param_count(class) { let params = vec![ValType::I64; param_count]; let results = vec![ValType::I64]; let type_idx = self.get_type_idx(params, results); @@ -1370,6 +1369,13 @@ impl WasmModuleEmitter { if let Some(ctor) = &class.constructor { let func = self.compile_class_constructor(class, ctor); code_section.function(&func); + } else if let Some(&forward) = self.synthesized_ctors.get(&class.name) { + let parent_ctor = class + .extends_name + .as_ref() + .and_then(|parent| self.class_ctor_map.get(parent).copied()); + let func = self.compile_synthesized_constructor(class, forward, parent_ctor); + code_section.function(&func); } for method in &class.methods { let func = self.compile_class_method(method); diff --git a/crates/perry-codegen-wasm/src/emit/function.rs b/crates/perry-codegen-wasm/src/emit/function.rs index c5741c64135..8199b60af3b 100644 --- a/crates/perry-codegen-wasm/src/emit/function.rs +++ b/crates/perry-codegen-wasm/src/emit/function.rs @@ -162,11 +162,42 @@ impl WasmModuleEmitter { let mut func = Function::new(locals); let _rt = self.rt.as_ref().unwrap(); - // Emit field initializers: class_set_field(this, field_name, value) via mem_call + self.emit_field_initializers(&mut func, class, &local_map, temp_local_idx, temp_i32_idx); + + // Emit constructor body. + // + // Constructors are declared in func_section as `(this, ...params) -> i64` + // unconditionally (see mod.rs around line 1688). Pass `in_returning_func=true` + // so that any explicit `return;` / `return expr;` inside the constructor + // body properly leaves an i64 on the operand stack before the WASM `return` + // instruction. Without this, an early `return` produced a bare `return` + // with empty stack, failing V8 validation with + // "expected i64 but nothing on stack" (#1081 sibling instance, constructor case). + let mut ctx = FuncEmitCtx::new(self, &local_map, temp_local_idx, temp_i32_idx); + ctx.current_class = Some(class.name.clone()); + for stmt in &ctor.body { + ctx.emit_stmt(&mut func, stmt, true); + } + + // Fallthrough: return `this` as the constructor's result. + func.instruction(&Instruction::LocalGet(0)); + func.instruction(&Instruction::End); + func + } + + /// The field initializers of `class`, each a `class_set_field(this, name, value)`. + fn emit_field_initializers( + &self, + func: &mut Function, + class: &perry_hir::ir::Class, + local_map: &BTreeMap, + temp_local_idx: u32, + temp_i32_idx: u32, + ) { for field in &class.fields { if let Some(init) = &field.init { - let mut ctx = FuncEmitCtx::new(self, &local_map, temp_local_idx, temp_i32_idx); - ctx.emit_frame_begin(&mut func, 3); + let mut ctx = FuncEmitCtx::new(self, local_map, temp_local_idx, temp_i32_idx); + ctx.emit_frame_begin(func, 3); // Compute base address (sp - 24) and save to temp_i32 local let sp = self.nan_temp_global; func.instruction(&Instruction::GlobalGet(sp)); @@ -198,28 +229,131 @@ impl WasmModuleEmitter { memory_index: 0, })); // Store value to slot 2 - ctx.emit_store_arg(&mut func, 2, init); + ctx.emit_store_arg(func, 2, init); // Call via mem_call - ctx.emit_memcall_void(&mut func, "class_set_field", 3); + ctx.emit_memcall_void(func, "class_set_field", 3); } } + } - // Emit constructor body. - // - // Constructors are declared in func_section as `(this, ...params) -> i64` - // unconditionally (see mod.rs around line 1688). Pass `in_returning_func=true` - // so that any explicit `return;` / `return expr;` inside the constructor - // body properly leaves an i64 on the operand stack before the WASM `return` - // instruction. Without this, an early `return` produced a bare `return` - // with empty stack, failing V8 validation with - // "expected i64 but nothing on stack" (#1081 sibling instance, constructor case). - let mut ctx = FuncEmitCtx::new(self, &local_map, temp_local_idx, temp_i32_idx); - ctx.current_class = Some(class.name.clone()); - for stmt in &ctor.body { - ctx.emit_stmt(&mut func, stmt, true); + /// The WASM arity (`this` + parameters) of `class`'s constructor, declared or + /// synthesized, or `None` when it has none. Registration, the function section + /// and the code section all read it, so the three stay in the same order. + pub(super) fn ctor_param_count(&self, class: &perry_hir::ir::Class) -> Option { + match &class.constructor { + Some(ctor) => Some(1 + ctor.params.len()), + None => self + .synthesized_ctors + .get(&class.name) + .map(|forward| 1 + forward), } + } + + /// Which classes get a constructor from `compile_synthesized_constructor`, and how + /// many parameters each forwards. A class gets one when it declares none and either + /// has field initializers (they run only inside a constructor) or extends a class + /// with a constructor, declared or synthesized (otherwise the parent's never runs). + /// A chain that reaches a constructor with a rest parameter is left alone: + /// forwarding `...args` through a fixed arity would drop arguments. + pub(super) fn synthesized_ctors( + modules: &[(String, perry_hir::ir::Module)], + ) -> BTreeMap { + #[derive(Clone, Copy)] + enum Ctor { + Absent, + Arity(usize), + Unsupported, + } + fn ctor_of( + name: &str, + classes: &BTreeMap<&str, &perry_hir::ir::Class>, + out: &mut BTreeMap, + depth: usize, + ) -> Ctor { + let Some(class) = classes.get(name) else { + return Ctor::Absent; + }; + if depth > classes.len() { + return Ctor::Unsupported; + } + if let Some(ctor) = &class.constructor { + return if ctor.params.iter().any(|p| p.is_rest) { + Ctor::Unsupported + } else { + Ctor::Arity(ctor.params.len()) + }; + } + let parent = match &class.extends_name { + Some(parent) => ctor_of(parent, classes, out, depth + 1), + None => Ctor::Absent, + }; + match parent { + Ctor::Unsupported => Ctor::Unsupported, + Ctor::Arity(n) => { + out.insert(class.name.clone(), n); + Ctor::Arity(n) + } + Ctor::Absent if class.fields.iter().any(|f| f.init.is_some()) => { + out.insert(class.name.clone(), 0); + Ctor::Arity(0) + } + Ctor::Absent => Ctor::Absent, + } + } + let classes: BTreeMap<&str, &perry_hir::ir::Class> = modules + .iter() + .flat_map(|(_, module)| module.classes.iter()) + .map(|class| (class.name.as_str(), class)) + .collect(); + let mut out = BTreeMap::new(); + for name in classes.keys() { + ctor_of(name, &classes, &mut out, 0); + } + out + } + + /// The constructor TypeScript gives a class that declares none: `constructor() {}` + /// for a base class, `constructor(...args) { super(...args); }` for a derived one. + /// Here `forward` is the arity of the nearest ancestor constructor, whose + /// parameters are passed through unchanged, and `parent_ctor` is that parent's + /// constructor. As in JS, the parent constructor runs before this class's field + /// initializers. See `synthesized_ctors` for which classes get one. + pub(super) fn compile_synthesized_constructor( + &self, + class: &perry_hir::ir::Class, + forward: usize, + parent_ctor: Option, + ) -> Function { + let param_count = 1 + forward; + let mut local_map = BTreeMap::new(); + let mut extra_locals = 0u32; + for field in &class.fields { + if let Some(init) = &field.init { + collect_expr_locals(init, &mut local_map, &mut extra_locals, param_count as u32); + } + } + let temp_local_idx = param_count as u32 + extra_locals; + let temp_i32_idx = temp_local_idx + 3; + let locals = vec![(extra_locals + 3, ValType::I64), (1, ValType::I32)]; + let mut func = Function::new(locals); + + if let Some(parent_idx) = parent_ctor { + for i in 0..param_count { + func.instruction(&Instruction::LocalGet(i as u32)); + } + if let Some(&expected) = self.func_param_counts.get(&parent_idx) { + for _ in param_count..expected { + func.instruction(&Instruction::I64Const(TAG_UNDEFINED as i64)); + } + } + func.instruction(&Instruction::Call(parent_idx)); + func.instruction(&Instruction::Drop); + let mut ctx = FuncEmitCtx::new(self, &local_map, temp_local_idx, temp_i32_idx); + ctx.emit_exception_guard(&mut func); + } + + self.emit_field_initializers(&mut func, class, &local_map, temp_local_idx, temp_i32_idx); - // Fallthrough: return `this` as the constructor's result. func.instruction(&Instruction::LocalGet(0)); func.instruction(&Instruction::End); func diff --git a/crates/perry-codegen-wasm/src/emit/module_emitter.rs b/crates/perry-codegen-wasm/src/emit/module_emitter.rs index b4d9908c3c2..33d32300a27 100644 --- a/crates/perry-codegen-wasm/src/emit/module_emitter.rs +++ b/crates/perry-codegen-wasm/src/emit/module_emitter.rs @@ -48,6 +48,9 @@ pub(super) struct WasmModuleEmitter { pub(super) ffi_imports: Vec<(String, usize, bool)>, /// Class parent map: child_class_name → parent_class_name pub(super) class_parent_map: BTreeMap, + /// Classes that declare no constructor but get one (`synthesized_ctors`), with + /// the number of parameters it forwards to the parent constructor. + pub(super) synthesized_ctors: BTreeMap, /// Enum member values: (enum_name, member_name) → numeric value or string pub(super) enum_values: BTreeMap<(String, String), EnumResolvedValue>, /// Global index for NaN-safe temp storage (global.set/get may preserve NaN in Firefox) @@ -114,6 +117,7 @@ impl WasmModuleEmitter { func_name_map: BTreeMap::new(), ffi_imports: Vec::new(), class_parent_map: BTreeMap::new(), + synthesized_ctors: BTreeMap::new(), enum_values: BTreeMap::new(), nan_temp_global: 0, // set during compile() async_func_imports: Vec::new(), diff --git a/crates/perry-codegen-wasm/src/emit/tests.rs b/crates/perry-codegen-wasm/src/emit/tests.rs index a57f3e84b2b..df38b2350e1 100644 --- a/crates/perry-codegen-wasm/src/emit/tests.rs +++ b/crates/perry-codegen-wasm/src/emit/tests.rs @@ -143,3 +143,31 @@ console.log("b"); "a 1 true\n\nb" ); } + +// A class that declares no constructor still runs its field initializers, and a +// derived one runs its parent's constructor (with the arguments) before them. +#[test] +fn class_without_constructor_initializes_fields_and_runs_the_parent_constructor() { + assert_eq!( + run_web_source( + r#" +class X { tag = "a"; n = 1 + 1; } +class A { x: number; constructor(x: number) { this.x = x; } } +class B extends A { y = 2; } +class C extends B {} +class P { v: number; constructor() { this.v = 1; } } +class Q extends P { w = this.v + 1; } +class E {} +class F extends E { t = "f"; } +const x = new X(); +const b = new B(7); +const c = new C(9); +const q = new Q(); +console.log(x.tag + " " + x.n); +console.log(b.x + " " + b.y + " " + c.x + " " + c.y); +console.log(q.v + " " + q.w + " " + new F().t); +"# + ), + "a 2\n7 2 9 2\n1 2 f" + ); +} From ab9d8027f35c1fbee0d91f5cc9c318ae3268b66c Mon Sep 17 00:00:00 2001 From: Ralic Lo Date: Fri, 13 Nov 2026 20:44:59 +0800 Subject: [PATCH 2/2] fix(wasm): a derived class initializes its fields after super(), not before the body Review follow-up on #14616. Field initializers were emitted before every constructor body. For a derived class that is wrong in JS terms (fields are initialized when `super(...)` returns), and with this PR it also became observable: a parent that now gets a synthesized constructor overwrote the child's initializers, so `class A { x = 1 } class B extends A { x = 2; constructor() { super(); } }` gave `new B().x === 1`. An initializer reading a field the parent set (`w = this.v + 1`) read it before it existed. For a derived class, the initializers are now emitted right after the top-level `super(...)` statement of the body. A base class, or a `super` call nested inside another statement, keeps them before the body. `derived_fields_initialize_after_super_and_base_fields_before_the_body` prints `1 NaN 2` with the old placement, `2 20 2` (as Node) with this one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CUQanVsnitcrmYLAWWPc3p --- .../perry-codegen-wasm/src/emit/function.rs | 32 +++++++++++++++++-- crates/perry-codegen-wasm/src/emit/tests.rs | 20 ++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/crates/perry-codegen-wasm/src/emit/function.rs b/crates/perry-codegen-wasm/src/emit/function.rs index 8199b60af3b..4bc4030f8f0 100644 --- a/crates/perry-codegen-wasm/src/emit/function.rs +++ b/crates/perry-codegen-wasm/src/emit/function.rs @@ -162,7 +162,26 @@ impl WasmModuleEmitter { let mut func = Function::new(locals); let _rt = self.rt.as_ref().unwrap(); - self.emit_field_initializers(&mut func, class, &local_map, temp_local_idx, temp_i32_idx); + // A derived class initializes its fields when `super(...)` returns, as in JS: + // emitted before the body, they would be overwritten by the parent constructor. + // A base class, or a `super` call that is not a top-level statement of the + // body, keeps them before the body. + let super_at = if class.extends_name.is_some() { + ctor.body + .iter() + .position(|stmt| matches!(stmt, Stmt::Expr(Expr::SuperCall(_)))) + } else { + None + }; + if super_at.is_none() { + self.emit_field_initializers( + &mut func, + class, + &local_map, + temp_local_idx, + temp_i32_idx, + ); + } // Emit constructor body. // @@ -175,8 +194,17 @@ impl WasmModuleEmitter { // "expected i64 but nothing on stack" (#1081 sibling instance, constructor case). let mut ctx = FuncEmitCtx::new(self, &local_map, temp_local_idx, temp_i32_idx); ctx.current_class = Some(class.name.clone()); - for stmt in &ctor.body { + for (i, stmt) in ctor.body.iter().enumerate() { ctx.emit_stmt(&mut func, stmt, true); + if super_at == Some(i) { + self.emit_field_initializers( + &mut func, + class, + &local_map, + temp_local_idx, + temp_i32_idx, + ); + } } // Fallthrough: return `this` as the constructor's result. diff --git a/crates/perry-codegen-wasm/src/emit/tests.rs b/crates/perry-codegen-wasm/src/emit/tests.rs index df38b2350e1..fd1578e52e4 100644 --- a/crates/perry-codegen-wasm/src/emit/tests.rs +++ b/crates/perry-codegen-wasm/src/emit/tests.rs @@ -171,3 +171,23 @@ console.log(q.v + " " + q.w + " " + new F().t); "a 2\n7 2 9 2\n1 2 f" ); } + +// A derived class with its own constructor initializes its fields when `super()` +// returns (so the parent's constructor cannot overwrite them, and they can read what +// it set); a base class initializes them before the constructor body. +#[test] +fn derived_fields_initialize_after_super_and_base_fields_before_the_body() { + assert_eq!( + run_web_source( + r#" +class A { x = 1; } +class B extends A { x = 2; constructor() { super(); } } +class P { v: number; constructor() { this.v = 1; } } +class Q extends P { w = this.v + 1; constructor() { super(); this.w = this.w * 10; } } +class K { a = 1; b: number; constructor() { this.b = this.a + 1; } } +console.log(new B().x + " " + new Q().w + " " + new K().b); +"# + ), + "2 20 2" + ); +}