From 467a21d4177b314977985f164c58a64711adfaef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 2 Sep 2026 10:51:36 +0200 Subject: [PATCH 1/2] fix(date): an offsetless ISO date-TIME is LOCAL, not UTC (#9449) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `new Date("2026-09-01T10:30")` was parsed as UTC. ECMA-262 §21.4.3.2 splits the default zone on whether a TIME is present: a date-ONLY form with no offset is UTC, a date-TIME form with no offset is LOCAL wall-clock time. Perry applied UTC to both, so the instant was silently off by the host's UTC offset — with no error — for the single most common timestamp spelling there is (`` values, log lines, hand-written literals), and it differed between a developer's machine and a UTC server. The doc comment on `parse_date_string` asserted exactly the wrong rule ("date-time forms without an offset are also treated as UTC (matching V8's ISO handling)"), which is why the behaviour looked deliberate; it is corrected with the code. `parse_iso8601` now records whether a time component was read and, with no designator, converts a date-time through the host's UTC offset at that instant. The date-only half is unchanged and the fixture pins it, so "make everything local" can never be the fix. An explicit `Z` / `±HH:MM` still wins in both forms. A trailing whitespace-separated `GMT` / `UTC` / `UT` / `Z` WORD is now recognised as a designator. The scan only looked for `Z`, `+` and `-`, so the word was ignored outright — invisible while every offsetless form was read as UTC, and a wrong instant the moment they are read as local. `new Date("2026-09-01 10:30 GMT")` stays 10:30 UTC, as in node. The conversion the RFC/month-name and numeric-slash grammars already performed inline is lifted into one `local_wall_clock_to_utc` helper that all three now share, so they cannot drift apart again. The slash grammar from #9414 is byte-for-byte unchanged. Validation: test-files/test_gap_date_iso_datetime_local_9449.ts is 68 lines byte-identical to node 26.5.1; 25 of those lines diverged on a compiler built from unfixed origin/main. Offsetless rows assert the LOCAL getters and an equality against a locally-constructed reference Date, so the expected bytes do not depend on the runner's zone. `cargo test --release -p perry-runtime --lib`: 2973 passed, 2 failed — both `gc::tests::handle_bound_method_name::*`, reproduced identically on unmodified origin/main sources under the same build profile. --- changelog.d/9449-iso-datetime-local.md | 64 ++++++++ crates/perry-runtime/src/date.rs | 56 +++++++ crates/perry-runtime/src/date/parse.rs | 79 ++++++--- .../test_gap_date_iso_datetime_local_9449.ts | 154 ++++++++++++++++++ 4 files changed, 332 insertions(+), 21 deletions(-) create mode 100644 changelog.d/9449-iso-datetime-local.md create mode 100644 test-files/test_gap_date_iso_datetime_local_9449.ts diff --git a/changelog.d/9449-iso-datetime-local.md b/changelog.d/9449-iso-datetime-local.md new file mode 100644 index 0000000000..623e71aa5f --- /dev/null +++ b/changelog.d/9449-iso-datetime-local.md @@ -0,0 +1,64 @@ +### Fixed + +- **`new Date("2026-09-01T10:30")` was parsed as UTC; node and ECMA-262 read an + offsetless date-TIME as LOCAL wall-clock time.** In a UTC+2 host the string + became `2026-09-01T10:30:00.000Z` instead of node's + `2026-09-01T08:30:00.000Z` — silently off by the host's UTC offset, with no + error and no diagnostic. `"YYYY-MM-DDTHH:mm"` is what every + `` value, most log lines and most hand-written + timestamps look like, so the wrong instant was plausible enough to survive + review and it differed between a developer's machine and a UTC server. + + ECMA-262 §21.4.3.2 splits the default zone on whether a TIME is present: + + - a date-**only** form with no offset (`"2026-09-01"`) is **UTC**; + - a date-**time** form with no offset (`"2026-09-01T10:30"`) is **LOCAL**. + + Perry applied UTC to both. The doc comment on `parse_date_string` asserted + exactly that — *"date-time forms without an offset are also treated as UTC + (matching V8's ISO handling)"* — which is wrong against both node and the + spec, and is the reason the behaviour looked deliberate. It is corrected + with the code. + + The date-only half was already right and is unchanged; the fixture pins it + so "make everything local" can never be the fix. An explicit `Z` / + `±HH:MM` designator still wins in both forms. + + Affected files: + + - `crates/perry-runtime/src/date/parse.rs` — `parse_iso8601` now records + whether a time component was read and, with no designator, converts a + date-time through the host's UTC offset at that instant; the doc comment + is corrected. The conversion the RFC/month-name and numeric-slash + grammars already performed inline is lifted into one + `local_wall_clock_to_utc` helper that all three now share, so the three + grammars cannot drift apart again. + + The space-separated MySQL spelling (`"2026-09-01 10:30"`) takes the same + branch, which is what node does — measured, including the seconds and + fractional-second variants. The numeric slash grammar from #9414 was + already local and is byte-for-byte unchanged; its fixture + (`test_gap_date_parse_slash_9414.ts`) is the regression guard and still + passes. + + Validation: `test-files/test_gap_date_iso_datetime_local_9449.ts` is 68 lines + byte-identical to node 26.5.1, of which **25 diverged** on a compiler + built from unfixed `origin/main`. It covers date-only (must stay UTC), + `T10:30`, `T10:30:45`, `T10:30:45.123`, over-long and short fractions, the + space spelling, midnight `T00:00`, the rolling `T24:00`, a January and a + July row (a fixed offset instead of the offset at that instant would break + one of them in any DST zone), the expanded-year spelling, `Z`, `+05:00`, + `-08:00`, `Date.parse` agreeing with `new Date`, and controls for the + slash, RFC-1123 and month-name grammars, plus twelve rows for the trailing + `GMT` / `UTC` / `UT` / `Z` designator word — the rows this change could + most easily have broken, since they were UTC before it only because + everything was. Offsetless rows assert the LOCAL getters and an equality + against a locally-constructed reference `Date`, so the expected bytes do + not depend on the runner's zone. + + `cargo test --release -p perry-runtime --lib`: 2973 passed, 2 failed. Both + failures are `gc::tests::handle_bound_method_name::*`, which assert + `&'static` literal POINTER IDENTITY; they reproduce identically on + unmodified `origin/main` sources under the same local build profile + (`CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16`, which lets the same read-only + literal be emitted into two codegen units) and are unrelated to dates. diff --git a/crates/perry-runtime/src/date.rs b/crates/perry-runtime/src/date.rs index 8901a958aa..ad39d13a68 100644 --- a/crates/perry-runtime/src/date.rs +++ b/crates/perry-runtime/src/date.rs @@ -1891,6 +1891,62 @@ mod tests { assert!(parse_date_string("not a date").is_nan()); } + /// #9449: ECMA-262 §21.4.3.2 reads an offsetless date-TIME as LOCAL wall + /// clock and an offsetless date-ONLY form as UTC. The local half is + /// asserted through the local-component decoder so the test is host-zone + /// independent; the UTC half and the explicit designators are fixed + /// epochs and are asserted directly. + #[test] + fn test_date_parse_iso_offsetless_datetime_is_local() { + let wall = |s: &str| { + let ts = parse_date_string(s); + assert!(!ts.is_nan(), "expected a valid date for {s:?}"); + let (y, mo, d, h, mi, sec, _) = + timestamp_to_local_components((ts as i64).div_euclid(1000)); + (y, mo, d, h, mi, sec) + }; + // Both separators, every clock precision. + assert_eq!(wall("2026-09-01T10:30"), (2026, 9, 1, 10, 30, 0)); + assert_eq!(wall("2026-09-01T10:30:45"), (2026, 9, 1, 10, 30, 45)); + assert_eq!(wall("2026-09-01T10:30:45.123"), (2026, 9, 1, 10, 30, 45)); + assert_eq!(wall("2026-09-01 10:30"), (2026, 9, 1, 10, 30, 0)); + assert_eq!(wall("2026-09-01 10:30:45.123"), (2026, 9, 1, 10, 30, 45)); + assert_eq!(wall("2026-09-01T00:00"), (2026, 9, 1, 0, 0, 0)); + // A January row and a July row: a FIXED offset in place of the offset + // in effect AT THAT INSTANT breaks one of the two in any DST zone. + assert_eq!(wall("2026-01-15T10:30"), (2026, 1, 15, 10, 30, 0)); + assert_eq!(wall("2026-07-15T10:30"), (2026, 7, 15, 10, 30, 0)); + // Sub-second precision survives the conversion (zone offsets are whole + // minutes, so the millisecond is untouched in every host zone). + assert_eq!(parse_date_string("2026-09-01T10:30:45.123") % 1000.0, 123.0); + + // The date-ONLY half must stay UTC, and an explicit designator must + // keep winning in the date-time form. + assert_eq!(parse_date_string("2020-01-02"), 1_577_923_200_000.0); + assert_eq!( + parse_date_string("2020-01-02T03:04:05.006Z"), + 1_577_934_245_006.0 + ); + assert_eq!( + parse_date_string("2020-01-02T03:04:05+02:30"), + 1_577_925_245_000.0 + ); + // A trailing GMT / UTC / UT / Z WORD is a designator too — node + // accepts it in the space-separated spelling, and it was silently + // ignored here while every offsetless form was read as UTC. + for s in [ + "2020-01-02 03:04:05 GMT", + "2020-01-02 03:04:05 UTC", + "2020-01-02 03:04:05 ut", + "2020-01-02 03:04:05 z", + "2020-01-02 03:04:05 Z", + ] { + assert_eq!(parse_date_string(s), 1_577_934_245_000.0, "{s:?}"); + } + // ...but a trailing parenthesised comment is not, so it stays local. + assert_eq!(wall("2026-09-01 10:30 (comment)"), (2026, 9, 1, 10, 30, 0)); + } + /// #9414: the numeric slash grammar node accepts as its /// implementation-defined format. Measured against /// `node --experimental-strip-types`, not derived from the spec (which diff --git a/crates/perry-runtime/src/date/parse.rs b/crates/perry-runtime/src/date/parse.rs index 01ddcefa28..169fc07405 100644 --- a/crates/perry-runtime/src/date/parse.rs +++ b/crates/perry-runtime/src/date/parse.rs @@ -16,8 +16,13 @@ use super::{make_utc_ms, time_clip, timestamp_to_local_components}; /// accepts: /// - ISO 8601: "YYYY", "YYYY-MM", "YYYY-MM-DD", with optional /// "THH:MM[:SS[.sss]]" and an optional "Z" / "+HH:MM" / "-HH:MM" offset. -/// Date-only forms are UTC; date-time forms without an offset are also -/// treated as UTC (matching V8's ISO handling). +/// ECMA-262 §21.4.3.2 splits the default zone on whether a TIME is +/// present: a date-ONLY form with no offset is UTC, a date-TIME form +/// with no offset is LOCAL wall-clock time. An explicit designator wins +/// in both. (#9449: this comment previously claimed date-time forms +/// were "also treated as UTC (matching V8's ISO handling)" and the code +/// did that — wrong against both node and the spec, and the reason the +/// behaviour looked deliberate.) /// - "YYYY-MM-DD HH:MM:SS" (space separator, MySQL form). /// - RFC-1123 / IETF: "Thu, 01 Jan 1970 00:00:00 GMT", /// "01 Jan 1970 00:00:00 GMT" (with optional weekday and optional @@ -80,6 +85,18 @@ fn parse_tz_offset(rest: &str) -> Option { Some(sign * (h * 60 + m)) } +/// Reinterpret an instant that was composed with `make_utc_ms` from +/// wall-clock components as LOCAL time: subtract the host's UTC offset in +/// effect at that instant. Shared by every grammar here that yields +/// wall-clock components with no zone designator — the ISO date-TIME form +/// (#9449), the RFC / month-name form and the numeric slash form (#9414). +/// Mirrors the conversion in `js_date_new_local_components`. +fn local_wall_clock_to_utc(base: f64) -> f64 { + let secs = (base as i64).div_euclid(1000); + let (_, _, _, _, _, _, tz_offset) = timestamp_to_local_components(secs); + base - (tz_offset * 1000) as f64 +} + /// ISO 8601 / MySQL branch. Returns `Some(ms)` on success. fn parse_iso8601(s: &str) -> Option { let b = s.as_bytes(); @@ -153,6 +170,9 @@ fn parse_iso8601(s: &str) -> Option { // Time part (after 'T' or ' '). let mut tz_minutes_east: Option = None; // None => "no offset present" + // #9449: the presence of a time component — not the presence of a zone — + // is what decides the default interpretation below. + let mut has_time = false; if idx < s.len() { let sep = b[idx]; if sep != b'T' && sep != b' ' { @@ -173,12 +193,33 @@ fn parse_iso8601(s: &str) -> Option { Some(p) => (&time_str[..p], &time_str[p..]), None => (time_str, ""), }; + // #9449: node also accepts the designator as a trailing, whitespace- + // separated WORD in the space-separated spelling — + // `new Date("2026-09-01 10:30 GMT")` is 10:30 UTC, and so are the + // `UTC` / `UT` / `Z` spellings in either case. The scan above only + // finds `Z`, `+` and `-`, so the word used to be ignored outright; + // that was invisible while every offsetless form was read as UTC and + // becomes a wrong instant the moment they are read as local. A + // parenthesised trailing comment is NOT a designator and stays local. + let (clock, utc_word) = match clock.rsplit_once(char::is_whitespace) { + Some((head, tail)) + if !head.trim().is_empty() + && matches!( + tail.to_ascii_lowercase().as_str(), + "gmt" | "utc" | "ut" | "z" + ) => + { + (head.trim_end(), true) + } + _ => (clock, false), + }; let cb = clock.as_bytes(); if clock.len() < 5 || cb[2] != b':' { return None; } hour = clock[0..2].parse().ok()?; minute = clock[3..5].parse().ok()?; + has_time = true; if clock.len() >= 8 && cb[5] == b':' { second = clock[6..8].parse().ok()?; if clock.len() > 9 && cb[8] == b'.' { @@ -195,15 +236,20 @@ fn parse_iso8601(s: &str) -> Option { Some(v) => tz_minutes_east = Some(v), None => return None, } + } else if utc_word { + tz_minutes_east = Some(0); } } let base = make_utc_ms(year, month1 as i64 - 1, day, hour, minute, second, millis); - // Apply zone offset: a clock with offset +HH:MM is `offset` ahead of UTC, - // so UTC = clock - offset. - let adjusted = if let Some(off) = tz_minutes_east { - base - (off * 60_000) as f64 - } else { - base + let adjusted = match tz_minutes_east { + // An explicit `Z` / `±HH:MM` always wins: a clock with offset +HH:MM + // is `offset` ahead of UTC, so UTC = clock - offset. + Some(off) => base - (off * 60_000) as f64, + // #9449: no designator and a TIME present => local wall clock. + None if has_time => local_wall_clock_to_utc(base), + // No designator and no time (a date-only form) => UTC, per the spec's + // deliberate asymmetry. This half was already right; it must stay. + None => base, }; let _ = idx; Some(adjusted) @@ -341,13 +387,8 @@ fn parse_rfc_or_named(s: &str) -> Option { let base = make_utc_ms(y, m as i64 - 1, d, hour, minute, second, 0); match tz_minutes_east { Some(off) => Some(base - (off * 60_000) as f64), - None => { - // Local-time interpretation: subtract local tz offset at that - // instant (mirrors js_date_new_local_components). - let secs = (base as i64).div_euclid(1000); - let (_, _, _, _, _, _, tz_offset) = timestamp_to_local_components(secs); - Some(base - (tz_offset * 1000) as f64) - } + // Local-time interpretation (see `local_wall_clock_to_utc`). + None => Some(local_wall_clock_to_utc(base)), } } @@ -555,11 +596,7 @@ fn parse_slash_date(s: &str) -> Option { let base = make_utc_ms(year, month - 1, day, hour, minute, second, millis); match tz_minutes_east { Some(off) => Some(base - (off * 60_000) as f64), - None => { - // No designator: the components are LOCAL wall-clock time. - let secs = (base as i64).div_euclid(1000); - let (_, _, _, _, _, _, tz_offset) = timestamp_to_local_components(secs); - Some(base - (tz_offset * 1000) as f64) - } + // No designator: the components are LOCAL wall-clock time. + None => Some(local_wall_clock_to_utc(base)), } } diff --git a/test-files/test_gap_date_iso_datetime_local_9449.ts b/test-files/test_gap_date_iso_datetime_local_9449.ts new file mode 100644 index 0000000000..eefed56a89 --- /dev/null +++ b/test-files/test_gap_date_iso_datetime_local_9449.ts @@ -0,0 +1,154 @@ +// #9449: `new Date("2026-09-01T10:30")` was parsed as UTC. ECMA-262 §21.4.3.2 +// splits on whether a TIME is present: a date-ONLY form with no offset is UTC, +// a date-TIME form with no offset is LOCAL. Perry applied UTC to both, so every +// `` value, log line and hand-written timestamp +// was silently off by the host's UTC offset. +// +// Fixture discipline (carried over from the #9414 slash-date fixture): a raw +// epoch — or a delta against any fixed reference — couples the expected bytes +// to the host zone's DST regime at each row's date. So: +// * rows that carry a zone designator (`Z`, `+HH:MM`, `-HH:MM`) and the +// date-ONLY rows are absolute instants: assert `toISOString()`. +// * offsetless date-TIME rows are wall-clock: assert the LOCAL getters +// (which read back the very digits that were written, in any zone) and +// compare the instant against a locally-constructed reference `Date` by +// equality. +// Every expectation is measured against `node --experimental-strip-types`. + +// ---- absolute rows: a zone designator, or no time at all ------------------- +function iso(input: string): void { + const d = new Date(input); + console.log(input, "=>", Number.isNaN(d.getTime()) ? "Invalid Date" : d.toISOString()); +} + +// Date-only forms have no time component, so they stay UTC. THIS IS THE HALF +// THAT MUST NOT MOVE. +iso("2026-09-01"); +iso("2026-01-15"); +iso("2026-09"); +iso("2026"); +iso("+002026-09-01"); +iso("-000001-07-01"); + +// An explicit designator wins in the date-time form, exactly as before. +iso("2026-09-01T10:30Z"); +iso("2026-09-01T10:30:45Z"); +iso("2026-09-01T10:30:45.123Z"); +iso("2026-09-01 10:30Z"); +iso("2026-09-01T10:30+05:00"); +iso("2026-09-01T10:30-08:00"); +iso("2026-09-01 10:30+05:00"); +iso("2026-09-01 10:30-08:00"); +iso("2026-01-15T10:30Z"); +iso("2026-09-01T00:00Z"); + +// A trailing, whitespace-separated `GMT` / `UTC` / `UT` / `Z` word is node's +// designator in the space-separated spelling. These rows are the ones this +// change could most easily have BROKEN: they were UTC before it only because +// everything was UTC, so "no designator => local" has to recognise the word +// rather than ignore it. +iso("2026-09-01 10:30 GMT"); +iso("2026-09-01 10:30 UTC"); +iso("2026-09-01 10:30 UT"); +iso("2026-09-01 10:30 Z"); +iso("2026-09-01 10:30 gmt"); +iso("2026-09-01 10:30 z"); +iso("2026-09-01 10:30:45 GMT"); +iso("2026-09-01 10:30:45.123 UTC"); +iso("2026-09-01 10:30 GMT+0500"); +iso("2026-09-01 10:30 GMT+05:00"); +iso("2026-09-01 10:30 +0500"); +iso("2026-09-01 10:30:45 +05:00"); + +// ---- wall-clock rows: a time, no designator => LOCAL ----------------------- +function local(input: string): void { + const d = new Date(input); + if (Number.isNaN(d.getTime())) { + console.log(input, "=> Invalid Date"); + return; + } + console.log( + input, + "=>", + d.getFullYear(), + d.getMonth(), + d.getDate(), + d.getHours(), + d.getMinutes(), + d.getSeconds(), + d.getMilliseconds(), + ); +} + +local("2026-09-01T10:30"); +local("2026-09-01T10:30:45"); +local("2026-09-01T10:30:45.123"); +local("2026-09-01T10:30:45.1"); +local("2026-09-01T10:30:45.123456"); +local("2026-09-01T00:00"); +local("2026-09-01T24:00"); +local("2026-12-31T23:59:59.999"); +// The space separator (the MySQL spelling) behaves exactly like `T` in node. +local("2026-09-01 10:30"); +local("2026-09-01 10:30:45"); +local("2026-09-01 10:30:45.123"); +local("2026-09-01 00:00"); +// A January row and a July row: if the conversion used a FIXED offset rather +// than the offset in effect at that instant, one of these two would be wrong +// in any zone that observes DST. +local("2026-01-15T10:30"); +local("2026-07-15T10:30"); +// The expanded-year spelling takes the same branch. +local("+002026-09-01T10:30"); +// A trailing parenthesised comment is not a designator, so these stay local. +local("2026-09-01 10:30 (comment)"); + +// ---- the instant itself, compared to a locally-constructed reference ------- +// `new Date(y, m, d, h, ...)` is defined on LOCAL components, so these +// equalities hold in every host zone if and only if the parse is local too. +function sameInstant(input: string, ref: Date): void { + console.log(input, "==", new Date(input).getTime() === ref.getTime()); +} + +sameInstant("2026-09-01T10:30", new Date(2026, 8, 1, 10, 30, 0, 0)); +sameInstant("2026-09-01T10:30:45", new Date(2026, 8, 1, 10, 30, 45, 0)); +sameInstant("2026-09-01T10:30:45.123", new Date(2026, 8, 1, 10, 30, 45, 123)); +sameInstant("2026-09-01 10:30", new Date(2026, 8, 1, 10, 30, 0, 0)); +sameInstant("2026-09-01 10:30:45.123", new Date(2026, 8, 1, 10, 30, 45, 123)); +sameInstant("2026-09-01T00:00", new Date(2026, 8, 1, 0, 0, 0, 0)); +sameInstant("2026-09-01T24:00", new Date(2026, 8, 2, 0, 0, 0, 0)); +sameInstant("2026-01-15T10:30", new Date(2026, 0, 15, 10, 30, 0, 0)); +sameInstant("2026-07-15T10:30", new Date(2026, 6, 15, 10, 30, 0, 0)); +// And the date-only form is NOT the local midnight of that day (except in a +// UTC host); it is the UTC midnight, which `Date.UTC` spells. +console.log( + "date-only is UTC:", + new Date("2026-09-01").getTime() === Date.UTC(2026, 8, 1, 0, 0, 0, 0), +); + +// ---- Date.parse is the same grammar --------------------------------------- +for ( + const s of [ + "2026-09-01T10:30", + "2026-09-01 10:30:45.123", + "2026-09-01", + "2026-09-01T10:30Z", + "2026-09-01T10:30-08:00", + ] +) { + console.log("parse==new", s, Date.parse(s) === new Date(s).getTime()); +} +console.log(Number.isNaN(Date.parse("2026-09-01T10"))); +console.log(Number.isNaN(Date.parse("2026-09-01Tnope"))); + +// ---- controls: the neighbouring grammars must not move --------------------- +// The slash grammar (#9414) is already local and is guarded by its own +// fixture; these rows only pin that this change did not reach it. +console.log(new Date("2026/09/01").getTime() === new Date(2026, 8, 1, 0, 0, 0, 0).getTime()); +console.log(new Date("2026/09/01 10:30").getTime() === new Date(2026, 8, 1, 10, 30).getTime()); +console.log(new Date("2026/09/01 10:30 UTC").toISOString()); +// RFC-1123 / month-name forms. +console.log(new Date("Thu, 01 Jan 1970 00:00:00 GMT").toISOString()); +console.log(new Date("01 Jan 1970 00:00:00 GMT").toISOString()); +console.log(new Date("March 7, 2020").getTime() === new Date(2020, 2, 7, 0, 0, 0, 0).getTime()); +console.log(String(new Date("not a date"))); From f6407e9514ab0c41bd6d20dde2c748be24286340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 2 Sep 2026 10:49:23 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(intl):=20Infinity.toLocaleString()=20is?= =?UTF-8?q?=20"=E2=88=9E",=20and=20-0=20keeps=20its=20sign=20(#9452)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Infinity.toLocaleString()` returned `"Infinity"`; node returns `"∞"`. The argument-bearing forms have been correct since #9448, which routes them through `Intl.NumberFormat` — so `Infinity.toLocaleString("en-US")` gave `"∞"` while `Infinity.toLocaleString()` gave `"Infinity"`: the same operation disagreeing with itself depending on whether an argument was passed. The no-argument call is folded by codegen to the inline `js_number_to_locale_string`, which returned Rust's `Display` spelling. `Number.prototype.toLocaleString` is defined as "format with a default `Intl.NumberFormat`", and that formatter renders the infinities with U+221E. The same fast path also dropped the sign of negative zero — `(-0).toLocaleString()` was `"0"` where node gives `"-0"` — because `-0.0 < 0.0` is false. `intl::number_format` already reads the sign off the bit pattern; the fast path now applies the identical test. `NaN` is `"NaN"` in both and does not move. `Object.prototype.toLocaleString.call(Infinity)` is a different operation (`Invoke(O, "toString")`), stays `"Infinity"`, and has a control row. The doc comment, which pinned the old spelling as intended behaviour, is corrected. Validation: the no-argument rows of test-files/test_gap_tolocalestring_locale_options_9414.ts are extended with Infinity, -Infinity, NaN and -0 — as literals, as a binding, and as the results of `0 * -1`, `-1 / Infinity`, `1 / 0`, `-1 / 0` and `0 / 0`, so a constant-folded receiver and a computed one are both covered — alongside the argument-bearing spelling of each, the standalone Intl.NumberFormat rows, the array-element spelling and the Object.prototype control. 81 lines byte-identical to node 26.5.1; 10 of them diverged on a compiler built from unfixed origin/main. --- changelog.d/9452-tolocalestring-infinity.md | 36 +++++++++++++++++ crates/perry-runtime/src/date.rs | 19 +++++++-- ..._gap_tolocalestring_locale_options_9414.ts | 40 +++++++++++++++++++ 3 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 changelog.d/9452-tolocalestring-infinity.md diff --git a/changelog.d/9452-tolocalestring-infinity.md b/changelog.d/9452-tolocalestring-infinity.md new file mode 100644 index 0000000000..59c7e1a31d --- /dev/null +++ b/changelog.d/9452-tolocalestring-infinity.md @@ -0,0 +1,36 @@ +### Fixed + +- **`Infinity.toLocaleString()` returned `"Infinity"`; node returns `"∞"`.** + The argument-bearing forms have been correct since #9448, which routes them + through `Intl.NumberFormat` — so `Infinity.toLocaleString("en-US")` gave + `"∞"` while `Infinity.toLocaleString()` gave `"Infinity"`, the same + operation disagreeing with itself depending on whether an argument was + passed. The no-argument call is folded by codegen to the inline + `js_number_to_locale_string`, which returned Rust's `Display` spelling. + + `Number.prototype.toLocaleString` is defined as "format with a default + `Intl.NumberFormat`", and that formatter renders the infinities with + U+221E. The same fast path also dropped the sign of negative zero — + `(-0).toLocaleString()` was `"0"` where node gives `"-0"` — because + `-0.0 < 0.0` is false; `intl::number_format` already reads the sign off the + bit pattern, and the fast path now applies the identical test. `NaN` is + `"NaN"` in both and does not move. + + `Object.prototype.toLocaleString.call(Infinity)` is a different operation + (ECMA-262 defines it as `Invoke(O, "toString")`), stays `"Infinity"`, and + has a control row. + + Affected files: + + - `crates/perry-runtime/src/date.rs` — `js_number_to_locale_string` spells + ±Infinity with U+221E and keeps the sign of `-0`; the doc comment, which + pinned the old spelling as intended behaviour, is corrected. + + Validation: the no-argument rows of + `test-files/test_gap_tolocalestring_locale_options_9414.ts` are extended + with `Infinity`, `-Infinity`, `NaN` and `-0` — as literals, as a binding, + and as the results of `0 * -1`, `-1 / Infinity`, `1 / 0`, `-1 / 0` and + `0 / 0`, so a constant-folded receiver and a computed one are both covered + — alongside the argument-bearing spelling of each, the standalone + `Intl.NumberFormat` rows, the array-element spelling and the + `Object.prototype` control. Byte-compared against node 26.5.1. diff --git a/crates/perry-runtime/src/date.rs b/crates/perry-runtime/src/date.rs index ad39d13a68..8e61c04a08 100644 --- a/crates/perry-runtime/src/date.rs +++ b/crates/perry-runtime/src/date.rs @@ -1593,17 +1593,28 @@ pub extern "C" fn js_date_to_locale_time_string(timestamp: f64) -> *mut crate::S /// support), so the LLVM codegen routes statically-Number receivers /// here and Date receivers to `js_date_to_locale_string` below. Decimal /// part follows JS spec: trailing zeros after the decimal stripped, -/// leading sign preserved, NaN/Infinity passed through as the literal -/// strings "NaN" / "Infinity" / "-Infinity". +/// leading sign preserved. +/// +/// The non-finite and signed-zero spellings are ECMA-402's, not Rust's +/// (#9452): the operation is defined as "format with a default +/// `Intl.NumberFormat`", whose number pattern renders the infinities with +/// U+221E ("∞" / "-∞") and keeps the sign of negative zero ("-0"). `NaN` +/// stays the literal "NaN" in both. Before #9452 this returned Rust's +/// `Display` spelling "Infinity"/"-Infinity" and dropped the sign of `-0`, +/// so `x.toLocaleString()` disagreed with `x.toLocaleString("en-US")` — +/// which since #9448 goes through the real formatter (see +/// `intl::number_format`, whose sign test this now mirrors). #[no_mangle] pub extern "C" fn js_number_to_locale_string(n: f64) -> *mut crate::StringHeader { if n.is_nan() { return alloc_runtime_string("NaN"); } if n.is_infinite() { - return alloc_runtime_string(if n > 0.0 { "Infinity" } else { "-Infinity" }); + return alloc_runtime_string(if n > 0.0 { "\u{221e}" } else { "-\u{221e}" }); } - let negative = n < 0.0; + // `-0.0 < 0.0` is false, so the sign of a negative zero has to be read + // off the bit pattern — the same test `intl::number_format` applies. + let negative = n < 0.0 || (n == 0.0 && n.is_sign_negative()); let abs = n.abs(); // Split into integer and decimal parts. JS's default // `Number.prototype.toLocaleString()` shows up to 3 fraction digits diff --git a/test-files/test_gap_tolocalestring_locale_options_9414.ts b/test-files/test_gap_tolocalestring_locale_options_9414.ts index d5a7dadf7a..b95f911a7a 100644 --- a/test-files/test_gap_tolocalestring_locale_options_9414.ts +++ b/test-files/test_gap_tolocalestring_locale_options_9414.ts @@ -56,6 +56,46 @@ console.log((0).toLocaleString()); console.log(NaN.toLocaleString()); console.log((2 ** 60).toLocaleString()); +// ---- #9452: the non-finite and signed-zero spellings of the no-argument +// fast path. `Number.prototype.toLocaleString()` is defined as "format with a +// default Intl.NumberFormat", and ECMA-402's number formatter spells the +// infinities with U+221E and keeps the sign of negative zero. Perry's inline +// `js_number_to_locale_string` returned Rust's `Display` spelling instead, so +// the no-argument call disagreed with the very same call carrying a locale. +// NaN is `"NaN"` in both and must not move. +console.log(Infinity.toLocaleString()); +console.log((-Infinity).toLocaleString()); +console.log(NaN.toLocaleString()); +console.log((-0).toLocaleString()); +// A negative zero the constant folder cannot spell away, and one that arrives +// from arithmetic rather than a literal. +const negZero = -0; +console.log(negZero.toLocaleString()); +console.log((0 * -1).toLocaleString()); +console.log((-1 / Infinity).toLocaleString()); +// Same values through a division that overflows, so the receiver is a computed +// f64 rather than a folded literal. +console.log((1 / 0).toLocaleString()); +console.log((-1 / 0).toLocaleString()); +console.log((0 / 0).toLocaleString()); +// The argument-bearing spelling of each (already correct since #9448) must +// agree with the no-argument one — that agreement is the point of the fix. +console.log(Infinity.toLocaleString("en-US"), (-Infinity).toLocaleString("en-US")); +console.log(NaN.toLocaleString("en-US"), (-0).toLocaleString("en-US")); +console.log((-0).toLocaleString(undefined, undefined)); +// The delegation target, standalone. +console.log(new Intl.NumberFormat().format(Infinity)); +console.log(new Intl.NumberFormat().format(-Infinity)); +console.log(new Intl.NumberFormat().format(-0)); +console.log(new Intl.NumberFormat().format(NaN)); +// The array and Object.prototype spellings inherit the element formatter. +console.log([Infinity, -Infinity, NaN, -0].toLocaleString()); +// `Object.prototype.toLocaleString.call(x)` is NOT the number formatter — it +// is defined as `Invoke(O, "toString")`, so it keeps the `"Infinity"` spelling +// and must NOT move with this fix. +console.log(Object.prototype.toLocaleString.call(Infinity)); +console.log(Object.prototype.toLocaleString.call(-0)); + // ---- the delegation target, standalone ------------------------------------- console.log(new Intl.NumberFormat("de-DE").format(1234.5)); console.log(new Intl.NumberFormat("en-US", { style: "percent" }).format(0.5));