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
87 changes: 87 additions & 0 deletions package/src/calendar-provider.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Temporal } from "@js-temporal/polyfill";
import { render, act, cleanup } from "@testing-library/react";
import type { ComponentProps } from "react";
import { describe, it, expect, vi, afterEach } from "vitest";
import { useCalendarStable, useCalendarState } from "./calendar-context";
import { CalendarProvider } from "./calendar-provider";
Expand Down Expand Up @@ -135,3 +136,89 @@ describe("CalendarProvider", () => {
spy.mockRestore();
});
});

describe("CalendarProvider — controlled-value integrity (M7)", () => {
it.each([
{
description: "range value that is not a { start, end } DateRange",
selectionMode: "range" as const,
value: "2026-03-15",
expected: { matches: /range/i },
},
{
description: "multiple value that is not an array",
selectionMode: "multiple" as const,
value: T.PlainDate.from("2026-03-15"),
expected: { matches: /multiple|array/i },
},
])(
"warns instead of silently going uncontrolled for a malformed $description",
({ selectionMode, value, expected }) => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
render(
// selectionMode is a union here (it.each), which can't narrow the
// discriminated props union — and value is intentionally wrong-shaped.
<CalendarProvider
{...({
temporal: T,
selectionMode,
value,
onValueChange: vi.fn(),
} as unknown as ComponentProps<typeof CalendarProvider>)}
>
<div />
</CalendarProvider>,
);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("[DatePicker]"),
);
expect(warn.mock.calls[0]?.[0]).toMatch(expected.matches);
warn.mockRestore();
},
);

it("warns once (not every render) for an out-of-bounds controlled value and never fires onValueChange", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const onChange = vi.fn();
const make = (onValueChange: () => void) => (
<CalendarProvider
temporal={T}
format="PlainDate"
selectionMode="single"
value={T.PlainDate.from("2020-06-15")}
min={T.PlainDate.from("2026-01-01")}
max={T.PlainDate.from("2026-12-31")}
onValueChange={onValueChange}
>
<div />
</CalendarProvider>
);
const { rerender } = render(make(onChange));
// Re-render with a fresh inline onValueChange (the common case): the
// out-of-bounds effect re-runs, but the warning must not repeat.
rerender(make(vi.fn()));
expect(onChange).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledTimes(1);
warn.mockRestore();
});

it("still clears and fires onValueChange for an out-of-bounds uncontrolled defaultValue", () => {
const onChange = vi.fn();
render(
<CalendarProvider
temporal={T}
format="PlainDate"
selectionMode="single"
defaultValue={T.PlainDate.from("2020-06-15")}
min={T.PlainDate.from("2026-01-01")}
max={T.PlainDate.from("2026-12-31")}
onValueChange={onChange}
>
<div />
</CalendarProvider>,
);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(null, expect.anything());
});
});
81 changes: 65 additions & 16 deletions package/src/calendar-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,35 @@ function CalendarProvider<F extends ValueFormat = "PlainDate">(
sortDates,
]);

// Surface (instead of silently self-managing) a controlled `value` whose
// shape doesn't match the selection mode — otherwise range/multiple values
// fall through to `undefined` above and the component quietly becomes
// uncontrolled, which looks like the prop being ignored. Warn once per
// malformed episode: this effect re-runs whenever the parent inlines a fresh
// `value` reference, but we only warn when entering the malformed state.
const warnedMalformedRef = useRef(false);
useEffect(() => {
const malformed =
valueProp != null &&
((isRange && !isDateRange<F>(valueProp)) ||
(isMultiple && !Array.isArray(valueProp)));
if (!malformed) {
warnedMalformedRef.current = false;
return;
}
if (warnedMalformedRef.current) return;
warnedMalformedRef.current = true;
if (isRange) {
console.warn(
`[DatePicker] selectionMode="range" expects \`value\` to be a { start, end } DateRange or null, but received ${Array.isArray(valueProp) ? "an array" : typeof valueProp}. Ignoring it and falling back to uncontrolled.`,
);
} else {
console.warn(
`[DatePicker] selectionMode="multiple" expects \`value\` to be an array or null, but received ${typeof valueProp}. Ignoring it and falling back to uncontrolled.`,
);
}
}, [valueProp, isRange, isMultiple]);

const defaultDates = useMemo<(Temporal.PlainDate | null)[]>(() => {
if (isMultiple) {
if (multipleDefault) return sortDates(multipleDefault.map(rawToPlain));
Expand Down Expand Up @@ -423,29 +452,48 @@ function CalendarProvider<F extends ValueFormat = "PlainDate">(
]);

// --- Out-of-bounds cleanup for single mode ---
const warnedOutOfBoundsRef = useRef(false);
useEffect(() => {
if (isRange || isMultiple) return;
const start = committedStart;
if (!start) return;
if (!start) {
warnedOutOfBoundsRef.current = false;
return;
}
const outOfBounds =
(minValue && T.PlainDate.compare(start, minValue) < 0) ||
(maxValue && T.PlainDate.compare(start, maxValue) > 0);
if (outOfBounds) {
const prev = currentSingleFormatted();
commitSelection({
newDates: [],
fireCallback: (onValueChange) => {
(
onValueChange as
| ((
v: RawValueForFormat<F> | null,
m: { date: undefined; previous: RawValueForFormat<F> | null },
) => void)
| undefined
)?.(null, { date: undefined, previous: prev });
},
});
if (!outOfBounds) {
warnedOutOfBoundsRef.current = false;
return;
}
if (isControlled) {
// The parent owns a controlled `value`. Don't clear it or fire
// onValueChange on mount — that fights the parent and risks a render
// loop. Warn once (this effect re-runs whenever the parent inlines
// `onValueChange`) so they can reconcile `value` with min/max.
if (!warnedOutOfBoundsRef.current) {
warnedOutOfBoundsRef.current = true;
console.warn(
`[DatePicker] The controlled \`value\` is outside the min/max range. It stays selected (rendered disabled) until you update \`value\`; the component will not clear it for you.`,
);
}
return;
}
const prev = currentSingleFormatted();
commitSelection({
newDates: [],
fireCallback: (onValueChange) => {
(
onValueChange as
| ((
v: RawValueForFormat<F> | null,
m: { date: undefined; previous: RawValueForFormat<F> | null },
) => void)
| undefined
)?.(null, { date: undefined, previous: prev });
},
});
}, [
minValue,
maxValue,
Expand All @@ -455,6 +503,7 @@ function CalendarProvider<F extends ValueFormat = "PlainDate">(
T,
isRange,
isMultiple,
isControlled,
]);

// --- onSelect ---
Expand Down
Loading