From 1afe0d7cc8106e68cdda01b95a28ed183dfc1e76 Mon Sep 17 00:00:00 2001 From: Saul Dominguez Date: Fri, 17 Jul 2026 11:38:20 +0200 Subject: [PATCH] fix(OneDateNavigator): revive persisted string dates before use A date-navigator value restored from OneDataCollection persisted storage round-trips through JSON, so its `from`/`to` arrive as ISO strings rather than Date objects. OneDateNavigator's equality check and its trigger (label, granularity math) call Date methods like `.getTime()` on them, throwing `TypeError: from.getTime is not a function` on the second load of any collection that persists a `date-navigator` navigation filter. Revive `value`/`defaultValue` into real Date objects at the OneDateNavigator boundary, so every downstream consumer receives Dates. It's a no-op (returns the same reference) when the range already holds Date objects. Co-Authored-By: Claude Opus 4.8 --- .../OneDateNavigator/OneDateNavigator.tsx | 26 ++++++++--- .../__tests__/OneDateNavigator.test.tsx | 43 +++++++++++++++++++ .../react/src/ui/DatePickerPopup/utils.ts | 37 ++++++++++++++++ 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/packages/react/src/patterns/OneDateNavigator/OneDateNavigator.tsx b/packages/react/src/patterns/OneDateNavigator/OneDateNavigator.tsx index c5dde36bdb..141202fb62 100644 --- a/packages/react/src/patterns/OneDateNavigator/OneDateNavigator.tsx +++ b/packages/react/src/patterns/OneDateNavigator/OneDateNavigator.tsx @@ -6,7 +6,10 @@ import { DatePickerPopup, DatePickerPopupProps, } from "@/ui/DatePickerPopup/DatePickerPopup" -import { isSameDatePickerValue } from "@/ui/DatePickerPopup/utils" +import { + isSameDatePickerValue, + reviveDatePickerValue, +} from "@/ui/DatePickerPopup/utils" import { getGranularityDefinitions } from "@/components/OneCalendar/granularities" import { @@ -37,17 +40,28 @@ function _OneDateNavigator({ dataTestId, ...props }: OneDatePickerProps) { + // A `value`/`defaultValue` restored from persisted storage (e.g. a + // OneDataCollection `date-navigator` filter) arrives with `from`/`to` as + // strings after its JSON round-trip. Revive them to `Date` at the boundary so + // every downstream consumer — the equality check below, the trigger label, + // granularity math — receives real `Date` objects. + const revivedValue = useMemo(() => reviveDatePickerValue(value), [value]) + const revivedDefaultValue = useMemo( + () => reviveDatePickerValue(defaultValue), + [defaultValue] + ) + const [localValue, setLocalValue] = useState( - defaultValue ?? value + revivedDefaultValue ?? revivedValue ) useEffect(() => { - if (isSameDatePickerValue(value, localValue)) { + if (isSameDatePickerValue(revivedValue, localValue)) { return } - setLocalValue(value || defaultValue) + setLocalValue(revivedValue || revivedDefaultValue) // eslint-disable-next-line react-hooks/exhaustive-deps -- we only want to update the local value when the value changes - }, [value, defaultValue]) + }, [revivedValue, revivedDefaultValue]) const [compareToValue, setCompareToValue] = useState< DateRangeComplete | DateRangeComplete[] | undefined @@ -88,7 +102,7 @@ function _OneDateNavigator({ { expect(previousButton).toBeNull() }) + // Regression: a value restored from persisted OneDataCollection storage has + // round-tripped through JSON, so its `from`/`to` are ISO strings rather than + // Date objects. The navigator used to call `.getTime()` on them and throw + // `TypeError: from.getTime is not a function`. It must revive them instead. + it("renders a defaultValue whose dates were serialized to strings", () => { + const onSelect = vi.fn() + const serializedDefaultValue = { + value: { + from: new Date(2023, 0, 1).toISOString(), + to: new Date(2023, 0, 31).toISOString(), + }, + granularity: "month" as const, + } as unknown as Parameters[0]["defaultValue"] + + expect(() => + render( + + ) + ).not.toThrow() + + expect(screen.getByRole("button", { name: "January 2023" })).toBeDefined() + }) + + it("renders a controlled value whose dates were serialized to strings", () => { + const onSelect = vi.fn() + const serializedValue = { + value: { + from: new Date(2023, 0, 1).toISOString(), + to: new Date(2023, 0, 31).toISOString(), + }, + granularity: "month" as const, + } as unknown as Parameters[0]["value"] + + expect(() => + render() + ).not.toThrow() + + expect(screen.getByRole("button", { name: "January 2023" })).toBeDefined() + }) + it("renders with go to current hidden", () => { const onSelect = vi.fn() diff --git a/packages/react/src/ui/DatePickerPopup/utils.ts b/packages/react/src/ui/DatePickerPopup/utils.ts index e63c6586fe..3d4819d7d0 100644 --- a/packages/react/src/ui/DatePickerPopup/utils.ts +++ b/packages/react/src/ui/DatePickerPopup/utils.ts @@ -1,5 +1,42 @@ import { DatePickerValue } from "./types" +const coerceToDate = (value: Date | string | number): Date => + value instanceof Date ? value : new Date(value) + +/** + * Revives a `DatePickerValue` whose `from`/`to` may have been serialized to + * strings before reaching us. + * + * The date range is typed as `Date` objects, but a value that has round-tripped + * through `JSON.stringify`/`JSON.parse` — e.g. a `OneDataCollection` + * `date-navigator` filter restored from persisted storage — arrives with + * `from`/`to` as ISO strings. Downstream consumers (the equality check, the + * trigger label, granularity math) all call `Date` methods such as + * `.getTime()`, so a string `from` throws + * `TypeError: from.getTime is not a function`. + * + * Returns the original reference untouched when the range is already made of + * real `Date` objects (the common case), so this adds no re-render churn. + */ +export const reviveDatePickerValue = ( + value: DatePickerValue | undefined +): DatePickerValue | undefined => { + if (!value?.value) { + return value + } + const { from, to } = value.value as { + from: Date | string | number + to: Date | string | number + } + if (from instanceof Date && to instanceof Date) { + return value + } + return { + ...value, + value: { from: coerceToDate(from), to: coerceToDate(to) }, + } +} + export const isSameDatePickerValue = ( a: DatePickerValue | undefined, b: DatePickerValue | undefined