Skip to content

Commit 5e99bf7

Browse files
committed
feat: implement own debounce and useSearchQueriesInProgress hook
1 parent c9c4430 commit 5e99bf7

File tree

4 files changed

+144
-38
lines changed

4 files changed

+144
-38
lines changed

src/experimental/Search/SearchBar/SearchBar.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import clsx from 'clsx';
22
import React, { useCallback, useEffect } from 'react';
33
import { useSearchContext } from '../SearchContext';
4+
import { useSearchQueriesInProgress } from '../hooks';
45
import { useTranslationContext } from '../../../context';
56
import { useStateStore } from '../../../store';
67
import type { SearchControllerState } from '../SearchController';
78

89
const searchControllerStateSelector = (nextValue: SearchControllerState) => ({
910
input: nextValue.input,
1011
isActive: nextValue.isActive,
11-
queriesInProgress: nextValue.queriesInProgress,
1212
searchQuery: nextValue.searchQuery,
1313
});
1414

@@ -21,8 +21,9 @@ export const SearchBar = () => {
2121
placeholder,
2222
searchController,
2323
} = useSearchContext();
24+
const queriesInProgress = useSearchQueriesInProgress(searchController);
2425

25-
const { input, isActive, queriesInProgress, searchQuery } = useStateStore(
26+
const { input, isActive, searchQuery } = useStateStore(
2627
searchController.state,
2728
searchControllerStateSelector,
2829
);

src/experimental/Search/SearchController.ts

Lines changed: 86 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import debounce from 'lodash.debounce';
21
import { StateStore } from 'stream-chat';
32
import type {
43
Channel,
@@ -16,7 +15,82 @@ import type {
1615
UserSort,
1716
} from 'stream-chat';
1817
import type { DefaultStreamChatGenerics } from '../../types';
19-
import type { DebouncedFunc } from 'lodash';
18+
19+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
20+
interface DebouncedFunc<T extends (...args: any[]) => any> {
21+
/**
22+
* Call the original function, but applying the debounce rules.
23+
*
24+
* If the debounced function can be run immediately, this calls it and returns its return
25+
* value.
26+
*
27+
* Otherwise, it returns the return value of the last invocation, or undefined if the debounced
28+
* function was not invoked yet.
29+
*/
30+
(...args: Parameters<T>): ReturnType<T> | undefined;
31+
32+
/**
33+
* Throw away any pending invocation of the debounced function.
34+
*/
35+
cancel(): void;
36+
37+
/**
38+
* If there is a pending invocation of the debounced function, invoke it immediately and return
39+
* its return value.
40+
*
41+
* Otherwise, return the value from the last invocation, or undefined if the debounced function
42+
* was never invoked.
43+
*/
44+
flush(): ReturnType<T> | undefined;
45+
}
46+
47+
// works exactly the same as lodash.debounce
48+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
49+
export const debounce = <T extends (...args: any[]) => any>(
50+
fn: T,
51+
timeout = 0,
52+
{ leading = false, trailing = true }: { leading?: boolean; trailing?: boolean } = {},
53+
): DebouncedFunc<T> => {
54+
let runningTimeout: null | NodeJS.Timeout = null;
55+
let argsForTrailingExecution: Parameters<T> | null = null;
56+
let lastResult: ReturnType<T> | undefined;
57+
58+
const debouncedFn = (...args: Parameters<T>) => {
59+
if (runningTimeout) {
60+
clearTimeout(runningTimeout);
61+
} else if (leading) {
62+
lastResult = fn(...args);
63+
}
64+
if (trailing) argsForTrailingExecution = args;
65+
66+
const timeoutHandler = () => {
67+
if (argsForTrailingExecution) {
68+
lastResult = fn(...argsForTrailingExecution);
69+
argsForTrailingExecution = null;
70+
}
71+
runningTimeout = null;
72+
};
73+
74+
runningTimeout = setTimeout(timeoutHandler, timeout);
75+
return lastResult;
76+
};
77+
78+
debouncedFn.cancel = () => {
79+
if (runningTimeout) clearTimeout(runningTimeout);
80+
};
81+
82+
debouncedFn.flush = () => {
83+
if (runningTimeout) {
84+
clearTimeout(runningTimeout);
85+
runningTimeout = null;
86+
if (argsForTrailingExecution) {
87+
lastResult = fn(...argsForTrailingExecution);
88+
}
89+
}
90+
return lastResult;
91+
};
92+
return debouncedFn;
93+
};
2094

2195
// eslint-disable-next-line @typescript-eslint/ban-types
2296
export type SearchSourceType = 'channels' | 'users' | 'messages' | (string & {});
@@ -67,7 +141,7 @@ export type SearchSourceOptions = {
67141
};
68142

69143
const DEFAULT_SEARCH_SOURCE_OPTIONS: Required<SearchSourceOptions> = {
70-
debounceMs: 300,
144+
debounceMs: 5000,
71145
isActive: false,
72146
pageSize: 10,
73147
} as const;
@@ -151,6 +225,15 @@ export abstract class BaseSearchSource<T> implements SearchSource<T> {
151225
};
152226

153227
async executeQuery(searchQuery: string) {
228+
const hasNewSearchQuery = typeof searchQuery !== 'undefined';
229+
if (!this.isActive || this.isLoading || !this.hasMore || !searchQuery) return;
230+
231+
if (hasNewSearchQuery) {
232+
this.resetState({ isActive: this.isActive, isLoading: true, searchQuery });
233+
} else {
234+
this.state.partialNext({ isLoading: true });
235+
}
236+
154237
const stateUpdate: Partial<SearchSourceState<T>> = {};
155238
try {
156239
const results = await this.query(searchQuery);
@@ -180,21 +263,6 @@ export abstract class BaseSearchSource<T> implements SearchSource<T> {
180263
}
181264

182265
search = async (searchQuery?: string) => {
183-
if (!this.isActive) return;
184-
const hasNewSearchQuery = typeof searchQuery !== 'undefined';
185-
const preventLoadMore =
186-
(!hasNewSearchQuery && !this.hasMore) ||
187-
this.isLoading ||
188-
(!hasNewSearchQuery && !this.searchQuery);
189-
const preventSearchStart = hasNewSearchQuery && this.isLoading;
190-
if (preventLoadMore || preventSearchStart) return;
191-
192-
if (hasNewSearchQuery) {
193-
this.resetState({ isActive: this.isActive, isLoading: true, searchQuery });
194-
} else {
195-
this.state.partialNext({ isLoading: true });
196-
}
197-
198266
await new Promise((resolve) => {
199267
this.resolveDebouncedSearch = resolve;
200268
this.searchDebounced(searchQuery ?? this.searchQuery);
@@ -416,7 +484,6 @@ export type SearchControllerState<
416484
Sources extends SearchSource[] = DefaultSearchSources<StreamChatGenerics>
417485
> = {
418486
isActive: boolean;
419-
queriesInProgress: Array<Sources[number]['type']>;
420487
searchQuery: string;
421488
sources: Sources;
422489
// FIXME: focusedMessage should live in a MessageListController class that does not exist yet.
@@ -448,7 +515,6 @@ export class SearchController<
448515
constructor({ config, sources }: SearchControllerOptions<StreamChatGenerics, Sources> = {}) {
449516
this.state = new StateStore<SearchControllerState<StreamChatGenerics, Sources>>({
450517
isActive: false,
451-
queriesInProgress: [],
452518
searchQuery: '',
453519
sources: sources ?? (([] as unknown) as Sources),
454520
});
@@ -470,10 +536,6 @@ export class SearchController<
470536
return this.state.getLatestValue().isActive;
471537
}
472538

473-
get queriesInProgress() {
474-
return this.state.getLatestValue().queriesInProgress;
475-
}
476-
477539
get searchQuery() {
478540
return this.state.getLatestValue().searchQuery;
479541
}
@@ -482,14 +544,6 @@ export class SearchController<
482544
return this.sources.map((s) => s.type) as Sources[number]['type'][];
483545
}
484546

485-
get isCleared() {
486-
return this.activeSources.every((s) => !s.hasResults && !s.isLoading && !s.searchQuery);
487-
}
488-
489-
get isLoading() {
490-
return this.state.getLatestValue().queriesInProgress.length > 0;
491-
}
492-
493547
setInputElement = (input: HTMLInputElement) => {
494548
this.state.partialNext({ input });
495549
};
@@ -558,13 +612,9 @@ export class SearchController<
558612
search = async (searchQuery?: string) => {
559613
const searchedSources = this.activeSources;
560614
this.state.partialNext({
561-
queriesInProgress: searchedSources.map((s) => s.type),
562615
searchQuery,
563616
});
564617
await Promise.all(searchedSources.map((source) => source.search(searchQuery)));
565-
this.state.partialNext({
566-
queriesInProgress: [],
567-
});
568618
};
569619

570620
cancelSearchQueries = () => {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './useSearchQueriesInProgress';
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {
2+
DefaultSearchSources,
3+
SearchController,
4+
SearchControllerState,
5+
SearchSource,
6+
} from '../SearchController';
7+
import { useEffect, useState } from 'react';
8+
import type { DefaultStreamChatGenerics } from '../../../types';
9+
import { useStateStore } from '../../../store';
10+
11+
const searchControllerStateSelector = <
12+
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
13+
Sources extends SearchSource[] = DefaultSearchSources<StreamChatGenerics>
14+
>(
15+
value: SearchControllerState<StreamChatGenerics, Sources>,
16+
) => ({
17+
sources: value.sources,
18+
});
19+
20+
export type UseSearchQueriesInProgressParams<
21+
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
22+
Sources extends SearchSource[] = DefaultSearchSources<StreamChatGenerics>
23+
> = {
24+
searchController: SearchController<StreamChatGenerics, Sources>;
25+
};
26+
27+
export const useSearchQueriesInProgress = <
28+
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
29+
Sources extends SearchSource[] = DefaultSearchSources<StreamChatGenerics>
30+
>(
31+
searchController: SearchController<StreamChatGenerics, Sources>,
32+
) => {
33+
const [queriesInProgress, setQueriesInProgress] = useState<string[]>([]);
34+
const { sources } = useStateStore(searchController.state, searchControllerStateSelector);
35+
36+
useEffect(() => {
37+
const subscriptions = sources.map((source) =>
38+
source.state.subscribeWithSelector(
39+
(value) => ({ isLoading: value.isLoading }),
40+
({ isLoading }) => {
41+
setQueriesInProgress((prev) => {
42+
if (isLoading) return prev.concat(source.type);
43+
return prev.filter((type) => type !== source.type);
44+
});
45+
},
46+
),
47+
);
48+
49+
return () => {
50+
subscriptions.forEach((unsubscribe) => unsubscribe());
51+
};
52+
}, [sources]);
53+
return queriesInProgress;
54+
};

0 commit comments

Comments
 (0)