` 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