Repository navigation
fix(date): an offsetless date-TIME is local, date-only stays UTC (#9449); ∞ and -0 on the no-arg toLocaleString fast path (#9452) #9508
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| `<input type="datetime-local">` 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<i64> { | |
| 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 | ||
|
Comment on lines
+96
to
+97
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
printf '%s\n' '--- repository guidance ---'
find /tmp/coderabbit-repo-knowledge/perryts-perry-d4a878bc -maxdepth 2 -type f -name '*.md' -print
printf '%s\n' '--- changed hunk ---'
git diff -- crates/perry-runtime/src/date/parse.rs
printf '%s\n' '--- parse.rs outline ---'
ast-grep outline crates/perry-runtime/src/date/parse.rs
printf '%s\n' '--- relevant source ---'
sed -n '1,180p' crates/perry-runtime/src/date/parse.rsRepository: PerryTS/perry Length of output: 10217 🏁 Script executed: #!/bin/bash
printf '%s\n' '--- runtime convention ---'
cat /tmp/coderabbit-repo-knowledge/perryts-perry-d4a878bc/conventions/crates-perry-runtime.md
printf '%s\n' '--- ISO conversion branch ---'
sed -n '175,270p' crates/perry-runtime/src/date/parse.rs
printf '%s\n' '--- bound date helpers ---'
rg -n -A35 -B8 'fn (make_utc_ms|timestamp_to_local_components|js_date_new_local_components)|local_wall_clock_to_utc|parse_iso8601' crates/perry-runtime/src/date
printf '%s\n' '--- relevant tests ---'
rg -n -A12 -B8 'parse_date_string|2026-03-29|2026-10-25|Europe/Berlin|DST|time.?zone' crates/perry-runtime/src/date crates/perry-runtime/tests 2>/dev/nullRepository: PerryTS/perry Length of output: 25811 🏁 Script executed: #!/bin/bash
printf '%s\n' '--- date module files ---'
fd -t f 'date(\.rs)?$' crates/perry-runtime
printf '%s\n' '--- local-time implementations ---'
rg -n -A45 -B10 'timestamp_to_local_components|js_date_new_local_components|Local::|from_local|local.*components|offset.*timestamp' crates/perry-runtime --glob '*.rs'
printf '%s\n' '--- date parser tests ---'
rg -n -A10 -B8 '`#9449`|`#9449`|parse.*date|Date\.parse|2026-0[39]-29|2026-10-25' crates/perry-runtime --glob '*.rs' --glob '*.toml'Repository: PerryTS/perry Length of output: 50370 🏁 Script executed: #!/bin/bash
TZ=Europe/Berlin node - <<'JS'
const inputs = [
"2026-03-29T02:30",
"2026-10-25T02:30",
];
for (const input of inputs) {
const base = Date.UTC(2026, input[6] === "0" ? 2 : 9, input[9] === "2" ? 29 : 25, 2, 30);
const parsed = Date.parse(input);
const baseLocal = new Date(base);
const parsedDate = new Date(parsed);
console.log(JSON.stringify({
input,
baseUtc: new Date(base).toISOString(),
baseLocal: baseLocal.toString(),
baseOffsetMinutes: -baseLocal.getTimezoneOffset(),
parsedUtc: parsedDate.toISOString(),
parsedLocal: parsedDate.toString(),
parsedOffsetMinutes: -parsedDate.getTimezoneOffset(),
}));
}
JSRepository: PerryTS/perry Length of output: 783 🏁 Script executed: #!/bin/bash
TZ=Europe/Berlin node - <<'JS'
for (const [label, base] of [
["gap", Date.UTC(2026, 2, 29, 2, 30)],
["overlap", Date.UTC(2026, 9, 25, 2, 30)],
]) {
const atBase = new Date(base);
const input = label === "gap" ? "2026-03-29T02:30" : "2026-10-25T02:30";
const parsed = new Date(Date.parse(input));
console.log(JSON.stringify({
label,
baseUtc: atBase.toISOString(),
baseLocal: atBase.toString(),
baseOffsetMinutes: -atBase.getTimezoneOffset(),
parsedUtc: parsed.toISOString(),
parsedLocal: parsed.toString(),
parsedOffsetMinutes: -parsed.getTimezoneOffset(),
}));
}
JSRepository: PerryTS/perry Length of output: 760 Resolve the offset from local civil time. In 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| } | ||
|
|
||
| /// ISO 8601 / MySQL branch. Returns `Some(ms)` on success. | ||
| fn parse_iso8601(s: &str) -> Option<f64> { | ||
| let b = s.as_bytes(); | ||
|
|
@@ -153,6 +170,9 @@ fn parse_iso8601(s: &str) -> Option<f64> { | |
|
|
||
| // Time part (after 'T' or ' '). | ||
| let mut tz_minutes_east: Option<i64> = 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<f64> { | |
| 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<f64> { | |
| 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<f64> { | |
| 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<f64> { | |
| 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)), | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 50369
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 50369
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 50372
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 8830
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 14559
Run both date regressions under a non-UTC timezone.
The local assertions can pass with the UTC parser when the process uses UTC. Run the Rust test in an isolated DST-observing, non-UTC child process with
RUST_TEST_THREADS=1.For the parity fixture, apply the same non-UTC
TZto both Node and Perry, or fail when the effective offset is zero.run_parity_tests.shcurrently appliesparity-envonly to Perry, so adding that directive alone does not configure the Node oracle.📍 Affects 2 files
crates/perry-runtime/src/date.rs#L1911-L1911(this comment)test-files/test_gap_date_iso_datetime_local_9449.ts#L16-L16🤖 Prompt for AI Agents
Source: Coding guidelines