diff --git a/crates/perry-runtime/src/date.rs b/crates/perry-runtime/src/date.rs index 45b9a1abcb..7d73832bda 100644 --- a/crates/perry-runtime/src/date.rs +++ b/crates/perry-runtime/src/date.rs @@ -1050,7 +1050,17 @@ pub extern "C" fn js_date_apply_setter( } else { unsafe { std::slice::from_raw_parts(args_ptr, argc) } }; - // setTime: single value, replaces the whole time. + // Spec: every `Date.prototype.set*` reads `thisTimeValue` (the receiver's + // current `[[DateValue]]`) BEFORE coercing any argument via ToNumber. A + // user `valueOf` on an argument can re-enter and mutate this very cell + // (test262 `set*/date-value-read-before-tonumber-when-date-is-{valid, + // invalid}`); the timestamp captured here is the one the rebuild must use, + // not whatever the cell holds after the ToNumber side effects. The brand + // check (`this` must be a Date) happens earlier, in the reflective setter + // thunks (`object::date_proto_thunks`) and on the codegen instance path. + let captured = date_cell_timestamp(date); + // setTime: single value, replaces the whole time. The old value is unused + // (beyond the brand check above), so no read-before ordering applies. if field == 7 { let v = if argc == 0 { f64::NAN @@ -1059,6 +1069,36 @@ pub extern "C" fn js_date_apply_setter( }; return date_cell_store(date, v); } + // setYear (annexB): like setFullYear but rebases a truncated year in + // `0..=99` to `1900 + y`, operates in local time, and has no UTC variant. + if field == 8 { + let y_raw = if argc == 0 { + f64::NAN + } else { + jsvalue_to_number(args[0]) + }; + let yyyy = if y_raw.is_nan() { + f64::NAN + } else { + let yi = y_raw.trunc(); + if (0.0..=99.0).contains(&yi) { + 1900.0 + yi + } else { + y_raw + } + }; + return rebuild_local_with( + date, + captured, + Some(yyyy), + None, + None, + None, + None, + None, + None, + ); + } // `req(0)` is the *leading*, required component: an omitted leading // argument coerces to NaN (e.g. `setHours()` → Invalid Date). Trailing // optional components use `opt(i)`: an omitted (or `undefined`) trailing @@ -1092,9 +1132,9 @@ pub extern "C" fn js_date_apply_setter( _ => return date_cell_store(date, f64::NAN), }; if is_utc != 0 { - rebuild_with(date, year, month0, day, hour, minute, second, ms) + rebuild_with(date, captured, year, month0, day, hour, minute, second, ms) } else { - rebuild_local_with(date, year, month0, day, hour, minute, second, ms) + rebuild_local_with(date, captured, year, month0, day, hour, minute, second, ms) } } @@ -1291,6 +1331,7 @@ pub extern "C" fn js_date_get_timezone_offset(timestamp: f64) -> f64 { #[allow(clippy::too_many_arguments)] fn rebuild_with( date: f64, + timestamp: f64, year: Option, month0: Option, day: Option, @@ -1299,12 +1340,15 @@ fn rebuild_with( second: Option, millisecond: Option, ) -> f64 { - let timestamp = date_cell_timestamp(date); let (cy, cm0, cd, ch, cmi, cs, cur_ms) = if timestamp.is_nan() { // Setting the year revives an Invalid Date (ECMA MakeDate seeds from - // year/0/1); any other component setter on an Invalid Date is a no-op. + // year/0/1); any other component setter on an Invalid Date returns NaN + // WITHOUT writing `[[DateValue]]` (spec step "If t is NaN, return NaN" + // precedes SetDateValue), so a `valueOf` that mutated the cell during + // argument coercion keeps its effect (test262 + // `date-value-read-before-tonumber-when-date-is-invalid`). if year.is_none() { - return date_cell_store(date, timestamp); + return f64::NAN; } (1970i64, 0i64, 1i64, 0i64, 0i64, 0i64, 0i64) } else { @@ -1358,6 +1402,7 @@ fn rebuild_with( #[allow(clippy::too_many_arguments)] fn rebuild_local_with( date: f64, + timestamp: f64, year: Option, month0: Option, day: Option, @@ -1366,10 +1411,11 @@ fn rebuild_local_with( second: Option, millisecond: Option, ) -> f64 { - let timestamp = date_cell_timestamp(date); let (cy, cm0, cd, ch, cmi, cs, cur_ms) = if timestamp.is_nan() { + // See `rebuild_with`: a NaN time value with no year override returns NaN + // without touching `[[DateValue]]`. if year.is_none() { - return date_cell_store(date, timestamp); + return f64::NAN; } (1970i64, 0i64, 1i64, 0i64, 0i64, 0i64, 0i64) } else { diff --git a/crates/perry-runtime/src/object/date_proto_thunks.rs b/crates/perry-runtime/src/object/date_proto_thunks.rs index 9fab892ad1..430728b401 100644 --- a/crates/perry-runtime/src/object/date_proto_thunks.rs +++ b/crates/perry-runtime/src/object/date_proto_thunks.rs @@ -112,3 +112,108 @@ pub(crate) fn install_date_proto_getters(proto_obj: *mut ObjectHeader) { super::global_this::install_proto_method(proto_obj, name, ptr, 0); } } + +// --- Setters --------------------------------------------------------------- +// +// Like the getters, the instance fast path (`d.setHours(...)`) is lowered by +// codegen straight to `js_date_apply_setter`. But `Date.prototype.setHours` is +// also a plain value (`.call`/`.apply`, method extraction, the legacy +// `setYear`/`setUTC*` family with no codegen fast path), and on that reflective +// path the methods were generic no-ops: `setDate.call(realDate, 1)` no-op'd and +// `setDate.call(nonDate, 1)` silently produced garbage instead of throwing. +// +// Each setter performs `thisTimeValue(this)` (TypeError if `this` is not a +// Date) and is variadic, so these thunks read the `IMPLICIT_THIS` receiver, +// brand-check it, collect the `rest` arguments, and dispatch to the same +// `js_date_apply_setter` the instance path uses. `js_date_apply_setter` reads +// `[[DateValue]]` BEFORE coercing the arguments, so the read-before-ToNumber +// ordering holds on the reflective path too. + +/// Resolve the `IMPLICIT_THIS` receiver to a Date value, or throw a `TypeError` +/// (`thisTimeValue` brand check) when it is not a Date. Returns the NaN-boxed +/// `DateCell` value (the setter dispatch needs the receiver itself, not just +/// its timestamp, so it can mutate the cell in place). +fn require_date_this() -> f64 { + let this = f64::from_bits(IMPLICIT_THIS.with(|c| c.get())); + if crate::date::is_date_value(this) { + this + } else { + super::object_ops::throw_object_type_error(b"this is not a Date object.") + } +} + +/// `field`/`is_utc` selectors mirror `crate::date::js_date_apply_setter`: +/// 0=FullYear 1=Month 2=Date 3=Hours 4=Minutes 5=Seconds 6=Milliseconds +/// 7=Time, plus 8=setYear (annexB; local-only). `is_utc != 0` picks the UTC +/// rebuild. +macro_rules! date_setter_thunk { + ($name:ident, $is_utc:expr, $field:expr) => { + extern "C" fn $name(_closure: *const crate::closure::ClosureHeader, rest: f64) -> f64 { + let this = require_date_this(); + let args = super::global_this::global_this_rest_array_values(rest); + crate::date::js_date_apply_setter( + this, + $is_utc, + $field, + args.as_ptr(), + args.len() as i32, + ) + } + }; +} + +date_setter_thunk!(date_set_time, 0, 7); +date_setter_thunk!(date_set_full_year, 0, 0); +date_setter_thunk!(date_set_month, 0, 1); +date_setter_thunk!(date_set_date, 0, 2); +date_setter_thunk!(date_set_hours, 0, 3); +date_setter_thunk!(date_set_minutes, 0, 4); +date_setter_thunk!(date_set_seconds, 0, 5); +date_setter_thunk!(date_set_milliseconds, 0, 6); +date_setter_thunk!(date_set_year, 0, 8); +date_setter_thunk!(date_set_utc_full_year, 1, 0); +date_setter_thunk!(date_set_utc_month, 1, 1); +date_setter_thunk!(date_set_utc_date, 1, 2); +date_setter_thunk!(date_set_utc_hours, 1, 3); +date_setter_thunk!(date_set_utc_minutes, 1, 4); +date_setter_thunk!(date_set_utc_seconds, 1, 5); +date_setter_thunk!(date_set_utc_milliseconds, 1, 6); + +/// Install the brand-checked `Date.prototype` setter thunks. Called from +/// `global_this::populate_builtin_prototype_methods`'s `"Date"` arm AFTER the +/// no-op block, so these overwrite the no-op setter entries. Each is installed +/// as a variadic (`rest`) method so the optional trailing components arrive in +/// the `rest` array; `spec_length` is the ECMAScript `.length`. +pub(crate) fn install_date_proto_setters(proto_obj: *mut ObjectHeader) { + if proto_obj.is_null() { + return; + } + // (name, func_ptr, spec `.length`) + let methods: &[(&str, *const u8, u32)] = &[ + ("setTime", date_set_time as *const u8, 1), + ("setFullYear", date_set_full_year as *const u8, 3), + ("setMonth", date_set_month as *const u8, 2), + ("setDate", date_set_date as *const u8, 1), + ("setHours", date_set_hours as *const u8, 4), + ("setMinutes", date_set_minutes as *const u8, 3), + ("setSeconds", date_set_seconds as *const u8, 2), + ("setMilliseconds", date_set_milliseconds as *const u8, 1), + ("setYear", date_set_year as *const u8, 1), + ("setUTCFullYear", date_set_utc_full_year as *const u8, 3), + ("setUTCMonth", date_set_utc_month as *const u8, 2), + ("setUTCDate", date_set_utc_date as *const u8, 1), + ("setUTCHours", date_set_utc_hours as *const u8, 4), + ("setUTCMinutes", date_set_utc_minutes as *const u8, 3), + ("setUTCSeconds", date_set_utc_seconds as *const u8, 2), + ( + "setUTCMilliseconds", + date_set_utc_milliseconds as *const u8, + 1, + ), + ]; + for (name, ptr, length) in methods.iter().copied() { + // call_fixed_arity = 0: every argument arrives in the `rest` array, so + // one thunk shape covers the 0..=4-arg setters uniformly. + super::global_this::install_proto_method_rest_with_length(proto_obj, name, ptr, length, 0); + } +} diff --git a/crates/perry-runtime/src/object/global_this.rs b/crates/perry-runtime/src/object/global_this.rs index 916b4a1440..ea6118e23b 100644 --- a/crates/perry-runtime/src/object/global_this.rs +++ b/crates/perry-runtime/src/object/global_this.rs @@ -4073,6 +4073,10 @@ fn populate_builtin_prototype_methods(builtin_name: &str, proto_obj: *mut Object // MUST run after the OBJECT_PROTO_METHODS block, which would // otherwise re-clobber `valueOf` with the generic Object no-op. date_proto_thunks::install_date_proto_getters(proto_obj); + // Same treatment for the mutating setters: `Date.prototype.setX` + // brand-checks `this`, reads `[[DateValue]]` before coercing args, + // then mutates the cell. Also after the OBJECT_PROTO_METHODS block. + date_proto_thunks::install_date_proto_setters(proto_obj); install_proto_method( proto_obj, "isPrototypeOf",