From a0064a9734d986c13f56e0d8c394bac674a8f5be Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 13 Aug 2026 15:24:42 +0200 Subject: [PATCH 1/6] feat(search): adapt debounce to query length and cancel superseded requests 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. --- src/channel.ts | 4 + src/client.ts | 64 +++- .../middleware/textComposer/mentions.ts | 60 +++- src/search/BaseSearchSource.ts | 155 ++++++--- src/search/ChannelMemberSearchSource.ts | 12 +- src/search/ChannelSearchSource.ts | 11 +- src/search/MessageSearchSource.ts | 8 +- src/search/UserSearchSource.ts | 12 +- src/search/types.ts | 15 +- src/types.ts | 14 +- src/utils.ts | 9 +- .../textComposer/MentionsSearchSource.test.ts | 79 +++-- test/unit/predefined_filters.test.ts | 8 + test/unit/requestAbortSignal.test.ts | 132 ++++++++ .../search/ChannelMemberSearchSource.test.ts | 47 ++- test/unit/search/ChannelSearchSource.test.ts | 1 + test/unit/search/MessageSearchSource.test.ts | 23 ++ test/unit/search/SearchController.test.js | 5 +- test/unit/search/UserSearchSource.test.ts | 4 + test/unit/search/searchDebounce.test.ts | 316 ++++++++++++++++++ test/unit/user_groups.test.ts | 6 +- 21 files changed, 869 insertions(+), 116 deletions(-) create mode 100644 test/unit/requestAbortSignal.test.ts create mode 100644 test/unit/search/searchDebounce.test.ts diff --git a/src/channel.ts b/src/channel.ts index e748eb7a49..28c4b727d5 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -14,6 +14,7 @@ import type { StreamChat } from './client'; import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from './constants'; import type { AIState, + ApiRequestOptions, APIResponse, AscDesc, BanUserOptions, @@ -377,6 +378,7 @@ export class Channel { * @param {MemberSort} [sort] Sort options, for instance [{created_at: -1}]. * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{name: -1}, {created_at: 1}] * @param {{ limit?: number; offset?: number }} [options] Option object, {limit: 10, offset:10} + * @param {ApiRequestOptions} [apiOptions] Request-level options such as an abort signal. Not sent in the request. * * @return {Promise} Query Members response */ @@ -384,6 +386,7 @@ export class Channel { filterConditions: MemberFilters, sort: MemberSort = [], options: QueryMembersOptions = {}, + apiOptions: ApiRequestOptions = {}, ) { let id: string | undefined; const type = this.type; @@ -406,6 +409,7 @@ export class Channel { ...options, }, }, + apiOptions, ); } diff --git a/src/client.ts b/src/client.ts index 012f8436be..3f4736e820 100644 --- a/src/client.ts +++ b/src/client.ts @@ -45,6 +45,7 @@ import type { AddUserGroupMembersOptions, AddUserGroupMembersResponse, APIErrorResponse, + ApiRequestOptions, APIResponse, AppIdentifier, AppSettings, @@ -1312,6 +1313,9 @@ export class StreamChat { // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any /**TODO: generalize error types */) { e.client_request_id = requestConfig.headers?.['x-client-request-id']; + // An aborted request is a deliberate cancellation, not a failure - logging it + // and counting it towards the token-refresh backoff would be misleading. + if (axios.isCancel(e)) throw e; this._logApiError(type, url, e); this.consecutiveFailures += 1; if (e.response) { @@ -1333,16 +1337,20 @@ export class StreamChat { } }; - get(url: string, params?: AxiosRequestConfig['params']) { - return this.doAxiosRequest('get', url, null, { params }); + get( + url: string, + params?: AxiosRequestConfig['params'], + config?: AxiosRequestConfig, + ) { + return this.doAxiosRequest('get', url, null, { params, config }); } put(url: string, data?: unknown) { return this.doAxiosRequest('put', url, data); } - post(url: string, data?: unknown) { - return this.doAxiosRequest('post', url, data); + post(url: string, data?: unknown, config?: AxiosRequestConfig) { + return this.doAxiosRequest('post', url, data, { config }); } patch(url: string, data?: unknown) { @@ -1837,6 +1845,7 @@ export class StreamChat { * @param {UserSort} sort Sort options, for instance [{last_active: -1}]. * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{last_active: -1}, {created_at: 1}] * @param {UserOptions} options Option object, {presence: true} + * @param {ApiRequestOptions} [apiOptions] Request-level options such as an abort signal. Not sent in the request. * * @return {Promise<{ users: Array }>} User Query Response */ @@ -1844,6 +1853,7 @@ export class StreamChat { filterConditions: UserFilters, sort: UserSort = [], options: UserOptions = {}, + apiOptions: ApiRequestOptions = {}, ) { const defaultOptions = { presence: false, @@ -1867,6 +1877,7 @@ export class StreamChat { ...options, }, }, + apiOptions, ); this.state.updateUsers(data.users); @@ -1918,13 +1929,17 @@ export class StreamChat { * searchUserGroups - Search user groups by prefix for autocomplete * * @param {SearchUserGroupsOptions} options The search options - * + * @param apiOptions the api request options * @return {Promise} User Group Search Response */ - async searchUserGroups(options: SearchUserGroupsOptions) { + async searchUserGroups( + options: SearchUserGroupsOptions, + apiOptions: ApiRequestOptions = {}, + ) { return await this.get( this.baseURL + '/usergroups/search', options, + apiOptions, ); } @@ -2061,6 +2076,7 @@ export class StreamChat { * @param {ChannelSort} [sort] Sort options, for instance {created_at: -1}. * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{last_updated: -1}, {created_at: 1}] * @param {ChannelOptions} [options] Options object. Can include predefined_filter, filter_values, and sort_values for using predefined filters. + * @param apiOptions the api request options. * * @return {Promise} full search channels response */ @@ -2068,6 +2084,7 @@ export class StreamChat { filterConditions: ChannelFilters, sort: ChannelSort = [], options: ChannelOptions = {}, + apiOptions: ApiRequestOptions = {}, ): Promise { const defaultOptions: ChannelOptions = { state: true, @@ -2101,7 +2118,11 @@ export class StreamChat { ...restOptions, }; - return await this.post(this.baseURL + '/channels', payload); + return await this.post( + this.baseURL + '/channels', + payload, + apiOptions, + ); } /** @@ -2147,6 +2168,7 @@ export class StreamChat { * - stateOptions.skipInitialization - Skips the initialization of the state for the channels matching the ids in the list. * - stateOptions.skipHydration - Skips returning the channels as instances of the Channel class and rather returns the raw query response. * - stateOptions.withResponse - Returns the full query response with hydrated channels. This is a compatibility bridge for internal callers that need response-level metadata while the default return value remains `Channel[]`. + * - stateOptions.signal - Aborts the request. See AbortController. * * @return {Promise>} search channels response */ @@ -2172,6 +2194,8 @@ export class StreamChat { filterConditions, sort, options, + // stateOptions is never serialized, so it carries the abort signal + { signal: stateOptions.signal }, ); const channels = queryChannelsResponse.channels; @@ -2317,6 +2341,7 @@ export class StreamChat { * @param {ChannelFilters} filterConditions MongoDB style filter conditions * @param {MessageFilters | string} query search query or object MongoDB style filters * @param {SearchOptions} [options] Option object, {user_id: 'tommaso'} + * @param {ApiRequestOptions} [apiOptions] Request-level options such as an abort signal. Not sent in the request. * * @return {Promise} search messages response */ @@ -2324,6 +2349,7 @@ export class StreamChat { filterConditions: ChannelFilters, query: string | MessageFilters, options: SearchOptions = {}, + apiOptions: ApiRequestOptions = {}, ) { if (options.offset && options.next) { throw Error(`Cannot specify offset with next`); @@ -2346,7 +2372,11 @@ export class StreamChat { // Make sure we wait for the connect promise if there is a pending one await this.wsPromise; - return await this.get(this.baseURL + '/search', { payload }); + return await this.get( + this.baseURL + '/search', + { payload }, + apiOptions, + ); } /** @@ -3806,6 +3836,12 @@ export class StreamChat { ...axiosRequestConfigRest } = this.options.axiosRequestConfig || {}; + // Most specific wins, and it is spread last so that a config carrying an explicit + // `signal: undefined` cannot clobber a client-wide one or an armed + // nextRequestAbortController. + const resolvedSignal = + options.config?.signal ?? axiosRequestConfigRest.signal ?? signal; + return { params: { user_id: this.userID, @@ -3821,9 +3857,9 @@ export class StreamChat { ...options.headers, ...(axiosRequestConfigHeaders || {}), }, - ...(signal ? { signal } : {}), ...options.config, - ...(axiosRequestConfigRest || {}), + ...axiosRequestConfigRest, + ...(resolvedSignal ? { signal: resolvedSignal } : {}), }; } @@ -3993,8 +4029,12 @@ export class StreamChat { * * @returns {Promise} */ - searchRoles(options: SearchRolesOptions) { - return this.get(`${this.baseURL}/roles/search`, options); + searchRoles(options: SearchRolesOptions, apiOptions: ApiRequestOptions = {}) { + return this.get( + `${this.baseURL}/roles/search`, + options, + apiOptions, + ); } /** deleteRole - deletes a custom role diff --git a/src/messageComposer/middleware/textComposer/mentions.ts b/src/messageComposer/middleware/textComposer/mentions.ts index 096566e8c8..be06d4dbb7 100644 --- a/src/messageComposer/middleware/textComposer/mentions.ts +++ b/src/messageComposer/middleware/textComposer/mentions.ts @@ -23,6 +23,7 @@ import type { } from './types'; import type { StreamChat } from '../../../client'; import type { + ApiRequestOptions, MemberFilters, MemberSort, SearchUserGroupsOptions, @@ -427,7 +428,7 @@ export class MentionsSearchSource extends BaseSearchSource { canExecuteQuery = (newSearchString?: string) => { const hasNewSearchQuery = typeof newSearchString !== 'undefined'; - return this.isActive && !this.isLoading && (hasNewSearchQuery || this.hasNext); + return this.isActive && this.canDispatchQuery(hasNewSearchQuery); }; protected updatePaginationStateFromQuery() { @@ -472,10 +473,13 @@ export class MentionsSearchSource extends BaseSearchSource { : []), ].filter(({ name }) => this.matchesPrefixSearchQuery(name, searchQuery)); - getRoleMentionSuggestions = async (query: string): Promise => { + getRoleMentionSuggestions = async ( + query: string, + apiOptions: ApiRequestOptions = {}, + ): Promise => { if (!this.isMentionTypeAllowed('role')) return []; if (!query) return []; - const { roles } = await this.client.searchRoles({ query }); + const { roles } = await this.client.searchRoles({ query }, apiOptions); return [...(roles?.map((role) => role.name) ?? [])] .sort((left, right) => left.localeCompare(right)) .map((role) => this.toRoleMentionSuggestion(role, query)); @@ -558,23 +562,35 @@ export class MentionsSearchSource extends BaseSearchSource { }; }; - queryUsers = async (searchQuery: string, offset = 0) => { + queryUsers = async ( + searchQuery: string, + offset = 0, + apiOptions: ApiRequestOptions = {}, + ) => { const { filters, sort, options } = this.prepareQueryUsersParams(searchQuery, offset); - const { users } = await this.client.queryUsers(filters, sort, options); + const { users } = await this.client.queryUsers(filters, sort, options, apiOptions); return users; }; - queryMembers = async (searchQuery: string, offset = 0) => { + queryMembers = async ( + searchQuery: string, + offset = 0, + apiOptions: ApiRequestOptions = {}, + ) => { const { filters, sort, options } = this.prepareQueryMembersParams( searchQuery, offset, ); - const response = await this.channel.queryMembers(filters, sort, options); + const response = await this.channel.queryMembers(filters, sort, options, apiOptions); return response.members.map((member) => member.user) as UserResponse[]; }; - getUserSuggestionsPage = async (searchQuery: string, userOffset = 0) => { + getUserSuggestionsPage = async ( + searchQuery: string, + userOffset = 0, + apiOptions: ApiRequestOptions = {}, + ) => { if (!this.isMentionTypeAllowed('user')) { return { items: [], @@ -587,7 +603,7 @@ export class MentionsSearchSource extends BaseSearchSource { this.allMembersLoadedWithInitialChannelQuery || !searchQuery; if (this.config.mentionAllAppUsers) { - users = await this.queryUsers(searchQuery, userOffset); + users = await this.queryUsers(searchQuery, userOffset, apiOptions); } else if (shouldSearchLocally) { const localUsers = this.searchMembersLocally(searchQuery); const items = localUsers @@ -601,7 +617,7 @@ export class MentionsSearchSource extends BaseSearchSource { : undefined, }; } else { - users = await this.queryMembers(searchQuery, userOffset); + users = await this.queryMembers(searchQuery, userOffset, apiOptions); } const items = users.map((user) => this.toUserSuggestion(user, searchQuery)); @@ -623,7 +639,11 @@ export class MentionsSearchSource extends BaseSearchSource { } satisfies UserGroupSearchCursor); }; - getUserGroupSuggestionsPage = async (searchQuery: string, cursor?: string) => { + getUserGroupSuggestionsPage = async ( + searchQuery: string, + cursor?: string, + apiOptions: ApiRequestOptions = {}, + ) => { if (!this.isMentionTypeAllowed('user_group')) { return { items: [], @@ -647,7 +667,7 @@ export class MentionsSearchSource extends BaseSearchSource { ...(userGroupCursor?.id_gt ? { id_gt: userGroupCursor.id_gt } : {}), ...(userGroupCursor?.name_gt ? { name_gt: userGroupCursor.name_gt } : {}), }; - const { user_groups } = await this.client.searchUserGroups(options); + const { user_groups } = await this.client.searchUserGroups(options, apiOptions); return { items: user_groups.map((userGroup) => @@ -657,20 +677,28 @@ export class MentionsSearchSource extends BaseSearchSource { }; }; - async query(searchQuery: string) { + async query(searchQuery: string, apiOptions: ApiRequestOptions = {}) { const userOffset = this.offset ?? 0; const isFirstPage = userOffset === 0 && typeof this.userGroupCursor === 'undefined'; const previousUserPaginationState = this.latestUserPaginationState; const previousUserGroupCursor = this.userGroupCursor; const [userResultsState, userGroupResultsState, roleSuggestionsState] = await Promise.allSettled([ - this.getUserSuggestionsPage(searchQuery, userOffset), - this.getUserGroupSuggestionsPage(searchQuery, previousUserGroupCursor), + this.getUserSuggestionsPage(searchQuery, userOffset, apiOptions), + this.getUserGroupSuggestionsPage( + searchQuery, + previousUserGroupCursor, + apiOptions, + ), isFirstPage - ? this.getRoleMentionSuggestions(searchQuery) + ? this.getRoleMentionSuggestions(searchQuery, apiOptions) : Promise.resolve([] as RoleMentionSuggestion[]), ]); + // On abort the requests above reject and the fallback branches below would write + // empty results into the pagination cursors, corrupting them for the newer query. + if (apiOptions.signal?.aborted) return { items: [] }; + const userResults = userResultsState.status === 'fulfilled' ? userResultsState.value diff --git a/src/search/BaseSearchSource.ts b/src/search/BaseSearchSource.ts index 6b5f9a28d1..86c15505ba 100644 --- a/src/search/BaseSearchSource.ts +++ b/src/search/BaseSearchSource.ts @@ -8,11 +8,15 @@ import type { } from './types'; import type { APIError } from '../errors'; import { isAPIError, isErrorRetryable } from '../errors'; +import type { ApiRequestOptions } from '../types'; export type DebounceOptions = { - debounceMs: number; + /** Applies to both short and long queries unless overridden by the options below. */ + debounceMs?: number; + shortQueryDebounceMs?: number; + longQueryDebounceMs?: number; + shortQueryMaxLength?: number; }; -type DebouncedExecQueryFunction = DebouncedFunc<(searchString?: string) => Promise>; // eslint-disable-next-line @typescript-eslint/no-explicit-any interface ISearchSource { @@ -54,18 +58,49 @@ export interface SearchSourceSync extends ISearchSource { search(text?: string): void; } -const DEFAULT_SEARCH_SOURCE_OPTIONS: Required = { - debounceMs: 300, +const DEFAULT_SHORT_QUERY_DEBOUNCE_MS = 500; +const DEFAULT_LONG_QUERY_DEBOUNCE_MS = 300; +const DEFAULT_SHORT_QUERY_MAX_LENGTH = 2; + +// Debounce defaults are resolved by resolveDebounceOptions, not here. +const DEFAULT_SEARCH_SOURCE_OPTIONS: Required< + Omit +> = { pageSize: 10, allowEmptySearchString: false, resetOnNewSearchQuery: true, } as const; -abstract class BaseSearchSourceBase implements ISearchSource { +/** + * An explicit `debounceMs` applies to both buckets, so integrators who already tuned it + * keep exactly their configured interval. Only when it is absent do the 500/300 defaults + * kick in. + */ +const resolveDebounceOptions = ({ + debounceMs, + shortQueryDebounceMs, + longQueryDebounceMs, + shortQueryMaxLength, +}: DebounceOptions) => ({ + shortQueryDebounceMs: + shortQueryDebounceMs ?? debounceMs ?? DEFAULT_SHORT_QUERY_DEBOUNCE_MS, + longQueryDebounceMs: + longQueryDebounceMs ?? debounceMs ?? DEFAULT_LONG_QUERY_DEBOUNCE_MS, + shortQueryMaxLength: shortQueryMaxLength ?? DEFAULT_SHORT_QUERY_MAX_LENGTH, +}); + +abstract class BaseSearchSourceBase< + T, + TExecuteResult extends void | Promise, +> implements ISearchSource { state: StateStore>; pageSize: number; protected allowEmptySearchString: boolean; protected resetOnNewSearchQuery: boolean; + protected shortQueryDebounceMs: number = DEFAULT_SHORT_QUERY_DEBOUNCE_MS; + protected longQueryDebounceMs: number = DEFAULT_LONG_QUERY_DEBOUNCE_MS; + protected shortQueryMaxLength: number = DEFAULT_SHORT_QUERY_MAX_LENGTH; + protected searchDebounced!: DebouncedFunc<(searchString?: string) => TExecuteResult>; abstract readonly type: SearchSourceType; protected constructor(options?: SearchSourceOptions) { @@ -77,8 +112,44 @@ abstract class BaseSearchSourceBase implements ISearchSource { this.allowEmptySearchString = allowEmptySearchString; this.resetOnNewSearchQuery = resetOnNewSearchQuery; this.state = new StateStore>(this.initialState); + // Field initializers run before this body, so setDebounceOptions is already + // assigned; it only captures getDebounceMs lazily. + this.setDebounceOptions(options ?? {}); + } + + abstract executeQuery(newSearchString?: string): TExecuteResult; + + setDebounceOptions = (options: DebounceOptions = {}) => { + const resolved = resolveDebounceOptions(options); + this.shortQueryDebounceMs = resolved.shortQueryDebounceMs; + this.longQueryDebounceMs = resolved.longQueryDebounceMs; + this.shortQueryMaxLength = resolved.shortQueryMaxLength; + this.searchDebounced = debounce(this.executeQuery.bind(this), (searchString) => + this.getDebounceMs(searchString), + ); + }; + + /** + * Short queries match too much and are slow server-side, so they get a longer debounce. + * Resolved per call, which is what lets the interval switch as the user keeps typing. + */ + protected getDebounceMs = (searchString?: string) => { + const { length } = searchString ?? this.searchQuery; + return length <= this.shortQueryMaxLength + ? this.shortQueryDebounceMs + : this.longQueryDebounceMs; + }; + + /** + * A new search query preempts an in-flight one; pagination waits for it to finish + * and needs a next page to fetch. + */ + protected canDispatchQuery(hasNewSearchQuery: boolean) { + return hasNewSearchQuery || (!this.isLoading && this.hasNext); } + search = (searchQuery?: string) => this.searchDebounced(searchQuery); + get lastQueryError() { return this.state.getLatestValue().lastQueryError; } @@ -143,8 +214,7 @@ abstract class BaseSearchSourceBase implements ISearchSource { const searchString = newSearchString ?? this.searchQuery; return !!( this.isActive && - !this.isLoading && - (this.hasNext || hasNewSearchQuery) && + this.canDispatchQuery(hasNewSearchQuery) && (this.allowEmptySearchString || searchString) ); }; @@ -217,34 +287,48 @@ abstract class BaseSearchSourceBase implements ISearchSource { } export abstract class BaseSearchSource - extends BaseSearchSourceBase + extends BaseSearchSourceBase> implements SearchSource { - protected searchDebounced!: DebouncedExecQueryFunction; - - constructor(options?: SearchSourceOptions) { - const { debounceMs } = { ...DEFAULT_SEARCH_SOURCE_OPTIONS, ...options }; - super(options); - this.setDebounceOptions({ debounceMs }); - } + /** Aborts the in-flight request, if any, once a newer query is dispatched. */ + protected queryAbortController: AbortController | null = null; - protected abstract query(searchQuery: string): Promise>; + protected abstract query( + searchQuery: string, + options?: ApiRequestOptions, + ): Promise>; protected abstract filterQueryResults(items: T[]): T[] | Promise; - setDebounceOptions = ({ debounceMs }: DebounceOptions) => { - this.searchDebounced = debounce(this.executeQuery.bind(this), debounceMs); - }; + /** Aborts the in-flight request, if any. Returns true when one was aborted. */ + protected abortInFlightQuery() { + if (!this.queryAbortController) return false; + this.queryAbortController.abort(); + this.queryAbortController = null; + return true; + } + + resetState() { + // otherwise an in-flight response would repopulate the state we just cleared + this.abortInFlightQuery(); + super.resetState(); + } async executeQuery(newSearchString?: string) { + // Checked before anything is dispatched, so that the abort below always has a + // successor that will clear isLoading. if (!this.canExecuteQuery(newSearchString)) return; + // cancel the previous request before dispatching the new one + this.abortInFlightQuery(); + const { signal } = (this.queryAbortController = new AbortController()); + const { hasNewSearchQuery, searchString } = this.prepareStateForQuery(newSearchString); let stateUpdate: Partial> = {}; try { - const results = await this.query(searchString); + const results = await this.query(searchString, { signal }); if (!results) return; const { items } = results; @@ -256,37 +340,34 @@ export abstract class BaseSearchSource stateUpdate.hasNext = false; } } finally { - this.state.next(this.getStateAfterQuery(stateUpdate, hasNewSearchQuery)); + // A newer query owns the state now - publishing here would clobber it with a + // stale result (and with the abort error). + if (!signal.aborted) { + this.state.next(this.getStateAfterQuery(stateUpdate, hasNewSearchQuery)); + } } } - search = (searchQuery?: string) => this.searchDebounced(searchQuery); - cancelScheduledQuery() { this.searchDebounced.cancel(); + // Nothing will dispatch a successor query, so release the loading state the + // aborted query owned - its own finally block skips the state write. + if (this.abortInFlightQuery() && this.isLoading) { + this.state.partialNext({ isLoading: false }); + } } } +// Queries are resolved locally, so there is nothing to cancel here - only the +// dynamic debounce applies. export abstract class BaseSearchSourceSync - extends BaseSearchSourceBase + extends BaseSearchSourceBase implements SearchSourceSync { - protected searchDebounced!: DebouncedExecQueryFunction; - - constructor(options?: SearchSourceOptions) { - const { debounceMs } = { ...DEFAULT_SEARCH_SOURCE_OPTIONS, ...options }; - super(options); - this.setDebounceOptions({ debounceMs }); - } - protected abstract query(searchQuery: string): QueryReturnValue; protected abstract filterQueryResults(items: T[]): T[]; - setDebounceOptions = ({ debounceMs }: DebounceOptions) => { - this.searchDebounced = debounce(this.executeQuery.bind(this), debounceMs); - }; - executeQuery(newSearchString?: string) { if (!this.canExecuteQuery(newSearchString)) return; @@ -311,8 +392,6 @@ export abstract class BaseSearchSourceSync } } - search = (searchQuery?: string) => this.searchDebounced(searchQuery); - cancelScheduledQuery() { this.searchDebounced.cancel(); } diff --git a/src/search/ChannelMemberSearchSource.ts b/src/search/ChannelMemberSearchSource.ts index f851d8c9c4..a10688e1ce 100644 --- a/src/search/ChannelMemberSearchSource.ts +++ b/src/search/ChannelMemberSearchSource.ts @@ -2,6 +2,7 @@ import { BaseSearchSource } from './BaseSearchSource'; import { FilterBuilder, type FilterBuilderOptions } from '../pagination'; import type { Channel } from '../channel'; import type { + ApiRequestOptions, ChannelMemberResponse, MemberFilters, MemberSort, @@ -63,10 +64,10 @@ export class ChannelMemberSearchSource< canExecuteQuery = (newSearchString?: string) => { const hasNewSearchQuery = typeof newSearchString !== 'undefined'; - return this.isActive && !this.isLoading && (this.hasNext || hasNewSearchQuery); + return this.isActive && this.canDispatchQuery(hasNewSearchQuery); }; - protected async query(searchQuery: string) { + protected async query(searchQuery: string, apiOptions: ApiRequestOptions = {}) { const filters = this.filterBuilder.buildFilters({ baseFilters: this.filters, context: { @@ -75,7 +76,12 @@ export class ChannelMemberSearchSource< }); const sort = this.sort ?? []; const options = { ...this.searchOptions, limit: this.pageSize, offset: this.offset }; - const { members } = await this.channel.queryMembers(filters ?? {}, sort, options); + const { members } = await this.channel.queryMembers( + filters ?? {}, + sort, + options, + apiOptions, + ); return { items: members }; } diff --git a/src/search/ChannelSearchSource.ts b/src/search/ChannelSearchSource.ts index 8bf1729338..15086cdb82 100644 --- a/src/search/ChannelSearchSource.ts +++ b/src/search/ChannelSearchSource.ts @@ -3,7 +3,12 @@ import type { FilterBuilderOptions } from '../pagination'; import { FilterBuilder } from '../pagination'; import type { Channel } from '../channel'; import type { StreamChat } from '../client'; -import type { ChannelFilters, ChannelOptions, ChannelSort } from '../types'; +import type { + ApiRequestOptions, + ChannelFilters, + ChannelOptions, + ChannelSort, +} from '../types'; import type { SearchSourceOptions } from './types'; type CustomContext = Record; @@ -51,7 +56,7 @@ export class ChannelSearchSource< }); } - protected async query(searchQuery: string) { + protected async query(searchQuery: string, apiOptions: ApiRequestOptions = {}) { const filters = this.filterBuilder.buildFilters({ baseFilters: { ...(this.client.userID ? { members: { $in: [this.client.userID] } } : {}), @@ -63,7 +68,7 @@ export class ChannelSearchSource< }); const sort = this.sort ?? {}; const options = { ...this.searchOptions, limit: this.pageSize, offset: this.offset }; - const items = await this.client.queryChannels(filters, sort, options); + const items = await this.client.queryChannels(filters, sort, options, apiOptions); return { items }; } diff --git a/src/search/MessageSearchSource.ts b/src/search/MessageSearchSource.ts index 63c9c33774..8f86041c03 100644 --- a/src/search/MessageSearchSource.ts +++ b/src/search/MessageSearchSource.ts @@ -1,5 +1,6 @@ import { BaseSearchSource } from './BaseSearchSource'; import type { + ApiRequestOptions, ChannelFilters, ChannelOptions, ChannelSort, @@ -129,7 +130,7 @@ export class MessageSearchSource< }); } - protected async query(searchQuery: string) { + protected async query(searchQuery: string, apiOptions: ApiRequestOptions = {}) { if (!this.client.userID || this.next === null) return { items: [] }; const channelFilters = this.messageSearchChannelFilterBuilder.buildFilters({ @@ -170,9 +171,13 @@ export class MessageSearchSource< channelFilters, messageFilters, options, + apiOptions, ); const items = results.map(({ message }) => message); + // a newer query already replaced this one - skip the cid scan and the hydration request + if (apiOptions.signal?.aborted) return { items, next }; + const cids = Array.from( items.reduce((acc, message) => { if (message.cid && !this.client.activeChannels[message.cid]) acc.add(message.cid); @@ -194,6 +199,7 @@ export class MessageSearchSource< ...this.channelQuerySort, }, this.channelQueryOptions, + apiOptions, ); } diff --git a/src/search/UserSearchSource.ts b/src/search/UserSearchSource.ts index 335073c8ad..8fdf84fa2d 100644 --- a/src/search/UserSearchSource.ts +++ b/src/search/UserSearchSource.ts @@ -1,7 +1,13 @@ import { BaseSearchSource } from './BaseSearchSource'; import { FilterBuilder, type FilterBuilderOptions } from '../pagination'; import type { StreamChat } from '../client'; -import type { UserFilters, UserOptions, UserResponse, UserSort } from '../types'; +import type { + ApiRequestOptions, + UserFilters, + UserOptions, + UserResponse, + UserSort, +} from '../types'; import type { SearchSourceOptions } from './types'; type CustomContext = Record; @@ -55,7 +61,7 @@ export class UserSearchSource< }); } - protected async query(searchQuery: string) { + protected async query(searchQuery: string, apiOptions: ApiRequestOptions = {}) { const filters = this.filterBuilder.buildFilters({ baseFilters: this.filters, context: { searchQuery } as UserSearchSourceFilterBuilderContext, @@ -68,7 +74,7 @@ export class UserSearchSource< sort = { id: 1, ...this.sort }; } const options = { ...this.searchOptions, limit: this.pageSize, offset: this.offset }; - const { users } = await this.client.queryUsers(filters, sort, options); + const { users } = await this.client.queryUsers(filters, sort, options, apiOptions); return { items: users }; } diff --git a/src/search/types.ts b/src/search/types.ts index f1708df177..3d7ffff304 100644 --- a/src/search/types.ts +++ b/src/search/types.ts @@ -11,8 +11,21 @@ export type SearchSourceState = { }; export type SearchSourceOptions = { - /** The number of milliseconds to debounce the search query. The default interval is 300ms. */ + /** + * Legacy single debounce interval. When set, it applies to both short and long queries, + * unless overridden by `shortQueryDebounceMs` / `longQueryDebounceMs`. + */ debounceMs?: number; + /** + * Debounce interval for queries no longer than `shortQueryMaxLength`. Such queries have + * low selectivity and are expensive server-side, so they are debounced harder. + * Defaults to 500ms. + */ + shortQueryDebounceMs?: number; + /** Debounce interval for queries longer than `shortQueryMaxLength`. Defaults to 300ms. */ + longQueryDebounceMs?: number; + /** Query length (inclusive) that still counts as short. Defaults to 2. */ + shortQueryMaxLength?: number; pageSize?: number; /** When true, the source can execute queries with an empty search string. Defaults to false. */ allowEmptySearchString?: boolean; diff --git a/src/types.ts b/src/types.ts index b2125b50f5..3bc390731e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1189,7 +1189,13 @@ export type ChannelQueryOptions = { watchers?: PaginationOptions; }; -export type ChannelStateOptions = { +/** + * Composed with ApiRequestOptions because `queryChannels` uses this bag for both state + * handling and request-level concerns - neither of which is serialized into the request. + * That makes passing an ApiRequestOptions here a declared relationship rather than + * incidental structural compatibility. + */ +export type ChannelStateOptions = ApiRequestOptions & { offlineMode?: boolean; skipInitialization?: string[]; skipHydration?: boolean; @@ -1573,6 +1579,12 @@ export type SearchOptions = { sort?: SearchMessageSort; }; +/** Per-request options that are not part of the serialized request payload. */ +export type ApiRequestOptions = { + /** Aborts the request. See AbortController. */ + signal?: AbortSignal; +}; + export type StreamChatOptions = AxiosRequestConfig & { /** * Used to disable warnings that are triggered by using connectUser or connectAnonymousUser server-side. diff --git a/src/utils.ts b/src/utils.ts index 0932b7fe31..9c515a96ac 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -723,11 +723,13 @@ export interface DebouncedFunc any> { flush(): ReturnType | undefined; } -// works exactly the same as lodash.debounce +// works exactly the same as lodash.debounce, except that the timeout can also be +// a function of the call arguments, resolved on every call (e.g. to debounce short, +// low-selectivity search queries harder than long ones) // eslint-disable-next-line @typescript-eslint/no-explicit-any export const debounce = any>( fn: T, - timeout = 0, + timeout: number | ((...args: Parameters) => number) = 0, { leading = false, trailing = true }: { leading?: boolean; trailing?: boolean } = {}, ): DebouncedFunc => { let runningTimeout: null | NodeJS.Timeout = null; @@ -750,7 +752,8 @@ export const debounce = any>( runningTimeout = null; }; - runningTimeout = setTimeout(timeoutHandler, timeout); + const delay = typeof timeout === 'function' ? timeout(...args) : timeout; + runningTimeout = setTimeout(timeoutHandler, delay); return lastResult; }; diff --git a/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts b/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts index 35d11341e9..65d99101a5 100644 --- a/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts +++ b/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, vi, type MockInstance } from 'vitest'; import { getAllowedMentionTypesFromCapabilities, MentionsSearchSource, @@ -339,12 +339,15 @@ describe('MentionsSearchSource', () => { const result = await source.query('adm'); - expect(client.searchUserGroups).toHaveBeenCalledWith({ - limit: 10, - query: 'adm', - team_id: 'engineering', - } satisfies SearchUserGroupsOptions); - expect(client.searchRoles).toHaveBeenCalledWith({ query: 'adm' }); + expect(client.searchUserGroups).toHaveBeenCalledWith( + { + limit: 10, + query: 'adm', + team_id: 'engineering', + } satisfies SearchUserGroupsOptions, + {}, + ); + expect(client.searchRoles).toHaveBeenCalledWith({ query: 'adm' }, {}); expect(getSuggestion(result.items, 'role', 'admin')).toBeDefined(); expect(getSuggestion(result.items, 'user_group', 'admins-group')).toBeDefined(); expect(getSuggestion(result.items, 'channel', 'channel')).toBeUndefined(); @@ -358,7 +361,7 @@ describe('MentionsSearchSource', () => { const result = await source.query('mod'); - expect(client.searchRoles).toHaveBeenCalledWith({ query: 'mod' }); + expect(client.searchRoles).toHaveBeenCalledWith({ query: 'mod' }, {}); expect(getSuggestion(result.items, 'role', 'moderator')).toBeDefined(); expect(getSuggestion(result.items, 'role', 'channel_moderator')).toBeDefined(); }); @@ -371,8 +374,8 @@ describe('MentionsSearchSource', () => { const firstResult = await source.query('adm'); const secondResult = await source.query('mod'); - expect(client.searchRoles).toHaveBeenNthCalledWith(1, { query: 'adm' }); - expect(client.searchRoles).toHaveBeenNthCalledWith(2, { query: 'mod' }); + expect(client.searchRoles).toHaveBeenNthCalledWith(1, { query: 'adm' }, {}); + expect(client.searchRoles).toHaveBeenNthCalledWith(2, { query: 'mod' }, {}); expect(getSuggestion(firstResult.items, 'role', 'admin')).toBeDefined(); expect(getSuggestion(firstResult.items, 'role', 'moderator')).toBeUndefined(); expect(getSuggestion(secondResult.items, 'role', 'moderator')).toBeDefined(); @@ -430,7 +433,7 @@ describe('MentionsSearchSource', () => { const result = await source.query('adm'); - expect(client.searchRoles).toHaveBeenCalledWith({ query: 'adm' }); + expect(client.searchRoles).toHaveBeenCalledWith({ query: 'adm' }, {}); expect(client.searchUserGroups).not.toHaveBeenCalled(); expect(getSuggestion(result.items, 'role', 'admin')).toBeDefined(); expect(getSuggestion(result.items, 'user_group', 'admins-group')).toBeUndefined(); @@ -554,8 +557,11 @@ describe('MentionsSearchSource', () => { source.activate(); expect(source.canExecuteQuery('test')).toBe(true); + // a new search query preempts an in-flight one source.state.partialNext({ isLoading: true }); - expect(source.canExecuteQuery('test')).toBe(false); + expect(source.canExecuteQuery('test')).toBe(true); + // ... but pagination still waits for it + expect(source.canExecuteQuery()).toBe(false); source.state.partialNext({ isLoading: false }); source.deactivate(); expect(source.canExecuteQuery('test')).toBe(false); @@ -581,18 +587,52 @@ describe('MentionsSearchSource', () => { expect.any(Object), expect.any(Object), expect.objectContaining({ limit: 10, offset: 3 }), + { signal: expect.any(AbortSignal) }, + ); + expect(client.searchUserGroups).toHaveBeenCalledWith( + { + id_gt: 'group-0', + limit: 10, + name_gt: 'Admins', + query: 'adm', + team_id: 'engineering', + } satisfies SearchUserGroupsOptions, + { signal: expect.any(AbortSignal) }, ); - expect(client.searchUserGroups).toHaveBeenCalledWith({ - id_gt: 'group-0', - limit: 10, - name_gt: 'Admins', - query: 'adm', - team_id: 'engineering', - } satisfies SearchUserGroupsOptions); expect(source.state.getLatestValue().next).toBeUndefined(); expect(source.state.getLatestValue().offset).toBe(7); }); + it('preserves pagination cursors when an aborted query rejects', async () => { + const source = new MentionsSearchSource(channel, { mentionAllAppUsers: true }); + source.activate(); + source.config.textComposerText = '@adm'; + const cursor = JSON.stringify({ id_gt: 'group-0', name_gt: 'Admins' }); + const internals = source as unknown as { + userGroupCursor?: string; + latestUserPaginationState?: { itemCount: number; nextOffset?: number }; + }; + internals.userGroupCursor = cursor; + internals.latestUserPaginationState = { itemCount: 4, nextOffset: 7 }; + + // an abort makes every sub-request reject, which would otherwise drive the + // fallback branches and overwrite the cursors with empty results + (client.queryUsers as unknown as MockInstance).mockRejectedValue( + new Error('canceled'), + ); + (client.searchUserGroups as unknown as MockInstance).mockRejectedValue( + new Error('canceled'), + ); + const controller = new AbortController(); + controller.abort(); + + const result = await source.query('adm', { signal: controller.signal }); + + expect(result.items).toEqual([]); + expect(internals.userGroupCursor).toBe(cursor); + expect(internals.latestUserPaginationState).toEqual({ itemCount: 4, nextOffset: 7 }); + }); + it('should correctly get members and watchers without duplicates', () => { const source = new MentionsSearchSource(channel); channel.state.watchers = { @@ -672,6 +712,7 @@ describe('MentionsSearchSource', () => { expect.any(Object), expect.any(Object), expect.objectContaining({ presence: true }), + {}, ); }); diff --git a/test/unit/predefined_filters.test.ts b/test/unit/predefined_filters.test.ts index e01ebca026..1a170a5219 100644 --- a/test/unit/predefined_filters.test.ts +++ b/test/unit/predefined_filters.test.ts @@ -289,6 +289,7 @@ describe('Predefined Filters', () => { watch: true, presence: false, }), + { signal: undefined }, ); // Should NOT include filter_conditions when using predefined filter expect(postSpy).toHaveBeenCalledWith( @@ -296,6 +297,7 @@ describe('Predefined Filters', () => { expect.not.objectContaining({ filter_conditions: expect.anything(), }), + { signal: undefined }, ); }); @@ -324,6 +326,7 @@ describe('Predefined Filters', () => { sort_values: { sort_field: 'last_message_at' }, limit: 50, }), + { signal: undefined }, ); }); @@ -352,12 +355,14 @@ describe('Predefined Filters', () => { ], limit: 20, }), + { signal: undefined }, ); expect(postSpy).toHaveBeenCalledWith( `${client.baseURL}/channels`, expect.not.objectContaining({ filter_conditions: expect.anything(), }), + { signal: undefined }, ); }); @@ -382,6 +387,7 @@ describe('Predefined Filters', () => { sort: [{ field: 'last_message_at', direction: -1 }], limit: 20, }), + { signal: undefined }, ); // Should NOT include predefined_filter fields expect(postSpy).toHaveBeenCalledWith( @@ -389,6 +395,7 @@ describe('Predefined Filters', () => { expect.not.objectContaining({ predefined_filter: expect.anything(), }), + { signal: undefined }, ); }); @@ -411,6 +418,7 @@ describe('Predefined Filters', () => { expect.objectContaining({ watch: false, }), + { signal: undefined }, ); }); diff --git a/test/unit/requestAbortSignal.test.ts b/test/unit/requestAbortSignal.test.ts new file mode 100644 index 0000000000..5b6db6137b --- /dev/null +++ b/test/unit/requestAbortSignal.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import axios from 'axios'; + +import { getClientWithUser } from './test-utils/getClient'; +import type { StreamChat } from '../../src'; + +describe('per-request abort signal', () => { + let client: StreamChat; + let signal: AbortSignal; + + beforeEach(() => { + client = getClientWithUser({ id: 'user-1' }); + signal = new AbortController().signal; + }); + + describe('verb wrappers', () => { + beforeEach(() => { + // these reach _enrichAxiosOptions, which needs a token + vi.spyOn(client, '_getToken').mockReturnValue('token'); + }); + + it('forwards a config from get() to axios', async () => { + const axiosGet = vi + .spyOn(client.axiosInstance, 'get') + .mockResolvedValue({ data: {}, status: 200 } as never); + + await client.get('https://example.com/x', { payload: {} }, { signal }); + + expect(axiosGet.mock.calls[0][1]).toMatchObject({ signal }); + }); + + it('forwards a config from post() to axios', async () => { + const axiosPost = vi + .spyOn(client.axiosInstance, 'post') + .mockResolvedValue({ data: {}, status: 200 } as never); + + await client.post('https://example.com/x', {}, { signal }); + + expect(axiosPost.mock.calls[0][2]).toMatchObject({ signal }); + }); + + it('takes precedence over a client-wide axiosRequestConfig signal', async () => { + const clientWideSignal = new AbortController().signal; + client.options.axiosRequestConfig = { signal: clientWideSignal }; + const axiosGet = vi + .spyOn(client.axiosInstance, 'get') + .mockResolvedValue({ data: {}, status: 200 } as never); + + await client.get('https://example.com/x', {}, { signal }); + + expect(axiosGet.mock.calls[0][1]).toMatchObject({ signal }); + }); + + it('falls back to the client-wide signal when no per-request one is given', async () => { + const clientWideSignal = new AbortController().signal; + client.options.axiosRequestConfig = { signal: clientWideSignal }; + const axiosGet = vi + .spyOn(client.axiosInstance, 'get') + .mockResolvedValue({ data: {}, status: 200 } as never); + + await client.get('https://example.com/x', {}); + + expect(axiosGet.mock.calls[0][1]).toMatchObject({ signal: clientWideSignal }); + }); + }); + + describe('cancelled requests', () => { + beforeEach(() => { + vi.spyOn(client, '_getToken').mockReturnValue('token'); + }); + + it('rethrows without logging or counting a failure', async () => { + const canceled = new axios.Cancel('canceled') as unknown as Error; + vi.spyOn(client.axiosInstance, 'get').mockRejectedValue(canceled); + const logApiError = vi.spyOn(client, '_logApiError'); + client.consecutiveFailures = 0; + + await expect(client.get('https://example.com/x', {})).rejects.toBe(canceled); + + // a deliberate cancellation is not an API failure + expect(logApiError).not.toHaveBeenCalled(); + expect(client.consecutiveFailures).toBe(0); + }); + + it('still logs and counts a genuine network failure', async () => { + vi.spyOn(client.axiosInstance, 'get').mockRejectedValue(new Error('ECONNRESET')); + const logApiError = vi.spyOn(client, '_logApiError'); + client.consecutiveFailures = 0; + + await expect(client.get('https://example.com/x', {})).rejects.toThrow('ECONNRESET'); + + expect(logApiError).toHaveBeenCalled(); + expect(client.consecutiveFailures).toBe(1); + }); + }); + + describe('query endpoints', () => { + it('passes the signal through search()', async () => { + const get = vi.spyOn(client, 'get').mockResolvedValue({ results: [] } as never); + + await client.search({}, 'hello', {}, { signal }); + + expect(get.mock.calls[0][2]).toEqual({ signal }); + }); + + it('passes the signal through queryUsers()', async () => { + const get = vi.spyOn(client, 'get').mockResolvedValue({ users: [] } as never); + + await client.queryUsers({}, [], {}, { signal }); + + expect(get.mock.calls[0][2]).toEqual({ signal }); + }); + + it('passes the signal through queryChannels()', async () => { + const post = vi.spyOn(client, 'post').mockResolvedValue({ channels: [] } as never); + + await client.queryChannels({}, [], {}, { signal }); + + expect(post.mock.calls[0][2]).toEqual({ signal }); + }); + + it('passes the signal through channel.queryMembers()', async () => { + const get = vi.spyOn(client, 'get').mockResolvedValue({ members: [] } as never); + const channel = client.channel('messaging', 'channel-1'); + + await channel.queryMembers({}, [], {}, { signal }); + + expect(get.mock.calls[0][2]).toEqual({ signal }); + }); + }); +}); diff --git a/test/unit/search/ChannelMemberSearchSource.test.ts b/test/unit/search/ChannelMemberSearchSource.test.ts index 8cc1e884dd..5dfd6bb92c 100644 --- a/test/unit/search/ChannelMemberSearchSource.test.ts +++ b/test/unit/search/ChannelMemberSearchSource.test.ts @@ -26,6 +26,11 @@ const getAutocompleteFilters = (searchQuery: string): Partial => $or: [{ name: { $autocomplete: searchQuery } }, { id: { $eq: searchQuery } }], }); +/** Requests dispatched through search() carry the source's abort signal. */ +const withSignal = { signal: expect.any(AbortSignal) }; +/** query() invoked directly in tests is not driven by executeQuery, so it has none. */ +const withoutSignal = {}; + describe('ChannelMemberSearchSource', () => { const mockMembers: ChannelMemberResponse[] = [ createChannelMember({ user_id: 'user-1', user: { id: 'user-1', name: 'Alice' } }), @@ -130,10 +135,15 @@ describe('ChannelMemberSearchSource', () => { expect(searchSource.canExecuteQuery()).toBe(false); }); - it('returns false while loading', () => { + it('lets a new search query preempt an in-flight one', () => { + searchSource.state.partialNext({ isLoading: true }); + + expect(searchSource.canExecuteQuery('')).toBe(true); + }); + + it('returns false for pagination while loading', () => { searchSource.state.partialNext({ isLoading: true }); - expect(searchSource.canExecuteQuery('')).toBe(false); expect(searchSource.canExecuteQuery()).toBe(false); }); @@ -180,6 +190,7 @@ describe('ChannelMemberSearchSource', () => { limit: searchSource.pageSize, offset: searchSource.offset, }, + withoutSignal, ); }); @@ -192,16 +203,18 @@ describe('ChannelMemberSearchSource', () => { }); describe('search', () => { - it('executes empty search queries after debounce', async () => { + it('executes empty search queries after the short-query debounce', async () => { searchSource.search(''); - await vi.advanceTimersByTimeAsync(300); + await vi.advanceTimersByTimeAsync(500); expect(searchSource.items).toEqual(mockMembers); expect(searchSource.searchQuery).toBe(''); - expect(channel.queryMembers).toHaveBeenCalledWith({}, [], { - limit: 10, - offset: 0, - }); + expect(channel.queryMembers).toHaveBeenCalledWith( + {}, + [], + { limit: 10, offset: 0 }, + withSignal, + ); }); it('executes typed search queries with autocomplete filters', async () => { @@ -213,6 +226,7 @@ describe('ChannelMemberSearchSource', () => { getAutocompleteFilters('john'), [], { limit: 10, offset: 0 }, + withSignal, ); }); @@ -228,6 +242,7 @@ describe('ChannelMemberSearchSource', () => { getAutocompleteFilters('john'), [], { limit: 10, offset: 0 }, + withSignal, ); }); @@ -243,6 +258,7 @@ describe('ChannelMemberSearchSource', () => { getAutocompleteFilters('second'), [], { limit: 10, offset: 0 }, + withSignal, ); }); @@ -262,18 +278,21 @@ describe('ChannelMemberSearchSource', () => { paginatedSource.activate(); paginatedSource.search(''); - await vi.advanceTimersByTimeAsync(300); + await vi.advanceTimersByTimeAsync(500); expect(paginatedSource.items).toEqual(firstPage); expect(paginatedSource.hasNext).toBe(true); paginatedSource.search(); - await vi.advanceTimersByTimeAsync(300); + await vi.advanceTimersByTimeAsync(500); - expect(queryMembersMock).toHaveBeenNthCalledWith(2, {}, [], { - limit: 2, - offset: 2, - }); + expect(queryMembersMock).toHaveBeenNthCalledWith( + 2, + {}, + [], + { limit: 2, offset: 2 }, + withSignal, + ); expect(paginatedSource.items).toEqual([...firstPage, ...secondPage]); expect(paginatedSource.hasNext).toBe(false); }); diff --git a/test/unit/search/ChannelSearchSource.test.ts b/test/unit/search/ChannelSearchSource.test.ts index 1a84c2fa92..48f33d1a96 100644 --- a/test/unit/search/ChannelSearchSource.test.ts +++ b/test/unit/search/ChannelSearchSource.test.ts @@ -154,6 +154,7 @@ describe('ChannelSearchSource', () => { }, { last_message_at: -1 }, { message_limit: 5, limit: searchSource.pageSize, offset: searchSource.offset }, + {}, ); }); diff --git a/test/unit/search/MessageSearchSource.test.ts b/test/unit/search/MessageSearchSource.test.ts index 0246d48646..bfb09384b2 100644 --- a/test/unit/search/MessageSearchSource.test.ts +++ b/test/unit/search/MessageSearchSource.test.ts @@ -234,6 +234,7 @@ describe('MessageSearchSource', () => { next: undefined, sort: { created_at: -1 }, }), + {}, ); expect(result.items).toEqual(messages); expect(result.next).toBe('next-token'); @@ -264,6 +265,7 @@ describe('MessageSearchSource', () => { next: 'next-token-old', sort: { created_at: 1 }, // note: merges created_at with default -1, order may vary }), + {}, ); }); @@ -310,6 +312,7 @@ describe('MessageSearchSource', () => { next: 'next-token-old', sort: { created_at: 1 }, // note: merges created_at with default -1, order may vary }), + {}, ); }); @@ -333,6 +336,7 @@ describe('MessageSearchSource', () => { next: 'next-token-old', sort: { created_at: -1 }, // note: merges created_at with default -1, order may vary }), + {}, ); }); @@ -353,9 +357,27 @@ describe('MessageSearchSource', () => { { cid: { $in: ['cid2'] }, type: 'abc' }, { last_message_at: -1 }, undefined, + {}, ); }); + it('skips the channel hydration request when the query was aborted', async () => { + const m1 = generateMsg({ cid: 'cid1' }); + client.activeChannels = {}; + searchMock.mockResolvedValueOnce({ + results: [{ message: m1 }], + next: undefined, + } as any); + const controller = new AbortController(); + controller.abort(); + + // @ts-expect-error protected access + const result = await searchSource.query('query', { signal: controller.signal }); + + expect(queryChannelsMock).not.toHaveBeenCalled(); + expect(result.items).toEqual([m1]); + }); + it('does not call queryChannels if all channels are loaded locally', async () => { const m1 = generateMsg({ cid: 'cid1' }); client.activeChannels = { cid1: {} as any }; @@ -393,6 +415,7 @@ describe('MessageSearchSource', () => { { cid: { $in: ['cid2'] }, type: 'efg' }, { last_message_at: -1 }, undefined, + {}, ); }); diff --git a/test/unit/search/SearchController.test.js b/test/unit/search/SearchController.test.js index 3a37ff93f2..b707fcb7a3 100644 --- a/test/unit/search/SearchController.test.js +++ b/test/unit/search/SearchController.test.js @@ -474,7 +474,10 @@ describe('BaseSearchSource and implementations', () => { searchSource.activate(); const querySpy = sinon.spy(searchSource, 'query'); await searchSource.executeQuery(''); - sinon.assert.calledOnceWithExactly(querySpy, ''); + sinon.assert.calledOnce(querySpy); + sinon.assert.calledWith(querySpy, '', { + signal: sinon.match.instanceOf(AbortSignal), + }); expect(searchSource.searchQuery).to.equal(''); expect(searchSource.items).to.be.eql(items); }); diff --git a/test/unit/search/UserSearchSource.test.ts b/test/unit/search/UserSearchSource.test.ts index 17d6721f05..16efd96d5d 100644 --- a/test/unit/search/UserSearchSource.test.ts +++ b/test/unit/search/UserSearchSource.test.ts @@ -174,6 +174,7 @@ describe('UserSearchSource', () => { }, { id: 1, created_at: -1 }, { presence: true, limit: searchSource.pageSize, offset: searchSource.offset }, + {}, ); }); @@ -187,6 +188,7 @@ describe('UserSearchSource', () => { expect.anything(), [{ created_at: -1 }, { id: 1 }], expect.anything(), + {}, ); }); @@ -201,6 +203,7 @@ describe('UserSearchSource', () => { expect.anything(), [{ id: -1 }, { created_at: -1 }], expect.anything(), + {}, ); }); @@ -214,6 +217,7 @@ describe('UserSearchSource', () => { expect.anything(), [{ id: 1 }], expect.anything(), + {}, ); }); diff --git a/test/unit/search/searchDebounce.test.ts b/test/unit/search/searchDebounce.test.ts new file mode 100644 index 0000000000..89a4c4d369 --- /dev/null +++ b/test/unit/search/searchDebounce.test.ts @@ -0,0 +1,316 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Channel } from '../../../src/channel'; +import { ChannelMemberSearchSource } from '../../../src/search/ChannelMemberSearchSource'; +import type { ChannelMemberResponse } from '../../../src/types'; + +const SHORT_QUERY_DEBOUNCE_MS = 500; +const LONG_QUERY_DEBOUNCE_MS = 300; + +const createChannelMember = (userId: string): ChannelMemberResponse => ({ + created_at: '2026-01-01T00:00:00.000000000Z', + updated_at: '2026-01-01T00:00:00.000000000Z', + user_id: userId, +}); + +const createChannel = () => ({ queryMembers: vi.fn() }) as unknown as Channel; + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +}; + +const membersResponse = (members: ChannelMemberResponse[]) => ({ + duration: '0.01s', + members, +}); + +describe('search source dynamic debounce', () => { + let channel: Channel; + let source: ChannelMemberSearchSource; + + beforeEach(() => { + vi.useFakeTimers(); + channel = createChannel(); + vi.spyOn(channel, 'queryMembers').mockResolvedValue( + membersResponse([createChannelMember('user-1')]), + ); + source = new ChannelMemberSearchSource(channel); + source.activate(); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it('waits the short-query interval for a query of at most 2 characters', async () => { + source.search('ab'); + + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + expect(channel.queryMembers).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(SHORT_QUERY_DEBOUNCE_MS - LONG_QUERY_DEBOUNCE_MS); + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + }); + + it('waits the long-query interval for a query of at least 3 characters', async () => { + source.search('abc'); + + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + }); + + it('switches to the long interval once the query grows past the threshold', async () => { + source.search('a'); + await vi.advanceTimersByTimeAsync(100); + source.search('ab'); + await vi.advanceTimersByTimeAsync(100); + source.search('abc'); + + // the last keystroke rescheduled at the long interval, not the short one + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + expect(source.searchQuery).toBe('abc'); + }); + + it('switches back to the short interval when the query shrinks', async () => { + source.search('abc'); + await vi.advanceTimersByTimeAsync(100); + source.search('ab'); + + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + expect(channel.queryMembers).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(SHORT_QUERY_DEBOUNCE_MS - LONG_QUERY_DEBOUNCE_MS); + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + }); + + it('debounces pagination using the length of the current search query', async () => { + // a full page keeps hasNext true, so pagination is allowed + vi.spyOn(channel, 'queryMembers').mockResolvedValue( + membersResponse( + Array.from({ length: 10 }, (_, index) => createChannelMember(`user-${index}`)), + ), + ); + + source.search('abc'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + + source.search(); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + expect(channel.queryMembers).toHaveBeenCalledTimes(2); + }); + + describe('configuration', () => { + it('honours custom debounce intervals and threshold', async () => { + const configured = new ChannelMemberSearchSource(channel, { + shortQueryDebounceMs: 800, + longQueryDebounceMs: 100, + shortQueryMaxLength: 4, + }); + configured.activate(); + + configured.search('abcd'); // still short under the custom threshold + await vi.advanceTimersByTimeAsync(700); + expect(channel.queryMembers).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(100); + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + + configured.search('abcde'); + await vi.advanceTimersByTimeAsync(100); + expect(channel.queryMembers).toHaveBeenCalledTimes(2); + }); + + it('applies a legacy debounceMs to both short and long queries', async () => { + const legacy = new ChannelMemberSearchSource(channel, { debounceMs: 800 }); + legacy.activate(); + + legacy.search('ab'); + await vi.advanceTimersByTimeAsync(700); + expect(channel.queryMembers).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(100); + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + + legacy.search('abcdef'); + await vi.advanceTimersByTimeAsync(700); + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(100); + expect(channel.queryMembers).toHaveBeenCalledTimes(2); + }); + + it('applies intervals updated through setDebounceOptions', async () => { + source.setDebounceOptions({ shortQueryDebounceMs: 900 }); + + source.search('ab'); + await vi.advanceTimersByTimeAsync(SHORT_QUERY_DEBOUNCE_MS); + expect(channel.queryMembers).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(900 - SHORT_QUERY_DEBOUNCE_MS); + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe('search source request cancellation', () => { + let channel: Channel; + let source: ChannelMemberSearchSource; + const stalePage = [createChannelMember('stale')]; + const freshPage = [createChannelMember('fresh')]; + + const signalOfCall = (index: number) => + (channel.queryMembers as unknown as ReturnType).mock.calls[index][3] + .signal as AbortSignal; + + beforeEach(() => { + vi.useFakeTimers(); + channel = createChannel(); + source = new ChannelMemberSearchSource(channel); + source.activate(); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it('aborts the in-flight request when a new search is dispatched', async () => { + const inFlight = deferred>(); + vi.spyOn(channel, 'queryMembers') + .mockReturnValueOnce(inFlight.promise as never) + .mockResolvedValueOnce(membersResponse(freshPage)); + + source.search('first'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + expect(signalOfCall(0).aborted).toBe(false); + + source.search('second'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + expect(signalOfCall(0).aborted).toBe(true); + expect(channel.queryMembers).toHaveBeenCalledTimes(2); + }); + + it('discards a late response from an aborted request', async () => { + const inFlight = deferred>(); + vi.spyOn(channel, 'queryMembers') + .mockReturnValueOnce(inFlight.promise as never) + .mockResolvedValueOnce(membersResponse(freshPage)); + + source.search('first'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + source.search('second'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + // the first request only comes back now, after it was superseded + inFlight.resolve(membersResponse(stalePage)); + await vi.advanceTimersByTimeAsync(0); + + expect(source.items).toEqual(freshPage); + expect(source.searchQuery).toBe('second'); + expect(source.isLoading).toBe(false); + }); + + it('does not surface the abort rejection as a query error', async () => { + // mimics axios rejecting once the signal is aborted + let rejectAborted!: (reason: Error) => void; + const aborting = new Promise((_, reject) => { + rejectAborted = reject; + }); + vi.spyOn(channel, 'queryMembers') + .mockReturnValueOnce(aborting as never) + .mockResolvedValueOnce(membersResponse(freshPage)); + + source.search('first'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + source.search('second'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + expect(signalOfCall(0).aborted).toBe(true); + rejectAborted(new Error('canceled')); + await vi.advanceTimersByTimeAsync(0); + + expect(source.lastQueryError).toBeUndefined(); + expect(source.items).toEqual(freshPage); + }); + + it('aborts the in-flight request on cancelScheduledQuery', async () => { + const inFlight = deferred>(); + vi.spyOn(channel, 'queryMembers').mockReturnValueOnce(inFlight.promise as never); + + source.search('first'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + source.cancelScheduledQuery(); + expect(signalOfCall(0).aborted).toBe(true); + + inFlight.resolve(membersResponse(stalePage)); + await vi.advanceTimersByTimeAsync(0); + + expect(source.items).toBeUndefined(); + // nothing dispatches a successor, so cancelling must release the loading state + expect(source.isLoading).toBe(false); + expect(source.canExecuteQuery()).toBe(true); + }); + + it('aborts the in-flight request on resetState', async () => { + const inFlight = deferred>(); + vi.spyOn(channel, 'queryMembers').mockReturnValueOnce(inFlight.promise as never); + + source.search('first'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + source.resetState(); + expect(signalOfCall(0).aborted).toBe(true); + + inFlight.resolve(membersResponse(stalePage)); + await vi.advanceTimersByTimeAsync(0); + + expect(source.items).toBeUndefined(); + expect(source.searchQuery).toBe(''); + expect(source.isLoading).toBe(false); + }); + + it('lets a new search query preempt an in-flight one', async () => { + const inFlight = deferred>(); + vi.spyOn(channel, 'queryMembers') + .mockReturnValueOnce(inFlight.promise as never) + .mockResolvedValueOnce(membersResponse(freshPage)); + + source.search('first'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + expect(source.isLoading).toBe(true); + + // previously this was dropped because the source was still loading + source.search('second'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + expect(channel.queryMembers).toHaveBeenCalledTimes(2); + expect(source.items).toEqual(freshPage); + }); + + it('does not start a pagination query while one is in flight', async () => { + const inFlight = deferred>(); + vi.spyOn(channel, 'queryMembers').mockReturnValueOnce(inFlight.promise as never); + + source.search('first'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + source.search(); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + + inFlight.resolve(membersResponse(freshPage)); + await vi.advanceTimersByTimeAsync(0); + }); +}); diff --git a/test/unit/user_groups.test.ts b/test/unit/user_groups.test.ts index ccba4a00e7..edd59dda35 100644 --- a/test/unit/user_groups.test.ts +++ b/test/unit/user_groups.test.ts @@ -120,7 +120,11 @@ describe('User Groups', () => { const result = await client.searchUserGroups(options); - expect(getSpy).toHaveBeenCalledWith(`${client.baseURL}/usergroups/search`, options); + expect(getSpy).toHaveBeenCalledWith( + `${client.baseURL}/usergroups/search`, + options, + {}, + ); expect(result.user_groups).toHaveLength(1); expect(result.user_groups[0].name).toBe('Backend Support'); }); From 5b1ec1349d409751e128076921240fa34b0dc2ea Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 13 Aug 2026 16:10:17 +0200 Subject: [PATCH 2/6] refactor(search): rename ApiRequestOptions to AbortOptions and narrow 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. --- src/channel.ts | 8 ++-- src/client.ts | 36 +++++++++-------- .../middleware/textComposer/mentions.ts | 39 +++++++++++-------- src/search/BaseSearchSource.ts | 4 +- src/search/ChannelMemberSearchSource.ts | 6 +-- src/search/ChannelSearchSource.ts | 11 ++---- src/search/MessageSearchSource.ts | 10 ++--- src/search/UserSearchSource.ts | 6 +-- src/types.ts | 20 ++++++---- 9 files changed, 73 insertions(+), 67 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 28c4b727d5..eef41f43dc 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -13,8 +13,8 @@ import { import type { StreamChat } from './client'; import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from './constants'; import type { + AbortOptions, AIState, - ApiRequestOptions, APIResponse, AscDesc, BanUserOptions, @@ -378,7 +378,7 @@ export class Channel { * @param {MemberSort} [sort] Sort options, for instance [{created_at: -1}]. * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{name: -1}, {created_at: 1}] * @param {{ limit?: number; offset?: number }} [options] Option object, {limit: 10, offset:10} - * @param {ApiRequestOptions} [apiOptions] Request-level options such as an abort signal. Not sent in the request. + * @param {AbortOptions} [abortOptions] Carries an abort signal. Not sent in the request. * * @return {Promise} Query Members response */ @@ -386,7 +386,7 @@ export class Channel { filterConditions: MemberFilters, sort: MemberSort = [], options: QueryMembersOptions = {}, - apiOptions: ApiRequestOptions = {}, + abortOptions: AbortOptions = {}, ) { let id: string | undefined; const type = this.type; @@ -409,7 +409,7 @@ export class Channel { ...options, }, }, - apiOptions, + abortOptions, ); } diff --git a/src/client.ts b/src/client.ts index 3f4736e820..54366bc6d8 100644 --- a/src/client.ts +++ b/src/client.ts @@ -41,11 +41,11 @@ import { } from './utils'; import type { + AbortOptions, ActiveLiveLocationsAPIResponse, AddUserGroupMembersOptions, AddUserGroupMembersResponse, APIErrorResponse, - ApiRequestOptions, APIResponse, AppIdentifier, AppSettings, @@ -1340,7 +1340,9 @@ export class StreamChat { get( url: string, params?: AxiosRequestConfig['params'], - config?: AxiosRequestConfig, + // `params` is omitted deliberately: _enrichAxiosOptions spreads `config` after the + // enriched params, so a `config.params` would drop api_key/user_id/connection_id. + config?: Omit, ) { return this.doAxiosRequest('get', url, null, { params, config }); } @@ -1349,7 +1351,7 @@ export class StreamChat { return this.doAxiosRequest('put', url, data); } - post(url: string, data?: unknown, config?: AxiosRequestConfig) { + post(url: string, data?: unknown, config?: Omit) { return this.doAxiosRequest('post', url, data, { config }); } @@ -1845,7 +1847,7 @@ export class StreamChat { * @param {UserSort} sort Sort options, for instance [{last_active: -1}]. * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{last_active: -1}, {created_at: 1}] * @param {UserOptions} options Option object, {presence: true} - * @param {ApiRequestOptions} [apiOptions] Request-level options such as an abort signal. Not sent in the request. + * @param {AbortOptions} [abortOptions] Carries an abort signal. Not sent in the request. * * @return {Promise<{ users: Array }>} User Query Response */ @@ -1853,7 +1855,7 @@ export class StreamChat { filterConditions: UserFilters, sort: UserSort = [], options: UserOptions = {}, - apiOptions: ApiRequestOptions = {}, + abortOptions: AbortOptions = {}, ) { const defaultOptions = { presence: false, @@ -1877,7 +1879,7 @@ export class StreamChat { ...options, }, }, - apiOptions, + abortOptions, ); this.state.updateUsers(data.users); @@ -1929,17 +1931,17 @@ export class StreamChat { * searchUserGroups - Search user groups by prefix for autocomplete * * @param {SearchUserGroupsOptions} options The search options - * @param apiOptions the api request options + * @param abortOptions the api request options * @return {Promise} User Group Search Response */ async searchUserGroups( options: SearchUserGroupsOptions, - apiOptions: ApiRequestOptions = {}, + abortOptions: AbortOptions = {}, ) { return await this.get( this.baseURL + '/usergroups/search', options, - apiOptions, + abortOptions, ); } @@ -2076,7 +2078,7 @@ export class StreamChat { * @param {ChannelSort} [sort] Sort options, for instance {created_at: -1}. * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{last_updated: -1}, {created_at: 1}] * @param {ChannelOptions} [options] Options object. Can include predefined_filter, filter_values, and sort_values for using predefined filters. - * @param apiOptions the api request options. + * @param abortOptions the api request options. * * @return {Promise} full search channels response */ @@ -2084,7 +2086,7 @@ export class StreamChat { filterConditions: ChannelFilters, sort: ChannelSort = [], options: ChannelOptions = {}, - apiOptions: ApiRequestOptions = {}, + abortOptions: AbortOptions = {}, ): Promise { const defaultOptions: ChannelOptions = { state: true, @@ -2121,7 +2123,7 @@ export class StreamChat { return await this.post( this.baseURL + '/channels', payload, - apiOptions, + abortOptions, ); } @@ -2341,7 +2343,7 @@ export class StreamChat { * @param {ChannelFilters} filterConditions MongoDB style filter conditions * @param {MessageFilters | string} query search query or object MongoDB style filters * @param {SearchOptions} [options] Option object, {user_id: 'tommaso'} - * @param {ApiRequestOptions} [apiOptions] Request-level options such as an abort signal. Not sent in the request. + * @param {AbortOptions} [abortOptions] Carries an abort signal. Not sent in the request. * * @return {Promise} search messages response */ @@ -2349,7 +2351,7 @@ export class StreamChat { filterConditions: ChannelFilters, query: string | MessageFilters, options: SearchOptions = {}, - apiOptions: ApiRequestOptions = {}, + abortOptions: AbortOptions = {}, ) { if (options.offset && options.next) { throw Error(`Cannot specify offset with next`); @@ -2375,7 +2377,7 @@ export class StreamChat { return await this.get( this.baseURL + '/search', { payload }, - apiOptions, + abortOptions, ); } @@ -4029,11 +4031,11 @@ export class StreamChat { * * @returns {Promise} */ - searchRoles(options: SearchRolesOptions, apiOptions: ApiRequestOptions = {}) { + searchRoles(options: SearchRolesOptions, abortOptions: AbortOptions = {}) { return this.get( `${this.baseURL}/roles/search`, options, - apiOptions, + abortOptions, ); } diff --git a/src/messageComposer/middleware/textComposer/mentions.ts b/src/messageComposer/middleware/textComposer/mentions.ts index be06d4dbb7..ff101eaa51 100644 --- a/src/messageComposer/middleware/textComposer/mentions.ts +++ b/src/messageComposer/middleware/textComposer/mentions.ts @@ -23,7 +23,7 @@ import type { } from './types'; import type { StreamChat } from '../../../client'; import type { - ApiRequestOptions, + AbortOptions, MemberFilters, MemberSort, SearchUserGroupsOptions, @@ -475,11 +475,11 @@ export class MentionsSearchSource extends BaseSearchSource { getRoleMentionSuggestions = async ( query: string, - apiOptions: ApiRequestOptions = {}, + abortOptions: AbortOptions = {}, ): Promise => { if (!this.isMentionTypeAllowed('role')) return []; if (!query) return []; - const { roles } = await this.client.searchRoles({ query }, apiOptions); + const { roles } = await this.client.searchRoles({ query }, abortOptions); return [...(roles?.map((role) => role.name) ?? [])] .sort((left, right) => left.localeCompare(right)) .map((role) => this.toRoleMentionSuggestion(role, query)); @@ -565,23 +565,28 @@ export class MentionsSearchSource extends BaseSearchSource { queryUsers = async ( searchQuery: string, offset = 0, - apiOptions: ApiRequestOptions = {}, + abortOptions: AbortOptions = {}, ) => { const { filters, sort, options } = this.prepareQueryUsersParams(searchQuery, offset); - const { users } = await this.client.queryUsers(filters, sort, options, apiOptions); + const { users } = await this.client.queryUsers(filters, sort, options, abortOptions); return users; }; queryMembers = async ( searchQuery: string, offset = 0, - apiOptions: ApiRequestOptions = {}, + abortOptions: AbortOptions = {}, ) => { const { filters, sort, options } = this.prepareQueryMembersParams( searchQuery, offset, ); - const response = await this.channel.queryMembers(filters, sort, options, apiOptions); + const response = await this.channel.queryMembers( + filters, + sort, + options, + abortOptions, + ); return response.members.map((member) => member.user) as UserResponse[]; }; @@ -589,7 +594,7 @@ export class MentionsSearchSource extends BaseSearchSource { getUserSuggestionsPage = async ( searchQuery: string, userOffset = 0, - apiOptions: ApiRequestOptions = {}, + abortOptions: AbortOptions = {}, ) => { if (!this.isMentionTypeAllowed('user')) { return { @@ -603,7 +608,7 @@ export class MentionsSearchSource extends BaseSearchSource { this.allMembersLoadedWithInitialChannelQuery || !searchQuery; if (this.config.mentionAllAppUsers) { - users = await this.queryUsers(searchQuery, userOffset, apiOptions); + users = await this.queryUsers(searchQuery, userOffset, abortOptions); } else if (shouldSearchLocally) { const localUsers = this.searchMembersLocally(searchQuery); const items = localUsers @@ -617,7 +622,7 @@ export class MentionsSearchSource extends BaseSearchSource { : undefined, }; } else { - users = await this.queryMembers(searchQuery, userOffset, apiOptions); + users = await this.queryMembers(searchQuery, userOffset, abortOptions); } const items = users.map((user) => this.toUserSuggestion(user, searchQuery)); @@ -642,7 +647,7 @@ export class MentionsSearchSource extends BaseSearchSource { getUserGroupSuggestionsPage = async ( searchQuery: string, cursor?: string, - apiOptions: ApiRequestOptions = {}, + abortOptions: AbortOptions = {}, ) => { if (!this.isMentionTypeAllowed('user_group')) { return { @@ -667,7 +672,7 @@ export class MentionsSearchSource extends BaseSearchSource { ...(userGroupCursor?.id_gt ? { id_gt: userGroupCursor.id_gt } : {}), ...(userGroupCursor?.name_gt ? { name_gt: userGroupCursor.name_gt } : {}), }; - const { user_groups } = await this.client.searchUserGroups(options, apiOptions); + const { user_groups } = await this.client.searchUserGroups(options, abortOptions); return { items: user_groups.map((userGroup) => @@ -677,27 +682,27 @@ export class MentionsSearchSource extends BaseSearchSource { }; }; - async query(searchQuery: string, apiOptions: ApiRequestOptions = {}) { + async query(searchQuery: string, abortOptions: AbortOptions = {}) { const userOffset = this.offset ?? 0; const isFirstPage = userOffset === 0 && typeof this.userGroupCursor === 'undefined'; const previousUserPaginationState = this.latestUserPaginationState; const previousUserGroupCursor = this.userGroupCursor; const [userResultsState, userGroupResultsState, roleSuggestionsState] = await Promise.allSettled([ - this.getUserSuggestionsPage(searchQuery, userOffset, apiOptions), + this.getUserSuggestionsPage(searchQuery, userOffset, abortOptions), this.getUserGroupSuggestionsPage( searchQuery, previousUserGroupCursor, - apiOptions, + abortOptions, ), isFirstPage - ? this.getRoleMentionSuggestions(searchQuery, apiOptions) + ? this.getRoleMentionSuggestions(searchQuery, abortOptions) : Promise.resolve([] as RoleMentionSuggestion[]), ]); // On abort the requests above reject and the fallback branches below would write // empty results into the pagination cursors, corrupting them for the newer query. - if (apiOptions.signal?.aborted) return { items: [] }; + if (abortOptions.signal?.aborted) return { items: [] }; const userResults = userResultsState.status === 'fulfilled' diff --git a/src/search/BaseSearchSource.ts b/src/search/BaseSearchSource.ts index 86c15505ba..b30535bee1 100644 --- a/src/search/BaseSearchSource.ts +++ b/src/search/BaseSearchSource.ts @@ -8,7 +8,7 @@ import type { } from './types'; import type { APIError } from '../errors'; import { isAPIError, isErrorRetryable } from '../errors'; -import type { ApiRequestOptions } from '../types'; +import type { AbortOptions } from '../types'; export type DebounceOptions = { /** Applies to both short and long queries unless overridden by the options below. */ @@ -295,7 +295,7 @@ export abstract class BaseSearchSource protected abstract query( searchQuery: string, - options?: ApiRequestOptions, + options?: AbortOptions, ): Promise>; protected abstract filterQueryResults(items: T[]): T[] | Promise; diff --git a/src/search/ChannelMemberSearchSource.ts b/src/search/ChannelMemberSearchSource.ts index a10688e1ce..6f739e9b6c 100644 --- a/src/search/ChannelMemberSearchSource.ts +++ b/src/search/ChannelMemberSearchSource.ts @@ -2,7 +2,7 @@ import { BaseSearchSource } from './BaseSearchSource'; import { FilterBuilder, type FilterBuilderOptions } from '../pagination'; import type { Channel } from '../channel'; import type { - ApiRequestOptions, + AbortOptions, ChannelMemberResponse, MemberFilters, MemberSort, @@ -67,7 +67,7 @@ export class ChannelMemberSearchSource< return this.isActive && this.canDispatchQuery(hasNewSearchQuery); }; - protected async query(searchQuery: string, apiOptions: ApiRequestOptions = {}) { + protected async query(searchQuery: string, abortOptions: AbortOptions = {}) { const filters = this.filterBuilder.buildFilters({ baseFilters: this.filters, context: { @@ -80,7 +80,7 @@ export class ChannelMemberSearchSource< filters ?? {}, sort, options, - apiOptions, + abortOptions, ); return { items: members }; } diff --git a/src/search/ChannelSearchSource.ts b/src/search/ChannelSearchSource.ts index 15086cdb82..4fe60451f2 100644 --- a/src/search/ChannelSearchSource.ts +++ b/src/search/ChannelSearchSource.ts @@ -3,12 +3,7 @@ import type { FilterBuilderOptions } from '../pagination'; import { FilterBuilder } from '../pagination'; import type { Channel } from '../channel'; import type { StreamChat } from '../client'; -import type { - ApiRequestOptions, - ChannelFilters, - ChannelOptions, - ChannelSort, -} from '../types'; +import type { AbortOptions, ChannelFilters, ChannelOptions, ChannelSort } from '../types'; import type { SearchSourceOptions } from './types'; type CustomContext = Record; @@ -56,7 +51,7 @@ export class ChannelSearchSource< }); } - protected async query(searchQuery: string, apiOptions: ApiRequestOptions = {}) { + protected async query(searchQuery: string, abortOptions: AbortOptions = {}) { const filters = this.filterBuilder.buildFilters({ baseFilters: { ...(this.client.userID ? { members: { $in: [this.client.userID] } } : {}), @@ -68,7 +63,7 @@ export class ChannelSearchSource< }); const sort = this.sort ?? {}; const options = { ...this.searchOptions, limit: this.pageSize, offset: this.offset }; - const items = await this.client.queryChannels(filters, sort, options, apiOptions); + const items = await this.client.queryChannels(filters, sort, options, abortOptions); return { items }; } diff --git a/src/search/MessageSearchSource.ts b/src/search/MessageSearchSource.ts index 8f86041c03..c6ce5a8a5b 100644 --- a/src/search/MessageSearchSource.ts +++ b/src/search/MessageSearchSource.ts @@ -1,6 +1,6 @@ import { BaseSearchSource } from './BaseSearchSource'; import type { - ApiRequestOptions, + AbortOptions, ChannelFilters, ChannelOptions, ChannelSort, @@ -130,7 +130,7 @@ export class MessageSearchSource< }); } - protected async query(searchQuery: string, apiOptions: ApiRequestOptions = {}) { + protected async query(searchQuery: string, abortOptions: AbortOptions = {}) { if (!this.client.userID || this.next === null) return { items: [] }; const channelFilters = this.messageSearchChannelFilterBuilder.buildFilters({ @@ -171,12 +171,12 @@ export class MessageSearchSource< channelFilters, messageFilters, options, - apiOptions, + abortOptions, ); const items = results.map(({ message }) => message); // a newer query already replaced this one - skip the cid scan and the hydration request - if (apiOptions.signal?.aborted) return { items, next }; + if (abortOptions.signal?.aborted) return { items, next }; const cids = Array.from( items.reduce((acc, message) => { @@ -199,7 +199,7 @@ export class MessageSearchSource< ...this.channelQuerySort, }, this.channelQueryOptions, - apiOptions, + abortOptions, ); } diff --git a/src/search/UserSearchSource.ts b/src/search/UserSearchSource.ts index 8fdf84fa2d..37e43776f4 100644 --- a/src/search/UserSearchSource.ts +++ b/src/search/UserSearchSource.ts @@ -2,7 +2,7 @@ import { BaseSearchSource } from './BaseSearchSource'; import { FilterBuilder, type FilterBuilderOptions } from '../pagination'; import type { StreamChat } from '../client'; import type { - ApiRequestOptions, + AbortOptions, UserFilters, UserOptions, UserResponse, @@ -61,7 +61,7 @@ export class UserSearchSource< }); } - protected async query(searchQuery: string, apiOptions: ApiRequestOptions = {}) { + protected async query(searchQuery: string, abortOptions: AbortOptions = {}) { const filters = this.filterBuilder.buildFilters({ baseFilters: this.filters, context: { searchQuery } as UserSearchSourceFilterBuilderContext, @@ -74,7 +74,7 @@ export class UserSearchSource< sort = { id: 1, ...this.sort }; } const options = { ...this.searchOptions, limit: this.pageSize, offset: this.offset }; - const { users } = await this.client.queryUsers(filters, sort, options, apiOptions); + const { users } = await this.client.queryUsers(filters, sort, options, abortOptions); return { items: users }; } diff --git a/src/types.ts b/src/types.ts index 3bc390731e..7b83904fbb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1190,12 +1190,12 @@ export type ChannelQueryOptions = { }; /** - * Composed with ApiRequestOptions because `queryChannels` uses this bag for both state - * handling and request-level concerns - neither of which is serialized into the request. - * That makes passing an ApiRequestOptions here a declared relationship rather than - * incidental structural compatibility. + * Composed with AbortOptions because `queryChannels` carries the abort signal in this + * bag; neither the state flags nor the signal are serialized into the request. Declaring + * the composition keeps passing an AbortOptions here intentional rather than incidental + * structural compatibility. */ -export type ChannelStateOptions = ApiRequestOptions & { +export type ChannelStateOptions = AbortOptions & { offlineMode?: boolean; skipInitialization?: string[]; skipHydration?: boolean; @@ -1579,9 +1579,13 @@ export type SearchOptions = { sort?: SearchMessageSort; }; -/** Per-request options that are not part of the serialized request payload. */ -export type ApiRequestOptions = { - /** Aborts the request. See AbortController. */ +/** + * Carries a cancellation handle into an operation. Never part of a serialized request + * payload, and it makes no claim that the operation performs one: a search source may + * resolve from local data and simply ignore the signal. + */ +export type AbortOptions = { + /** Aborts the operation. See AbortController. */ signal?: AbortSignal; }; From d25f63d2e9523fe407749a34013bcfbc524d0b9f Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 13 Aug 2026 16:35:37 +0200 Subject: [PATCH 3/6] refactor(search): split abort options into SearchQueryOptions and RequestOptions 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. --- src/channel.ts | 8 ++-- src/client.ts | 30 +++++++------- .../middleware/textComposer/mentions.ts | 39 ++++++++++--------- src/search/BaseSearchSource.ts | 14 ++++++- src/search/ChannelMemberSearchSource.ts | 7 ++-- src/search/ChannelSearchSource.ts | 8 ++-- src/search/MessageSearchSource.ts | 11 +++--- src/search/UserSearchSource.ts | 14 ++----- src/types.ts | 16 +++----- 9 files changed, 74 insertions(+), 73 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index eef41f43dc..497febdd4d 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -13,7 +13,6 @@ import { import type { StreamChat } from './client'; import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from './constants'; import type { - AbortOptions, AIState, APIResponse, AscDesc, @@ -63,6 +62,7 @@ import type { QueryMembersOptions, Reaction, ReactionAPIResponse, + RequestOptions, SearchAPIResponse, SearchMessageSortBase, SearchOptions, @@ -378,7 +378,7 @@ export class Channel { * @param {MemberSort} [sort] Sort options, for instance [{created_at: -1}]. * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{name: -1}, {created_at: 1}] * @param {{ limit?: number; offset?: number }} [options] Option object, {limit: 10, offset:10} - * @param {AbortOptions} [abortOptions] Carries an abort signal. Not sent in the request. + * @param {RequestOptions} [requestOptions] Carries an abort signal. Not sent in the request. * * @return {Promise} Query Members response */ @@ -386,7 +386,7 @@ export class Channel { filterConditions: MemberFilters, sort: MemberSort = [], options: QueryMembersOptions = {}, - abortOptions: AbortOptions = {}, + requestOptions: RequestOptions = {}, ) { let id: string | undefined; const type = this.type; @@ -409,7 +409,7 @@ export class Channel { ...options, }, }, - abortOptions, + requestOptions, ); } diff --git a/src/client.ts b/src/client.ts index 54366bc6d8..7c0a2e706c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -41,7 +41,6 @@ import { } from './utils'; import type { - AbortOptions, ActiveLiveLocationsAPIResponse, AddUserGroupMembersOptions, AddUserGroupMembersResponse, @@ -220,6 +219,7 @@ import type { ReminderAPIResponse, RemoveUserGroupMembersOptions, RemoveUserGroupMembersResponse, + RequestOptions, ReviewFlagReportOptions, ReviewFlagReportResponse, SdkIdentifier, @@ -1847,7 +1847,7 @@ export class StreamChat { * @param {UserSort} sort Sort options, for instance [{last_active: -1}]. * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{last_active: -1}, {created_at: 1}] * @param {UserOptions} options Option object, {presence: true} - * @param {AbortOptions} [abortOptions] Carries an abort signal. Not sent in the request. + * @param {RequestOptions} [requestOptions] Carries an abort signal. Not sent in the request. * * @return {Promise<{ users: Array }>} User Query Response */ @@ -1855,7 +1855,7 @@ export class StreamChat { filterConditions: UserFilters, sort: UserSort = [], options: UserOptions = {}, - abortOptions: AbortOptions = {}, + requestOptions: RequestOptions = {}, ) { const defaultOptions = { presence: false, @@ -1879,7 +1879,7 @@ export class StreamChat { ...options, }, }, - abortOptions, + requestOptions, ); this.state.updateUsers(data.users); @@ -1931,17 +1931,17 @@ export class StreamChat { * searchUserGroups - Search user groups by prefix for autocomplete * * @param {SearchUserGroupsOptions} options The search options - * @param abortOptions the api request options + * @param requestOptions the api request options * @return {Promise} User Group Search Response */ async searchUserGroups( options: SearchUserGroupsOptions, - abortOptions: AbortOptions = {}, + requestOptions: RequestOptions = {}, ) { return await this.get( this.baseURL + '/usergroups/search', options, - abortOptions, + requestOptions, ); } @@ -2078,7 +2078,7 @@ export class StreamChat { * @param {ChannelSort} [sort] Sort options, for instance {created_at: -1}. * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{last_updated: -1}, {created_at: 1}] * @param {ChannelOptions} [options] Options object. Can include predefined_filter, filter_values, and sort_values for using predefined filters. - * @param abortOptions the api request options. + * @param requestOptions the api request options. * * @return {Promise} full search channels response */ @@ -2086,7 +2086,7 @@ export class StreamChat { filterConditions: ChannelFilters, sort: ChannelSort = [], options: ChannelOptions = {}, - abortOptions: AbortOptions = {}, + requestOptions: RequestOptions = {}, ): Promise { const defaultOptions: ChannelOptions = { state: true, @@ -2123,7 +2123,7 @@ export class StreamChat { return await this.post( this.baseURL + '/channels', payload, - abortOptions, + requestOptions, ); } @@ -2343,7 +2343,7 @@ export class StreamChat { * @param {ChannelFilters} filterConditions MongoDB style filter conditions * @param {MessageFilters | string} query search query or object MongoDB style filters * @param {SearchOptions} [options] Option object, {user_id: 'tommaso'} - * @param {AbortOptions} [abortOptions] Carries an abort signal. Not sent in the request. + * @param {RequestOptions} [requestOptions] Carries an abort signal. Not sent in the request. * * @return {Promise} search messages response */ @@ -2351,7 +2351,7 @@ export class StreamChat { filterConditions: ChannelFilters, query: string | MessageFilters, options: SearchOptions = {}, - abortOptions: AbortOptions = {}, + requestOptions: RequestOptions = {}, ) { if (options.offset && options.next) { throw Error(`Cannot specify offset with next`); @@ -2377,7 +2377,7 @@ export class StreamChat { return await this.get( this.baseURL + '/search', { payload }, - abortOptions, + requestOptions, ); } @@ -4031,11 +4031,11 @@ export class StreamChat { * * @returns {Promise} */ - searchRoles(options: SearchRolesOptions, abortOptions: AbortOptions = {}) { + searchRoles(options: SearchRolesOptions, requestOptions: RequestOptions = {}) { return this.get( `${this.baseURL}/roles/search`, options, - abortOptions, + requestOptions, ); } diff --git a/src/messageComposer/middleware/textComposer/mentions.ts b/src/messageComposer/middleware/textComposer/mentions.ts index ff101eaa51..d425014020 100644 --- a/src/messageComposer/middleware/textComposer/mentions.ts +++ b/src/messageComposer/middleware/textComposer/mentions.ts @@ -9,7 +9,11 @@ import { userSuggestionToMentionEntity, userSuggestionToUserResponse, } from './mentionUtils'; -import { BaseSearchSource, type SearchSourceOptions } from '../../../search'; +import { + BaseSearchSource, + type SearchQueryOptions, + type SearchSourceOptions, +} from '../../../search'; import { mergeWith } from '../../../utils/mergeWith'; import type { ChannelMentionSuggestion, @@ -23,7 +27,6 @@ import type { } from './types'; import type { StreamChat } from '../../../client'; import type { - AbortOptions, MemberFilters, MemberSort, SearchUserGroupsOptions, @@ -475,11 +478,11 @@ export class MentionsSearchSource extends BaseSearchSource { getRoleMentionSuggestions = async ( query: string, - abortOptions: AbortOptions = {}, + queryOptions: SearchQueryOptions = {}, ): Promise => { if (!this.isMentionTypeAllowed('role')) return []; if (!query) return []; - const { roles } = await this.client.searchRoles({ query }, abortOptions); + const { roles } = await this.client.searchRoles({ query }, queryOptions); return [...(roles?.map((role) => role.name) ?? [])] .sort((left, right) => left.localeCompare(right)) .map((role) => this.toRoleMentionSuggestion(role, query)); @@ -565,17 +568,17 @@ export class MentionsSearchSource extends BaseSearchSource { queryUsers = async ( searchQuery: string, offset = 0, - abortOptions: AbortOptions = {}, + queryOptions: SearchQueryOptions = {}, ) => { const { filters, sort, options } = this.prepareQueryUsersParams(searchQuery, offset); - const { users } = await this.client.queryUsers(filters, sort, options, abortOptions); + const { users } = await this.client.queryUsers(filters, sort, options, queryOptions); return users; }; queryMembers = async ( searchQuery: string, offset = 0, - abortOptions: AbortOptions = {}, + queryOptions: SearchQueryOptions = {}, ) => { const { filters, sort, options } = this.prepareQueryMembersParams( searchQuery, @@ -585,7 +588,7 @@ export class MentionsSearchSource extends BaseSearchSource { filters, sort, options, - abortOptions, + queryOptions, ); return response.members.map((member) => member.user) as UserResponse[]; @@ -594,7 +597,7 @@ export class MentionsSearchSource extends BaseSearchSource { getUserSuggestionsPage = async ( searchQuery: string, userOffset = 0, - abortOptions: AbortOptions = {}, + queryOptions: SearchQueryOptions = {}, ) => { if (!this.isMentionTypeAllowed('user')) { return { @@ -608,7 +611,7 @@ export class MentionsSearchSource extends BaseSearchSource { this.allMembersLoadedWithInitialChannelQuery || !searchQuery; if (this.config.mentionAllAppUsers) { - users = await this.queryUsers(searchQuery, userOffset, abortOptions); + users = await this.queryUsers(searchQuery, userOffset, queryOptions); } else if (shouldSearchLocally) { const localUsers = this.searchMembersLocally(searchQuery); const items = localUsers @@ -622,7 +625,7 @@ export class MentionsSearchSource extends BaseSearchSource { : undefined, }; } else { - users = await this.queryMembers(searchQuery, userOffset, abortOptions); + users = await this.queryMembers(searchQuery, userOffset, queryOptions); } const items = users.map((user) => this.toUserSuggestion(user, searchQuery)); @@ -647,7 +650,7 @@ export class MentionsSearchSource extends BaseSearchSource { getUserGroupSuggestionsPage = async ( searchQuery: string, cursor?: string, - abortOptions: AbortOptions = {}, + queryOptions: SearchQueryOptions = {}, ) => { if (!this.isMentionTypeAllowed('user_group')) { return { @@ -672,7 +675,7 @@ export class MentionsSearchSource extends BaseSearchSource { ...(userGroupCursor?.id_gt ? { id_gt: userGroupCursor.id_gt } : {}), ...(userGroupCursor?.name_gt ? { name_gt: userGroupCursor.name_gt } : {}), }; - const { user_groups } = await this.client.searchUserGroups(options, abortOptions); + const { user_groups } = await this.client.searchUserGroups(options, queryOptions); return { items: user_groups.map((userGroup) => @@ -682,27 +685,27 @@ export class MentionsSearchSource extends BaseSearchSource { }; }; - async query(searchQuery: string, abortOptions: AbortOptions = {}) { + async query(searchQuery: string, queryOptions: SearchQueryOptions = {}) { const userOffset = this.offset ?? 0; const isFirstPage = userOffset === 0 && typeof this.userGroupCursor === 'undefined'; const previousUserPaginationState = this.latestUserPaginationState; const previousUserGroupCursor = this.userGroupCursor; const [userResultsState, userGroupResultsState, roleSuggestionsState] = await Promise.allSettled([ - this.getUserSuggestionsPage(searchQuery, userOffset, abortOptions), + this.getUserSuggestionsPage(searchQuery, userOffset, queryOptions), this.getUserGroupSuggestionsPage( searchQuery, previousUserGroupCursor, - abortOptions, + queryOptions, ), isFirstPage - ? this.getRoleMentionSuggestions(searchQuery, abortOptions) + ? this.getRoleMentionSuggestions(searchQuery, queryOptions) : Promise.resolve([] as RoleMentionSuggestion[]), ]); // On abort the requests above reject and the fallback branches below would write // empty results into the pagination cursors, corrupting them for the newer query. - if (abortOptions.signal?.aborted) return { items: [] }; + if (queryOptions.signal?.aborted) return { items: [] }; const userResults = userResultsState.status === 'fulfilled' diff --git a/src/search/BaseSearchSource.ts b/src/search/BaseSearchSource.ts index b30535bee1..244c95d6ef 100644 --- a/src/search/BaseSearchSource.ts +++ b/src/search/BaseSearchSource.ts @@ -8,7 +8,17 @@ import type { } from './types'; import type { APIError } from '../errors'; import { isAPIError, isErrorRetryable } from '../errors'; -import type { AbortOptions } from '../types'; + +/** + * Passed to a search source's `query()`. Carries a cancellation handle and makes no + * claim that the query performs a request: a source may resolve from local data and + * ignore the signal. Where a source does issue requests, forward this straight through + * as the request options. + */ +export type SearchQueryOptions = { + /** Aborted once a newer query supersedes this one. */ + signal?: AbortSignal; +}; export type DebounceOptions = { /** Applies to both short and long queries unless overridden by the options below. */ @@ -295,7 +305,7 @@ export abstract class BaseSearchSource protected abstract query( searchQuery: string, - options?: AbortOptions, + options?: SearchQueryOptions, ): Promise>; protected abstract filterQueryResults(items: T[]): T[] | Promise; diff --git a/src/search/ChannelMemberSearchSource.ts b/src/search/ChannelMemberSearchSource.ts index 6f739e9b6c..22e1d63a6f 100644 --- a/src/search/ChannelMemberSearchSource.ts +++ b/src/search/ChannelMemberSearchSource.ts @@ -1,8 +1,7 @@ -import { BaseSearchSource } from './BaseSearchSource'; +import { BaseSearchSource, type SearchQueryOptions } from './BaseSearchSource'; import { FilterBuilder, type FilterBuilderOptions } from '../pagination'; import type { Channel } from '../channel'; import type { - AbortOptions, ChannelMemberResponse, MemberFilters, MemberSort, @@ -67,7 +66,7 @@ export class ChannelMemberSearchSource< return this.isActive && this.canDispatchQuery(hasNewSearchQuery); }; - protected async query(searchQuery: string, abortOptions: AbortOptions = {}) { + protected async query(searchQuery: string, queryOptions: SearchQueryOptions = {}) { const filters = this.filterBuilder.buildFilters({ baseFilters: this.filters, context: { @@ -80,7 +79,7 @@ export class ChannelMemberSearchSource< filters ?? {}, sort, options, - abortOptions, + queryOptions, ); return { items: members }; } diff --git a/src/search/ChannelSearchSource.ts b/src/search/ChannelSearchSource.ts index 4fe60451f2..1848c9b52c 100644 --- a/src/search/ChannelSearchSource.ts +++ b/src/search/ChannelSearchSource.ts @@ -1,9 +1,9 @@ -import { BaseSearchSource } from './BaseSearchSource'; +import { BaseSearchSource, type SearchQueryOptions } from './BaseSearchSource'; import type { FilterBuilderOptions } from '../pagination'; import { FilterBuilder } from '../pagination'; import type { Channel } from '../channel'; import type { StreamChat } from '../client'; -import type { AbortOptions, ChannelFilters, ChannelOptions, ChannelSort } from '../types'; +import type { ChannelFilters, ChannelOptions, ChannelSort } from '../types'; import type { SearchSourceOptions } from './types'; type CustomContext = Record; @@ -51,7 +51,7 @@ export class ChannelSearchSource< }); } - protected async query(searchQuery: string, abortOptions: AbortOptions = {}) { + protected async query(searchQuery: string, queryOptions: SearchQueryOptions = {}) { const filters = this.filterBuilder.buildFilters({ baseFilters: { ...(this.client.userID ? { members: { $in: [this.client.userID] } } : {}), @@ -63,7 +63,7 @@ export class ChannelSearchSource< }); const sort = this.sort ?? {}; const options = { ...this.searchOptions, limit: this.pageSize, offset: this.offset }; - const items = await this.client.queryChannels(filters, sort, options, abortOptions); + const items = await this.client.queryChannels(filters, sort, options, queryOptions); return { items }; } diff --git a/src/search/MessageSearchSource.ts b/src/search/MessageSearchSource.ts index c6ce5a8a5b..c845ba92e3 100644 --- a/src/search/MessageSearchSource.ts +++ b/src/search/MessageSearchSource.ts @@ -1,6 +1,5 @@ -import { BaseSearchSource } from './BaseSearchSource'; +import { BaseSearchSource, type SearchQueryOptions } from './BaseSearchSource'; import type { - AbortOptions, ChannelFilters, ChannelOptions, ChannelSort, @@ -130,7 +129,7 @@ export class MessageSearchSource< }); } - protected async query(searchQuery: string, abortOptions: AbortOptions = {}) { + protected async query(searchQuery: string, queryOptions: SearchQueryOptions = {}) { if (!this.client.userID || this.next === null) return { items: [] }; const channelFilters = this.messageSearchChannelFilterBuilder.buildFilters({ @@ -171,12 +170,12 @@ export class MessageSearchSource< channelFilters, messageFilters, options, - abortOptions, + queryOptions, ); const items = results.map(({ message }) => message); // a newer query already replaced this one - skip the cid scan and the hydration request - if (abortOptions.signal?.aborted) return { items, next }; + if (queryOptions.signal?.aborted) return { items, next }; const cids = Array.from( items.reduce((acc, message) => { @@ -199,7 +198,7 @@ export class MessageSearchSource< ...this.channelQuerySort, }, this.channelQueryOptions, - abortOptions, + queryOptions, ); } diff --git a/src/search/UserSearchSource.ts b/src/search/UserSearchSource.ts index 37e43776f4..e53371f0cf 100644 --- a/src/search/UserSearchSource.ts +++ b/src/search/UserSearchSource.ts @@ -1,13 +1,7 @@ -import { BaseSearchSource } from './BaseSearchSource'; +import { BaseSearchSource, type SearchQueryOptions } from './BaseSearchSource'; import { FilterBuilder, type FilterBuilderOptions } from '../pagination'; import type { StreamChat } from '../client'; -import type { - AbortOptions, - UserFilters, - UserOptions, - UserResponse, - UserSort, -} from '../types'; +import type { UserFilters, UserOptions, UserResponse, UserSort } from '../types'; import type { SearchSourceOptions } from './types'; type CustomContext = Record; @@ -61,7 +55,7 @@ export class UserSearchSource< }); } - protected async query(searchQuery: string, abortOptions: AbortOptions = {}) { + protected async query(searchQuery: string, queryOptions: SearchQueryOptions = {}) { const filters = this.filterBuilder.buildFilters({ baseFilters: this.filters, context: { searchQuery } as UserSearchSourceFilterBuilderContext, @@ -74,7 +68,7 @@ export class UserSearchSource< sort = { id: 1, ...this.sort }; } const options = { ...this.searchOptions, limit: this.pageSize, offset: this.offset }; - const { users } = await this.client.queryUsers(filters, sort, options, abortOptions); + const { users } = await this.client.queryUsers(filters, sort, options, queryOptions); return { items: users }; } diff --git a/src/types.ts b/src/types.ts index 7b83904fbb..8be30925e3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1190,12 +1190,12 @@ export type ChannelQueryOptions = { }; /** - * Composed with AbortOptions because `queryChannels` carries the abort signal in this + * Composed with RequestOptions because `queryChannels` carries the abort signal in this * bag; neither the state flags nor the signal are serialized into the request. Declaring - * the composition keeps passing an AbortOptions here intentional rather than incidental + * the composition keeps passing a RequestOptions here intentional rather than incidental * structural compatibility. */ -export type ChannelStateOptions = AbortOptions & { +export type ChannelStateOptions = RequestOptions & { offlineMode?: boolean; skipInitialization?: string[]; skipHydration?: boolean; @@ -1579,13 +1579,9 @@ export type SearchOptions = { sort?: SearchMessageSort; }; -/** - * Carries a cancellation handle into an operation. Never part of a serialized request - * payload, and it makes no claim that the operation performs one: a search source may - * resolve from local data and simply ignore the signal. - */ -export type AbortOptions = { - /** Aborts the operation. See AbortController. */ +/** Per-request options that are never part of the serialized request payload. */ +export type RequestOptions = { + /** Aborts the request. See AbortController. */ signal?: AbortSignal; }; From 8712d9cb05cffd6ff81a292ce00fc8d61a30dc95 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 14 Aug 2026 10:39:58 +0200 Subject: [PATCH 4/6] refactor(search): address review feedback on BaseSearchSourceBase 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 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. --- src/search/BaseSearchSource.ts | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/search/BaseSearchSource.ts b/src/search/BaseSearchSource.ts index 244c95d6ef..73eb8e0edb 100644 --- a/src/search/BaseSearchSource.ts +++ b/src/search/BaseSearchSource.ts @@ -68,10 +68,6 @@ export interface SearchSourceSync extends ISearchSource { search(text?: string): void; } -const DEFAULT_SHORT_QUERY_DEBOUNCE_MS = 500; -const DEFAULT_LONG_QUERY_DEBOUNCE_MS = 300; -const DEFAULT_SHORT_QUERY_MAX_LENGTH = 2; - // Debounce defaults are resolved by resolveDebounceOptions, not here. const DEFAULT_SEARCH_SOURCE_OPTIONS: Required< Omit @@ -92,25 +88,24 @@ const resolveDebounceOptions = ({ longQueryDebounceMs, shortQueryMaxLength, }: DebounceOptions) => ({ - shortQueryDebounceMs: - shortQueryDebounceMs ?? debounceMs ?? DEFAULT_SHORT_QUERY_DEBOUNCE_MS, - longQueryDebounceMs: - longQueryDebounceMs ?? debounceMs ?? DEFAULT_LONG_QUERY_DEBOUNCE_MS, - shortQueryMaxLength: shortQueryMaxLength ?? DEFAULT_SHORT_QUERY_MAX_LENGTH, + shortQueryDebounceMs: shortQueryDebounceMs ?? debounceMs ?? 500, + longQueryDebounceMs: longQueryDebounceMs ?? debounceMs ?? 300, + shortQueryMaxLength: shortQueryMaxLength ?? 2, }); abstract class BaseSearchSourceBase< T, - TExecuteResult extends void | Promise, + R extends void | Promise, > implements ISearchSource { state: StateStore>; pageSize: number; protected allowEmptySearchString: boolean; protected resetOnNewSearchQuery: boolean; - protected shortQueryDebounceMs: number = DEFAULT_SHORT_QUERY_DEBOUNCE_MS; - protected longQueryDebounceMs: number = DEFAULT_LONG_QUERY_DEBOUNCE_MS; - protected shortQueryMaxLength: number = DEFAULT_SHORT_QUERY_MAX_LENGTH; - protected searchDebounced!: DebouncedFunc<(searchString?: string) => TExecuteResult>; + // assigned by setDebounceOptions, which the constructor always calls + protected shortQueryDebounceMs!: number; + protected longQueryDebounceMs!: number; + protected shortQueryMaxLength!: number; + protected searchDebounced!: DebouncedFunc<(searchString?: string) => R>; abstract readonly type: SearchSourceType; protected constructor(options?: SearchSourceOptions) { @@ -127,7 +122,7 @@ abstract class BaseSearchSourceBase< this.setDebounceOptions(options ?? {}); } - abstract executeQuery(newSearchString?: string): TExecuteResult; + abstract executeQuery(newSearchString?: string): R; setDebounceOptions = (options: DebounceOptions = {}) => { const resolved = resolveDebounceOptions(options); @@ -180,7 +175,7 @@ abstract class BaseSearchSourceBase< return this.state.getLatestValue().isLoading; } - get initialState() { + get initialState(): SearchSourceState { return { hasNext: true, isActive: false, From 9ba33317c382999de78db15958511bd750d48616 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 14 Aug 2026 11:05:10 +0200 Subject: [PATCH 5/6] feat(search): accept query parameters as constructor options on every 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. --- .../middleware/textComposer/mentions.ts | 17 +++ src/search/ChannelMemberSearchSource.ts | 15 ++- src/search/ChannelSearchSource.ts | 15 ++- src/search/MessageSearchSource.ts | 31 ++++- src/search/UserSearchSource.ts | 15 ++- .../searchSourceConstructorOptions.test.ts | 116 ++++++++++++++++++ 6 files changed, 201 insertions(+), 8 deletions(-) create mode 100644 test/unit/search/searchSourceConstructorOptions.test.ts diff --git a/src/messageComposer/middleware/textComposer/mentions.ts b/src/messageComposer/middleware/textComposer/mentions.ts index d425014020..038d8af516 100644 --- a/src/messageComposer/middleware/textComposer/mentions.ts +++ b/src/messageComposer/middleware/textComposer/mentions.ts @@ -96,6 +96,13 @@ export const calculateLevenshtein = (query: string, name: string) => { }; export type MentionsSearchSourceOptions = SearchSourceOptions & { + /** Static base filters for the app-wide user query (mentionAllAppUsers). */ + userFilters?: UserFilters; + userSort?: UserSort; + /** Static base filters for the channel member query. */ + memberFilters?: MemberFilters; + memberSort?: MemberSort; + searchOptions?: Omit; mentionAllAppUsers?: boolean; suggestionFactoryMappers?: MentionSuggestionFactoryMapperOverrides; textComposerText?: string; @@ -308,15 +315,25 @@ export class MentionsSearchSource extends BaseSearchSource { constructor(channel: Channel, options?: MentionsSearchSourceOptions) { const { mentionAllAppUsers, + memberFilters, + memberSort, + searchOptions, suggestionFactoryMappers, textComposerText, transliterate, trigger, + userFilters, + userSort, ...restOptions } = options || {}; super(restOptions); this.client = channel.getClient(); this.channel = channel; + this.userFilters = userFilters; + this.userSort = userSort; + this.memberFilters = memberFilters; + this.memberSort = memberSort; + this.searchOptions = searchOptions; this.config = { mentionAllAppUsers, suggestionFactoryMappers, diff --git a/src/search/ChannelMemberSearchSource.ts b/src/search/ChannelMemberSearchSource.ts index 22e1d63a6f..2d310418c5 100644 --- a/src/search/ChannelMemberSearchSource.ts +++ b/src/search/ChannelMemberSearchSource.ts @@ -15,6 +15,13 @@ export type ChannelMemberSearchSourceFilterBuilderContext< C extends CustomContext = CustomContext, > = { searchQuery?: string } & C; +export type ChannelMemberSearchSourceOptions = SearchSourceOptions & { + /** Static base filters merged under the dynamically generated ones. */ + filters?: MemberFilters; + sort?: MemberSort; + searchOptions?: Omit; +}; + export class ChannelMemberSearchSource< TFilterContext extends CustomContext = CustomContext, > extends BaseSearchSource { @@ -30,14 +37,18 @@ export class ChannelMemberSearchSource< constructor( channel: Channel, - options?: SearchSourceOptions, + options?: ChannelMemberSearchSourceOptions, filterBuilderOptions: FilterBuilderOptions< MemberFilters, ChannelMemberSearchSourceFilterBuilderContext > = {}, ) { - super(options); + const { filters, sort, searchOptions, ...restOptions } = options || {}; + super(restOptions); this.channel = channel; + this.filters = filters; + this.sort = sort; + this.searchOptions = searchOptions; this.filterBuilder = new FilterBuilder< MemberFilters, ChannelMemberSearchSourceFilterBuilderContext diff --git a/src/search/ChannelSearchSource.ts b/src/search/ChannelSearchSource.ts index 1848c9b52c..c0c4a972f2 100644 --- a/src/search/ChannelSearchSource.ts +++ b/src/search/ChannelSearchSource.ts @@ -12,6 +12,13 @@ export type ChannelSearchSourceFilterBuilderContext< C extends CustomContext = CustomContext, > = { searchQuery?: string } & C; +export type ChannelSearchSourceOptions = SearchSourceOptions & { + /** Static base filters merged under the dynamically generated ones. */ + filters?: ChannelFilters; + sort?: ChannelSort; + searchOptions?: Omit; +}; + export class ChannelSearchSource< TFilterContext extends CustomContext = CustomContext, > extends BaseSearchSource { @@ -27,14 +34,18 @@ export class ChannelSearchSource< constructor( client: StreamChat, - options?: SearchSourceOptions, + options?: ChannelSearchSourceOptions, filterBuilderOptions: FilterBuilderOptions< ChannelFilters, ChannelSearchSourceFilterBuilderContext > = {}, ) { - super(options); + const { filters, sort, searchOptions, ...restOptions } = options || {}; + super(restOptions); this.client = client; + this.filters = filters; + this.sort = sort; + this.searchOptions = searchOptions; this.filterBuilder = new FilterBuilder< ChannelFilters, ChannelSearchSourceFilterBuilderContext diff --git a/src/search/MessageSearchSource.ts b/src/search/MessageSearchSource.ts index c845ba92e3..69d87e8f3c 100644 --- a/src/search/MessageSearchSource.ts +++ b/src/search/MessageSearchSource.ts @@ -54,6 +54,18 @@ export type MessageSearchSourceFilterBuilderOptions< >; }>; +export type MessageSearchSourceOptions = SearchSourceOptions & { + /** Static base filters for the channel scope of the message search. */ + messageSearchChannelFilters?: ChannelFilters; + /** Static base filters for the message search itself. */ + messageSearchFilters?: MessageFilters; + messageSearchSort?: SearchMessageSort; + /** Static base filters for the follow-up query that hydrates unknown channels. */ + channelQueryFilters?: ChannelFilters; + channelQuerySort?: ChannelSort; + channelQueryOptions?: Omit; +}; + export class MessageSearchSource< TContexts extends MessageSearchSourceContexts = {}, > extends BaseSearchSource { @@ -86,11 +98,26 @@ export class MessageSearchSource< constructor( client: StreamChat, - options?: SearchSourceOptions, + options?: MessageSearchSourceOptions, filterBuilderOptions?: MessageSearchSourceFilterBuilderOptions, ) { - super(options); + const { + messageSearchChannelFilters, + messageSearchFilters, + messageSearchSort, + channelQueryFilters, + channelQuerySort, + channelQueryOptions, + ...restOptions + } = options || {}; + super(restOptions); this.client = client; + this.messageSearchChannelFilters = messageSearchChannelFilters; + this.messageSearchFilters = messageSearchFilters; + this.messageSearchSort = messageSearchSort; + this.channelQueryFilters = channelQueryFilters; + this.channelQuerySort = channelQuerySort; + this.channelQueryOptions = channelQueryOptions; this.messageSearchChannelFilterBuilder = new FilterBuilder< ChannelFilters, diff --git a/src/search/UserSearchSource.ts b/src/search/UserSearchSource.ts index e53371f0cf..baee5416c7 100644 --- a/src/search/UserSearchSource.ts +++ b/src/search/UserSearchSource.ts @@ -10,6 +10,13 @@ export type UserSearchSourceFilterBuilderContext< C extends CustomContext = CustomContext, > = { searchQuery?: string } & C; +export type UserSearchSourceOptions = SearchSourceOptions & { + /** Static base filters merged under the dynamically generated ones. */ + filters?: UserFilters; + sort?: UserSort; + searchOptions?: Omit; +}; + export class UserSearchSource< TFilterContext extends CustomContext = CustomContext, > extends BaseSearchSource { @@ -25,14 +32,18 @@ export class UserSearchSource< constructor( client: StreamChat, - options?: SearchSourceOptions, + options?: UserSearchSourceOptions, filterBuilderOptions: FilterBuilderOptions< UserFilters, UserSearchSourceFilterBuilderContext > = {}, ) { - super(options); + const { filters, sort, searchOptions, ...restOptions } = options || {}; + super(restOptions); this.client = client; + this.filters = filters; + this.sort = sort; + this.searchOptions = searchOptions; this.filterBuilder = new FilterBuilder< UserFilters, UserSearchSourceFilterBuilderContext diff --git a/test/unit/search/searchSourceConstructorOptions.test.ts b/test/unit/search/searchSourceConstructorOptions.test.ts new file mode 100644 index 0000000000..fb5bd3e39c --- /dev/null +++ b/test/unit/search/searchSourceConstructorOptions.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { Channel } from '../../../src/channel'; +import { ChannelMemberSearchSource } from '../../../src/search/ChannelMemberSearchSource'; +import { ChannelSearchSource } from '../../../src/search/ChannelSearchSource'; +import { MessageSearchSource } from '../../../src/search/MessageSearchSource'; +import { UserSearchSource } from '../../../src/search/UserSearchSource'; +import { MentionsSearchSource } from '../../../src/messageComposer/middleware/textComposer/mentions'; +import { getClientWithUser } from '../test-utils/getClient'; + +const channelStub = (client: unknown) => + ({ + getClient: () => client, + state: { members: {}, watchers: {} }, + queryMembers: vi.fn(), + }) as unknown as Channel; + +describe('search source query parameters as constructor options', () => { + it('seeds ChannelSearchSource', () => { + const client = getClientWithUser({ id: 'u1' }); + const source = new ChannelSearchSource(client, { + filters: { type: 'messaging' }, + searchOptions: { presence: true }, + sort: { last_message_at: -1 }, + }); + + expect(source.filters).toEqual({ type: 'messaging' }); + expect(source.sort).toEqual({ last_message_at: -1 }); + expect(source.searchOptions).toEqual({ presence: true }); + }); + + it('seeds UserSearchSource', () => { + const client = getClientWithUser({ id: 'u1' }); + const source = new UserSearchSource(client, { + filters: { role: { $eq: 'admin' } }, + sort: [{ name: 1 }], + }); + + expect(source.filters).toEqual({ role: { $eq: 'admin' } }); + expect(source.sort).toEqual([{ name: 1 }]); + }); + + it('seeds ChannelMemberSearchSource', () => { + const client = getClientWithUser({ id: 'u1' }); + const source = new ChannelMemberSearchSource(channelStub(client), { + filters: { user_id: 'user-2' }, + sort: [{ user_id: 1 }], + }); + + expect(source.filters).toEqual({ user_id: 'user-2' }); + expect(source.sort).toEqual([{ user_id: 1 }]); + }); + + it('seeds all six MessageSearchSource parameters', () => { + const client = getClientWithUser({ id: 'u1' }); + const source = new MessageSearchSource(client, { + channelQueryFilters: { type: 'team' }, + channelQueryOptions: { presence: false }, + channelQuerySort: { last_message_at: -1 }, + messageSearchChannelFilters: { type: 'messaging' }, + messageSearchFilters: { type: 'regular' }, + messageSearchSort: { created_at: 1 }, + }); + + expect(source.messageSearchChannelFilters).toEqual({ type: 'messaging' }); + expect(source.messageSearchFilters).toEqual({ type: 'regular' }); + expect(source.messageSearchSort).toEqual({ created_at: 1 }); + expect(source.channelQueryFilters).toEqual({ type: 'team' }); + expect(source.channelQuerySort).toEqual({ last_message_at: -1 }); + expect(source.channelQueryOptions).toEqual({ presence: false }); + }); + + it('seeds MentionsSearchSource user and member parameters', () => { + const client = getClientWithUser({ id: 'u1' }); + const source = new MentionsSearchSource(channelStub(client), { + memberFilters: { user_id: 'user-2' }, + memberSort: [{ user_id: 1 }], + userFilters: { role: { $eq: 'admin' } }, + userSort: [{ name: 1 }], + }); + + expect(source.userFilters).toEqual({ role: { $eq: 'admin' } }); + expect(source.userSort).toEqual([{ name: 1 }]); + expect(source.memberFilters).toEqual({ user_id: 'user-2' }); + expect(source.memberSort).toEqual([{ user_id: 1 }]); + }); + + it('leaves query parameters undefined when no options are given', () => { + const client = getClientWithUser({ id: 'u1' }); + const source = new ChannelSearchSource(client); + + expect(source.filters).toBeUndefined(); + expect(source.sort).toBeUndefined(); + expect(source.searchOptions).toBeUndefined(); + }); + + it('still honours the SearchSourceOptions passed alongside them', () => { + const client = getClientWithUser({ id: 'u1' }); + const source = new ChannelSearchSource(client, { + filters: { type: 'messaging' }, + pageSize: 42, + }); + + expect(source.pageSize).toBe(42); + expect(source.filters).toEqual({ type: 'messaging' }); + }); + + it('keeps property assignment working as the way to change them later', () => { + const client = getClientWithUser({ id: 'u1' }); + const source = new ChannelSearchSource(client, { filters: { type: 'messaging' } }); + + source.filters = { type: 'team' }; + + expect(source.filters).toEqual({ type: 'team' }); + }); +}); From 907e37a9da810e6870a520d258e12e5225fba1aa Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Fri, 14 Aug 2026 12:41:43 +0200 Subject: [PATCH 6/6] refactor(search): narrow MentionsSearchSource config and drop redundant 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. --- src/client.ts | 4 ++-- .../middleware/textComposer/mentions.ts | 13 ++++++------- src/search/ChannelMemberSearchSource.ts | 9 ++------- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/client.ts b/src/client.ts index 7c0a2e706c..95706f2563 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1931,7 +1931,7 @@ export class StreamChat { * searchUserGroups - Search user groups by prefix for autocomplete * * @param {SearchUserGroupsOptions} options The search options - * @param requestOptions the api request options + * @param {RequestOptions} [requestOptions] Carries an abort signal. Not sent in the request. * @return {Promise} User Group Search Response */ async searchUserGroups( @@ -2078,7 +2078,7 @@ export class StreamChat { * @param {ChannelSort} [sort] Sort options, for instance {created_at: -1}. * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{last_updated: -1}, {created_at: 1}] * @param {ChannelOptions} [options] Options object. Can include predefined_filter, filter_values, and sort_values for using predefined filters. - * @param requestOptions the api request options. + * @param {RequestOptions} [requestOptions] Carries an abort signal. Not sent in the request. * * @return {Promise} full search channels response */ diff --git a/src/messageComposer/middleware/textComposer/mentions.ts b/src/messageComposer/middleware/textComposer/mentions.ts index 038d8af516..47599fec62 100644 --- a/src/messageComposer/middleware/textComposer/mentions.ts +++ b/src/messageComposer/middleware/textComposer/mentions.ts @@ -310,7 +310,10 @@ export class MentionsSearchSource extends BaseSearchSource { userSort: UserSort | undefined; memberSort: MemberSort | undefined; // todo: document there are filters and sort options for users and members searchOptions: Omit | undefined; - config: MentionsSearchSourceOptions; + config: Pick< + MentionsSearchSourceOptions, + 'mentionAllAppUsers' | 'suggestionFactoryMappers' | 'textComposerText' | 'trigger' + >; constructor(channel: Channel, options?: MentionsSearchSourceOptions) { const { @@ -326,7 +329,8 @@ export class MentionsSearchSource extends BaseSearchSource { userSort, ...restOptions } = options || {}; - super(restOptions); + // suggestions are shown for a bare trigger, so the empty query must be allowed + super({ ...restOptions, allowEmptySearchString: true }); this.client = channel.getClient(); this.channel = channel; this.userFilters = userFilters; @@ -446,11 +450,6 @@ export class MentionsSearchSource extends BaseSearchSource { }; } - canExecuteQuery = (newSearchString?: string) => { - const hasNewSearchQuery = typeof newSearchString !== 'undefined'; - return this.isActive && this.canDispatchQuery(hasNewSearchQuery); - }; - protected updatePaginationStateFromQuery() { const userPaginationState = this.latestUserPaginationState ?? { itemCount: 0 }; diff --git a/src/search/ChannelMemberSearchSource.ts b/src/search/ChannelMemberSearchSource.ts index 2d310418c5..f4abe92d0c 100644 --- a/src/search/ChannelMemberSearchSource.ts +++ b/src/search/ChannelMemberSearchSource.ts @@ -44,7 +44,8 @@ export class ChannelMemberSearchSource< > = {}, ) { const { filters, sort, searchOptions, ...restOptions } = options || {}; - super(restOptions); + // members are listed with an empty query, so the initial load must be allowed + super({ ...restOptions, allowEmptySearchString: true }); this.channel = channel; this.filters = filters; this.sort = sort; @@ -71,12 +72,6 @@ export class ChannelMemberSearchSource< }); } - canExecuteQuery = (newSearchString?: string) => { - const hasNewSearchQuery = typeof newSearchString !== 'undefined'; - - return this.isActive && this.canDispatchQuery(hasNewSearchQuery); - }; - protected async query(searchQuery: string, queryOptions: SearchQueryOptions = {}) { const filters = this.filterBuilder.buildFilters({ baseFilters: this.filters,