Skip to content

Render a locale string for every date the spec calls valid - #2981

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/tolocalestring-out-of-clr-range
Aug 11, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/tolocalestring-out-of-clr-range

Conversation

@lahma

@lahma lahma commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

toLocaleString/toLocaleDateString/toLocaleTimeString called ToDateTime() with no range check, so every date outside DateTime's years-1-to-9999 window threw ArgumentOutOfRangeException out of engine.Evaluate — at both ends: new Date(8640000000000000).toLocaleString() (the spec's maximum time value), one past year 9999, one before year 1. Same escaping-CLR-exception class as #2954/#2965.

Out-of-range dates now get the culture-independent rendering of their non-locale sibling (toString/toDateString/toTimeString), which is already pure integer arithmetic on the time value and correct across the whole range. The decision was made on measured evidence, not preference:

  • ECMA-402's FormatDateTime is defined for every valid time value, but the implementation rests on DateTime + .NET calendars; ECMA-262 says toLocaleString contents are implementation-defined.
  • node renders 9/13/275760, 12:00:00 AM — better in the common case, but ICU renders year 0 as 12/31/1 and −271821 as 4/20/271822, era-less and ambiguous.
  • The clamp+originalYear lane Intl.DateTimeFormat.format already uses was measured producing garbage for these values: 12/31, 27576011:59:59 PM — month/day/time from DateTime.MaxValue, and BuildFormatString classifies the quoted year literal as an hour literal, so the separators land wrong too. That mis-rendering is pre-existing, still there, and wants its own fix — noted, not attempted here.

Option validation is preserved (the formatter is constructed first, so toLocaleString('!!bad!!') still throws RangeError), and a sibling in-range crash rides along: ConvertToTimeZone's explicit-offset branch used DateTime.Add and threw for new Date(253402300799999).toLocaleString('en-US',{timeZone:'+03:00'}) — a valid date; it now saturates like the named-zone branch beside it.

Red 7 / green on both TFMs. Test262 at baseline (the S7.8.5 timeout family fails identically on unmodified cf3c048de under load and passes on branch code when quiet).

🤖 Generated with Claude Code

@lahma
lahma enabled auto-merge (squash) August 11, 2026 20:50
…ocaleString

new Date(8640000000000000).toLocaleString() threw ArgumentOutOfRangeException,
"Value to add was out of range", out of engine.Evaluate. Not a JavaScriptException,
so a script try/catch around the call never saw it -- the same escape sebastienros#2954 closed
for sort and sebastienros#2965 for the first instant of year 10000, whose message named this one
as left alone. toLocaleDateString and toLocaleTimeString went the same way, and so
did both ends of the range: 253402300800001 and 8640000000000000 above,
-62135596800001 and -8640000000000000 below.

All three converted the time value with DatePresentation.ToDateTime() before handing
it to the ECMA-402 path, and that conversion is unguarded. DateTime spans years 1
through 9999. https://tc39.es/ecma262/#sec-timeclip admits every time value up to
8.64e15, which is years -271821 through 275760, so a perfectly legal Date can name a
year no calendar on the platform can hold.

Those years now get the culture-independent rendering their non-locale siblings
already give the same value: toLocaleString answers what toString does,
toLocaleDateString what toDateString does, toLocaleTimeString what toTimeString does.
https://tc39.es/ecma262/#sec-datestring and #sec-timestring are integer arithmetic on
the time value and render year 275760 as readily as year 2026, which is why toString
never had the problem.

Two better-looking alternatives were tried and rejected. Clamping the DateTime to
MinValue/MaxValue and overriding only the year is what
Intl.DateTimeFormat.prototype.format does today, and it is worse than it sounds:
month, day and time come from the clamp, so new Date(8640000000000000) formats as
"12/31, 27576011:59:59 PM" -- December 31 rather than September 13, the time from
DateTime.MaxValue, and the separators misplaced because BuildFormatString reads the
year literal that lane emits as an hour literal. Carrying the real fields in on a
substitute year congruent mod 400 would render what node renders, but it needs the
whole component pipeline to learn about per-field overrides it does not have, for a
band of years no non-Gregorian calendar can express anyway. Being wrong in the
locale's shape is worse than being right in nobody's.

The formatter is still constructed before the switch, so CreateDateTimeFormat still
reads the locale list and the options bag and still rejects what the spec says to
reject: new Date(8640000000000000).toLocaleString('!!bad!!') is a RangeError, as it
was.

One more CLR exception on the same path, at a value inside DateTime's range. An
explicit numeric offset shifts the wall clock past the end of DateTime --
toLocaleString('en-US', { timeZone: '+03:00' }) at 253402300799999 -- and the offset
branch of ConvertToTimeZone applied it with DateTime.Add, which throws. It saturates
now, which is what TimeZoneInfo.ConvertTimeFromUtc, the named-zone branch beside it,
has always done.

Left alone: Intl.DateTimeFormat.prototype.format still mis-renders these years
through the clamp-plus-originalYear lane described above. That is a wrong string
rather than an exception escaping the engine, and it wants its own fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lahma
lahma force-pushed the fix/tolocalestring-out-of-clr-range branch from 26d4d05 to bf2a0fc Compare August 11, 2026 20:53
@lahma
lahma merged commit 1528b10 into sebastienros:main Aug 11, 2026
5 checks passed
lahma added a commit that referenced this pull request Aug 14, 2026
#3018)

Two Intl defects from #3009, both about a value the engine had but did not use.

Unicode extension option values were never canonicalized. Every path that reads
one out of a locale *tag* gets both halves of
https://tc39.es/ecma402/#sec-canonicalizeuvalue (9.2.2) for free, because the tag
parsers lowercase as they go and CanonicalizeUnicodeLocaleId applies the CLDR
bcp47 alias data to the extension sequence. The options bag got neither, so
https://tc39.es/ecma402/#sec-resolvelocale (9.2.7) step 10 -- "Set optionsValue to
CanonicalizeUValue(ukey, optionsValue)", immediately before asking whether
keyLocaleData contains it -- and https://tc39.es/ecma402/#sec-makelocalerecord
(15.1.3), which does the same for Intl.Locale, both compared the caller's spelling.
IntlUtilities.CanonicalizeUValue is that operation, next to the syntax check of
9.2.8 step 6.d.ii it belongs beside, and it fixes three symptoms:

  new Intl.Collator('de', { collation: 'PHONEBK' }).resolvedOptions().collation
    "default" -> "phonebk"; the [[co]] list was matched with StringComparison.Ordinal
  new Intl.Locale('de', { collation: 'PHONEBK' }).toString()
    "de-u-co-PHONEBK" -> "de-u-co-phonebk"; getCollations() answered ["PHONEBK"]
  new Intl.NumberFormat('en', { numberingSystem: 'LATN' })
    RangeError -> "latn"; the validator required char.IsAsciiLetterLower and
    rejected the hyphen the "type" nonterminal admits, so a well-formed
    multi-subtag value threw where it should merely have matched nothing

Intl.DurationFormat and Intl.RelativeTimeFormat had the same defect on
numberingSystem and get the same fold; both probe an OrdinalIgnoreCase dictionary,
so 'LATN' was accepted and reported back verbatim. Intl.DateTimeFormat already
adopted the canonical spelling from its supported list and is only pinned.

Intl.DateTimeFormat.format mis-rendered every date outside DateTime's range, which
is what #2981 left behind ("still mis-renders these years through the
clamp-plus-originalYear lane"). new Intl.DateTimeFormat("en-US").format(new
Date(8.64e15)) was "12/31, 275760" where it should be 9/13/275760, and with time
components "12/31, 27576011:59:59 PM". Four links:

  the conversion clamped to DateTime.MinValue/MaxValue and carried only the year
  out, so month, day and every time field came from the clamp;
  the year was re-inserted as a quoted literal;
  BuildFormatString reads a part starting with an apostrophe as an hour, so it put
  the date/time separator in front of the year and ran the time fields in behind it;
  and FormatStyleToParts took originalYear and dropped it, so { dateStyle: 'short' }
  printed year 9999.

The value is decomposed instead, by DatePrototype.FieldsFromTimeValue -- the V8
400-year-cycle arithmetic the engine already had, replacing DateTimeFormatPrototype's
third partial copy of it -- onto the representative year CreateDateTimeSafe was
already building for Temporal. Congruent mod 400 means leap status, weekday and
day-of-year alignment are the real ones, so a single per-field override carries the
whole date; Format now routes a value carrying one through the parts lane, which is
the lane that understands overrides, exactly as era and the non-Gregorian calendars
already did.

Because the substitute's year is meaningful only mod 400, every absolute-year
comparison had to start reading the real one: the Ethiopic, Islamic and Japanese era
boundaries and the Japanese era-year all compared against the substitute and were
right only by accident of where the clamp had put it.
intl402/DateTimeFormat/prototype/formatToParts/era.js catches exactly this.

Behaviour change: Date.prototype.toLocaleString / toLocaleDateString /
toLocaleTimeString no longer fall back to the Date.prototype.toString shape for
these years. new Date(8640000000000000).toLocaleString('en-US') was
"Sat Sep 13 275760 02:00:00 GMT+0200 (FLE Standard Time)" in 4.16.0 and is
"9/13/275760, 12:00:00 AM" (UTC engine) now. That guard existed only because the
formatter was broken, and #2981's own message names carrying the real fields in on a
substitute year as what it wanted and could not have.

One more, on the same lines: ToDateTimeFormattable keeps a Date object as itself
where the spec converts it to a Number, so HandleDateTimeValue's "If x is not a
finite Number, throw a RangeError" never ran for one. format(new Date(NaN)) answered
with an arbitrary date instead of throwing.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lahma
lahma deleted the fix/tolocalestring-out-of-clr-range branch August 20, 2026 19:46
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