From bac5a1a23b660a44d7dcaf314e108d6173c567c3 Mon Sep 17 00:00:00 2001
From: Justin Grant
Date: Sat, 15 Jul 2023 16:38:33 -0700
Subject: [PATCH] Normative: Limit offset time zones to minutes
At implementers' request to reduce the storage requirements of
Temporal.TimeZone from 49+ bits to 12-13 bits, this commit requires
that the [[OffsetNanoseconds]] internal slot of Temporal.TimeZone is
limited to minute precision.
Sub-minute precision is still allowed for custom time zone objects and
built-in named time zones. In other words, this commit changes storage
requirements but not internal calculation requirements.
This commit is fairly narrow:
* Changes |TimeZoneUTCOffsetName| production to restrict allowed
offset syntax for parsing.
* Changes FormatOffsetTimeZoneIdentifier AO to format minute strings
only.
* Moves sub-minute offset formatting from FormatOffsetTimeZoneIdentifier
to instead be inlined in GetOffsetStringFor, which is now the only
place where sub-minute offsets are formatted.
Fixes #2593.
---
polyfill/lib/ecmascript.mjs | 54 +++++++++++++++++++++-------------
polyfill/lib/regex.mjs | 2 +-
polyfill/test/validStrings.mjs | 14 +++++----
spec/abstractops.html | 2 +-
spec/mainadditions.html | 2 ++
spec/timezone.html | 52 +++++++++++++++++---------------
6 files changed, 75 insertions(+), 51 deletions(-)
diff --git a/polyfill/lib/ecmascript.mjs b/polyfill/lib/ecmascript.mjs
index 7871c29326..71642d15a4 100644
--- a/polyfill/lib/ecmascript.mjs
+++ b/polyfill/lib/ecmascript.mjs
@@ -577,6 +577,7 @@ const OFFSET_IDENTIFIER = new RegExp(`^${PARSE.offsetIdentifier.source}$`);
export function ParseTimeZoneIdentifier(identifier) {
if (!TIMEZONE_IDENTIFIER.test(identifier)) throw new RangeError(`Invalid time zone identifier: ${identifier}`);
if (OFFSET_IDENTIFIER.test(identifier)) {
+ // The regex limits the input to minutes precision
const { offsetNanoseconds } = ParseDateTimeUTCOffset(identifier);
return { offsetNanoseconds };
}
@@ -1430,7 +1431,7 @@ export function InterpretISODateTimeOffset(
// the user-provided offset doesn't match any instants for this time
// zone and date/time.
if (offsetOpt === 'reject') {
- const offsetStr = FormatOffsetTimeZoneIdentifier(offsetNs);
+ const offsetStr = formatOffsetStringNanoseconds(offsetNs);
const timeZoneString = IsTemporalTimeZone(timeZone) ? GetSlot(timeZone, TIMEZONE_ID) : 'time zone';
throw new RangeError(`Offset ${offsetStr} is invalid for ${dt} in ${timeZoneString}`);
}
@@ -2135,7 +2136,10 @@ export function ToTemporalTimeZoneSlotValue(temporalTimeZoneLike) {
}
if (z) return 'UTC';
// if !tzName && !z then offset must be present
- const { offsetNanoseconds } = ParseDateTimeUTCOffset(offset);
+ const { offsetNanoseconds, hasSubMinutePrecision } = ParseDateTimeUTCOffset(offset);
+ if (hasSubMinutePrecision) {
+ throw new RangeError(`Seconds not allowed in offset time zone: ${offset}`);
+ }
return FormatOffsetTimeZoneIdentifier(offsetNanoseconds);
}
@@ -2199,7 +2203,28 @@ export function GetOffsetNanosecondsFor(timeZone, instant, getOffsetNanosecondsF
export function GetOffsetStringFor(timeZone, instant) {
const offsetNs = GetOffsetNanosecondsFor(timeZone, instant);
- return FormatOffsetTimeZoneIdentifier(offsetNs);
+ return formatOffsetStringNanoseconds(offsetNs);
+}
+
+// In the spec, the code below only exists as part of GetOffsetStringFor.
+// But in the polyfill, we re-use it to provide clearer error messages.
+function formatOffsetStringNanoseconds(offsetNs) {
+ const offsetMinutes = MathTrunc(offsetNs / 6e10);
+ let offsetStringMinutes = FormatOffsetTimeZoneIdentifier(offsetMinutes * 6e10);
+ const subMinuteNanoseconds = MathAbs(offsetNs) % 6e10;
+ if (!subMinuteNanoseconds) return offsetStringMinutes;
+
+ // For offsets between -1s and 0, exclusive, FormatOffsetTimeZoneIdentifier's
+ // return value of "+00:00" is incorrect if there are sub-minute units.
+ if (!offsetMinutes && offsetNs < 0) offsetStringMinutes = '-00:00';
+
+ const seconds = MathFloor(subMinuteNanoseconds / 1e9) % 60;
+ const secondString = ISODateTimePartString(seconds);
+ const nanoseconds = subMinuteNanoseconds % 1e9;
+ if (!nanoseconds) return `${offsetStringMinutes}:${secondString}`;
+
+ let fractionString = `${nanoseconds}`.padStart(9, '0').replace(/0+$/, '');
+ return `${offsetStringMinutes}:${secondString}.${fractionString}`;
}
export function GetPlainDateTimeFor(timeZone, instant, calendar) {
@@ -2716,23 +2741,12 @@ export function GetNamedTimeZoneOffsetNanoseconds(id, epochNanoseconds) {
export function FormatOffsetTimeZoneIdentifier(offsetNanoseconds) {
const sign = offsetNanoseconds < 0 ? '-' : '+';
- offsetNanoseconds = MathAbs(offsetNanoseconds);
- const hours = MathFloor(offsetNanoseconds / 3600e9);
- const hourString = ISODateTimePartString(hours);
- const minutes = MathFloor(offsetNanoseconds / 60e9) % 60;
- const minuteString = ISODateTimePartString(minutes);
- const seconds = MathFloor(offsetNanoseconds / 1e9) % 60;
- const secondString = ISODateTimePartString(seconds);
- const nanoseconds = offsetNanoseconds % 1e9;
- let post = '';
- if (nanoseconds) {
- let fraction = `${nanoseconds}`.padStart(9, '0');
- while (fraction[fraction.length - 1] === '0') fraction = fraction.slice(0, -1);
- post = `:${secondString}.${fraction}`;
- } else if (seconds) {
- post = `:${secondString}`;
- }
- return `${sign}${hourString}:${minuteString}${post}`;
+ const absoluteMinutes = MathAbs(offsetNanoseconds / 6e10);
+ const intHours = MathFloor(absoluteMinutes / 60);
+ const hh = ISODateTimePartString(intHours);
+ const intMinutes = absoluteMinutes % 60;
+ const mm = ISODateTimePartString(intMinutes);
+ return `${sign}${hh}:${mm}`;
}
export function FormatDateTimeUTCOffsetRounded(offsetNanoseconds) {
diff --git a/polyfill/lib/regex.mjs b/polyfill/lib/regex.mjs
index e07db6dffb..8a71c63841 100644
--- a/polyfill/lib/regex.mjs
+++ b/polyfill/lib/regex.mjs
@@ -24,7 +24,7 @@ export const datesplit = new RegExp(
const timesplit = /(\d{2})(?::(\d{2})(?::(\d{2})(?:[.,](\d{1,9}))?)?|(\d{2})(?:(\d{2})(?:[.,](\d{1,9}))?)?)?/;
export const offset = /([+\u2212-])([01][0-9]|2[0-3])(?::?([0-5][0-9])(?::?([0-5][0-9])(?:[.,](\d{1,9}))?)?)?/;
const offsetpart = new RegExp(`([zZ])|${offset.source}?`);
-export const offsetIdentifier = offset;
+export const offsetIdentifier = /([+\u2212-])([01][0-9]|2[0-3])(?::?([0-5][0-9])?)?/;
export const annotation = /\[(!)?([a-z_][a-z0-9_-]*)=([A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)\]/g;
export const zoneddatetime = new RegExp(
diff --git a/polyfill/test/validStrings.mjs b/polyfill/test/validStrings.mjs
index 554ff063a7..3ba6aa9b1f 100644
--- a/polyfill/test/validStrings.mjs
+++ b/polyfill/test/validStrings.mjs
@@ -6,6 +6,7 @@
import assert from 'assert';
import * as ES from '../lib/ecmascript.mjs';
+import { Instant } from '../lib/instant.mjs';
const timezoneNames = Intl.supportedValuesOf('timeZone');
const calendarNames = Intl.supportedValuesOf('calendar');
@@ -248,7 +249,12 @@ const temporalSign = withCode(
);
const temporalDecimalFraction = fraction;
function saveOffset(data, result) {
- data.offset = ES.FormatOffsetTimeZoneIdentifier(ES.ParseDateTimeUTCOffset(result).offsetNanoseconds);
+ // To canonicalize an offset string that may include nanoseconds, we use GetOffsetStringFor
+ const instant = new Instant(0n);
+ const fakeTimeZone = {
+ getOffsetNanosecondsFor: () => ES.ParseDateTimeUTCOffset(result).offsetNanoseconds
+ };
+ data.offset = ES.GetOffsetStringFor(fakeTimeZone, instant);
}
const utcOffsetSubMinutePrecision = withCode(
seq(
@@ -262,11 +268,7 @@ const utcOffsetSubMinutePrecision = withCode(
saveOffset
);
const dateTimeUTCOffset = choice(utcDesignator, utcOffsetSubMinutePrecision);
-const timeZoneUTCOffsetName = seq(
- sign,
- hour,
- choice([minuteSecond, [minuteSecond, [fraction]]], seq(':', minuteSecond, [':', minuteSecond, [fraction]]))
-);
+const timeZoneUTCOffsetName = seq(sign, hour, choice([minuteSecond], seq(':', minuteSecond)));
const timeZoneIANAName = choice(...timezoneNames);
const timeZoneIdentifier = withCode(
choice(timeZoneUTCOffsetName, timeZoneIANAName),
diff --git a/spec/abstractops.html b/spec/abstractops.html
index ec11764594..e5eb10c358 100644
--- a/spec/abstractops.html
+++ b/spec/abstractops.html
@@ -1158,7 +1158,7 @@ ISO 8601 grammar
UTCOffsetSubMinutePrecision
TimeZoneUTCOffsetName :
- UTCOffsetSubMinutePrecision
+ UTCOffsetMinutePrecision
TZLeadingChar :
Alpha
diff --git a/spec/mainadditions.html b/spec/mainadditions.html
index 9cb6db4530..59778e1478 100644
--- a/spec/mainadditions.html
+++ b/spec/mainadditions.html
@@ -285,6 +285,8 @@ Time Zone Offset String FormatFormats
ECMAScript defines string interchange formats for UTC offsets, derived from ISO 8601.
+ UTC offsets that represent offset time zone identifiers, or that are intended for interoperability with ISO 8601, use only hours and minutes and are specified by |UTCOffsetMinutePrecision|.
+ UTC offsets that represent the offset of a named or custom time zone can be more precise, and are specified by |UTCOffsetSubMinutePrecision|.
These formats are described by the ISO String grammar in .
diff --git a/spec/timezone.html b/spec/timezone.html
index 3db3f99df6..b5ae2d8af6 100644
--- a/spec/timezone.html
+++ b/spec/timezone.html
@@ -284,6 +284,7 @@
Properties of Temporal.TimeZone Instances
An integer for nanoseconds representing the constant offset of this time zone relative to UTC, or ~empty~ if the instance represents a named time zone.
+ If not ~empty~, this value must represent an integer number of minutes (i.e., [[OffsetNanoseconds]] modulo (6 × 1010) must always be 0).
If not ~empty~, this value must be in the interval from −8.64 × 1013 (exclusive) to +8.64 × 1013 (exclusive) (i.e., strictly less than 24 hours in magnitude).
|
@@ -342,7 +343,7 @@
Although the [[Identifier]] internal slot is a String in this specification, implementations may choose to store named time zone identifiers it in any other form (for example as an enumeration or index into a List of identifier strings) as long as the String can be regenerated when needed.
- Similar flexibility exists for the storage of the [[OffsetNanoseconds]] internal slot, which can be interchangeably represented as a 6-byte signed integer or as a String value that may be as long as 19 characters.
+ Similar flexibility exists for the storage of the [[OffsetNanoseconds]] internal slot, which can be interchangeably represented as a 12-bit signed integer or as a 6-character ±HH:MM String value.
ParseTimeZoneIdentifier and FormatOffsetTimeZoneIdentifier may be used to losslessly convert one representation to the other.
Implementations are free to store either or both representations.
@@ -482,31 +483,20 @@
It formats a time zone offset, in nanoseconds, into a UTC offset string.
If _style_ is ~legacy~, then the output will be formatted like ±HHMM.
- If _style_ is ~separated~, then the output will be formatted like ±HH:MM if _offsetNanoseconds_ represents an integer number of minutes, like ±HH:MM:SS if _offsetNanoseconds_ represents an integer number of seconds, and otherwise like ±HH:MM:SS.fff where "fff" is a sequence of at least 1 and at most 9 fractional seconds digits with no trailing zeroes.
+ If _style_ is ~separated~, then the output will be formatted like ±HH:MM.
1. If _offsetNanoseconds_ ≥ 0, let _sign_ be the code unit 0x002B (PLUS SIGN); otherwise, let _sign_ be the code unit 0x002D (HYPHEN-MINUS).
- 1. Set _offsetNanoseconds_ to abs(_offsetNanoseconds_).
- 1. Let _hours_ be floor(_offsetNanoseconds_ / (3.6 × 1012)).
- 1. Let _h_ be ToZeroPaddedDecimalString(_hours_, 2).
- 1. Let _minutes_ be floor(_offsetNanoseconds_ / (6 × 1010)) modulo 60.
- 1. Let _m_ be ToZeroPaddedDecimalString(_minutes_, 2).
+ 1. Let _absoluteMinutes_ be abs(_offsetNanoseconds_ / (6 × 1010)).
+ 1. Assert: _absoluteMinutes_ is an integer.
+ 1. Let _intHours_ be floor(_absoluteMinutes_ / 60).
+ 1. Let _hh_ be ToZeroPaddedDecimalString(_intHours_, 2).
+ 1. Let _intMinutes_ be _absoluteMinutes_ modulo 60.
+ 1. Let _mm_ be ToZeroPaddedDecimalString(_intMinutes_, 2).
1. If _style_ is ~legacy~, then
- 1. Assert _offsetNanoseconds_ modulo (6 × 1010) = 0.
- 1. Return the string-concatenation of _sign_, _h_, and _m_.
- 1. Let _seconds_ be floor(_offsetNanoseconds_ / 109) modulo 60.
- 1. Let _s_ be ToZeroPaddedDecimalString(_seconds_, 2).
- 1. Let _nanoseconds_ be _offsetNanoseconds_ modulo 109.
- 1. If _nanoseconds_ ≠ 0, then
- 1. Let _fraction_ be ToZeroPaddedDecimalString(_nanoseconds_, 9).
- 1. Set _fraction_ to the longest possible substring of _fraction_ starting at position 0 and not ending with the code unit 0x0030 (DIGIT ZERO).
- 1. Let _post_ be the string-concatenation of the code unit 0x003A (COLON), _s_, the code unit 0x002E (FULL STOP), and _fraction_.
- 1. Else if seconds ≠ 0, then
- 1. Let _post_ be the string-concatenation of the code unit 0x003A (COLON) and _s_.
- 1. Else,
- 1. Let _post_ be the empty String.
- 1. Return the string-concatenation of _sign_, _h_, the code unit 0x003A (COLON), _m_, and _post_.
+ 1. Return the string-concatenation of _sign_, _hh_, and _mm_.
+ 1. Return the string-concatenation of _sign_, _hh_, the code unit 0x003A (COLON), and _mm_.
@@ -657,11 +647,26 @@
This operation is the internal implementation of the `Temporal.TimeZone.prototype.getOffsetStringFor` method.
If the given _timeZone_ is an Object, it observably calls _timeZone_'s `getOffsetNanosecondsFor` method.
+ If the offset represents an integer number of minutes, then the output will be formatted like ±HH:MM.
+ Otherwise, the output will be formatted like ±HH:MM:SS or (if the offset does not evenly divide into seconds) ±HH:MM:SS.fff… where the "fff" part is a sequence of at least 1 and at most 9 fractional seconds digits with no trailing zeroes.
1. Let _offsetNanoseconds_ be ? GetOffsetNanosecondsFor(_timeZone_, _instant_).
- 1. Return FormatOffsetTimeZoneIdentifier(_offsetNanoseconds_, ~separated~).
+ 1. Let _offsetMinutes_ be truncate(_offsetNanoseconds_ / (6 × 1010)).
+ 1. Let _offsetString_ be FormatOffsetTimeZoneIdentifier(_offsetMinutes_ × (6 × 1010), ~separate~).
+ 1. Let _subMinuteNanoseconds_ be abs(_offsetNanoseconds_) modulo (6 × 1010).
+ 1. If _subMinuteNanoseconds_ = 0, then
+ 1. Return _offsetString_.
+ 1. If _offsetMinutes_ = 0 and _offsetNanoseconds_ < 0, set _offsetString_ to *"-00:00"*.
+ 1. Let _seconds_ be floor(_subMinuteNanoseconds_ / 109) modulo 60.
+ 1. Let _ss_ be ToZeroPaddedDecimalString(_seconds_, 2).
+ 1. Let _nanoseconds_ be _subMinuteNanoseconds_ modulo 109.
+ 1. If _nanoseconds_ = 0, then
+ 1. Return the string-concatenation of _offsetString_, the code unit 0x003A (COLON), and _ss_.
+ 1. Let _fractionString_ be ToZeroPaddedDecimalString(_nanoseconds_, 9).
+ 1. Set _fractionString_ to the longest prefix of _fractionString_ ending with a code unit other than 0x0030 (DIGIT ZERO).
+ 1. Return the string-concatenation of _offsetString_, the code unit 0x003A (COLON), _ss_, the code unit 0x002E (FULL STOP), and _fractionString_.
@@ -826,7 +831,7 @@
description
If _identifier_ is a named time zone identifier, [[Name]] will be _identifier_ and [[OffsetNanoseconds]] will be ~empty~.
- If _identifier_ is an offset time zone identifier, [[Name]] will be ~empty~ and [[OffsetNanoseconds]] will be a signed integer.
+ If _identifier_ is an offset time zone identifier, [[Name]] will be ~empty~ and [[OffsetNanoseconds]] will be a signed integer that is evenly divisible by 6 × 1010.
Otherwise, a *RangeError* will be thrown.
@@ -840,6 +845,7 @@
1. Assert: _parseResult_ contains a |TimeZoneUTCOffsetName| Parse Node.
1. Let _offsetString_ be the source text matched by the |TimeZoneUTCOffsetName| Parse Node contained within _parseResult_.
1. Let _offsetNanoseconds_ be ! ParseDateTimeUTCOffset(_offsetString_).
+ 1. Assert: _offsetNanoseconds_ modulo (6 × 1010) = 0.
1. Return the Record { [[Name]]: ~empty~, [[OffsetNanoseconds]]: _offsetNanoseconds_ }.