Skip to content

Commit

Permalink
Speed up toLocaleString
Browse files Browse the repository at this point in the history
Speed up toLocaleString ~2.5x by optimizing creation of
Intl.DateTimeFormat instances.

Port of js-temporal/temporal-polyfill#12
  • Loading branch information
justingrant authored and Ms2ger committed Jul 16, 2021
1 parent cc38541 commit 88bad4d
Show file tree
Hide file tree
Showing 3 changed files with 112 additions and 27 deletions.
2 changes: 2 additions & 0 deletions polyfill/lib/ecmascript.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -139,6 +140,7 @@ import * as PARSE from './regex.mjs';
const ES2020 = {
Call,
GetMethod,
HasOwnProperty,
IsInteger,
ToInteger,
ToLength,
Expand Down
98 changes: 74 additions & 24 deletions polyfill/lib/intl.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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 });
}

Expand Down Expand Up @@ -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)
};
}

Expand All @@ -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)
};
}

Expand All @@ -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)
};
}

Expand All @@ -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)
};
}

Expand Down Expand Up @@ -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)
};
}

Expand All @@ -434,15 +484,15 @@ function extractOverrides(temporalObj, main) {

return {
instant: GetSlot(temporalObj, INSTANT),
formatter: main[ZONED],
formatter: getPropLazy(main, ZONED),
timeZone: objTimeZone
};
}

if (ES.IsTemporalInstant(temporalObj)) {
return {
instant: temporalObj,
formatter: main[INST]
formatter: getPropLazy(main, INST)
};
}

Expand Down
39 changes: 36 additions & 3 deletions polyfill/test/intl.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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]';
Expand Down

0 comments on commit 88bad4d

Please sign in to comment.