Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,24 @@ All notable changes to this project are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.0.0] - 2026-05-31
## [2.0.1] - 2026-05-31

### Changed

- **Snappier, lighter rendering — same behavior.** Internal cleanup that removes
redundant work without changing the component's output:
- The hour, minute, and day-of-month range pickers now compute their
cross-disabled options directly while rendering instead of mirroring them
into state through effects, so a start/end change reflects in the same render
instead of a render later.
- `Intl.DateTimeFormat` / `Intl.RelativeTimeFormat` formatters used by the
Next-runs calendar are cached per locale + options, so the same formatter is
reused across renders and per-run loops rather than rebuilt each time.

### Removed

- Dropped the unused `vitest-browser-react` dev dependency. No effect on
published output.

### Added

Expand Down
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "material-ui-cron",
"version": "2.0.0",
"version": "2.0.1",
"description": "A React cron editor using material ui",
"author": "Parichay Barpanda <parichay.barpanda@gmail.com> (https://github.com/baymac/)",
"license": "MIT",
Expand Down Expand Up @@ -61,8 +61,7 @@
"typescript": "^6.0.3",
"vite": "^8.0.14",
"vite-plugin-dts": "^5.0.1",
"vitest": "^4",
"vitest-browser-react": "^2"
"vitest": "^4"
},
"peerDependencies": {
"@emotion/react": "^11.11.0",
Expand Down
28 changes: 23 additions & 5 deletions src/Scheduler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ export default function Scheduler(props: SchedulerProps) {
const { cron, setCron, setCronError, isAdmin, locale, customLocale } = props;
const { timezone, layout = 'auto', slotProps, title, color } = props;
const period = useAtomValue(periodState);
const [periodIndex, setPeriodIndex] = React.useState(0);
// Which fields to show is a pure function of the selected period — derive it
// during render rather than mirroring it into state via an effect.
const periodIndex = getPeriodIndex(period);

// Recolor everything that reads `palette.primary` (header bar, the selected
// segment of the toggles, the section pills) by overriding primary in a
Expand Down Expand Up @@ -129,20 +131,28 @@ export default function Scheduler(props: SchedulerProps) {
const setHourAtEvery = useSetAtom(hourAtEveryState);
const setDayOfMonthAtEvery = useSetAtom(dayOfMonthAtEveryState);

// Notify the parent of the current validation error. This is the controlled
// component's output contract, not derived state we own: `cronError` is read
// from an internal atom and pushed up through the `setCronError` prop so the
// host can react to validity. The error originates here (the validator), so
// it can't be "fetched in the parent" as the lint suggests.
// react-doctor-disable-next-line react-doctor/no-derived-state-effect
React.useEffect(() => {
// react-doctor-disable-next-line react-doctor/no-pass-data-to-parent
setCronError(cronError);
}, [cronError, setCronError]);

React.useEffect(() => {
setPeriodIndex(getPeriodIndex(period));
}, [period]);

// Two-way binding between the controlled `cron` prop and the internal
// `cronExpInput` atom. Splitting this into two opposing effects causes them
// to swap stale values on a single commit and ping-pong forever whenever the
// initial prop differs from the atom default (-> "Maximum update depth
// exceeded"). A single effect that propagates only the side that actually
// changed converges in one render.
// This effect is the load-bearing fix for issues #16/#19/#20: it is the
// controlled component's two-way bridge between the `cron` prop and the
// internal atom, propagating only the side that actually changed so it
// converges in one render instead of ping-ponging. The `setCron` call is the
// documented output half of that contract, not stray "data to parent".
const prevSync = React.useRef<{ cron: string; input: string } | null>(null);
React.useEffect(() => {
if (prevSync.current === null) {
Expand All @@ -152,6 +162,7 @@ export default function Scheduler(props: SchedulerProps) {
if (cron !== cronExpInput) {
setCronExpInput(cron);
} else {
// react-doctor-disable-next-line react-doctor/no-pass-data-to-parent
setCron(cronExpInput);
}
prevSync.current = { cron, input: cronExpInput };
Expand All @@ -164,6 +175,7 @@ export default function Scheduler(props: SchedulerProps) {
setCronExpInput(cron);
} else if (inputChanged && cronExpInput !== cron) {
// Internal value changed (user edit / field change) -> notify parent.
// react-doctor-disable-next-line react-doctor/no-pass-data-to-parent
setCron(cronExpInput);
}
prevSync.current = { cron, input: cronExpInput };
Expand Down Expand Up @@ -193,6 +205,12 @@ export default function Scheduler(props: SchedulerProps) {
// switch (e.g. period reads "day" under Chinese). Re-map each selection onto
// the matching option in the new locale, preserving the choice and
// refreshing its label. Falls back to the existing value if no match.
//
// The multiple setters write six INDEPENDENT jotai atoms (period, the three
// at/every toggles, week, month) — separate pieces of global store state, not
// co-located component state a useReducer could unify. Synchronizing an
// external store when an input changes is a legitimate effect.
// react-doctor-disable-next-line react-doctor/no-cascading-set-state
React.useEffect(() => {
const remap = (opts: SelectOptions[]) => (prev: SelectOptions) =>
opts.find((o) => o.value === prev.value) ?? prev;
Expand Down
48 changes: 28 additions & 20 deletions src/components/CustomSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ import type React from 'react';
import type { CustomSelectProps, SelectOptions } from '../types';
import { getSortedOptions } from '../utils';

// Map custom sizes to MUI sizes and widths. Widths are kept tight so a select
// showing a short value (e.g. "week", "9") doesn't stretch across the row;
// multi-select sizes (lg) leave room for a few chips before wrapping.
const getSizeConfig = (customSize: 'sm' | 'md' | 'lg') => {
switch (customSize) {
case 'sm':
return { muiSize: 'small' as const, width: '110px' };
case 'md':
return { muiSize: 'small' as const, width: '140px' };
case 'lg':
return { muiSize: 'small' as const, width: '190px' };
default:
return { muiSize: 'small' as const, width: '110px' };
}
};

export default function CustomSelect<V extends SelectOptions | SelectOptions[]>(
props: CustomSelectProps<V>,
) {
Expand All @@ -21,25 +37,9 @@ export default function CustomSelect<V extends SelectOptions | SelectOptions[]>(
...otherprops
} = props;

// Map custom sizes to MUI sizes and widths. Widths are kept tight so a select
// showing a short value (e.g. "week", "9") doesn't stretch across the row;
// multi-select sizes (lg) leave room for a few chips before wrapping.
const getSizeConfig = (customSize: 'sm' | 'md' | 'lg') => {
switch (customSize) {
case 'sm':
return { muiSize: 'small' as const, width: '110px' };
case 'md':
return { muiSize: 'small' as const, width: '140px' };
case 'lg':
return { muiSize: 'small' as const, width: '190px' };
default:
return { muiSize: 'small' as const, width: '110px' };
}
};

const sizeConfig = getSizeConfig(size);

const handleChange = (
const applySelection = (
event: React.SyntheticEvent<Element, Event>,
newValue: SelectOptions | SelectOptions[] | null,
reason: AutocompleteChangeReason,
Expand Down Expand Up @@ -81,7 +81,7 @@ export default function CustomSelect<V extends SelectOptions | SelectOptions[]>(
multiple
options={options}
value={value}
onChange={handleChange}
onChange={applySelection}
isOptionEqualToValue={(option, val) =>
(option as SelectOptions).value === (val as SelectOptions).value
}
Expand Down Expand Up @@ -158,13 +158,21 @@ export default function CustomSelect<V extends SelectOptions | SelectOptions[]>(
const chips = shown.map((option, index) => {
const disableSingleItemRemove =
items.length === 1 && disableEmpty ? { onDelete: undefined } : {};
// React forbids a `key` arriving via spread (it must be a
// direct JSX attribute). MUI's getItemProps returns one (typed
// off the public return shape), so strip it and pass our own
// stable key explicitly, spreading the rest.
const { key: _itemKey, ...itemProps } = getItemProps({ index }) as Record<
string,
unknown
>;
return (
<Chip
key={(option as SelectOptions).label}
label={(option as SelectOptions).label}
size='small'
{...getItemProps({ index })}
{...itemProps}
{...disableSingleItemRemove}
key={(option as SelectOptions).label}
/>
);
});
Expand Down
61 changes: 48 additions & 13 deletions src/components/NextRuns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,15 +182,32 @@ interface NextRunsProps {
timezone?: string;
}

// Cache Intl.DateTimeFormat instances by locale + options. monthLabel runs on
// every render (per visible month) and weekdayInitials per locale, so reusing
// the formatter avoids reallocating it each time.
const dateTimeFormatCache = new Map<string, Intl.DateTimeFormat>();
function dateTimeFormat(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
const key = `${locale}|${JSON.stringify(options)}`;
let fmt = dateTimeFormatCache.get(key);
if (!fmt) {
// This IS the hoist the rule wants: built once per locale+options key and
// cached (mirrors the cache in nextRuns.ts).
// react-doctor-disable-next-line react-doctor/js-hoist-intl
fmt = new Intl.DateTimeFormat(locale, options);
dateTimeFormatCache.set(key, fmt);
}
return fmt;
}

// Sunday-first narrow weekday initials for `localeTag` (Jan 1 2023 is a Sunday).
function weekdayInitials(localeTag: string): string[] {
const fmt = new Intl.DateTimeFormat(localeTag, { weekday: 'narrow', timeZone: 'UTC' });
const fmt = dateTimeFormat(localeTag, { weekday: 'narrow', timeZone: 'UTC' });
return Array.from({ length: 7 }, (_, i) => fmt.format(new Date(Date.UTC(2023, 0, 1 + i))));
}

function monthLabel(ym: string, localeTag: string): string {
const [year, month] = ym.split('-').map(Number);
return new Intl.DateTimeFormat(localeTag, {
return dateTimeFormat(localeTag, {
month: 'long',
year: 'numeric',
timeZone: 'UTC',
Expand Down Expand Up @@ -227,6 +244,11 @@ export default function NextRuns({ timezone }: NextRunsProps) {
React.useEffect(() => {
const at = new Date();
if (validationError.length > 0) {
// Not derivable during render: the firing-day set comes from an async
// cron-parser computation (dynamically imported, off the main entry) with
// a cancellation guard. The effect is the correct tool here; the empty
// branch just short-circuits the same state the async path writes.
// react-doctor-disable-next-line react-doctor/no-adjust-state-on-prop-change
setData({ days: [], anchor: at });
return;
}
Expand Down Expand Up @@ -257,23 +279,29 @@ export default function NextRuns({ timezone }: NextRunsProps) {

// On a fresh schedule, jump to the month of the soonest run and pre-select that
// day so the list isn't empty; if nothing runs in the window, fall back to the
// current month with no selection.
// current month with no selection. This is a state-reset-on-prop-change: do it
// during render (the React-recommended pattern) keyed off the firing-day set,
// so the reset only fires when `days` itself changes (new cron / tz) — not on
// every render that recreates `months`, and without the extra commit a useEffect
// would force.
const [cursor, setCursor] = React.useState(0);
const [selectedDay, setSelectedDay] = React.useState<string | null>(null);
React.useEffect(() => {
// Track the last firing-day set we reacted to. A ref (not state) because it's
// only compared during render, never rendered — the setCursor/setSelectedDay
// calls below already drive the re-render.
const prevDays = React.useRef(days);
if (prevDays.current !== days) {
prevDays.current = days;
if (days.length === 0) {
setCursor(0);
setSelectedDay(null);
return;
} else {
const firstDay = days[0];
setSelectedDay(firstDay);
const idx = months.indexOf(firstDay.slice(0, 7));
setCursor(idx >= 0 ? idx : 0);
}
const firstDay = days[0];
setSelectedDay(firstDay);
const idx = months.indexOf(firstDay.slice(0, 7));
setCursor(idx >= 0 ? idx : 0);
// Reset only when the firing-day set itself changes (new cron / tz), not on
// every render that recreates `months`.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [days]);
}

// The actual run times for the SELECTED day only — computed on demand (a cheap
// single-day window) rather than up front for every day in the window. Tagged
Expand All @@ -286,10 +314,17 @@ export default function NextRuns({ timezone }: NextRunsProps) {
);
React.useEffect(() => {
if (!selectedDay || validationError.length > 0) {
// Not derivable during render: a firing day's run times come from an async
// cron-parser computation (see below) with a cancellation guard. These two
// early branches just short-circuit the same state the async path writes
// (no selection / a non-firing day → no runs), so the effect stays the
// single owner of `selectedRuns`.
// react-doctor-disable-next-line react-doctor/no-adjust-state-on-prop-change
setSelectedRuns(null);
return;
}
if (!daySet.has(selectedDay)) {
// react-doctor-disable-next-line react-doctor/no-adjust-state-on-prop-change
setSelectedRuns({ day: selectedDay, runs: [] });
return;
}
Expand Down
5 changes: 5 additions & 0 deletions src/components/SchedulerHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ export default function SchedulerHeader({ sx, title }: SchedulerHeaderProps) {
// the debounced text side). Do NOT collapse these into one effect.
const debouncedCronExpInput = useDebounce(cronExpInput, 500);

// Two-way text<->atom binding, not derived state: the text field is edited by
// the user (writing the atom) AND updated when the cron changes elsewhere
// (reading it). Each direction is debounced/guarded; collapsing them re-opens
// the ping-pong fixed in #20. Keep as two effects.
// react-doctor-disable-next-line react-doctor/no-derived-state-effect
React.useEffect(() => {
setCronExpInput(cronExp);
}, [cronExp, setCronExpInput]);
Expand Down
29 changes: 4 additions & 25 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { SelectOptions } from './types';
import { getTimesOfTheDayList, range } from './utils';
import { range } from './range';

export const generateOrdinalOptions = (start: number, end: number): SelectOptions[] => {
const generateOrdinalOptions = (start: number, end: number): SelectOptions[] => {
return range(start, end).map((day) => {
let customLabel = `${day}th`;
if (!(day.length > 1 && day.startsWith('1'))) {
Expand Down Expand Up @@ -67,7 +67,7 @@ export const weekOptions = (weekDayLabels: string[]): SelectOptions[] =>

/* DAY OF MONTH */

export const defaultDayOfMonthOptions = () => {
const defaultDayOfMonthOptions = () => {
return range(1, 31).map((day) => {
return {
value: `${day}`,
Expand All @@ -76,7 +76,7 @@ export const defaultDayOfMonthOptions = () => {
});
};

export const defaultDayOfMonthOptionsWithOrdinal = () => {
const defaultDayOfMonthOptionsWithOrdinal = () => {
return generateOrdinalOptions(1, 31);
};

Expand All @@ -102,15 +102,6 @@ export const getMonthOptions = (monthOptionLabels: string[]) =>

/* HOUR */

export const defaultHourOptionsHr = () => {
return getTimesOfTheDayList().map((time, idx) => {
return {
value: `${idx}`,
label: time,
};
});
};

export const defaultHourOptions = (type?: string) => {
return range(0, 23).map((time) => {
return {
Expand Down Expand Up @@ -151,18 +142,6 @@ export const atEveryOptions = (atLabel: string, everyLabel: string): SelectOptio
},
];

export const atEveryOptionsNonAdmin = (atLabel: string, everyLabel: string): SelectOptions[] => [
{
value: 'at',
label: atLabel,
disabled: true,
},
{
value: 'every',
label: everyLabel,
},
];

export const atOptionsNonAdmin = (atLabel: string, everyLabel: string): SelectOptions[] => [
{
value: 'at',
Expand Down
Loading
Loading