From f027c0452d043a551ac6f33711a3592f9ef20d9f Mon Sep 17 00:00:00 2001 From: jottakka Date: Thu, 16 Jul 2026 11:19:43 -0300 Subject: [PATCH 1/3] fix: stabilize Algolia search feedback Debounce queries and suppress stale result renders so the search dialog reports loading, empty, and error states without flicker or literal highlight tags. Co-authored-by: Cursor --- app/_components/algolia-search.tsx | 204 ++++++++++++++++++++++------- tests/algolia-search.test.ts | 65 +++++++++ 2 files changed, 220 insertions(+), 49 deletions(-) create mode 100644 tests/algolia-search.test.ts diff --git a/app/_components/algolia-search.tsx b/app/_components/algolia-search.tsx index f808c2e8d..81a6e273c 100644 --- a/app/_components/algolia-search.tsx +++ b/app/_components/algolia-search.tsx @@ -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, @@ -41,6 +41,64 @@ 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; + +export function getSearchErrorMessage(status: SearchStatus): string | null { + return status === "error" + ? "Search failed. Check your connection and try again." + : null; +} + +type ScheduleSearchOptions = { + query: string; + search: (nextQuery: string) => void; + setTypedQuery: (nextQuery: string) => void; + currentTimer: SearchTimer | null; + delayMs?: number; +}; + +export function scheduleSearch({ + query, + search, + setTypedQuery, + currentTimer, + delayMs = ALGOLIA_SEARCH_DEBOUNCE_MS, +}: ScheduleSearchOptions): SearchTimer | null { + setTypedQuery(query); + if (currentTimer) { + clearTimeout(currentTimer); + } + if (!query.trim()) { + search(query); + return null; + } + return setTimeout(() => search(query), delayMs); +} + +export function searchResultsAreCurrent( + query: string, + resultsQuery: string, + status: SearchStatus +): boolean { + const normalizedQuery = query.trim(); + return ( + normalizedQuery.length > 0 && + status === "idle" && + resultsQuery.trim() === normalizedQuery + ); +} + function safeHref(url: string | undefined): string { if (!url) { return "/"; @@ -138,28 +196,107 @@ function SearchHit({ hit }: { hit: DocSearchRecord }) { ); } -function EmptyQuery() { - const { indexUiState } = useInstantSearch(); - if (indexUiState.query) { - return null; +function SearchResults({ query }: { query: string }) { + const { results, status } = useInstantSearch({ catchError: true }); + if (!query.trim()) { + return ( +

+ Start typing to search the docs… +

+ ); + } + + const errorMessage = getSearchErrorMessage(status); + if (errorMessage) { + return ( +

+ {errorMessage} +

+ ); + } + + if ( + !(results && searchResultsAreCurrent(query, results.query ?? "", status)) + ) { + return ( +

+ Searching… +

+ ); + } + + if (results.nbHits === 0) { + return ( +

+ No results for{" "} + "{results.query}" +

+ ); } + return ( -

- Start typing to search the docs… -

+ ( + + )} + /> ); } -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 searchTimerRef = useRef | null>(null); + const queryHook = useCallback((query, search) => { + searchTimerRef.current = scheduleSearch({ + query, + search, + setTypedQuery, + currentTimer: searchTimerRef.current, + }); + }, []); + + useEffect( + () => () => { + if (searchTimerRef.current) { + clearTimeout(searchTimerRef.current); + } + }, + [] + ); + return ( -

- No results for{" "} - "{results.query}" -

+ <> + +
+ + +
+
+ +
+ ); } @@ -222,38 +359,7 @@ export function AlgoliaSearch() {
{searchClient && indexName ? ( - -
- - -
-
- - - ( - - )} - /> -
+
) : ( diff --git a/tests/algolia-search.test.ts b/tests/algolia-search.test.ts new file mode 100644 index 000000000..96c20eb38 --- /dev/null +++ b/tests/algolia-search.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + ALGOLIA_SEARCH_CONFIG, + ALGOLIA_SEARCH_DEBOUNCE_MS, + getSearchErrorMessage, + scheduleSearch, + 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 setTypedQuery = vi.fn(); + let timer = scheduleSearch({ + query: "g", + search, + setTypedQuery, + currentTimer: null, + }); + timer = scheduleSearch({ + query: "github", + search, + setTypedQuery, + currentTimer: timer, + }); + + expect(setTypedQuery).toHaveBeenLastCalledWith("github"); + expect(search).not.toHaveBeenCalled(); + vi.advanceTimersByTime(ALGOLIA_SEARCH_DEBOUNCE_MS); + expect(search).toHaveBeenCalledOnce(); + expect(search).toHaveBeenCalledWith("github"); + + scheduleSearch({ + query: "", + search, + 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(); + }); +}); From 572aef32468213fb66bf13b1f3d282ca53624921 Mon Sep 17 00:00:00 2001 From: jottakka Date: Thu, 16 Jul 2026 15:56:13 -0300 Subject: [PATCH 2/3] fix: hide stale Algolia errors for new queries Only render a search failure when it belongs to the query currently shown in the dialog. Co-authored-by: Cursor --- app/_components/algolia-search.tsx | 17 ++++++++++++++++- tests/algolia-search.test.ts | 6 ++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/app/_components/algolia-search.tsx b/app/_components/algolia-search.tsx index 81a6e273c..b32f94443 100644 --- a/app/_components/algolia-search.tsx +++ b/app/_components/algolia-search.tsx @@ -60,6 +60,18 @@ export function getSearchErrorMessage(status: SearchStatus): string | null { : null; } +export function searchErrorIsCurrent( + query: string, + resultsQuery: string, + status: SearchStatus +): boolean { + return ( + status === "error" && + query.trim().length > 0 && + resultsQuery.trim() === query.trim() + ); +} + type ScheduleSearchOptions = { query: string; search: (nextQuery: string) => void; @@ -206,7 +218,10 @@ function SearchResults({ query }: { query: string }) { ); } - const errorMessage = getSearchErrorMessage(status); + const errorMessage = + results && searchErrorIsCurrent(query, results.query ?? "", status) + ? getSearchErrorMessage(status) + : null; if (errorMessage) { return (

{ ); 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); + }); }); From 31543109e1033d6df1016b4a7555a1589c5b5148 Mon Sep 17 00:00:00 2001 From: jottakka Date: Thu, 16 Jul 2026 16:02:13 -0300 Subject: [PATCH 3/3] fix: retain current Algolia failures during debounce Track the dispatched query independently so current failures stay visible while stale failures disappear for new input. Co-authored-by: Cursor --- app/_components/algolia-search.tsx | 31 +++++++++++++++++++++--------- tests/algolia-search.test.ts | 5 +++++ 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/app/_components/algolia-search.tsx b/app/_components/algolia-search.tsx index b32f94443..0fccac74e 100644 --- a/app/_components/algolia-search.tsx +++ b/app/_components/algolia-search.tsx @@ -62,13 +62,13 @@ export function getSearchErrorMessage(status: SearchStatus): string | null { export function searchErrorIsCurrent( query: string, - resultsQuery: string, + dispatchedQuery: string, status: SearchStatus ): boolean { return ( status === "error" && query.trim().length > 0 && - resultsQuery.trim() === query.trim() + dispatchedQuery.trim() === query.trim() ); } @@ -76,6 +76,7 @@ type ScheduleSearchOptions = { query: string; search: (nextQuery: string) => void; setTypedQuery: (nextQuery: string) => void; + setDispatchedQuery?: (nextQuery: string) => void; currentTimer: SearchTimer | null; delayMs?: number; }; @@ -84,6 +85,7 @@ export function scheduleSearch({ query, search, setTypedQuery, + setDispatchedQuery, currentTimer, delayMs = ALGOLIA_SEARCH_DEBOUNCE_MS, }: ScheduleSearchOptions): SearchTimer | null { @@ -92,10 +94,14 @@ export function scheduleSearch({ clearTimeout(currentTimer); } if (!query.trim()) { + setDispatchedQuery?.(query); search(query); return null; } - return setTimeout(() => search(query), delayMs); + return setTimeout(() => { + setDispatchedQuery?.(query); + search(query); + }, delayMs); } export function searchResultsAreCurrent( @@ -208,7 +214,13 @@ function SearchHit({ hit }: { hit: DocSearchRecord }) { ); } -function SearchResults({ query }: { query: string }) { +function SearchResults({ + query, + dispatchedQuery, +}: { + query: string; + dispatchedQuery: string; +}) { const { results, status } = useInstantSearch({ catchError: true }); if (!query.trim()) { return ( @@ -218,10 +230,9 @@ function SearchResults({ query }: { query: string }) { ); } - const errorMessage = - results && searchErrorIsCurrent(query, results.query ?? "", status) - ? getSearchErrorMessage(status) - : null; + const errorMessage = searchErrorIsCurrent(query, dispatchedQuery, status) + ? getSearchErrorMessage(status) + : null; if (errorMessage) { return (

| null>(null); const queryHook = useCallback((query, search) => { searchTimerRef.current = scheduleSearch({ query, search, setTypedQuery, + setDispatchedQuery, currentTimer: searchTimerRef.current, }); }, []); @@ -309,7 +322,7 @@ function SearchContent() { />

- +
); diff --git a/tests/algolia-search.test.ts b/tests/algolia-search.test.ts index eb5c09ef7..55b113b63 100644 --- a/tests/algolia-search.test.ts +++ b/tests/algolia-search.test.ts @@ -21,16 +21,19 @@ describe("Algolia search configuration", () => { 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, }); @@ -40,10 +43,12 @@ describe("Algolia search configuration", () => { vi.advanceTimersByTime(ALGOLIA_SEARCH_DEBOUNCE_MS); expect(search).toHaveBeenCalledOnce(); expect(search).toHaveBeenCalledWith("github"); + expect(setDispatchedQuery).toHaveBeenCalledWith("github"); scheduleSearch({ query: "", search, + setDispatchedQuery, setTypedQuery, currentTimer: timer, });