Skip to content

Commit f02f5be

Browse files
committed
Polyfill: Refactor time zone identifier handling
1 parent bbffae9 commit f02f5be

File tree

5 files changed

+766
-25
lines changed

5 files changed

+766
-25
lines changed

polyfill/lib/ecmascript.mjs

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const ArrayIncludes = Array.prototype.includes;
44
const ArrayPrototypePush = Array.prototype.push;
55
const ArrayPrototypeSort = Array.prototype.sort;
66
const IntlDateTimeFormat = globalThis.Intl.DateTimeFormat;
7+
const IntlSupportedValuesOf = globalThis.Intl.supportedValuesOf;
78
const MathAbs = Math.abs;
89
const MathFloor = Math.floor;
910
const MathMax = Math.max;
@@ -368,7 +369,9 @@ export function ParseTemporalTimeZone(stringIdent) {
368369
const { tzName, offset, z } = ParseTemporalTimeZoneString(stringIdent);
369370
if (tzName) {
370371
if (IsTimeZoneOffsetString(tzName)) return CanonicalizeTimeZoneOffsetString(tzName);
371-
return GetCanonicalTimeZoneIdentifier(tzName);
372+
const record = GetAvailableNamedTimeZoneIdentifier(tzName);
373+
if (!record) throw new RangeError(`Unrecognized time zone ${tzName}`);
374+
return record.primaryIdentifier;
372375
}
373376
if (z) return 'UTC';
374377
// if !tzName && !z then offset must be present
@@ -2589,15 +2592,92 @@ export function ParseTimeZoneOffsetString(string) {
25892592
return sign * (((hours * 60 + minutes) * 60 + seconds) * 1e9 + nanoseconds);
25902593
}
25912594

2592-
// In the spec, GetCanonicalTimeZoneIdentifier is infallible and is always
2593-
// preceded by a call to IsAvailableTimeZoneName. However in the polyfill,
2594-
// we don't (yet) have a way to check if a time zone ID is valid without
2595-
// also canonicalizing it. So we combine both operations into one function,
2596-
// which will return the canonical ID if the ID is valid, and will throw
2597-
// if it's not.
2598-
export function GetCanonicalTimeZoneIdentifier(timeZoneIdentifier) {
2599-
const formatter = getIntlDateTimeFormatEnUsForTimeZone(timeZoneIdentifier);
2600-
return formatter.resolvedOptions().timeZone;
2595+
let canonicalTimeZoneIdsCache = undefined;
2596+
2597+
export function GetAvailableNamedTimeZoneIdentifier(identifier) {
2598+
// TODO: should there be an assertion here that IsTimeZoneOffsetString returns false?
2599+
if (IsTimeZoneOffsetString(identifier)) return undefined;
2600+
2601+
// The most common case is when the identifier is a canonical time zone ID.
2602+
// Fast-path that case by caching a list of canonical IDs. If this is an old
2603+
// ECMAScript implementation that doesn't have this API, we'll set the cache
2604+
// to `null` so we won't bother trying again.
2605+
if (canonicalTimeZoneIdsCache === undefined) {
2606+
const canonicalTimeZoneIds = IntlSupportedValuesOf?.('timeZone') ?? null;
2607+
if (canonicalTimeZoneIds) {
2608+
const entries = canonicalTimeZoneIds.map((id) => [ASCIILowercase(id), id]);
2609+
canonicalTimeZoneIdsCache = new Map(entries);
2610+
}
2611+
}
2612+
2613+
const lower = ASCIILowercase(identifier);
2614+
let primaryIdentifier = canonicalTimeZoneIdsCache?.get(lower);
2615+
if (primaryIdentifier) return { identifier: primaryIdentifier, primaryIdentifier };
2616+
2617+
// It's not already a primary identifier, so get its primary identifier (or
2618+
// return if it's not an available named time zone ID).
2619+
try {
2620+
const formatter = getIntlDateTimeFormatEnUsForTimeZone(identifier);
2621+
primaryIdentifier = formatter.resolvedOptions().timeZone;
2622+
} catch {
2623+
return undefined;
2624+
}
2625+
2626+
// The identifier is an alias (a deprecated identifier that's a synonym for
2627+
// a primary identifier), so we need to case-normalize the identifier to
2628+
// match the IANA TZDB, e.g. america/new_york => America/New_York. There's
2629+
// no built-in way to do this using Intl.DateTimeFormat, but the we can
2630+
// normalize almost all aliases (modulo a few special cases) using the
2631+
// TZDB's basic capitalization pattern:
2632+
// 1. capitalize the first letter of the identifier
2633+
// 2. capitalize the letter after every slash, dash, or underscore delimiter
2634+
const standardCase = [...lower]
2635+
.map((c, i) => (i === 0 || '/-_'.includes(lower[i - 1]) ? c.toUpperCase() : c))
2636+
.join('');
2637+
const segments = standardCase.split('/');
2638+
2639+
if (segments.length === 1) {
2640+
// For single-segment legacy IDs, if it's 2-3 chars or contains a number or dash, then
2641+
// (except for the "GB-Eire" special case) the case-normalized form is all uppercase.
2642+
// GMT+0, GMT-0, ACT, LHI, NSW, GB, NZ, PRC, ROC, ROK, UCT, GMT, GMT0,
2643+
// CET, CST6CDT, EET, EST, HST, MET, MST, MST7MDT, PST8PDT, WET, NZ-CHAT, W-SU
2644+
// Otherwise it's the standard form: first letter capitalized, e.g. Iran, Egypt, Hongkong
2645+
if (lower === 'gb-eire') return { identifier: 'GB-Eire', primaryIdentifier };
2646+
return {
2647+
identifier: lower.length <= 3 || /[-0-9]/.test(lower) ? lower.toUpperCase() : segments[0],
2648+
primaryIdentifier
2649+
};
2650+
}
2651+
2652+
// All Etc zone names are upper case except a few exceptions.
2653+
if (segments[0] === 'Etc') {
2654+
const etcName = ['Zulu', 'Greenwich', 'Universal'].includes(segments[1]) ? segments[1] : segments[1].toUpperCase();
2655+
return { identifier: `Etc/${etcName}`, primaryIdentifier };
2656+
}
2657+
2658+
// Legacy US identifiers like US/Alaska or US/Indiana-Starke. They're always 2 segments and use standard case.
2659+
if (segments[0] === 'Us') return { identifier: `US/${segments[1]}`, primaryIdentifier };
2660+
2661+
// For multi-segment IDs, there's a few special cases in the second/third segments
2662+
const specialCases = {
2663+
Act: 'ACT',
2664+
Lhi: 'LHI',
2665+
Nsw: 'NSW',
2666+
Dar_Es_Salaam: 'Dar_es_Salaam',
2667+
Port_Of_Spain: 'Port_of_Spain',
2668+
Isle_Of_Man: 'Isle_of_Man',
2669+
Comodrivadavia: 'ComodRivadavia',
2670+
Knox_In: 'Knox_IN',
2671+
Dumontdurville: 'DumontDUrville',
2672+
Mcmurdo: 'McMurdo',
2673+
Denoronha: 'DeNoronha',
2674+
Easterisland: 'EasterIsland',
2675+
Bajanorte: 'BajaNorte',
2676+
Bajasur: 'BajaSur'
2677+
};
2678+
segments[1] = specialCases[segments[1]] ?? segments[1];
2679+
if (segments.length > 2) segments[2] = specialCases[segments[2]] ?? segments[2];
2680+
return { identifier: segments.join('/'), primaryIdentifier };
26012681
}
26022682

26032683
export function GetNamedTimeZoneOffsetNanoseconds(id, epochNanoseconds) {

polyfill/lib/intl.mjs

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ const TIME = Symbol('time');
2121
const DATETIME = Symbol('datetime');
2222
const INST = Symbol('instant');
2323
const ORIGINAL = Symbol('original');
24-
const TZ_RESOLVED = Symbol('timezone');
24+
const TZ_CANONICAL = Symbol('timezone-canonical');
25+
const TZ_ORIGINAL = Symbol('timezone-original');
2526
const CAL_ID = Symbol('calendar-id');
2627
const LOCALE = Symbol('locale');
2728
const OPTIONS = Symbol('options');
@@ -81,14 +82,33 @@ export function DateTimeFormat(locale = undefined, options = undefined) {
8182

8283
this[LOCALE] = ro.locale;
8384
this[ORIGINAL] = original;
84-
this[TZ_RESOLVED] = ro.timeZone;
85+
this[TZ_CANONICAL] = ro.timeZone;
8586
this[CAL_ID] = ro.calendar;
8687
this[DATE] = dateAmend;
8788
this[YM] = yearMonthAmend;
8889
this[MD] = monthDayAmend;
8990
this[TIME] = timeAmend;
9091
this[DATETIME] = datetimeAmend;
9192
this[INST] = instantAmend;
93+
94+
// Save the original time zone, for a few reasons:
95+
// - Clearer error messages
96+
// - More clearly follows the spec for InitializeDateTimeFormat
97+
// - Because it follows the spec more closely, will make it easier to integrate
98+
// support of offset strings and other potential changes like proposal-canonical-tz.
99+
const timeZoneOption = hasOptions ? options.timeZone : undefined;
100+
if (timeZoneOption === undefined) {
101+
this[TZ_ORIGINAL] = ro.timeZone;
102+
} else {
103+
const id = ES.ToString(timeZoneOption);
104+
if (ES.IsTimeZoneOffsetString(id)) {
105+
// Note: https://github.com/tc39/ecma402/issues/683 will remove this
106+
throw new RangeError('Intl.DateTimeFormat does not currently support offset time zones');
107+
}
108+
const record = ES.GetAvailableNamedTimeZoneIdentifier(id);
109+
if (!record) throw new RangeError(`Intl.DateTimeFormat formats built-in time zones, not ${id}`);
110+
this[TZ_ORIGINAL] = record.primaryIdentifier;
111+
}
92112
}
93113

94114
DateTimeFormat.supportedLocalesOf = function (...args) {
@@ -118,7 +138,9 @@ Object.defineProperty(DateTimeFormat, 'prototype', {
118138
});
119139

120140
function resolvedOptions() {
121-
return this[ORIGINAL].resolvedOptions();
141+
const resolved = this[ORIGINAL].resolvedOptions();
142+
resolved.timeZone = this[TZ_ORIGINAL];
143+
return resolved;
122144
}
123145

124146
function format(datetime, ...rest) {
@@ -335,7 +357,7 @@ function extractOverrides(temporalObj, main) {
335357
const nanosecond = GetSlot(temporalObj, ISO_NANOSECOND);
336358
const datetime = new DateTime(1970, 1, 1, hour, minute, second, millisecond, microsecond, nanosecond, main[CAL_ID]);
337359
return {
338-
instant: ES.GetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'),
360+
instant: ES.GetInstantFor(main[TZ_CANONICAL], datetime, 'compatible'),
339361
formatter: getPropLazy(main, TIME)
340362
};
341363
}
@@ -352,7 +374,7 @@ function extractOverrides(temporalObj, main) {
352374
}
353375
const datetime = new DateTime(isoYear, isoMonth, referenceISODay, 12, 0, 0, 0, 0, 0, calendar);
354376
return {
355-
instant: ES.GetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'),
377+
instant: ES.GetInstantFor(main[TZ_CANONICAL], datetime, 'compatible'),
356378
formatter: getPropLazy(main, YM)
357379
};
358380
}
@@ -369,7 +391,7 @@ function extractOverrides(temporalObj, main) {
369391
}
370392
const datetime = new DateTime(referenceISOYear, isoMonth, isoDay, 12, 0, 0, 0, 0, 0, calendar);
371393
return {
372-
instant: ES.GetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'),
394+
instant: ES.GetInstantFor(main[TZ_CANONICAL], datetime, 'compatible'),
373395
formatter: getPropLazy(main, MD)
374396
};
375397
}
@@ -384,7 +406,7 @@ function extractOverrides(temporalObj, main) {
384406
}
385407
const datetime = new DateTime(isoYear, isoMonth, isoDay, 12, 0, 0, 0, 0, 0, main[CAL_ID]);
386408
return {
387-
instant: ES.GetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'),
409+
instant: ES.GetInstantFor(main[TZ_CANONICAL], datetime, 'compatible'),
388410
formatter: getPropLazy(main, DATE)
389411
};
390412
}
@@ -421,7 +443,7 @@ function extractOverrides(temporalObj, main) {
421443
);
422444
}
423445
return {
424-
instant: ES.GetInstantFor(main[TZ_RESOLVED], datetime, 'compatible'),
446+
instant: ES.GetInstantFor(main[TZ_CANONICAL], datetime, 'compatible'),
425447
formatter: getPropLazy(main, DATETIME)
426448
};
427449
}

polyfill/lib/timezone.mjs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,13 @@ export class TimeZone {
2626
if (arguments.length < 1) {
2727
throw new RangeError('missing argument: identifier is required');
2828
}
29-
3029
let stringIdentifier = ES.ToString(identifier);
3130
if (ES.IsTimeZoneOffsetString(stringIdentifier)) {
3231
stringIdentifier = ES.CanonicalizeTimeZoneOffsetString(stringIdentifier);
3332
} else {
34-
stringIdentifier = ES.GetCanonicalTimeZoneIdentifier(stringIdentifier);
33+
const record = ES.GetAvailableNamedTimeZoneIdentifier(stringIdentifier);
34+
if (!record) throw new RangeError(`Invalid time zone identifier: ${stringIdentifier}`);
35+
stringIdentifier = record.primaryIdentifier;
3536
}
3637
CreateSlots(this);
3738
SetSlot(this, TIMEZONE_ID, stringIdentifier);

polyfill/lib/zoneddatetime.mjs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -471,13 +471,15 @@ export class ZonedDateTime {
471471
// The rest of the defaults will be filled in by formatting the Instant
472472
}
473473

474-
let timeZone = ES.ToTemporalTimeZoneIdentifier(GetSlot(this, TIME_ZONE));
475-
if (ES.IsTimeZoneOffsetString(timeZone)) {
474+
const timeZoneIdentifier = ES.ToTemporalTimeZoneIdentifier(GetSlot(this, TIME_ZONE));
475+
if (ES.IsTimeZoneOffsetString(timeZoneIdentifier)) {
476476
// Note: https://github.com/tc39/ecma402/issues/683 will remove this
477-
throw new RangeError('toLocaleString does not support offset string time zones');
477+
throw new RangeError('toLocaleString does not currently support offset time zones');
478+
} else {
479+
const record = ES.GetAvailableNamedTimeZoneIdentifier(timeZoneIdentifier);
480+
if (!record) throw new RangeError(`toLocaleString formats built-in time zones, not ${timeZoneIdentifier}`);
481+
optionsCopy.timeZone = record.primaryIdentifier;
478482
}
479-
timeZone = ES.GetCanonicalTimeZoneIdentifier(timeZone);
480-
optionsCopy.timeZone = timeZone;
481483

482484
const formatter = new DateTimeFormat(locales, optionsCopy);
483485

0 commit comments

Comments
 (0)