From 4af3d809a696d2357fd0763577d5e083e641badc Mon Sep 17 00:00:00 2001 From: Adam Shaw Date: Thu, 7 Mar 2024 19:03:56 -0500 Subject: [PATCH] Updates to ZonedDateTime diffing algorithm and dayCorrection. Fixes bug mentioned here: https://github.com/tc39/proposal-temporal/pull/2760#issuecomment-1971930951 --- polyfill/lib/ecmascript.mjs | 122 ++++++++++++++++++++++-------------- polyfill/test262 | 2 +- 2 files changed, 75 insertions(+), 49 deletions(-) diff --git a/polyfill/lib/ecmascript.mjs b/polyfill/lib/ecmascript.mjs index 4da8de0e05..525010a3b6 100644 --- a/polyfill/lib/ecmascript.mjs +++ b/polyfill/lib/ecmascript.mjs @@ -3900,8 +3900,9 @@ export function DifferenceZonedDateTime( norm: TimeDuration.ZERO }; } + const sign = nsDiff.lt(0) ? -1 : 1; - // Find the difference in dates only. + // Convert start/end instants to datetimes const TemporalInstant = GetIntrinsic('%Temporal.Instant%'); const start = new TemporalInstant(ns1); const end = new TemporalInstant(ns2); @@ -3909,36 +3910,56 @@ export function DifferenceZonedDateTime( const dtEnd = GetPlainDateTimeFor(timeZoneRec, end, calendarRec.receiver); // Simulate moving ns1 as many years/months/weeks/days as possible without - // surpassing ns2. This value is stored in intermediateDateTime/intermediate. + // surpassing ns2. This value is stored in intermediateDateTime/intermediateInstant/intermediateNs. // We do not literally move years/months/weeks/days with calendar arithmetic, // but rather assume intermediateDateTime will have the same time-parts as // dtStart and the date-parts from dtEnd, and move backward from there. - // - // This loop will run 3 times max: - // 1. initial run - // 2. backoff if the time-parts of intermediateDateTime have conflicting sign - // with overall diff direction, just like how DifferenceISODateTime works - // 3. backoff if intermediateDateTime fell into a DST gap and was pushed in a - // direction that would make the diff of the time-parts conflict with the - // sign of the overall direction. (Only possible when sign is +1, because - // a DST gap uses 'compatible' disambiguation resolution and can only move - // the intermediate forward) - // + // The number of days we move backward is stored in dayCorrection. // Credit to Adam Shaw for devising this algorithm. - const sign = nsDiff.lt(0) ? -1 : 1; - const maxTries = sign === 1 ? 3 : 2; - for (let dayCorrection = 0; dayCorrection < maxTries; dayCorrection++) { - const correctedEndDate = BalanceISODate( + let dayCorrection = 0; + let intermediateDateTime; + let norm; + + // The max number of allowed day corrections depends on the direction of travel. + // Both directions allow for 1 day correction due to an ISO wall-clock overshoot (see below). + // Only the forward direction allows for an additional 1 day correction caused by a push-forward + // 'compatible' DST transition causing the wall-clock to overshoot again. + // This max value is inclusive. + let maxDayCorrection = sign === 1 ? 2 : 1; + + // Detect ISO wall-clock overshoot. + // If the diff of the ISO wall-clock times is opposite to the overall diff's sign, + // we are guaranteed to need at least one day correction. + let timeDuration = DifferenceTime( + GetSlot(dtStart, ISO_HOUR), + GetSlot(dtStart, ISO_MINUTE), + GetSlot(dtStart, ISO_SECOND), + GetSlot(dtStart, ISO_MILLISECOND), + GetSlot(dtStart, ISO_MICROSECOND), + GetSlot(dtStart, ISO_NANOSECOND), + GetSlot(dtEnd, ISO_HOUR), + GetSlot(dtEnd, ISO_MINUTE), + GetSlot(dtEnd, ISO_SECOND), + GetSlot(dtEnd, ISO_MILLISECOND), + GetSlot(dtEnd, ISO_MICROSECOND), + GetSlot(dtEnd, ISO_NANOSECOND) + ); + if (timeDuration.sign() === -sign) { + dayCorrection++; + } + + for (; dayCorrection <= maxDayCorrection; dayCorrection++) { + const intermediateDate = BalanceISODate( GetSlot(dtEnd, ISO_YEAR), GetSlot(dtEnd, ISO_MONTH), GetSlot(dtEnd, ISO_DAY) - dayCorrection * sign ); // Incorporate time parts from dtStart - const intermediateDateTime = CreateTemporalDateTime( - correctedEndDate.year, - correctedEndDate.month, - correctedEndDate.day, + intermediateDateTime = CreateTemporalDateTime( + intermediateDate.year, + intermediateDate.month, + intermediateDate.day, GetSlot(dtStart, ISO_HOUR), GetSlot(dtStart, ISO_MINUTE), GetSlot(dtStart, ISO_SECOND), @@ -3947,36 +3968,41 @@ export function DifferenceZonedDateTime( GetSlot(dtStart, ISO_NANOSECOND), calendarRec.receiver ); - const intermediate = GetInstantFor(timeZoneRec, intermediateDateTime, 'compatible'); - // may disambiguate - const intermediateNs = GetSlot(intermediate, EPOCHNANOSECONDS); - - // Did intermediateNs surpass ns2? - const norm = TimeDuration.fromEpochNsDiff(ns2, intermediateNs); - const timeSign = norm.sign(); - if (sign === 0 || timeSign == 0 || sign === timeSign) { - // sign of timeDuration now compatible with the overall sign - - // Similar to what happens in DifferenceISODateTime with date parts only: - const date1 = TemporalDateTimeToDate(dtStart); - const date2 = TemporalDateTimeToDate(intermediateDateTime); - const dateLargestUnit = LargerOfTwoTemporalUnits('day', largestUnit); - const untilOptions = SnapshotOwnProperties(options, null); - untilOptions.largestUnit = dateLargestUnit; - const dateDifference = DifferenceDate(calendarRec, date1, date2, untilOptions); - const years = GetSlot(dateDifference, YEARS); - const months = GetSlot(dateDifference, MONTHS); - const weeks = GetSlot(dateDifference, WEEKS); - const days = GetSlot(dateDifference, DAYS); - CombineDateAndNormalizedTimeDuration(years, months, weeks, days, norm); - return { years, months, weeks, days, norm }; + // Convert intermediate datetime to epoch-nanoseconds (may disambiguate) + const intermediateInstant = GetInstantFor(timeZoneRec, intermediateDateTime, 'compatible'); + const intermediateNs = GetSlot(intermediateInstant, EPOCHNANOSECONDS); + + // Compute the nanosecond diff between the intermediate instant and the final destination + norm = TimeDuration.fromEpochNsDiff(ns2, intermediateNs); + + // Did intermediateNs NOT surpass ns2? + // If so, exit the loop with success (without incrementing dayCorrection past maxDayCorrection) + if (norm.sign() !== -sign) { + break; } - // Else, keep backing off... } - throw new RangeError( - `inconsistent return from calendar or time zone method: more than ${maxTries - 1} days correction needed` - ); + + if (dayCorrection > maxDayCorrection) { + throw new RangeError( + `inconsistent return from calendar or time zone method: more than ${maxDayCorrection} day correction needed` + ); + } + + // Similar to what happens in DifferenceISODateTime with date parts only: + const date1 = TemporalDateTimeToDate(dtStart); + const date2 = TemporalDateTimeToDate(intermediateDateTime); + const dateLargestUnit = LargerOfTwoTemporalUnits('day', largestUnit); + const untilOptions = SnapshotOwnProperties(options, null); + untilOptions.largestUnit = dateLargestUnit; + const dateDifference = DifferenceDate(calendarRec, date1, date2, untilOptions); + const years = GetSlot(dateDifference, YEARS); + const months = GetSlot(dateDifference, MONTHS); + const weeks = GetSlot(dateDifference, WEEKS); + const days = GetSlot(dateDifference, DAYS); + + CombineDateAndNormalizedTimeDuration(years, months, weeks, days, norm); + return { years, months, weeks, days, norm }; } export function GetDifferenceSettings(op, options, group, disallowed, fallbackSmallest, smallestLargestDefaultUnit) { diff --git a/polyfill/test262 b/polyfill/test262 index 0fd1675f7e..2f36b42562 160000 --- a/polyfill/test262 +++ b/polyfill/test262 @@ -1 +1 @@ -Subproject commit 0fd1675f7ed02723772d30e718f04e6af455d3c9 +Subproject commit 2f36b42562e07b8d9e3d51810b5596a2f291e57b