Stu Mason
Stu Mason

Activity

StuMason/cleanconnect
TidyLinker.com
TypeScript
Pull Request Merged

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} />
)}
  1. User clicks > to advance month
  2. onMonthChangefetchMonthAvailability sets loadingMonth = true
  3. Conditional flips → DatePicker unmounts, loader shows
  4. Fetch resolves ~200ms → loadingMonth = false
  5. DatePicker remounts as a fresh instance → internal isOpen resets to false
  6. 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

  1. booking-dialog.tsx — keep DatePicker mounted regardless of loadingMonth. Render a small inline "Checking availability..." hint as helper text below the picker. Calendar stays open, isOpen preserved, dates briefly stay greyed until the new month's availability lands.

  2. date-picker.tsxhandleSelect now only auto-closes when a concrete date is selected. Matches the range picker's guard.

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

  4. 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
1PR #176 ships responsive DatePicker. Verified at 375 + 1280 viewports locally.
2Adele reports > arrow breaks the picker on prod.
3I traced via MutationObserver, found the booking-dialog unmount. First commit on this PR.
4Local "smoke test" of fix passed — but I tested with Hannah (no availability), missing the bugged code path entirely.
5Stu pushed back. Seeded availability locally, reproduced. Found layer 2 (Calendar inner remount). Second commit.
6All 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 isOpen out of DatePicker to make it a fully controlled component. Bigger change touching 6 call sites, ends this entire class of bug but not warranted just yet.
+28
additions
-26
deletions
2
files changed