Skip to content

Replace Algolia search with the TagoIO search API#69

Merged
felipefdl merged 7 commits into
mainfrom
feat/218-replace-algolia-search
May 18, 2026
Merged

Replace Algolia search with the TagoIO search API#69
felipefdl merged 7 commits into
mainfrom
feat/218-replace-algolia-search

Conversation

@gustavo-aguiar
Copy link
Copy Markdown
Collaborator

@gustavo-aguiar gustavo-aguiar commented May 15, 2026

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 /search page
for shareable, deep-linkable result views.

  • Swizzled src/theme/SearchBar/ with a modal trigger, debounced
    fetch, grouped results, keyboard navigation, and a "See all
    results" link.
  • New /search page reads ?q= from the URL, syncs the query back as
    the user types, and renders up to 20 results with the same grouped
    layout.
  • Algolia config, --docsearch-* CSS overrides, and the
    .DocSearch-Button mobile rule were removed entirely.

Screenshots

Navbar trigger

Navbar with search trigger and ⌘K hint

Modal (light)

Idle Results
Idle modal Grouped results for mqtt

/search page

Light Dark
Search page light Search page dark

Dark mode modal

Modal results in dark mode

Mobile

Navbar Modal
Mobile navbar with icon-only trigger Fullscreen modal on mobile with See all results CTA

QA

# Area Check Result
1 Navbar Search trigger renders with Cmd/Ctrl + K shortcut hint Pass
2 Navbar Clicking the trigger opens the modal and focuses the input Pass
3 API Typing mqtt fires a single request to api.ai.tago.io/docs/search?q=mqtt&limit=10 after ~250ms debounce Pass
4 API Typing fast cancels prior in-flight requests via AbortController Pass
5 API Queries shorter than 1 char or longer than 200 chars are not sent Pass
6 Results Results are grouped under Documentation and API Reference headers with per-group counts Pass
7 Results Each row shows a category icon (document or code brackets), title, and breadcrumb path Pass
8 Results Breadcrumb uses canonical casing (TagoIO, TagoDeploy, TagoCore, TagoTiP, API, MQTT, HTTP, IoT, SDK) Pass
9 Navigation Arrow Up and Down move the active row across groups with wraparound Pass
10 Navigation Enter on a result routes via Docusaurus history without a full page reload Pass
11 Navigation Enter on the See all results CTA navigates to /search?q=... Pass
12 Shortcuts Cmd/Ctrl + K opens the modal from anywhere on the site Pass
13 Shortcuts / opens the modal when no input or editable element is focused Pass
14 Shortcuts The close button (X) and Escape both close the modal Pass
15 Focus Closing the modal returns focus to the navbar trigger Pass
16 Focus Body scroll is locked while the modal is open and restored on close Pass
17 States Empty query shows the idle prompt Pass
18 States In-flight request shows the spinner row Pass
19 States Unknown query renders the no-results message with the searched term Pass
20 States Network failure renders the error state with a working Try again button Pass
21 /search Loading /search?q=mqtt directly fetches the API and renders 20 results Pass
22 /search Typing in the page input updates the URL with ?q= via history.replace Pass
23 /search Clear button resets the query and removes the URL parameter Pass
24 A11y Input uses role="combobox" with aria-controls, aria-expanded, and aria-activedescendant Pass
25 A11y Result list uses role="listbox" and rows use role="option" Pass
26 A11y Modal uses role="dialog" with aria-modal="true" and an accessible label Pass
27 Mobile Navbar search button is visible below 996px Pass
28 Mobile Modal fills the viewport below 768px Pass
29 Mobile Close X button uses a larger tap target on mobile Pass
30 Theming Modal, page, pills, and selected states render correctly in light and dark mode Pass
31 Cleanup grep -ri "algolia|docsearch" src/ docusaurus.config.ts returns no matches Pass
32 Build npm run lint reports 0 warnings and 0 errors Pass
33 Build npm run typecheck succeeds Pass
34 Build npm run build succeeds and emits /search/index.html Pass

Closes tago-io/issues#218

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.
Copy link
Copy Markdown
Member

@felipefdl felipefdl left a comment

Choose a reason for hiding this comment

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

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:30query 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:299role="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:22GROUP_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:54controller.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:1SAME_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:42handleClose 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:16tier 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 (&ldquo; / &rdquo;) and U+2026 ellipsis with
straight " and ... per the writing style guide (nit 2).
@gustavo-aguiar
Copy link
Copy Markdown
Collaborator Author

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:

  • 44aeedb security + a11y: dangerous URL schemes blocked, Tab focus trap, See-all moved inside the listbox, aria-live moved to the status region.
  • 9a6ba48 data flow: API response shape validated, URL sync split from the search trigger so each keystroke fires one request, /search input reconciles with the URL on back/forward, search failures log before the generic error UI, unknown API categories surface in their own group, stale responses dropped via a monotonic request id.
  • 517c417 keyboard + focus: Cmd/Ctrl+K is suppressed inside inputs, the running host counts as same-origin during local dev, the global keydown listener is stable across modal toggles, and the trigger refocus skips when the modal closes via navigation.
  • ecc4c2f straight quotes and ... in user-facing strings.

npm run check, npm run typecheck, and npm run build all pass locally. Build emits /search/index.html. Smoke test against a local dev server still pending; ready for re-review.

@Freddyminu Freddyminu requested a review from felipefdl May 18, 2026 19:58
Copy link
Copy Markdown
Member

@felipefdl felipefdl left a comment

Choose a reason for hiding this comment

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

All 8 bugs, 5 suggestions, and 4 nits from the prior review are addressed. Verified each fix against the current code:

  • /search keystroke fires one request (URL sync split from search trigger), back/forward reconciles via lastSyncedQueryRef.
  • API response validated through isSearchResult plus an Array.isArray check, drops malformed items.
  • Failures log via console.error before the generic error UI, in both the modal and the page.
  • toInternalPath rejects //, :, \ in the fallback; callers only hit window.location.href for http(s):// and warn otherwise.
  • Dialog traps Tab/Shift+Tab between input and close, See-all moved inside the listbox as role="option", aria-live moved to each status row.
  • Unknown categories surface as their own group with console.warn, no silent drops.
  • Monotonic lastRequestIdRef drops stale responses, Cmd/Ctrl+K guards editable targets, isSameOriginHost covers local dev, keydown listener stable via openRef, refocus skipped on result navigation.
  • Straight quotes and ... everywhere, tier field gone, dead isQueryInRange branch removed.

Architectural fallback note left as a non-blocking follow-up.

Approving. Merge once CI is green.

@felipefdl felipefdl merged commit d767325 into main May 18, 2026
1 check passed
@felipefdl felipefdl deleted the feat/218-replace-algolia-search branch May 18, 2026 20:10
@Freddyminu
Copy link
Copy Markdown
Collaborator

Closes issue #60

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants