Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 0 additions & 51 deletions .claude/TODO/2026-09-13-search-empty-state-never-renders.md

This file was deleted.

10 changes: 4 additions & 6 deletions .claude/TODO/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) |

---
Expand All @@ -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
Expand All @@ -80,7 +78,7 @@ _none open_

| Area | Count |
|---|---|
| components | 3 |
| components | 2 |
| routing | 2 |
| services | 0 |
| auth | 0 |
Expand Down
11 changes: 10 additions & 1 deletion .claude/references/GOTCHAS.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Gotchas
type: gotchas
last_updated: 2026-04-17
last_updated: 2026-10-10
---

# Gotchas
Expand Down Expand Up @@ -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: `<CommandEmpty>` never renders even though the search returned nothing

### Utils / labels
- GOTCHA-040: IMb barcode silently becomes POSTNET (or disappears) with no log
Expand Down Expand Up @@ -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: `<CommandEmpty>` never renders even though the search returned nothing

**Symptom:** A cmdk combobox with `shouldFilter={false}` gets zero results back, but its `<CommandEmpty>` 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 `<div>` 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
Expand Down
30 changes: 21 additions & 9 deletions src/app/(web)/tools/addeditfamily/add-edit-family.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,22 +305,33 @@ 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 <CommandEmpty>No contacts found.</CommandEmpty>
// 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();
await screen.findByText(/Search to find an existing household/i);
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 () => {
Expand All @@ -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 () => {
Expand Down
25 changes: 20 additions & 5 deletions src/app/(web)/tools/addeditfamily/add-edit-family.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
} from "@/components/ui/command";
Expand Down Expand Up @@ -392,18 +391,26 @@ function FamilySearchBar({ onLoadExisting, onCreateNew, disabled }: FamilySearch
const [query, setQuery] = useState("");
const [results, setResults] = useState<ContactSearchResult[]>([]);
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<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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);
}
Expand Down Expand Up @@ -454,9 +461,17 @@ function FamilySearchBar({ onLoadExisting, onCreateNew, disabled }: FamilySearch
Searching…
</div>
)}
{!isSearching && query.trim().length >= 2 && results.length === 0 && (
<CommandEmpty>No contacts found.</CommandEmpty>
)}
{/* Not <CommandEmpty>: 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 && (
<div className="py-4 text-center text-sm text-muted-foreground">
No contacts found.
</div>
)}
{!isSearching && query.trim().length < 2 && (
<div className="py-4 text-center text-sm text-muted-foreground">
Type at least 2 characters
Expand Down
Loading