From 476f8e481f105ec2f1a1f8efc46624b15f7f98ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 11 Jun 2026 21:01:02 +0200 Subject: [PATCH] fix(wasm): handle PutValueSet so module-level member writes persist on the web target (#5016) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HIR lowers both `obj.prop = v` and `obj[i] = v` to a single Expr::PutValueSet node. The perry-codegen-wasm backend had no arm for it, so every member write fell through emit_expr's `_ => TAG_UNDEFINED` catch-all and was silently dropped — module-level array/object mutation appeared immutable on the web target (reads returned the initializer). Native always worked. Regression of #1993. Handle PutValueSet in the WASM backend, mirroring the pre-PutValueSet lowering split: string-literal key -> class_set_field (preserves class getters/setters), any other key -> object_set_dynamic; both return the assigned RHS value. Companion walkers updated in tandem: string_collection (intern key/value strings), closures (collect closures in operands), js_fallback (emit JS assignment). WASM suite: 17->21 passing, 0 regressions (the 4 newly-green tests were broken by the same bug). Adds tests/wasm/26_module_array_element_write.ts. --- .../perry-codegen-wasm/src/emit/closures.rs | 15 +++++ .../src/emit/expr/objects.rs | 55 +++++++++++++++++++ .../src/emit/js_fallback.rs | 13 +++++ .../src/emit/string_collection.rs | 16 ++++++ .../26_module_array_element_write.expected | 5 ++ tests/wasm/26_module_array_element_write.ts | 36 ++++++++++++ 6 files changed, 140 insertions(+) create mode 100644 tests/wasm/26_module_array_element_write.expected create mode 100644 tests/wasm/26_module_array_element_write.ts diff --git a/crates/perry-codegen-wasm/src/emit/closures.rs b/crates/perry-codegen-wasm/src/emit/closures.rs index 5c35a2d01a..525b0a1229 100644 --- a/crates/perry-codegen-wasm/src/emit/closures.rs +++ b/crates/perry-codegen-wasm/src/emit/closures.rs @@ -169,6 +169,21 @@ pub(super) fn collect_closures_from_expr( collect_closures_from_expr(index, out); collect_closures_from_expr(value, out); } + // #5016: PutValueSet is the lowered form of `obj.prop = v` / `obj[i] = v`. + // Recurse into all operands so a closure used as a value/key/target + // (e.g. `obj.handler = () => {}`) is collected and emitted. + Expr::PutValueSet { + target, + key, + value, + receiver, + .. + } => { + collect_closures_from_expr(target, out); + collect_closures_from_expr(key, out); + collect_closures_from_expr(value, out); + collect_closures_from_expr(receiver, out); + } Expr::NativeMethodCall { args, object, .. } => { if let Some(o) = object { collect_closures_from_expr(o, out); diff --git a/crates/perry-codegen-wasm/src/emit/expr/objects.rs b/crates/perry-codegen-wasm/src/emit/expr/objects.rs index a497eb2d02..60d1edb3e4 100644 --- a/crates/perry-codegen-wasm/src/emit/expr/objects.rs +++ b/crates/perry-codegen-wasm/src/emit/expr/objects.rs @@ -260,6 +260,61 @@ impl<'a> FuncEmitCtx<'a> { self.emit_memcall_void(func, "object_set_dynamic", 3); func.instruction(&Instruction::LocalGet(self.temp_store_local)); } + // #5016: assignment PutValue for property/index references + // (`obj.prop = v` / `obj[i] = v`). The HIR lowers BOTH member-write + // forms to `PutValueSet`; without this arm it fell through to the + // `_ => TAG_UNDEFINED` catch-all and the write was silently dropped + // — module-level array element mutation (the recommended mutable + // state pattern) appeared immutable on the web/wasm target. + // + // Mirror the pre-`PutValueSet` lowering split so behavior matches + // the still-handled `PropertySet`/`IndexSet` arms: a string-literal + // key (`obj.prop = v`) routes through `class_set_field` so class + // getters/setters still fire; any other key (`obj[i] = v`) routes + // through `object_set_dynamic`. Both return the assigned RHS value + // to preserve assignment-expression semantics. + Expr::PutValueSet { + target, key, value, .. + } => { + if let Expr::String(property) = key.as_ref() { + let key_id = self + .emitter + .string_map + .get(property.as_str()) + .copied() + .unwrap_or(0); + let key_bits = (STRING_TAG << 48) | (key_id as u64); + self.emit_frame_begin(func, 3); + self.emit_store_arg(func, 0, target); + self.emit_store_const(func, 1, f64::from_bits(key_bits)); + self.emit_expr(func, value); + func.instruction(&Instruction::LocalSet(self.temp_store_local)); + self.emit_slot_addr(func, 2); + func.instruction(&Instruction::LocalGet(self.temp_store_local)); + func.instruction(&Instruction::I64Store(wasm_encoder::MemArg { + offset: 0, + align: 3, + memory_index: 0, + })); + self.emit_memcall_void(func, "class_set_field", 3); + func.instruction(&Instruction::LocalGet(self.temp_store_local)); + } else { + self.emit_frame_begin(func, 3); + self.emit_store_arg(func, 0, target); + self.emit_store_arg(func, 1, key); + self.emit_expr(func, value); + func.instruction(&Instruction::LocalSet(self.temp_store_local)); + self.emit_slot_addr(func, 2); + func.instruction(&Instruction::LocalGet(self.temp_store_local)); + func.instruction(&Instruction::I64Store(wasm_encoder::MemArg { + offset: 0, + align: 3, + memory_index: 0, + })); + self.emit_memcall_void(func, "object_set_dynamic", 3); + func.instruction(&Instruction::LocalGet(self.temp_store_local)); + } + } Expr::IndexUpdate { object, index, diff --git a/crates/perry-codegen-wasm/src/emit/js_fallback.rs b/crates/perry-codegen-wasm/src/emit/js_fallback.rs index d6bfcb91b5..e55f12e178 100644 --- a/crates/perry-codegen-wasm/src/emit/js_fallback.rs +++ b/crates/perry-codegen-wasm/src/emit/js_fallback.rs @@ -599,6 +599,19 @@ impl WasmModuleEmitter { obj, idx, val, val ) } + // #5016: `obj.prop = v` / `obj[i] = v` lower to PutValueSet. In the + // JS fallback both are plain `obj[key] = val`; return the RHS value. + Expr::PutValueSet { + target, key, value, .. + } => { + let obj = self.emit_js_expr(target, locals); + let k = self.emit_js_expr(key, locals); + let val = self.emit_js_expr(value, locals); + format!( + "(toJsValue({})[toJsValue({})] = toJsValue({}), {})", + obj, k, val, val + ) + } Expr::ArrayPush { array_id, value } => { let arr = locals .get(array_id) diff --git a/crates/perry-codegen-wasm/src/emit/string_collection.rs b/crates/perry-codegen-wasm/src/emit/string_collection.rs index c2b33441a5..d3cd51cb14 100644 --- a/crates/perry-codegen-wasm/src/emit/string_collection.rs +++ b/crates/perry-codegen-wasm/src/emit/string_collection.rs @@ -526,6 +526,22 @@ impl WasmModuleEmitter { self.collect_strings_in_expr(index); self.collect_strings_in_expr(value); } + // #5016: `obj.prop = v` / `obj[i] = v` lower to PutValueSet. Recurse + // into all operands so the key string (when an `Expr::String`) and + // any string literals in the value/target are interned — the emit + // arm reads the key via `string_map`. + Expr::PutValueSet { + target, + key, + value, + receiver, + .. + } => { + self.collect_strings_in_expr(target); + self.collect_strings_in_expr(key); + self.collect_strings_in_expr(value); + self.collect_strings_in_expr(receiver); + } Expr::Await(e) | Expr::TypeOf(e) | Expr::Void(e) => { self.collect_strings_in_expr(e); } diff --git a/tests/wasm/26_module_array_element_write.expected b/tests/wasm/26_module_array_element_write.expected new file mode 100644 index 0000000000..cfae9982d0 --- /dev/null +++ b/tests/wasm/26_module_array_element_write.expected @@ -0,0 +1,5 @@ +fnRead=5 +topRead=5 +afterTop=99 +viaParam=7 +objProp=11 diff --git a/tests/wasm/26_module_array_element_write.ts b/tests/wasm/26_module_array_element_write.ts new file mode 100644 index 0000000000..7b8a3126c6 --- /dev/null +++ b/tests/wasm/26_module_array_element_write.ts @@ -0,0 +1,36 @@ +// #5016 regression guard: writes to module-level array/object members performed +// inside a function (and at top level) must persist on the web/wasm target. +// Before the fix, `A[0] = ...` lowered to a `PutValueSet` HIR node that the WASM +// codegen never handled, so the write fell through to the undefined catch-all and +// reads returned the initializer. Native always worked; this guards the web path. + +const A: number[] = [0.0]; +function bump(n: number): void { + A[0] = A[0] + n; +} // write a module-level array element inside a function +function readA(): number { + return A[0]; +} +const step: number = Date.now() > 0.0 ? 5.0 : 1.0; // runtime value → not const-folded +bump(step); +console.log("fnRead=" + readA().toString()); +console.log("topRead=" + A[0].toString()); + +// Top-level element write must persist too. +A[0] = 99.0; +console.log("afterTop=" + A[0].toString()); + +// Write through a parameter aliasing the module array. +function setVia(arr: number[], v: number): void { + arr[0] = v; +} +setVia(A, 7.0); +console.log("viaParam=" + A[0].toString()); + +// Object property write (string key) inside a function. +const o: { x: number } = { x: 1.0 }; +function bumpObj(): void { + o.x = o.x + 10.0; +} +bumpObj(); +console.log("objProp=" + o.x.toString());