Skip to content

fix(intl): resolve and apply the host time zone in DateTimeFormat - #6452

Merged
proggeramlug merged 4 commits into
PerryTS:mainfrom
proggeramlug:fix/intl-host-timezone
Jul 16, 2026
Merged

fix(intl): resolve and apply the host time zone in DateTimeFormat#6452
proggeramlug merged 4 commits into
PerryTS:mainfrom
proggeramlug:fix/intl-host-timezone

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Intl.DateTimeFormat().resolvedOptions().timeZone returned "UTC" regardless of the host machine, and all DateTimeFormat / Date.prototype.toLocaleString formatting was done in UTC. On any non-UTC host every result was off by the host offset, and Node's host zone (e.g. Europe/Berlin) never appeared.

This surfaced in a bundled Next.js app: next-intl reads resolvedOptions().timeZone, so the server-rendered hydration payload carried timeZone:"UTC" where Node produced timeZone:"Europe/Berlin".

How

  • date.rshost_time_zone_name() resolves the IANA id from TZ (honored like libc/Node) then the /etc/localtime symlink, falling back to "UTC"; zone_offset_seconds() returns a DST-aware offset: 0 for UTC/GMT, a parsed fixed offset for ±HH:mm, and the process zone via libc::localtime for the host zone.
  • intl.rs / date_collator.rs — the DateTimeFormat constructor defaults timeZone to the host zone (was UTC), resolvedOptions() reports it, and the two instance format paths shift the epoch into the configured zone before extracting Y/M/D H:M:S.
  • date_proto_thunks.rsDate.prototype.toLocaleString renders the instant in the resolved zone (the timeZone option, else host) instead of UTC.

Testing

test_gap_intl_host_timezone.ts (byte-compared to Node on the same host): resolved-zone-is-a-string, resolved matches the global default, UTC formatting, host-zone formatting (host offset applied), fixed +05:00 / -03:30 offsets, and toLocaleString({timeZone:'UTC'}). Under TZ=UTC (typical CI) the host zone is UTC so this is a no-op; the full perry-runtime suite passes on both a UTC and a Europe/Berlin host.

Scope / follow-up

A named zone that is not the host zone (e.g. America/New_York on a Europe/Berlin host) still needs the OS tz database to resolve, and mutating libc's global TZ is unsafe in a threaded runtime, so it currently falls back to UTC. The exact cases — default/host zone, explicit UTC, fixed numeric offsets — cover the common usage; a follow-up can wire the already-vendored jiff-tzdb (currently gated on Temporal) for arbitrary named zones. The German date pattern (15.01. vs 1/15/) is a separate locale-localization gap, not timezone.

https://claude.ai/code/session_01NjymZygYh31wM9h3wLnUqv

Summary by CodeRabbit

  • New Features

    • Default Intl.DateTimeFormat now resolves to the host system time zone (not hardcoded UTC).
    • Added time-zone resolution helpers, including canonicalization and numeric offset handling.
    • resolvedOptions().timeZone reflects the effective host zone.
  • Bug Fixes

    • Corrected Intl.DateTimeFormat/Date.prototype.toLocaleString formatting so non-Temporal inputs apply the selected time zone correctly.
  • Tests

    • Added host time-zone resolution tests covering default formatting, UTC, and numeric offsets.

`Intl.DateTimeFormat().resolvedOptions().timeZone` returned `"UTC"` regardless of
the host, and formatting always used UTC — so on a non-UTC machine every
`DateTimeFormat`/`Date.toLocaleString` result was off by the host offset, and
Node's `Europe/Berlin` (etc.) never appeared. (Surfaced by a Next.js app: its
next-intl config reads `resolvedOptions().timeZone`, so the hydrated payload
differed from Node.)

- `date.rs`: `host_time_zone_name()` resolves the IANA id from `TZ` (honored like
  libc/Node), else the `/etc/localtime` symlink; `zone_offset_seconds()` returns
  a DST-aware offset — 0 for UTC/GMT, a parsed fixed offset for `±HH:mm`, and the
  process zone via libc for the host zone.
- The `DateTimeFormat` constructor defaults `timeZone` to the host zone (was UTC),
  `resolvedOptions()` reports it, and the two instance format paths shift the
  epoch into the configured zone before extracting components.
- `Date.prototype.toLocaleString` renders the instant in the resolved zone (the
  `timeZone` option, else host) instead of UTC.

Under `TZ=UTC` (typical CI) the host zone is UTC, so this is a no-op there; the
full perry-runtime suite passes on both a UTC and a Europe/Berlin host.

Scope: a named zone that is NOT the host zone (e.g. `America/New_York` on a
Europe/Berlin host) still needs the OS tz database to resolve — mutating libc's
global `TZ` is unsafe in a threaded runtime — so it falls back to UTC for now.
The exact cases (default/host zone, UTC, fixed numeric offsets) cover the common
usage; a follow-up can wire jiff-tzdb for arbitrary named zones.
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Host Time-Zone Formatting

Layer / File(s) Summary
Time-zone discovery and resolution
crates/perry-runtime/src/date.rs, crates/perry-runtime/src/intl.rs, crates/perry-runtime/src/intl/time_zone.rs
Host IANA zones, fixed numeric offsets, and effective locale-formatting time zones are resolved and canonicalized.
DateTimeFormat zone-local formatting
crates/perry-runtime/src/intl/date_collator.rs, crates/perry-runtime/src/intl/date_names.rs, test-files/test_gap_intl_host_timezone.ts
DateTimeFormat applies configured zone offsets to plain Date inputs, preserves Temporal handling, reports the host zone by default, centralizes date-name tables, and adds host-time-zone coverage.
Date locale-string time-zone application
crates/perry-runtime/src/object/date_proto_thunks.rs
Date.prototype.toLocaleString shifts epoch milliseconds using the resolved zone offset before locale formatting.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Date
  participant IntlDateTimeFormat
  participant DateRuntime
  participant LocaleFormatter
  Date->>IntlDateTimeFormat: format instant
  IntlDateTimeFormat->>DateRuntime: resolve timeZone offset
  DateRuntime-->>IntlDateTimeFormat: offset seconds
  IntlDateTimeFormat->>LocaleFormatter: format zone-local fields
  LocaleFormatter-->>Date: formatted date-time
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: resolving and applying the host time zone in DateTimeFormat.
Description check ✅ Passed The description covers the summary, implementation details, testing, and remaining limitations; only optional template sections are missing.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/intl.rs`:
- Around line 318-337: In crates/perry-runtime/src/intl.rs:318-337, replace
resolved_date_time_zone with a unified resolver that canonicalizes valid zones,
throws a RangeError for explicitly provided invalid timeZone values, and falls
back to "UTC" when the host default zone is unrecognized. In
crates/perry-runtime/src/intl.rs:1156-1176, remove make_instance’s duplicated
validation and call the unified resolved_date_time_zone helper directly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: af972856-17da-488f-b1cd-f48b172b8dd8

📥 Commits

Reviewing files that changed from the base of the PR and between 2988892 and c6c0a2b.

📒 Files selected for processing (5)
  • crates/perry-runtime/src/date.rs
  • crates/perry-runtime/src/intl.rs
  • crates/perry-runtime/src/intl/date_collator.rs
  • crates/perry-runtime/src/object/date_proto_thunks.rs
  • test-files/test_gap_intl_host_timezone.ts

Comment thread crates/perry-runtime/src/intl.rs Outdated
Ralph Küpper and others added 2 commits July 16, 2026 05:47
… on default) + split files

Two correctness bugs this PR introduced, pulling opposite ways (ECMA-402):
  * `resolved_date_time_zone` swallowed an invalid explicit `timeZone` option,
    returning the raw string — `toLocaleString(x, {timeZone: '+9'})` silently
    formatted instead of throwing RangeError.
  * `make_instance` threw RangeError when the DEFAULT host zone was
    unrecognized. ECMA-402 DefaultTimeZone must ALWAYS yield a valid zone,
    falling back to UTC; the constructor must not throw when no option is given.

Consolidated both into a single `resolved_date_time_zone` that throws only on an
explicit invalid zone and falls back to UTC for an unrecognized host default;
`make_instance` now calls it. Verified against node: a malformed explicit zone
(bad offset, empty segment) throws RangeError on both the constructor and
toLocaleString paths; valid named/offset zones and the host default are
unchanged. (A structurally-valid-but-nonexistent named zone like 'Not/AZone'
still passes — Perry has no tz database — a pre-existing limitation unrelated to
this fix.)

The consolidation plus a file split keeps both files under the 2000-line cap:
time-zone helpers → `intl/time_zone.rs`, month/weekday name tables →
`intl/date_names.rs`. All 13 intl/datetime gap tests pass.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Fixed both — the lint (file-size) and CodeRabbit's Major time-zone finding (3e597aa3f). The two correctness bugs are real and this PR introduced them.

Consolidated the zone resolution. resolved_date_time_zone now throws RangeError only on an explicit invalid timeZone, and falls back to "UTC" for an unrecognized default host zone (ECMA-402 DefaultTimeZone must always yield a valid zone). make_instance calls the same helper instead of duplicating the logic. Verified against node — a malformed explicit zone throws on both the constructor and toLocaleString paths, valid zones and the host default are unchanged:

input node perry (after)
{timeZone: "+99:99"} RangeError RangeError ✅
{timeZone: "A//B"} RangeError RangeError ✅
toLocaleString(x, {timeZone: "+9"}) RangeError RangeError ✅
{timeZone: "America/New_York"} ok ok ✅
no option (host default) never throws never throws ✅

One honest limitation: a structurally-valid-but-nonexistent named zone like "Not/AZone" still passes (node rejects it). Perry has no tz database, so it can only reject malformed identifiers, not fake-but-well-formed ones — that's pre-existing and orthogonal to this fix.

File-size. The consolidation removed the duplication, and a split keeps both files under the 2000-line cap: time-zone helpers → intl/time_zone.rs, month/weekday name tables → intl/date_names.rs. All 13 intl/datetime gap tests pass.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
crates/perry-runtime/src/intl/time_zone.rs (2)

17-29: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid unnecessary String clone.

explicit.clone() clones the inner String (if present) just to check explicit.is_some() later. You can avoid this heap allocation by extracting the boolean check first and then consuming the Option directly.

♻️ Proposed refactor
-    let explicit = get_option_string(options, "timeZone");
-    let tz = explicit
-        .clone()
-        .unwrap_or_else(|| crate::date::host_time_zone_name().to_string());
+    let explicit = get_option_string(options, "timeZone");
+    let is_explicit = explicit.is_some();
+    let tz = explicit
+        .unwrap_or_else(|| crate::date::host_time_zone_name().to_string());
     let canonical = if matches!(tz.as_bytes().first(), Some(b'+') | Some(b'-')) {
         is_valid_offset_time_zone(&tz).then(|| canonicalize_offset_time_zone(&tz))
     } else {
         canonicalize_named_time_zone(&tz)
     };
     match canonical {
         Some(c) => c,
-        None if explicit.is_some() => {
+        None if is_explicit => {
             throw_range_error(&format!("Invalid time zone specified: {tz}"))
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/intl/time_zone.rs` around lines 17 - 29, Update the
time-zone handling around explicit and tz to record whether explicit is present
before consuming it, then consume explicit directly when selecting the fallback
host time zone instead of cloning the inner String. Preserve the existing
explicit.is_some() behavior in the canonical match error branch.

85-102: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid allocating a Vec for string splitting.

tz.split('/').collect() allocates a vector to hold the segments. Because you only need to enforce a minimum segment count and iterate over the characters, you can consume the Split iterator directly and track the segment count, bypassing the heap allocation.

♻️ Proposed refactor
-    let segments: Vec<&str> = tz.split('/').collect();
-    if segments.len() < 2 {
-        return None;
-    }
     let mut has_alpha = false;
-    for seg in &segments {
+    let mut segment_count = 0;
+    for seg in tz.split('/') {
+        segment_count += 1;
         if seg.is_empty() {
             return None;
         }
         for b in seg.bytes() {
             if b.is_ascii_alphabetic() {
                 has_alpha = true;
             } else if !(b.is_ascii_alphanumeric() || b == b'_' || b == b'+' || b == b'-') {
                 return None;
             }
         }
     }
-    has_alpha.then(|| tz.to_string())
+    if segment_count < 2 {
+        return None;
+    }
+    has_alpha.then(|| tz.to_string())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/intl/time_zone.rs` around lines 85 - 102, Update the
timezone validation logic around the split iterator to avoid collecting
`tz.split('/')` into a Vec. Consume the iterator directly, track the segment
count while validating each segment, and return None unless at least two
non-empty segments are processed; preserve the existing character validation and
has_alpha behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/perry-runtime/src/intl/time_zone.rs`:
- Around line 17-29: Update the time-zone handling around explicit and tz to
record whether explicit is present before consuming it, then consume explicit
directly when selecting the fallback host time zone instead of cloning the inner
String. Preserve the existing explicit.is_some() behavior in the canonical match
error branch.
- Around line 85-102: Update the timezone validation logic around the split
iterator to avoid collecting `tz.split('/')` into a Vec. Consume the iterator
directly, track the segment count while validating each segment, and return None
unless at least two non-empty segments are processed; preserve the existing
character validation and has_alpha behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f34163dd-24e8-4272-909f-767cf8030c66

📥 Commits

Reviewing files that changed from the base of the PR and between c6c0a2b and 3e597aa.

📒 Files selected for processing (4)
  • crates/perry-runtime/src/intl.rs
  • crates/perry-runtime/src/intl/date_collator.rs
  • crates/perry-runtime/src/intl/date_names.rs
  • crates/perry-runtime/src/intl/time_zone.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-runtime/src/intl/date_collator.rs

@proggeramlug
proggeramlug merged commit 949ac76 into PerryTS:main Jul 16, 2026
24 of 26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant