Skip to content

fix(timezone): keep a target wall clock the host DST gap cannot hold - #3219

Open
andr2k wants to merge 1 commit into
iamkun:devfrom
andr2k:fix/tz-host-dst-gap
Open

andr2k wants to merge 1 commit into
iamkun:devfrom
andr2k:fix/tz-host-dst-gap

Conversation

@andr2k

@andr2k andr2k commented Sep 18, 2026

Copy link
Copy Markdown

The bug

dayjs(instant).tz(zone) is an hour off whenever the target zone's wall clock
falls inside the host machine's spring-forward gap, even though the target
zone has no transition there.

// host TZ=America/Vancouver (or any US host: the gap is 2026-03-08 02:00-03:00)
const instant = new Date('2026-03-07T12:27:20Z')
dayjs(instant).tz('Pacific/Chatham').format('YYYY-MM-DD HH:mm')
// was:      2026-03-08 03:12   (and valueOf() an hour past the instant given)
// now:      2026-03-08 02:12   (matches moment-timezone and Intl.DateTimeFormat)

Two causes, both fixed here

1. proto.tz parsed a locale string in the host timezone.

const target = date.toLocaleString('en-US', { timeZone: timezone })
ins = d(target, { locale: this.$L }).$set(MS, this.$ms).utcOffset(offset, true)

d(target) hands those fields to new Date(string), which reads them as host
local time. When they name a nonexistent host-local time the engine shifts them
an hour forward, and utcOffset(offset, true) keeps the shifted clock.
tzOffset() already reads the same fields via Intl.DateTimeFormat.formatToParts,
so they are taken from there and the instance built with Date.UTC(...). The
toLocaleString round trip is gone, which also removes .tz()'s dependence on
how the running ICU formats and how the engine parses it.

2. Every instance with a display offset stored its wall clock in a host-local
Date, which cannot hold one the host itself skips.

This is the deeper one, and it is not in the timezone plugin at all:

// no timezone plugin loaded, host TZ=America/Vancouver
dayjs(new Date('2026-03-07T12:27:20Z')).utcOffset(825).format()
// was: 2026-03-08T03:12:20+13:45

With $u === false, init() reads the fields off $d with the local getters,
so $d had to be a host-local Date whose local fields are the wall clock -
and in a gap no such Date exists. Both candidate offsets and the
$x.$localOffset compensation were checked; neither has a solution. So offset
instances now keep their wall clock in the UTC fields of $d, which can
represent any wall clock a zone shows:

before after
$d host-local, wall clock in local fields wall clock in UTC fields
$u false true ($offset is what marks it non-UTC)
valueOf() $d - ($offset + ($x.$localOffset || $d.getTimezoneOffset())) $d - $offset
utcOffset() $u checked before $offset $offset checked before $u
isUTC() !!$u !!$u && !$offset
toDate('s') re-parse of format('YYYY-MM-DD HH:mm:ss:SSS') new Date($d)

$x.$localOffset existed only to paper over the host's offset at $d and is
gone. startOf/$set/add in src/index.js need no change: they already
branch on $u, so they now apply the UTC setters to fields that match.

Behaviour change to review

.utcOffset(offset, true) on a UTC-mode instance now keeps the wall clock that
instance displays. It used to keep it only where the host offset happened to
equal the requested one; on any other host the result's format() and
toISOString() disagreed with each other:

// host TZ=America/Vancouver, before this PR
const d = dayjs('2021-02-28 19:40:10').utc().utcOffset(-480, true)
d.format()       // 2021-03-01T03:40:10-08:00  ... which is 11:40:10Z
d.toISOString()  // 2021-03-01T03:40:10.000Z   ... an 8-hour disagreement

Five tests in test/plugin/utc.test.js and one in test/timezone.test.js
encoded that. Each passed dayjs .utc() where it passed moment .utc(true), so
only the moment side kept local time; they now keep it on both sides, which
makes them host-independent, and each gained a format() assertion, since
toISOString() alone could not see the disagreement above.

Tests

  • test/plugin/timezone.test.js - the reported instants per zone, plus controls
    at quarter-hour, whole-hour and zero offsets that no gap swallows, plus a sweep
    of every 20 minutes of 2026 across Pacific/Chatham, Australia/Lord_Howe,
    America/St_Johns, Asia/Kolkata and UTC, asserting both the wall clock and
    that valueOf() still equals the instant.
  • test/timezone.test.js - the same for .utcOffset() without the timezone
    plugin, including startOf('day') off the shifted wall clock.

Expectations are derived from Intl.DateTimeFormat and from moment, not typed
by hand, so they track the platform's tz database.

The sweeps are the real guard: a fixed instant only reproduces on a host whose
own gap swallows it, but over a year of instants a sweep crosses whatever gap
the host running it has. Against unmodified src/, the new tests fail under
Europe/London and Pacific/Auckland in npm run test-tz, and under all four
hosts of npm run test-tz-plugin; they pass on UTC and America/Whitehorse,
which have no DST.

npm test (all 8 host-timezone runs plus the 100%-line coverage run, 808 tests)
and npm run lint pass.

Refs #2303

dayjs(instant).tz(zone) read the target zone back through
date.toLocaleString('en-US', { timeZone }) and new Date(string), which
parses those fields in the host timezone. Where the target wall clock
names an hour the host skips at its own spring-forward there is no such
instant, so the engine shifted the fields an hour forward. On a host in
America/Vancouver, dayjs(new Date('2026-03-07T12:27:20Z')).tz(chatham)
returned 2026-03-08 03:12 rather than 02:12, and its valueOf() sat an
hour off the instant it was handed.

Take the fields from Intl.DateTimeFormat instead - tzOffset() already
reads them through makeFormatParts - and build the instance from those.

The wall clock itself was held in a host-local Date by every instance
carrying a display offset, so dayjs(instant).utcOffset(825) showed the
same shift with the timezone plugin not even loaded. Such instances now
hold their wall clock in the UTC fields of $d, which can represent every
wall clock a zone shows:

- utcOffset() reads $offset before $u, since they now set both
- valueOf() subtracts $offset alone, no longer the host's offset at $d,
  which $x.$localOffset existed to paper over
- isUTC() is $u without a display offset
- toDate('s') hands startOf/endOf $d itself, whose UTC fields are what
  the $u setters they pick already address

That also settles what .utcOffset(offset, true) means on a UTC-mode
instance: it keeps the wall clock that instance displays. It used to
keep it only where the host offset equalled the requested one; on any
other host the result's format() and toISOString() disagreed, by eight
hours in the case the tests cover. Those tests passed dayjs .utc() where
they passed moment .utc(true), and now keep local time on both sides.

The sweeps are the guard: over a year of instants they cross whatever
gap the host running them has, so they fail under Europe/London and
Pacific/Auckland in npm run test-tz, and under all four hosts of
npm run test-tz-plugin.

Refs iamkun#2303

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant