Skip to content

feat(search): adapt debounce to query length and cancel superseded requests - #1823

Merged
oliverlaz merged 6 commits into
masterfrom
feat/search-dynamic-debounce-cancellation
Aug 14, 2026
Merged

feat(search): adapt debounce to query length and cancel superseded requests#1823
oliverlaz merged 6 commits into
masterfrom
feat/search-dynamic-debounce-cancellation

Conversation

@oliverlaz

Copy link
Copy Markdown
Member

CLA

  • I have signed the Stream CLA (required).
  • Code changes are tested

Description of the changes, What, Why and How?

Short search queries (1-2 characters) have low selectivity, so the backend search and QueryChannels endpoints are slow or time out on them. Two things made that worse than it needed to be:

  1. The debounce did not adapt to query length. debounce() was built once in the constructor with a fixed 300ms, so a 1-character query fired just as eagerly as a precise one.
  2. Nothing was ever cancelled. cancelScheduledQuery() only cancelled a scheduled call. An already-dispatched request ran to completion and wrote its result into state via a finally block. Worse, canExecuteQuery had a hard !this.isLoading guard, so a new search string typed while a request was in flight was silently dropped and the UI kept showing stale results.

Dynamic debounce

debounce() in src/utils.ts now accepts a function for its timeout, resolved on every call right before setTimeout. That is the whole switching mechanism: each keystroke clears the pending timer and reschedules using the delay computed from that call's arguments.

Option Default Applies to
shortQueryDebounceMs 500 queries of at most shortQueryMaxLength characters
longQueryDebounceMs 300 longer queries
shortQueryMaxLength 2 the boundary, inclusive

Typing a -> ab -> abc schedules at 500ms, 500ms, then 300ms, and only the last runs. An explicit debounceMs applies to both buckets, so anyone who already tuned it (say debounceMs: 800) keeps exactly their interval rather than being sped up to 500ms on short queries.

Request cancellation

Each source holds one AbortController, aborted at the top of every dispatch. The state write stays in finally but is skipped when the signal is aborted, so a late response cannot clobber the newer query. cancelScheduledQuery() and resetState() abort as well.

I did not use withCancellation from src/utils/concurrency.ts: it serializes same-tag callbacks, which would make the new query queue behind the aborted one. A per-instance AbortController is a few lines and has no such coupling.

The signal reaches axios through a new optional trailing ApiRequestOptions on search, queryUsers, queryChannels (via the non-serialized ChannelStateOptions), searchRoles, searchUserGroups and channel.queryMembers, plus an optional config argument on client.get/post, which were previously dropping any axios config. axios.isCancel errors are no longer logged or counted toward the token-refresh backoff.

Applies to

MessageSearchSource, ChannelSearchSource, UserSearchSource, ChannelMemberSearchSource and MentionsSearchSource all inherit this from the two base classes. $autocomplete / $q filter generation is unchanged. CommandSearchSource resolves locally, so it gets the dynamic debounce but no cancellation.

Short queries are not blocked, only debounced harder.

Behaviour changes worth calling out for downstream SDKs

  • Queries of at most 2 characters now wait 500ms instead of 300ms.
  • canExecuteQuery semantics changed: a new search query now preempts an in-flight request instead of being dropped. Pagination still waits. This is a public method that stream-chat-react and custom sources both call and override.
  • Custom query() implementations with their own try/catch will start seeing CanceledError.

All additions are optional parameters or widened optional fields, so this is a minor release, not a breaking one.

Verification

yarn lint, yarn types, yarn test (3064 passing, 77 files) and yarn build are clean. 22 new tests cover the threshold switch in both directions, the config resolution rules including legacy debounceMs, stale-response discard, isLoading release on cancel, and the signal reaching axios.

Changelog

  • Search sources debounce short, low-selectivity queries harder than long ones, configurable via shortQueryDebounceMs, longQueryDebounceMs and shortQueryMaxLength
  • A new search cancels the in-flight request instead of letting it finish, and no longer gets dropped while one is loading
  • search, queryUsers, queryChannels, searchRoles, searchUserGroups and channel.queryMembers accept an abort signal

…quests

Short queries (1-2 chars) have low selectivity, so the search and QueryChannels
endpoints are slow or time out on them. Every search source debounced at a fixed
300ms, and nothing was ever cancelled: an already-dispatched request ran to
completion and wrote its result into state, while a new search string typed
during an in-flight request was silently dropped by canExecuteQuery.

Debounce now depends on query length and is resolved per keystroke, so a query
moves between buckets as it grows or shrinks. Defaults: 500ms at or below
shortQueryMaxLength (2), 300ms above it. An explicit debounceMs still applies to
both buckets so existing configurations keep their interval.

A new query aborts the in-flight request before dispatching, and the aborted
query's response is discarded rather than clobbering newer state.
cancelScheduledQuery() and resetState() abort too. The signal reaches axios via
a new optional ApiRequestOptions on search, queryUsers, queryChannels,
searchRoles, searchUserGroups and channel.queryMembers, plus an optional config
argument on client.get/post.

canExecuteQuery is relaxed so a new search query preempts an in-flight one;
pagination still waits. Downstream SDKs that relied on the drop-while-loading
behaviour will see the change.

All additions are optional parameters or widened optional fields, so this is a
minor release.
Comment thread src/client.ts Outdated
Comment thread src/client.ts
Comment thread src/client.ts Outdated
… request config

Addresses review feedback.

The name implied the options were tied to the Chat API, when they are neither
API-specific nor even request-specific: a custom search source may resolve from
local data and ignore the signal entirely. AbortOptions describes what the bag
actually carries and matches the existing UploadRequestOptions precedent for not
prefixing transport concerns with "Api". Parameters renamed to abortOptions.

client.get/post took a full AxiosRequestConfig, which let a caller pass
config.params. _enrichAxiosOptions spreads config after the enriched params
object, so that replaced it wholesale and dropped api_key, user_id and
connection_id, breaking authentication. Both now take
Omit<AxiosRequestConfig, 'params'>.
…uestOptions

Follow-up to review feedback on the naming. One shared type could not serve both
roles honestly.

A search source's query() may resolve from local data and never issue a request,
so naming its options after a request was wrong. That bag is now
SearchQueryOptions, declared in the search domain, and its doc comment says
outright that ignoring the signal is valid.

The client and channel methods do perform HTTP requests, so RequestOptions fits
there. It cannot share the SearchQueryOptions name anyway: client.search()
already takes a SearchOptions, and two near-identical names on one signature
would be worse than the problem being fixed.

Parameters are queryOptions and requestOptions. Sources forward the former
straight into the latter where they do issue requests.
@oliverlaz

Copy link
Copy Markdown
Member Author

Naming and the config.params hazard are both addressed; summary of what changed since review, since the branch history shows an intermediate name that did not survive.

Thread Outcome
config.params duplication Fixed in 5b1ec13. It was worse than duplication: _enrichAxiosOptions spreads config after the enriched params, so a config.params replaced the whole object and dropped api_key, user_id and connection_id. Verified on the branch before the fix (api_key present? false). Applied your Omit<AxiosRequestConfig, 'params'> to get, and to post for the same reason.
config not on AxiosRequestConfig No change needed, it comes from the intersection on doAxiosRequest's own parameter (client.ts:1275-1277). The awkward shape predates this PR; happy to open a follow-up to flatten it.
ApiRequestOptions naming Split in d25f63d into SearchQueryOptions for query() and RequestOptions for the client/channel methods. See the thread for why one name could not cover both.

yarn lint, yarn types, yarn test (3064 passing) and yarn build are clean.

@szuperaz szuperaz left a comment

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.

Didn't review the code as I've seen there are comments already, but tested with RN and was wokring fine for me.

Rename the execute-result generic to R.

Drop the debounce field initializers. The constructor always calls
setDebounceOptions, and resolveDebounceOptions already applies the defaults, so
every instance was writing those three fields twice. The resolution itself has
to stay in setDebounceOptions rather than move into the constructor, since it is
part of the SearchSource and SearchSourceSync interfaces and must re-resolve at
runtime.

Give the initialState getter an explicit SearchSourceState<T> return type. It was
inferring items as undefined rather than T[] | undefined, so it only satisfied
ISearchSource by assignability and a missing field would have surfaced somewhere
downstream instead of here.

Inline the debounce default values into resolveDebounceOptions.
… search source

filters, sort and searchOptions could only be set by assigning to the instance
after construction, which is why the documented constructor form did not
compile. Each source now takes them in its options argument as well, following
the MentionsSearchSourceOptions pattern already in the repo: the source-specific
keys are peeled off and the rest forwarded to super.

- ChannelSearchSourceOptions, UserSearchSourceOptions and
  ChannelMemberSearchSourceOptions add filters, sort and searchOptions
- MessageSearchSourceOptions adds the six messageSearch*/channelQuery* parameters
- MentionsSearchSourceOptions adds userFilters, userSort, memberFilters,
  memberSort and searchOptions

Additive only. The properties stay public and mutable, since changing them at
runtime is what FilterBuilder.updateContext and reactive filter UIs rely on, and
widening a parameter type keeps existing callers and subclass constructors
compiling. Minor release.
…nt overrides

Addresses review feedback.

MentionsSearchSourceOptions gained the query parameters in the previous commit,
which also widened the public `config` property because it is declared with that
same type. The constructor only ever writes four keys and the query path reads
the instance fields, so `config.userFilters = x` typechecked and was silently
ignored. config is now a Pick of the four keys that are actually read.

Note this narrows `config` from 13 readable keys to 4, so reads of
`config.pageSize`, `config.debounceMs`, `config.transliterate` and the rest stop
compiling. All of them were already undefined at runtime, since the constructor
never populated them, so this turns a silently-wrong read into a compile error
rather than changing behaviour.

ChannelMemberSearchSource and MentionsSearchSource both overrode canExecuteQuery
with what reduced, after the canDispatchQuery extraction, to the base rule minus
its allowEmptySearchString term. Both overrides are gone and the flag is pinned
in the constructor instead, which is exactly equivalent: the overrides ignored
the flag, and spreading it after restOptions keeps a caller from turning it off.

Also aligns two JSDoc lines that still described the parameter as "the api
request options", the wording the RequestOptions rename removed.
@oliverlaz
oliverlaz merged commit 1ca8bf7 into master Aug 14, 2026
7 checks passed
@oliverlaz
oliverlaz deleted the feat/search-dynamic-debounce-cancellation branch August 14, 2026 11:12
github-actions Bot pushed a commit that referenced this pull request Aug 14, 2026
## [9.51.0](v9.50.3...v9.51.0) (2026-08-14)

### Features

* **search:** adapt debounce to query length and cancel superseded requests ([#1823](#1823)) ([1ca8bf7](1ca8bf7))

### Chores

* **deps:** bump axios, form-data and dev dependencies ([#1825](#1825)) ([940f2bc](940f2bc))

### Performance Improvements

* introduce event sync limit for offline db ([#1826](#1826)) ([46cb7df](46cb7df))
@stream-ci-bot

Copy link
Copy Markdown

🎉 This PR is included in version 9.51.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants