Skip to content

Commit 4626e70

Browse files
committed
feat(core): cancel and dispose unified search requests
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
1 parent a2057db commit 4626e70

2 files changed

Lines changed: 104 additions & 23 deletions

File tree

core/src/services/UnifiedSearchController.ts

Lines changed: 60 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,24 @@ type CategorySearchItem = {
1111
hasMore: boolean
1212
}
1313

14+
/**
15+
* Runs a unified search across categories in priority order, blocking
16+
* lower-priority results until their predecessors arrive or a timer reveals them.
17+
*/
1418
export class UnifiedSearchController {
1519
private searchItems: Record<string, CategorySearchItem> = {}
1620
private requestId: number = 0
1721
private revealTimer: ReturnType<typeof setTimeout> | null = null
18-
22+
private searchAbortHandlers: (() => void)[] = []
23+
24+
/**
25+
* Start a search. Cancels and replaces any search already in flight.
26+
*
27+
* @param query the search term
28+
* @param categories category ids in priority order
29+
*/
1930
search(query: string, categories: string[]): void {
31+
this.cancelPendingRequests()
2032
this.searchItems = {}
2133
this.requestId++
2234
const dispatchId = this.requestId
@@ -30,12 +42,14 @@ export class UnifiedSearchController {
3042
cursor: null,
3143
hasMore: false,
3244
}
33-
const { request } = unifiedSearch({
45+
const { request, cancel } = unifiedSearch({
3446
type: category,
3547
query,
3648
cursor: null,
3749
})
3850

51+
this.searchAbortHandlers.push(cancel)
52+
3953
request().then((response) => {
4054
if (this.requestId !== dispatchId) {
4155
// A new search has been started, ignore this result
@@ -66,7 +80,24 @@ export class UnifiedSearchController {
6680
})
6781
}
6882

69-
reconcileCategoryStatuses(categories: string[]): void {
83+
/**
84+
* A shallow copy of the current per-category state, safe to read for rendering.
85+
*
86+
* @return the current search items keyed by category id
87+
*/
88+
getSnapshot(): Record<string, CategorySearchItem> {
89+
return { ...this.searchItems }
90+
}
91+
92+
/**
93+
* Tear down on unmount: cancels in-flight requests and stops the reveal timer.
94+
*/
95+
dispose(): void {
96+
this.cancelPendingRequests()
97+
this.stopRevealTimer()
98+
}
99+
100+
private reconcileCategoryStatuses(categories: string[]): void {
70101
categories.forEach((category) => {
71102
if (['loading', 'failed'].includes(this.searchItems[category].status)) {
72103
return
@@ -75,15 +106,39 @@ export class UnifiedSearchController {
75106
})
76107
}
77108

78-
unblockAllCategories(categories: string[]): void {
109+
private startRevealTimer(): void {
110+
this.stopRevealTimer()
111+
this.revealTimer = setTimeout(() => {
112+
const categories = Object.keys(this.searchItems)
113+
const hasPendingCategories = categories.some((category) => ['loading', 'blocked'].includes(this.searchItems[category].status))
114+
this.unblockAllCategories(categories)
115+
if (hasPendingCategories) {
116+
this.startRevealTimer()
117+
}
118+
}, REVEAL_INTERVAL)
119+
}
120+
121+
private stopRevealTimer(): void {
122+
if (this.revealTimer) {
123+
clearTimeout(this.revealTimer)
124+
this.revealTimer = null
125+
}
126+
}
127+
128+
private cancelPendingRequests(): void {
129+
this.searchAbortHandlers.forEach((cancel) => cancel())
130+
this.searchAbortHandlers = []
131+
}
132+
133+
private unblockAllCategories(categories: string[]): void {
79134
categories.forEach((category) => {
80135
if (this.searchItems[category].status === 'blocked') {
81136
this.searchItems[category].status = 'loaded'
82137
}
83138
})
84139
}
85140

86-
categoryShouldBlock(category: string, categories: string[]): boolean {
141+
private categoryShouldBlock(category: string, categories: string[]): boolean {
87142
const categoryItem = this.searchItems[category]
88143
if (!categoryItem) {
89144
return false
@@ -94,22 +149,4 @@ export class UnifiedSearchController {
94149
return item && ['loading', 'blocked'].includes(item.status)
95150
})
96151
}
97-
98-
startRevealTimer(): void {
99-
if (this.revealTimer) {
100-
clearTimeout(this.revealTimer)
101-
}
102-
this.revealTimer = setTimeout(() => {
103-
const categories = Object.keys(this.searchItems)
104-
const hasPendingCategories = categories.some((category) => ['loading', 'blocked'].includes(this.searchItems[category].status))
105-
this.unblockAllCategories(categories)
106-
if (hasPendingCategories) {
107-
this.startRevealTimer()
108-
}
109-
}, REVEAL_INTERVAL)
110-
}
111-
112-
getSnapshot(): Record<string, CategorySearchItem> {
113-
return { ...this.searchItems }
114-
}
115152
}

core/src/tests/services/UnifiedSearchController.spec.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,50 @@ describe('UnifiedSearchController', () => {
214214
})
215215
})
216216

217+
describe('cancellation', () => {
218+
it('cancels the previous search\'s in-flight requests when a new search starts', () => {
219+
const first = mockProviders(['files', 'talk'])
220+
221+
const searchController = new UnifiedSearchController()
222+
searchController.search('first', ['files', 'talk'])
223+
224+
// A new search supersedes the first while its requests are in flight.
225+
mockProviders(['files', 'talk'])
226+
searchController.search('second', ['files', 'talk'])
227+
228+
expect(first.files.cancel).toHaveBeenCalledOnce()
229+
expect(first.talk.cancel).toHaveBeenCalledOnce()
230+
})
231+
})
232+
233+
describe('dispose', () => {
234+
it('cancels in-flight requests when disposed', () => {
235+
const providers = mockProviders(['files', 'talk'])
236+
237+
const searchController = new UnifiedSearchController()
238+
searchController.search('query', ['files', 'talk'])
239+
240+
searchController.dispose()
241+
242+
expect(providers.files.cancel).toHaveBeenCalledOnce()
243+
expect(providers.talk.cancel).toHaveBeenCalledOnce()
244+
})
245+
246+
it('stops the reveal timer when disposed', () => {
247+
mockProviders(['files', 'talk'])
248+
249+
const searchController = new UnifiedSearchController()
250+
searchController.search('query', ['files', 'talk'])
251+
252+
// A search arms the reveal timer.
253+
expect(vi.getTimerCount()).toBe(1)
254+
255+
searchController.dispose()
256+
257+
expect(vi.getTimerCount()).toBe(0)
258+
})
259+
})
260+
217261
describe('reveal timer', () => {
218262
it('marks blocked categories as loaded after a certain amount of time has elapsed', async () => {
219263
const providers = mockProviders(['files', 'talk', 'deck'])

0 commit comments

Comments
 (0)