From 945026d44601500d3d663776a8cdfda6402448ec Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 7 Jul 2026 08:56:04 +0200 Subject: [PATCH] fix(admin): stop the event-date field crashing the page on backspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repro: create an event, click into the date field, backspace a day digit. The whole page white-screened and needed a reload. Root cause: LocalizedDateInput's `toIso` only checked the day/month were 1-2 digits, not that they formed a real date — so a mid-backspace value like "0/07/2026" was coerced to the string "2026-07-00" and committed to `event_date`. CreateEventPage then rendered `format(addDays(new Date('2026-07-00'), days))`, and date-fns `format` throws RangeError on an Invalid Date — thrown during render, so React tore the tree down to the error boundary. Two complementary fixes: - `toIso` round-trips the parsed y/m/d through `Date` and rejects impossible dates (day 00, month 13, 31 Feb…), so the field never commits a value that isn't a real calendar date. - `useLocalizedDate.format`/`formatDistanceToNow` guard with `isValid` and return '' instead of throwing — defence in depth for the ~57 call sites that could otherwise white-screen on a bad date. Verified live: backspacing to a partial/invalid date no longer crashes (the form stays rendered), a valid date still commits + the expiry preview renders. Adds a LocalizedDateInput regression test; tsc + build green. --- .../components/common/LocalizedDateInput.tsx | 7 ++++ .../__tests__/LocalizedDateInput.test.tsx | 39 +++++++++++++++++++ frontend/src/hooks/useLocalizedDate.ts | 8 +++- 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/common/__tests__/LocalizedDateInput.test.tsx diff --git a/frontend/src/components/common/LocalizedDateInput.tsx b/frontend/src/components/common/LocalizedDateInput.tsx index 77fef538..23d37318 100644 --- a/frontend/src/components/common/LocalizedDateInput.tsx +++ b/frontend/src/components/common/LocalizedDateInput.tsx @@ -90,6 +90,13 @@ export const LocalizedDateInput: React.FC = ({ [d, mo, y] = [a, b, c]; } if (!/^\d{1,2}$/.test(d) || !/^\d{1,2}$/.test(mo) || !/^\d{4}$/.test(y)) return ''; + // Reject impossible calendar dates (day 00, month 13, 31 Feb…) — a partial + // value mid-backspace like "0/07/2026" is otherwise coerced to "2026-07-00", + // which is a valid string but an Invalid Date that crashes date-fns format() + // downstream. Round-trip through Date to confirm the components survive. + const yy = Number(y), mm = Number(mo), dd = Number(d); + const probe = new Date(yy, mm - 1, dd); + if (probe.getFullYear() !== yy || probe.getMonth() !== mm - 1 || probe.getDate() !== dd) return ''; return `${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`; }; diff --git a/frontend/src/components/common/__tests__/LocalizedDateInput.test.tsx b/frontend/src/components/common/__tests__/LocalizedDateInput.test.tsx new file mode 100644 index 00000000..e4963c5f --- /dev/null +++ b/frontend/src/components/common/__tests__/LocalizedDateInput.test.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { vi } from 'vitest'; +import { LocalizedDateInput } from '../LocalizedDateInput'; + +// Stub the settings hook so the component doesn't need a QueryClient; falls +// back to the default DD.MM.YYYY display format. The parse/validation under +// test is separator-independent. +vi.mock('../../../hooks/usePublicSettings', () => ({ + usePublicSettings: () => ({ settings: {} }), +})); + +describe('LocalizedDateInput', () => { + const renderInput = (value = '2026-07-07') => { + const onChange = vi.fn(); + render(); + const input = screen.getByDisplayValue('07.07.2026') as HTMLInputElement; + return { input, onChange }; + }; + + it('does not commit an impossible date mid-edit (regression: backspacing the day → "2026-07-00" crashed the page)', () => { + const { input, onChange } = renderInput(); + // Backspacing a day digit leaves "0.07.2026" — a syntactically complete but + // invalid date. It must NOT propagate (used to coerce to "2026-07-00", + // which crashed date-fns format() downstream). + fireEvent.change(input, { target: { value: '0.07.2026' } }); + expect(onChange).not.toHaveBeenCalled(); + + // Nor may an out-of-range calendar date (31 Feb). + fireEvent.change(input, { target: { value: '31.02.2026' } }); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('commits a complete, valid date as ISO', () => { + const { input, onChange } = renderInput(); + fireEvent.change(input, { target: { value: '15.08.2026' } }); + expect(onChange).toHaveBeenCalledWith('2026-08-15'); + }); +}); diff --git a/frontend/src/hooks/useLocalizedDate.ts b/frontend/src/hooks/useLocalizedDate.ts index 46e043cb..0c9e914b 100644 --- a/frontend/src/hooks/useLocalizedDate.ts +++ b/frontend/src/hooks/useLocalizedDate.ts @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns'; +import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow, isValid } from 'date-fns'; import { de, enUS, ptBR, fr } from 'date-fns/locale'; import { usePublicSettings } from './usePublicSettings'; @@ -32,6 +32,11 @@ export const useLocalizedDate = () => { const format = (date: Date | string, formatStr?: string) => { const dateObj = typeof date === 'string' ? new Date(date) : date; + // date-fns `format` throws RangeError on an Invalid Date, which crashes the + // whole page when a call site renders a transient/partial date (e.g. the + // event-date field mid-edit). Return '' instead so a bad value degrades to + // blank rather than tearing down the tree. + if (!isValid(dateObj)) return ''; // Use admin-configured date format if available and no format string provided let dateFormat = formatStr; if (!dateFormat && settings?.general_date_format) { @@ -50,6 +55,7 @@ export const useLocalizedDate = () => { const formatDistanceToNow = (date: Date | string, options?: { addSuffix?: boolean }) => { const dateObj = typeof date === 'string' ? new Date(date) : date; + if (!isValid(dateObj)) return ''; return dateFnsFormatDistanceToNow(dateObj, { ...options, locale: getLocale() }); };