fix(admin): stop the event-date field crashing the page on backspace

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.
This commit is contained in:
Paul Nothaft
2026-07-07 08:56:04 +02:00
parent 2522d7e1ce
commit 945026d446
3 changed files with 53 additions and 1 deletions
@@ -90,6 +90,13 @@ export const LocalizedDateInput: React.FC<LocalizedDateInputProps> = ({
[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')}`;
};
@@ -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(<LocalizedDateInput value={value} onChange={onChange} />);
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');
});
});