From dde7d08c370b4e4987039888a7529753f066f566 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Fri, 19 Sep 2025 10:37:56 -0700 Subject: [PATCH 01/13] 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 --- polyfill/lib/monthcode.mjs | 23 +++--- polyfill/lib/regex.mjs | 2 - polyfill/test/all.mjs | 2 + polyfill/test/monthcode.mjs | 140 ++++++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 polyfill/test/monthcode.mjs diff --git a/polyfill/lib/monthcode.mjs b/polyfill/lib/monthcode.mjs index e426593fd4..b4db11f8f7 100644 --- a/polyfill/lib/monthcode.mjs +++ b/polyfill/lib/monthcode.mjs @@ -2,24 +2,31 @@ import { String as StringCtor, RangeError as RangeErrorCtor, TypeError as TypeErrorCtor, + ArrayFrom, StringPrototypePadStart, - RegExpPrototypeExec + StringPrototypeSlice } from './primordials.mjs'; import Call from 'es-abstract/2024/Call.js'; import ToPrimitive from 'es-abstract/2024/ToPrimitive.js'; -import { monthCode as MONTH_CODE_REGEX } from './regex.mjs'; +const digitsForMonthNumber = ArrayFrom({ length: 100 }, (_, i) => (i < 10 ? `0${i}` : `${i}`)); export function ParseMonthCode(argument) { const value = ToPrimitive(argument, StringCtor); if (typeof value !== 'string') throw new TypeErrorCtor('month code must be a string'); - const match = Call(RegExpPrototypeExec, MONTH_CODE_REGEX, [value]); - if (!match) throw new RangeErrorCtor(`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 = Call(StringPrototypeSlice, value, [1, 3]); + const monthNumber = digits.length === 2 ? +digits | 0 : -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 RangeErrorCtor(`bad month code ${value}; must match M01-M99 or M00L-M99L`); + } + return { monthNumber, isLeapMonth }; } export function CreateMonthCode(monthNumber, isLeapMonth) { diff --git a/polyfill/lib/regex.mjs b/polyfill/lib/regex.mjs index 7f721be28e..8e35329aa2 100644 --- a/polyfill/lib/regex.mjs +++ b/polyfill/lib/regex.mjs @@ -69,5 +69,3 @@ const fraction = /(\d+)(?:[.,](\d{1,9}))?/; const durationDate = /(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?/; const durationTime = new RegExpCtor(`(?:${fraction.source}H)?(?:${fraction.source}M)?(?:${fraction.source}S)?`); export const duration = new RegExpCtor(`^([+-])?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/polyfill/test/all.mjs b/polyfill/test/all.mjs index d25fd9ae04..8a2c5c2fa1 100644 --- a/polyfill/test/all.mjs +++ b/polyfill/test/all.mjs @@ -20,6 +20,8 @@ import './math.mjs'; // Internal 96-bit integer implementation, not suitable for test262 import './timeduration.mjs'; +import './monthcode.mjs'; + Promise.resolve() .then(() => { return Demitasse.report(Pretty.reporter); diff --git a/polyfill/test/monthcode.mjs b/polyfill/test/monthcode.mjs new file mode 100644 index 0000000000..968000f8e4 --- /dev/null +++ b/polyfill/test/monthcode.mjs @@ -0,0 +1,140 @@ +import Demitasse from '@pipobscure/demitasse'; +const { describe, it, report } = Demitasse; + +import Pretty from '@pipobscure/demitasse-pretty'; +const { reporter } = Pretty; + +import { strict as assert } from 'assert'; +const { deepEqual, equal, throws } = assert; + +import { CreateMonthCode, ParseMonthCode } from '../lib/monthcode.mjs'; + +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); + } + }); +}); + +import { normalize } from 'path'; +if (normalize(import.meta.url.slice(8)) === normalize(process.argv[1])) { + report(reporter).then((failed) => process.exit(failed ? 1 : 0)); +} From 254e58da3ca086ce21c9d3d45b9e48128c7b8d54 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Fri, 19 Sep 2025 11:10:09 -0700 Subject: [PATCH 02/13] 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 --- polyfill/lib/calendar.mjs | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/polyfill/lib/calendar.mjs b/polyfill/lib/calendar.mjs index c11f44af74..b8bc782be2 100644 --- a/polyfill/lib/calendar.mjs +++ b/polyfill/lib/calendar.mjs @@ -495,6 +495,15 @@ class OneObjectCache { Call(WeakMapPrototypeSet, OneObjectCache.objectMap, [obj, this]); this.report(); } + static generateCalendarToISOKey(id, { year, month, day }, overflow) { + return JSONStringify({ func: 'calendarToIsoDate', year, month, day, overflow, id }); + } + static generateISOToCalendarKey(id, { year, month, day }) { + return JSONStringify({ func: 'isoToCalendarDate', year, month, day, id }); + } + static generateMonthListKey(id, year) { + return JSONStringify({ func: 'getMonthList', year, id }); + } } OneObjectCache.objectMap = new WeakMapCtor(); OneObjectCache.MAX_CACHE_ENTRIES = 1000; @@ -574,7 +583,7 @@ const nonIsoHelperBase = { }, isoToCalendarDate(isoDate, cache) { const { year: isoYear, month: isoMonth, day: isoDay } = isoDate; - const key = JSONStringify({ func: 'isoToCalendarDate', isoYear, isoMonth, isoDay, id: this.id }); + const key = OneObjectCache.generateISOToCalendarKey(this.id, isoDate); const cached = cache.get(key); if (cached) return cached; @@ -668,14 +677,7 @@ const nonIsoHelperBase = { cache.set(key, calendarDate); // Also cache the reverse mapping const cacheReverse = (overflow) => { - const keyReverse = JSONStringify({ - 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); }; Call(ArrayPrototypeForEach, ['constrain', 'reject'], [cacheReverse]); @@ -818,7 +820,7 @@ const nonIsoHelperBase = { date = this.regulateMonthDayNaive(date, overflow, cache); const { year, month, day } = date; - const key = JSONStringify({ 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 @@ -830,14 +832,7 @@ const nonIsoHelperBase = { originalDate.day !== undefined && (originalDate.year !== date.year || originalDate.month !== date.month || originalDate.day !== date.day) ) { - keyOriginal = JSONStringify({ - func: 'calendarToIsoDate', - year: originalDate.year, - month: originalDate.month, - day: originalDate.day, - overflow, - id: this.id - }); + keyOriginal = OneObjectCache.generateCalendarToISOKey(this.id, originalDate, overflow); cached = cache.get(keyOriginal); if (cached) return cached; } @@ -1882,7 +1877,7 @@ const helperChinese = ObjectAssign({}, nonIsoHelperBase, { if (calendarYear === undefined) { throw new TypeErrorCtor('Missing year'); } - const key = JSONStringify({ func: 'getMonthList', calendarYear, id: this.id }); + const key = OneObjectCache.generateMonthListKey(this.id, calendarYear); const cached = cache.get(key); if (cached) return cached; From f70dfe846f02cc4ec6e0c5d92cb90d20883e74ce Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Fri, 19 Sep 2025 12:28:48 -0700 Subject: [PATCH 03/13] Polyfill: Bring PlainDate.toPlain{YearMonth,MonthDay} in line with spec The spec has CONSTRAIN explicitly here. The overflow argument shouldn't be treated as optional for CalendarYearMonthFromFields and CalendarMonthDayFromFields. This doesn't observably affect any results, but regarding #3153, it avoids generating calendar cache keys with `undefined` as the overflow part of the key (should be `constrain` so that the same cache entry is fetched for the same overflow behaviour regardless of whether `constrain` was specified explicitly or not.) --- polyfill/lib/plaindate.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/polyfill/lib/plaindate.mjs b/polyfill/lib/plaindate.mjs index 476d67747d..88c17c9279 100644 --- a/polyfill/lib/plaindate.mjs +++ b/polyfill/lib/plaindate.mjs @@ -198,14 +198,14 @@ export class PlainDate { if (!ES.IsTemporalDate(this)) throw new TypeErrorCtor('invalid receiver'); const calendar = GetSlot(this, CALENDAR); const fields = ES.ISODateToFields(calendar, GetSlot(this, ISO_DATE)); - const isoDate = ES.CalendarYearMonthFromFields(calendar, fields); + const isoDate = ES.CalendarYearMonthFromFields(calendar, fields, 'constrain'); return ES.CreateTemporalYearMonth(isoDate, calendar); } toPlainMonthDay() { if (!ES.IsTemporalDate(this)) throw new TypeErrorCtor('invalid receiver'); const calendar = GetSlot(this, CALENDAR); const fields = ES.ISODateToFields(calendar, GetSlot(this, ISO_DATE)); - const isoDate = ES.CalendarMonthDayFromFields(calendar, fields); + const isoDate = ES.CalendarMonthDayFromFields(calendar, fields, 'constrain'); return ES.CreateTemporalMonthDay(isoDate, calendar); } From 3143b8295b563e37a9ad99e8ed8baa3650f61426 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Fri, 19 Sep 2025 12:10:48 -0700 Subject: [PATCH 04/13] 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 --- polyfill/lib/calendar.mjs | 56 ++++++++++++++++++---------------- polyfill/lib/plaindate.mjs | 5 ++- polyfill/lib/plaindatetime.mjs | 8 ++++- 3 files changed, 41 insertions(+), 28 deletions(-) diff --git a/polyfill/lib/calendar.mjs b/polyfill/lib/calendar.mjs index b8bc782be2..6d187d02eb 100644 --- a/polyfill/lib/calendar.mjs +++ b/polyfill/lib/calendar.mjs @@ -54,6 +54,7 @@ import { import Call from 'es-abstract/2024/Call.js'; import Type from 'es-abstract/2024/Type.js'; +import { assert } from './assert.mjs'; import * as ES from './ecmascript.mjs'; import { DefineIntrinsic } from './intrinsicclass.mjs'; import { CreateMonthCode, ParseMonthCode } from './monthcode.mjs'; @@ -445,18 +446,21 @@ function CanonicalizeEraInCalendar(calendar, era) { * 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 { - constructor(cacheToClone = undefined) { + constructor(id, cacheToClone = undefined) { + this.id = id; this.map = new MapCtor(); this.calls = 0; this.now = now(); this.hits = 0; this.misses = 0; if (cacheToClone !== undefined) { + assert(cacheToClone.id === this.id, 'should not clone cache from a different calendar'); let i = 0; const entriesIterator = Call(MapPrototypeEntries, cacheToClone.map, []); for (;;) { @@ -495,28 +499,28 @@ class OneObjectCache { Call(WeakMapPrototypeSet, OneObjectCache.objectMap, [obj, this]); this.report(); } - static generateCalendarToISOKey(id, { year, month, day }, overflow) { - return JSONStringify({ func: 'calendarToIsoDate', year, month, day, overflow, id }); + static generateCalendarToISOKey({ year, month, day }, overflow) { + return JSONStringify({ func: 'calendarToIsoDate', year, month, day, overflow }); } - static generateISOToCalendarKey(id, { year, month, day }) { - return JSONStringify({ func: 'isoToCalendarDate', year, month, day, id }); + static generateISOToCalendarKey({ year, month, day }) { + return JSONStringify({ func: 'isoToCalendarDate', year, month, day }); } - static generateMonthListKey(id, year) { - return JSONStringify({ func: 'getMonthList', year, id }); + static generateMonthListKey(year) { + return JSONStringify({ func: 'getMonthList', year }); } } OneObjectCache.objectMap = new WeakMapCtor(); OneObjectCache.MAX_CACHE_ENTRIES = 1000; /** * Returns a WeakMap-backed cache that's used to store expensive results - * that are associated with a particular Temporal object instance. + * that are associated with a particular ISO Date Record object instance. * * @param obj - object to associate with the cache */ -OneObjectCache.getCacheForObject = function (obj) { +OneObjectCache.getCacheForObject = function (id, obj) { let cache = Call(WeakMapPrototypeGet, OneObjectCache.objectMap, [obj]); if (!cache) { - cache = new OneObjectCache(); + cache = new OneObjectCache(id); Call(WeakMapPrototypeSet, OneObjectCache.objectMap, [obj, cache]); } return cache; @@ -583,7 +587,7 @@ const nonIsoHelperBase = { }, isoToCalendarDate(isoDate, cache) { 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; @@ -677,7 +681,7 @@ const nonIsoHelperBase = { cache.set(key, calendarDate); // Also cache the reverse mapping const cacheReverse = (overflow) => { - const keyReverse = OneObjectCache.generateCalendarToISOKey(this.id, calendarDate, overflow); + const keyReverse = OneObjectCache.generateCalendarToISOKey(calendarDate, overflow); cache.set(keyReverse, isoDate); }; Call(ArrayPrototypeForEach, ['constrain', 'reject'], [cacheReverse]); @@ -820,7 +824,7 @@ const nonIsoHelperBase = { 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 @@ -832,7 +836,7 @@ const nonIsoHelperBase = { originalDate.day !== undefined && (originalDate.year !== date.year || originalDate.month !== date.month || originalDate.day !== date.day) ) { - keyOriginal = OneObjectCache.generateCalendarToISOKey(this.id, originalDate, overflow); + keyOriginal = OneObjectCache.generateCalendarToISOKey(originalDate, overflow); cached = cache.get(keyOriginal); if (cached) return cached; } @@ -1877,7 +1881,7 @@ const helperChinese = ObjectAssign({}, nonIsoHelperBase, { if (calendarYear === undefined) { throw new TypeErrorCtor('Missing year'); } - const key = OneObjectCache.generateMonthListKey(this.id, calendarYear); + const key = OneObjectCache.generateMonthListKey(calendarYear); const cached = cache.get(key); if (cached) return cached; @@ -2047,13 +2051,13 @@ const nonIsoGeneralImpl = { } }, dateToISO(fields, overflow) { - const cache = new OneObjectCache(); + const cache = new OneObjectCache(this.id); const result = this.helper.calendarToIsoDate(fields, overflow, cache); cache.setObject(result); return result; }, monthDayToISOReferenceDate(fields, overflow) { - const cache = new OneObjectCache(); + const cache = new OneObjectCache(this.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); @@ -2103,27 +2107,27 @@ const nonIsoGeneralImpl = { return arrayFromSet(result); }, dateAdd(isoDate, { years, months, weeks, days }, overflow) { - const cache = OneObjectCache.getCacheForObject(isoDate); + const cache = OneObjectCache.getCacheForObject(this.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.id, isoAdded)) { + const newCache = new OneObjectCache(this.id, cache); newCache.setObject(isoAdded); } return isoAdded; }, dateUntil(one, two, largestUnit) { - const cacheOne = OneObjectCache.getCacheForObject(one); - const cacheTwo = OneObjectCache.getCacheForObject(two); + const cacheOne = OneObjectCache.getCacheForObject(this.id, one); + const cacheTwo = OneObjectCache.getCacheForObject(this.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); return result; }, isoToDate(isoDate, requestedFields) { - const cache = OneObjectCache.getCacheForObject(isoDate); + const cache = OneObjectCache.getCacheForObject(this.id, isoDate); const calendarDate = this.helper.isoToCalendarDate(isoDate, cache); if (requestedFields.dayOfWeek) { calendarDate.dayOfWeek = impl['iso8601'].isoToDate(isoDate, { dayOfWeek: true }).dayOfWeek; diff --git a/polyfill/lib/plaindate.mjs b/polyfill/lib/plaindate.mjs index 88c17c9279..9a7869e0c9 100644 --- a/polyfill/lib/plaindate.mjs +++ b/polyfill/lib/plaindate.mjs @@ -120,7 +120,10 @@ export class PlainDate { withCalendar(calendar) { if (!ES.IsTemporalDate(this)) throw new TypeErrorCtor('invalid receiver'); calendar = ES.ToTemporalCalendarIdentifier(calendar); - 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, options = undefined) { if (!ES.IsTemporalDate(this)) throw new TypeErrorCtor('invalid receiver'); diff --git a/polyfill/lib/plaindatetime.mjs b/polyfill/lib/plaindatetime.mjs index fd29dff1b2..793a78531f 100644 --- a/polyfill/lib/plaindatetime.mjs +++ b/polyfill/lib/plaindatetime.mjs @@ -182,7 +182,13 @@ export class PlainDateTime { withCalendar(calendar) { if (!ES.IsTemporalDateTime(this)) throw new TypeErrorCtor('invalid receiver'); calendar = ES.ToTemporalCalendarIdentifier(calendar); - 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, options = undefined) { if (!ES.IsTemporalDateTime(this)) throw new TypeErrorCtor('invalid receiver'); From 219ca92d4f9a524ab763e15408a38e6a6dbb622e Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Fri, 19 Sep 2025 11:18:06 -0700 Subject: [PATCH 05/13] 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 --- polyfill/lib/calendar.mjs | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/polyfill/lib/calendar.mjs b/polyfill/lib/calendar.mjs index 6d187d02eb..451e223eb5 100644 --- a/polyfill/lib/calendar.mjs +++ b/polyfill/lib/calendar.mjs @@ -499,14 +499,33 @@ class OneObjectCache { Call(WeakMapPrototypeSet, OneObjectCache.objectMap, [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, month, day, flags) { + // -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 }, overflow) { - return JSONStringify({ func: 'calendarToIsoDate', year, month, day, overflow }); + const flags = overflow === 'constrain' ? 0b10 : 0b11; + return this.privKey(year, month, day, flags); } static generateISOToCalendarKey({ year, month, day }) { - return JSONStringify({ func: 'isoToCalendarDate', year, month, day }); + return this.privKey(year, month, day, 1); } static generateMonthListKey(year) { - return JSONStringify({ func: 'getMonthList', year }); + return this.privKey(year, 0, 0, 0); } } OneObjectCache.objectMap = new WeakMapCtor(); From 90921785e5460b13a21f939fe3507c3eef6f6fa0 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Fri, 19 Sep 2025 17:41:29 -0700 Subject: [PATCH 06/13] 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 --- polyfill/lib/calendar.mjs | 75 ++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/polyfill/lib/calendar.mjs b/polyfill/lib/calendar.mjs index 451e223eb5..bbb7a7c64f 100644 --- a/polyfill/lib/calendar.mjs +++ b/polyfill/lib/calendar.mjs @@ -729,48 +729,48 @@ const nonIsoHelperBase = { } } }, - /** Fill in missing parts of the (year, era, eraYear) tuple */ - completeEraYear(calendarDate) { - const eraFromYear = (year) => { - let eraYear; - const adjustedCalendarDate = { ...calendarDate, year }; - const ix = Call(ArrayPrototypeFindIndex, this.eras, [ - (e, i) => { - 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 RangeErrorCtor(`Signed year ${year} is invalid for era ${e.name}`); - 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; + /** Private helper function */ + eraFromYear(calendarDate) { + const { year } = calendarDate; + let eraYear; + const ix = Call(ArrayPrototypeFindIndex, this.eras, [ + (e, i) => { + 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]; } - const comparison = nonIsoHelperBase.compareCalendarDates(adjustedCalendarDate, e.anchorEpoch); - if (comparison >= 0) { - eraYear = year - e.anchorEpoch.year + (e.hasYearZero ? 0 : 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 RangeErrorCtor(`Signed year ${year} is invalid for era ${e.name}`); + eraYear = e.anchorEpoch.year - year; return true; } - return false; + // 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; } - ]); - if (ix === -1) throw new RangeErrorCtor(`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 }; - }; - + const comparison = nonIsoHelperBase.compareCalendarDates(calendarDate, e.anchorEpoch); + if (comparison >= 0) { + eraYear = year - e.anchorEpoch.year + (e.hasYearZero ? 0 : 1); + return true; + } + return false; + } + ]); + if (ix === -1) throw new RangeErrorCtor(`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) { let { year, eraYear, era } = calendarDate; if (year !== undefined) { - const matchData = eraFromYear(year); + const matchData = this.eraFromYear(calendarDate); ({ eraYear, era } = matchData); if (calendarDate.era !== undefined && CanonicalizeEraInCalendar(this.id, calendarDate.era) !== era) { throw new RangeErrorCtor(`Input era ${calendarDate.era} doesn't match calculated value ${era}`); @@ -794,7 +794,8 @@ const nonIsoHelperBase = { // 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 84562f9f77f9858f1949d6b3556120762cc0ce62 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Fri, 19 Sep 2025 17:48:07 -0700 Subject: [PATCH 07/13] 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 --- polyfill/lib/calendar.mjs | 61 +++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/polyfill/lib/calendar.mjs b/polyfill/lib/calendar.mjs index bbb7a7c64f..7e90740ad5 100644 --- a/polyfill/lib/calendar.mjs +++ b/polyfill/lib/calendar.mjs @@ -372,43 +372,43 @@ function weekNumber(firstDayOfWeek, minimalDaysInFirstWeek, desiredDay, dayOfWee // about non-ISO calendars. However, non-ISO calendar implementation is subject // to change because these calendars are implementation-defined. -const eraInfo = { - buddhist: { +const eraInfoEntries = { + buddhist: ObjectEntries({ be: {} - }, - coptic: { + }), + coptic: ObjectEntries({ am: {} - }, - ethioaa: { + }), + ethioaa: ObjectEntries({ aa: { aliases: ['mundi'] } - }, - ethiopic: { + }), + ethiopic: ObjectEntries({ am: { aliases: ['incar'] }, aa: { aliases: ['mundi'] } - }, - gregory: { + }), + gregory: ObjectEntries({ ce: { aliases: ['ad'] }, bce: { aliases: ['bc'] } - }, - hebrew: { + }), + hebrew: ObjectEntries({ am: {} - }, - indian: { + }), + indian: ObjectEntries({ shaka: {} - }, - 'islamic-civil': { + }), + 'islamic-civil': ObjectEntries({ ah: {}, bh: {} - }, - 'islamic-tbla': { + }), + 'islamic-tbla': ObjectEntries({ ah: {}, bh: {} - }, - 'islamic-umalqura': { + }), + 'islamic-umalqura': ObjectEntries({ ah: {}, bh: {} - }, - japanese: { + }), + japanese: ObjectEntries({ reiwa: {}, heisei: {}, showa: {}, @@ -416,27 +416,26 @@ const eraInfo = { meiji: {}, ce: { aliases: ['ad'] }, bce: { aliases: ['bc'] } - }, - persian: { + }), + persian: ObjectEntries({ ap: {} - }, - roc: { + }), + roc: ObjectEntries({ roc: { aliases: ['minguo'] }, broc: { aliases: ['before-roc', 'minguo-qian'] } - } + }) }; function CalendarSupportsEra(calendar) { - return ObjectHasOwn(eraInfo, calendar); + return ObjectHasOwn(eraInfoEntries, calendar); } function CanonicalizeEraInCalendar(calendar, era) { - const eras = eraInfo[calendar]; - const entries = ObjectEntries(eras); + const entries = eraInfoEntries[calendar]; for (let ix = 0; ix < entries.length; ix++) { const canonicalName = entries[ix][0]; - const info = entries[ix][1]; if (era === canonicalName) return era; + const info = entries[ix][1]; if (info.aliases && Call(ArrayPrototypeIncludes, info.aliases, [era])) return canonicalName; } return undefined; From 0571e3323c7e9a678b640e09e0dce3b70f686e70 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Tue, 23 Sep 2025 11:20:00 -0700 Subject: [PATCH 08/13] 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 --- polyfill/lib/calendar.mjs | 65 ++++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/polyfill/lib/calendar.mjs b/polyfill/lib/calendar.mjs index 7e90740ad5..cea979eaa2 100644 --- a/polyfill/lib/calendar.mjs +++ b/polyfill/lib/calendar.mjs @@ -1233,30 +1233,21 @@ const helperHebrew = makeNonISOHelper([{ code: 'am', isoEpoch: { year: -3760, mo return this.inLeapYear(calendarDate) ? 13 : 12; }, minimumMonthLength(calendarDate) { - return this.minMaxMonthLength(calendarDate, 'min'); + return this.minMaxMonthLength(calendarDate, 0); }, maximumMonthLength(calendarDate) { - return this.minMaxMonthLength(calendarDate, 'max'); + return this.minMaxMonthLength(calendarDate, 1); }, minMaxMonthLength(calendarDate, minOrMax) { const { month, year } = calendarDate; const monthCode = this.getMonthCode(year, month); - const monthInfo = Call(ArrayPrototypeFind, ObjectEntries(this.months), [(m) => m[1].monthCode === monthCode]); - if (monthInfo === undefined) throw new RangeErrorCtor(`unmatched Hebrew month: ${month}`); - const daysInMonth = monthInfo[1].days; + const daysInMonth = this.monthLengths[monthCode]; + if (daysInMonth === undefined) throw new RangeErrorCtor(`unmatched Hebrew month: ${month}`); return typeof daysInMonth === 'number' ? daysInMonth : daysInMonth[minOrMax]; }, maxLengthOfMonthCodeInAnyYear(monthCode) { - if ( - monthCode === 'M04' || - monthCode === 'M06' || - monthCode === 'M08' || - monthCode === 'M10' || - monthCode === 'M12' - ) { - return 29; - } - return 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) { @@ -1264,20 +1255,36 @@ const helperHebrew = makeNonISOHelper([{ code: 'am', isoEpoch: { year: -3760, mo return { year: year - 3760, month: 1, day: 1 }; }, months: { - 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: { + // monthCode: len | [min, max] + 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, month) { if (this.inLeapYear({ year })) { From 75c1880fb79e7f008b3b0ef94d833bd3f9294afa Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Tue, 23 Sep 2025 11:27:35 -0700 Subject: [PATCH 09/13] 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 --- polyfill/lib/calendar.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/polyfill/lib/calendar.mjs b/polyfill/lib/calendar.mjs index cea979eaa2..8a2539311b 100644 --- a/polyfill/lib/calendar.mjs +++ b/polyfill/lib/calendar.mjs @@ -1240,7 +1240,7 @@ const helperHebrew = makeNonISOHelper([{ code: 'am', isoEpoch: { year: -3760, mo }, minMaxMonthLength(calendarDate, minOrMax) { 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 RangeErrorCtor(`unmatched Hebrew month: ${month}`); return typeof daysInMonth === 'number' ? daysInMonth : daysInMonth[minOrMax]; From df0c8fd60c62c8a25d31f01456f0945a0ad68e6e Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Tue, 23 Sep 2025 12:42:51 -0700 Subject: [PATCH 10/13] 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. --- polyfill/lib/calendar.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/polyfill/lib/calendar.mjs b/polyfill/lib/calendar.mjs index 8a2539311b..01dfb6187c 100644 --- a/polyfill/lib/calendar.mjs +++ b/polyfill/lib/calendar.mjs @@ -600,7 +600,8 @@ const nonIsoHelperBase = { try { return Call(IntlDateTimeFormatPrototypeFormatToParts, dateTimeFormat, [legacyDate]); } catch (e) { - throw new RangeErrorCtor(`Invalid ISO date: ${isoString}`); + if (e instanceof RangeErrorCtor) throw new RangeErrorCtor(`Invalid ISO date: ${isoString}`); + throw e; } }, isoToCalendarDate(isoDate, cache) { From 33c75abc108a0e32759281e335d5c6c362dea3c5 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Wed, 24 Sep 2025 10:20:47 -0700 Subject: [PATCH 11/13] 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 --- polyfill/lib/calendar.mjs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/polyfill/lib/calendar.mjs b/polyfill/lib/calendar.mjs index 01dfb6187c..0c6758dab4 100644 --- a/polyfill/lib/calendar.mjs +++ b/polyfill/lib/calendar.mjs @@ -1854,6 +1854,24 @@ const helperChinese = ObjectAssign({}, nonIsoHelperBase, { monthsInYear(calendarDate, cache) { return this.inLeapYear(calendarDate, cache) ? 13 : 12; }, + daysInMonth(calendarDate, cache) { + const { month, year } = calendarDate; + const monthEntries = ObjectEntries(this.getMonthList(calendarDate.year, cache)); + const matchingMonthEntry = Call(ArrayPrototypeFind, monthEntries, [(entry) => entry[1].monthIndex === month]); + if (matchingMonthEntry === undefined) { + throw new RangeErrorCtor(`Invalid month ${month} in Chinese year ${year}`); + } + return matchingMonthEntry[1].daysInMonth; + }, + daysInPreviousMonth(calendarDate, cache) { + const { month, year } = calendarDate; + + const previousMonthYear = month > 1 ? year : year - 1; + let 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 */) => 29, maximumMonthLength: (/* calendarDate */) => 30, maxLengthOfMonthCodeInAnyYear(monthCode) { @@ -1973,7 +1991,6 @@ const helperChinese = ObjectAssign({}, nonIsoHelperBase, { // month before the loop ends at 12-13 months. daysPastJan31 += 30; } - monthList[oldMonthString].daysInMonth = oldDay + 30 - calendarFields.day; cache.set(key, monthList); return monthList; From 51d1b266ab2d642924a031a52b09174bbe074bdd Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Wed, 24 Sep 2025 12:54:57 -0700 Subject: [PATCH 12/13] 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 --- polyfill/lib/calendar.mjs | 68 ++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 37 deletions(-) diff --git a/polyfill/lib/calendar.mjs b/polyfill/lib/calendar.mjs index 0c6758dab4..24e609bbba 100644 --- a/polyfill/lib/calendar.mjs +++ b/polyfill/lib/calendar.mjs @@ -36,9 +36,10 @@ import { RegExpPrototypeExec, SetPrototypeAdd, SetPrototypeValues, - StringPrototypeIndexOf, + StringPrototypeEndsWith, StringPrototypeNormalize, StringPrototypeReplace, + StringPrototypeSlice, StringPrototypeSplit, StringPrototypeToLowerCase, SymbolIterator, @@ -1848,20 +1849,18 @@ const helperChinese = ObjectAssign({}, nonIsoHelperBase, { id: 'chinese', calendarType: 'lunisolar', inLeapYear(calendarDate, cache) { - const months = this.getMonthList(calendarDate.year, cache); - return ObjectEntries(months).length === 13; + return this.getMonthList(calendarDate.year, cache).monthsInYear === 13; }, monthsInYear(calendarDate, cache) { - return this.inLeapYear(calendarDate, cache) ? 13 : 12; + return this.getMonthList(calendarDate.year, cache).monthsInYear; }, daysInMonth(calendarDate, cache) { const { month, year } = calendarDate; - const monthEntries = ObjectEntries(this.getMonthList(calendarDate.year, cache)); - const matchingMonthEntry = Call(ArrayPrototypeFind, monthEntries, [(entry) => entry[1].monthIndex === month]); + const matchingMonthEntry = this.getMonthList(year, cache)[month]; if (matchingMonthEntry === undefined) { throw new RangeErrorCtor(`Invalid month ${month} in Chinese year ${year}`); } - return matchingMonthEntry[1].daysInMonth; + return matchingMonthEntry.daysInMonth; }, daysInPreviousMonth(calendarDate, cache) { const { month, year } = calendarDate; @@ -1974,23 +1973,29 @@ const helperChinese = ObjectAssign({}, nonIsoHelperBase, { const monthList = {}; let monthIndex = 1; let oldDay; - let oldMonthString; for (;;) { const { day, monthString, relatedYear } = updateCalendarFields(); + const isLeapMonth = Call(StringPrototypeEndsWith, monthString, ['bis']); + const monthCode = CreateMonthCode( + isLeapMonth ? Call(StringPrototypeSlice, monthString, [0, -3]) : monthString, + isLeapMonth + ); if (oldDay) { - monthList[oldMonthString].daysInMonth = oldDay + 30 - day; + monthList[monthIndex - 1].daysInMonth = oldDay + 30 - day; } oldDay = day; - oldMonthString = monthString; 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; @@ -2008,11 +2013,11 @@ const helperChinese = ObjectAssign({}, nonIsoHelperBase, { // month. Below we'll normalize the output. if (monthExtra && monthExtra !== 'bis') throw new RangeErrorCtor(`Unexpected leap month suffix: ${monthExtra}`); const monthCode = CreateMonthCode(month, monthExtra !== undefined); - const monthString = `${month}${monthExtra || ''}`; const months = this.getMonthList(year, cache); - const monthInfo = months[monthString]; - if (monthInfo === undefined) throw new RangeErrorCtor(`Unmatched month ${monthString} in Chinese year ${year}`); - month = monthInfo.monthIndex; + month = months[monthCode]; + if (month === undefined) { + throw new RangeErrorCtor(`Unmatched month ${month}${monthExtra || ''} in Chinese year ${year}`); + } return { year, month, day, monthCode }; } else { // When called without input coming from legacy Date output, @@ -2021,25 +2026,20 @@ const helperChinese = ObjectAssign({}, nonIsoHelperBase, { if (month === undefined) { 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 RangeErrorCtor(`Unmatched month ${monthCode} in Chinese year ${year}`); } } else if (monthCode === undefined) { const months = this.getMonthList(year, cache); - const monthEntries = ObjectEntries(months); - const largestMonth = monthEntries.length; + const largestMonth = months.monthsInYear; if (overflow === 'reject') { ES.RejectToRange(month, 1, largestMonth); ES.RejectToRange(day, 1, this.maximumMonthLength()); @@ -2047,22 +2047,16 @@ const helperChinese = ObjectAssign({}, nonIsoHelperBase, { month = ES.ConstrainToRange(month, 1, largestMonth); day = ES.ConstrainToRange(day, 1, this.maximumMonthLength()); } - const matchingMonthEntry = Call(ArrayPrototypeFind, monthEntries, [(entry) => entry[1].monthIndex === month]); - if (matchingMonthEntry === undefined) { + monthCode = months[month].monthCode; + if (monthCode === undefined) { throw new RangeErrorCtor(`Invalid month ${month} in Chinese year ${year}`); } - monthCode = CreateMonthCode( - Call(StringPrototypeReplace, matchingMonthEntry[0], ['bis', '']), - Call(StringPrototypeIndexOf, matchingMonthEntry[0], ['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 RangeErrorCtor(`Unmatched monthCode ${monthCode} in Chinese year ${year}`); - if (month !== monthInfo.monthIndex) { + const monthIndex = months[monthCode]; + if (!monthIndex) throw new RangeErrorCtor(`Unmatched monthCode ${monthCode} in Chinese year ${year}`); + if (month !== monthIndex) { throw new RangeErrorCtor( `monthCode ${monthCode} doesn't correspond to month ${month} in Chinese year ${year}` ); From f9907aeed3d55c2ab3e4d74c363a73f48bce39be Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Thu, 25 Sep 2025 10:29:09 -0700 Subject: [PATCH 13/13] 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. --- polyfill/lib/calendar.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/polyfill/lib/calendar.mjs b/polyfill/lib/calendar.mjs index 24e609bbba..8269471380 100644 --- a/polyfill/lib/calendar.mjs +++ b/polyfill/lib/calendar.mjs @@ -1975,6 +1975,7 @@ const helperChinese = ObjectAssign({}, nonIsoHelperBase, { let oldDay; 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 = Call(StringPrototypeEndsWith, monthString, ['bis']); const monthCode = CreateMonthCode( isLeapMonth ? Call(StringPrototypeSlice, monthString, [0, -3]) : monthString,