From 84d14ab42c03d98830345a313f0dc68ebc6af5ee Mon Sep 17 00:00:00 2001 From: SUNGSU LEE Date: Thu, 19 Feb 2026 01:03:24 +0900 Subject: [PATCH] fix: skip native Date fast paths when local timezone is overridden (#9669) `setLocalTimeZone` overrides the cached timezone string, but fast paths in `getTimeZoneOffset` and `toAbsolute` delegate to native `Date` which always uses the browser's real timezone. This causes incorrect offsets when the overridden timezone differs from the real one. Skip the fast paths when `setLocalTimeZone` has been called and not reset. --- .../@internationalized/date/src/conversion.ts | 6 ++--- packages/@internationalized/date/src/index.ts | 1 + .../@internationalized/date/src/queries.ts | 8 ++++++ .../date/tests/conversion.test.js | 26 ++++++++++++++++++- .../date/tests/queries.test.js | 23 ++++++++++++++++ 5 files changed, 60 insertions(+), 4 deletions(-) diff --git a/packages/@internationalized/date/src/conversion.ts b/packages/@internationalized/date/src/conversion.ts index d34b256ae0e..b121b5780da 100644 --- a/packages/@internationalized/date/src/conversion.ts +++ b/packages/@internationalized/date/src/conversion.ts @@ -17,7 +17,7 @@ import {AnyCalendarDate, AnyDateTime, AnyTime, Calendar, DateFields, Disambiguat import {CalendarDate, CalendarDateTime, Time, ZonedDateTime} from './CalendarDate'; import {constrain} from './manipulation'; import {getExtendedYear, GregorianCalendar} from './calendars/GregorianCalendar'; -import {getLocalTimeZone, isEqualCalendar} from './queries'; +import {getLocalTimeZone, isEqualCalendar, isLocalTimeZoneOverridden} from './queries'; import {Mutable} from './utils'; export function epochFromDate(date: AnyDateTime): number { @@ -42,7 +42,7 @@ export function getTimeZoneOffset(ms: number, timeZone: string): number { } // Fast path: for local timezone after 1970, use native Date. - if (ms > 0 && timeZone === getLocalTimeZone()) { + if (ms > 0 && timeZone === getLocalTimeZone() && !isLocalTimeZoneOverridden()) { return new Date(ms).getTimezoneOffset() * -60 * 1000; } @@ -124,7 +124,7 @@ export function toAbsolute(date: CalendarDate | CalendarDateTime, timeZone: stri } // Fast path: if the time zone is the local timezone and disambiguation is compatible, use native Date. - if (timeZone === getLocalTimeZone() && disambiguation === 'compatible') { + if (timeZone === getLocalTimeZone() && disambiguation === 'compatible' && !isLocalTimeZoneOverridden()) { dateTime = toCalendar(dateTime, new GregorianCalendar()); // Don't use Date constructor here because two-digit years are interpreted in the 20th century. diff --git a/packages/@internationalized/date/src/index.ts b/packages/@internationalized/date/src/index.ts index 5d373c842f1..02752a6045d 100644 --- a/packages/@internationalized/date/src/index.ts +++ b/packages/@internationalized/date/src/index.ts @@ -66,6 +66,7 @@ export { getLocalTimeZone, setLocalTimeZone, resetLocalTimeZone, + isLocalTimeZoneOverridden, startOfMonth, startOfWeek, startOfYear, diff --git a/packages/@internationalized/date/src/queries.ts b/packages/@internationalized/date/src/queries.ts index ce48d95d75c..0ee5baa48f6 100644 --- a/packages/@internationalized/date/src/queries.ts +++ b/packages/@internationalized/date/src/queries.ts @@ -130,6 +130,7 @@ export function getHoursInDay(a: CalendarDate, timeZone: string): number { } let localTimeZone: string | null = null; +let localTimeZoneOverridden = false; /** Returns the time zone identifier for the current user. */ export function getLocalTimeZone(): string { @@ -143,11 +144,18 @@ export function getLocalTimeZone(): string { /** Sets the time zone identifier for the current user. */ export function setLocalTimeZone(timeZone: string): void { localTimeZone = timeZone; + localTimeZoneOverridden = true; } /** Resets the time zone identifier for the current user. */ export function resetLocalTimeZone(): void { localTimeZone = null; + localTimeZoneOverridden = false; +} + +/** Returns whether the local time zone has been overridden via setLocalTimeZone. */ +export function isLocalTimeZoneOverridden(): boolean { + return localTimeZoneOverridden; } /** Returns the first date of the month for the given date. */ diff --git a/packages/@internationalized/date/tests/conversion.test.js b/packages/@internationalized/date/tests/conversion.test.js index 7fdc06c1792..b21dabc9c7c 100644 --- a/packages/@internationalized/date/tests/conversion.test.js +++ b/packages/@internationalized/date/tests/conversion.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {BuddhistCalendar, CalendarDate, CalendarDateTime, EthiopicAmeteAlemCalendar, EthiopicCalendar, GregorianCalendar, HebrewCalendar, IndianCalendar, IslamicCivilCalendar, IslamicTabularCalendar, IslamicUmalquraCalendar, JapaneseCalendar, PersianCalendar, TaiwanCalendar, Time, toCalendar, toCalendarDate, toCalendarDateTime, toTime, ZonedDateTime} from '..'; +import {BuddhistCalendar, CalendarDate, CalendarDateTime, EthiopicAmeteAlemCalendar, EthiopicCalendar, GregorianCalendar, HebrewCalendar, IndianCalendar, IslamicCivilCalendar, IslamicTabularCalendar, IslamicUmalquraCalendar, JapaneseCalendar, PersianCalendar, TaiwanCalendar, Time, toCalendar, toCalendarDate, toCalendarDateTime, toTime, ZonedDateTime, setLocalTimeZone, resetLocalTimeZone} from '..'; import {Custom454Calendar} from './customCalendarImpl'; import {fromAbsolute, possibleAbsolutes, toAbsolute, toDate} from '../src/conversion'; @@ -522,4 +522,28 @@ describe('CalendarDate conversion', function () { expect(toTime(dateTime)).toEqual(new Time(8, 23, 10, 80)); }); }); + + describe('setLocalTimeZone interaction with fast paths', function () { + afterEach(() => { + resetLocalTimeZone(); + }); + + it('fromAbsolute should return the correct offset when local timezone is overridden', function () { + // Etc/GMT-10 means UTC+10 + setLocalTimeZone('Etc/GMT-10'); + let ms = Date.UTC(2020, 5, 15, 12, 0, 0); // 2020-06-15T12:00:00Z + let zdt = fromAbsolute(ms, 'Etc/GMT-10'); + expect(zdt.hour).toBe(22); // 12:00 UTC + 10 = 22:00 + expect(zdt.offset).toBe(10 * 60 * 60 * 1000); + }); + + it('toAbsolute should return the correct epoch when local timezone is overridden', function () { + // Etc/GMT-10 means UTC+10 + setLocalTimeZone('Etc/GMT-10'); + let date = new CalendarDateTime(2020, 6, 15, 22, 0, 0); + let ms = toAbsolute(date, 'Etc/GMT-10'); + // 22:00 in UTC+10 = 12:00 UTC + expect(ms).toBe(Date.UTC(2020, 5, 15, 12, 0, 0)); + }); + }); }); diff --git a/packages/@internationalized/date/tests/queries.test.js b/packages/@internationalized/date/tests/queries.test.js index 40fceb3494e..0b8e657415e 100644 --- a/packages/@internationalized/date/tests/queries.test.js +++ b/packages/@internationalized/date/tests/queries.test.js @@ -32,6 +32,7 @@ import { maxDate, minDate, PersianCalendar, + isLocalTimeZoneOverridden, resetLocalTimeZone, setLocalTimeZone, startOfMonth, @@ -371,4 +372,26 @@ describe('queries', function () { expect(getLocalTimeZone()).toBe(systemTimeZone); }); }); + + describe('isLocalTimeZoneOverridden', function () { + afterEach(() => { + resetLocalTimeZone(); + }); + + it('returns false by default', function () { + expect(isLocalTimeZoneOverridden()).toBe(false); + }); + + it('returns true after setLocalTimeZone', function () { + setLocalTimeZone('America/Denver'); + expect(isLocalTimeZoneOverridden()).toBe(true); + }); + + it('returns false after resetLocalTimeZone', function () { + setLocalTimeZone('America/Denver'); + expect(isLocalTimeZoneOverridden()).toBe(true); + resetLocalTimeZone(); + expect(isLocalTimeZoneOverridden()).toBe(false); + }); + }); });