PR #177 merged: fix: don't unmount DatePicker while loading month availability
What Adele saw
when I go to the top > to change the month it goes off again
Clicking the calendar's Next/Previous month arrow dismissed the date picker entirely on prod. PR #176 missed this; the bug only manifests for cleaners with hasAvailability !== false, and no demo cleaner locally had availability slots configured.
Two-layer root cause
Layer 1 — booking-dialog.tsx:501 unmounts the entire DatePicker during the availability fetch
{loadingMonth && hasAvailability !== false ? (
<div>Loading dates...</div> // ← unmounts DatePicker
) : (
<DatePicker ... onMonthChange={fetchMonthAvailability} />
)}
- User clicks
>to advance month onMonthChange→fetchMonthAvailabilitysetsloadingMonth = true- Conditional flips → DatePicker unmounts, loader shows
- Fetch resolves ~200ms →
loadingMonth = false - DatePicker remounts as a fresh instance → internal
isOpenresets tofalse - Calendar gone from the user's POV
Captured with a MutationObserver during prod repro: clicking "Go to the Next Month" removed DIV.relative (the DatePicker root) 18ms later.
Layer 2 — date-picker.tsx handleSelect closed on any callback including spurious undefined
After fixing layer 1, the bug still reproduced locally once availability was seeded. Tracing showed the inner Calendar (rdp-root) was remounting twice during the re-render — and react-day-picker fires onSelect(undefined) during reconciliation. My handleSelect always called setIsOpen(false), treating spurious undefined the same as a real pick. The range picker already had the guard (if (range?.from && range?.to)); the single picker didn't.
Fixes
-
booking-dialog.tsx— keep DatePicker mounted regardless ofloadingMonth. Render a small inline "Checking availability..." hint as helper text below the picker. Calendar stays open,isOpenpreserved, dates briefly stay greyed until the new month's availability lands. -
date-picker.tsx—handleSelectnow only auto-closes when a concrete date is selected. Matches the range picker's guard. -
DevelopmentSeeder.php— give[email protected]a M–F 9–5 availability schedule. The dormant code path is now hot on local — same shape as prod. -
docs/standards/frontend.md— two new rules under UI Components:- "Never unmount a stateful component during async loading" with the exact PR #176 antipattern + the working fix
- "Don't auto-close popovers on every onSelect" for the react-day-picker spurious-undefined case
Verified locally
With Hannah (HOVtlDrO) seeded for M–F 9–5 availability — Select dropdown active, identical to prod Kenneth (ARbs1igz):
- Click
Preferred date→ calendar opens - Click
>→ calendar stays open, advances May → June - Click
>again → advances June → July - All months reachable, calendar persists across consecutive month changes
- Screenshot:
scratch/datepicker-next-month-FIXED.png
Post-mortem — why we shipped this twice
Sequence
| # | Event |
|---|---|
| 1 | PR #176 ships responsive DatePicker. Verified at 375 + 1280 viewports locally. |
| 2 | Adele reports > arrow breaks the picker on prod. |
| 3 | I traced via MutationObserver, found the booking-dialog unmount. First commit on this PR. |
| 4 | Local "smoke test" of fix passed — but I tested with Hannah (no availability), missing the bugged code path entirely. |
| 5 | Stu pushed back. Seeded availability locally, reproduced. Found layer 2 (Calendar inner remount). Second commit. |
| 6 | All paths now verified locally with a cleaner that actually has availability. |
Why we missed it twice
- Test data divergence. No demo cleaner had availability configured locally. Half the booking dialog's code was dormant. Fix: this PR seeds it.
- No automated browser test for this regression. Per Stu's earlier call, browser tests were skipped on PR #176. The bug only manifests as click → async fetch → React re-render — exactly what browser tests catch. Decision still defensible (flake/slowness), but a single happy-path browser smoke test for the booking flow is cheap insurance worth reconsidering.
- "Same DOM" was a false signal. I compared local + prod DOM structure for the date picker and they matched. The behavioural divergence came from state (availability data), not structure. Static DOM inspection lied.
- First fix verified against the dormant code path. I KNEW the bug needed availability data and still didn't seed it before retesting. Avoidable.
The two anti-patterns at root, plus what's at risk elsewhere
A — Conditional render swaps a stateful component during async loading.
Any component with internal state (open/closed, focus, scroll, transient form values) gets nuked when its container's loading flag flips. Searched the codebase — only booking-dialog.tsx had this exact pattern. No other consumer of DatePicker, DateRangePicker, Sheet, Dialog, or Popover swaps under a loading flag.
B — Self-state component closes itself on any callback value.
Custom popovers that hold their own isOpen and dismiss on onSelect are vulnerable to 3rd-party widgets firing spurious callbacks during reconciliation. date-range-picker.tsx already guarded; date-picker.tsx now matches. No other custom popover with self-state in components/ui/.
Prevention going forward
In this PR:
- ✅ Seed availability on the test cleaner (data parity local ↔ prod)
- ✅ Document the two anti-patterns in
docs/standards/frontend.md
Out of scope, worth discussing separately:
- One Pest browser test for the booking happy path (open → pick date → change month → still open). One test, not a suite — meaningfully changes the cost/benefit math vs PR #176.
- Lifting
isOpenout ofDatePickerto make it a fully controlled component. Bigger change touching 6 call sites, ends this entire class of bug but not warranted just yet.