Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 36 additions & 5 deletions crates/perry-runtime/src/intl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,18 +260,49 @@ fn get_option_value(options: f64, key: &str) -> f64 {
get_field(obj, key)
}

fn get_option_string(options: f64, key: &str) -> Option<String> {
let value = get_option_value(options, key);
/// Coerce an already-fetched option value to its GetOption string form. ECMA-402
/// GetOption treats ONLY `undefined` as "absent → fallback"; every other value —
/// `null` included — is coerced with ToString and then checked against the
/// allow-list, so `{ localeMatcher: null }` must surface as the string "null"
/// (which no enum accepts) and raise a RangeError, not be silently ignored.
/// Kept separate from the property read so callers that must observe the option
/// getter exactly once (the GetOption call-order tests) can reuse the value.
fn coerce_option_string(value: f64) -> Option<String> {
let js = JSValue::from_bits(value.to_bits());
if js.is_undefined() || js.is_null() {
if js.is_undefined() {
None
} else if js.is_null() {
Some("null".to_string())
} else if js.is_any_string() {
string_from_string_value(value)
} else {
Some(value_to_string(value))
}
}

fn get_option_string(options: f64, key: &str) -> Option<String> {
coerce_option_string(get_option_value(options, key))
}

/// As `get_option_string`, but for the Unicode locale-extension keys (`calendar`,
/// `numberingSystem`) whose value is validated for *well-formedness* rather than
/// against a closed enum. ECMA-402 coerces `null` to the string `"null"` — a
/// well-formed `type` subtag that names no supported calendar / numbering system,
/// so ResolveLocale drops it and `resolvedOptions` reports the locale default
/// (`gregory` / `latn`). Perry models no per-locale extension negotiation and
/// otherwise echoes the requested value verbatim, so it mirrors that observable
/// outcome by treating `null` as "absent" (leaving the field at its default)
/// rather than reporting a literal `"null"`. A non-null unsupported value is
/// still echoed, matching Perry's existing behaviour. The option getter is read
/// exactly once so the GetOption call-order is preserved.
fn get_locale_extension_option(options: f64, key: &str) -> Option<String> {
let value = get_option_value(options, key);
if JSValue::from_bits(value.to_bits()).is_null() {
return None;
}
coerce_option_string(value)
}

fn get_option_number(options: f64, key: &str) -> Option<f64> {
let value = get_option_value(options, key);
let js = JSValue::from_bits(value.to_bits());
Expand Down Expand Up @@ -838,7 +869,7 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option
);
// `calendar` must match the Unicode locale `type` nonterminal; store
// the canonicalized ID so `resolvedOptions().calendar` reflects it.
if let Some(calendar) = get_option_string(options, "calendar") {
if let Some(calendar) = get_locale_extension_option(options, "calendar") {
match canonicalize_calendar_id(&calendar) {
Some(canonical) => {
set_internal_field(obj, KEY_CALENDAR, string_value(&canonical))
Expand All @@ -849,7 +880,7 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option
}
}
// `numberingSystem` must be a well-formed `type` nonterminal.
if let Some(ns) = get_option_string(options, "numberingSystem") {
if let Some(ns) = get_locale_extension_option(options, "numberingSystem") {
if !is_well_formed_numbering_system(&ns) {
throw_range_error(&format!(
"Value {ns} out of range for Intl options property numberingSystem"
Expand Down
32 changes: 32 additions & 0 deletions test-files/test_gap_intl_datetimeformat_null_option_5582.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// #5582 — Intl.DateTimeFormat option validation for a `null` option value.
//
// GetOption (ECMA-402) treats ONLY `undefined` as "absent → fallback"; every
// other value, `null` included, is coerced with ToString. So:
// * an enum option (localeMatcher, weekday, hourCycle, dateStyle, …) sees the
// string "null", which no allow-list accepts → RangeError; and
// * a Unicode locale-extension key (calendar, numberingSystem) sees "null",
// which is a well-formed but unsupported `type` subtag, so ResolveLocale
// drops it and resolvedOptions reports the locale default (gregory / latn).
// Both branches must match `node --experimental-strip-types` byte-for-byte.
function probe(opt: string): string {
try {
const options: Record<string, unknown> = {};
options[opt] = null;
new Intl.DateTimeFormat(undefined, options as Intl.DateTimeFormatOptions);
return "accepted";
} catch (e) {
return (e as Error).constructor.name;
}
}

for (const opt of ["localeMatcher", "formatMatcher", "weekday", "hourCycle", "dateStyle"]) {
console.log(opt, probe(opt));
}

// Locale-extension keys fall back to the locale default rather than throwing.
const ro = new Intl.DateTimeFormat("en", {
calendar: null as unknown as string,
numberingSystem: null as unknown as string,
}).resolvedOptions();
console.log("calendar", ro.calendar);
console.log("numberingSystem", ro.numberingSystem);
Loading