diff --git a/package/src/calendar-provider.test.tsx b/package/src/calendar-provider.test.tsx
index e0f0e6c..ed07d31 100644
--- a/package/src/calendar-provider.test.tsx
+++ b/package/src/calendar-provider.test.tsx
@@ -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";
@@ -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.
+ )}
+ >
+
+ ,
+ );
+ 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) => (
+
+
+
+ );
+ 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(
+
+
+ ,
+ );
+ expect(onChange).toHaveBeenCalledTimes(1);
+ expect(onChange).toHaveBeenCalledWith(null, expect.anything());
+ });
+});
diff --git a/package/src/calendar-provider.tsx b/package/src/calendar-provider.tsx
index 95e5f8b..56c1e65 100644
--- a/package/src/calendar-provider.tsx
+++ b/package/src/calendar-provider.tsx
@@ -199,6 +199,35 @@ function CalendarProvider(
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(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));
@@ -423,29 +452,48 @@ function CalendarProvider(
]);
// --- 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 | null,
- m: { date: undefined; previous: RawValueForFormat | 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 | null,
+ m: { date: undefined; previous: RawValueForFormat | null },
+ ) => void)
+ | undefined
+ )?.(null, { date: undefined, previous: prev });
+ },
+ });
}, [
minValue,
maxValue,
@@ -455,6 +503,7 @@ function CalendarProvider(
T,
isRange,
isMultiple,
+ isControlled,
]);
// --- onSelect ---