Skip to content
Closed
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
232 changes: 183 additions & 49 deletions app/_components/algolia-search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { liteClient as algoliasearch } from "algoliasearch/lite";
import { Search } from "lucide-react";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Configure,
Highlight,
Expand Down Expand Up @@ -41,6 +41,82 @@ const indexName = process.env.NEXT_PUBLIC_ALGOLIA_INDEX_NAME;
const searchClient =
appId && searchKey ? algoliasearch(appId, searchKey) : null;

export const ALGOLIA_SEARCH_CONFIG = {
attributesToSnippet: ["content:20"],
distinct: true,
highlightPreTag: "__ais-highlight__",
highlightPostTag: "__/ais-highlight__",
hitsPerPage: 15,
snippetEllipsisText: "…",
};
export const ALGOLIA_SEARCH_DEBOUNCE_MS = 150;

type SearchStatus = "idle" | "loading" | "stalled" | "error";
type SearchTimer = ReturnType<typeof setTimeout>;

export function getSearchErrorMessage(status: SearchStatus): string | null {
return status === "error"
? "Search failed. Check your connection and try again."
: null;
}

export function searchErrorIsCurrent(
query: string,
dispatchedQuery: string,
status: SearchStatus
): boolean {
return (
status === "error" &&
query.trim().length > 0 &&
dispatchedQuery.trim() === query.trim()
);
}

type ScheduleSearchOptions = {
query: string;
search: (nextQuery: string) => void;
setTypedQuery: (nextQuery: string) => void;
setDispatchedQuery?: (nextQuery: string) => void;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Misleading optional on a consequence-bearing parameter

setDispatchedQuery is typed as optional (?), so external callers of the exported scheduleSearch can legally omit it. When omitted, dispatchedQuery stays at "", making searchErrorIsCurrent always return false — Algolia errors are silently swallowed and the UI shows an indefinite loading state.

Since this parameter is mandatory for correct error display, consider removing the ? or adding a JSDoc warning.

currentTimer: SearchTimer | null;
delayMs?: number;
};

export function scheduleSearch({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scheduleSearch duplicates useDebounce from @uidotdev/usehooks (already a dep)

@uidotdev/usehooks v2.4.1 is already in the dep tree (used in app/en/resources/integrations/components/use-toolkit-filters.ts). scheduleSearch + the useRef<setTimeout> block in SearchContent re-implements the same debounce logic, creating two divergent patterns to maintain.

Replacing with the existing hook would remove the manual timer management, eliminate the exported scheduleSearch, and keep both debounced flows in the codebase consistent.

query,
search,
setTypedQuery,
setDispatchedQuery,
currentTimer,
delayMs = ALGOLIA_SEARCH_DEBOUNCE_MS,
}: ScheduleSearchOptions): SearchTimer | null {
setTypedQuery(query);
if (currentTimer) {
clearTimeout(currentTimer);
}
if (!query.trim()) {
setDispatchedQuery?.(query);
search(query);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary Algolia request on every input clear

The empty-query fast path calls search(query) (i.e. search("")) immediately without debouncing. SearchResults then discards the response via its !query.trim() early return. This fires a real Algolia API round-trip on every clear, consuming operations quota for a response that is always thrown away.

return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whitespace skips clear search

Low Severity

scheduleSearch treats whitespace-only input as empty via trim, but still calls search with the raw spaces instead of clearing to an empty query. SearchResults meanwhile shows the empty prompt for that input, so InstantSearch can run a whitespace query the UI never reflects.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5cf7f8b. Configure here.

}
return setTimeout(() => {
setDispatchedQuery?.(query);
search(query);
}, delayMs);
}

export function searchResultsAreCurrent(
query: string,
resultsQuery: string,
status: SearchStatus
): boolean {
const normalizedQuery = query.trim();
return (
normalizedQuery.length > 0 &&
status === "idle" &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: status === "stalled" hides valid cached results

React InstantSearch sets status to "stalled" when a request exceeds stalledSearchDelay (default 200ms). At that point results still holds the previous matching response — results.query equals typedQuery and results.nbHits > 0 — but searchResultsAreCurrent returns false because it gates on status === "idle" strictly. The caller falls through to the "Searching…" placeholder, replacing valid results with a loading message for the entire stall duration.

Suggested fix:

return (
  normalizedQuery.length > 0 &&
  (status === "idle" || status === "stalled") &&
  resultsQuery.trim() === normalizedQuery
);

resultsQuery.trim() === normalizedQuery
);
}

function safeHref(url: string | undefined): string {
if (!url) {
return "/";
Expand Down Expand Up @@ -138,28 +214,117 @@ function SearchHit({ hit }: { hit: DocSearchRecord }) {
);
}

function EmptyQuery() {
const { indexUiState } = useInstantSearch();
if (indexUiState.query) {
return null;
function SearchResults({
query,
dispatchedQuery,
}: {
query: string;
dispatchedQuery: string;
}) {
const { results, status } = useInstantSearch({ catchError: true });
if (!query.trim()) {
return (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
Start typing to search the docs…
</p>
);
}

const errorMessage = searchErrorIsCurrent(query, dispatchedQuery, status)
? getSearchErrorMessage(status)
: null;
if (errorMessage) {
return (
<p
className="px-4 py-8 text-center text-sm text-muted-foreground"
role="alert"
>
{errorMessage}
</p>
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale error during debounced typing

Medium Severity

After a search fails, the SearchResults component can display a stale error message. The typedQuery updates immediately, but the debounced search() call (which would update the search status) is delayed. This causes the error message to persist for a new query, even as the user continues typing.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5cf7f8b. Configure here.

}

if (
!(results && searchResultsAreCurrent(query, results.query ?? "", status))
) {
return (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
Searching…
</p>
);
}

if (results.nbHits === 0) {
return (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
No results for{" "}
<strong className="text-foreground">"{results.query}"</strong>
</p>
);
}

return (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
Start typing to search the docs…
</p>
<Hits
classNames={{ item: "", list: "space-y-0.5", root: "" }}
hitComponent={({ hit }) => (
<SearchHit hit={hit as unknown as DocSearchRecord} />
)}
/>
);
}

function NoResults() {
const { results } = useInstantSearch();
if (!results?.query || results.nbHits > 0) {
return null;
}
type SearchQueryHook = (
query: string,
search: (nextQuery: string) => void
) => void;

function SearchContent() {
const [typedQuery, setTypedQuery] = useState("");
const [dispatchedQuery, setDispatchedQuery] = useState("");
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const queryHook = useCallback<SearchQueryHook>((query, search) => {
searchTimerRef.current = scheduleSearch({
query,
search,
setTypedQuery,
setDispatchedQuery,
currentTimer: searchTimerRef.current,
});
}, []);

useEffect(
() => () => {
if (searchTimerRef.current) {
clearTimeout(searchTimerRef.current);
}
},
[]
);

return (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
No results for{" "}
<strong className="text-foreground">"{results.query}"</strong>
</p>
<>
<Configure {...ALGOLIA_SEARCH_CONFIG} />
<div className="flex items-center border-b border-border px-4">
<Search className="size-4 shrink-0 text-muted-foreground" />
<SearchBox
autoFocus
classNames={{
form: "flex flex-1",
input:
"w-full bg-transparent px-3 py-4 text-sm text-foreground placeholder:text-muted-foreground outline-none",
loadingIndicator: "hidden",
reset: "hidden",
root: "flex-1",
submit: "hidden",
}}
placeholder="Search docs…"
queryHook={queryHook}
/>
</div>
<div className="max-h-[60vh] min-h-24 overflow-y-auto p-2">
<SearchResults dispatchedQuery={dispatchedQuery} query={typedQuery} />
</div>
</>
);
}

Expand Down Expand Up @@ -222,38 +387,7 @@ export function AlgoliaSearch() {
<div className="relative z-10 w-full max-w-2xl overflow-hidden rounded-xl border border-border bg-popover shadow-2xl">
{searchClient && indexName ? (
<InstantSearch indexName={indexName} searchClient={searchClient}>
<Configure
attributesToSnippet={["content:20"]}
distinct={true}
hitsPerPage={15}
snippetEllipsisText="…"
/>
<div className="flex items-center border-b border-border px-4">
<Search className="size-4 shrink-0 text-muted-foreground" />
<SearchBox
autoFocus
classNames={{
form: "flex flex-1",
input:
"w-full bg-transparent px-3 py-4 text-sm text-foreground placeholder:text-muted-foreground outline-none",
loadingIndicator: "hidden",
reset: "hidden",
root: "flex-1",
submit: "hidden",
}}
placeholder="Search docs…"
/>
</div>
<div className="max-h-[60vh] overflow-y-auto p-2">
<EmptyQuery />
<NoResults />
<Hits
classNames={{ item: "", list: "space-y-0.5", root: "" }}
hitComponent={({ hit }) => (
<SearchHit hit={hit as unknown as DocSearchRecord} />
)}
/>
</div>
<SearchContent />
</InstantSearch>
) : (
<SearchUnavailable />
Expand Down
76 changes: 76 additions & 0 deletions tests/algolia-search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import {
ALGOLIA_SEARCH_CONFIG,
ALGOLIA_SEARCH_DEBOUNCE_MS,
getSearchErrorMessage,
scheduleSearch,
searchErrorIsCurrent,
searchResultsAreCurrent,
} from "../app/_components/algolia-search";

describe("Algolia search configuration", () => {
afterEach(() => {
vi.useRealTimers();
});

test("uses the highlight markers expected by React InstantSearch", () => {
expect(ALGOLIA_SEARCH_CONFIG.highlightPreTag).toBe("__ais-highlight__");
expect(ALGOLIA_SEARCH_CONFIG.highlightPostTag).toBe("__/ais-highlight__");
});

test("debounces search requests while typing", () => {
vi.useFakeTimers();
const search = vi.fn();
const setDispatchedQuery = vi.fn();
const setTypedQuery = vi.fn();
let timer = scheduleSearch({
query: "g",
search,
setDispatchedQuery,
setTypedQuery,
currentTimer: null,
});
timer = scheduleSearch({
query: "github",
search,
setDispatchedQuery,
setTypedQuery,
currentTimer: timer,
});

expect(setTypedQuery).toHaveBeenLastCalledWith("github");
expect(search).not.toHaveBeenCalled();
vi.advanceTimersByTime(ALGOLIA_SEARCH_DEBOUNCE_MS);
expect(search).toHaveBeenCalledOnce();
expect(search).toHaveBeenCalledWith("github");
expect(setDispatchedQuery).toHaveBeenCalledWith("github");

scheduleSearch({
query: "",
search,
setDispatchedQuery,
setTypedQuery,
currentTimer: timer,
});
expect(search).toHaveBeenLastCalledWith("");
});

test("does not render stale results while a new query is pending", () => {
expect(searchResultsAreCurrent("slack", "github", "idle")).toBe(false);
expect(searchResultsAreCurrent("slack", "slack", "loading")).toBe(false);
expect(searchResultsAreCurrent("slack", "slack", "stalled")).toBe(false);
expect(searchResultsAreCurrent("slack", "slack", "idle")).toBe(true);
});

test("surfaces failed searches instead of leaving a loading state", () => {
expect(getSearchErrorMessage("error")).toBe(
"Search failed. Check your connection and try again."
);
expect(getSearchErrorMessage("loading")).toBeNull();
});

test("does not show an old error for a newly typed query", () => {
expect(searchErrorIsCurrent("slack", "github", "error")).toBe(false);
expect(searchErrorIsCurrent("slack", "slack", "error")).toBe(true);
});
});
Loading