Steps to reproduce
Split out from #23163, which fixed setYear/setMonth/addYears/addMonths in #23296 and deliberately scoped out the sibling methods. This is the remaining part.
AdapterDayjs returns wrong results for startOfYear, startOfMonth, startOfDay, endOfYear, endOfMonth, endOfDay and getDaysInMonth on dates predating the standardization of the timezone, where the IANA database falls back on the Local Mean Time of the location (Asia/Kolkata is GMT+05:53:28).
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
const adapter = new AdapterDayjs();
// A plausible birthdate, in a timezone whose Local Mean Time is not a round number of minutes.
const value = adapter.setYear(adapter.date('2026-08-15T12:30:00', 'Asia/Kolkata'), 1883);
adapter.getDaysInMonth(value); // 1 (expected 31)
adapter.startOfMonth(value); // 1883-07-31 23:59 (expected 1883-08-01 00:00)
adapter.startOfYear(value); // 1882-12-31 23:59 (expected 1883-01-01 00:00)
adapter.endOfMonth(value); // 1883-09-01 00:00 (expected 1883-08-31 23:59)
adapter.endOfYear(value); // 1884-01-01 00:00 (expected 1883-12-31 23:59)
adapter.startOfDay(value); // 1883-08-14 23:59 (expected 1883-08-15 00:00)
adapter.endOfDay(value); // 1883-08-16 00:00 (expected 1883-08-15 23:59)
Note that startOfYear and endOfYear are off by a whole year, not just by a day.
User-visible consequence. getDaysInMonth feeds the day section boundaries, so arrow editing on the day section collapses the day to 1 and can never leave it:
<DateField
defaultValue={adapter.setYear(adapter.date('2026-08-15T12:30:00', 'Asia/Kolkata'), 1883)}
format="MM/DD/YYYY"
timezone="Asia/Kolkata"
/>
Select the day section and press ArrowUp:
| case |
getDaysInMonth |
day section on ArrowUp |
1883, Asia/Kolkata |
1 |
25 → 01 → 01 (stuck) |
2020, Asia/Kolkata |
31 |
25 → 26 → 27 |
1883, America/Detroit |
31 |
25 → 26 → 27 |
1883, UTC |
31 |
25 → 26 → 27 |
Typing a day works (numeric editing computes its boundaries with currentDate: null, so it does not consult getDaysInMonth), and DateCalendar renders correctly for those dates (getWeekArray is unaffected: correct header, 31 day cells, correct selection). Arrow editing is the path that breaks.
Current behavior
Measured over 8 timezones × 6 years (202, 1582, 1883, 1900, 1950, 2020), with the system timezone set to UTC:
| method |
wrong results |
startOfYear |
14/48 |
startOfMonth |
14/48 |
startOfDay |
14/48 |
endOfYear |
15/48 |
endOfMonth |
15/48 |
endOfDay |
15/48 |
getDaysInMonth |
11/48 |
getWeekArray |
0/48 |
Which timezones are affected depends on the system timezone as well as the timezone prop, because dayjs derives the offset by reparsing a locale string in the system zone. With TZ=UTC it is Asia/Kolkata, Australia/Adelaide and Asia/Kathmandu; with TZ=America/Detroit it is America/New_York and others. So the reproduction above may need a different timezone on your machine.
Expected behavior
startOf* should return the first instant of the year/month/day and endOf* the last one, and getDaysInMonth should return the real number of days in the month, on these dates as on any other.
Context
The cause is upstream. dayjs's timezone plugin overrides startOf by formatting the value to a wall clock, applying startOf in the system timezone, then calling .tz(zone, true):
proto.startOf = function (units, startOf) {
if (!this.$x || !this.$x.$timezone) return oldStartOf.call(this, units, startOf)
const withoutTz = dayjs(this.format('YYYY-MM-DD HH:mm:ss:SSS'), { locale: this.$L })
return oldStartOf.call(withoutTz, units, startOf).tz(this.$x.$timezone, true)
}
That last .tz(zone, true) is the broken step on Local Mean Time offsets, and endOf and daysInMonth both derive from it. Two dayjs behaviours are involved:
- The offset is computed as
new Date(date.toLocaleString('en-US', { timeZone })), parsed in the system zone, which skews once the system zone also has a sub-minute offset for the date.
utcOffset() treats a magnitude below 16 as hours (Math.abs(s) < 16 ? 60 * s : s in plugin/utc.js), so Europe/Paris at GMT+00:09:21 is read as +09:00.
The fix used in #23296 (restoring the day of the month after the operation) does not transfer here: these methods need the whole wall clock rebuilt, not just the day, so it needs its own approach. Repairing them by composing the already-safe primitives is one option, since setDate/setHours/... are unaffected.
Search keywords: AdapterDayjs startOf endOf getDaysInMonth timezone Local Mean Time
Steps to reproduce
Split out from #23163, which fixed
setYear/setMonth/addYears/addMonthsin #23296 and deliberately scoped out the sibling methods. This is the remaining part.AdapterDayjsreturns wrong results forstartOfYear,startOfMonth,startOfDay,endOfYear,endOfMonth,endOfDayandgetDaysInMonthon dates predating the standardization of the timezone, where the IANA database falls back on the Local Mean Time of the location (Asia/KolkataisGMT+05:53:28).Note that
startOfYearandendOfYearare off by a whole year, not just by a day.User-visible consequence.
getDaysInMonthfeeds the day section boundaries, so arrow editing on the day section collapses the day to1and can never leave it:Select the day section and press ArrowUp:
getDaysInMonth1883,Asia/Kolkata125→01→01(stuck)2020,Asia/Kolkata3125→26→271883,America/Detroit3125→26→271883,UTC3125→26→27Typing a day works (numeric editing computes its boundaries with
currentDate: null, so it does not consultgetDaysInMonth), andDateCalendarrenders correctly for those dates (getWeekArrayis unaffected: correct header, 31 day cells, correct selection). Arrow editing is the path that breaks.Current behavior
Measured over 8 timezones × 6 years (
202,1582,1883,1900,1950,2020), with the system timezone set toUTC:startOfYearstartOfMonthstartOfDayendOfYearendOfMonthendOfDaygetDaysInMonthgetWeekArrayWhich timezones are affected depends on the system timezone as well as the
timezoneprop, becausedayjsderives the offset by reparsing a locale string in the system zone. WithTZ=UTCit isAsia/Kolkata,Australia/AdelaideandAsia/Kathmandu; withTZ=America/Detroitit isAmerica/New_Yorkand others. So the reproduction above may need a different timezone on your machine.Expected behavior
startOf*should return the first instant of the year/month/day andendOf*the last one, andgetDaysInMonthshould return the real number of days in the month, on these dates as on any other.Context
The cause is upstream.
dayjs's timezone plugin overridesstartOfby formatting the value to a wall clock, applyingstartOfin the system timezone, then calling.tz(zone, true):That last
.tz(zone, true)is the broken step on Local Mean Time offsets, andendOfanddaysInMonthboth derive from it. Twodayjsbehaviours are involved:new Date(date.toLocaleString('en-US', { timeZone })), parsed in the system zone, which skews once the system zone also has a sub-minute offset for the date.utcOffset()treats a magnitude below 16 as hours (Math.abs(s) < 16 ? 60 * s : sinplugin/utc.js), soEurope/ParisatGMT+00:09:21is read as+09:00.The fix used in #23296 (restoring the day of the month after the operation) does not transfer here: these methods need the whole wall clock rebuilt, not just the day, so it needs its own approach. Repairing them by composing the already-safe primitives is one option, since
setDate/setHours/... are unaffected.Search keywords: AdapterDayjs startOf endOf getDaysInMonth timezone Local Mean Time