diff --git a/.claude/TODO/2026-09-13-search-empty-state-never-renders.md b/.claude/TODO/2026-09-13-search-empty-state-never-renders.md deleted file mode 100644 index 6b6e3d7..0000000 --- a/.claude/TODO/2026-09-13-search-empty-state-never-renders.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -title: "Add/Edit Family search: 'No contacts found' empty state never renders" -severity: low -tags: [bug] -area: components -files: [src/app/(web)/tools/addeditfamily/add-edit-family.tsx] -discovered: 2026-09-13 -discovered_by: coverage-agent-addeditfamily -status: open ---- - -## Problem -`FamilySearchBar` (in `add-edit-family.tsx`) renders a `No contacts -found.` whenever the query is at least 2 characters and -`searchContacts` returned zero results. In practice this text never appears in -the DOM, because `cmdk`'s `CommandEmpty` only renders when the command's -*global* registered-item count (`filtered.count`, tracked across the whole -`Command` tree, not just the group it lives in) is zero. The sibling "Or" -`CommandGroup` always registers a `+ New Family with last name "…"` item as -soon as the query is >= 2 characters, so that count is never zero, and -`CommandEmpty` unconditionally returns `null` regardless of our own -`results.length === 0` check. - -Net effect: a user who searches for a name with no matches sees only the -"+ New Family" suggestion, with no indication that the search itself came up -empty (they may not notice there's a difference between "no matches" and -"you haven't typed enough"). - -## Evidence -- `src/app/(web)/tools/addeditfamily/add-edit-family.tsx:457-459` — the - `CommandEmpty` block, gated on local `results`/`query` state. -- `node_modules/cmdk/dist/index.mjs` — `CommandEmpty` (`Ie`) renders based on - `useCommandState(v => v.filtered.count === 0)`, and `filtered.count` is - computed from `shouldFilter === false` as `u.current.size` (total item - registrations), not scoped to a single `CommandGroup`. -- `src/app/(web)/tools/addeditfamily/add-edit-family.test.tsx` — the test - `"finds no CommandEmpty text when the search returns nothing"` documents and - asserts this actual (broken) behavior instead of the intended message. - -## Proposed fix -Don't rely on `CommandEmpty`'s built-in visibility heuristic here. Replace it -with an explicit conditional `
` (matching the style already used for the -"Type at least 2 characters" and "Searching…" states a few lines above) that -renders whenever `!isSearching && query.trim().length >= 2 && results.length -=== 0`, instead of wrapping the message in ``. - -## Impact if not fixed -Cosmetic/UX only — the "+ New Family" path still works, so no data is lost or -corrupted. A user searching for an existing family that doesn't actually exist -gets a slightly less clear affordance (no explicit "not found" feedback), but -can still proceed by creating a new family. diff --git a/.claude/TODO/INDEX.md b/.claude/TODO/INDEX.md index 4ecde9d..1611244 100644 --- a/.claude/TODO/INDEX.md +++ b/.claude/TODO/INDEX.md @@ -18,7 +18,7 @@ Severity tiers: - **medium**: doc drift, missing test, refactor with real cost - **low**: nits, minor doc fixes, stylistic improvements -Total: **5 open TODOs**. +Total: **4 open TODOs**. > **`step6`** and **`step8`** are the remaining steps from the 2026-09-30 MPNext > playbook audit (one PR each). `held-*` items wait on an owner decision. @@ -39,11 +39,10 @@ _none open_ | routing | security, drift | Step 6 — Headers, next.config, server-only, prerender + build in CI | [→](2026-09-30-step6-headers-csp-build-guards.md) | | routing | security, doc | Held — CSP notes for customer-deployed clones | [→](2026-09-30-held-csp-customer-deployments.md) | -### Low (3) +### Low (2) | Area | Tags | Title | File | |---|---|---|---| | components | security, bug | Step 8 — Address-label caps, error boundaries, tool-params decode, codegen escaping | [→](2026-09-30-step8-fork-specific-low-risk-items.md) | -| components | bug | Add/Edit Family search: "No contacts found" empty state never renders | [→](2026-09-13-search-empty-state-never-renders.md) | | components | refactor, missing-test | Unreachable "empty STEP_FIELDS" branch in `GroupWizard.handleNext` | [→](2026-09-13-dead-empty-fields-branch-handlenext.md) | --- @@ -55,9 +54,8 @@ _none open_ - held-csp-customer-deployments — medium - step8-fork-specific-low-risk-items — low -### bug (2) +### bug (1) - step8-fork-specific-low-risk-items — low -- search-empty-state-never-renders — low ### drift (1) - step6-headers-csp-build-guards — medium @@ -80,7 +78,7 @@ _none open_ | Area | Count | |---|---| -| components | 3 | +| components | 2 | | routing | 2 | | services | 0 | | auth | 0 | diff --git a/.claude/references/GOTCHAS.md b/.claude/references/GOTCHAS.md index 7767667..cd97456 100644 --- a/.claude/references/GOTCHAS.md +++ b/.claude/references/GOTCHAS.md @@ -1,7 +1,7 @@ --- title: Gotchas type: gotchas -last_updated: 2026-04-17 +last_updated: 2026-10-10 --- # Gotchas @@ -61,6 +61,7 @@ Known pitfalls for agents working on MPNext-Tools. Each entry has a symptom-firs - GOTCHA-038: _Retired 2026-10-10 (template editor removed)_ - GOTCHA-039: _Retired 2026-10-10 (template editor removed)_ - GOTCHA-044: A flag set inside one `setState` updater is still `false` when the next setter runs +- GOTCHA-045: `` never renders even though the search returned nothing ### Utils / labels - GOTCHA-040: IMb barcode silently becomes POSTNET (or disappears) with no log @@ -404,6 +405,14 @@ Retired 2026-10-10: applied only to the template editor's merge tokens; the edit **Enforced where:** `src/components/field-management/use-field-order-state.ts` (`removeGroup`) and its tests. **Related:** [components/field-management.md](components/field-management.md) +### GOTCHA-045: `` never renders even though the search returned nothing + +**Symptom:** A cmdk combobox with `shouldFilter={false}` gets zero results back, but its `` message never appears, even when it is wrapped in your own `results.length === 0` condition. +**Root cause:** `CommandEmpty` renders only when the **whole** `Command` has zero registered items (`filtered.count === 0`), not just the group it sits in. A permanent item elsewhere, such as a "+ New …" action, keeps that count above zero. +**Fix:** Don't use `CommandEmpty` alongside a permanent item. Render a plain `
` gated on your own state. Gate it on the term that was actually searched, too, or it flashes during the debounce while `results` still describes the previous term. +**Enforced where:** `src/app/(web)/tools/addeditfamily/add-edit-family.tsx` (`FamilySearchBar`, `searchedTerm`) and its tests. +**Related:** [components/](components/) + --- ## Utils / labels diff --git a/src/app/(web)/tools/addeditfamily/add-edit-family.test.tsx b/src/app/(web)/tools/addeditfamily/add-edit-family.test.tsx index c0a1897..d1ea455 100644 --- a/src/app/(web)/tools/addeditfamily/add-edit-family.test.tsx +++ b/src/app/(web)/tools/addeditfamily/add-edit-family.test.tsx @@ -305,13 +305,7 @@ describe("AddEditFamily — search bar", () => { expect(await screen.findByDisplayValue("Jones")).toBeInTheDocument(); }); - it("finds no CommandEmpty text when the search returns nothing (see TODO: cmdk always counts the '+ New Family' item)", async () => { - // NOTE: the source renders No contacts found. - // whenever query.length >= 2 && results.length === 0, but cmdk's - // CommandEmpty only paints when its *global* registered-item count is 0. - // The "+ New Family" CommandItem is always registered once the query is - // >= 2 chars, so that count is never 0 and this message never renders. - // See .claude/TODO/2026-09-13-search-empty-state-never-renders.md + it("shows 'No contacts found.' alongside the new-family option when the search returns nothing", async () => { const user = userEvent.setup(); mockSearchContacts.mockResolvedValue([]); setup(); @@ -319,8 +313,25 @@ describe("AddEditFamily — search bar", () => { await user.click(screen.getByRole("combobox", { name: "" })); await user.type(screen.getByPlaceholderText("Type a name…"), "zz"); await waitFor(() => expect(mockSearchContacts).toHaveBeenCalledWith("zz")); + expect(await screen.findByText("No contacts found.")).toBeInTheDocument(); + expect(screen.getByText(/\+ New Family with last name/)).toBeInTheDocument(); + }); + + it("does not claim 'No contacts found.' before the debounced search for the current term has run", async () => { + const user = userEvent.setup(); + mockSearchContacts.mockResolvedValue([]); + setup(); + await screen.findByText(/Search to find an existing household/i); + await user.click(screen.getByRole("combobox", { name: "" })); + const input = screen.getByPlaceholderText("Type a name…"); + await user.type(input, "zz"); + expect(await screen.findByText("No contacts found.")).toBeInTheDocument(); + + // A new term invalidates the previous answer until it has been searched. + await user.type(input, "y"); expect(screen.queryByText("No contacts found.")).not.toBeInTheDocument(); - expect(await screen.findByText(/\+ New Family with last name/)).toBeInTheDocument(); + await waitFor(() => expect(mockSearchContacts).toHaveBeenCalledWith("zzy")); + expect(await screen.findByText("No contacts found.")).toBeInTheDocument(); }); it("clears results silently when the search action throws", async () => { @@ -331,8 +342,9 @@ describe("AddEditFamily — search bar", () => { await user.click(screen.getByRole("combobox", { name: "" })); await user.type(screen.getByPlaceholderText("Type a name…"), "zz"); await waitFor(() => expect(mockSearchContacts).toHaveBeenCalledWith("zz")); - // No crash, and no stale results rendered. + // No crash, no stale results rendered, and no false "not found" claim. expect(screen.queryByText("Existing households")).not.toBeInTheDocument(); + expect(screen.queryByText("No contacts found.")).not.toBeInTheDocument(); }); it("creates a new family from the search box", async () => { diff --git a/src/app/(web)/tools/addeditfamily/add-edit-family.tsx b/src/app/(web)/tools/addeditfamily/add-edit-family.tsx index 2164938..516f2f0 100644 --- a/src/app/(web)/tools/addeditfamily/add-edit-family.tsx +++ b/src/app/(web)/tools/addeditfamily/add-edit-family.tsx @@ -24,7 +24,6 @@ import { Command, CommandInput, CommandList, - CommandEmpty, CommandGroup, CommandItem, } from "@/components/ui/command"; @@ -392,18 +391,26 @@ function FamilySearchBar({ onLoadExisting, onCreateNew, disabled }: FamilySearch const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [isSearching, setIsSearching] = useState(false); + // The term the current `results` belong to. While the debounce is pending, + // `results` still describes an older term, so "No contacts found" must wait + // until the term on screen has actually been searched. + const [searchedTerm, setSearchedTerm] = useState(null); const debounceRef = useRef | null>(null); const doSearch = useCallback(async (term: string) => { - if (term.trim().length < 2) { + const trimmed = term.trim(); + if (trimmed.length < 2) { setResults([]); + setSearchedTerm(null); return; } setIsSearching(true); try { setResults(await searchContacts(term)); + setSearchedTerm(trimmed); } catch { setResults([]); + setSearchedTerm(null); } finally { setIsSearching(false); } @@ -454,9 +461,17 @@ function FamilySearchBar({ onLoadExisting, onCreateNew, disabled }: FamilySearch Searching…
)} - {!isSearching && query.trim().length >= 2 && results.length === 0 && ( - No contacts found. - )} + {/* Not : cmdk shows that only when the whole + Command has zero items, and the "+ New Family" item below + is always registered once the query is >= 2 characters. */} + {!isSearching && + query.trim().length >= 2 && + searchedTerm === query.trim() && + results.length === 0 && ( +
+ No contacts found. +
+ )} {!isSearching && query.trim().length < 2 && (
Type at least 2 characters