Skip to content

Commit 6680df7

Browse files
committed
Optimize toLocaleString + Intl.DateTimeFormat
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.
1 parent e302e0c commit 6680df7

File tree

4 files changed

+113
-28
lines changed

4 files changed

+113
-28
lines changed

lib/ecmascript.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import ToNumber from 'es-abstract/2020/ToNumber';
2727
import ToPrimitive from 'es-abstract/2020/ToPrimitive';
2828
import ToString from 'es-abstract/2020/ToString';
2929
import Type from 'es-abstract/2020/Type';
30+
import HasOwnProperty from 'es-abstract/2020/HasOwnProperty';
3031

3132
import { GetIntrinsic } from './intrinsicclass.mjs';
3233
import {
@@ -139,6 +140,7 @@ import * as PARSE from './regex.mjs';
139140
const ES2020 = {
140141
Call,
141142
GetMethod,
143+
HasOwnProperty,
142144
IsInteger,
143145
ToInteger,
144146
ToLength,

lib/intl.mjs

Lines changed: 74 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ const ORIGINAL = Symbol('original');
2828
const TZ_RESOLVED = Symbol('timezone');
2929
const TZ_GIVEN = Symbol('timezone-id-given');
3030
const CAL_ID = Symbol('calendar-id');
31+
const LOCALE = Symbol('locale');
32+
const OPTIONS = Symbol('options');
3133

3234
const descriptor = (value) => {
3335
return {
@@ -41,21 +43,68 @@ const descriptor = (value) => {
4143
const IntlDateTimeFormat = globalThis.Intl.DateTimeFormat;
4244
const ObjectAssign = Object.assign;
4345

44-
export function DateTimeFormat(locale = IntlDateTimeFormat().resolvedOptions().locale, options = {}) {
46+
// Construction of built-in Intl.DateTimeFormat objects is sloooooow,
47+
// so we'll only create those instances when we need them.
48+
// See https://bugs.chromium.org/p/v8/issues/detail?id=6528
49+
function getPropLazy(obj, prop) {
50+
let val = obj[prop];
51+
if (typeof val === 'function') {
52+
val = new IntlDateTimeFormat(obj[LOCALE], val(obj[OPTIONS]));
53+
obj[prop] = val;
54+
}
55+
return val;
56+
}
57+
// Similarly, lazy-init TimeZone instances.
58+
function getResolvedTimeZoneLazy(obj) {
59+
let val = obj[TZ_RESOLVED];
60+
if (typeof val === 'string') {
61+
val = new TimeZone(val);
62+
obj[TZ_RESOLVED] = val;
63+
}
64+
return val;
65+
}
66+
67+
export function DateTimeFormat(locale = undefined, options = undefined) {
4568
if (!(this instanceof DateTimeFormat)) return new DateTimeFormat(locale, options);
69+
const hasOptions = typeof options !== 'undefined';
70+
options = hasOptions ? ObjectAssign({}, options) : {};
71+
const original = new IntlDateTimeFormat(locale, options);
72+
const ro = original.resolvedOptions();
73+
74+
// DateTimeFormat instances are very expensive to create. Therefore, they will
75+
// be lazily created only when needed, using the locale and options provided.
76+
// But it's possible for callers to mutate those inputs before lazy creation
77+
// happens. For this reason, we clone the inputs instead of caching the
78+
// original objects. To avoid the complexity of deep cloning any inputs that
79+
// are themselves objects (e.g. the locales array, or options property values
80+
// that will be coerced to strings), we rely on `resolvedOptions()` to do the
81+
// coercion and cloning for us. Unfortunately, we can't just use the resolved
82+
// options as-is because our options-amending logic adds additional fields if
83+
// the user doesn't supply any unit fields like year, month, day, hour, etc.
84+
// Therefore, we limit the properties in the clone to properties that were
85+
// present in the original input.
86+
if (hasOptions) {
87+
const clonedResolved = ObjectAssign({}, ro);
88+
for (const prop in clonedResolved) {
89+
if (!ES.HasOwnProperty(options, prop)) delete clonedResolved[prop];
90+
}
91+
this[OPTIONS] = clonedResolved;
92+
} else {
93+
this[OPTIONS] = options;
94+
}
4695

4796
this[TZ_GIVEN] = options.timeZone ? options.timeZone : null;
48-
49-
this[ORIGINAL] = new IntlDateTimeFormat(locale, options);
50-
this[TZ_RESOLVED] = new TimeZone(this.resolvedOptions().timeZone);
51-
this[CAL_ID] = this.resolvedOptions().calendar;
52-
this[DATE] = new IntlDateTimeFormat(locale, dateAmend(options));
53-
this[YM] = new IntlDateTimeFormat(locale, yearMonthAmend(options));
54-
this[MD] = new IntlDateTimeFormat(locale, monthDayAmend(options));
55-
this[TIME] = new IntlDateTimeFormat(locale, timeAmend(options));
56-
this[DATETIME] = new IntlDateTimeFormat(locale, datetimeAmend(options));
57-
this[ZONED] = new IntlDateTimeFormat(locale, zonedDateTimeAmend(options));
58-
this[INST] = new IntlDateTimeFormat(locale, instantAmend(options));
97+
this[LOCALE] = ro.locale;
98+
this[ORIGINAL] = original;
99+
this[TZ_RESOLVED] = ro.timeZone;
100+
this[CAL_ID] = ro.calendar;
101+
this[DATE] = dateAmend;
102+
this[YM] = yearMonthAmend;
103+
this[MD] = monthDayAmend;
104+
this[TIME] = timeAmend;
105+
this[DATETIME] = datetimeAmend;
106+
this[ZONED] = zonedDateTimeAmend;
107+
this[INST] = instantAmend;
59108
}
60109

61110
DateTimeFormat.supportedLocalesOf = function (...args) {
@@ -85,6 +134,7 @@ function resolvedOptions() {
85134
function adjustFormatterTimeZone(formatter, timeZone) {
86135
if (!timeZone) return formatter;
87136
const options = formatter.resolvedOptions();
137+
if (options.timeZone === timeZone) return formatter;
88138
return new IntlDateTimeFormat(options.locale, { ...options, timeZone });
89139
}
90140

@@ -327,8 +377,8 @@ function extractOverrides(temporalObj, main) {
327377
const nanosecond = GetSlot(temporalObj, ISO_NANOSECOND);
328378
const datetime = new DateTime(1970, 1, 1, hour, minute, second, millisecond, microsecond, nanosecond, main[CAL_ID]);
329379
return {
330-
instant: ES.BuiltinTimeZoneGetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'),
331-
formatter: main[TIME]
380+
instant: ES.BuiltinTimeZoneGetInstantFor(getResolvedTimeZoneLazy(main), datetime, 'compatible'),
381+
formatter: getPropLazy(main, TIME)
332382
};
333383
}
334384

@@ -344,8 +394,8 @@ function extractOverrides(temporalObj, main) {
344394
}
345395
const datetime = new DateTime(isoYear, isoMonth, referenceISODay, 12, 0, 0, 0, 0, 0, calendar);
346396
return {
347-
instant: ES.BuiltinTimeZoneGetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'),
348-
formatter: main[YM]
397+
instant: ES.BuiltinTimeZoneGetInstantFor(getResolvedTimeZoneLazy(main), datetime, 'compatible'),
398+
formatter: getPropLazy(main, YM)
349399
};
350400
}
351401

@@ -361,8 +411,8 @@ function extractOverrides(temporalObj, main) {
361411
}
362412
const datetime = new DateTime(referenceISOYear, isoMonth, isoDay, 12, 0, 0, 0, 0, 0, calendar);
363413
return {
364-
instant: ES.BuiltinTimeZoneGetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'),
365-
formatter: main[MD]
414+
instant: ES.BuiltinTimeZoneGetInstantFor(getResolvedTimeZoneLazy(main), datetime, 'compatible'),
415+
formatter: getPropLazy(main, MD)
366416
};
367417
}
368418

@@ -376,8 +426,8 @@ function extractOverrides(temporalObj, main) {
376426
}
377427
const datetime = new DateTime(isoYear, isoMonth, isoDay, 12, 0, 0, 0, 0, 0, main[CAL_ID]);
378428
return {
379-
instant: ES.BuiltinTimeZoneGetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'),
380-
formatter: main[DATE]
429+
instant: ES.BuiltinTimeZoneGetInstantFor(getResolvedTimeZoneLazy(main), datetime, 'compatible'),
430+
formatter: getPropLazy(main, DATE)
381431
};
382432
}
383433

@@ -413,8 +463,8 @@ function extractOverrides(temporalObj, main) {
413463
);
414464
}
415465
return {
416-
instant: ES.BuiltinTimeZoneGetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'),
417-
formatter: main[DATETIME]
466+
instant: ES.BuiltinTimeZoneGetInstantFor(getResolvedTimeZoneLazy(main), datetime, 'compatible'),
467+
formatter: getPropLazy(main, DATETIME)
418468
};
419469
}
420470

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

435485
return {
436486
instant: GetSlot(temporalObj, INSTANT),
437-
formatter: main[ZONED],
487+
formatter: getPropLazy(main, ZONED),
438488
timeZone: objTimeZone
439489
};
440490
}
441491

442492
if (ES.IsTemporalInstant(temporalObj)) {
443493
return {
444494
instant: temporalObj,
445-
formatter: main[INST]
495+
formatter: getPropLazy(main, INST)
446496
};
447497
}
448498

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"browser": "dist/index.umd.js",
88
"types": "index.d.ts",
99
"scripts": {
10-
"test": "node --no-warnings --experimental-modules --icu-data-dir node_modules/full-icu --loader ./test/resolve.source.mjs ./test/all.mjs",
10+
"test": "time node --no-warnings --experimental-modules --icu-data-dir node_modules/full-icu --loader ./test/resolve.source.mjs ./test/all.mjs",
1111
"build": "rollup -c rollup.config.js",
1212
"prepublishOnly": "npm run build",
1313
"playground": "node --experimental-modules --no-warnings --icu-data-dir node_modules/full-icu -r ./lib/init.js",

test/intl.mjs

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,10 +1050,43 @@ describe('Intl', () => {
10501050
it('should return an Array', () => assert(Array.isArray(Intl.DateTimeFormat.supportedLocalesOf())));
10511051
});
10521052

1053-
const us = new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York' });
1054-
const at = new Intl.DateTimeFormat('de-AT', { timeZone: 'Europe/Vienna' });
1053+
// Verify that inputs to DateTimeFormat constructor are immune to mutation.
1054+
// Also verify that options properties are only read once.
1055+
const onlyOnce = (value) => {
1056+
const obj = {
1057+
calls: 0,
1058+
toString() {
1059+
if (++this.calls > 1) throw new RangeError('prop read twice');
1060+
return value;
1061+
}
1062+
};
1063+
return obj;
1064+
};
1065+
const optionsAT = {
1066+
timeZone: onlyOnce('Europe/Vienna')
1067+
};
1068+
const optionsUS = {
1069+
calls: 0,
1070+
value: 'America/New_York',
1071+
get timeZone() {
1072+
if (++this.calls > 1) throw new RangeError('prop read twice');
1073+
return this.value;
1074+
},
1075+
set timeZone(val) {
1076+
this.value = val;
1077+
}
1078+
};
1079+
const localesAT = ['de-AT'];
1080+
const us = new Intl.DateTimeFormat('en-US', optionsUS);
1081+
const at = new Intl.DateTimeFormat(localesAT, optionsAT);
1082+
optionsAT.timeZone = {
1083+
toString: () => 'Bogus/Time-Zone',
1084+
toJSON: () => 'Bogus/Time-Zone'
1085+
};
1086+
optionsUS.timeZone = 'Bogus/Time-Zone';
10551087
const us2 = new Intl.DateTimeFormat('en-US');
1056-
const at2 = new Intl.DateTimeFormat('de-AT');
1088+
const at2 = new Intl.DateTimeFormat(localesAT);
1089+
localesAT[0] = ['invalid locale'];
10571090
const usCalendar = us.resolvedOptions().calendar;
10581091
const atCalendar = at.resolvedOptions().calendar;
10591092
const t1 = '1976-11-18T14:23:30+00:00[UTC]';

0 commit comments

Comments
 (0)