Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
dayjs(instant).tz(zone)is an hour off whenever the target zone's wall clockfalls inside the host machine's spring-forward gap, even though the target
zone has no transition there.
Two causes, both fixed here
1.
proto.tzparsed a locale string in the host timezone.d(target)hands those fields tonew Date(string), which reads them as hostlocal 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 viaIntl.DateTimeFormat.formatToParts,so they are taken from there and the instance built with
Date.UTC(...). ThetoLocaleStringround trip is gone, which also removes.tz()'s dependence onhow 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:
With
$u === false,init()reads the fields off$dwith the local getters,so
$dhad to be a host-localDatewhose local fields are the wall clock -and in a gap no such
Dateexists. Both candidate offsets and the$x.$localOffsetcompensation were checked; neither has a solution. So offsetinstances now keep their wall clock in the UTC fields of
$d, which canrepresent any wall clock a zone shows:
$d$ufalsetrue($offsetis what marks it non-UTC)valueOf()$d - ($offset + ($x.$localOffset || $d.getTimezoneOffset()))$d - $offsetutcOffset()$uchecked before$offset$offsetchecked before$uisUTC()!!$u!!$u && !$offsettoDate('s')format('YYYY-MM-DD HH:mm:ss:SSS')new Date($d)$x.$localOffsetexisted only to paper over the host's offset at$dand isgone.
startOf/$set/addinsrc/index.jsneed no change: they alreadybranch 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 thatinstance 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()andtoISOString()disagreed with each other:Five tests in
test/plugin/utc.test.jsand one intest/timezone.test.jsencoded that. Each passed
dayjs .utc()where it passedmoment .utc(true), soonly the moment side kept local time; they now keep it on both sides, which
makes them host-independent, and each gained a
format()assertion, sincetoISOString()alone could not see the disagreement above.Tests
test/plugin/timezone.test.js- the reported instants per zone, plus controlsat 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/KolkataandUTC, asserting both the wall clock andthat
valueOf()still equals the instant.test/timezone.test.js- the same for.utcOffset()without the timezoneplugin, including
startOf('day')off the shifted wall clock.Expectations are derived from
Intl.DateTimeFormatand from moment, not typedby 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 underEurope/LondonandPacific/Aucklandinnpm run test-tz, and under all fourhosts of
npm run test-tz-plugin; they pass onUTCandAmerica/Whitehorse,which have no DST.
npm test(all 8 host-timezone runs plus the 100%-line coverage run, 808 tests)and
npm run lintpass.Refs #2303