Skip to content

Temporal: a date a calendar reports fields for is a date it reckons arithmetic in - #3502

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:temporal-range-and-table-consistency
Aug 30, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:temporal-range-and-table-consistency

Conversation

@lahma

@lahma lahma commented Aug 30, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #3483.

What was wrong

Four of the eleven non-ISO calendars are backed by a System.Globalization.Calendar that covers less
than Temporal's range — hebrew ISO 1583-01-01 to 2239-09-29, persian from 622, chinese 1901–2101,
dangi 918–2051. Their field accessors have long answered past those bounds from a reckoning of the
calendar's own: HebrewAlgorithmicFromIso / HebrewAlgorithmicToIso and the Persian pair, and the
astronomical reckoning #3482 added for the lunisolar pair. Their arithmetic did not.
NonIsoCalendars.CalendarDateAdd reached for the BCL calendar directly — GetLeapMonthOrdinal,
MonthCodeToOrdinal, GetMonthsInYear, GetDaysInMonthCal, and finally cal.ToDateTime — and turned
that last call's ArgumentOutOfRangeException into the CalendarRangeException #3452 introduced.

So the same date, in the same calendar, in the same engine, got two verdicts about whether the engine
could reckon it:

const d = Temporal.PlainDate.from('1500-06-15').withCalendar('hebrew');
d.year;               // 5260   — reckoned, and right
d.monthCode;          // "M10"  — reckoned, and right
d.add({ days: 1 });   // answers — days are added as ISO days, never through the calendar
d.add({ months: 1 }); // RangeError: Date is outside the range supported by the 'hebrew' calendar

Nothing about a date says which of the two it will get. withCalendar validates nothing, from builds
these dates happily, toString prints them, with edits them, and until measures them by day — and
then add({ months: 1 }) refuses. Six of the eleven calendars, the ones with no BCL calendar behind
them, already measured across the whole of Temporal's range; the other five stopped at the edge of a
table that is an implementation detail of Jint's, not a property of the calendar.

Refusing is permitted, which is why this is not a conformance fix

NonISODateAdd is declared as returning
"either a normal completion containing an ISO Date Record or a throw completion", so a RangeError
there is spec-legal — that clause is exactly what let #3452 replace a hang with one. This is a coherence
fix, not a conformance one, and the argument for it is that the refusal was right against the alternative
it replaced and is not right against the one available now.

When #3452 landed, the alternative was ClampToCalendarRange, which answered with the calendar's
maximum date whichever end had been overrun — so subtract moved forward and until's month walk
stood still forever. Refusing beat that. But the conversions know the answer, and #3482 finished the last
two calendars that did not. The arithmetic was simply not asking them.

What changed

Every one of the five touchpoints now falls back to the same reckoning the field accessors read, for
exactly the years the backing calendar declines:

what the walk asks the BCL calendar past the end of its table
GetLeapMonthOrdinal LunisolarAstronomy.ForYear(...).LeapIndex + 1 for chinese/dangi; the Hebrew 19-year cycle already answered
GetMonthsInYear LunisolarYear.MonthCount; the Hebrew arm already answered
MonthCodeToOrdinal reads the leap ordinal above, so it follows
GetDaysInMonthCal new AlgorithmicDaysInMonth — the reckoning's month length, HebrewDaysInMonthOrdinal, PersianAlgorithmicDaysInMonth. It used to answer a flat 30
cal.ToDateTime new BuiltinCalendarDateToIso, the same switch CalendarDateToIso runs, whose arms already reckon past their tables

BuiltinCalendarDateToIso rather than CalendarDateToIso: the latter has a last-resort arm that answers
an unplaceable date with its fields read as ISO, which is another calendar's answer wearing this one's
name — and, worse for the walk below, an answer whose progress says nothing about this calendar's.

Temporal.PlainDate.from('1500-06-15').withCalendar('hebrew').add({ months: 1 });
// 5.0: RangeError    5.x: 1500-07-14[u-ca=hebrew]   (M10 → M11)

Temporal.PlainDate.from('1950-01-01').withCalendar('chinese').subtract({ years: 100 });
// 5.0: RangeError    5.x: 1849-12-26[u-ca=chinese]

Temporal.PlainDate.from('1910-01-01').withCalendar('chinese')
    .until(Temporal.PlainDate.from('1900-01-03').withCalendar('chinese'), { largestUnit: 'year' });
// before #3452: never returned    5.0: RangeError    5.x: -P9Y12M17D

All eleven non-ISO calendars now measure and add across the whole of Temporal's range.

The walk still terminates, and here is why

#3452's guarantee is the one thing that may not regress: CalendarDateUntil walks a month at a time and
its only exit is "this step passed the target", so an answer that does not progress is a hang no
execution constraint can interrupt (#3428). Three things keep it bounded, in that order:

  1. The reckoning is strictly monotone in (year, ordinal month). LunisolarAlgorithmicToIso answers
    MonthStarts[ordinal - 1] + day - 1, HebrewAlgorithmicToIso and PersianAlgorithmicToIso sum month
    lengths from a year start, and LunisolarAstronomy.Build fills MonthStarts from a strictly
    increasing new-moon sequence. Every step therefore moves.
  2. It declines outright past Temporal's own range. LunisolarAstronomy.ForYear returns null outside
    ISO −271821…275760, BuiltinCalendarDateToIso returns null, and CalendarRangeException is raised —
    still a RangeError, still catchable. That is the case
    TheRefusalPastTemporalsOwnRangeIsAJavaScriptErrorAScriptCanCatch now covers.
  3. The no-progress guard stays. A step that does not move the date is still a RangeError, which is
    the structural guarantee against a reckoning that saturates rather than progressing — including a host
    ICalendarProvider, which is still free to.

Every case in NonIsoCalendarRangeTests and NonIsoCalendarArithmeticRangeTests runs on a dedicated
thread with a 15-second join, so a regression fails the run rather than wedging it, and both sweep to
±100,000 years.

Terminating was not enough: what a walk that is now taken costs

CI found this and it is the more interesting half. A difference these four calendars used to refuse is now
walked, and the first version of this branch took longer than 15 seconds on the GitHub runners to
measure 250 years of chinese by month. Two changes, and the second is the one that mattered:

  • The whole-year estimate the walk starts from was the starting year's month count. A lunisolar year
    holds twelve months or thirteen, averaging 12.37, so starting from a thirteen-month year and estimating
    a millennium overshoots by some 625 months — every one of which the back-off loop then stepped off one
    at a time. It is now the average month length the same conversion reports, DaysInYear / MonthsInYear,
    which does not drift; the two correction loops still decide the answer, so the estimate only decides how
    long the walk is.
  • LunisolarAstronomy keeps a two-tier cache of built years — a [ThreadStatic] most-recent entry,
    which is what makes reading one date's six fields cost one build rather than six, and a shared 256-entry
    direct-mapped table published with Volatile.Write/Volatile.Read. A LunisolarYear is a pure function
    of its year and region, so sharing it is safe and a slot lost to a race costs a rebuild and nothing else.
until(..., { largestUnit: 'month' }) first push now
chinese 2050 → 2300 (250 y) >15 s on CI 186 ms
dangi 2050 → 2300 >15 s on CI 62 ms
hebrew 2050 → 2300 22 ms 3 ms
chinese 1990 → 1000 (990 y) 36.3 s 1.9 s

What is left is that add/subtract of months in bulk is still linear in the years crossed for the three
calendars whose years hold a varying number of months, so add({ months: 3000000 }) is seconds of
uninterruptible work. The six closed-form calendars answer the same query in ~100 ms and always could;
follow-up filed as #3511.

Failing-test evidence

Jint.Tests/Runtime/NonIsoCalendarArithmeticRangeTests.cs against unfixed main:

Failed!  - Failed: 33, Passed: 7, Total: 40

Failed TheReportedHebrewDateAddsAMonthWhereItsYearIsAlreadyAnswered
  d.year is 5260, and d.add({ months: 1 }) is
  "RangeError: Date is outside the range supported by the 'hebrew' calendar"

Failed AMonthAddedPastTheBackingRangeIsAMonthTakenBack                  (all 7 dates)
Failed AYearAddedPastTheBackingRangeKeepsTheMonthCode                   (all 7 dates)
Failed TheMonthDifferencePastTheBackingRangeIsTheMonthsThatWereAdded    (all 7 dates)
Failed TheDateAMonthAddedLandsOnIsTheDateItsOwnFieldsBuild              (all 7 dates)
    hebrew below its 1583 floor / above its 2239 ceiling, persian below its 622 floor,
    chinese below 1901 / above 2101, dangi below 918 / above 2051

Failed EveryCalendarThatReportsAYearAlsoAddsToIt("chinese")  ("dangi", "hebrew", "persian")
  — the other seven calendars pass on main and after: they never had a table to run out of

On main, for the dates whose fields it answers:

hebrew  1500-06-15  .year       5260    .add({ months: 1 })  RangeError
chinese 1800-01-01  .monthCode  "M12"   .add({ months: 1 })  RangeError
persian 0500-06-15  .year       -121    .add({ years: 1 })   RangeError
dangi   0800-01-01                      .add({ months: 1 })  RangeError

The pinned refusals this deliberately updates

NonIsoCalendarRangeTests pinned the #3452 behaviour, so 15 of its cases now assert the answer instead of
the refusal — which the issue names as the deliberate change. What that file is for has not moved: the
dedicated thread, the join timeout, the sweep over every difference surface that walks months, and the
largestUnit-by-op matrix all stay, and each case now also asserts that a.add(a.until(b)) is b and
a.subtract(a.since(b)) is b — a walk that stops in the right place, not merely one that stops.

ArithmeticInsideEveryCalendarRangeIsUnchanged passes on main and after: nothing inside a table moves.

Testing

  • dotnet build -c Release on the solution — clean, bar the one pre-existing MSB3277 in
    Jint.Tests.CommonScripts on net472.
  • Jint.Tests — net472 7,707 / net8.0 11,086 / net10.0 11,087 passed; the one net8.0 failure was
    Wpt.RunsTheFetchRedirectSuite, unrelated to calendars, and passes in isolation.
  • Jint.Tests.PublicInterface — net472 2,679 / net8.0 3,302 / net10.0 3,312 passed, 0 failed.
  • Jint.Tests.CommonScripts 28 on each of two TFMs, Jint.Tests.SourceGenerators 71 — 0 failed.
  • Jint.Tests.Test262 — 102,521 passed, 165 skipped, plus the two staging/sm/Array/toSpliced-dense
    30-second timeouts that are the known under-load flake and pass in isolation (182 passed, 0 failed).
    102,521 + 2 = 102,523, which is the control on this base measured with nothing applied.

Migration guide: §4.70, and §4.50's "arithmetic past a table's end still refuses" bullet now points at it.

🤖 Generated with Claude Code

https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S

…rithmetic in

Fixes sebastienros#3483.

Four calendars are backed by a System.Globalization.Calendar that covers less than Temporal's
range, and their field accessors have long answered past those bounds from a reckoning of the
calendar's own. Their arithmetic did not: NonIsoCalendars.CalendarDateAdd read the backing
calendar directly and turned its refusal into a RangeError, so the same date under the same
calendar got two verdicts about whether the engine could reckon it.

The five things the walk asks that calendar -- a year's month count, its leap month's ordinal, a
monthCode's ordinal in a given year, a month's length, and where a resolved (year, ordinal, day)
lands in ISO -- now fall back to the conversions the accessors already read, for exactly the years
the backing calendar declines. All eleven non-ISO calendars measure and add across the whole of
Temporal's range.

Termination survives: the reckoning is strictly monotone in (year, ordinal month) so every step of
CalendarDateUntil's walk moves, it declines outright past Temporal's own range (still a
CalendarRangeException, still a RangeError), and the walk's no-progress guard stays as the
structural guarantee against a reckoning that saturates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
@lahma
lahma force-pushed the temporal-range-and-table-consistency branch from e627eb0 to c5d86d7 Compare August 30, 2026 15:45
@lahma
lahma merged commit 9f0afc3 into sebastienros:main Aug 30, 2026
7 checks passed
lahma added a commit that referenced this pull request Sep 9, 2026
… on the 33-year cycle, so the ends of Temporal's range land in the right Persian year (#4006)

Backport of PR #3751 (commit 5a57a7d) from main.

`PersianCalendar` spans ISO 622-03-22 to 9999-12-31 and derives those years
astronomically; everything outside them is answered by an arithmetic rule, because
no ephemeris covers a quarter of a million years. That rule was the 2820-year cycle
(Reingold-Dershowitz / Birashk), and it is not the one the platform's `persian`
calendar is: ICU places a year's first day 365 * (year - 1) + floor((8 * year + 21) / 33)
days after the epoch and calls a year a leap year when floorMod(25 * year + 11, 33) < 8.
Over Temporal's own range the two drift about two months apart, which put both ends of
it in the wrong Persian year.

Where the hand-off happens is now an ISO window this repository states -- 622-03-22
through 9999-12-31, held as `PersianTableFirstJdn`/`PersianTableLastJdn` and, for the
direction that asks in Persian fields, `PersianTableHolds` -- rather than the platform's
`MinSupportedDateTime`/`MaxSupportedDateTime` and the `ArgumentOutOfRangeException`
`ToDateTime` raises outside them.

Adapted for 4.x:

- `PersianDateToIso`'s maximum-day lookup keeps 4.x's `try`/`catch` around
  `PersianCal.GetDaysInMonth`; main's `SupportsYear`/`TryGetDaysInMonth` helpers come
  from the un-ported #3502/#3528 chain and do not exist here.
- The `HebrewMonthSteppingTests` hunk is dropped: that file arrived with #3525, which
  4.x does not carry.
- Tests transcribed from NUnit to xUnit, which is what 4.x's `Jint.Tests` still is.
- Only one of main's four test262 exclusions comes out here. `ZonedDateTime/from/extreme-dates.js`
  passes; the three `*/prototype/withCalendar/extreme-dates.js` files get past their persian rows
  and stop at `chinese minimum non-approximated date`, which is the lunisolar reckoning chain
  (#3482/#3502/#3519) 4.x does not carry, so they stay excluded with that reason recorded.
- `TheYearTheWindowStopsInsideIsStillAWholeYear` is narrowed to
  `TheLastDayTheWindowHoldsIsStillTheTablesToPlace`: its three length assertions pin
  #3528's answer for the part-year 9378, and #3528 is not on this branch.


Claude-Session: https://claude.ai/code/session_01SLCujwvKtTvtWD9f6RTyiF

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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.

Temporal: calendar arithmetic refuses past a BCL calendar's range where the field accessors answer

1 participant