diff --git a/src/twocore/frontend/js/lower.gleam b/src/twocore/frontend/js/lower.gleam index 6b71797..c7411ff 100644 --- a/src/twocore/frontend/js/lower.gleam +++ b/src/twocore/frontend/js/lower.gleam @@ -4849,7 +4849,11 @@ fn lower_static_call( // argument of get/set is not modelled here. "Reflect", "has", [t, k, ..] -> host("reflect_has", [t, k]) "Reflect", "get", [t, k, ..] -> host("reflect_get", [t, k]) - "Reflect", "set", [t, k, v, ..] -> host("reflect_set", [t, k, v]) + // Reflect.set(target, propertyKey, V[, receiver]): a missing receiver + // defaults to `target` (§28.1.13 step 4.a), so the ordinary 3-arg form still + // writes on the target. A distinct receiver diverts the write there. + "Reflect", "set", [t, k, v, r, ..] -> host("reflect_set", [t, k, v, r]) + "Reflect", "set", [t, k, v] -> host("reflect_set", [t, k, v, t]) "Reflect", "deleteProperty", [t, k, ..] -> host("reflect_delete_property", [t, k]) "Reflect", "ownKeys", [t, ..] -> host("reflect_own_keys", [t]) diff --git a/src/twocore/runtime/rt_js.gleam b/src/twocore/runtime/rt_js.gleam index bffd603..c942891 100644 --- a/src/twocore/runtime/rt_js.gleam +++ b/src/twocore/runtime/rt_js.gleam @@ -1084,10 +1084,17 @@ pub fn reflect_has(target: Dynamic, key: Dynamic) -> Dynamic @external(erlang, "twocore_rt_js_ffi", "reflect_get") pub fn reflect_get(target: Dynamic, key: Dynamic) -> Dynamic -/// `Reflect.set(target, key, value)` → JS boolean success; `target` must be an -/// object, else TypeError. A frozen target reports `false`. `receiver` is ignored. +/// `Reflect.set(target, key, value, receiver)` → JS boolean success; `target` +/// must be an object, else TypeError. A frozen target reports `false`. The write +/// lands on `receiver` (defaulting to `target` when the JS call omits it): a +/// primitive receiver or a non-writable/non-extensible receiver reports `false`. @external(erlang, "twocore_rt_js_ffi", "reflect_set") -pub fn reflect_set(target: Dynamic, key: Dynamic, value: Dynamic) -> Dynamic +pub fn reflect_set( + target: Dynamic, + key: Dynamic, + value: Dynamic, + receiver: Dynamic, +) -> Dynamic /// `Reflect.deleteProperty(target, key)` → JS boolean success; `target` must be an /// object, else TypeError. A frozen/sealed non-configurable property reports `false`. diff --git a/src/twocore_rt_js_ffi.erl b/src/twocore_rt_js_ffi.erl index b102e66..02d7738 100644 --- a/src/twocore_rt_js_ffi.erl +++ b/src/twocore_rt_js_ffi.erl @@ -110,7 +110,7 @@ object_prevent_extensions/1, object_is_extensible/1, object_seal/1, object_is_sealed/1, object_from_entries/1, object_is/2, object_has_own/2, - reflect_has/2, reflect_get/2, reflect_set/3, reflect_delete_property/2, + reflect_has/2, reflect_get/2, reflect_set/4, reflect_delete_property/2, reflect_own_keys/1, reflect_get_prototype_of/1, reflect_is_extensible/1, reflect_prevent_extensions/1, reflect_apply/3, json_stringify/3, json_parse/1, @@ -1914,18 +1914,50 @@ reflect_get(Target, Key) when is_reference(Target) -> reflect_get(Target, _Key) -> type_error(Target). -%% Reflect.set(target, propertyKey, V) (§28.1.13) -> JS boolean success. TypeError -%% if target is not an Object. A frozen/non-writable target makes the write a no-op -%% and reports `false`; otherwise the write succeeds and reports `true`. The optional -%% `receiver` is not modelled. -reflect_set(Target, Key, V) when is_reference(Target) -> +%% Reflect.set(target, propertyKey, V, Receiver) (§28.1.13) -> JS boolean success. +%% TypeError if `target` is not an Object. Implements the receiver-aware portion of +%% OrdinarySetWithOwnDescriptor (9.1.9.2) that is observable in this v1 model: +%% +%% * A frozen `target` presents a non-writable data descriptor, so the write is +%% refused and `false` is returned (step 5.a). +%% * `Type(Receiver)` must be Object; a primitive receiver (string/number/…) is +%% refused with `false` and nothing is written (step 5.b). +%% * Otherwise the value is written on the RECEIVER, not the target (steps 5.c–f): +%% with no explicit receiver the lowering passes `Receiver = target`, so an +%% ordinary `Reflect.set(o, k, v)` still mutates `o`; with a distinct receiver +%% object the value lands there and the target is left untouched. +%% * A frozen receiver (existing property non-writable) or a non-extensible +%% receiver that lacks the key (CreateDataProperty fails) reports `false`. +%% +%% Individual per-property writable/accessor descriptors and the prototype-chain +%% walk of step 4 are not modelled (no descriptor surface in v1). +reflect_set(Target, Key, V, Receiver) when is_reference(Target) -> case erlang:get({js_frozen, Target}) =:= true of - true -> false; + true -> + false; _ -> - set_prop(Target, Key, V), - true + case is_reference(Receiver) of + false -> + false; + true -> + case erlang:get({js_frozen, Receiver}) =:= true of + true -> + false; + _ -> + HasOwn = has_prop(Receiver, Key) =:= 1, + NonExt = + erlang:get({js_nonextensible, Receiver}) =:= true, + case (not HasOwn) andalso NonExt of + true -> + false; + false -> + set_prop(Receiver, Key, V), + true + end + end + end end; -reflect_set(Target, _Key, _V) -> +reflect_set(Target, _Key, _V, _Receiver) -> type_error(Target). %% Reflect.deleteProperty(target, propertyKey) (§28.1.4) -> JS boolean success. diff --git a/test/js_compiler_test.gleam b/test/js_compiler_test.gleam index 7011cc1..06c0176 100644 --- a/test/js_compiler_test.gleam +++ b/test/js_compiler_test.gleam @@ -6851,3 +6851,55 @@ pub fn array_map_thisarg_arrow_unaffected_test() { val("[1, 2, 3].map((x) => x * 2, { k: 9 }).join(',')") |> should.equal(dyn("2,4,6")) } + +// ==== Reflect (wave 13) ==== + +// §28.1.13 Reflect.set(target, key, V): with the receiver omitted the receiver +// defaults to the target (step 4.a), so the value is written on the target and a +// successful write reports the boolean `true`. +pub fn reflect_set_default_receiver_test() { + let m = + compile( + "function f() { var o = { p: 43 };" + <> " var r = Reflect.set(o, 'p', 42);" + <> " return (r === true ? 1 : 0) * 1000 + o.p; }", + ) + to_float(call(m, "f", [])) |> should.equal(1042.0) +} + +// §28.1.13 / 9.1.9.2 OrdinarySetWithOwnDescriptor step 5.c–f: when a distinct +// receiver object is supplied the data write lands on the RECEIVER, the target is +// left unchanged, and the operation still reports `true`. +pub fn reflect_set_distinct_receiver_test() { + let m = + compile( + "function f() { var o = { p: 43 }; var rec = { p: 44 };" + <> " var r = Reflect.set(o, 'p', 42, rec);" + <> " return (r === true ? 1 : 0) * 100000 + o.p * 100 + rec.p; }", + ) + to_float(call(m, "f", [])) |> should.equal(104_342.0) +} + +// §28.1.13 / 9.1.9.2 step 5.b: a data-property write with a non-Object receiver +// (here a primitive string) returns `false` and mutates nothing on the target. +pub fn reflect_set_primitive_receiver_false_test() { + let m = + compile( + "function f() { var o = { p: 42 };" + <> " var r = Reflect.set(o, 'p', 43, 'not an object');" + <> " return (r === false ? 1 : 0) * 1000 + o.p; }", + ) + to_float(call(m, "f", [])) |> should.equal(1042.0) +} + +// §28.1.13 / 9.1.9.2 step 5.a: a frozen target presents a non-writable data +// descriptor, so the write is refused with `false` and the value is unchanged. +pub fn reflect_set_frozen_target_false_test() { + let m = + compile( + "function f() { var o = { p: 1 }; Object.freeze(o);" + <> " var r = Reflect.set(o, 'p', 2);" + <> " return (r === false ? 1 : 0) * 1000 + o.p; }", + ) + to_float(call(m, "f", [])) |> should.equal(1001.0) +}