Render a locale string for every date the spec calls valid - #2981
Merged
lahma merged 1 commit intoAug 11, 2026
Merged
Conversation
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
force-pushed
the
fix/tolocalestring-out-of-clr-range
branch
from
August 11, 2026 20:53
26d4d05 to
bf2a0fc
Compare
This was referenced Aug 14, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
toLocaleString/toLocaleDateString/toLocaleTimeStringcalledToDateTime()with no range check, so every date outsideDateTime's years-1-to-9999 window threwArgumentOutOfRangeExceptionout ofengine.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:FormatDateTimeis defined for every valid time value, but the implementation rests onDateTime+ .NET calendars; ECMA-262 saystoLocaleStringcontents are implementation-defined.9/13/275760, 12:00:00 AM— better in the common case, but ICU renders year 0 as12/31/1and −271821 as4/20/271822, era-less and ambiguous.originalYearlaneIntl.DateTimeFormat.formatalready uses was measured producing garbage for these values:12/31, 27576011:59:59 PM— month/day/time fromDateTime.MaxValue, andBuildFormatStringclassifies 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 throwsRangeError), and a sibling in-range crash rides along:ConvertToTimeZone's explicit-offset branch usedDateTime.Addand threw fornew 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
cf3c048deunder load and passes on branch code when quiet).🤖 Generated with Claude Code