Skip to content

Commit

Permalink
Optimize toLocaleString + Intl.DateTimeFormat
Browse files Browse the repository at this point in the history
This commit speeds up toLocaleString and other operations that depend
on the polyfilled Intl.DateTimeFormat. The fix was to prevent
unnecessary creation of built-in Intl.DateTimeFormat objects, because
the constructor of that built-in class is slooooooow.

For more details about the underlying issue see:
https://bugs.chromium.org/p/v8/issues/detail?id=6528

In local testing, speedup is about 2.5x for ZDT toLocaleString calls.
  • Loading branch information
justingrant committed Jul 16, 2021
1 parent e302e0c commit 6680df7
Show file tree
Hide file tree
Showing 4 changed files with 113 additions and 28 deletions.
2 changes: 2 additions & 0 deletions 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 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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"browser": "dist/index.umd.js",
"types": "index.d.ts",
"scripts": {
"test": "node --no-warnings --experimental-modules --icu-data-dir node_modules/full-icu --loader ./test/resolve.source.mjs ./test/all.mjs",
"test": "time node --no-warnings --experimental-modules --icu-data-dir node_modules/full-icu --loader ./test/resolve.source.mjs ./test/all.mjs",
"build": "rollup -c rollup.config.js",
"prepublishOnly": "npm run build",
"playground": "node --experimental-modules --no-warnings --icu-data-dir node_modules/full-icu -r ./lib/init.js",
Expand Down
39 changes: 36 additions & 3 deletions 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 6680df7

Please sign in to comment.