Replace Algolia search with the TagoIO search API#69
Conversation
Swizzles SearchBar to drive a custom modal against https://api.ai.tago.io/docs/search and adds a /search page for shareable results. Removes Algolia config, --docsearch-* CSS overrides, and the DocSearch-Button mobile rule.
felipefdl
left a comment
There was a problem hiding this comment.
Summary
Replaces Algolia DocSearch with a custom swizzled SearchBar, a new SearchModal, and a dedicated /search page backed by https://api.ai.tago.io/docs/search. Structurally sound (abort handling, ARIA roles, dark mode, keyboard shortcuts) but trades a hosted, low-maintenance dependency for ~1900 lines of code the team now owns, with a single hardcoded API endpoint and no fallback when that endpoint is slow or down.
Seven real bugs to fix before merge, plus a handful of suggestions and nits. Architectural concern noted at the end.
Issue counts
- bugs: 7
- suggestions: 5
- nits: 4
Bugs
1. /search runs the API twice per keystroke
src/pages/search.tsx:83 — the debounced effect lists location.search in its dep array, and inside the timeout it calls history.replace(...) which mutates location.search. That re-runs the effect, schedules a second timeout, and 250ms later fires runSearch(query) again with the same query. Second call aborts the first if still in-flight; if not, you commit state twice and double the API load.
Fix: split URL sync and search trigger into separate effects.
// Effect 1: search when query changes
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => void runSearch(query), DEBOUNCE_MS);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
}, [query, runSearch]);
// Effect 2: URL sync, no debounce
useEffect(() => {
const params = new URLSearchParams(location.search);
const current = params.get("q") ?? "";
const trimmed = query.trim();
if (trimmed === current) return;
if (trimmed) params.set("q", trimmed); else params.delete("q");
const next = params.toString();
history.replace({ pathname: location.pathname, search: next ? `?${next}` : "" });
}, [query, history, location.pathname, location.search]);2. /search input desyncs from URL on back/forward navigation
src/pages/search.tsx:30 — query is seeded from initialQuery only at mount. Hit back/forward and location.search changes, but the input keeps the old text. A single keystroke clobbers the URL state with stale-plus-new-char.
Fix: reconcile in the URL-sync effect when the URL q differs from the last value the component pushed. Track the last pushed value with a ref so user-initiated changes do not bounce back.
3. API response cast, not validated
src/theme/SearchBar/searchClient.ts:37 — (await response.json()) as SearchResponse is a TypeScript lie at runtime. If the backend returns {}, null, {error: "..."}, or {results: null}, callers crash on response.results.length inside the same try. The catch swallows that into the generic "temporarily unavailable" with no log, so contract drift is invisible.
Fix:
const raw = await response.json();
if (!raw || !Array.isArray(raw.results)) {
throw new SearchContractError("Malformed response");
}
return raw as SearchResponse;Ideally filter items missing title/permalink/category and log drops.
4. Errors swallowed, no log before generic UI message
src/theme/SearchBar/SearchModal.tsx:59 and src/pages/search.tsx:55 — every non-abort failure becomes "The search service is temporarily unavailable" with no console output. Network drop, 4xx, 5xx, and runtime TypeError all collapse into the same message. Add console.error("search: request failed", error) before setStatus("error") so user-reported bugs surface in dev tools.
Branching the message (offline vs server vs contract) is nice-to-have but logging is the minimum.
5. toInternalPath fallback enables navigation to arbitrary schemes
src/theme/SearchBar/toInternalPath.ts:11 — the catch returns permalink.startsWith("/") ? permalink : null. The caller (SearchModal.tsx:104, search.tsx:97) then does window.location.href = permalink for the null case. If the API ever returns javascript:..., data:..., mailto:..., or a URL with whitespace that fails new URL, the user's browser navigates there.
Fix: tighten the fallback to only return rooted same-origin paths, and in the caller, only fall back to window.location.href for https?: URLs. console.warn when falling back.
6. Dialog has no Tab focus trap
src/theme/SearchBar/SearchModal.tsx:299 — role="dialog" aria-modal="true" requires keeping Tab focus inside the dialog. Tab from the close button currently leaks focus into the underlying page while body scroll is locked, so the user lands on hidden interactive content.
Fix: trap Tab/Shift+Tab between the first (input) and last (close button) focusable elements, or pull in focus-trap-react.
7. aria-activedescendant points outside the listbox
src/theme/SearchBar/SearchModal.tsx:154 — input declares aria-controls={LISTBOX_ID} and aria-activedescendant can resolve to SEE_ALL_ID, but the See-all button sits outside <ul role="listbox"> (sibling under .resultsScroll). The combobox + listbox pattern requires the active descendant to be a descendant of the controlled listbox.
Fix: either (a) make See-all an <li role="option" id={SEE_ALL_ID}> inside the listbox styled as a CTA, or (b) remove it from the keyboard rotation and trigger it via a separate shortcut (Cmd+Enter).
8. Unknown API categories silently dropped
src/theme/SearchBar/groupResults.ts:22 — GROUP_ORDER.filter(...) only iterates "documentation" and "api". First time the backend adds a third category, every item in that category disappears, the count math in SearchModal (seeAllIndex, totalSelectable) goes off, and nobody notices.
Fix: collect unknown categories under an "other" bucket, log them via console.warn once per call.
Suggestions
Out-of-order responses can commit stale state
src/theme/SearchBar/SearchModal.tsx:54 — controller.signal.aborted checks the current request's own signal. If response A buffers before abort() fires on it, A's await resolves and writes results even though a newer B is in flight. Use a monotonic request id:
const lastIdRef = useRef(0);
const runSearch = useCallback(async (q: string) => {
const id = ++lastIdRef.current;
abortRef.current?.abort();
// ...
const response = await searchDocs(q, controller.signal, RESULT_LIMIT);
if (id !== lastIdRef.current) return;
// commit
}, []);Cmd/Ctrl+K steals focus from inputs and contenteditable
src/theme/SearchBar/index.tsx:25 — the / branch correctly guards against input/textarea/contenteditable. The Cmd+K branch does not, so users filling forms or editing embedded code samples lose keystrokes. Apply the same guard.
toInternalPath hardcoded hosts breaks local dev
src/theme/SearchBar/toInternalPath.ts:1 — SAME_ORIGIN_HOSTS is {docs.tago.io, docs.beta.tago.io}. Local dev on localhost:3000 and preview deployments hit window.location.href = ... for every result, full reloading. Add window.location.hostname as an allowed match in the browser.
Keydown listener re-binds on every open toggle
src/theme/SearchBar/index.tsx:38 — [open] dep causes detach/re-attach on every modal toggle. Use a ref:
const openRef = useRef(open);
useEffect(() => { openRef.current = open; }, [open]);
useEffect(() => { /* read openRef.current */ }, []);Focus restore races with route change on result click
src/theme/SearchBar/index.tsx:42 — handleClose always schedules requestAnimationFrame(() => triggerRef.current?.focus()). When close was triggered by navigating to a result, the rAF races with the route change. Pass a restoreFocus flag and skip the rAF when navigateToPath triggered the close.
Nits
aria-live="polite" on the backdrop
src/theme/SearchBar/SearchModal.tsx:298 — live region updates fire reliably when the live region itself is stable and content changes. Move aria-live="polite" aria-atomic="true" to a wrapper around the status text inside renderBody() so screen readers announce "Searching the docs", "Something went wrong", "No matches".
Curly quotes and U+2026 ellipsis in user-facing strings
SearchModal.tsx:169, 189, 276 and search.tsx:113, 146, 171, 189 — style guide forbids curly quotes and the literal … character. Use straight " and ....
Unused tier field
src/theme/SearchBar/searchClient.ts:16 — tier is never read. Either remove it from the type or document why the public shape keeps it.
Dead defensive branch in searchDocs
src/theme/SearchBar/searchClient.ts:28 — callers always pre-check with isQueryInRange before calling searchDocs. Returning { results: [], tier: "" } here masquerades as "no results" if it ever fires. Either throw RangeError or remove the branch.
Architectural note (not blocking)
The previous Algolia integration was ~10 lines of config with a hosted SLA. This PR replaces it with 11 files and ~1900 lines, plus a hard dependency on a single internal API and no fallback. If api.ai.tago.io is unavailable, search is dead for every visitor.
Worth considering: a Docusaurus-native search plugin (docusaurus-lunr-search, docusaurus-search-local, Typesense's theme) as a fallback layer, or a circuit-breaker that links to a Google site:docs.tago.io query when the API repeatedly fails. Up to you whether the product wins (grouping, breadcrumbs, dedicated page) justify the maintenance and reliability cost.
- block dangerous URL schemes in result navigation (bug 5) - trap Tab focus inside the search modal (bug 6) - move "see all" inside the listbox for aria-activedescendant (bug 7) - move aria-live from backdrop to the status region (nit 1)
- validate the api response shape; drop the unused tier field and the dead isQueryInRange guard (bug 3, nits 3 and 4) - split url sync from the search trigger so each keystroke fires one request (bug 1) - reconcile /search input with the url on back/forward (bug 2) - log search failures before showing the generic error ui (bug 4) - preserve unknown api categories under their own group (bug 8) - drop stale responses with a monotonic request id (suggestion 1)
- skip Cmd/Ctrl+K shortcut while typing in inputs (suggestion 2) - treat the running host as same-origin during local dev (suggestion 3) - read open state via a ref so the keydown listener is stable across modal toggles (suggestion 4) - skip trigger refocus when the modal closes via result navigation (suggestion 5)
Replace curly quotes (“ / ”) and U+2026 ellipsis with straight " and ... per the writing style guide (nit 2).
|
Applied 8 bugs, 5 suggestions, and 4 nits from the review. The architectural fallback note (Docusaurus-native search / circuit breaker) is intentionally skipped, since it is a strategic call rather than a blocker. Pushed in four commits, grouped by concern so the diff stays readable:
|
felipefdl
left a comment
There was a problem hiding this comment.
All 8 bugs, 5 suggestions, and 4 nits from the prior review are addressed. Verified each fix against the current code:
/searchkeystroke fires one request (URL sync split from search trigger), back/forward reconciles vialastSyncedQueryRef.- API response validated through
isSearchResultplus anArray.isArraycheck, drops malformed items. - Failures log via
console.errorbefore the generic error UI, in both the modal and the page. toInternalPathrejects//,:,\in the fallback; callers only hitwindow.location.hrefforhttp(s)://and warn otherwise.- Dialog traps Tab/Shift+Tab between input and close, See-all moved inside the listbox as
role="option",aria-livemoved to each status row. - Unknown categories surface as their own group with
console.warn, no silent drops. - Monotonic
lastRequestIdRefdrops stale responses, Cmd/Ctrl+K guards editable targets,isSameOriginHostcovers local dev, keydown listener stable viaopenRef, refocus skipped on result navigation. - Straight quotes and
...everywhere,tierfield gone, deadisQueryInRangebranch removed.
Architectural fallback note left as a non-blocking follow-up.
Approving. Merge once CI is green.
|
Closes issue #60 |
Summary
Removes the Algolia DocSearch integration and replaces it with a custom
SearchBar swizzle backed by the internal TagoIO search API at
https://api.ai.tago.io/docs/search. Adds a dedicated/searchpagefor shareable, deep-linkable result views.
src/theme/SearchBar/with a modal trigger, debouncedfetch, grouped results, keyboard navigation, and a "See all
results" link.
/searchpage reads?q=from the URL, syncs the query back asthe user types, and renders up to 20 results with the same grouped
layout.
--docsearch-*CSS overrides, and the.DocSearch-Buttonmobile rule were removed entirely.Screenshots
Navbar trigger
Modal (light)
/searchpageDark mode modal
Mobile
QA
Cmd/Ctrl + Kshortcut hintmqttfires a single request toapi.ai.tago.io/docs/search?q=mqtt&limit=10after ~250ms debounceDocumentationandAPI Referenceheaders with per-group counts/search?q=...Cmd/Ctrl + Kopens the modal from anywhere on the site/opens the modal when no input or editable element is focused/search/search?q=mqttdirectly fetches the API and renders 20 results/search?q=viahistory.replace/searchrole="combobox"witharia-controls,aria-expanded, andaria-activedescendantrole="listbox"and rows userole="option"role="dialog"witharia-modal="true"and an accessible labelgrep -ri "algolia|docsearch" src/ docusaurus.config.tsreturns no matchesnpm run lintreports 0 warnings and 0 errorsnpm run typechecksucceedsnpm run buildsucceeds and emits/search/index.htmlCloses tago-io/issues#218