Skip to content

[scheduler] Read the timestamps of the processed dates in the recurring events - #24004

Merged
flaviendelangle merged 3 commits into
mui:masterfrom
flaviendelangle:scheduler-plain-date-recurring-internals
Oct 27, 2026
Merged

flaviendelangle merged 3 commits into
mui:masterfrom
flaviendelangle:scheduler-plain-date-recurring-internals

Conversation

@flaviendelangle

@flaviendelangle flaviendelangle commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

This PR moves the internal inputs of the recurring events engine from adapter values to timestamps. The series, the expansion of the visible range, the deletions and the edits read the instants and the timezones the processed events already carry, instead of reading them back from adapter values with getTime() and getTimezone(). The dependency engine uses the addDaysOnWallClock() of plain-date-utils instead of its own copy. The public API doesn't change.

Changes

  • RecurringSeries takes the instant of DTSTART as a timestamp, and its timezone becomes required. RecurringSeries.fromEvent() passes the timestamp of the processed start, and decideSplitRRule() takes the timestamp and the timezone of the series instead of an adapter value.
  • The visible range reaches the recurring events plugin as timestamps. getOccurrencesFromEvents() reads its two bounds once and passes them to the event range index and to getOccurrencesForVisibleDays(). The range index read them on every query, and the expansion on every recurring event. The range index no longer needs the adapter.
  • The deletion of one occurrence or of the following ones, and the end of an edit of the whole series, take the data timezone of the processed event instead of the timezone the occurrence's adapter value is labeled with. The store labels these values in the data timezone, so it's the same timezone.
  • The dependency engine adds the days of a lag with the addDaysOnWallClock() of plain-date-utils. It keeps its range check, which returns NaN past the range of Date, where Intl throws instead of returning the offsets of a timezone. The other difference between the two helpers, the shortcut for 0 days, can't be reached: a lag of 0 is no lag.

Behavior changes

None. The engine reads the same instants and timezones as before: the timestamp of a processed date is the getTime() of its value, and the data timezone is the one its values are labeled with. getTimezone() returns 'system' for a value labeled 'default', and both read the offsets of the system.

The range check of the dependency engine now adds the lag to the instant rather than to the wall-clock time. The two differ by less than a day, and only matter at the edge of the range of Date, in the year 275,760.

Bundle size

Measured from source with esbuild, minified, with third-party packages external and without the error minifier, gzip level 9.

Entry master This PR Difference
event-calendar 75.94 kB 75.93 kB −10 B
event-calendar-premium 91.61 kB 91.54 kB −65 B
event-timeline-premium 96.54 kB 96.48 kB −58 B

Adapter usage

Counted like in #23943. The references go from 234 to 226.

Method or property master This PR Difference
getTime 57 53 −4
getTimezone 17 13 −4

getOccurrencesFromEvents() gains the two getTime() calls that the range index and the expansion lose. The references left in the recurring events engine read values of the public API: the UNTIL of the processed rule, the EXDATE of the processed event, and the dates updateRecurringEvent() receives.

Benchmarks

Run in jsdom with pnpm vitest bench, master then this PR, on a machine running other test suites. Operations per second, higher is better.

Benchmark master This PR
Expansion of 200 series in the timezone of the system, week view 238 232
Same, month view 93.1 88.5
Same, year timeline 12.1 12.6
Expansion of 200 series in America/New_York displayed in Europe/Paris, week view 97.1 104
Same, month view 21.6 23.2
Same, year timeline 2.39 2.47
Series with COUNT, week view 10 years after DTSTART 2,398 2,537
Same, month view 951 1,000
Dependency cascade of 300 tasks, default data timezone 1,077 1,204
Same, America/New_York 344 359
Same, America/New_York, all-day tasks 287 290
Delete only this occurrence 1,406 1,365
Delete this and following occurrences 1,033 1,332
Update this and following occurrences 135 142
Range index, narrow query of 50k events 514,356 466,105
Range index, broad query of 50k events 4,584 4,795
Occurrences grouped by day, month view 1,506 1,492

The differences stay within the noise of the runs: the linear queries of the range index benchmark, whose code this PR doesn't change, vary by up to 44% between the two runs. The range index benchmark reads the timestamps of its bounds at each query, so it measures the same work as before.

Follow-ups

  • The callers of getOccurrencesFromEvents(), the occurrence selectors and useEventOccurrencesGroupedByDay(), can pass the timestamps of their days. That removes its two getTime() calls, and useEventOccurrencesGroupedByDay() no longer has to create two adapter values just to pass them.

Validation

  • The tests of the recurring and dependency engines, auto-scheduling, the range index, the occurrence hooks and the Timeline dependencies pass in jsdom (1,106 tests) and in the browser (349 tests). CI runs the full suites.

  • The tests of the series, the expansion, the split rules and the range index pass timestamps.

  • A new test of addDependencyLag() checks that a lag in days or weeks past the range of Date returns NaN, in UTC and in America/New_York. It fails without the range check. The existing tests didn't pin it: they run in the timezone of the system, whose offsets come out as NaN past the range anyway.

  • On systems in Pacific/Apia, Australia/Lord_Howe and America/Santiago, the tests of the recurring and dependency engines give the same results as on master, where 39 of them expect a UTC system.

  • A /simplify pass with four reviews (reuse, simplification, efficiency, altitude): decideSplitRRule() takes the processed event and builds its series with RecurringSeries.fromEvent(), and getOccurrencesFromEvents() takes the timestamps of its range, so the days of a view no longer create two adapter values to read their instants back. The range check of the dependency engine stays: it existed on master, and dropping it would let Intl throw on an absurd lag.

  • A review round with Codex and the base-ui-review skill approved without findings.

  • Rita's review: useEventOccurrencesGroupedByDay() reads the bounds of its days with getStartOfDayMs() and getEndOfDayMs(), the constant of the range check is MAX_EPOCH_MS, as it bounds an instant, and the contracts of the range say that both bounds are timestamps and included. A new test checks that the excluded occurrences still count toward the COUNT of the series a split creates, and the fixture of the split tests uses the 'default' timezone of real events. The delete helpers still read the day of occurrenceStart in its own timezone: their JSDoc says it must be in the data timezone, and the store passes it so. The range check stays in the dependency engine.

  • TypeScript, ESLint and Prettier pass.

  • I have followed (at least) the PR section of the contributing guide.

🤖 Generated with Claude Code

…ng events

The recurring events read the instants and the timezones of the processed
dates they receive, instead of calling `getTime()` and `getTimezone()` on
their adapter values. `RecurringSeries` takes the timestamp of its first
occurrence and its timezone, the expansion and the range index take the
timestamps of the visible range, and the edits and deletions use the data
timezone of the event.

The dependency engine adds its day lags with the `addDaysOnWallClock()` of
`plain-date-utils`, and keeps returning `NaN` past the range of `Date`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@flaviendelangle flaviendelangle added type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature. scope: scheduler Changes related to the scheduler. labels Oct 26, 2026
@code-infra-dashboard

code-infra-dashboard Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Deploy preview

https://deploy-preview-24004--material-ui-x.netlify.app/
QR code for https://deploy-preview-24004--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%)
@mui/x-scheduler ▼-23B(-0.01%) ▼-15B(-0.01%)
@mui/x-scheduler-premium ▼-124B(-0.02%) ▼-79B(-0.04%)
@mui/x-chat 0B(0.00%) 0B(0.00%)
@mui/x-license 0B(0.00%) 0B(0.00%)

Details of bundle changes


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

…urrences

`decideSplitRRule()` takes the processed event and builds its series with
`RecurringSeries.fromEvent()`. `getOccurrencesFromEvents()` takes the
timestamps of its range, so the days of a view no longer create two adapter
values to read their instants back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@flaviendelangle
flaviendelangle marked this pull request as ready for review October 27, 2026 06:47

@rita-codes rita-codes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR review

Nothing is merge-blocking. The "no behavior change" claim holds: every comparison and bound I traced is operator-for-operator equivalent to master, the store relabels occurrenceStart and changes into the data timezone before the plugin runs, and the full scheduler jsdom suites pass (3,939 tests). The new addDependencyLag test does fail without the range check, as claimed. What remains is a hand-rolled copy of two existing helpers, a constant whose new name says the opposite of what it compares, and one invariant the PR now relies on without a test.

Bugs (1)

1. ℹ️ The delete helpers now mix the label timezone of occurrenceStart with the data timezone

Location: packages/x-scheduler-internals-premium/src/internals/utils/recurring-events/deleteRecurringEvent.ts:57

createStartOfPlainDate(
  adapter,
  buildTemporalPlainDate(adapter, occurrenceStart),
  originalEvent.dataTimezone.timezone,
),

buildTemporalPlainDate reads the calendar date in the timezone occurrenceStart is labeled with, and createStartOfPlainDate builds midnight of that date in the data timezone. Master used the label for both. Today the two agree because the store relabels occurrenceStart with adapter.setTimezone(occurrenceStart, original.dataTimezone.timezone) before calling the plugin, so this is not reachable, but the correctness of the EXDATE and UNTIL instants now depends on that relabel rather than on the helper itself.

Failure scenario: A future direct caller passes a display-timezone value (data New York, display Tokyo, occurrence 22:00 NY = 11:00 Tokyo the next day): EXDATE lands on the wrong day's midnight.

Fix: Read the plain date in the data timezone too: toPlainDateTime(adapter.getTime(occurrenceStart), dataTimezone).date, or keep as is and leave the existing "Expects occurrenceStart labeled in the event's data timezone" comment, which is now load-bearing.

Tests (2)

1. 🟡 Nothing pins that excluded occurrences still count toward COUNT at the split

Location: packages/x-scheduler-internals-premium/src/internals/utils/recurring-events/updateRecurringEvent.ts:366

// The excluded occurrences still count toward COUNT.
const series = RecurringSeries.fromEvent(adapter, originalEvent);

Master built the series without EXDATEs. fromEvent passes event.dataTimezone.exDates, and the behavior is unchanged only because countBefore() never consults excludedDays. The only decideSplitRRule COUNT test (count 42 → 37) uses an event without EXDATE, and the only EXDATE test in the file uses a rule without COUNT, so remaining is never computed with both.

Failure scenario: A later "fix" making countBefore() skip excluded days (plausible, since hasOccurrence* already ignores them) silently inflates the new series' COUNT after a this-and-following edit, and no split test notices.

Fix: In describe('decideSplitRRule'), DAILY count 5 from Jan 1 with EXDATE Jan 2, split at Jan 4 → expect { count: 2 }.

2. ℹ️ The decideSplitRRule fixture labels the event 'system', which production never produces

Location: packages/x-scheduler-internals-premium/src/internals/utils/recurring-events/updateRecurringEvent.test.ts:26

timezone: adapter.getTimezone(originalSeriesStart) yields 'system' for a 'default'-labeled start, while real events carry dataTimezone.timezone === 'default'. The engine treats both the same, so no gap, but the fixture reproduces the pre-PR adapter detour instead of the new contract. timezone: defaultEvent.dataTimezone.timezone is the honest fixture.

Simplifications (2)

1. 🟡 useEventOccurrencesGroupedByDay rebuilds getStartOfDayMs and getEndOfDayMs by hand

Location: packages/x-scheduler-internals/src/use-event-occurrences-grouped-by-day/useEventOccurrencesGroupedByDay.ts:91

start: toEpochMs(plainDateFromDayNumber(dayNumbers[0]), 0, displayTimezone),
// The last instant before the day after the last one starts.
end: toEpochMs(plainDateFromDayNumber(lastDayNumber + 1), 0, displayTimezone) - 1,

Both lines are the bodies of helpers already exported from the barrel: getStartOfDayMs and getEndOfDayMs in plain-date-utils/timeZone.ts, including the - 1 and the DST comment.

Fix:

start: getStartOfDayMs(days[0].timestamp, displayTimezone),
end: getEndOfDayMs(days[days.length - 1].timestamp, displayTimezone),

Removes lastDayNumber, the comment, two PlainDate allocations and the plainDateFromDayNumber/toEpochMs imports.

2. ℹ️ The range guard could live in addDaysOnWallClock() itself

Location: packages/x-scheduler-internals-premium/src/internals/utils/dependency-utils.ts:59

addDaysOnWallClock has a second caller in useDayCellDropTarget.ts that would get the same Intl throw on an absurd result. A one-line check inside the shared helper would delete addDaysInDateRange, the constant and its comments here. The PR body argues for keeping it in the engine, so this is a judgement call.

Docs (2)

1. 🟡 MAX_WALL_CLOCK_MS now bounds an instant, not a wall-clock time

Location: packages/x-scheduler-internals-premium/src/internals/utils/dependency-utils.ts:53

// The range of `Date`, minus a margin for the offsets of the timezone read around the result.
const MAX_WALL_CLOCK_MS = 8.64e15 - 2 * MS_PER_DAY;
...
return Math.abs(epochMs + dayCount * MS_PER_DAY) > MAX_WALL_CLOCK_MS

Master compared toLocalMs(epochMs, tz) + days under the name MAX_LOCAL_MS. The PR renames it to "wall clock" while switching the operand to an epoch instant, which the PR body itself states ("adds the lag to the instant rather than to the wall-clock time"). The JSDoc "where the offsets of the timezone can't be read" is also only true for IANA zones: for 'UTC' the helper returns a finite out-of-range number without throwing, and the guard is what makes the result a uniform NaN.

Fix: MAX_EPOCH_MS (or MAX_DATE_MS), and "…or returns NaN past the range of Date, where Intl throws instead of returning the offsets of an IANA timezone."

2. ℹ️ The plugin contract says "instant" without saying timestamp or inclusive

Location: packages/x-scheduler-internals/src/internals/plugins/SchedulerRecurringEventsPlugin.types.ts:31

"from the instant start to the instant end" on a bare number pair. Both bounds are inclusive (the range index uses <=/>=, the expansion scans the whole day of end, and the grouped-by-day caller passes nextDayStart - 1 for that reason). event-utils.ts already says "The first instant of the range, as a timestamp" / "The last instant…"; the cross-package contract deserves the same, plus "both included". Same wording in getRecurringEventOccurrencesForVisibleDays and getEventsForRange.

Verdict

Approve after nits — no regression, but the hand-rolled day bounds and the misleading constant name are worth a pass before merge.


🤖 Review generated with Claude Code

…OUNT of a split

- `useEventOccurrencesGroupedByDay()` reads the bounds of its days with `getStartOfDayMs()` and `getEndOfDayMs()`.
- The range check of the dependency engine names its constant `MAX_EPOCH_MS`, as it bounds an instant.
- The contracts of the visible range say that both bounds are timestamps and included.
- A new test checks that the excluded occurrences still count toward the COUNT of the series a split creates, and the fixture of the split tests uses the `'default'` timezone of real events.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@rita-codes rita-codes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up verified: getStartOfDayMs/getEndOfDayMs in the grouped-by-day hook, the MAX_EPOCH_MS rename and JSDoc, the "both included" range docs and the EXDATE+COUNT split test (COUNT 2 is right). Full scheduler suite green locally. Thanks!

@flaviendelangle
flaviendelangle merged commit 55bb9ff into mui:master Oct 27, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: scheduler Changes related to the scheduler. type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants