From fceb44a4e37de47b171fe2b919a45e4fef706734 Mon Sep 17 00:00:00 2001 From: alienx5499 Date: Sun, 15 Mar 2026 16:20:04 +0530 Subject: [PATCH 1/5] feat(intl): implement Temporal.PlainYearMonth.prototype.toLocaleString --- core/engine/src/builtins/date/utils.rs | 8 ++++ .../builtins/temporal/plain_year_month/mod.rs | 38 +++++++++++++++-- .../temporal/plain_year_month/tests.rs | 42 +++++++++++++++++++ 3 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 core/engine/src/builtins/temporal/plain_year_month/tests.rs diff --git a/core/engine/src/builtins/date/utils.rs b/core/engine/src/builtins/date/utils.rs index 964c63e541f..400ff1065c1 100644 --- a/core/engine/src/builtins/date/utils.rs +++ b/core/engine/src/builtins/date/utils.rs @@ -442,6 +442,14 @@ pub(super) fn make_date(day: f64, time: f64) -> f64 { tv } +#[cfg(feature = "intl")] +pub(crate) fn timestamp_for_first_of_month_utc(year: i32, month: u8) -> f64 { + debug_assert!((1..=12).contains(&month)); + let day = make_day(f64::from(year), f64::from(month - 1), 1.0); + let time = make_time(0.0, 0.0, 0.0, 0.0); + time_clip(make_date(day, time)) +} + /// Abstract operation `MakeFullYear ( year )` /// /// More info: diff --git a/core/engine/src/builtins/temporal/plain_year_month/mod.rs b/core/engine/src/builtins/temporal/plain_year_month/mod.rs index 1c328da5997..336245d8c4e 100644 --- a/core/engine/src/builtins/temporal/plain_year_month/mod.rs +++ b/core/engine/src/builtins/temporal/plain_year_month/mod.rs @@ -33,6 +33,10 @@ use super::{ to_temporal_duration, }; +#[cfg(feature = "temporal")] +#[cfg(test)] +mod tests; + /// The `Temporal.PlainYearMonth` built-in implementation /// /// More information: @@ -755,12 +759,15 @@ impl PlainYearMonth { /// /// [spec]: https://tc39.es/proposal-temporal/#sec-temporal.plainyearmonth.tolocalestring /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/PlainYearMonth/toLocaleString + #[allow( + unused_variables, + reason = "`args` and `context` are used when the `intl` feature is enabled" + )] pub(crate) fn to_locale_string( this: &JsValue, - _: &[JsValue], - _: &mut Context, + args: &[JsValue], + context: &mut Context, ) -> JsResult { - // TODO: Update for ECMA-402 compliance let object = this.as_object(); let year_month = object .as_ref() @@ -769,7 +776,30 @@ impl PlainYearMonth { JsNativeError::typ().with_message("this value must be a PlainYearMonth object.") })?; - Ok(JsString::from(year_month.inner.to_string()).into()) + #[cfg(feature = "intl")] + { + use crate::builtins::{ + date::utils::timestamp_for_first_of_month_utc, + intl::date_time_format::{FormatDefaults, FormatType, format_date_time_locale}, + }; + let locales = args.get_or_undefined(0); + let options = args.get_or_undefined(1); + let timestamp = + timestamp_for_first_of_month_utc(year_month.inner.year(), year_month.inner.month()); + format_date_time_locale( + locales, + options, + FormatType::Date, + FormatDefaults::Date, + timestamp, + context, + ) + } + + #[cfg(not(feature = "intl"))] + { + Ok(JsString::from(year_month.inner.to_string()).into()) + } } /// 9.3.21 `Temporal.PlainYearMonth.prototype.toJSON ( )` diff --git a/core/engine/src/builtins/temporal/plain_year_month/tests.rs b/core/engine/src/builtins/temporal/plain_year_month/tests.rs new file mode 100644 index 00000000000..9d87b328a50 --- /dev/null +++ b/core/engine/src/builtins/temporal/plain_year_month/tests.rs @@ -0,0 +1,42 @@ +use crate::{JsNativeErrorKind, TestAction, run_test_actions}; + +#[test] +fn to_locale_string_returns_string() { + run_test_actions([ + TestAction::assert( + "typeof Temporal.PlainYearMonth.from('2024-03').toLocaleString() === 'string'", + ), + TestAction::assert("Temporal.PlainYearMonth.from('2024-03').toLocaleString().length > 0"), + ]); +} + +#[test] +fn to_locale_string_invalid_receiver_throws() { + run_test_actions([TestAction::assert_native_error( + "Temporal.PlainYearMonth.prototype.toLocaleString.call({})", + JsNativeErrorKind::Type, + "this value must be a PlainYearMonth object.", + )]); +} + +#[cfg(feature = "intl")] +#[test] +fn to_locale_string_different_locales_produce_different_output() { + run_test_actions([TestAction::assert( + "Temporal.PlainYearMonth.from('2024-03').toLocaleString('en-US') !== \ + Temporal.PlainYearMonth.from('2024-03').toLocaleString('de-DE')", + )]); +} + +#[cfg(feature = "intl")] +#[test] +fn to_locale_string_options_affect_output() { + run_test_actions([ + TestAction::assert( + "typeof Temporal.PlainYearMonth.from('2024-03').toLocaleString('en-US', { dateStyle: 'short' }) === 'string'", + ), + TestAction::assert( + "Temporal.PlainYearMonth.from('2024-03').toLocaleString('en-US', { dateStyle: 'short' }).length > 0", + ), + ]); +} From 3f5d49e819ff6be3fcd31a4f9aa533e64b746182 Mon Sep 17 00:00:00 2001 From: alienx5499 Date: Fri, 20 Mar 2026 23:26:21 +0530 Subject: [PATCH 2/5] fix(intl): align Temporal.PlainYearMonth.prototype.toLocaleString with ECMA-402 --- core/engine/src/builtins/date/utils.rs | 8 -- .../builtins/temporal/plain_year_month/mod.rs | 81 +++++++++++++++++-- .../temporal/plain_year_month/tests.rs | 19 +++++ 3 files changed, 92 insertions(+), 16 deletions(-) diff --git a/core/engine/src/builtins/date/utils.rs b/core/engine/src/builtins/date/utils.rs index 400ff1065c1..964c63e541f 100644 --- a/core/engine/src/builtins/date/utils.rs +++ b/core/engine/src/builtins/date/utils.rs @@ -442,14 +442,6 @@ pub(super) fn make_date(day: f64, time: f64) -> f64 { tv } -#[cfg(feature = "intl")] -pub(crate) fn timestamp_for_first_of_month_utc(year: i32, month: u8) -> f64 { - debug_assert!((1..=12).contains(&month)); - let day = make_day(f64::from(year), f64::from(month - 1), 1.0); - let time = make_time(0.0, 0.0, 0.0, 0.0); - time_clip(make_date(day, time)) -} - /// Abstract operation `MakeFullYear ( year )` /// /// More info: diff --git a/core/engine/src/builtins/temporal/plain_year_month/mod.rs b/core/engine/src/builtins/temporal/plain_year_month/mod.rs index 336245d8c4e..c34ccad63ea 100644 --- a/core/engine/src/builtins/temporal/plain_year_month/mod.rs +++ b/core/engine/src/builtins/temporal/plain_year_month/mod.rs @@ -768,8 +768,10 @@ impl PlainYearMonth { args: &[JsValue], context: &mut Context, ) -> JsResult { + // 1. Let plainYearMonth be the this value. + // 2. Perform ? RequireInternalSlot(plainYearMonth, [[InitializedTemporalYearMonth]]). let object = this.as_object(); - let year_month = object + let plain_year_month = object .as_ref() .and_then(JsObject::downcast_ref::) .ok_or_else(|| { @@ -778,17 +780,80 @@ impl PlainYearMonth { #[cfg(feature = "intl")] { - use crate::builtins::{ - date::utils::timestamp_for_first_of_month_utc, - intl::date_time_format::{FormatDefaults, FormatType, format_date_time_locale}, + use crate::builtins::intl::date_time_format::{ + FormatDefaults, FormatType, format_date_time_locale, }; + use temporal_rs::TimeZone; let locales = args.get_or_undefined(0); let options = args.get_or_undefined(1); - let timestamp = - timestamp_for_first_of_month_utc(year_month.inner.year(), year_month.inner.month()); + + // From Temporal: `Temporal.PlainYearMonth.prototype.toLocaleString` steps (ECMA-402 integration). + // Source: https://tc39.es/proposal-temporal/#sec-temporal.plainyearmonth.tolocalestring + // 3. Let dateFormat be ? CreateDateTimeFormat(%Intl.DateTimeFormat%, locales, options, date, date). + // 4. Return ? FormatDateTime(dateFormat, plainYearMonth). + // Delegation: `format_date_time_locale` implements CreateDateTimeFormat + FormatDateTime + // and consumes the precomputed epochNs for the Temporal plain anchor. + + // ECMA-402 Temporal integration plumbing (HandleDateTimeTemporalYearMonth). + // + // Source: https://tc39.es/proposal-temporal/#sec-temporal-handledatetimetemporalyearmonth + // + // 1. If temporalYearMonth.[[Calendar]] is not equal to dateTimeFormat.[[Calendar]], throw a RangeError exception. + // 2. Let isoDateTime be CombineISODateAndTimeRecord(temporalYearMonth.[[ISODate]], NoonTimeRecord()). + // 3. Let epochNs be GetUTCEpochNanoseconds(isoDateTime). + // 4. Let format be dateTimeFormat.[[TemporalPlainYearMonthFormat]]. + // 5. If format is null, throw a TypeError exception. + // 6. Return Value Format Record { [[Format]]: format, [[EpochNanoseconds]]: epochNs, [[IsPlain]]: true }. + // + // Delegation note: + // - In this implementation we compute epochNs (steps 2–3), validate Intl `calendar` (step 1), + // and delegate the ECMA-402 formatting to `format_date_time_locale`. + let temporal_calendar = plain_year_month.inner.calendar().identifier(); + + // Ensure Intl inputs match HandleDateTimeTemporalYearMonth expectations. + let options_obj = get_options_object(options)?; + let user_calendar = options_obj.get(js_string!("calendar"), context)?; + + if user_calendar.is_undefined() { + options_obj.create_data_property_or_throw( + js_string!("calendar"), + JsValue::from(JsString::from(temporal_calendar)), + context, + )?; + } else { + // Intl's `calendar` option is parsed from ToString, so we do the same for comparison. + let user_calendar = user_calendar.to_string(context)?.to_std_string_escaped(); + if user_calendar != temporal_calendar { + return Err( + JsNativeError::range() + .with_message("Temporal.PlainYearMonth calendar must match Intl.DateTimeFormat calendar.") + .into(), + ); + } + } + + options_obj.create_data_property_or_throw( + js_string!("timeZone"), + // Temporal plain values ignore user-provided `timeZone` and always use "+00:00". + JsValue::from(js_string!("+00:00")), + context, + )?; + + // Compute epochNs from isoDate + NoonTimeRecord (steps 2-3), then pass it to the shared + // ECMA-402 formatting pipeline as epoch milliseconds. + let epoch_ns = plain_year_month + .inner + .epoch_ns_for_with_provider( + TimeZone::utc_with_provider(context.timezone_provider()), + context.timezone_provider(), + ) + .map_err(|e| JsNativeError::range().with_message(e.to_string()))?; + let timestamp = (epoch_ns.as_i128() as f64) / 1_000_000.0; + + // Delegate the Intl formatting pipeline to the shared ECMA-402 implementation. format_date_time_locale( locales, - options, + &options_obj.into(), FormatType::Date, FormatDefaults::Date, timestamp, @@ -798,7 +863,7 @@ impl PlainYearMonth { #[cfg(not(feature = "intl"))] { - Ok(JsString::from(year_month.inner.to_string()).into()) + Ok(JsString::from(plain_year_month.inner.to_string()).into()) } } diff --git a/core/engine/src/builtins/temporal/plain_year_month/tests.rs b/core/engine/src/builtins/temporal/plain_year_month/tests.rs index 9d87b328a50..ec06d02d696 100644 --- a/core/engine/src/builtins/temporal/plain_year_month/tests.rs +++ b/core/engine/src/builtins/temporal/plain_year_month/tests.rs @@ -40,3 +40,22 @@ fn to_locale_string_options_affect_output() { ), ]); } + +#[cfg(feature = "intl")] +#[test] +fn to_locale_string_ignores_time_zone_for_plain_values() { + run_test_actions([TestAction::assert( + "Temporal.PlainYearMonth.from('2024-03').toLocaleString('en-US', { timeZone: 'America/New_York' }) === \ + Temporal.PlainYearMonth.from('2024-03').toLocaleString('en-US', { timeZone: '+00:00' })", + )]); +} + +#[cfg(feature = "intl")] +#[test] +fn to_locale_string_incompatible_calendar_throws() { + run_test_actions([TestAction::assert_native_error( + "Temporal.PlainYearMonth.from('2024-03').toLocaleString('en-US', { calendar: 'japanese' })", + JsNativeErrorKind::Range, + "Temporal.PlainYearMonth calendar must match Intl.DateTimeFormat calendar.", + )]); +} From e887556fc20b5e70f1827621e5c31d7dbe16d581 Mon Sep 17 00:00:00 2001 From: alienx5499 Date: Sat, 21 Mar 2026 12:50:47 +0530 Subject: [PATCH 3/5] fix(intl): avoid Date style defaults in PlainYearMonth.toLocaleString --- .../src/builtins/intl/date_time_format/mod.rs | 61 ++++++++++++++++--- .../builtins/temporal/plain_year_month/mod.rs | 37 +++++++++-- .../temporal/plain_year_month/tests.rs | 18 ++++++ 3 files changed, 102 insertions(+), 14 deletions(-) diff --git a/core/engine/src/builtins/intl/date_time_format/mod.rs b/core/engine/src/builtins/intl/date_time_format/mod.rs index cc692602c79..2d3d219202e 100644 --- a/core/engine/src/builtins/intl/date_time_format/mod.rs +++ b/core/engine/src/builtins/intl/date_time_format/mod.rs @@ -1011,6 +1011,25 @@ fn unwrap_date_time_format( .into()) } +fn format_date_time_locale_after_coerce( + locales: &JsValue, + options: JsObject, + format_type: FormatType, + defaults: FormatDefaults, + timestamp: f64, + context: &mut Context, +) -> JsResult { + // `options` is already normalized/coerced by the caller. + let options_value = options.into(); + let dtf = create_date_time_format(locales, &options_value, format_type, defaults, context)?; + let x = time_clip(timestamp); + if x.is_nan() { + return Err(js_error!(RangeError: "formatted date cannot be NaN")); + } + let result = format_timestamp_with_dtf(&dtf, x, context)?; + Ok(JsValue::from(result)) +} + /// Shared helper used by Date.prototype.toLocaleString, /// Date.prototype.toLocaleDateString, and Date.prototype.toLocaleTimeString. /// Applies `ToDateTimeOptions` defaults, calls [`create_date_time_format`], and formats @@ -1043,13 +1062,37 @@ pub(crate) fn format_date_time_locale( context, )?; } - let options_value = options.into(); - let dtf = create_date_time_format(locales, &options_value, format_type, defaults, context)?; - // FormatDateTime steps 1–2: TimeClip and NaN check (format_timestamp_with_dtf does ToLocalTime + format only). - let x = time_clip(timestamp); - if x.is_nan() { - return Err(js_error!(RangeError: "formatted date cannot be NaN")); - } - let result = format_timestamp_with_dtf(&dtf, x, context)?; - Ok(JsValue::from(result)) + format_date_time_locale_after_coerce( + locales, + options, + format_type, + defaults, + timestamp, + context, + ) +} + +/// Like [`format_date_time_locale`], but does not inject `"long"` `dateStyle` / `timeStyle`. +/// +/// Callers that need Temporal `PlainYearMonth` default presentation should supply explicit +/// `year` / `month` (or `dateStyle`) on the options object so `CreateDateTimeFormat` does not apply +/// full date defaults that include `day`. +#[allow(clippy::too_many_arguments)] +pub(crate) fn format_date_time_locale_no_implicit_styles( + locales: &JsValue, + options: &JsValue, + format_type: FormatType, + defaults: FormatDefaults, + timestamp: f64, + context: &mut Context, +) -> JsResult { + let options = coerce_options_to_object(options, context)?; + format_date_time_locale_after_coerce( + locales, + options, + format_type, + defaults, + timestamp, + context, + ) } diff --git a/core/engine/src/builtins/temporal/plain_year_month/mod.rs b/core/engine/src/builtins/temporal/plain_year_month/mod.rs index c34ccad63ea..b4db0ad9146 100644 --- a/core/engine/src/builtins/temporal/plain_year_month/mod.rs +++ b/core/engine/src/builtins/temporal/plain_year_month/mod.rs @@ -781,7 +781,7 @@ impl PlainYearMonth { #[cfg(feature = "intl")] { use crate::builtins::intl::date_time_format::{ - FormatDefaults, FormatType, format_date_time_locale, + FormatDefaults, FormatType, format_date_time_locale_no_implicit_styles, }; use temporal_rs::TimeZone; let locales = args.get_or_undefined(0); @@ -791,8 +791,8 @@ impl PlainYearMonth { // Source: https://tc39.es/proposal-temporal/#sec-temporal.plainyearmonth.tolocalestring // 3. Let dateFormat be ? CreateDateTimeFormat(%Intl.DateTimeFormat%, locales, options, date, date). // 4. Return ? FormatDateTime(dateFormat, plainYearMonth). - // Delegation: `format_date_time_locale` implements CreateDateTimeFormat + FormatDateTime - // and consumes the precomputed epochNs for the Temporal plain anchor. + // Delegation: `format_date_time_locale_no_implicit_styles` — same as `Date`'s path but + // without injecting `dateStyle`/`timeStyle: "long"` (that would include the ISO reference day). // ECMA-402 Temporal integration plumbing (HandleDateTimeTemporalYearMonth). // @@ -807,7 +807,7 @@ impl PlainYearMonth { // // Delegation note: // - In this implementation we compute epochNs (steps 2–3), validate Intl `calendar` (step 1), - // and delegate the ECMA-402 formatting to `format_date_time_locale`. + // and delegate the ECMA-402 formatting to `format_date_time_locale_no_implicit_styles`. let temporal_calendar = plain_year_month.inner.calendar().identifier(); // Ensure Intl inputs match HandleDateTimeTemporalYearMonth expectations. @@ -839,6 +839,33 @@ impl PlainYearMonth { context, )?; + // When no explicit style/fields are provided, supply `year` + `month` so + // `CreateDateTimeFormat` does not apply full date defaults (which include `day`). + // We use `month: "short"` (instead of `"numeric"`) because the current Intl formatter can + // produce compact numeric output like `12/24`, which fails Test262's full-year expectation + // (`default-does-not-include-day-time-and-time-zone-name.js` requires `2024` to appear). + let date_style = options_obj.get(js_string!("dateStyle"), context)?; + let time_style = options_obj.get(js_string!("timeStyle"), context)?; + if date_style.is_undefined() && time_style.is_undefined() { + if options_obj.get(js_string!("year"), context)?.is_undefined() { + options_obj.create_data_property_or_throw( + js_string!("year"), + JsValue::from(js_string!("numeric")), + context, + )?; + } + if options_obj + .get(js_string!("month"), context)? + .is_undefined() + { + options_obj.create_data_property_or_throw( + js_string!("month"), + JsValue::from(js_string!("short")), + context, + )?; + } + } + // Compute epochNs from isoDate + NoonTimeRecord (steps 2-3), then pass it to the shared // ECMA-402 formatting pipeline as epoch milliseconds. let epoch_ns = plain_year_month @@ -851,7 +878,7 @@ impl PlainYearMonth { let timestamp = (epoch_ns.as_i128() as f64) / 1_000_000.0; // Delegate the Intl formatting pipeline to the shared ECMA-402 implementation. - format_date_time_locale( + format_date_time_locale_no_implicit_styles( locales, &options_obj.into(), FormatType::Date, diff --git a/core/engine/src/builtins/temporal/plain_year_month/tests.rs b/core/engine/src/builtins/temporal/plain_year_month/tests.rs index ec06d02d696..b854c3a48ba 100644 --- a/core/engine/src/builtins/temporal/plain_year_month/tests.rs +++ b/core/engine/src/builtins/temporal/plain_year_month/tests.rs @@ -50,6 +50,24 @@ fn to_locale_string_ignores_time_zone_for_plain_values() { )]); } +#[cfg(feature = "intl")] +#[test] +fn to_locale_string_default_excludes_reference_day_time_and_zone_name() { + // Mirrors test262 `default-does-not-include-day-time-and-time-zone-name.js`. + run_test_actions([TestAction::assert( + "(() => { \ + const p = new Temporal.PlainYearMonth(2024, 12, 'iso8601', 26); \ + const r = p.toLocaleString('en-u-ca-iso8601', { timeZone: 'UTC' }); \ + return r.includes('2024') \ + && (r.includes('12') || r.includes('Dec')) \ + && !r.includes('26') \ + && !r.includes('00') \ + && !r.includes('UTC') \ + && !r.includes('Coordinated Universal Time'); \ + })()", + )]); +} + #[cfg(feature = "intl")] #[test] fn to_locale_string_incompatible_calendar_throws() { From ea730d8a5219977c053444136818dc69d333ca01 Mon Sep 17 00:00:00 2001 From: alienx5499 Date: Wed, 25 Mar 2026 12:52:02 +0530 Subject: [PATCH 4/5] refactor(intl): extract HandleDateTimeValue dispatcher for Temporal formatting --- .../src/builtins/intl/date_time_format/mod.rs | 181 ++++++++++++++++-- .../builtins/temporal/plain_year_month/mod.rs | 119 ++---------- 2 files changed, 180 insertions(+), 120 deletions(-) diff --git a/core/engine/src/builtins/intl/date_time_format/mod.rs b/core/engine/src/builtins/intl/date_time_format/mod.rs index 2d3d219202e..1ecbbf1457a 100644 --- a/core/engine/src/builtins/intl/date_time_format/mod.rs +++ b/core/engine/src/builtins/intl/date_time_format/mod.rs @@ -1072,27 +1072,182 @@ pub(crate) fn format_date_time_locale( ) } -/// Like [`format_date_time_locale`], but does not inject `"long"` `dateStyle` / `timeStyle`. +/// 15.6.16 `HandleDateTimeTemporalYearMonth ( dateTimeFormat, temporalYearMonth )` /// -/// Callers that need Temporal `PlainYearMonth` default presentation should supply explicit -/// `year` / `month` (or `dateStyle`) on the options object so `CreateDateTimeFormat` does not apply -/// full date defaults that include `day`. -#[allow(clippy::too_many_arguments)] -pub(crate) fn format_date_time_locale_no_implicit_styles( +/// Prepares a `Temporal.PlainYearMonth` for formatting by `Intl.DateTimeFormat`. +/// +/// NOTE: This structure mirrors the spec steps to allow reuse of the same +/// pattern across other Temporal type handlers (`PlainDate`, `PlainMonthDay`, etc.). +/// +/// [spec]: https://tc39.es/proposal-temporal/#sec-temporal-handledatetimetemporalyearmonth +fn handle_date_time_temporal_year_month( + temporal_year_month: &JsValue, locales: &JsValue, options: &JsValue, - format_type: FormatType, - defaults: FormatDefaults, - timestamp: f64, context: &mut Context, ) -> JsResult { - let options = coerce_options_to_object(options, context)?; + use crate::builtins::temporal::PlainYearMonth; + use temporal_rs::TimeZone; + + let object = temporal_year_month.as_object(); + let plain_year_month = object + .as_ref() + .and_then(JsObject::downcast_ref::) + .ok_or_else(|| { + JsNativeError::typ().with_message("this value must be a PlainYearMonth object.") + })?; + + // 1. If temporalYearMonth.[[Calendar]] is not equal to dateTimeFormat.[[Calendar]], + // throw a RangeError exception. + let temporal_calendar = plain_year_month.inner.calendar().identifier(); + + let options_obj = coerce_options_to_object(options, context)?; + let user_calendar = options_obj.get(js_string!("calendar"), context)?; + + if user_calendar.is_undefined() { + options_obj.create_data_property_or_throw( + js_string!("calendar"), + JsValue::from(JsString::from(temporal_calendar)), + context, + )?; + } else { + let user_calendar = user_calendar.to_string(context)?.to_std_string_escaped(); + if user_calendar != temporal_calendar { + return Err(JsNativeError::range() + .with_message( + "Temporal.PlainYearMonth calendar must match Intl.DateTimeFormat calendar.", + ) + .into()); + } + } + + // Implementation note: Temporal plain values always use UTC. + // Override any user-provided timeZone before CreateDateTimeFormat. + options_obj.create_data_property_or_throw( + js_string!("timeZone"), + JsValue::from(js_string!("+00:00")), + context, + )?; + + // Implementation note: When no explicit style/fields are provided, supply + // year + month so CreateDateTimeFormat does not apply full date defaults + // (which include day). + let date_style = options_obj.get(js_string!("dateStyle"), context)?; + let time_style = options_obj.get(js_string!("timeStyle"), context)?; + if date_style.is_undefined() && time_style.is_undefined() { + if options_obj.get(js_string!("year"), context)?.is_undefined() { + options_obj.create_data_property_or_throw( + js_string!("year"), + JsValue::from(js_string!("numeric")), + context, + )?; + } + if options_obj + .get(js_string!("month"), context)? + .is_undefined() + { + options_obj.create_data_property_or_throw( + js_string!("month"), + JsValue::from(js_string!("short")), + context, + )?; + } + } + + // 2. Let isoDateTime be CombineISODateAndTimeRecord(temporalYearMonth.[[ISODate]], NoonTimeRecord()). + // 3. Let epochNs be GetUTCEpochNanoseconds(isoDateTime). + let epoch_ns = plain_year_month + .inner + .epoch_ns_for_with_provider( + TimeZone::utc_with_provider(context.timezone_provider()), + context.timezone_provider(), + ) + .map_err(|e: temporal_rs::TemporalError| { + JsNativeError::range().with_message(e.to_string()) + })?; + let timestamp = (epoch_ns.as_i128() as f64) / 1_000_000.0; + + // 4. Let format be dateTimeFormat.[[TemporalPlainYearMonthFormat]]. + // 5. If format is null, throw a TypeError exception. + // 6. Return Value Format Record { [[Format]]: format, [[EpochNanoseconds]]: epochNs, [[IsPlain]]: true }. + // + // These steps are delegated to the ECMA-402 formatting pipeline via + // `format_date_time_locale_after_coerce`, which handles CreateDateTimeFormat + // and FormatDateTime internally. format_date_time_locale_after_coerce( locales, - options, - format_type, - defaults, + options_obj, + FormatType::Date, + FormatDefaults::Date, timestamp, context, ) } + +/// 15.6.22 `HandleDateTimeValue ( dateTimeFormat, x )` +/// +/// Dispatches `x` to the appropriate handler based on its Temporal type for +/// formatting by `dateTimeFormat`. +/// +/// [spec]: https://tc39.es/proposal-temporal/#sec-temporal-handledatetimevalue +pub(crate) fn handle_date_time_value( + x: &JsValue, + locales: &JsValue, + options: &JsValue, + context: &mut Context, +) -> JsResult { + use crate::builtins::temporal::{ + Instant, PlainDate, PlainDateTime, PlainMonthDay, PlainTime, PlainYearMonth, ZonedDateTime, + }; + + let Some(obj) = x.as_object() else { + return Err(JsNativeError::typ() + .with_message("value is not a Temporal object") + .into()); + }; + + // 1. If x is a Number, return ? HandleDateTimeOthers(dateTimeFormat, x). + // NOTE: Not applicable — callers pass Temporal objects, not Numbers. + + // 2. If x has an [[InitializedTemporalDate]] internal slot, return ? HandleDateTimeTemporalDate(dateTimeFormat, x). + if obj.downcast_ref::().is_some() { + return Err(js_error!(Error: "HandleDateTimeTemporalDate is not yet implemented")); + } + + // 3. If x has an [[InitializedTemporalYearMonth]] internal slot, return ? HandleDateTimeTemporalYearMonth(dateTimeFormat, x). + if obj.downcast_ref::().is_some() { + return handle_date_time_temporal_year_month(x, locales, options, context); + } + + // 4. If x has an [[InitializedTemporalMonthDay]] internal slot, return ? HandleDateTimeTemporalMonthDay(dateTimeFormat, x). + if obj.downcast_ref::().is_some() { + return Err(js_error!(Error: "HandleDateTimeTemporalMonthDay is not yet implemented")); + } + + // 5. If x has an [[InitializedTemporalTime]] internal slot, return ? HandleDateTimeTemporalTime(dateTimeFormat, x). + if obj.downcast_ref::().is_some() { + return Err(js_error!(Error: "HandleDateTimeTemporalTime is not yet implemented")); + } + + // 6. If x has an [[InitializedTemporalDateTime]] internal slot, return ? HandleDateTimeTemporalDateTime(dateTimeFormat, x). + if obj.downcast_ref::().is_some() { + return Err(js_error!(Error: "HandleDateTimeTemporalDateTime is not yet implemented")); + } + + // 7. If x has an [[InitializedTemporalInstant]] internal slot, return HandleDateTimeTemporalInstant(dateTimeFormat, x). + if obj.downcast_ref::().is_some() { + return Err(js_error!(Error: "HandleDateTimeTemporalInstant is not yet implemented")); + } + + // 8. Assert: x has an [[InitializedTemporalZonedDateTime]] internal slot. + // 9. Throw a TypeError exception. + if obj.downcast_ref::().is_some() { + return Err(JsNativeError::typ() + .with_message("Temporal.ZonedDateTime is not allowed in Intl.DateTimeFormat") + .into()); + } + + Err(JsNativeError::typ() + .with_message("value is not a recognized Temporal object") + .into()) +} diff --git a/core/engine/src/builtins/temporal/plain_year_month/mod.rs b/core/engine/src/builtins/temporal/plain_year_month/mod.rs index b4db0ad9146..72f2d0785e4 100644 --- a/core/engine/src/builtins/temporal/plain_year_month/mod.rs +++ b/core/engine/src/builtins/temporal/plain_year_month/mod.rs @@ -761,7 +761,7 @@ impl PlainYearMonth { /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/PlainYearMonth/toLocaleString #[allow( unused_variables, - reason = "`args` and `context` are used when the `intl` feature is enabled" + reason = "`args` and `context` may be unused depending on feature flags" )] pub(crate) fn to_locale_string( this: &JsValue, @@ -770,127 +770,32 @@ impl PlainYearMonth { ) -> JsResult { // 1. Let plainYearMonth be the this value. // 2. Perform ? RequireInternalSlot(plainYearMonth, [[InitializedTemporalYearMonth]]). - let object = this.as_object(); - let plain_year_month = object - .as_ref() - .and_then(JsObject::downcast_ref::) + let () = this + .as_object() + .and_then(|o| o.downcast_ref::().map(|_| ())) .ok_or_else(|| { JsNativeError::typ().with_message("this value must be a PlainYearMonth object.") })?; #[cfg(feature = "intl")] { - use crate::builtins::intl::date_time_format::{ - FormatDefaults, FormatType, format_date_time_locale_no_implicit_styles, - }; - use temporal_rs::TimeZone; + use crate::builtins::intl::date_time_format::handle_date_time_value; + let locales = args.get_or_undefined(0); let options = args.get_or_undefined(1); - // From Temporal: `Temporal.PlainYearMonth.prototype.toLocaleString` steps (ECMA-402 integration). - // Source: https://tc39.es/proposal-temporal/#sec-temporal.plainyearmonth.tolocalestring // 3. Let dateFormat be ? CreateDateTimeFormat(%Intl.DateTimeFormat%, locales, options, date, date). // 4. Return ? FormatDateTime(dateFormat, plainYearMonth). - // Delegation: `format_date_time_locale_no_implicit_styles` — same as `Date`'s path but - // without injecting `dateStyle`/`timeStyle: "long"` (that would include the ISO reference day). - - // ECMA-402 Temporal integration plumbing (HandleDateTimeTemporalYearMonth). - // - // Source: https://tc39.es/proposal-temporal/#sec-temporal-handledatetimetemporalyearmonth - // - // 1. If temporalYearMonth.[[Calendar]] is not equal to dateTimeFormat.[[Calendar]], throw a RangeError exception. - // 2. Let isoDateTime be CombineISODateAndTimeRecord(temporalYearMonth.[[ISODate]], NoonTimeRecord()). - // 3. Let epochNs be GetUTCEpochNanoseconds(isoDateTime). - // 4. Let format be dateTimeFormat.[[TemporalPlainYearMonthFormat]]. - // 5. If format is null, throw a TypeError exception. - // 6. Return Value Format Record { [[Format]]: format, [[EpochNanoseconds]]: epochNs, [[IsPlain]]: true }. - // - // Delegation note: - // - In this implementation we compute epochNs (steps 2–3), validate Intl `calendar` (step 1), - // and delegate the ECMA-402 formatting to `format_date_time_locale_no_implicit_styles`. - let temporal_calendar = plain_year_month.inner.calendar().identifier(); - - // Ensure Intl inputs match HandleDateTimeTemporalYearMonth expectations. - let options_obj = get_options_object(options)?; - let user_calendar = options_obj.get(js_string!("calendar"), context)?; - - if user_calendar.is_undefined() { - options_obj.create_data_property_or_throw( - js_string!("calendar"), - JsValue::from(JsString::from(temporal_calendar)), - context, - )?; - } else { - // Intl's `calendar` option is parsed from ToString, so we do the same for comparison. - let user_calendar = user_calendar.to_string(context)?.to_std_string_escaped(); - if user_calendar != temporal_calendar { - return Err( - JsNativeError::range() - .with_message("Temporal.PlainYearMonth calendar must match Intl.DateTimeFormat calendar.") - .into(), - ); - } - } - - options_obj.create_data_property_or_throw( - js_string!("timeZone"), - // Temporal plain values ignore user-provided `timeZone` and always use "+00:00". - JsValue::from(js_string!("+00:00")), - context, - )?; - - // When no explicit style/fields are provided, supply `year` + `month` so - // `CreateDateTimeFormat` does not apply full date defaults (which include `day`). - // We use `month: "short"` (instead of `"numeric"`) because the current Intl formatter can - // produce compact numeric output like `12/24`, which fails Test262's full-year expectation - // (`default-does-not-include-day-time-and-time-zone-name.js` requires `2024` to appear). - let date_style = options_obj.get(js_string!("dateStyle"), context)?; - let time_style = options_obj.get(js_string!("timeStyle"), context)?; - if date_style.is_undefined() && time_style.is_undefined() { - if options_obj.get(js_string!("year"), context)?.is_undefined() { - options_obj.create_data_property_or_throw( - js_string!("year"), - JsValue::from(js_string!("numeric")), - context, - )?; - } - if options_obj - .get(js_string!("month"), context)? - .is_undefined() - { - options_obj.create_data_property_or_throw( - js_string!("month"), - JsValue::from(js_string!("short")), - context, - )?; - } - } - - // Compute epochNs from isoDate + NoonTimeRecord (steps 2-3), then pass it to the shared - // ECMA-402 formatting pipeline as epoch milliseconds. - let epoch_ns = plain_year_month - .inner - .epoch_ns_for_with_provider( - TimeZone::utc_with_provider(context.timezone_provider()), - context.timezone_provider(), - ) - .map_err(|e| JsNativeError::range().with_message(e.to_string()))?; - let timestamp = (epoch_ns.as_i128() as f64) / 1_000_000.0; - - // Delegate the Intl formatting pipeline to the shared ECMA-402 implementation. - format_date_time_locale_no_implicit_styles( - locales, - &options_obj.into(), - FormatType::Date, - FormatDefaults::Date, - timestamp, - context, - ) + handle_date_time_value(this, locales, options, context) } #[cfg(not(feature = "intl"))] { - Ok(JsString::from(plain_year_month.inner.to_string()).into()) + let plain_year_month = this + .as_object() + .and_then(|o| o.downcast_ref::().map(|ym| ym.inner.to_string())) + .expect("RequireInternalSlot already validated"); + Ok(JsString::from(plain_year_month).into()) } } From a1db0486e9b3ea6b9726ec0bef8508e42005ca5c Mon Sep 17 00:00:00 2001 From: alienx5499 Date: Mon, 13 Apr 2026 13:46:28 +0530 Subject: [PATCH 5/5] fix(intl): stop forcing dateStyle/timeStyle defaults in Date locale formatting --- .../src/builtins/intl/date_time_format/mod.rs | 18 --------------- .../builtins/intl/date_time_format/tests.rs | 23 +++++++++++++++++++ 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/core/engine/src/builtins/intl/date_time_format/mod.rs b/core/engine/src/builtins/intl/date_time_format/mod.rs index 4eb1e1b9702..356beccd5b0 100644 --- a/core/engine/src/builtins/intl/date_time_format/mod.rs +++ b/core/engine/src/builtins/intl/date_time_format/mod.rs @@ -1044,24 +1044,6 @@ pub(crate) fn format_date_time_locale( context: &mut Context, ) -> JsResult { let options = coerce_options_to_object(options, context)?; - if format_type != FormatType::Time - && get_option::(&options, js_string!("dateStyle"), context)?.is_none() - { - options.create_data_property_or_throw( - js_string!("dateStyle"), - JsValue::from(js_string!("long")), - context, - )?; - } - if format_type != FormatType::Date - && get_option::(&options, js_string!("timeStyle"), context)?.is_none() - { - options.create_data_property_or_throw( - js_string!("timeStyle"), - JsValue::from(js_string!("long")), - context, - )?; - } format_date_time_locale_after_coerce( locales, options, diff --git a/core/engine/src/builtins/intl/date_time_format/tests.rs b/core/engine/src/builtins/intl/date_time_format/tests.rs index 66d97ccfd99..84723688752 100644 --- a/core/engine/src/builtins/intl/date_time_format/tests.rs +++ b/core/engine/src/builtins/intl/date_time_format/tests.rs @@ -65,3 +65,26 @@ fn dtf_basic() { TestAction::assert_eq("result === 'Sunday, 20 December 2020 at 14:23:16'", true), ]); } + +#[cfg(feature = "intl_bundled")] +#[test] +fn date_to_locale_string_style_does_not_force_missing_counterpart() { + run_test_actions([ + TestAction::run(indoc! {" + const date = new Date(Date.UTC(2024, 2, 10, 2, 30, 0, 0)); + + const dateOnly = date.toLocaleString('en-US', { dateStyle: 'short', timeZone: 'UTC' }); + const timeOnly = date.toLocaleString('en-US', { timeStyle: 'short', timeZone: 'UTC' }); + "}), + TestAction::assert_eq("dateOnly === '3/10/24'", true), + TestAction::assert_eq("/^2:30\\s?AM$/.test(timeOnly)", true), + TestAction::assert_eq( + "dateOnly.includes(':') || dateOnly.includes('AM') || dateOnly.includes('PM')", + false, + ), + TestAction::assert_eq( + "timeOnly.includes('/') || timeOnly.includes('-') || /January|February|March|April|May|June|July|August|September|October|November|December/.test(timeOnly)", + false, + ), + ]); +}