Skip to content

Commit

Permalink
Normative: Make ZonedDateTime.toLocaleString work without DateTimeFormat
Browse files Browse the repository at this point in the history
See PR #2479 about which a consensus was not reached. This change allows
Temporal.ZonedDateTime.prototype.toLocaleString to work by overriding the
time zone at the time of creating an Intl.DateTimeFormat object and
formatting the corresponding Temporal.Instant, but disallows calling any
of the Intl.DateTimeFormat methods on a Temporal.ZonedDateTime.

NOTE: The reference code does not implement the spec exactly as written.
It observably modifies the options before passing them to the real
Intl.DateTimeFormat constructor. The behaviour described in the spec is
the correct behaviour.
  • Loading branch information
ptomato committed Apr 13, 2023
1 parent 654321b commit b8e56c2
Show file tree
Hide file tree
Showing 3 changed files with 93 additions and 107 deletions.
77 changes: 12 additions & 65 deletions polyfill/lib/intl.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { ES } from './ecmascript.mjs';
import { GetIntrinsic } from './intrinsicclass.mjs';
import {
GetSlot,
INSTANT,
ISO_YEAR,
ISO_MONTH,
ISO_DAY,
Expand All @@ -12,20 +11,17 @@ import {
ISO_MILLISECOND,
ISO_MICROSECOND,
ISO_NANOSECOND,
CALENDAR,
TIME_ZONE
CALENDAR
} from './slots.mjs';

const DATE = Symbol('date');
const YM = Symbol('ym');
const MD = Symbol('md');
const TIME = Symbol('time');
const DATETIME = Symbol('datetime');
const ZONED = Symbol('zoneddatetime');
const INST = Symbol('instant');
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');
Expand Down Expand Up @@ -83,7 +79,6 @@ export function DateTimeFormat(locale = undefined, options = undefined) {
this[OPTIONS] = options;
}

this[TZ_GIVEN] = options.timeZone ? options.timeZone : null;
this[LOCALE] = ro.locale;
this[ORIGINAL] = original;
this[TZ_RESOLVED] = ro.timeZone;
Expand All @@ -93,7 +88,6 @@ export function DateTimeFormat(locale = undefined, options = undefined) {
this[MD] = monthDayAmend;
this[TIME] = timeAmend;
this[DATETIME] = datetimeAmend;
this[ZONED] = zonedDateTimeAmend;
this[INST] = instantAmend;
}

Expand Down Expand Up @@ -127,26 +121,17 @@ function resolvedOptions() {
return this[ORIGINAL].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 });
}

function format(datetime, ...rest) {
let { instant, formatter, timeZone } = extractOverrides(datetime, this);
let { instant, formatter } = extractOverrides(datetime, this);
if (instant && formatter) {
formatter = adjustFormatterTimeZone(formatter, timeZone);
return formatter.format(instant.epochMilliseconds);
}
return this[ORIGINAL].format(datetime, ...rest);
}

function formatToParts(datetime, ...rest) {
let { instant, formatter, timeZone } = extractOverrides(datetime, this);
let { instant, formatter } = extractOverrides(datetime, this);
if (instant && formatter) {
formatter = adjustFormatterTimeZone(formatter, timeZone);
return formatter.formatToParts(instant.epochMilliseconds);
}
return this[ORIGINAL].formatToParts(datetime, ...rest);
Expand All @@ -157,14 +142,10 @@ function formatRange(a, b) {
if (!sameTemporalType(a, b)) {
throw new TypeError('Intl.DateTimeFormat.formatRange accepts two values of the same type');
}
const { instant: aa, formatter: aformatter, timeZone: atz } = extractOverrides(a, this);
const { instant: bb, formatter: bformatter, timeZone: btz } = extractOverrides(b, this);
if (atz && btz && atz !== btz) {
throw new RangeError('cannot format range between different time zones');
}
const { instant: aa, formatter: aformatter } = extractOverrides(a, this);
const { instant: bb, formatter: bformatter } = extractOverrides(b, this);
if (aa && bb && aformatter && bformatter && aformatter === bformatter) {
const formatter = adjustFormatterTimeZone(aformatter, atz);
return formatter.formatRange(aa.epochMilliseconds, bb.epochMilliseconds);
return aformatter.formatRange(aa.epochMilliseconds, bb.epochMilliseconds);
}
}
return this[ORIGINAL].formatRange(a, b);
Expand All @@ -175,14 +156,10 @@ function formatRangeToParts(a, b) {
if (!sameTemporalType(a, b)) {
throw new TypeError('Intl.DateTimeFormat.formatRangeToParts accepts two values of the same type');
}
const { instant: aa, formatter: aformatter, timeZone: atz } = extractOverrides(a, this);
const { instant: bb, formatter: bformatter, timeZone: btz } = extractOverrides(b, this);
if (atz && btz && atz !== btz) {
throw new RangeError('cannot format range between different time zones');
}
const { instant: aa, formatter: aformatter } = extractOverrides(a, this);
const { instant: bb, formatter: bformatter } = extractOverrides(b, this);
if (aa && bb && aformatter && bformatter && aformatter === bformatter) {
const formatter = adjustFormatterTimeZone(aformatter, atz);
return formatter.formatRangeToParts(aa.epochMilliseconds, bb.epochMilliseconds);
return aformatter.formatRangeToParts(aa.epochMilliseconds, bb.epochMilliseconds);
}
}
return this[ORIGINAL].formatRangeToParts(a, b);
Expand Down Expand Up @@ -298,21 +275,6 @@ function datetimeAmend(options) {
return options;
}

function zonedDateTimeAmend(options) {
if (!hasTimeOptions(options) && !hasDateOptions(options)) {
options = ObjectAssign({}, options, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric'
});
if (options.timeZoneName === undefined) options.timeZoneName = 'short';
}
return options;
}

function instantAmend(options) {
if (!hasTimeOptions(options) && !hasDateOptions(options)) {
options = ObjectAssign({}, options, {
Expand Down Expand Up @@ -465,24 +427,9 @@ function extractOverrides(temporalObj, main) {
}

if (ES.IsTemporalZonedDateTime(temporalObj)) {
const calendar = ES.ToTemporalCalendarIdentifier(GetSlot(temporalObj, CALENDAR));
if (calendar !== 'iso8601' && calendar !== main[CAL_ID]) {
throw new RangeError(
`cannot format ZonedDateTime with calendar ${calendar} in locale with calendar ${main[CAL_ID]}`
);
}

let timeZone = GetSlot(temporalObj, TIME_ZONE);
const objTimeZone = ES.ToTemporalTimeZoneIdentifier(timeZone);
if (main[TZ_GIVEN] && main[TZ_GIVEN] !== objTimeZone) {
throw new RangeError(`timeZone option ${main[TZ_GIVEN]} doesn't match actual time zone ${objTimeZone}`);
}

return {
instant: GetSlot(temporalObj, INSTANT),
formatter: getPropLazy(main, ZONED),
timeZone: objTimeZone
};
throw new TypeError(
'Temporal.ZonedDateTime not supported in DateTimeFormat methods. Use toLocaleString() instead.'
);
}

if (ES.IsTemporalInstant(temporalObj)) {
Expand Down
56 changes: 55 additions & 1 deletion polyfill/lib/zoneddatetime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import bigInt from 'big-integer';

const ArrayPrototypePush = Array.prototype.push;
const customResolvedOptions = DateTimeFormat.prototype.resolvedOptions;
const ObjectCreate = Object.create;

export class ZonedDateTime {
Expand Down Expand Up @@ -441,7 +442,60 @@ export class ZonedDateTime {
}
toLocaleString(locales = undefined, options = undefined) {
if (!ES.IsTemporalZonedDateTime(this)) throw new TypeError('invalid receiver');
return new DateTimeFormat(locales, options).format(this);
options = ES.GetOptionsObject(options);

const optionsCopy = ObjectCreate(null);
// This is not quite per specification, but this polyfill's DateTimeFormat
// already doesn't match the InitializeDateTimeFormat operation, and the
// access order might change anyway;
// see https://github.com/tc39/ecma402/issues/747
ES.CopyDataProperties(optionsCopy, options, ['timeZone']);

if (options.timeZone !== undefined) {
throw new TypeError('ZonedDateTime toLocaleString does not accept a timeZone option');
}

if (
optionsCopy.year === undefined &&
optionsCopy.month === undefined &&
optionsCopy.day === undefined &&
optionsCopy.weekday === undefined &&
optionsCopy.dateStyle === undefined &&
optionsCopy.hour === undefined &&
optionsCopy.minute === undefined &&
optionsCopy.second === undefined &&
optionsCopy.timeStyle === undefined &&
optionsCopy.dayPeriod === undefined &&
optionsCopy.timeZoneName === undefined
) {
optionsCopy.timeZoneName = 'short';
// The rest of the defaults will be filled in by formatting the Instant
}

let timeZone = ES.ToTemporalTimeZoneIdentifier(GetSlot(this, TIME_ZONE));
if (ES.IsTimeZoneOffsetString(timeZone)) {
// Note: https://github.com/tc39/ecma402/issues/683 will remove this
throw new RangeError('toLocaleString does not support offset string time zones');
}
timeZone = ES.GetCanonicalTimeZoneIdentifier(timeZone);
optionsCopy.timeZone = timeZone;

const formatter = new DateTimeFormat(locales, optionsCopy);

const localeCalendarIdentifier = ES.Call(customResolvedOptions, formatter, []).calendar;
const calendarIdentifier = ES.ToTemporalCalendarIdentifier(GetSlot(this, CALENDAR));
if (
calendarIdentifier !== 'iso8601' &&
localeCalendarIdentifier !== 'iso8601' &&
localeCalendarIdentifier !== calendarIdentifier
) {
throw new RangeError(
`cannot format ZonedDateTime with calendar ${calendarIdentifier}` +
` in locale with calendar ${localeCalendarIdentifier}`
);
}

return formatter.format(GetSlot(this, INSTANT));
}
toJSON() {
if (!ES.IsTemporalZonedDateTime(this)) throw new TypeError('invalid receiver');
Expand Down
67 changes: 26 additions & 41 deletions spec/intl.html
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,13 @@ <h1>
<h1><a href="https://tc39.es/ecma402/#sec-datetimeformat-abstracts">Abstract Operations For DateTimeFormat Objects</a></h1>

<emu-clause id="sec-temporal-initializedatetimeformat" aoid="InitializeDateTimeFormat">
<h1>InitializeDateTimeFormat ( _dateTimeFormat_, _locales_, _options_ )</h1>
<h1>InitializeDateTimeFormat ( _dateTimeFormat_, _locales_, _options_ [ , <ins>_toLocaleStringTimeZone_</ins> ] )</h1>

<p>
The abstract operation InitializeDateTimeFormat accepts the arguments _dateTimeFormat_ (which must be an object), _locales_, and _options_. It initializes _dateTimeFormat_ as a DateTimeFormat object. This abstract operation functions as follows:
The abstract operation InitializeDateTimeFormat accepts the arguments _dateTimeFormat_ (which must be an object), _locales_, and _options_.
It initializes _dateTimeFormat_ as a DateTimeFormat object.
<ins>If an additional _toLocaleStringTimeZone_ argument is provided (which, if present, must be a canonical time zone name string), the time zone will be overridden and some adjustments will be made to the defaults in order to implement the behaviour of `Temporal.ZonedDateTime.prototype.toLocaleString`.</ins>
This abstract operation functions as follows:
</p>

<p>
Expand Down Expand Up @@ -238,8 +241,12 @@ <h1>InitializeDateTimeFormat ( _dateTimeFormat_, _locales_, _options_ )</h1>
1. <del>Set _dateTimeFormat_.[[HourCycle]] to _hc_.</del>
1. Let _timeZone_ be ? Get(_options_, *"timeZone"*).
1. If _timeZone_ is *undefined*, then
1. Set _timeZone_ to DefaultTimeZone().
1. <ins>If _toLocaleStringTimeZone_ is present, then</ins>
1. <ins>Set _timeZone_ to _toLocaleStringTimeZone_.</ins>
1. <ins>Else,</ins>
1. Set _timeZone_ to DefaultTimeZone().
1. Else,
1. <ins>If _toLocaleStringTimeZone_ is present, throw a *TypeError* exception.</ins>
1. Set _timeZone_ to ? ToString(_timeZone_).
1. If <del>the result of IsValidTimeZoneName(_timeZone_)</del><ins>IsAvailableTimeZoneName(_timeZone_)</ins> is *false*, then
1. Throw a *RangeError* exception.
Expand Down Expand Up @@ -308,8 +315,9 @@ <h1>InitializeDateTimeFormat ( _dateTimeFormat_, _locales_, _options_ )</h1>
1. <ins>Set _limitedOptions_.[[&lt;_field_&gt;]] to _formatOptions_.[[&lt;_field_&gt;]].</ins>
1. <ins>If _needDefaults_ is *true*, then</ins>
1. <ins>Let _defaultFields_ be the list of fields in the Default fields column of the row.</ins>
1. <ins>If the Pattern column of the row is [[TemporalInstantPattern]], and _toLocaleStringTimeZone_ is present, append [[timeZoneName]] to _defaultFields_.</ins>
1. <ins>For each element _field_ of _defaultFields_, do</ins>
1. <ins>If _field_ is *"timeZoneName"*, then</ins>
1. <ins>If _field_ is [[timeZoneName]], then</ins>
1. <ins>Let _defaultValue_ be *"short"*.</ins>
1. <ins>Else,</ins>
1. <ins>Let _defaultValue_ be *"numeric"*.</ins>
Expand Down Expand Up @@ -360,13 +368,8 @@ <h1>InitializeDateTimeFormat ( _dateTimeFormat_, _locales_, _options_ )</h1>
</tr>
<tr>
<th>[[TemporalInstantPattern]]</th>
<td>[[weekday]], [[era]], [[year]], [[month]], [[day]], [[hour]], [[minute]], [[second]], [[dayPeriod]], [[fractionalSecondDigits]]</td>
<td>[[year]], [[month]], [[day]], [[hour]], [[minute]], [[second]]</td>
</tr>
<tr>
<th>[[TemporalZonedDateTimePattern]]</th>
<td>[[weekday]], [[era]], [[year]], [[month]], [[day]], [[hour]], [[minute]], [[second]], [[dayPeriod]], [[fractionalSecondDigits]], [[timeZoneName]]</td>
<td>[[year]], [[month]], [[day]], [[hour]], [[minute]], [[second]], [[timeZoneName]]</td>
<td>[[year]], [[month]], [[day]], [[hour]], [[minute]], [[second]]</td>
</tr>
</tbody>
</table>
Expand Down Expand Up @@ -889,33 +892,6 @@ <h1>HandleDateTimeTemporalInstant ( _dateTimeFormat_, _instant_ )</h1>
</emu-alg>
</emu-clause>
</ins>
<ins class="block">
<emu-clause id="sec-temporal-handledatetimevaluetemporalzoneddatetime" aoid="HandleDateTimeTemporalZonedDateTime">
<h1>HandleDateTimeTemporalZonedDateTime ( _dateTimeFormat_, _zonedDateTime_ )</h1>

<p>
The abstract operation HandleDateTimeTemporalZonedDateTime accepts the arguments _dateTimeFormat_ (which must be an object initialized as a DateTimeFormat) and _zonedDateTime_ (which must be an ECMAScript value has an [[InitializedTemporalDateTime]] internal slot). It returns a record which contains the appropriate pattern and epochNanoseconds values for the input. This abstract operation functions as follows:
</p>

<emu-alg>
1. Assert: _zonedDateTime_ has an [[InitializedTemporalZonedDateTime]] internal slot.
1. Let _pattern_ be _dateTimeFormat_.[[TemporalZonedDateTimePattern]].
1. Let _calendar_ be ? ToTemporalCalendarIdentifier(_zonedDateTime_.[[Calendar]]).
1. If _calendar_ is not *"iso8601"* and not equal to _dateTimeFormat_.[[Calendar]], then
1. Throw a *RangeError* exception.
1. Let _timeZone_ be ? ToTemporalTimeZoneIdentifier(_zonedDateTime_.[[TimeZone]]).
1. If _dateTimeFormat_.[[TimeZone]] is not equal to DefaultTimeZone(), and _timeZone_ is not equal to _dateTimeFormat_.[[TimeZone]], then
1. Throw a *RangeError* exception.
1. Let _instant_ be ! CreateTemporalInstant(_zonedDateTime_.[[Nanoseconds]]).
1. If _pattern_ is *null*, throw a *TypeError* exception.
1. Return the Record {
[[pattern]]: _pattern_.[[pattern]],
[[rangePatterns]]: _pattern_.[[rangePatterns]],
[[epochNanoseconds]]: _instant_.[[Nanoseconds]]
}.
</emu-alg>
</emu-clause>
</ins>

<ins class="block">
<emu-clause id="sec-temporal-handledatetimeothers" aoid="HandleDateTimeOthers">
Expand Down Expand Up @@ -967,7 +943,7 @@ <h1>HandleDateTimeValue ( _dateTimeFormat_, _x_ )</h1>
1. If _x_ has an [[InitializedTemporalInstant]] internal slot, then
1. Return ? HandleDateTimeTemporalInstant(_dateTimeFormat_, _x_).
1. Assert: _x_ has an [[InitializedTemporalZonedDateTime]] internal slot.
1. Return ? HandleDateTimeTemporalZonedDateTime(_dateTimeFormat_, _x_).
1. Throw a *TypeError* exception.
1. Return ? HandleDateTimeOthers(_dateTimeFormat_, _x_).
</emu-alg>
</emu-clause>
Expand Down Expand Up @@ -1333,7 +1309,7 @@ <h1><a href="https://tc39.es/ecma402/#sec-properties-of-intl-datetimeformat-inst
<li>[[DateStyle]], [[TimeStyle]] are each either *undefined*, or a String value with values *"full"*, *"long"*, *"medium"*, or *"short"*.</li>
<li>[[Pattern]] is a String value as described in <emu-xref href="#sec-intl.datetimeformat-internal-slots"></emu-xref>.</li>
<li>[[RangePatterns]] is a Record as described in <emu-xref href="#sec-intl.datetimeformat-internal-slots"></emu-xref>.</li>
<li><ins>[[TemporalPlainDatePattern]], [[TemporalPlainYearMonthPattern]], [[TemporalPlainMonthDayPattern]], [[TemporalPlainTimePattern]], [[TemporalPlainDateTimePattern]], [[TemporalInstantPattern]], and [[TemporalZonedDateTimePattern]] are records containing at least a [[pattern]] field as described in <emu-xref href="#sec-intl.datetimeformat-internal-slots"></emu-xref>.</ins></li>
<li><ins>[[TemporalPlainDatePattern]], [[TemporalPlainYearMonthPattern]], [[TemporalPlainMonthDayPattern]], [[TemporalPlainTimePattern]], [[TemporalPlainDateTimePattern]], and [[TemporalInstantPattern]] are records containing at least a [[pattern]] field as described in <emu-xref href="#sec-intl.datetimeformat-internal-slots"></emu-xref>.</ins></li>
</ul>

<p>
Expand Down Expand Up @@ -2489,8 +2465,17 @@ <h1>Temporal.ZonedDateTime.prototype.toLocaleString ( [ _locales_ [ , _options_
<emu-alg>
1. Let _zonedDateTime_ be the *this* value.
1. Perform ? RequireInternalSlot(_zonedDateTime_, [[InitializedTemporalZonedDateTime]]).
1. Let _dateFormat_ be ? Construct(%DateTimeFormat%, « _locales_, _options_ »).
1. Return ? FormatDateTime(_dateFormat_, _zonedDateTime_).
1. Let _dateTimeFormat_ be ! OrdinaryCreateFromConstructor(%DateTimeFormat%, %DateTimeFormat.protoytpe%, « [[InitializedDateTimeFormat]], [[Locale]], [[Calendar]], [[NumberingSystem]], [[TimeZone]], [[Weekday]], [[Era]], [[Year]], [[Month]], [[Day]], [[DayPeriod]], [[Hour]], [[Minute]], [[Second]], [[FractionalSecondDigits]], [[TimeZoneName]], [[HourCycle]], [[Pattern]], [[BoundFormat]] »).
1. Let _timeZone_ be ? ToTemporalTimeZoneIdentifier(_zonedDateTime_.[[TimeZone]]).
1. If IsTimeZoneOffsetString(_timeZone_) is *true*, throw a *RangeError* exception.
1. If IsAvailableTimeZoneName(_timeZone_) is *false*, throw a *RangeError* exception.
1. Set _timeZone_ to CanonicalizeTimeZoneName(_timeZone_).
1. Perform ? InitializeDateTimeFormat(_dateTimeFormat_, _locales_, _options_, _timeZone_).
1. Let _calendar_ be ? ToTemporalCalendarIdentifier(_zonedDateTime_.[[Calendar]]).
1. If _calendar_ is not *"iso8601"* and not equal to _dateTimeFormat_.[[Calendar]], then
1. Throw a *RangeError* exception.
1. Let _instant_ be ! CreateTemporalInstant(_zonedDateTime_.[[Nanoseconds]]).
1. Return ? FormatDateTime(_dateTimeFormat_, _instant_).
</emu-alg>
</emu-clause>

Expand Down

0 comments on commit b8e56c2

Please sign in to comment.