[Search] Debounce autocomplete network query

Every keystroke in the right-side desktop search and the composer's
mention/emoji autocomplete was firing a fresh searchActorsTypeahead
request and re-rendering the full dropdown. Under that per-keystroke
render pressure, fast-typing-then-Enter could submit a truncated
`query` to the Search screen because the urgent state-update flush
wasn't completing between keystrokes.

Wrap the incoming `q` in `useDebouncedValue(q, 150)` and use the
debounced value for the TanStack queryKey and queryFn. Live `q` still
flows through `select` so the search-fallback row and the moderation
`isExactMatch` check stay in sync with what the user just typed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hailey
2026-04-26 03:21:17 +00:00
parent 46b8a5819b
commit defe8eac82
@@ -2,6 +2,7 @@ import {useCallback} from 'react'
import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {keepPreviousData, useQuery} from '@tanstack/react-query'
import {useDebouncedValue} from '#/lib/hooks/useDebouncedValue'
import {isJustAMute, moduiContainsHideableOffense} from '#/lib/moderation'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {STALE} from '#/state/queries'
@@ -15,6 +16,8 @@ import {
} from '#/components/Autocomplete/types'
import {useEmojiSearch} from './useEmojiSearch'
const QUERY_DEBOUNCE_MS = 150
const DEFAULT_MOD_OPTS = {
userDid: undefined,
prefs: DEFAULT_LOGGED_OUT_PREFERENCES.moderationPrefs,
@@ -34,6 +37,10 @@ export function useAutocomplete({
const agent = useAgent()
const moderationOpts = useModerationOpts()
const emojiSearch = useEmojiSearch()
// Debounce the value that drives the network request, so we don't refetch
// and re-render the dropdown on every keystroke. Live `q` is still used in
// `select` below for the search-fallback row so it tracks the input.
const debouncedQ = useDebouncedValue(q, QUERY_DEBOUNCE_MS)
const query = useQuery({
staleTime: STALE.MINUTES.ONE,
@@ -41,19 +48,19 @@ export function useAutocomplete({
'autocomplete',
{
type,
query: q,
query: debouncedQ,
},
],
async queryFn() {
if (type === 'profile') {
// TODO return recents
if (!q) return []
if (!debouncedQ) return []
// Going from "foo" to "foo." should not clear matches.
q = q.toLowerCase().trim().replace(/\.$/, '')
const normalized = debouncedQ.toLowerCase().trim().replace(/\.$/, '')
const res = await agent.searchActorsTypeahead({
q,
q: normalized,
limit: limit || 8,
})
@@ -64,7 +71,7 @@ export function useAutocomplete({
profile,
}))
} else if (type === 'emoji') {
return emojiSearch(q, limit || 8)
return emojiSearch(debouncedQ, limit || 8)
}
return []