From b8e56c21cefbdb0ea68c3380a9d83c702461e0b9 Mon Sep 17 00:00:00 2001 From: Philip Chimento Date: Tue, 21 Mar 2023 09:24:30 -0700 Subject: [PATCH] Normative: Make ZonedDateTime.toLocaleString work without DateTimeFormat 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. --- polyfill/lib/intl.mjs | 77 ++++++---------------------------- polyfill/lib/zoneddatetime.mjs | 56 ++++++++++++++++++++++++- spec/intl.html | 67 ++++++++++++----------------- 3 files changed, 93 insertions(+), 107 deletions(-) diff --git a/polyfill/lib/intl.mjs b/polyfill/lib/intl.mjs index a0ee6899a5..610bcbabec 100644 --- a/polyfill/lib/intl.mjs +++ b/polyfill/lib/intl.mjs @@ -2,7 +2,6 @@ import { ES } from './ecmascript.mjs'; import { GetIntrinsic } from './intrinsicclass.mjs'; import { GetSlot, - INSTANT, ISO_YEAR, ISO_MONTH, ISO_DAY, @@ -12,8 +11,7 @@ import { ISO_MILLISECOND, ISO_MICROSECOND, ISO_NANOSECOND, - CALENDAR, - TIME_ZONE + CALENDAR } from './slots.mjs'; const DATE = Symbol('date'); @@ -21,11 +19,9 @@ 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'); @@ -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; @@ -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; } @@ -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); @@ -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); @@ -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); @@ -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, { @@ -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)) { diff --git a/polyfill/lib/zoneddatetime.mjs b/polyfill/lib/zoneddatetime.mjs index a665b836cb..41b9d83d23 100644 --- a/polyfill/lib/zoneddatetime.mjs +++ b/polyfill/lib/zoneddatetime.mjs @@ -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 { @@ -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'); diff --git a/spec/intl.html b/spec/intl.html index 4e4952ef38..6d05e957b9 100644 --- a/spec/intl.html +++ b/spec/intl.html @@ -189,10 +189,13 @@

Abstract Operations For DateTimeFormat Objects

-

InitializeDateTimeFormat ( _dateTimeFormat_, _locales_, _options_ )

+

InitializeDateTimeFormat ( _dateTimeFormat_, _locales_, _options_ [ , _toLocaleStringTimeZone_ ] )

- 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. + 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`. + This abstract operation functions as follows:

@@ -238,8 +241,12 @@

InitializeDateTimeFormat ( _dateTimeFormat_, _locales_, _options_ )

1. Set _dateTimeFormat_.[[HourCycle]] to _hc_. 1. Let _timeZone_ be ? Get(_options_, *"timeZone"*). 1. If _timeZone_ is *undefined*, then - 1. Set _timeZone_ to DefaultTimeZone(). + 1. If _toLocaleStringTimeZone_ is present, then + 1. Set _timeZone_ to _toLocaleStringTimeZone_. + 1. Else, + 1. Set _timeZone_ to DefaultTimeZone(). 1. Else, + 1. If _toLocaleStringTimeZone_ is present, throw a *TypeError* exception. 1. Set _timeZone_ to ? ToString(_timeZone_). 1. If the result of IsValidTimeZoneName(_timeZone_)IsAvailableTimeZoneName(_timeZone_) is *false*, then 1. Throw a *RangeError* exception. @@ -308,8 +315,9 @@

InitializeDateTimeFormat ( _dateTimeFormat_, _locales_, _options_ )

1. Set _limitedOptions_.[[<_field_>]] to _formatOptions_.[[<_field_>]]. 1. If _needDefaults_ is *true*, then 1. Let _defaultFields_ be the list of fields in the Default fields column of the row. + 1. If the Pattern column of the row is [[TemporalInstantPattern]], and _toLocaleStringTimeZone_ is present, append [[timeZoneName]] to _defaultFields_. 1. For each element _field_ of _defaultFields_, do - 1. If _field_ is *"timeZoneName"*, then + 1. If _field_ is [[timeZoneName]], then 1. Let _defaultValue_ be *"short"*. 1. Else, 1. Let _defaultValue_ be *"numeric"*. @@ -360,13 +368,8 @@

InitializeDateTimeFormat ( _dateTimeFormat_, _locales_, _options_ )

[[TemporalInstantPattern]] - [[weekday]], [[era]], [[year]], [[month]], [[day]], [[hour]], [[minute]], [[second]], [[dayPeriod]], [[fractionalSecondDigits]] - [[year]], [[month]], [[day]], [[hour]], [[minute]], [[second]] - - - [[TemporalZonedDateTimePattern]] [[weekday]], [[era]], [[year]], [[month]], [[day]], [[hour]], [[minute]], [[second]], [[dayPeriod]], [[fractionalSecondDigits]], [[timeZoneName]] - [[year]], [[month]], [[day]], [[hour]], [[minute]], [[second]], [[timeZoneName]] + [[year]], [[month]], [[day]], [[hour]], [[minute]], [[second]] @@ -889,33 +892,6 @@

HandleDateTimeTemporalInstant ( _dateTimeFormat_, _instant_ )

- - -

HandleDateTimeTemporalZonedDateTime ( _dateTimeFormat_, _zonedDateTime_ )

- -

- 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: -

- - - 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]] - }. - -
-
@@ -967,7 +943,7 @@

HandleDateTimeValue ( _dateTimeFormat_, _x_ )

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_).
@@ -1333,7 +1309,7 @@

  • [[Pattern]] is a String value as described in .
  • [[RangePatterns]] is a Record as described in .
  • -
  • [[TemporalPlainDatePattern]], [[TemporalPlainYearMonthPattern]], [[TemporalPlainMonthDayPattern]], [[TemporalPlainTimePattern]], [[TemporalPlainDateTimePattern]], [[TemporalInstantPattern]], and [[TemporalZonedDateTimePattern]] are records containing at least a [[pattern]] field as described in .
  • +
  • [[TemporalPlainDatePattern]], [[TemporalPlainYearMonthPattern]], [[TemporalPlainMonthDayPattern]], [[TemporalPlainTimePattern]], [[TemporalPlainDateTimePattern]], and [[TemporalInstantPattern]] are records containing at least a [[pattern]] field as described in .
  • @@ -2489,8 +2465,17 @@

    Temporal.ZonedDateTime.prototype.toLocaleString ( [ _locales_ [ , _options_ 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_).