From 3daa59870581a15eeaa78549423a9174ee7b960f Mon Sep 17 00:00:00 2001 From: Chris Kehayias Date: Sat, 10 Oct 2026 11:47:09 -0400 Subject: [PATCH] fix(addeditfamily): show "No contacts found" when a search comes up empty cmdk's CommandEmpty renders only when the whole Command has zero items, and the "+ New Family" item is always registered once the query is >= 2 characters, so the message never appeared. Replace it with a plain div gated on our own state, and only once the term on screen has actually been searched (tracked as searchedTerm), so it does not flash during the 300 ms debounce. Adds GOTCHA-045. Closes the 2026-09-13 search-empty-state-never-renders TODO. Co-Authored-By: Claude Opus 5.5 (1M context) --- ...-09-13-search-empty-state-never-renders.md | 51 ------------------- .claude/TODO/INDEX.md | 10 ++-- .claude/references/GOTCHAS.md | 11 +++- .../addeditfamily/add-edit-family.test.tsx | 30 +++++++---- .../tools/addeditfamily/add-edit-family.tsx | 25 +++++++-- 5 files changed, 55 insertions(+), 72 deletions(-) delete mode 100644 .claude/TODO/2026-09-13-search-empty-state-never-renders.md 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