From ab17bf567a440e64800cfd5f2d61e1ef35a857b3 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Mon, 4 Aug 2025 08:07:27 -0700 Subject: [PATCH 01/30] Polyfill: Update reference code to reflect normative change UPSTREAM_COMMIT=b078965819a012a91810cce8df1987b0c9137ee6 --- lib/duration.ts | 2 +- lib/ecmascript.ts | 10 +++++----- lib/instant.ts | 4 ++-- lib/plaindatetime.ts | 2 +- lib/plaintime.ts | 2 +- lib/zoneddatetime.ts | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/lib/duration.ts b/lib/duration.ts index 608816af..69b9198d 100644 --- a/lib/duration.ts +++ b/lib/duration.ts @@ -178,7 +178,7 @@ export class Duration implements Temporal.Duration { let largestUnit = ES.GetTemporalUnitValuedOption(roundTo, 'largestUnit'); let { plainRelativeTo, zonedRelativeTo } = ES.GetTemporalRelativeToOption(roundTo); - const roundingIncrement = ES.GetTemporalRoundingIncrementOption(roundTo); + const roundingIncrement = ES.GetRoundingIncrementOption(roundTo); const roundingMode = ES.GetRoundingModeOption(roundTo, 'halfExpand'); let smallestUnit = ES.GetTemporalUnitValuedOption(roundTo, 'smallestUnit'); ES.ValidateTemporalUnitValue(smallestUnit, 'datetime'); diff --git a/lib/ecmascript.ts b/lib/ecmascript.ts index 81faa9e3..44b6742e 100644 --- a/lib/ecmascript.ts +++ b/lib/ecmascript.ts @@ -928,7 +928,7 @@ export function GetDirectionOption(options: { direction?: 'next' | 'previous' }) return GetOption(options, 'direction', ['next', 'previous'], REQUIRED); } -export function GetTemporalRoundingIncrementOption(options: { roundingIncrement?: number }) { +export function GetRoundingIncrementOption(options: { roundingIncrement?: number }) { let increment = options.roundingIncrement; if (increment === undefined) return 1; const integerIncrement = ToIntegerWithTruncation(increment); @@ -4123,18 +4123,18 @@ function GetDifferenceSettings( }, [] as (Temporal.DateTimeUnit | Temporal.PluralUnit)[]); let largestUnit = GetTemporalUnitValuedOption(options, 'largestUnit'); + const roundingIncrement = GetRoundingIncrementOption(options); + let roundingMode = GetRoundingModeOption(options, 'trunc'); + let smallestUnit = GetTemporalUnitValuedOption(options, 'smallestUnit'); + ValidateTemporalUnitValue(largestUnit, group, ['auto']); if (!largestUnit) largestUnit = 'auto'; if (disallowed.includes(largestUnit)) { throw new RangeError(`largestUnit must be one of ${ALLOWED_UNITS.join(', ')}, not ${largestUnit}`); } - const roundingIncrement = GetTemporalRoundingIncrementOption(options); - - let roundingMode = GetRoundingModeOption(options, 'trunc'); if (op === 'since') roundingMode = NegateRoundingMode(roundingMode); - let smallestUnit = GetTemporalUnitValuedOption(options, 'smallestUnit'); ValidateTemporalUnitValue(smallestUnit, group); if (!smallestUnit) smallestUnit = fallbackSmallest; if (disallowed.includes(smallestUnit)) { diff --git a/lib/instant.ts b/lib/instant.ts index 836e32e1..fbd94f37 100644 --- a/lib/instant.ts +++ b/lib/instant.ts @@ -52,7 +52,7 @@ export class Instant implements Temporal.Instant { typeof roundToParam === 'string' ? (ES.CreateOnePropObject('smallestUnit', roundToParam) as Exclude) : ES.GetOptionsObject(roundToParam); - const roundingIncrement = ES.GetTemporalRoundingIncrementOption(roundTo); + const roundingIncrement = ES.GetRoundingIncrementOption(roundTo); const roundingMode = ES.GetRoundingModeOption(roundTo, 'halfExpand'); const smallestUnit = ES.GetTemporalUnitValuedOption(roundTo, 'smallestUnit', ES.REQUIRED); ES.ValidateTemporalUnitValue(smallestUnit, 'time'); @@ -82,9 +82,9 @@ export class Instant implements Temporal.Instant { const digits = ES.GetTemporalFractionalSecondDigitsOption(resolvedOptions); const roundingMode = ES.GetRoundingModeOption(resolvedOptions, 'trunc'); const smallestUnit = ES.GetTemporalUnitValuedOption(resolvedOptions, 'smallestUnit'); + let timeZone = resolvedOptions.timeZone; ES.ValidateTemporalUnitValue(smallestUnit, 'time'); if (smallestUnit === 'hour') throw new RangeError('smallestUnit must be a time unit other than "hour"'); - let timeZone = resolvedOptions.timeZone; if (timeZone !== undefined) timeZone = ES.ToTemporalTimeZoneIdentifier(timeZone); const { precision, unit, increment } = ES.ToSecondsStringPrecisionRecord(smallestUnit, digits); const ns = GetSlot(this, EPOCHNANOSECONDS); diff --git a/lib/plaindatetime.ts b/lib/plaindatetime.ts index 26e71ded..bdc22794 100644 --- a/lib/plaindatetime.ts +++ b/lib/plaindatetime.ts @@ -168,7 +168,7 @@ export class PlainDateTime implements Temporal.PlainDateTime { typeof roundToParam === 'string' ? (ES.CreateOnePropObject('smallestUnit', roundToParam) as Exclude) : ES.GetOptionsObject(roundToParam); - const roundingIncrement = ES.GetTemporalRoundingIncrementOption(roundTo); + const roundingIncrement = ES.GetRoundingIncrementOption(roundTo); const roundingMode = ES.GetRoundingModeOption(roundTo, 'halfExpand'); const smallestUnit = ES.GetTemporalUnitValuedOption(roundTo, 'smallestUnit', ES.REQUIRED); ES.ValidateTemporalUnitValue(smallestUnit, 'time', ['day']); diff --git a/lib/plaintime.ts b/lib/plaintime.ts index 771e8354..f7b9945a 100644 --- a/lib/plaintime.ts +++ b/lib/plaintime.ts @@ -92,7 +92,7 @@ export class PlainTime implements Temporal.PlainTime { typeof roundToParam === 'string' ? (ES.CreateOnePropObject('smallestUnit', roundToParam) as Exclude) : ES.GetOptionsObject(roundToParam); - const roundingIncrement = ES.GetTemporalRoundingIncrementOption(roundTo); + const roundingIncrement = ES.GetRoundingIncrementOption(roundTo); const roundingMode = ES.GetRoundingModeOption(roundTo, 'halfExpand'); const smallestUnit = ES.GetTemporalUnitValuedOption(roundTo, 'smallestUnit', ES.REQUIRED); ES.ValidateTemporalUnitValue(smallestUnit, 'time'); diff --git a/lib/zoneddatetime.ts b/lib/zoneddatetime.ts index 53c8e4d3..29b82e61 100644 --- a/lib/zoneddatetime.ts +++ b/lib/zoneddatetime.ts @@ -236,7 +236,7 @@ export class ZonedDateTime implements Temporal.ZonedDateTime { typeof roundToParam === 'string' ? (ES.CreateOnePropObject('smallestUnit', roundToParam) as Exclude) : ES.GetOptionsObject(roundToParam); - const roundingIncrement = ES.GetTemporalRoundingIncrementOption(roundTo); + const roundingIncrement = ES.GetRoundingIncrementOption(roundTo); const roundingMode = ES.GetRoundingModeOption(roundTo, 'halfExpand'); const smallestUnit = ES.GetTemporalUnitValuedOption(roundTo, 'smallestUnit', ES.REQUIRED); ES.ValidateTemporalUnitValue(smallestUnit, 'time', ['day']); @@ -331,9 +331,9 @@ export class ZonedDateTime implements Temporal.ZonedDateTime { const showOffset = ES.GetTemporalShowOffsetOption(resolvedOptions); const roundingMode = ES.GetRoundingModeOption(resolvedOptions, 'trunc'); const smallestUnit = ES.GetTemporalUnitValuedOption(resolvedOptions, 'smallestUnit'); + const showTimeZone = ES.GetTemporalShowTimeZoneNameOption(resolvedOptions); ES.ValidateTemporalUnitValue(smallestUnit, 'time'); if (smallestUnit === 'hour') throw new RangeError('smallestUnit must be a time unit other than "hour"'); - const showTimeZone = ES.GetTemporalShowTimeZoneNameOption(resolvedOptions); const { precision, unit, increment } = ES.ToSecondsStringPrecisionRecord(smallestUnit, digits); return ES.TemporalZonedDateTimeToString(this, precision, showCalendar, showTimeZone, showOffset, { unit, From 2dd81bce58eb11ac96f9a8c0d8128fe45dd4acd8 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Fri, 22 Aug 2025 12:35:21 -0700 Subject: [PATCH 02/30] Update test262 UPSTREAM_COMMIT=c0930994b7df57e0313152f446200871f4c78183 --- test262 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test262 b/test262 index a9ac87d3..b947715f 160000 --- a/test262 +++ b/test262 @@ -1 +1 @@ -Subproject commit a9ac87d3175a05bba8482c621062f905ed30f3a4 +Subproject commit b947715fdda79a420b253821c1cc52272a77222d From f6f3cbfa4b0d03421060df62229c464cc152b9c4 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Mon, 25 Aug 2025 08:03:38 -0700 Subject: [PATCH 03/30] Update test262 UPSTREAM_COMMIT=9b64df55465ef25a20bada50eca26515bb82669d --- test262 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test262 b/test262 index b947715f..04eaeb99 160000 --- a/test262 +++ b/test262 @@ -1 +1 @@ -Subproject commit b947715fdda79a420b253821c1cc52272a77222d +Subproject commit 04eaeb99080ceb60d7b86ea0c4bed6355ef4cdcb From 14bda7274a540fff3ea9b375b82e453324262e89 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Mon, 15 Sep 2025 18:05:12 -0700 Subject: [PATCH 04/30] Editorial: Revert inadvertent observable change In #2989 I added checks to prevent calling GetUTCEpochNanoseconds with out-of-range arguments, which, per ECMA-262, is legal but ill-defined. (See https://github.com/tc39/ecma262/issues/1087). These checks should not have changed any observable semantics, just caused some inputs to throw that would have thrown anyway. These checks later became CheckISODateRange. However, in one of the checks I made a mistake: the GetUTCEpochNanoseconds call in GetNamedTimeZoneEpochNanoseconds can never trigger the ill-defined behaviour because the time zone is always UTC in the time-zone-unaware algorithm given there. So the extra check was not necessary, and in fact was incorrect for time-zone-aware implementations, causing an observable change in semantics as they would throw where they previously did not. Since this was an unintended and unwanted change to observable semantics, which was not approved in committee, we must revert it. Web compatibility status: Firefox ships the correct behaviour. Chrome currently ships the broken behaviour, but that has not yet been exposed to the web unflagged. Safari has not implemented this part of the proposal yet. UPSTREAM_COMMIT=6274e5de91b499cc3063cf8372d1fb8ce5d027d0 --- lib/ecmascript.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/ecmascript.ts b/lib/ecmascript.ts index 44b6742e..d830b6b1 100644 --- a/lib/ecmascript.ts +++ b/lib/ecmascript.ts @@ -2264,7 +2264,6 @@ function GetPossibleEpochNanoseconds(timeZone: string, isoDateTime: ISODateTime) return [epochNs]; } - CheckISODaysRange(isoDateTime.isoDate); return GetNamedTimeZoneEpochNanoseconds(timeZone, isoDateTime); } From 064e2e643fbc3126deb34d5d09afdd1fcb507448 Mon Sep 17 00:00:00 2001 From: Richard Gibson Date: Fri, 19 Sep 2025 15:19:29 -0400 Subject: [PATCH 05/30] Polyfill: Speed up largestUnit: month date difference for calendars without leap months Fixes #3153 UPSTREAM_COMMIT=ad9c6ff00bb5ec7e5c82391b9cd4f4738528c9a1 --- lib/calendar.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/calendar.ts b/lib/calendar.ts index cacf9eba..074d300a 100644 --- a/lib/calendar.ts +++ b/lib/calendar.ts @@ -1139,7 +1139,7 @@ abstract class HelperBase { } const diffYears = calendarTwo.year - calendarOne.year; const diffDays = calendarTwo.day - calendarOne.day; - if (largestUnit === 'year' && diffYears) { + if (diffYears && (largestUnit === 'year' || !monthCodeInfo[this.id]?.additionalMonths)) { let diffInYearSign = 0; if (calendarTwo.monthCode > calendarOne.monthCode) diffInYearSign = 1; if (calendarTwo.monthCode < calendarOne.monthCode) diffInYearSign = -1; @@ -1148,6 +1148,10 @@ abstract class HelperBase { years = isOneFurtherInYear ? diffYears - sign : diffYears; } const yearsAdded = years ? this.addCalendar(calendarOne, { years }, 'constrain', cache) : calendarOne; + if (years && largestUnit === 'month') { + months += years * 12; + years = 0; + } // Now we have less than one year remaining. Add one month at a time // until we go over the target, then back up one month and calculate // remaining days and weeks. From e9194adccc39e0eb07b5c3ec8d043dba08b5b64b Mon Sep 17 00:00:00 2001 From: Richard Gibson Date: Fri, 19 Sep 2025 15:19:29 -0400 Subject: [PATCH 06/30] Polyfill: Speed up many-months date addition for calendars without leap months Ref #3153 UPSTREAM_COMMIT=87e287c2064cff5542c5370f3c82c667afdd5c2e --- lib/calendar.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/calendar.ts b/lib/calendar.ts index 074d300a..9b4378f9 100644 --- a/lib/calendar.ts +++ b/lib/calendar.ts @@ -1100,11 +1100,17 @@ abstract class HelperBase { } addCalendar( calendarDate: CalendarYMD & { monthCode: string }, - { years = 0, months = 0, weeks = 0, days = 0 }, + { years: yearsParam = 0, months: monthsParam = 0, weeks = 0, days = 0 }, overflow: Overflow, cache: OneObjectCache ): FullCalendarDate { + let years = yearsParam; + let months = monthsParam; const { year, day, monthCode } = calendarDate; + if (Math.abs(months) > 12 && !monthCodeInfo[this.id]?.additionalMonths) { + years += Math.trunc(months / 12); + months %= 12; + } const addedYears = this.adjustCalendarDate({ year: year + years, monthCode, day }, cache); const addedMonths = this.addMonthsCalendar(addedYears, months, overflow, cache); const initialDays = days + weeks * 7; From 6f8a2369ad81a71e126a40db3af2d22a0c9efd5e Mon Sep 17 00:00:00 2001 From: Adam Shaw Date: Wed, 27 Aug 2025 18:58:04 -0400 Subject: [PATCH 07/30] fix for issues #3141, #3148 and #3149 UPSTREAM_COMMIT=87af06d0aa7feb0f9dcd39c6526a44ebbb18cef9 --- lib/ecmascript.ts | 76 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 60 insertions(+), 16 deletions(-) diff --git a/lib/ecmascript.ts b/lib/ecmascript.ts index d830b6b1..472de58d 100644 --- a/lib/ecmascript.ts +++ b/lib/ecmascript.ts @@ -3523,6 +3523,20 @@ function DifferenceZonedDateTime( const isoDtStart = GetISODateTimeFor(timeZone, ns1); const isoDtEnd = GetISODateTimeFor(timeZone, ns2); + // If year-month-day values are same-day, + // there's no point involving the calendar/timezone for zdt->pdt conversions. + // It also avoids a situation where dayCorrection backs up too far on same-day diffs + // with reverse-direction wallclock delta due to DST: + // https://github.com/tc39/proposal-temporal/issues/3141 + if ( + isoDtStart.isoDate.year === isoDtEnd.isoDate.year && + isoDtStart.isoDate.month === isoDtEnd.isoDate.month && + isoDtStart.isoDate.day === isoDtEnd.isoDate.day + ) { + const timeDuration = new TimeDuration(nsDiff); + return { date: ZeroDateDuration(), time: timeDuration }; + } + // Simulate moving ns1 as many years/months/weeks/days as possible without // surpassing ns2. This value is stored in intermediateDateTime/intermediateInstant/intermediateNs. // We do not literally move years/months/weeks/days with calendar arithmetic, @@ -3588,6 +3602,7 @@ function DifferenceZonedDateTime( function NudgeToCalendarUnit( sign: -1 | 1, durationParam: InternalDuration, + originEpochNs: JSBI, destEpochNs: JSBI, isoDateTime: ISODateTime, timeZone: string | null, @@ -3647,22 +3662,29 @@ function NudgeToCalendarUnit( if (sign === 1) assert(r1 >= 0 && r1 < r2, `positive ordering of r1, r2: 0 ≤ ${r1} < ${r2}`); if (sign === -1) assert(r1 <= 0 && r1 > r2, `negative ordering of r1, r2: 0 ≥ ${r1} > ${r2}`); - // Apply to origin, output PlainDateTimes - const start = CalendarDateAdd(calendar, isoDateTime.isoDate, startDuration, 'constrain'); - const end = CalendarDateAdd(calendar, isoDateTime.isoDate, endDuration, 'constrain'); - - // Convert to epoch-nanoseconds - let startEpochNs, endEpochNs; - const startDateTime = CombineISODateAndTimeRecord(start, isoDateTime.time); - const endDateTime = CombineISODateAndTimeRecord(end, isoDateTime.time); - if (timeZone) { - startEpochNs = GetEpochNanosecondsFor(timeZone, startDateTime, 'compatible'); - endEpochNs = GetEpochNanosecondsFor(timeZone, endDateTime, 'compatible'); + // Convert to bound-START to epoch-nanoseconds + let startEpochNs; + if (!r1) { + // If the start of the bound is the same as the "origin" (aka relativeTo), + // use the origin's epoch-nanoseconds as-is instead of relying on isoDateTime, + // which then gets zoned and converted back to epoch-nanoseconds, + // which looses precision and creates a distorted bounding window. + startEpochNs = originEpochNs; } else { - startEpochNs = GetUTCEpochNanoseconds(startDateTime); - endEpochNs = GetUTCEpochNanoseconds(endDateTime); + const start = CalendarDateAdd(calendar, isoDateTime.isoDate, startDuration, 'constrain'); + const startDateTime = CombineISODateAndTimeRecord(start, isoDateTime.time); + startEpochNs = timeZone + ? GetEpochNanosecondsFor(timeZone, startDateTime, 'compatible') + : GetUTCEpochNanoseconds(startDateTime); } + // Convert to bound-END to epoch-nanoseconds + const end = CalendarDateAdd(calendar, isoDateTime.isoDate, endDuration, 'constrain'); + const endDateTime = CombineISODateAndTimeRecord(end, isoDateTime.time); + const endEpochNs = timeZone + ? GetEpochNanosecondsFor(timeZone, endDateTime, 'compatible') + : GetUTCEpochNanoseconds(endDateTime); + // Round the smallestUnit within the epoch-nanosecond span if (sign === 1) { assert( @@ -3896,6 +3918,7 @@ function BubbleRelativeDuration( function RoundRelativeDuration( durationParam: InternalDuration, + originEpochNs: JSBI, destEpochNs: JSBI, isoDateTime: ISODateTime, timeZone: string | null, @@ -3921,6 +3944,7 @@ function RoundRelativeDuration( ({ nudgeResult } = NudgeToCalendarUnit( sign, duration, + originEpochNs, destEpochNs, isoDateTime, timeZone, @@ -3977,6 +4001,7 @@ function RoundRelativeDuration( function TotalRelativeDuration( duration: InternalDuration, + originEpochNs: JSBI, destEpochNs: JSBI, isoDateTime: ISODateTime, timeZone: string | null, @@ -3992,7 +4017,18 @@ function TotalRelativeDuration( if (IsCalendarUnit(unit) || (timeZone && unit === 'day')) { // Rounding an irregular-length unit? Use epoch-nanosecond-bounding technique const sign = InternalDurationSign(duration) < 0 ? -1 : 1; - return NudgeToCalendarUnit(sign, duration, destEpochNs, isoDateTime, timeZone, calendar, 1, unit, 'trunc').total; + return NudgeToCalendarUnit( + sign, + duration, + originEpochNs, + destEpochNs, + isoDateTime, + timeZone, + calendar, + 1, + unit, + 'trunc' + ).total; } // Rounding uniform-length days/hours/minutes/etc units. Simple nanosecond // math. years/months/weeks unchanged @@ -4019,9 +4055,11 @@ export function DifferencePlainDateTimeWithRounding( if (smallestUnit === 'nanosecond' && roundingIncrement === 1) return duration; + const originEpochNs = GetUTCEpochNanoseconds(isoDateTime1); const destEpochNs = GetUTCEpochNanoseconds(isoDateTime2); return RoundRelativeDuration( duration, + originEpochNs, destEpochNs, isoDateTime1, null, @@ -4047,8 +4085,9 @@ export function DifferencePlainDateTimeWithTotal( if (unit === 'nanosecond') return JSBI.toNumber(duration.time.totalNs); + const originEpochNs = GetUTCEpochNanoseconds(isoDateTime1); const destEpochNs = GetUTCEpochNanoseconds(isoDateTime2); - return TotalRelativeDuration(duration, destEpochNs, isoDateTime1, null, calendar, unit); + return TotalRelativeDuration(duration, originEpochNs, destEpochNs, isoDateTime1, null, calendar, unit); } export function DifferenceZonedDateTimeWithRounding( @@ -4073,6 +4112,7 @@ export function DifferenceZonedDateTimeWithRounding( const dateTime = GetISODateTimeFor(timeZone, ns1); return RoundRelativeDuration( duration, + ns1, ns2, dateTime, timeZone, @@ -4098,7 +4138,7 @@ export function DifferenceZonedDateTimeWithTotal( const duration = DifferenceZonedDateTime(ns1, ns2, timeZone, calendar, unit); const dateTime = GetISODateTimeFor(timeZone, ns1); - return TotalRelativeDuration(duration, ns2, dateTime, timeZone, calendar, unit); + return TotalRelativeDuration(duration, ns1, ns2, dateTime, timeZone, calendar, unit); } type DifferenceOperation = 'since' | 'until'; @@ -4212,9 +4252,11 @@ export function DifferenceTemporalPlainDate( if (!roundingIsNoop) { const isoDateTime = CombineISODateAndTimeRecord(isoDate, MidnightTimeRecord()); const isoDateTimeOther = CombineISODateAndTimeRecord(isoOther, MidnightTimeRecord()); + const originEpochNs = GetUTCEpochNanoseconds(isoDateTime); const destEpochNs = GetUTCEpochNanoseconds(isoDateTimeOther); duration = RoundRelativeDuration( duration, + originEpochNs, destEpochNs, isoDateTime, null, @@ -4325,9 +4367,11 @@ export function DifferenceTemporalPlainYearMonth( if (settings.smallestUnit !== 'month' || settings.roundingIncrement !== 1) { const isoDateTime = CombineISODateAndTimeRecord(thisDate, MidnightTimeRecord()); const isoDateTimeOther = CombineISODateAndTimeRecord(otherDate, MidnightTimeRecord()); + const originEpochNs = GetUTCEpochNanoseconds(isoDateTime); const destEpochNs = GetUTCEpochNanoseconds(isoDateTimeOther); duration = RoundRelativeDuration( duration, + originEpochNs, destEpochNs, isoDateTime, null, From d21f9b394f90df90f1ce6dc9eed51aa3bf2867e0 Mon Sep 17 00:00:00 2001 From: Richard Gibson Date: Mon, 22 Sep 2025 01:03:29 -0400 Subject: [PATCH 08/30] Polyfill: Speed up Chinese calendar getMonthList UPSTREAM_COMMIT=1a1ef9375de23fb52bc281ca994f0543b9373666 --- lib/calendar.ts | 113 ++++++++++++++++++++++++---------------------- lib/ecmascript.ts | 2 +- 2 files changed, 59 insertions(+), 56 deletions(-) diff --git a/lib/calendar.ts b/lib/calendar.ts index 9b4378f9..6be8797d 100644 --- a/lib/calendar.ts +++ b/lib/calendar.ts @@ -16,6 +16,8 @@ import type { } from './internaltypes'; import { CreateMonthCode, ParseMonthCode } from './monthcode'; +const midnightTimeRecord = ES.MidnightTimeRecord(); + function arrayFromSet(src: Set): T[] { return [...src]; } @@ -572,7 +574,7 @@ class OneObjectCache { } } -function toUtcIsoDateString({ isoYear, isoMonth, isoDay }: { isoYear: number; isoMonth: number; isoDay: number }) { +function toUtcIsoDateString(isoYear: number, isoMonth: number, isoDay: number) { const yearString = ES.ISOYearString(isoYear); const monthString = ES.ISODateTimePartString(isoMonth); const dayString = ES.ISODateTimePartString(isoDay); @@ -665,7 +667,7 @@ abstract class HelperBase { const cached = cache.get(key); if (cached) return cached; - const isoString = toUtcIsoDateString({ isoYear, isoMonth, isoDay }); + const isoString = toUtcIsoDateString(isoYear, isoMonth, isoDay); const parts = this.getCalendarParts(isoString); const hasEra = CalendarSupportsEra(this.id); const result: Partial = {}; @@ -2169,74 +2171,75 @@ abstract class ChineseBaseHelper extends HelperBase { const key = JSON.stringify({ func: 'getMonthList', calendarYear, id: this.id }); const cached = cache.get(key); if (cached) return cached; + + // Reuse the same local object for calendar-specific results, starting with + // a date close to Chinese New Year. Feb 17 will either be in the new year + // or near the end of the previous year's final month. + let daysPastJan31 = 17; + const calendarFields: { day: number; monthString: string; relatedYear: number | undefined } = { + day: 0, + monthString: '', + relatedYear: undefined + }; const dateTimeFormat = this.getFormatter(); - const getCalendarDate = (isoYear: number, daysPastFeb1: number) => { - const isoStringFeb1 = toUtcIsoDateString({ isoYear, isoMonth: 2, isoDay: 1 }); - const legacyDate = new Date(isoStringFeb1); - // Now add the requested number of days, which may wrap to the next month. - legacyDate.setUTCDate(daysPastFeb1 + 1); - const newYearGuess = dateTimeFormat.formatToParts(legacyDate); - // The 'month' and 'day' parts are guaranteed to be present because the - // formatter was created with month and day options. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const calendarMonthString = newYearGuess.find((tv) => tv.type === 'month')!.value; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const calendarDay = +newYearGuess.find((tv) => tv.type === 'day')!.value; - const calendarYearPartToVerify = newYearGuess.find((tv) => (tv.type as string) === 'relatedYear'); - let calendarYearToVerify: number | undefined; - if (calendarYearPartToVerify !== undefined) { - calendarYearToVerify = +calendarYearPartToVerify.value; - } else { + const updateCalendarFields = () => { + // Abuse GetUTCEpochMilliseconds for automatic rebalancing. + const isoNumbers = { year: calendarYear, month: 2, day: daysPastJan31 }; + const ms = ES.GetUTCEpochMilliseconds({ isoDate: isoNumbers, time: midnightTimeRecord }); + const fieldEntries = dateTimeFormat.formatToParts(ms); + for (let i = 0; i < fieldEntries.length; i++) { + const { type, value } = fieldEntries[i]; + // day and year should be decimal strings, but month values like "5bis" are not number-coercible. + // TODO: remove this type annotation when `relatedYear` gets into TS lib types + if (type === 'day' || type === ('relatedYear' as Intl.DateTimeFormatPartTypes)) { + calendarFields[type as 'day' | 'relatedYear'] = +value; + } else if (type === 'month') { + calendarFields.monthString = value; + } + } + if (calendarFields.relatedYear === undefined) { // Node 12 has outdated ICU data that lacks the `relatedYear` field in the // output of Intl.DateTimeFormat.formatToParts. throw new RangeError( `Intl.DateTimeFormat.formatToParts lacks relatedYear in ${this.id} calendar. Try Node 14+ or modern browsers.` ); } - return { calendarMonthString, calendarDay, calendarYearToVerify }; + return calendarFields; }; - // First, find a date close to Chinese New Year. Feb 17 will either be in - // the first month or near the end of the last month of the previous year. - let isoDaysDelta = 17; - let { calendarMonthString, calendarDay, calendarYearToVerify } = getCalendarDate(calendarYear, isoDaysDelta); - - // If we didn't guess the first month correctly, add (almost in some months) - // a lunar month - if (calendarMonthString !== '1') { - isoDaysDelta += 29; - ({ calendarMonthString, calendarDay } = getCalendarDate(calendarYear, isoDaysDelta)); + // Ensure that we're in the first month. + updateCalendarFields(); + if (calendarFields.monthString !== '1') { + daysPastJan31 += 29; + updateCalendarFields(); } - // Now back up to near the start of the first month, but not too near that + // Now back up to near the start of the first month, but not so near that // off-by-one issues matter. - isoDaysDelta -= calendarDay - 5; - const result = {} as ChineseDraftMonthInfo; + daysPastJan31 -= calendarFields.day - 5; + + const monthList: ChineseDraftMonthInfo = {}; let monthIndex = 1; - let oldCalendarDay: number | undefined; - let oldMonthString: string | undefined; - let done = false; - do { - ({ calendarMonthString, calendarDay, calendarYearToVerify } = getCalendarDate(calendarYear, isoDaysDelta)); - if (oldCalendarDay) { - result[oldMonthString as string].daysInMonth = oldCalendarDay + 30 - calendarDay; - } - if (calendarYearToVerify !== calendarYear) { - done = true; - } else { - result[calendarMonthString] = { monthIndex: monthIndex++ }; - // Move to the next month. Because months are sometimes 29 days, the day of the - // calendar month will move forward slowly but not enough to flip over to a new - // month before the loop ends at 12-13 months. - isoDaysDelta += 30; + let old: { day: number; monthString: string } | undefined; + for (;;) { + const { day, monthString, relatedYear } = updateCalendarFields(); + if (old) { + monthList[old.monthString].daysInMonth = old.day + 30 - day; } - oldCalendarDay = calendarDay; - oldMonthString = calendarMonthString; - } while (!done); - result[oldMonthString].daysInMonth = oldCalendarDay + 30 - calendarDay; + old = { day, monthString }; + + if (relatedYear !== calendarYear) break; + + monthList[monthString] = { monthIndex: monthIndex++ }; + // Move to the next month. Because months are sometimes 29 days, the day of the + // calendar month will move forward slowly but not enough to flip over to a new + // month before the loop ends at 12-13 months. + daysPastJan31 += 30; + } + monthList[old.monthString].daysInMonth = old.day + 30 - calendarFields.day; - cache.set(key, result); - return result as ChineseMonthInfo; + cache.set(key, monthList); + return monthList as ChineseMonthInfo; } estimateIsoDate(calendarDate: CalendarYMD) { const { year, month } = calendarDate; diff --git a/lib/ecmascript.ts b/lib/ecmascript.ts index 472de58d..eebbd8b0 100644 --- a/lib/ecmascript.ts +++ b/lib/ecmascript.ts @@ -2658,7 +2658,7 @@ function FormatDateTimeUTCOffsetRounded(offsetNanosecondsParam: number): string return FormatOffsetTimeZoneIdentifier(offsetNanoseconds / 60e9); } -function GetUTCEpochMilliseconds({ +export function GetUTCEpochMilliseconds({ isoDate: { year, month, day }, time: { hour, minute, second, millisecond } }: { From 087cfc780a8ed8ee95fd8aaaa8530bae2d7a3352 Mon Sep 17 00:00:00 2001 From: Richard Gibson Date: Tue, 23 Sep 2025 00:55:57 -0400 Subject: [PATCH 09/30] Polyfill: Speed up GetUTCEpochMilliseconds Use `Date.UTC` when possible. UPSTREAM_COMMIT=f4d86e5f963d9b7936f3dcfc89351216f3dbe397 --- lib/calendar.ts | 2 +- lib/ecmascript.ts | 36 +++++++++++++++--------------------- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/lib/calendar.ts b/lib/calendar.ts index 6be8797d..42dfba85 100644 --- a/lib/calendar.ts +++ b/lib/calendar.ts @@ -2185,7 +2185,7 @@ abstract class ChineseBaseHelper extends HelperBase { const updateCalendarFields = () => { // Abuse GetUTCEpochMilliseconds for automatic rebalancing. const isoNumbers = { year: calendarYear, month: 2, day: daysPastJan31 }; - const ms = ES.GetUTCEpochMilliseconds({ isoDate: isoNumbers, time: midnightTimeRecord }); + const ms = ES.GetUTCEpochMilliseconds(isoNumbers, midnightTimeRecord); const fieldEntries = dateTimeFormat.formatToParts(ms); for (let i = 0; i < fieldEntries.length; i++) { const { type, value } = fieldEntries[i]; diff --git a/lib/ecmascript.ts b/lib/ecmascript.ts index eebbd8b0..0a60f9e1 100644 --- a/lib/ecmascript.ts +++ b/lib/ecmascript.ts @@ -2633,7 +2633,7 @@ function GetNamedTimeZoneOffsetNanosecondsImpl(id: string, epochMilliseconds: nu const { year, month, day, hour, minute, second } = GetFormatterParts(id, epochMilliseconds); let millisecond = epochMilliseconds % 1000; if (millisecond < 0) millisecond += 1000; - const utc = GetUTCEpochMilliseconds({ isoDate: { year, month, day }, time: { hour, minute, second, millisecond } }); + const utc = GetUTCEpochMilliseconds({ year, month, day }, { hour, minute, second, millisecond }); return (utc - epochMilliseconds) * 1e6; } @@ -2658,13 +2658,10 @@ function FormatDateTimeUTCOffsetRounded(offsetNanosecondsParam: number): string return FormatOffsetTimeZoneIdentifier(offsetNanoseconds / 60e9); } -export function GetUTCEpochMilliseconds({ - isoDate: { year, month, day }, - time: { hour, minute, second, millisecond } -}: { - isoDate: ISODate; - time: Omit; -}) { +export function GetUTCEpochMilliseconds(isoDate: ISODate, time: Omit) { + const { year, month, day } = isoDate; + const { hour, minute, second, millisecond /* ignored: microsecond, nanosecond */ } = time; + // The pattern of leap years in the ISO 8601 calendar repeats every 400 // years. To avoid overflowing at the edges of the range, we reduce the year // to the remainder after dividing by 400, and then add back all the @@ -2672,18 +2669,18 @@ export function GetUTCEpochMilliseconds({ const reducedYear = year % 400; const yearCycles = (year - reducedYear) / 400; - // Note: Date.UTC() interprets one and two-digit years as being in the - // 20th century, so don't use it - const legacyDate = new Date(); - legacyDate.setUTCHours(hour, minute, second, millisecond); - legacyDate.setUTCFullYear(reducedYear, month - 1, day); - const ms = legacyDate.getTime(); - return ms + MS_IN_400_YEAR_CYCLE * yearCycles; + // `Date.UTC(year, monthIndex, days)` maps year [0, 99] to [1900, 1999], so + // avoid that range. + const extraCycles = reducedYear >= 0 ? 5 : 0; + const ms = Date.UTC(reducedYear + 400 * extraCycles, month - 1, day, hour, minute, second, millisecond); + + return ms + MS_IN_400_YEAR_CYCLE * (yearCycles - extraCycles); } function GetUTCEpochNanoseconds(isoDateTime: ISODateTime) { - const ms = GetUTCEpochMilliseconds(isoDateTime); - const subMs = isoDateTime.time.microsecond * 1e3 + isoDateTime.time.nanosecond; + const { isoDate, time } = isoDateTime; + const ms = GetUTCEpochMilliseconds(isoDate, time); + const subMs = time.microsecond * 1e3 + time.nanosecond; return JSBI.add(epochMsToNs(ms), JSBI.BigInt(subMs)); } @@ -3437,10 +3434,7 @@ export function CombineDateAndTimeDuration(dateDuration: DateDuration, timeDurat // Caution: month is 0-based export function ISODateToEpochDays(year: number, month: number, day: number) { return ( - GetUTCEpochMilliseconds({ - isoDate: { year, month: month + 1, day }, - time: { hour: 0, minute: 0, second: 0, millisecond: 0 } - }) / DAY_MS + GetUTCEpochMilliseconds({ year, month: month + 1, day }, { hour: 0, minute: 0, second: 0, millisecond: 0 }) / DAY_MS ); } From 086890228b9f1f81edb35d5ad0052883d06094c5 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Fri, 5 Sep 2025 16:47:21 -0700 Subject: [PATCH 10/30] Polyfill: Make Gregorian calendar arithmetic match ISO8601 While working on exhaustive test scripts, I discovered a bug in the calendar code, which was a failure to apply the algorithm change in PR #2535 to the Gregorian calendar. Meanwhile I added test262 tests covering it (wrapping-at-end-of-month-gregorian.js). This change fixes that bug. This is just a bug in the reference code, not in the spec text. UPSTREAM_COMMIT=1f6d6df82ed711ad042ff853d85f2269f9da4ac5 --- lib/calendar.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/calendar.ts b/lib/calendar.ts index 42dfba85..2eba6e52 100644 --- a/lib/calendar.ts +++ b/lib/calendar.ts @@ -1162,7 +1162,7 @@ abstract class HelperBase { } // Now we have less than one year remaining. Add one month at a time // until we go over the target, then back up one month and calculate - // remaining days and weeks. + // remaining days. let current; let next: CalendarYMD = yearsAdded; do { @@ -1170,8 +1170,9 @@ abstract class HelperBase { current = next; next = this.addMonthsCalendar(current, sign, 'constrain', cache); if (next.day !== calendarOne.day) { - // In case the day was constrained down, try to un-constrain it - next = this.regulateDate({ ...next, day: calendarOne.day }, 'constrain', cache); + // In case the day was constrained down, un-constrain it (even if + // that's not a real date) + next = { ...next, day: calendarOne.day }; } } while (this.compareCalendarDates(calendarTwo, next) * sign >= 0); months -= sign; // correct for loop above which overshoots by 1 From 83660f367433f748612ddd513bc69a752ddc1f11 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Wed, 10 Sep 2025 09:03:43 -0700 Subject: [PATCH 11/30] Polyfill: Make more useful out-of-range error messages The existing error messages for PlainDate and PlainDateTime being outside of the representable range were not very helpful for debugging, because they did not show the value that was out of range. This change adds that information to the error messages. UPSTREAM_COMMIT=3414e61cb74dd108d21c543db7ed8554be2b82b4 --- lib/ecmascript.ts | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/lib/ecmascript.ts b/lib/ecmascript.ts index 0a60f9e1..61e0b71e 100644 --- a/lib/ecmascript.ts +++ b/lib/ecmascript.ts @@ -2402,16 +2402,20 @@ export function TemporalDurationToString( return result; } +function ISODateToString({ year, month, day }: ISODate) { + const yearString = ISOYearString(year); + const monthString = ISODateTimePartString(month); + const dayString = ISODateTimePartString(day); + return `${yearString}-${monthString}-${dayString}`; +} + export function TemporalDateToString( date: Temporal.PlainDate, showCalendar: Temporal.ShowCalendarOption['calendarName'] = 'auto' ) { - const { year, month, day } = GetSlot(date, ISO_DATE); - const yearString = ISOYearString(year); - const monthString = ISODateTimePartString(month); - const dayString = ISODateTimePartString(day); + const dateString = ISODateToString(GetSlot(date, ISO_DATE)); const calendar = FormatCalendarAnnotation(GetSlot(date, CALENDAR), showCalendar); - return `${yearString}-${monthString}-${dayString}${calendar}`; + return dateString + calendar; } export function TimeRecordToString( @@ -3167,7 +3171,12 @@ export function RejectISODate(year: number, month: number, day: number) { function RejectDateRange(isoDate: ISODate) { // Noon avoids trouble at edges of DateTime range (excludes midnight) - RejectDateTimeRange(CombineISODateAndTimeRecord(isoDate, NoonTimeRecord())); + const isoDateTime = CombineISODateAndTimeRecord(isoDate, NoonTimeRecord()); + const ns = GetUTCEpochNanoseconds(isoDateTime); + if (JSBI.lessThan(ns, DATETIME_NS_MIN) || JSBI.greaterThan(ns, DATETIME_NS_MAX)) { + const dateString = ISODateToString(isoDateTime.isoDate); + throw new RangeError(`${dateString} is outside of supported range`); + } } export function RejectTime( @@ -3204,10 +3213,8 @@ export function RejectDateTime( export function RejectDateTimeRange(isoDateTime: ISODateTime) { const ns = GetUTCEpochNanoseconds(isoDateTime); if (JSBI.lessThan(ns, DATETIME_NS_MIN) || JSBI.greaterThan(ns, DATETIME_NS_MAX)) { - // Because PlainDateTime's range is wider than Instant's range, the line - // below will always throw. Calling `ValidateEpochNanoseconds` avoids - // repeating the same error message twice. - ValidateEpochNanoseconds(ns); + const dateTimeString = ISODateTimeToString(isoDateTime, 'iso8601', undefined, 'never'); + throw new RangeError(`${dateTimeString} is outside of supported range`); } } @@ -3216,7 +3223,7 @@ function AssertISODateTimeWithinLimits(isoDateTime: ISODateTime) { const ns = GetUTCEpochNanoseconds(isoDateTime); assert( JSBI.greaterThanOrEqual(ns, DATETIME_NS_MIN) && JSBI.lessThanOrEqual(ns, DATETIME_NS_MAX), - `${ISODateTimeToString(isoDateTime, 'iso8601', 'auto')} is outside the representable range` + `${ISODateTimeToString(isoDateTime, 'iso8601', undefined, 'never')} is outside the representable range` ); } From 0b83e1099007d4ecec781f0ce15a0921d5c363c0 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Thu, 11 Sep 2025 12:48:46 -0700 Subject: [PATCH 12/30] Polyfill: Correctly handle limits in GetNamedTimeZoneEpochNanoseconds GetNamedTimeZoneEpochNanoseconds is called with a valid ISODateTime, which may end up being outside of the epoch nanoseconds limit because we call GetUTCEpochNanoseconds on it even though it originated in a non-UTC time zone. Handle this case and just clip the epoch nanoseconds to the supported range. This does not need a spec change, since this is purely a bug in the way we consume time zone data in the polyfill. UPSTREAM_COMMIT=00963438502fe7e6920f9c41c0e50330d616c089 --- lib/ecmascript.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/ecmascript.ts b/lib/ecmascript.ts index 61e0b71e..6831f206 100644 --- a/lib/ecmascript.ts +++ b/lib/ecmascript.ts @@ -2903,10 +2903,14 @@ function GetNamedTimeZoneEpochNanoseconds(id: string, isoDateTime: ISODateTime) // Get the offset of one day before and after the requested calendar date and // clock time, avoiding overflows if near the edge of the Instant range. let ns = GetUTCEpochNanoseconds(isoDateTime); + // Note, ns may be up to DAY_NANOS outside the NS_MIN...NS_MAX range here, + // even if isoDateTime represents a valid wall time in a non-UTC time zone. + // The TZDB does not describe any transitions that would occur in that extra + // day, so we just clip in order not to throw in GetFormatterParts. let nsEarlier = JSBI.subtract(ns, DAY_NANOS_JSBI); - if (JSBI.lessThan(nsEarlier, NS_MIN)) nsEarlier = ns; + if (JSBI.lessThan(nsEarlier, NS_MIN)) nsEarlier = NS_MIN; let nsLater = JSBI.add(ns, DAY_NANOS_JSBI); - if (JSBI.greaterThan(nsLater, NS_MAX)) nsLater = ns; + if (JSBI.greaterThan(nsLater, NS_MAX)) nsLater = NS_MAX; const earlierOffsetNs = GetNamedTimeZoneOffsetNanoseconds(id, nsEarlier); const laterOffsetNs = GetNamedTimeZoneOffsetNanoseconds(id, nsLater); From ad75f82a9cfc8143394ab30d4ec3ed70f4914603 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Tue, 23 Sep 2025 16:40:31 -0700 Subject: [PATCH 13/30] Update test262 UPSTREAM_COMMIT=0afbaf54e816e2ba76066a9e7340928e6cd127a0 --- test262 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test262 b/test262 index 04eaeb99..409001b6 160000 --- a/test262 +++ b/test262 @@ -1 +1 @@ -Subproject commit 04eaeb99080ceb60d7b86ea0c4bed6355ef4cdcb +Subproject commit 409001b61bd3e1546028d8b768a65ab96ac5928d From 68c113a0c8f2e2678d63ef6af9422d5c45ad643e Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Fri, 26 Sep 2025 10:57:56 -0700 Subject: [PATCH 14/30] Polyfill: Speed up months arithmetic in coptic, ethioaa, ethiopic, hebrew calendars We have an optimization for months arithmetic in calendars where a year is always 12 months. Extend this to cover calendars with years that are not 12-months but that do have a mathematically predictable X-months-in- Y-years cycle. The Coptic and Orthodox family of calendars always have 13 months per year. The Hebrew calendar always has 235 months per 19 years (the Metonic cycle). Closes: #3153 UPSTREAM_COMMIT=0c454308d7d323f21b41eac11ce01b62a5824f1a --- lib/calendar.ts | 53 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/lib/calendar.ts b/lib/calendar.ts index 2eba6e52..9d2c92e7 100644 --- a/lib/calendar.ts +++ b/lib/calendar.ts @@ -358,24 +358,38 @@ type CalendarYM = { year: number; month: number }; type CalendarYearOnly = { year: number }; type EraAndEraYear = { era: string; eraYear: number }; -const monthCodeInfo: Partial> = { +type CycleInfo = { + years: number; + months: number; +}; +type MonthCodeInfo = { + additionalMonths: string[]; + cycleInfo?: CycleInfo; +}; + +const monthCodeInfo: Partial> = { chinese: { additionalMonths: ['M01L', 'M02L', 'M03L', 'M04L', 'M05L', 'M06L', 'M07L', 'M08L', 'M09L', 'M10L', 'M11L', 'M12L'] }, coptic: { - additionalMonths: ['M13'] + additionalMonths: ['M13'], + cycleInfo: { years: 1, months: 13 } }, dangi: { additionalMonths: ['M01L', 'M02L', 'M03L', 'M04L', 'M05L', 'M06L', 'M07L', 'M08L', 'M09L', 'M10L', 'M11L', 'M12L'] }, ethioaa: { - additionalMonths: ['M13'] + additionalMonths: ['M13'], + cycleInfo: { years: 1, months: 13 } }, ethiopic: { - additionalMonths: ['M13'] + additionalMonths: ['M13'], + cycleInfo: { years: 1, months: 13 } }, hebrew: { - additionalMonths: ['M05L'] + additionalMonths: ['M05L'], + // Metonic cycle: 7 leap years every 19 years + cycleInfo: { years: 19, months: 19 * 12 + 7 } } }; @@ -1109,9 +1123,12 @@ abstract class HelperBase { let years = yearsParam; let months = monthsParam; const { year, day, monthCode } = calendarDate; - if (Math.abs(months) > 12 && !monthCodeInfo[this.id]?.additionalMonths) { - years += Math.trunc(months / 12); - months %= 12; + const monthInfo = monthCodeInfo[this.id]; + const cycleInfo = monthInfo ? monthInfo.cycleInfo : { years: 1, months: 12 }; + if (cycleInfo && Math.abs(months) > cycleInfo.months) { + const cycleCount = Math.trunc(months / cycleInfo.months); + years += cycleCount * cycleInfo.years; + months %= cycleInfo.months; } const addedYears = this.adjustCalendarDate({ year: year + years, monthCode, day }, cache); const addedMonths = this.addMonthsCalendar(addedYears, months, overflow, cache); @@ -1147,7 +1164,9 @@ abstract class HelperBase { } const diffYears = calendarTwo.year - calendarOne.year; const diffDays = calendarTwo.day - calendarOne.day; - if (diffYears && (largestUnit === 'year' || !monthCodeInfo[this.id]?.additionalMonths)) { + const monthInfo = monthCodeInfo[this.id]; + const cycleInfo = monthInfo ? monthInfo.cycleInfo : { years: 1, months: 12 }; + if (diffYears && (largestUnit === 'year' || cycleInfo)) { let diffInYearSign = 0; if (calendarTwo.monthCode > calendarOne.monthCode) diffInYearSign = 1; if (calendarTwo.monthCode < calendarOne.monthCode) diffInYearSign = -1; @@ -1155,16 +1174,22 @@ abstract class HelperBase { const isOneFurtherInYear = diffInYearSign * sign < 0; years = isOneFurtherInYear ? diffYears - sign : diffYears; } - const yearsAdded = years ? this.addCalendar(calendarOne, { years }, 'constrain', cache) : calendarOne; - if (years && largestUnit === 'month') { - months += years * 12; + // Try to skip ahead as many months as possible for this calendar + // without adding month by month in a loop + if (largestUnit === 'month') { + if (cycleInfo && Math.abs(years) >= cycleInfo.years) { + const cycleCount = Math.trunc(years / cycleInfo.years); + months = cycleCount * cycleInfo.months; + } years = 0; } - // Now we have less than one year remaining. Add one month at a time + const intermediate = + years || months ? this.addCalendar(calendarOne, { years, months }, 'constrain', cache) : calendarOne; + // Now we have less than one cycle remaining. Add one month at a time // until we go over the target, then back up one month and calculate // remaining days. let current; - let next: CalendarYMD = yearsAdded; + let next: CalendarYMD = intermediate; do { months += sign; current = next; From 7baae7ff300c35244cfbe401082c1d8042baec12 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Fri, 19 Sep 2025 10:37:56 -0700 Subject: [PATCH 15/30] Polyfill: Avoid RegExp in ParseMonthCode The RegExp was showing up on profiles. This is not the root cause of the slowness in #3153, but it noticeably helps. A RegExp is kind of overkill anyway, since the MonthCode grammar is very simple and the different elements occur at fixed indices in the string. Replacing the month code RegExp with very simple string-indexing code makes it go a lot faster. Adds a unit test suite for this file. See: #3153 Co-Authored-By: Richard Gibson UPSTREAM_COMMIT=a23f1cfdcc4fa735e3ecf323326238f5b48655cc --- lib/monthcode.ts | 21 ++++--- lib/regex.ts | 2 - test/monthcode.mjs | 138 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 9 deletions(-) create mode 100644 test/monthcode.mjs diff --git a/lib/monthcode.ts b/lib/monthcode.ts index 195f7237..71d403bf 100644 --- a/lib/monthcode.ts +++ b/lib/monthcode.ts @@ -1,14 +1,21 @@ import { ToPrimitiveRequireString } from './primitive'; -import { monthCode as MONTH_CODE_REGEX } from './regex'; + +const digitsForMonthNumber = Array.from({ length: 100 }, (_, i) => (i < 10 ? `0${i}` : `${i}`)); export function ParseMonthCode(argument: unknown) { const value = ToPrimitiveRequireString(argument); - const match = MONTH_CODE_REGEX.exec(value); - if (!match) throw new RangeError(`bad month code ${value}; must match M01-M99 or M00L-M99L`); - return { - monthNumber: +(match[1] ?? match[3] ?? match[5]), - isLeapMonth: (match[2] ?? match[4] ?? match[6]) === 'L' - }; + const digits = value.slice(1, 3); + const monthNumber = digits.length === 2 ? +digits : -1; // -1 ensures failure + const isLeapMonth = value.length === 4; + if ( + !(monthNumber >= 0) || + digits !== digitsForMonthNumber[monthNumber] || + value[0] !== 'M' || + (isLeapMonth ? value[3] !== 'L' : value.length !== 3 || monthNumber === 0) + ) { + throw new RangeError(`bad month code ${value}; must match M01-M99 or M00L-M99L`); + } + return { monthNumber, isLeapMonth }; } export function CreateMonthCode(monthNumber: number, isLeapMonth: boolean) { diff --git a/lib/regex.ts b/lib/regex.ts index 1267e3df..52c56927 100644 --- a/lib/regex.ts +++ b/lib/regex.ts @@ -57,5 +57,3 @@ const fraction = /(\d+)(?:[.,](\d{1,9}))?/; const durationDate = /(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?/; const durationTime = new RegExp(`(?:${fraction.source}H)?(?:${fraction.source}M)?(?:${fraction.source}S)?`); export const duration = new RegExp(`^([+-])?P${durationDate.source}(?:T(?!$)${durationTime.source})?$`, 'i'); - -export const monthCode = /^M(?:(00)(L)|(0[1-9])(L)?|([1-9][0-9])(L)?)$/; diff --git a/test/monthcode.mjs b/test/monthcode.mjs new file mode 100644 index 00000000..9fedbea5 --- /dev/null +++ b/test/monthcode.mjs @@ -0,0 +1,138 @@ +import { describe, expect, it } from '@jest/globals'; +import { CreateMonthCode, ParseMonthCode } from '../lib/monthcode'; + +// Avoid these wrappers in new tests: +function deepEqual(a, b) { + expect(a).toEqual(b); +} +function equal(a, b) { + expect(a).toBe(b); +} +function throws(f, cons) { + expect(f).toThrow(cons); +} + +function badMonthCode(code) { + throws(() => ParseMonthCode(code), RangeError, code); +} + +describe('ParseMonthCode', () => { + it('all Gregorian month codes', () => { + ['M01', 'M02', 'M03', 'M04', 'M05', 'M06', 'M07', 'M08', 'M09', 'M10', 'M11', 'M12'].forEach((code, ix) => { + deepEqual(ParseMonthCode(code), { monthNumber: ix + 1, isLeapMonth: false }); + }); + }); + it('Intercalary month 13', () => { + deepEqual(ParseMonthCode('M13'), { monthNumber: 13, isLeapMonth: false }); + }); + it('all Chinese leap month codes', () => { + ['M01L', 'M02L', 'M03L', 'M04L', 'M05L', 'M06L', 'M07L', 'M08L', 'M09L', 'M10L', 'M11L', 'M12L'].forEach( + (code, ix) => { + deepEqual(ParseMonthCode(code), { monthNumber: ix + 1, isLeapMonth: true }); + } + ); + }); + it('M00L (valid but does not occur in currently supported calendars)', () => { + deepEqual(ParseMonthCode('M00L'), { monthNumber: 0, isLeapMonth: true }); + }); + it('various other month codes that do not occur in currently supported calendars', () => { + const tests = [ + ['M14', 14, false], + ['M13L', 13, true], + ['M99', 99, false], + ['M99L', 99, true], + ['M42', 42, false], + ['M57L', 57, true] + ]; + for (const [code, monthNumber, isLeapMonth] of tests) { + deepEqual(ParseMonthCode(code), { monthNumber, isLeapMonth }); + } + }); + it('goes through ToPrimitive', () => { + ['toString', Symbol.toPrimitive].forEach((prop) => { + const convertibleObject = { + [prop]() { + return 'M01'; + } + }; + deepEqual(ParseMonthCode(convertibleObject), { monthNumber: 1, isLeapMonth: false }, prop); + }); + }); + it('no M00', () => badMonthCode('M00')); + it('missing leading zero', () => { + badMonthCode('M1'); + badMonthCode('M5L'); + }); + it('number too big', () => { + badMonthCode('M100'); + badMonthCode('M999L'); + }); + it('negative number', () => { + badMonthCode('M-3'); + badMonthCode('M-7L'); + }); + it('decimal point', () => { + badMonthCode('M2.'); + badMonthCode('M.9L'); + badMonthCode('M0.L'); + }); + it('no leading space', () => { + badMonthCode('M 5'); + badMonthCode('M 9L'); + }); + it('not a number', () => { + badMonthCode('M__'); + badMonthCode('MffL'); + }); + it('wrong leading character', () => { + badMonthCode('m11'); + badMonthCode('N11L'); + }); + it('missing leading character', () => { + badMonthCode('12'); + badMonthCode('03L'); + }); + it('wrong leap signifier', () => { + badMonthCode('M06l'); + badMonthCode('M06T'); + }); + it('junk at end of string', () => badMonthCode('M04L+')); + it('wrong primitive type', () => { + [true, 3, Symbol('M01'), 7n].forEach((wrongType) => { + throws(() => ParseMonthCode(wrongType), TypeError, typeof wrongType); + }); + }); + it('wrong toString', () => { + throws(() => ParseMonthCode({}), RangeError); + }); +}); + +describe('CreateMonthCode', () => { + it('all Gregorian month codes', () => { + ['M01', 'M02', 'M03', 'M04', 'M05', 'M06', 'M07', 'M08', 'M09', 'M10', 'M11', 'M12'].forEach((code, ix) => { + equal(CreateMonthCode(ix + 1, false), code); + }); + }); + it('Intercalary month 13', () => equal(CreateMonthCode(13, false), 'M13')); + it('all Chinese leap month codes', () => { + ['M01L', 'M02L', 'M03L', 'M04L', 'M05L', 'M06L', 'M07L', 'M08L', 'M09L', 'M10L', 'M11L', 'M12L'].forEach( + (code, ix) => { + equal(CreateMonthCode(ix + 1, true), code); + } + ); + }); + it('M00L (valid but does not occur in currently supported calendars)', () => equal(CreateMonthCode(0, true), 'M00L')); + it('various other month codes that do not occur in currently supported calendars', () => { + const tests = [ + [14, false, 'M14'], + [13, true, 'M13L'], + [99, false, 'M99'], + [99, true, 'M99L'], + [42, false, 'M42'], + [57, true, 'M57L'] + ]; + for (const [monthNumber, isLeapMonth, code] of tests) { + equal(CreateMonthCode(monthNumber, isLeapMonth), code); + } + }); +}); From dcafbed47d2b833904ed721db99a738faa2a0e6b Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Fri, 19 Sep 2025 11:10:09 -0700 Subject: [PATCH 16/30] Polyfill: Factor out code to generate cache keys I'm going to experiment if keys can be generated in a way that makes cache retrieval faster, so I will factor these out so that all the keys are generated in the same place. See: #3153 UPSTREAM_COMMIT=5da68a18ee23edb0fa9eb4b79edb27a8243ff404 --- lib/calendar.ts | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/lib/calendar.ts b/lib/calendar.ts index 9d2c92e7..ff3e15f4 100644 --- a/lib/calendar.ts +++ b/lib/calendar.ts @@ -568,6 +568,15 @@ class OneObjectCache { OneObjectCache.objectMap.set(obj, this); this.report(); } + static generateCalendarToISOKey(id: BuiltinCalendarId, { year, month, day }: CalendarYMD, overflow: Overflow) { + return JSON.stringify({ func: 'calendarToIsoDate', year, month, day, overflow, id }); + } + static generateISOToCalendarKey(id: BuiltinCalendarId, { year, month, day }: ISODate) { + return JSON.stringify({ func: 'isoToCalendarDate', year, month, day, id }); + } + static generateMonthListKey(id: BuiltinCalendarId, year: number) { + return JSON.stringify({ func: 'getMonthList', year, id }); + } static objectMap = new WeakMap(); static MAX_CACHE_ENTRIES = 1000; @@ -677,7 +686,7 @@ abstract class HelperBase { } isoToCalendarDate(isoDate: ISODate, cache: OneObjectCache): FullCalendarDate { const { year: isoYear, month: isoMonth, day: isoDay } = isoDate; - const key = JSON.stringify({ func: 'isoToCalendarDate', isoYear, isoMonth, isoDay, id: this.id }); + const key = OneObjectCache.generateISOToCalendarKey(this.id, isoDate); const cached = cache.get(key); if (cached) return cached; @@ -773,14 +782,7 @@ abstract class HelperBase { cache.set(key, calendarDate); // Also cache the reverse mapping const cacheReverse = (overflow: Overflow) => { - const keyReverse = JSON.stringify({ - func: 'calendarToIsoDate', - year: calendarDate.year, - month: calendarDate.month, - day: calendarDate.day, - overflow, - id: this.id - }); + const keyReverse = OneObjectCache.generateCalendarToISOKey(this.id, calendarDate, overflow); cache.set(keyReverse, isoDate); }; (['constrain', 'reject'] as const).forEach(cacheReverse); @@ -938,7 +940,7 @@ abstract class HelperBase { date = this.regulateMonthDayNaive(date, overflow, cache); const { year, month, day } = date; - const key = JSON.stringify({ func: 'calendarToIsoDate', year, month, day, overflow, id: this.id }); + const key = OneObjectCache.generateCalendarToISOKey(this.id, date, overflow); let cached = cache.get(key); if (cached) return cached; // If YMD are present in the input but the input has been constrained @@ -950,14 +952,7 @@ abstract class HelperBase { originalDate.day !== undefined && (originalDate.year !== date.year || originalDate.month !== date.month || originalDate.day !== date.day) ) { - keyOriginal = JSON.stringify({ - func: 'calendarToIsoDate', - year: originalDate.year, - month: originalDate.month, - day: originalDate.day, - overflow, - id: this.id - }); + keyOriginal = OneObjectCache.generateCalendarToISOKey(this.id, originalDate as CalendarYMD, overflow); cached = cache.get(keyOriginal); if (cached) return cached; } @@ -2194,7 +2189,7 @@ abstract class ChineseBaseHelper extends HelperBase { if (calendarYear === undefined) { throw new TypeError('Missing year'); } - const key = JSON.stringify({ func: 'getMonthList', calendarYear, id: this.id }); + const key = OneObjectCache.generateMonthListKey(this.id, calendarYear); const cached = cache.get(key); if (cached) return cached; From 91dd18c9fae12ec199caa7c70f1b333d6721dc5e Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Fri, 19 Sep 2025 12:10:48 -0700 Subject: [PATCH 17/30] Polyfill: Only cache for one calendar at a time Almost always when we populate an object with a copy of another object's cache, it is for calculations in the same calendar. So we can say that any particular object's cache is for only one calendar, and then we don't need to include the calendar ID in the lookup key. The only time when we copied the cache and it _wasn't_ for the same calendar, was after doing a withCalendar(). In this case it was useless to copy the cache anyway, because the entries from the old calendar would never be looked up anymore. So we change PlainDate and PlainDateTime's withCalendar methods to clone a fresh ISO Date Record object instead of reusing it. This provides an additional small speedup because it makes the keys shorter. Also fixes outdated comments about OneObjectCache; it associates caches with ISO Date Record objects, not Temporal objects. See: #3153 UPSTREAM_COMMIT=111381871a2a8388a5806148efac5c35b9b0197e --- lib/calendar.ts | 56 ++++++++++++++++++++++++-------------------- lib/plaindate.ts | 5 +++- lib/plaindatetime.ts | 8 ++++++- 3 files changed, 42 insertions(+), 27 deletions(-) diff --git a/lib/calendar.ts b/lib/calendar.ts index ff3e15f4..ac04f903 100644 --- a/lib/calendar.ts +++ b/lib/calendar.ts @@ -1,3 +1,4 @@ +import { assert } from './assert'; import * as ES from './ecmascript'; import { DefineIntrinsic } from './intrinsicclass'; import type { Temporal } from '..'; @@ -517,11 +518,13 @@ function CanonicalizeEraInCalendar(calendar: keyof typeof eraInfo, era: string) * This prototype implementation of non-ISO calendars makes many repeated calls * to Intl APIs which may be slow (e.g. >0.2ms). This trivial cache will speed * up these repeat accesses. Each cache instance is associated (via a WeakMap) - * to a specific Temporal object, which speeds up multiple calendar calls on the - * same Temporal object instance. No invalidation or pruning is necessary - * because each object's cache is thrown away when the object is GC-ed. + * to a specific ISO Date Record object, which speeds up multiple calendar calls + * on Temporal objects with the same ISO Date Record instance. No invalidation + * or pruning is necessary because each object's cache is thrown away when the + * object is GC-ed. */ class OneObjectCache { + id: BuiltinCalendarId; map = new Map(); calls = 0; // now = OneObjectCache.monotonicTimestamp(); @@ -532,8 +535,10 @@ class OneObjectCache { // return performance?.now() ?? Date.now(); // } - constructor(cacheToClone?: OneObjectCache) { + constructor(id: BuiltinCalendarId, cacheToClone?: OneObjectCache) { + this.id = id; if (cacheToClone !== undefined) { + assert(cacheToClone.id === this.id, 'should not clone cache from a different calendar'); let i = 0; for (const entry of cacheToClone.map.entries()) { if (++i > OneObjectCache.MAX_CACHE_ENTRIES) break; @@ -568,14 +573,14 @@ class OneObjectCache { OneObjectCache.objectMap.set(obj, this); this.report(); } - static generateCalendarToISOKey(id: BuiltinCalendarId, { year, month, day }: CalendarYMD, overflow: Overflow) { - return JSON.stringify({ func: 'calendarToIsoDate', year, month, day, overflow, id }); + static generateCalendarToISOKey({ year, month, day }: CalendarYMD, overflow: Overflow) { + return JSON.stringify({ func: 'calendarToIsoDate', year, month, day, overflow }); } - static generateISOToCalendarKey(id: BuiltinCalendarId, { year, month, day }: ISODate) { - return JSON.stringify({ func: 'isoToCalendarDate', year, month, day, id }); + static generateISOToCalendarKey({ year, month, day }: ISODate) { + return JSON.stringify({ func: 'isoToCalendarDate', year, month, day }); } - static generateMonthListKey(id: BuiltinCalendarId, year: number) { - return JSON.stringify({ func: 'getMonthList', year, id }); + static generateMonthListKey(year: number) { + return JSON.stringify({ func: 'getMonthList', year }); } static objectMap = new WeakMap(); @@ -585,12 +590,13 @@ class OneObjectCache { * Returns a WeakMap-backed cache that's used to store expensive results * that are associated with a particular Temporal object instance. * + * @param id - calendar ID for the cache * @param obj - object to associate with the cache */ - static getCacheForObject(obj: ISODate) { + static getCacheForObject(id: BuiltinCalendarId, obj: ISODate) { let cache = OneObjectCache.objectMap.get(obj); if (!cache) { - cache = new OneObjectCache(); + cache = new OneObjectCache(id); OneObjectCache.objectMap.set(obj, cache); } return cache; @@ -686,7 +692,7 @@ abstract class HelperBase { } isoToCalendarDate(isoDate: ISODate, cache: OneObjectCache): FullCalendarDate { const { year: isoYear, month: isoMonth, day: isoDay } = isoDate; - const key = OneObjectCache.generateISOToCalendarKey(this.id, isoDate); + const key = OneObjectCache.generateISOToCalendarKey(isoDate); const cached = cache.get(key); if (cached) return cached; @@ -782,7 +788,7 @@ abstract class HelperBase { cache.set(key, calendarDate); // Also cache the reverse mapping const cacheReverse = (overflow: Overflow) => { - const keyReverse = OneObjectCache.generateCalendarToISOKey(this.id, calendarDate, overflow); + const keyReverse = OneObjectCache.generateCalendarToISOKey(calendarDate, overflow); cache.set(keyReverse, isoDate); }; (['constrain', 'reject'] as const).forEach(cacheReverse); @@ -940,7 +946,7 @@ abstract class HelperBase { date = this.regulateMonthDayNaive(date, overflow, cache); const { year, month, day } = date; - const key = OneObjectCache.generateCalendarToISOKey(this.id, date, overflow); + const key = OneObjectCache.generateCalendarToISOKey(date, overflow); let cached = cache.get(key); if (cached) return cached; // If YMD are present in the input but the input has been constrained @@ -952,7 +958,7 @@ abstract class HelperBase { originalDate.day !== undefined && (originalDate.year !== date.year || originalDate.month !== date.month || originalDate.day !== date.day) ) { - keyOriginal = OneObjectCache.generateCalendarToISOKey(this.id, originalDate as CalendarYMD, overflow); + keyOriginal = OneObjectCache.generateCalendarToISOKey(originalDate as CalendarYMD, overflow); cached = cache.get(keyOriginal); if (cached) return cached; } @@ -2189,7 +2195,7 @@ abstract class ChineseBaseHelper extends HelperBase { if (calendarYear === undefined) { throw new TypeError('Missing year'); } - const key = OneObjectCache.generateMonthListKey(this.id, calendarYear); + const key = OneObjectCache.generateMonthListKey(calendarYear); const cached = cache.get(key); if (cached) return cached; @@ -2374,13 +2380,13 @@ class NonIsoCalendar implements CalendarImpl { } } dateToISO(fields: CalendarDateFields, overflow: Overflow) { - const cache = new OneObjectCache(); + const cache = new OneObjectCache(this.helper.id); const result = this.helper.calendarToIsoDate(fields, overflow, cache); cache.setObject(result); return result; } monthDayToISOReferenceDate(fields: MonthDayFromFieldsObject, overflow: Overflow) { - const cache = new OneObjectCache(); + const cache = new OneObjectCache(this.helper.id); const result = this.helper.monthDayFromFields(fields, overflow, cache); // result.year is a reference year where this month/day exists in this calendar cache.setObject(result); @@ -2432,20 +2438,20 @@ class NonIsoCalendar implements CalendarImpl { return arrayFromSet(result); } dateAdd(isoDate: ISODate, { years, months, weeks, days }: DateDuration, overflow: Overflow) { - const cache = OneObjectCache.getCacheForObject(isoDate); + const cache = OneObjectCache.getCacheForObject(this.helper.id, isoDate); const calendarDate = this.helper.isoToCalendarDate(isoDate, cache); const added = this.helper.addCalendar(calendarDate, { years, months, weeks, days }, overflow, cache); const isoAdded = this.helper.calendarToIsoDate(added, 'constrain', cache); // The new object's cache starts with the cache of the old object - if (!OneObjectCache.getCacheForObject(isoAdded)) { - const newCache = new OneObjectCache(cache); + if (!OneObjectCache.getCacheForObject(this.helper.id, isoAdded)) { + const newCache = new OneObjectCache(this.helper.id, cache); newCache.setObject(isoAdded); } return isoAdded; } dateUntil(one: ISODate, two: ISODate, largestUnit: Temporal.DateUnit) { - const cacheOne = OneObjectCache.getCacheForObject(one); - const cacheTwo = OneObjectCache.getCacheForObject(two); + const cacheOne = OneObjectCache.getCacheForObject(this.helper.id, one); + const cacheTwo = OneObjectCache.getCacheForObject(this.helper.id, two); const calendarOne = this.helper.isoToCalendarDate(one, cacheOne); const calendarTwo = this.helper.isoToCalendarDate(two, cacheTwo); const result = this.helper.untilCalendar(calendarOne, calendarTwo, largestUnit, cacheOne); @@ -2457,7 +2463,7 @@ class NonIsoCalendar implements CalendarImpl { [Field in keyof CalendarDateRecord]: Request extends { [K in Field]: true } ? CalendarDateRecord[Field] : never; } >(isoDate: ISODate, requestedFields: Request): T { - const cache = OneObjectCache.getCacheForObject(isoDate); + const cache = OneObjectCache.getCacheForObject(this.helper.id, isoDate); const calendarDate: Partial & FullCalendarDate = this.helper.isoToCalendarDate(isoDate, cache); if (requestedFields.dayOfWeek) { calendarDate.dayOfWeek = impl['iso8601'].isoToDate(isoDate, { dayOfWeek: true }).dayOfWeek; diff --git a/lib/plaindate.ts b/lib/plaindate.ts index 8946c536..e1311a49 100644 --- a/lib/plaindate.ts +++ b/lib/plaindate.ts @@ -94,7 +94,10 @@ export class PlainDate implements Temporal.PlainDate { withCalendar(calendarParam: Params['withCalendar'][0]): Return['withCalendar'] { ES.CheckReceiver(this, ES.IsTemporalDate); const calendar = ES.ToTemporalCalendarIdentifier(calendarParam); - return ES.CreateTemporalDate(GetSlot(this, ISO_DATE), calendar); + // Don't reuse the same ISODate object, as it should start with a fresh + // calendar cache + const { year, month, day } = GetSlot(this, ISO_DATE); + return ES.CreateTemporalDate({ year, month, day }, calendar); } add(temporalDurationLike: Params['add'][0], options: Params['add'][1] = undefined): Return['add'] { ES.CheckReceiver(this, ES.IsTemporalDate); diff --git a/lib/plaindatetime.ts b/lib/plaindatetime.ts index bdc22794..5439f74e 100644 --- a/lib/plaindatetime.ts +++ b/lib/plaindatetime.ts @@ -140,7 +140,13 @@ export class PlainDateTime implements Temporal.PlainDateTime { withCalendar(calendarParam: Params['withCalendar'][0]): Return['withCalendar'] { ES.CheckReceiver(this, ES.IsTemporalDateTime); const calendar = ES.ToTemporalCalendarIdentifier(calendarParam); - return ES.CreateTemporalDateTime(GetSlot(this, ISO_DATE_TIME), calendar); + // Don't reuse the same ISODate object, as it should start with a fresh + // calendar cache + const { + isoDate: { year, month, day }, + time + } = GetSlot(this, ISO_DATE_TIME); + return ES.CreateTemporalDateTime(ES.CombineISODateAndTimeRecord({ year, month, day }, time), calendar); } add(temporalDurationLike: Params['add'][0], options: Params['add'][1] = undefined): Return['add'] { ES.CheckReceiver(this, ES.IsTemporalDateTime); From f229b263632062be83df5fc7bf4c624b167deaad Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Fri, 19 Sep 2025 11:18:06 -0700 Subject: [PATCH 18/30] Polyfill: Use int32 cache keys I'm not entirely sure how V8's Map.prototype.get algorithm works internally but using int32 keys seems to make the maps work much, much faster. Luckily, we can pack all the information for any cache key into 31 bits, now that we don't include the calendar ID in the key. See: #3153 UPSTREAM_COMMIT=ed9952802293922fe9b36e2204557bf61c7dd890 --- lib/calendar.ts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/lib/calendar.ts b/lib/calendar.ts index ac04f903..55a0a77e 100644 --- a/lib/calendar.ts +++ b/lib/calendar.ts @@ -546,7 +546,7 @@ class OneObjectCache { } } } - get(key: string) { + get(key: number) { const result = this.map.get(key); if (result) { this.hits++; @@ -555,7 +555,7 @@ class OneObjectCache { this.calls++; return result; } - set(key: string, value: unknown) { + set(key: number, value: unknown) { this.map.set(key, value); this.misses++; this.report(); @@ -573,14 +573,33 @@ class OneObjectCache { OneObjectCache.objectMap.set(obj, this); this.report(); } + // Cache keys are int32 + // int32 msb fedcba9876543210fedcba9876543210 lsb + // uyyyyyyyyyyyyyyyyyyyymmmmdddddff + // u = unused (1 bit) + // y = year + 280804 (20 bits; max is 564387) + // m = month (4 bits; max is 13) + // d = day (5 bits; max is 31) + // f = flags (indicates type of key, and overflow for calendar-to-ISO type) + // 00 = Chinese/Dangi month list + // 01 = ISO-to-calendar + // 10 = calendar-to-ISO, overflow constrain + // 11 = calendar-to-ISO, overflow reject + static privKey(year: number, month: number, day: number, flags: number) { + // -280804 is the earliest year number in any supported calendar (in this + // case, Hijri calendars) + const unsignedYear = year + 280804; + return (unsignedYear << 11) | (month << 7) | (day << 2) | flags; + } static generateCalendarToISOKey({ year, month, day }: CalendarYMD, overflow: Overflow) { - return JSON.stringify({ func: 'calendarToIsoDate', year, month, day, overflow }); + const flags = overflow === 'constrain' ? 0b10 : 0b11; + return this.privKey(year, month, day, flags); } static generateISOToCalendarKey({ year, month, day }: ISODate) { - return JSON.stringify({ func: 'isoToCalendarDate', year, month, day }); + return this.privKey(year, month, day, 1); } static generateMonthListKey(year: number) { - return JSON.stringify({ func: 'getMonthList', year }); + return this.privKey(year, 0, 0, 0); } static objectMap = new WeakMap(); From e3f2e1cdd720d5c32cfde1757238740397894266 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Fri, 19 Sep 2025 17:41:29 -0700 Subject: [PATCH 19/30] Polyfill: Pull eraFromYear() outside of completeEraYear() Un-nesting this function makes the code easier to read, while also avoiding some iterations through calendar date properties. See: #3153 UPSTREAM_COMMIT=4f68cbf71ce05dc7ace108d60b7eeb8b0c73be52 --- lib/calendar.ts | 81 +++++++++++++++++++++++++------------------------ 1 file changed, 41 insertions(+), 40 deletions(-) diff --git a/lib/calendar.ts b/lib/calendar.ts index 55a0a77e..9e171322 100644 --- a/lib/calendar.ts +++ b/lib/calendar.ts @@ -834,51 +834,51 @@ abstract class HelperBase { } } } + /** Private helper function */ + eraFromYear(calendarDate: Partial & { year: number }) { + const { year } = calendarDate; + let eraYear; + const ix = this.eras.findIndex((eParam, i) => { + let e = eParam; + if (i === this.eras.length - 1) { + if (e.skip) { + // This last era is only present for legacy ICU data. Treat the + // previous era as the last era. + e = this.eras[i - 1]; + } + if (e.reverseOf) { + // This is a reverse-sign era (like BCE) which must be the oldest + // era. Count years backwards. + if (year > 0) throw new RangeError(`Signed year ${year} is invalid for era ${e.code}`); + eraYear = e.anchorEpoch.year - year; + return true; + } + // last era always gets all "leftover" (older than epoch) years, + // so no need for a comparison like below. + eraYear = year - e.anchorEpoch.year + (e.hasYearZero ? 0 : 1); + return true; + } + // FIXME: This cast may not be correct. I think month and day are always + // present when we get here, but the type system does not prove it + const comparison = this.compareCalendarDates(calendarDate as CalendarYMD, e.anchorEpoch); + if (comparison >= 0) { + eraYear = year - e.anchorEpoch.year + (e.hasYearZero ? 0 : 1); + return true; + } + return false; + }); + if (ix === -1) throw new RangeError(`Year ${year} was not matched by any era`); + let matchingEra = this.eras[ix]; + if (matchingEra.skip) matchingEra = this.eras[ix - 1]; + return { eraYear, era: matchingEra.code }; + } /** Fill in missing parts of the (year, era, eraYear) tuple */ completeEraYear>( calendarDate: T ): T & Required> { - const eraFromYear = (year: number) => { - let eraYear; - const adjustedCalendarDate = { ...calendarDate, year }; - const ix = this.eras.findIndex((eParam, i) => { - let e = eParam; - if (i === this.eras.length - 1) { - if (e.skip) { - // This last era is only present for legacy ICU data. Treat the - // previous era as the last era. - e = this.eras[i - 1]; - } - if (e.reverseOf) { - // This is a reverse-sign era (like BCE) which must be the oldest - // era. Count years backwards. - if (year > 0) throw new RangeError(`Signed year ${year} is invalid for era ${e.code}`); - eraYear = e.anchorEpoch.year - year; - return true; - } - // last era always gets all "leftover" (older than epoch) years, - // so no need for a comparison like below. - eraYear = year - e.anchorEpoch.year + (e.hasYearZero ? 0 : 1); - return true; - } - // FIXME: This cast may not be correct. I think month and day are always - // present when we get here, but the type system does not prove it - const comparison = this.compareCalendarDates(adjustedCalendarDate as CalendarYMD, e.anchorEpoch); - if (comparison >= 0) { - eraYear = year - e.anchorEpoch.year + (e.hasYearZero ? 0 : 1); - return true; - } - return false; - }); - if (ix === -1) throw new RangeError(`Year ${year} was not matched by any era`); - let matchingEra = this.eras[ix]; - if (matchingEra.skip) matchingEra = this.eras[ix - 1]; - return { eraYear, era: matchingEra.code }; - }; - let { year, eraYear, era } = calendarDate; if (year !== undefined) { - const matchData = eraFromYear(year); + const matchData = this.eraFromYear(calendarDate as typeof calendarDate & { year: number }); ({ eraYear, era } = matchData); if (calendarDate.era !== undefined && CanonicalizeEraInCalendar(this.id, calendarDate.era) !== era) { throw new RangeError(`Input era ${calendarDate.era} doesn't match calculated value ${era}`); @@ -903,7 +903,8 @@ abstract class HelperBase { // the era or after its end as long as it's in the same year. If that // happens, we'll adjust the era/eraYear pair to be the correct era for // the `year`. - ({ eraYear, era } = eraFromYear(year)); + const adjustedCalendarDate = { year, month: calendarDate.month, day: calendarDate.day }; + ({ eraYear, era } = this.eraFromYear(adjustedCalendarDate)); } // validateCalendarDate already ensured that either year or era+eraYear are // present From de8cb7ac0fed00eb7b1cd4941a89b3bd2ee39092 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Fri, 19 Sep 2025 17:48:07 -0700 Subject: [PATCH 20/30] Polyfill: Change format of eraInfo table Object.entries is showing up as very hot on profiles of completeEraYear. It seems we are calling Object.entries on this table repeatedly, so may as well just store it in the entries format in the first place. See: #3153 UPSTREAM_COMMIT=84453d0e544886f69bcd4c82f90287f071ff2ec2 --- lib/calendar.ts | 67 ++++++++++++++++++++++++------------------------- 1 file changed, 33 insertions(+), 34 deletions(-) diff --git a/lib/calendar.ts b/lib/calendar.ts index 9e171322..43591c08 100644 --- a/lib/calendar.ts +++ b/lib/calendar.ts @@ -443,43 +443,43 @@ function weekNumber(firstDayOfWeek: number, minimalDaysInFirstWeek: number, desi return weekNo; } -const eraInfo: Partial>> = { - buddhist: { +const eraInfoEntries: Partial> = { + buddhist: Object.entries({ be: {} - }, - coptic: { + }), + coptic: Object.entries({ am: {} - }, - ethioaa: { + }), + ethioaa: Object.entries({ aa: { aliases: ['mundi'] } - }, - ethiopic: { + }), + ethiopic: Object.entries({ am: { aliases: ['incar'] }, aa: { aliases: ['mundi'] } - }, - gregory: { + }), + gregory: Object.entries({ ce: { aliases: ['ad'] }, bce: { aliases: ['bc'] } - }, - hebrew: { + }), + hebrew: Object.entries({ am: {} - }, - indian: { + }), + indian: Object.entries({ shaka: {} - }, - 'islamic-civil': { + }), + 'islamic-civil': Object.entries({ ah: {}, bh: {} - }, - 'islamic-tbla': { + }), + 'islamic-tbla': Object.entries({ ah: {}, bh: {} - }, - 'islamic-umalqura': { + }), + 'islamic-umalqura': Object.entries({ ah: {}, bh: {} - }, - japanese: { + }), + japanese: Object.entries({ reiwa: {}, heisei: {}, showa: {}, @@ -487,28 +487,27 @@ const eraInfo: Partial Date: Tue, 23 Sep 2025 11:20:00 -0700 Subject: [PATCH 21/30] Polyfill: Change format of Hebrew months table As in the previous commit, Object.entries is showing up as very hot on profiles when performing date arithmetic in the Hebrew calendar. Turns out, the month information is actually used for two separate lookups: one by long name returned from Intl, and one by month code. We can separate the table into two tables, one for each lookup. We also make the min and max month length a 2-element array, so that we can avoid the property lookup in favour of an array element lookup. See: #3153 UPSTREAM_COMMIT=8929f859edbb4bde7178883a2e46513a0e3ee239 --- lib/calendar.ts | 71 +++++++++++++++++++++++++++---------------------- 1 file changed, 39 insertions(+), 32 deletions(-) diff --git a/lib/calendar.ts b/lib/calendar.ts index 43591c08..86eab5b7 100644 --- a/lib/calendar.ts +++ b/lib/calendar.ts @@ -1368,7 +1368,7 @@ abstract class HelperBase { } interface HebrewMonthInfo { - [m: string]: ( + [m: string]: | { leap: undefined; regular: number; @@ -1380,17 +1380,9 @@ interface HebrewMonthInfo { | { leap: number; regular: number; - } - ) & { - monthCode: string; - days: - | number - | { - min: number; - max: number; - }; - }; + }; } +type HebrewMonthCodeInfo = Record; class HebrewHelper extends HelperBase { constructor() { @@ -1411,21 +1403,21 @@ class HebrewHelper extends HelperBase { return this.inLeapYear(calendarDate) ? 13 : 12; } minimumMonthLength(calendarDate: CalendarYM) { - return this.minMaxMonthLength(calendarDate, 'min'); + return this.minMaxMonthLength(calendarDate, 0); } maximumMonthLength(calendarDate: CalendarYM) { - return this.minMaxMonthLength(calendarDate, 'max'); + return this.minMaxMonthLength(calendarDate, 1); } - minMaxMonthLength(calendarDate: CalendarYM, minOrMax: 'min' | 'max') { + minMaxMonthLength(calendarDate: CalendarYM, minOrMax: 0 | 1) { const { month, year } = calendarDate; const monthCode = this.getMonthCode(year, month); - const monthInfo = Object.entries(this.months).find((m) => m[1].monthCode === monthCode); - if (monthInfo === undefined) throw new RangeError(`unmatched Hebrew month: ${month}`); - const daysInMonth = monthInfo[1].days; + const daysInMonth = this.monthLengths[monthCode]; + if (daysInMonth === undefined) throw new RangeError(`unmatched Hebrew month: ${month}`); return typeof daysInMonth === 'number' ? daysInMonth : daysInMonth[minOrMax]; } maxLengthOfMonthCodeInAnyYear(monthCode: string) { - return ['M04', 'M06', 'M08', 'M10', 'M12'].includes(monthCode) ? 29 : 30; + const daysInMonth = this.monthLengths[monthCode]; + return typeof daysInMonth === 'number' ? daysInMonth : daysInMonth[1]; } /** Take a guess at what ISO date a particular calendar date corresponds to */ estimateIsoDate(calendarDate: CalendarYMD) { @@ -1433,20 +1425,35 @@ class HebrewHelper extends HelperBase { return { year: year - 3760, month: 1, day: 1 }; } months: HebrewMonthInfo = { - Tishri: { leap: 1, regular: 1, monthCode: 'M01', days: 30 }, - Heshvan: { leap: 2, regular: 2, monthCode: 'M02', days: { min: 29, max: 30 } }, - Kislev: { leap: 3, regular: 3, monthCode: 'M03', days: { min: 29, max: 30 } }, - Tevet: { leap: 4, regular: 4, monthCode: 'M04', days: 29 }, - Shevat: { leap: 5, regular: 5, monthCode: 'M05', days: 30 }, - Adar: { leap: undefined, regular: 6, monthCode: 'M06', days: 29 }, - 'Adar I': { leap: 6, regular: undefined, monthCode: 'M05L', days: 30 }, - 'Adar II': { leap: 7, regular: undefined, monthCode: 'M06', days: 29 }, - Nisan: { leap: 8, regular: 7, monthCode: 'M07', days: 30 }, - Iyar: { leap: 9, regular: 8, monthCode: 'M08', days: 29 }, - Sivan: { leap: 10, regular: 9, monthCode: 'M09', days: 30 }, - Tamuz: { leap: 11, regular: 10, monthCode: 'M10', days: 29 }, - Av: { leap: 12, regular: 11, monthCode: 'M11', days: 30 }, - Elul: { leap: 13, regular: 12, monthCode: 'M12', days: 29 } + Tishri: { leap: 1, regular: 1 }, + Heshvan: { leap: 2, regular: 2 }, + Kislev: { leap: 3, regular: 3 }, + Tevet: { leap: 4, regular: 4 }, + Shevat: { leap: 5, regular: 5 }, + Adar: { leap: undefined, regular: 6 }, + 'Adar I': { leap: 6, regular: undefined }, + 'Adar II': { leap: 7, regular: undefined }, + Nisan: { leap: 8, regular: 7 }, + Iyar: { leap: 9, regular: 8 }, + Sivan: { leap: 10, regular: 9 }, + Tamuz: { leap: 11, regular: 10 }, + Av: { leap: 12, regular: 11 }, + Elul: { leap: 13, regular: 12 } + }; + monthLengths: HebrewMonthCodeInfo = { + M01: 30, + M02: [29, 30], + M03: [29, 30], + M04: 29, + M05: 30, + M05L: 30, + M06: 29, + M07: 30, + M08: 29, + M09: 30, + M10: 29, + M11: 30, + M12: 29 }; getMonthCode(year: number, month: number) { if (this.inLeapYear({ year })) { From ceb75c6bf518c0d1a44c6043f3547261129697c9 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Tue, 23 Sep 2025 11:27:35 -0700 Subject: [PATCH 22/30] Polyfill: Avoid computing Hebrew month code if we already have it Sometimes when we reach this function, the month code is already present on the calendarDate parameter. It's a small speedup to just use it if it's there. (Not a large speedup, because calculating it doesn't involve a DateTimeFormat operation.) See: #3153 UPSTREAM_COMMIT=c8fa9d43dea225a9eced7e62c0d94f28c5a639ae --- lib/calendar.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/calendar.ts b/lib/calendar.ts index 86eab5b7..72074dd8 100644 --- a/lib/calendar.ts +++ b/lib/calendar.ts @@ -1408,9 +1408,9 @@ class HebrewHelper extends HelperBase { maximumMonthLength(calendarDate: CalendarYM) { return this.minMaxMonthLength(calendarDate, 1); } - minMaxMonthLength(calendarDate: CalendarYM, minOrMax: 0 | 1) { + minMaxMonthLength(calendarDate: CalendarYM & { monthCode?: string }, minOrMax: 0 | 1) { const { month, year } = calendarDate; - const monthCode = this.getMonthCode(year, month); + const monthCode = calendarDate.monthCode ?? this.getMonthCode(year, month); const daysInMonth = this.monthLengths[monthCode]; if (daysInMonth === undefined) throw new RangeError(`unmatched Hebrew month: ${month}`); return typeof daysInMonth === 'number' ? daysInMonth : daysInMonth[minOrMax]; From 7d447cc60ef2cb66e9474f1fb751db941d9d3f7c Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Tue, 23 Sep 2025 12:42:51 -0700 Subject: [PATCH 23/30] Polyfill: Don't swallow non-RangeErrors from formatToParts() I found out that outside a certain date range, DateTimeFormat's methods throw a TypeError when using the Chinese calendar. We don't want to swallow this error, it is confusing to replace it with "Invalid ISO date" when the ISO date is clearly valid. UPSTREAM_COMMIT=b6592ec7791e4933ba48c5a22dbac0e18f580188 --- lib/calendar.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/calendar.ts b/lib/calendar.ts index 72074dd8..bd6c01d7 100644 --- a/lib/calendar.ts +++ b/lib/calendar.ts @@ -705,7 +705,8 @@ abstract class HelperBase { try { return dateTimeFormat.formatToParts(legacyDate); } catch (e) { - throw new RangeError(`Invalid ISO date: ${isoString}`); + if (e instanceof RangeError) throw new RangeError(`Invalid ISO date: ${isoString}`); + throw e; } } isoToCalendarDate(isoDate: ISODate, cache: OneObjectCache): FullCalendarDate { From e96c3dedab512bb6ae5a3a951d2a993f23293cfd Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Wed, 24 Sep 2025 10:20:47 -0700 Subject: [PATCH 24/30] Polyfill: Use cached daysInMonth information in Chinese calendar We computed and cached the number of days in each month in the Chinese calendar, but never used that information. Overriding daysInMonth and daysInPreviousMonth in the Chinese calendar helper to look up the cached month lengths cuts the time of a slow date difference almost in _half_. However, snapshot testing revealed that in some cases we cached incorrect daysInMonth information, which went unnoticed because we didn't use it until now. Fixing that requires removing the post-loop setting of `monthList[oldMonthString]` (that's wrong, because it's month 1 from the following calendar year.) See: #3153 UPSTREAM_COMMIT=a37fa88ff07d979fa3f77d8cf0c7d967bae321c2 --- lib/calendar.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/calendar.ts b/lib/calendar.ts index bd6c01d7..42d20791 100644 --- a/lib/calendar.ts +++ b/lib/calendar.ts @@ -2185,6 +2185,24 @@ abstract class ChineseBaseHelper extends HelperBase { monthsInYear(calendarDate: CalendarYearOnly, cache: OneObjectCache) { return this.inLeapYear(calendarDate, cache) ? 13 : 12; } + override daysInMonth(calendarDate: CalendarYM, cache: OneObjectCache) { + const { month, year } = calendarDate; + const monthEntries = Object.entries(this.getMonthList(calendarDate.year, cache)); + const matchingMonthEntry = monthEntries.find((entry) => entry[1].monthIndex === month); + if (matchingMonthEntry === undefined) { + throw new RangeError(`Invalid month ${month} in Chinese year ${year}`); + } + return matchingMonthEntry[1].daysInMonth; + } + override daysInPreviousMonth(calendarDate: CalendarYM, cache: OneObjectCache) { + const { month, year } = calendarDate; + + const previousMonthYear = month > 1 ? year : year - 1; + const previousMonthDate = { year: previousMonthYear, month, day: 1 }; + const previousMonth = month > 1 ? month - 1 : this.monthsInYear(previousMonthDate, cache); + + return this.daysInMonth({ year: previousMonthYear, month: previousMonth }, cache); + } minimumMonthLength(/* calendarDate */) { return 29; } @@ -2290,7 +2308,6 @@ abstract class ChineseBaseHelper extends HelperBase { // month before the loop ends at 12-13 months. daysPastJan31 += 30; } - monthList[old.monthString].daysInMonth = old.day + 30 - calendarFields.day; cache.set(key, monthList); return monthList as ChineseMonthInfo; From 628442750a92a649af6bdfc102d1376dd3d17201 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Wed, 24 Sep 2025 12:54:57 -0700 Subject: [PATCH 25/30] Polyfill: Cache monthcode-month mapping both ways in Chinese calendar Previously we cached the Chinese calendar's month list as an object indexed by Intl.DateTimeFormat month strings (e.g. "12", "4bis"), and providing the corresponding ordinal month and days in the month. Looking at what info we actually use: We use it to calculate the number of months in the year, which is already computed in getMonthList, so just add a monthsInYear property to the month list object. We lookup months by ordinal month, month code, and DTF month string. For month code lookups, we had to convert to DTF month string to index the month list object, and ordinal month lookups were expensive because we had to search the month list object's entries. So instead, cache the mappings both ways. We can look up month code by ordinal month and ordinal month by month code. Looking up by DTF month string is less common (the fromLegacyDate case) and it's trivial to convert a DTF month string to a month code. The daysInMonth are only looked up by ordinal month, so only put those in the ordinal month entries. This provides a modest speedup of arithmetic in the Chinese calendar. See: #3153 UPSTREAM_COMMIT=1707140adf27f4bbce91f573b07c894521036e54 --- lib/calendar.ts | 85 +++++++++++++++++++++++-------------------------- 1 file changed, 40 insertions(+), 45 deletions(-) diff --git a/lib/calendar.ts b/lib/calendar.ts index 42d20791..247fc02f 100644 --- a/lib/calendar.ts +++ b/lib/calendar.ts @@ -2165,12 +2165,16 @@ class JapaneseHelper extends SameMonthDayAsGregorianBaseHelper { } } -interface ChineseMonthInfo { - [key: string]: { monthIndex: number; daysInMonth: number }; -} -interface ChineseDraftMonthInfo { - [key: string]: { monthIndex: number; daysInMonth?: number }; -} +type ChineseMonthInfo = { monthCode: string; daysInMonth: number }[] & { + monthsInYear: number; +} & { + [key: string]: number; +}; +type ChineseDraftMonthInfo = { monthCode: string; daysInMonth?: number }[] & { + monthsInYear?: number; +} & { + [key: string]: number; +}; abstract class ChineseBaseHelper extends HelperBase { constructor() { @@ -2179,20 +2183,18 @@ abstract class ChineseBaseHelper extends HelperBase { abstract override id: BuiltinCalendarId; calendarType = 'lunisolar' as const; inLeapYear(calendarDate: CalendarYearOnly, cache: OneObjectCache) { - const months = this.getMonthList(calendarDate.year, cache); - return Object.entries(months).length === 13; + return this.getMonthList(calendarDate.year, cache).monthsInYear === 13; } monthsInYear(calendarDate: CalendarYearOnly, cache: OneObjectCache) { - return this.inLeapYear(calendarDate, cache) ? 13 : 12; + return this.getMonthList(calendarDate.year, cache).monthsInYear; } override daysInMonth(calendarDate: CalendarYM, cache: OneObjectCache) { const { month, year } = calendarDate; - const monthEntries = Object.entries(this.getMonthList(calendarDate.year, cache)); - const matchingMonthEntry = monthEntries.find((entry) => entry[1].monthIndex === month); + const matchingMonthEntry = this.getMonthList(year, cache)[month]; if (matchingMonthEntry === undefined) { throw new RangeError(`Invalid month ${month} in Chinese year ${year}`); } - return matchingMonthEntry[1].daysInMonth; + return matchingMonthEntry.daysInMonth; } override daysInPreviousMonth(calendarDate: CalendarYM, cache: OneObjectCache) { const { month, year } = calendarDate; @@ -2290,24 +2292,29 @@ abstract class ChineseBaseHelper extends HelperBase { // off-by-one issues matter. daysPastJan31 -= calendarFields.day - 5; - const monthList: ChineseDraftMonthInfo = {}; + const monthList = [] as unknown as ChineseDraftMonthInfo; let monthIndex = 1; - let old: { day: number; monthString: string } | undefined; + let oldDay: number | undefined; for (;;) { const { day, monthString, relatedYear } = updateCalendarFields(); - if (old) { - monthList[old.monthString].daysInMonth = old.day + 30 - day; + const isLeapMonth = monthString.endsWith('bis'); + const monthCode = CreateMonthCode(+(isLeapMonth ? monthString.slice(0, -3) : monthString), isLeapMonth); + if (oldDay) { + monthList[monthIndex - 1].daysInMonth = oldDay + 30 - day; } - old = { day, monthString }; + oldDay = day; if (relatedYear !== calendarYear) break; - monthList[monthString] = { monthIndex: monthIndex++ }; + monthList[monthIndex] = { monthCode }; + monthList[monthCode] = monthIndex++; + // Move to the next month. Because months are sometimes 29 days, the day of the // calendar month will move forward slowly but not enough to flip over to a new // month before the loop ends at 12-13 months. daysPastJan31 += 30; } + monthList.monthsInYear = monthIndex - 1; // subtract 1, it was incremented after the loop cache.set(key, monthList); return monthList as ChineseMonthInfo; @@ -2330,11 +2337,11 @@ abstract class ChineseBaseHelper extends HelperBase { // month. Below we'll normalize the output. if (monthExtra && monthExtra !== 'bis') throw new RangeError(`Unexpected leap month suffix: ${monthExtra}`); const monthCode = CreateMonthCode(month as number, monthExtra !== undefined); - const monthString = `${month}${monthExtra || ''}`; const months = this.getMonthList(year, cache); - const monthInfo = months[monthString]; - if (monthInfo === undefined) throw new RangeError(`Unmatched month ${monthString} in Chinese year ${year}`); - month = monthInfo.monthIndex; + month = months[monthCode]; + if (month === undefined) { + throw new RangeError(`Unmatched month ${month}${monthExtra || ''} in Chinese year ${year}`); + } return { year, month, day: day as number, monthCode }; } else { // When called without input coming from legacy Date output, @@ -2344,26 +2351,20 @@ abstract class ChineseBaseHelper extends HelperBase { ES.assertExists(monthCode); const months = this.getMonthList(year, cache); const { monthNumber, isLeapMonth } = ParseMonthCode(monthCode); - const numberPart = `${monthNumber}${isLeapMonth ? 'bis' : ''}`; - let monthInfo = months[numberPart]; - month = monthInfo && monthInfo.monthIndex; - + month = months[monthCode]; // If this leap month isn't present in this year, constrain to the same // day of the previous month. - if (month === undefined && isLeapMonth && monthNumber !== 13 && overflow === 'constrain') { - monthInfo = months[monthNumber]; - if (monthInfo) { - month = monthInfo.monthIndex; - monthCode = CreateMonthCode(monthNumber, false); - } + if (month === undefined && isLeapMonth && overflow === 'constrain') { + const adjustedMonthCode = CreateMonthCode(monthNumber, false); + month = months[adjustedMonthCode]; + monthCode = adjustedMonthCode; } if (month === undefined) { throw new RangeError(`Unmatched month ${monthCode} in Chinese year ${year}`); } } else if (monthCode === undefined) { const months = this.getMonthList(year, cache); - const monthEntries = Object.entries(months); - const largestMonth = monthEntries.length; + const largestMonth = months.monthsInYear; if (overflow === 'reject') { ES.RejectToRange(month, 1, largestMonth); ES.RejectToRange(day as number, 1, this.maximumMonthLength()); @@ -2371,22 +2372,16 @@ abstract class ChineseBaseHelper extends HelperBase { month = ES.ConstrainToRange(month, 1, largestMonth); day = ES.ConstrainToRange(day, 1, this.maximumMonthLength()); } - const matchingMonthEntry = monthEntries.find((entry) => entry[1].monthIndex === month); - if (matchingMonthEntry === undefined) { + monthCode = months[month].monthCode; + if (monthCode === undefined) { throw new RangeError(`Invalid month ${month} in Chinese year ${year}`); } - monthCode = CreateMonthCode( - +matchingMonthEntry[0].replace('bis', ''), - matchingMonthEntry[0].indexOf('bis') !== -1 - ); } else { // Both month and monthCode are present. Make sure they don't conflict. const months = this.getMonthList(year, cache); - const { monthNumber, isLeapMonth } = ParseMonthCode(monthCode); - const numberPart = `${monthNumber}${isLeapMonth ? 'bis' : ''}`; - const monthInfo = months[numberPart]; - if (!monthInfo) throw new RangeError(`Unmatched monthCode ${monthCode} in Chinese year ${year}`); - if (month !== monthInfo.monthIndex) { + const monthIndex = months[monthCode]; + if (!monthIndex) throw new RangeError(`Unmatched monthCode ${monthCode} in Chinese year ${year}`); + if (month !== monthIndex) { throw new RangeError(`monthCode ${monthCode} doesn't correspond to month ${month} in Chinese year ${year}`); } } From 3516db5017a0688bcb8c29b58a009f0c0bce3e54 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Thu, 25 Sep 2025 10:29:09 -0700 Subject: [PATCH 26/30] Polyfill: Add assertion to catch error in Chinese calendar month cache See issue #3158. This doesn't fix the issue, but adds an assertion that fails early when we hit that case, instead of later on with a more confusing error message. UPSTREAM_COMMIT=a3bc92f302a2f9ea3836abc1804da6b3ff52e2e7 --- lib/calendar.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/calendar.ts b/lib/calendar.ts index 247fc02f..e840acc6 100644 --- a/lib/calendar.ts +++ b/lib/calendar.ts @@ -2297,6 +2297,7 @@ abstract class ChineseBaseHelper extends HelperBase { let oldDay: number | undefined; for (;;) { const { day, monthString, relatedYear } = updateCalendarFields(); + if (monthIndex === 1) assert(monthString === '1', `we didn't back up to the beginning of year ${calendarYear}`); const isLeapMonth = monthString.endsWith('bis'); const monthCode = CreateMonthCode(+(isLeapMonth ? monthString.slice(0, -3) : monthString), isLeapMonth); if (oldDay) { From d4cd661d051d3cac89c0c0a56836a59643cf304a Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Fri, 5 Sep 2025 16:37:30 -0700 Subject: [PATCH 27/30] Make "datemath" test more exhaustive and compare with snapshots This rewrites the datemath.mjs test to exercise difference arithmetic between all pairs of dates taken from a list of all combinations of two lists of "interesting" years and month-days. It also adds snapshotting so that even if we don't precompute expectations for every combination, we will still get alerted when the results change. UPSTREAM_COMMIT=42d2ca11966b11e5569975fc440c9183313b834c --- test/datemath.mjs | 97 ----------------------------------------------- 1 file changed, 97 deletions(-) delete mode 100644 test/datemath.mjs diff --git a/test/datemath.mjs b/test/datemath.mjs deleted file mode 100644 index 340a2551..00000000 --- a/test/datemath.mjs +++ /dev/null @@ -1,97 +0,0 @@ -/* - ** Copyright (C) 2018-2019 Bloomberg LP. All rights reserved. - ** This code is governed by the license found in the LICENSE file. - */ - -import { describe, expect, it } from '@jest/globals'; -import * as Temporal from '@js-temporal/polyfill'; - -// Avoid these wrappers in new tests: -function assert(cond) { - expect(cond).toBeTruthy(); -} -function equal(a, b) { - expect(a).toBe(b); -} - -describe('Date.since(simple, simple)', () => { - build('Before Leap Day', '2020-01-03', '2020-02-15'); - build('Before Leap Day', '2020-01-28', '2020-02-15'); - build('Before Leap Day', '2020-01-31', '2020-02-15'); - build('Cross Leap Day', '2020-01-31', '2020-06-30'); - build('After Leap Day', '2020-03-31', '2020-06-30'); - build('After Leap Day', '2020-03-25', '2020-07-31'); -}); -describe('Date.since(normal, normal)', () => { - build('Month<2 & Month<2', '2018-01-20', '2019-01-05'); - build('Month>2 & Month>2', '2018-03-20', '2019-03-05'); - build('Month>2 & Month>2', '2018-04-20', '2019-04-05'); - build('Month<2 & Month>2', '2018-01-20', '2019-04-05'); - build('Month>2 & Month<2', '2018-03-20', '2019-01-05'); - build('Month>2 & Month<2', '2018-04-20', '2019-01-05'); -}); -describe('Date.since(leap, leap)', () => { - build('Month<2 & Month<2', '2016-01-20', '2020-01-05'); - build('Month>2 & Month>2', '2016-03-20', '2020-04-05'); - build('Month>2 & Month>2', '2016-03-20', '2020-03-05'); - build('Month<2 & Month>2', '2016-01-20', '2020-02-05'); - build('Month>2 & Month<2', '2016-03-20', '2020-01-05'); - build('Month>2 & Month<2', '2016-04-20', '2020-01-05'); -}); -describe('Date.since(leap, normal)', () => { - build('Month<2 & Month<2', '2016-01-20', '2017-01-05'); - build('Month>2 & Month>2', '2016-03-20', '2017-04-05'); - build('Month>2 & Month>2', '2016-04-20', '2017-03-05'); - build('Month<2 & Month>2', '2016-01-20', '2017-04-05'); - build('Month>2 & Month<2', '2016-03-20', '2017-01-05'); - build('Month>2 & Month<2', '2016-04-20', '2017-01-05'); -}); -describe('Date.since(normal, leap)', () => { - build('Month<2 & Month<2', '2019-01-20', '2020-01-05'); - build('Month>2 & Month>2', '2019-03-20', '2020-04-05'); - build('Month>2 & Month>2', '2019-04-20', '2020-03-05'); - build('Month<2 & Month>2', '2019-01-20', '2020-04-05'); - build('Month>2 & Month<2', '2019-03-20', '2020-01-05'); - build('Month>2 & Month<2', '2019-04-20', '2020-01-05'); -}); - -function build(name, sone, stwo) { - const calendars = ['iso8601', 'gregory']; - describe(name, () => { - const largestUnits = ['years', 'months', 'weeks', 'days']; - for (const calendar of calendars) { - const [one, two] = [ - Temporal.PlainDate.from(sone).withCalendar(calendar), - Temporal.PlainDate.from(stwo).withCalendar(calendar) - ].sort(Temporal.PlainDate.compare); - buildSub(one, two, largestUnits); - buildSub(one.with({ day: 25 }), two.with({ day: 5 }), largestUnits); - buildSub(one.with({ day: 30 }), two.with({ day: 29 }), largestUnits); - buildSub(one.with({ day: 30 }), two.with({ day: 5 }), largestUnits); - } - }); -} -function buildSub(one, two, largestUnits) { - largestUnits.forEach((largestUnit) => { - describe(`< ${one} : ${two} (${largestUnit})>`, () => { - const dif = two.since(one, { largestUnit }); - const overflow = 'reject'; - if (largestUnit === 'months' || largestUnit === 'years') { - // For months and years, `until` and `since` won't agree because the - // starting point is always `this` and month-aware arithmetic behavior - // varies based on the starting point. - it(`(${two}).subtract(${dif}) => ${one}`, () => assert(two.subtract(dif).equals(one))); - it(`(${two}).add(-${dif}) => ${one}`, () => assert(two.add(dif.negated()).equals(one))); - const difUntil = one.until(two, { largestUnit }); - it(`(${one}).subtract(-${difUntil}) => ${two}`, () => assert(one.subtract(difUntil.negated()).equals(two))); - it(`(${one}).add(${difUntil}) => ${two}`, () => assert(one.add(difUntil).equals(two))); - } else { - it('until() and since() agree', () => equal(`${dif}`, `${one.until(two, { largestUnit })}`)); - it(`(${one}).add(${dif}) => ${two}`, () => assert(one.add(dif, { overflow }).equals(two))); - it(`(${two}).subtract(${dif}) => ${one}`, () => assert(two.subtract(dif, { overflow }).equals(one))); - it(`(${one}).subtract(-${dif}) => ${two}`, () => assert(one.subtract(dif.negated(), { overflow }).equals(two))); - it(`(${two}).add(-${dif}) => ${one}`, () => assert(two.add(dif.negated(), { overflow }).equals(one))); - } - }); - }); -} From aa55006d21347212bf8470108faf25acaa0fa23c Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Fri, 31 Oct 2025 16:03:12 -0700 Subject: [PATCH 28/30] Polyfill: Fix off-by-1-or-2s error due to FP precision loss Discovered by comparing the snapshot tests against other implementations. h/t Tim Chevalier for figuring out where the bug was. The spec is correct, no change needed. Update the snapshots accordingly. Regression from 7b924d436. UPSTREAM_COMMIT=8e2f879cdeff2f4b5b841db6c613ad917f429599 --- lib/ecmascript.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/lib/ecmascript.ts b/lib/ecmascript.ts index 6831f206..8e7dd0e3 100644 --- a/lib/ecmascript.ts +++ b/lib/ecmascript.ts @@ -4454,15 +4454,22 @@ export function DifferenceTemporalZonedDateTime( } export function AddTime( - { hour, minute, second: secondParam, millisecond, microsecond, nanosecond: nanosecondParam }: TimeRecord, + { hour, minute, second, millisecond, microsecond, nanosecond }: TimeRecord, timeDuration: TimeDuration ) { - let second = secondParam; - let nanosecond = nanosecondParam; - - second += timeDuration.sec; - nanosecond += timeDuration.subsec; - return BalanceTime(hour, minute, second, millisecond, microsecond, nanosecond); + // timeDuration.sec is a safe integer, but second+timeDuration.sec may not be. + // minute+trunc(timeDuration.sec/60) is safe. nanosecond+timeDuration.subsec + // is also safe. + const minutes = Math.trunc(timeDuration.sec / 60); + const seconds = timeDuration.sec - minutes * 60; + return BalanceTime( + hour, + minute + minutes, + second + seconds, + millisecond, + microsecond, + nanosecond + timeDuration.subsec + ); } function AddInstant(epochNanoseconds: JSBI, timeDuration: TimeDuration) { From 66a570656569f15be9c684643d956adda96ff801 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Mon, 3 Nov 2025 12:29:37 -0800 Subject: [PATCH 29/30] Update test262 UPSTREAM_COMMIT=8bae70005847737e71b8df2fc4fe9bae602aaf2d --- test262 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test262 b/test262 index 409001b6..7b3764f5 160000 --- a/test262 +++ b/test262 @@ -1 +1 @@ -Subproject commit 409001b61bd3e1546028d8b768a65ab96ac5928d +Subproject commit 7b3764f5702970a054033d0c47b5be38a6cf2874 From c6a673fb2480249b4c5662478ae8989e1ad67202 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Wed, 5 Nov 2025 16:23:00 -0800 Subject: [PATCH 30/30] Update test262 UPSTREAM_COMMIT=53731c2534b4182a2efd82fbaf53d7a4c1f0da69 --- test262 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test262 b/test262 index 7b3764f5..ac3035f0 160000 --- a/test262 +++ b/test262 @@ -1 +1 @@ -Subproject commit 7b3764f5702970a054033d0c47b5be38a6cf2874 +Subproject commit ac3035f0d11b520016b33ff1401ecced29fef5a4