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 ---