Skip to content

[pickers] Fix DST regression in AdapterDayjs.adjustOffset#22278

Draft
LukasTy wants to merge 4 commits intomui:masterfrom
LukasTy:claude/competent-kilby-9a1521
Draft

[pickers] Fix DST regression in AdapterDayjs.adjustOffset#22278
LukasTy wants to merge 4 commits intomui:masterfrom
LukasTy:claude/competent-kilby-9a1521

Conversation

@LukasTy
Copy link
Copy Markdown
Member

@LukasTy LukasTy commented Apr 30, 2026

Fixes #21669.

Summary

In a non-UTC system timezone (e.g. Los Angeles) on a DST start day (e.g. March 8, 2026), picking a time like 04:00 AM in the time picker silently produced a different underlying instant. v7 was fine; v8/v9 regressed.

Root cause

AdapterDayjs.adjustOffset mutated value.$offset after every setX/addX/startOfX/endOfX. This was correct for tz-aware dayjs values (where $d is local-as-UTC and $offset compensates) but wrong for plain dayjs() values, whose valueOf() is computed as:

$d.getTime() - ($offset + $d.getTimezoneOffset()) * 60000

Mutating only $offset (without updating $d) drifted the underlying instant by the guessed-zone offset. Display getters (hour(), format()) still looked right because they read $d directly, which is why this slipped through.

PR #22170 (April 2026) simplified createSystemDate to return plain dayjs(value), which is exactly the path where the mutation produces wrong results.

What changed

  • adjustOffset now early-returns for plain dayjs and UTC values, and uses a clean value.tz(timezone, true) round-trip for tz-aware values. No more direct $offset mutation.
  • Dropped the redundant adjustOffset wrappers from the eight startOfX/endOfX methods. The dayjs timezone plugin's startOf override already handles DST for tz-aware values, JS Date semantics handle it for plain values, and endOf is implemented as startOf(unit, false) in dayjs core, so it inherits the same handling.
  • Kept adjustOffset on the seven addX and seven setX methods (the dayjs timezone plugin does not override .add() or .set(), so explicit DST handling is still needed there).

#13290 stays fixed: plain values are returned untouched, so no spurious $x.$timezone is attached.

Tests

New regression tests in AdapterDayjs.test.tsx:

  • should not mutate $offset on plain dayjs() values when dayjs.tz.guess() is non-UTC (regression #21669) - asserts the invariant directly via $offset and toDate().toISOString().
  • Same for addMonths.
  • Time picker (regression #21669) > should call onChange with the clicked hour when picking on a DST start day with system timezone - end-to-end via <DigitalClock timezone=\"system\">, mirrors the user's reproduction.

New shared "should update the offset when entering DST" cases for setDate and setHours in describeGregorianAdapter/testCalculations.ts, mirroring the existing addMonths/addWeeks/addDays coverage.

Sanity-checked by temporarily reverting adjustOffset to the old mutation: all three new dayjs tests fail with the buggy code and pass with the fix; every other test passes in both states.

Test plan

Replace the `value.$offset` mutation with a `value.tz(tz, true)` round-trip
for tz-aware values, and skip the adjustment for plain `dayjs()` values.
The mutation corrupted plain values' `valueOf()` because their formula
relies on `$offset` and `$d.getTimezoneOffset()` together. After PR mui#22170
made system-timezone values plain, this surfaced as the time picker
silently shifting the picked hour on DST start days in non-UTC system
zones.

Drop the redundant `adjustOffset` from `startOfX`/`endOfX` - the dayjs
timezone plugin already handles DST inside its `startOf` override.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@LukasTy LukasTy self-assigned this Apr 30, 2026
@LukasTy LukasTy added type: regression A bug, but worse, it used to behave as expected. scope: pickers Changes related to the date/time pickers. needs cherry-pick The PR should be cherry-picked to master after merge. feature: time-zone Issues about time zone management. v8.x labels Apr 30, 2026
@code-infra-dashboard
Copy link
Copy Markdown

code-infra-dashboard Bot commented Apr 30, 2026

Deploy preview

https://deploy-preview-22278--material-ui-x.netlify.app/

Bundle size

Bundle Parsed size Gzip size
@mui/x-data-grid 0B(0.00%) 0B(0.00%)
@mui/x-data-grid-pro 0B(0.00%) 0B(0.00%)
@mui/x-data-grid-premium 0B(0.00%) 0B(0.00%)
@mui/x-charts 0B(0.00%) 0B(0.00%)
@mui/x-charts-pro 0B(0.00%) 0B(0.00%)
@mui/x-charts-premium 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers-pro 0B(0.00%) 0B(0.00%)
@mui/x-tree-view 0B(0.00%) 0B(0.00%)
@mui/x-tree-view-pro 0B(0.00%) 0B(0.00%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

LukasTy and others added 3 commits April 30, 2026 18:20
The first cut at the fix replaced the `$offset` mutation with
`return value.tz(timezone, true)`, which has two side effects:

  - the timezone plugin's "keep local time" branch runs
    `n.add(i - m, 'minute')`, shifting `$d` whenever the offset changes;
  - dayjs's `utcOffset(_, true)` does not write `$x.$localOffset`,
    leaving the side table that `valueOf()` consults empty.

For tz-aware values built via `dayjs.tz(...)` (which sets `$localOffset`
via the no-keep-local-time branch of `utcOffset`), this combination made
`$d.getHours()` land on the wrong side of the DST gap once the system
timezone was non-UTC (e.g. user's LA env on March 8). The picker reports
`getHours(setHours(value, X))` against `X` to decide whether each hour
slot is selectable, so the off-by-one shift visibly disabled hours
adjacent to the gap (1 disabled slot turned into 3).

Restoring the in-place `$offset` mutation - while keeping the
plain-value early return that fixes the original mui#21669 - leaves `$d`
and `$x.$localOffset` untouched, which is what `valueOf()` and
`getHours()` rely on.

Adds a structural test that pins both invariants for the `dayjs.tz(...)`
construction path; the test fails with the `value.tz(timezone, true)`
return and passes with the mutation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous early-return check was `if (!value.$x?.$timezone)`, which
also skipped values that came through `dayjs(...).tz(undefined, false)` -
the picker's `now` from `createTZDate(undefined, 'default')`. Those
values have `$offset` set to the system zone's construction-time offset
but no `$x.$timezone`. Skipping `adjustOffset` left `$offset` stale
after `setHours` to a different DST regime, so the picker's
`onChange` handed the parent a value whose underlying instant drifted
an hour from the picked clock time, even though `getHours()` looked
right.

Tighten the early return to check `$offset` instead - only pure
`dayjs(value)` values (no `$offset`) skip the mutation, while anything
that has been touched by `.tz()` runs through the OLD mutation logic.
This restores the v8/v9 behavior for the picker's `now` value while
keeping the mui#21669 fix for plain values and the mui#13290 fix
(plain values stay plain, no spurious timezone metadata).

Restores the `adjustOffset` wrappers on `startOfX`/`endOfX` for the
same reason - the picker chains those with `setX`/`addX` and any drift
in the start/end value would propagate.

Adds a regression test that pins the `now`-shape behavior. Sanity-
checked: the test fails with the over-aggressive early return and
passes with the tightened one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`(value.set('hour', hour) as Dayjs).$d` doesn't typecheck - `$d` is a
private dayjs internal not on the public `Dayjs` type, and the cast is
to the same type that lacks it. Moving the existing `// @ts-ignore`
comment one line up covers the call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature: time-zone Issues about time zone management. needs cherry-pick The PR should be cherry-picked to master after merge. scope: pickers Changes related to the date/time pickers. type: regression A bug, but worse, it used to behave as expected. v8.x

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[pickers] Time selection issue around Daylight Saving Time (DST)

1 participant