From 88bad4d200ce671cd0117d8b885e38056b6ce79e Mon Sep 17 00:00:00 2001 From: Justin Grant Date: Fri, 16 Jul 2021 05:46:16 -0700 Subject: [PATCH] Speed up toLocaleString Speed up toLocaleString ~2.5x by optimizing creation of Intl.DateTimeFormat instances. Port of https://github.com/js-temporal/temporal-polyfill/pull/12 --- polyfill/lib/ecmascript.mjs | 2 + polyfill/lib/intl.mjs | 98 ++++++++++++++++++++++++++++--------- polyfill/test/intl.mjs | 39 +++++++++++++-- 3 files changed, 112 insertions(+), 27 deletions(-) diff --git a/polyfill/lib/ecmascript.mjs b/polyfill/lib/ecmascript.mjs index d20643c579..0b29826a9a 100644 --- a/polyfill/lib/ecmascript.mjs +++ b/polyfill/lib/ecmascript.mjs @@ -27,6 +27,7 @@ import ToNumber from 'es-abstract/2020/ToNumber'; import ToPrimitive from 'es-abstract/2020/ToPrimitive'; import ToString from 'es-abstract/2020/ToString'; import Type from 'es-abstract/2020/Type'; +import HasOwnProperty from 'es-abstract/2020/HasOwnProperty'; import { GetIntrinsic } from './intrinsicclass.mjs'; import { @@ -139,6 +140,7 @@ import * as PARSE from './regex.mjs'; const ES2020 = { Call, GetMethod, + HasOwnProperty, IsInteger, ToInteger, ToLength, diff --git a/polyfill/lib/intl.mjs b/polyfill/lib/intl.mjs index 702a623cc0..c7240f41e2 100644 --- a/polyfill/lib/intl.mjs +++ b/polyfill/lib/intl.mjs @@ -28,6 +28,8 @@ const ORIGINAL = Symbol('original'); const TZ_RESOLVED = Symbol('timezone'); const TZ_GIVEN = Symbol('timezone-id-given'); const CAL_ID = Symbol('calendar-id'); +const LOCALE = Symbol('locale'); +const OPTIONS = Symbol('options'); const descriptor = (value) => { return { @@ -41,21 +43,68 @@ const descriptor = (value) => { const IntlDateTimeFormat = globalThis.Intl.DateTimeFormat; const ObjectAssign = Object.assign; -export function DateTimeFormat(locale = IntlDateTimeFormat().resolvedOptions().locale, options = {}) { +// Construction of built-in Intl.DateTimeFormat objects is sloooooow, +// so we'll only create those instances when we need them. +// See https://bugs.chromium.org/p/v8/issues/detail?id=6528 +function getPropLazy(obj, prop) { + let val = obj[prop]; + if (typeof val === 'function') { + val = new IntlDateTimeFormat(obj[LOCALE], val(obj[OPTIONS])); + obj[prop] = val; + } + return val; +} +// Similarly, lazy-init TimeZone instances. +function getResolvedTimeZoneLazy(obj) { + let val = obj[TZ_RESOLVED]; + if (typeof val === 'string') { + val = new TimeZone(val); + obj[TZ_RESOLVED] = val; + } + return val; +} + +export function DateTimeFormat(locale = undefined, options = undefined) { if (!(this instanceof DateTimeFormat)) return new DateTimeFormat(locale, options); + const hasOptions = typeof options !== 'undefined'; + options = hasOptions ? ObjectAssign({}, options) : {}; + const original = new IntlDateTimeFormat(locale, options); + const ro = original.resolvedOptions(); + + // DateTimeFormat instances are very expensive to create. Therefore, they will + // be lazily created only when needed, using the locale and options provided. + // But it's possible for callers to mutate those inputs before lazy creation + // happens. For this reason, we clone the inputs instead of caching the + // original objects. To avoid the complexity of deep cloning any inputs that + // are themselves objects (e.g. the locales array, or options property values + // that will be coerced to strings), we rely on `resolvedOptions()` to do the + // coercion and cloning for us. Unfortunately, we can't just use the resolved + // options as-is because our options-amending logic adds additional fields if + // the user doesn't supply any unit fields like year, month, day, hour, etc. + // Therefore, we limit the properties in the clone to properties that were + // present in the original input. + if (hasOptions) { + const clonedResolved = ObjectAssign({}, ro); + for (const prop in clonedResolved) { + if (!ES.HasOwnProperty(options, prop)) delete clonedResolved[prop]; + } + this[OPTIONS] = clonedResolved; + } else { + this[OPTIONS] = options; + } this[TZ_GIVEN] = options.timeZone ? options.timeZone : null; - - this[ORIGINAL] = new IntlDateTimeFormat(locale, options); - this[TZ_RESOLVED] = new TimeZone(this.resolvedOptions().timeZone); - this[CAL_ID] = this.resolvedOptions().calendar; - this[DATE] = new IntlDateTimeFormat(locale, dateAmend(options)); - this[YM] = new IntlDateTimeFormat(locale, yearMonthAmend(options)); - this[MD] = new IntlDateTimeFormat(locale, monthDayAmend(options)); - this[TIME] = new IntlDateTimeFormat(locale, timeAmend(options)); - this[DATETIME] = new IntlDateTimeFormat(locale, datetimeAmend(options)); - this[ZONED] = new IntlDateTimeFormat(locale, zonedDateTimeAmend(options)); - this[INST] = new IntlDateTimeFormat(locale, instantAmend(options)); + this[LOCALE] = ro.locale; + this[ORIGINAL] = original; + this[TZ_RESOLVED] = ro.timeZone; + this[CAL_ID] = ro.calendar; + this[DATE] = dateAmend; + this[YM] = yearMonthAmend; + this[MD] = monthDayAmend; + this[TIME] = timeAmend; + this[DATETIME] = datetimeAmend; + this[ZONED] = zonedDateTimeAmend; + this[INST] = instantAmend; } DateTimeFormat.supportedLocalesOf = function (...args) { @@ -85,6 +134,7 @@ function resolvedOptions() { function adjustFormatterTimeZone(formatter, timeZone) { if (!timeZone) return formatter; const options = formatter.resolvedOptions(); + if (options.timeZone === timeZone) return formatter; return new IntlDateTimeFormat(options.locale, { ...options, timeZone }); } @@ -327,8 +377,8 @@ function extractOverrides(temporalObj, main) { const nanosecond = GetSlot(temporalObj, ISO_NANOSECOND); const datetime = new DateTime(1970, 1, 1, hour, minute, second, millisecond, microsecond, nanosecond, main[CAL_ID]); return { - instant: ES.BuiltinTimeZoneGetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'), - formatter: main[TIME] + instant: ES.BuiltinTimeZoneGetInstantFor(getResolvedTimeZoneLazy(main), datetime, 'compatible'), + formatter: getPropLazy(main, TIME) }; } @@ -344,8 +394,8 @@ function extractOverrides(temporalObj, main) { } const datetime = new DateTime(isoYear, isoMonth, referenceISODay, 12, 0, 0, 0, 0, 0, calendar); return { - instant: ES.BuiltinTimeZoneGetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'), - formatter: main[YM] + instant: ES.BuiltinTimeZoneGetInstantFor(getResolvedTimeZoneLazy(main), datetime, 'compatible'), + formatter: getPropLazy(main, YM) }; } @@ -361,8 +411,8 @@ function extractOverrides(temporalObj, main) { } const datetime = new DateTime(referenceISOYear, isoMonth, isoDay, 12, 0, 0, 0, 0, 0, calendar); return { - instant: ES.BuiltinTimeZoneGetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'), - formatter: main[MD] + instant: ES.BuiltinTimeZoneGetInstantFor(getResolvedTimeZoneLazy(main), datetime, 'compatible'), + formatter: getPropLazy(main, MD) }; } @@ -376,8 +426,8 @@ function extractOverrides(temporalObj, main) { } const datetime = new DateTime(isoYear, isoMonth, isoDay, 12, 0, 0, 0, 0, 0, main[CAL_ID]); return { - instant: ES.BuiltinTimeZoneGetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'), - formatter: main[DATE] + instant: ES.BuiltinTimeZoneGetInstantFor(getResolvedTimeZoneLazy(main), datetime, 'compatible'), + formatter: getPropLazy(main, DATE) }; } @@ -413,8 +463,8 @@ function extractOverrides(temporalObj, main) { ); } return { - instant: ES.BuiltinTimeZoneGetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'), - formatter: main[DATETIME] + instant: ES.BuiltinTimeZoneGetInstantFor(getResolvedTimeZoneLazy(main), datetime, 'compatible'), + formatter: getPropLazy(main, DATETIME) }; } @@ -434,7 +484,7 @@ function extractOverrides(temporalObj, main) { return { instant: GetSlot(temporalObj, INSTANT), - formatter: main[ZONED], + formatter: getPropLazy(main, ZONED), timeZone: objTimeZone }; } @@ -442,7 +492,7 @@ function extractOverrides(temporalObj, main) { if (ES.IsTemporalInstant(temporalObj)) { return { instant: temporalObj, - formatter: main[INST] + formatter: getPropLazy(main, INST) }; } diff --git a/polyfill/test/intl.mjs b/polyfill/test/intl.mjs index 3724697621..325ab17a74 100644 --- a/polyfill/test/intl.mjs +++ b/polyfill/test/intl.mjs @@ -1050,10 +1050,43 @@ describe('Intl', () => { it('should return an Array', () => assert(Array.isArray(Intl.DateTimeFormat.supportedLocalesOf()))); }); - const us = new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York' }); - const at = new Intl.DateTimeFormat('de-AT', { timeZone: 'Europe/Vienna' }); + // Verify that inputs to DateTimeFormat constructor are immune to mutation. + // Also verify that options properties are only read once. + const onlyOnce = (value) => { + const obj = { + calls: 0, + toString() { + if (++this.calls > 1) throw new RangeError('prop read twice'); + return value; + } + }; + return obj; + }; + const optionsAT = { + timeZone: onlyOnce('Europe/Vienna') + }; + const optionsUS = { + calls: 0, + value: 'America/New_York', + get timeZone() { + if (++this.calls > 1) throw new RangeError('prop read twice'); + return this.value; + }, + set timeZone(val) { + this.value = val; + } + }; + const localesAT = ['de-AT']; + const us = new Intl.DateTimeFormat('en-US', optionsUS); + const at = new Intl.DateTimeFormat(localesAT, optionsAT); + optionsAT.timeZone = { + toString: () => 'Bogus/Time-Zone', + toJSON: () => 'Bogus/Time-Zone' + }; + optionsUS.timeZone = 'Bogus/Time-Zone'; const us2 = new Intl.DateTimeFormat('en-US'); - const at2 = new Intl.DateTimeFormat('de-AT'); + const at2 = new Intl.DateTimeFormat(localesAT); + localesAT[0] = ['invalid locale']; const usCalendar = us.resolvedOptions().calendar; const atCalendar = at.resolvedOptions().calendar; const t1 = '1976-11-18T14:23:30+00:00[UTC]';