Skip to content

Commit a7ca899

Browse files
authored
Merge pull request #63003 from nextcloud/fix/search-reveal-order
fix(core): never insert a unified search result above a rendered one
2 parents 0100d15 + 6645d55 commit a7ca899

8 files changed

Lines changed: 533 additions & 108 deletions

File tree

core/src/components/UnifiedSearch/UnifiedSearchModal.vue

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -376,11 +376,12 @@ export default defineComponent({
376376
const searchStore = useSearchStore()
377377
const isSmallMobile = useIsSmallMobile()
378378
379-
const { searchStates, search, loadMore, reset } = useUnifiedSearch()
379+
const { searchStates, revealOrder, search, loadMore, reset } = useUnifiedSearch()
380380
381381
return {
382382
t,
383383
searchStates,
384+
revealOrder,
384385
search,
385386
loadMore,
386387
reset,
@@ -548,18 +549,19 @@ export default defineComponent({
548549
.filter((filter) => filter.type !== 'provider')
549550
.map((filter) => filter.type)
550551
551-
return Object.entries(this.searchStates)
552-
.filter(([, state]) => state.entries.length > 0 && (state.status === 'loaded' || state.status === 'loading'))
553-
.map(([providerId, state]) => {
554-
const provider = this.providers.find((p) => p.id === providerId)
555-
const supportsActiveFilters = this.providerIsCompatibleWithFilters(provider, contentFilterTypes)
556-
return {
557-
...provider,
558-
results: state.entries,
559-
hasMore: state.hasMore,
560-
supportsActiveFilters,
561-
}
562-
})
552+
// Category order and category-level visibility are the controller's, see
553+
// getRevealOrder(). Do not re-derive or re-sort them here.
554+
return this.revealOrder.map((providerId) => {
555+
const state = this.searchStates[providerId]
556+
const provider = this.providers.find((p) => p.id === providerId)
557+
const supportsActiveFilters = this.providerIsCompatibleWithFilters(provider, contentFilterTypes)
558+
return {
559+
...provider,
560+
results: state.entries,
561+
hasMore: state.hasMore,
562+
supportsActiveFilters,
563+
}
564+
})
563565
},
564566
565567
filteredResults() {
@@ -615,6 +617,10 @@ export default defineComponent({
615617
// two can't drift (a11y invariant). Aggregate: filtered then partial-match groups,
616618
// capped to RESULTS_PER_CATEGORY with `overflow` when there's more. Detail: the
617619
// opened category alone, uncapped.
620+
//
621+
// This partition is a second ordering axis, so reveal order holds *within* a section,
622+
// not across the two: with content filters active, a filter-compatible category that
623+
// lands late still renders above an already-shown partial match.
618624
renderedGroups() {
619625
if (this.detailCategory) {
620626
return this.detailGroup
@@ -763,9 +769,7 @@ export default defineComponent({
763769
// when closed (e.g. the local search bar on deck), so a hidden modal must
764770
// not fire background searches.
765771
if (this.open) {
766-
// Mark busy synchronously so the debounce window doesn't flash the empty state.
767-
this.pendingSearch = true
768-
this.debouncedFind(this.searchQuery)
772+
this.scheduleSearch()
769773
}
770774
},
771775
},
@@ -948,6 +952,18 @@ export default defineComponent({
948952
this.$emit('update:open', false)
949953
},
950954
955+
/**
956+
* Blank the results, then queue the search. Every query and filter change comes through
957+
* here. The results on screen answer the previous question, so holding them until the
958+
* debounce fires only means they shift once the real ones land.
959+
*/
960+
scheduleSearch() {
961+
this.reset()
962+
// Mark busy synchronously so the debounce window doesn't flash the empty state.
963+
this.pendingSearch = true
964+
this.debouncedFind(this.searchQuery)
965+
},
966+
951967
find(query: string) {
952968
// The debounced search is running now; from here `searching` (or `!initialized`) drives busy.
953969
this.pendingSearch = false
@@ -1047,7 +1063,7 @@ export default defineComponent({
10471063
this.filters[existingPersonFilter].name = person.displayName
10481064
}
10491065
1050-
this.debouncedFind(this.searchQuery)
1066+
this.scheduleSearch()
10511067
unifiedSearchLogger.debug('Person filter applied', { person })
10521068
},
10531069
@@ -1155,7 +1171,7 @@ export default defineComponent({
11551171
})
11561172
this.filters = this.syncProviderFilters(this.filters, this.filteredProviders)
11571173
unifiedSearchLogger.debug('Search filters (newly added)', { filters: this.filters })
1158-
this.debouncedFind(this.searchQuery)
1174+
this.scheduleSearch()
11591175
},
11601176
11611177
removeFilter(filter) {
@@ -1177,7 +1193,7 @@ export default defineComponent({
11771193
}
11781194
}
11791195
}
1180-
this.debouncedFind(this.searchQuery)
1196+
this.scheduleSearch()
11811197
},
11821198
11831199
syncProviderFilters(firstArray, secondArray) {
@@ -1213,7 +1229,7 @@ export default defineComponent({
12131229
this.filters.push(this.dateFilter)
12141230
}
12151231
1216-
this.debouncedFind(this.searchQuery)
1232+
this.scheduleSearch()
12171233
},
12181234
12191235
applyQuickDateRange(range) {
@@ -1295,7 +1311,7 @@ export default defineComponent({
12951311
break
12961312
}
12971313
}
1298-
this.debouncedFind(this.searchQuery)
1314+
this.scheduleSearch()
12991315
},
13001316
13011317
groupProvidersByApp(filters) {

core/src/composables/useUnifiedSearch.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@ import { UnifiedSearchController } from '../services/UnifiedSearchController.ts'
1313
*/
1414
export function useUnifiedSearch() {
1515
const searchStates = shallowRef<Record<string, CategorySearchState>>({})
16+
const revealOrder = shallowRef<string[]>([])
1617

1718
const controller = new UnifiedSearchController((states) => {
19+
// Both assigned here, never separately: the view reads one against the other.
1820
searchStates.value = states
21+
revealOrder.value = controller.getRevealOrder()
1922
})
2023

2124
onUnmounted(() => {
@@ -24,6 +27,7 @@ export function useUnifiedSearch() {
2427

2528
return {
2629
searchStates,
30+
revealOrder,
2731
search: controller.search.bind(controller),
2832
loadMore: controller.loadMore.bind(controller),
2933
reset: controller.reset.bind(controller),

core/src/services/UnifiedSearchController.ts

Lines changed: 78 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -23,22 +23,42 @@ export interface CategorySearchParams {
2323
extraQueries?: object
2424
}
2525

26-
export const REVEAL_INTERVAL_MS = 1500
26+
export const REVEAL_INTERVAL_MS = 1000
2727

2828
/**
2929
* Results fetched per category per page. Sized for the detail view (which shows the
3030
* whole page); the aggregate caps to RESULTS_PER_CATEGORY. Server default 5, design 10.
3131
*/
3232
export const PAGE_SIZE = 10
3333

34+
/**
35+
* Whether a category has anything for the user to look at. Blocked is deliberately withheld
36+
* and failed carries no entries. Loading counts because paging keeps the pages already
37+
* fetched on screen while the next one is in flight; a new query has no entries to show, so
38+
* it reads as not visible until results actually land.
39+
*
40+
* Exported so the one definition also serves the Vue-side test doubles; the controller is
41+
* the only place that decides category-level visibility.
42+
*
43+
* @param state the category state to test
44+
*/
45+
export function isCategoryVisible(state: CategorySearchState): boolean {
46+
return state.entries.length > 0 && (state.status === 'loaded' || state.status === 'loading')
47+
}
48+
3449
/**
3550
* Runs a unified search across categories in priority order, blocking
3651
* lower-priority results until their predecessors arrive or a timer reveals them.
52+
*
53+
* Priority decides who waits for whom. It has no say over what is already on screen:
54+
* see `getRevealOrder()`.
3755
*/
3856
export class UnifiedSearchController {
3957
private query: string = ''
4058
private params: Record<string, CategorySearchParams> = {}
4159
private searchStates: Record<string, CategorySearchState> = {}
60+
private revealOrder: string[] = []
61+
private revealWindowOpen: boolean = false
4262
private searchGeneration: number = 0
4363
private revealTimer: ReturnType<typeof setTimeout> | null = null
4464
private pendingCancels: (() => void)[] = []
@@ -55,26 +75,20 @@ export class UnifiedSearchController {
5575
*/
5676
async search(query: string, categories: string[], params?: Record<string, CategorySearchParams>): Promise<void> {
5777
this.cancelPendingRequests()
58-
// Stale-while-revalidate: keep the previous page on screen while the new search is in
59-
// flight, so refining a query swaps results in place instead of flashing an empty panel.
60-
// Each recurring category is reseeded with its prior entries below; dropped ones vanish.
61-
const previous = this.searchStates
78+
// A new query hides everything the last one produced. Carrying results over would only
79+
// let them shift under the user once the real ones land, and the results are about to
80+
// differ anyway. So each search is a clean slate: empty screen, then a fresh ordered
81+
// reveal from priority order. Nothing is on screen, so nothing can be displaced.
6282
this.searchStates = {}
83+
this.revealOrder = []
6384
this.searchGeneration++
6485
const generation = this.searchGeneration
6586
this.query = query
6687
this.params = params || {}
6788

6889
this.startRevealTimer()
6990

70-
await Promise.allSettled(categories.map((category) => {
71-
const prev = previous[category]
72-
// Only entries that were actually on screen seed the stale view. A blocked or failed
73-
// category's entries were fetched but never rendered, so they must not carry over
74-
// (and must not let the category skip the ordered reveal).
75-
const staleEntries = prev && (prev.status === 'loaded' || prev.status === 'loading') ? prev.entries : []
76-
return this.searchCategory(category, generation, categories, staleEntries)
77-
}))
91+
await Promise.allSettled(categories.map((category) => this.searchCategory(category, generation, categories)))
7892
}
7993

8094
/**
@@ -137,13 +151,31 @@ export class UnifiedSearchController {
137151
return { ...this.searchStates }
138152
}
139153

154+
/**
155+
* The ids of the categories currently on screen, in display order.
156+
*
157+
* Append-only within a search, so a category never moves up into a slot another one already
158+
* occupies: a result that arrives late renders below what the user is already reading,
159+
* however high its priority. A new query starts over from priority order, since it clears
160+
* the screen first and so has nothing to displace. Read this rather than the snapshot's key
161+
* order, which is the priority order and an input to blocking, not a rendering order.
162+
*
163+
* Only ever names categories the current snapshot holds, so a caller can map without guarding.
164+
*
165+
* @return visible category ids, top to bottom
166+
*/
167+
getRevealOrder(): string[] {
168+
return [...this.revealOrder]
169+
}
170+
140171
dispose(): void {
141172
this.stopBackgroundWork()
142173
}
143174

144175
reset(): void {
145176
this.stopBackgroundWork()
146177
this.searchStates = {}
178+
this.revealOrder = []
147179
this.query = ''
148180
this.params = {}
149181
this.searchGeneration++
@@ -154,13 +186,10 @@ export class UnifiedSearchController {
154186
category: string,
155187
generation: number,
156188
categories: string[],
157-
staleEntries: unknown[] = [],
158189
): Promise<void> {
159-
// Seed with the prior page (stale-while-revalidate) so it stays visible under the
160-
// spinner until the fresh page replaces it. Empty on a first search.
161190
this.patchStates({ [category]: {
162191
status: 'loading',
163-
entries: staleEntries,
192+
entries: [],
164193
cursor: null,
165194
hasMore: false,
166195
loadMoreFailed: false,
@@ -185,12 +214,9 @@ export class UnifiedSearchController {
185214

186215
const { entries, cursor, isPaginated } = response.data.ocs.data
187216
// Decide blocked vs loaded once, here at settle. Reconcile only promotes after this
188-
// (never re-blocks), so this is the only place a category becomes blocked. A category
189-
// that carried stale results skips blocking: it is already on screen, so blocking it
190-
// would blink it off until its predecessors clear. Ordered reveal is only for the
191-
// first paint, when nothing is shown yet.
217+
// (never re-blocks), so this is the only place a category becomes blocked.
192218
this.patchStates({ [category]: {
193-
status: (staleEntries.length === 0 && this.shouldBlockCategory(category, categories)) ? 'blocked' : 'loaded',
219+
status: this.shouldBlockCategory(category, categories) ? 'blocked' : 'loaded',
194220
entries,
195221
cursor,
196222
hasMore: this.hasMorePages(isPaginated, cursor),
@@ -225,19 +251,23 @@ export class UnifiedSearchController {
225251
})
226252
}
227253

254+
/**
255+
* Arm the one reveal window a search gets. Ordered reveal governs the first paint only:
256+
* when the window closes everything blocked is shown and nothing may block again, so a
257+
* category that lands later is revealed straight away, at the end. Only a new search
258+
* opens another window.
259+
*/
228260
private startRevealTimer(): void {
229261
this.stopRevealTimer()
262+
this.revealWindowOpen = true
230263
this.revealTimer = setTimeout(() => {
231-
const categories = Object.keys(this.searchStates)
232-
const hasPendingCategories = categories.some((category) => ['loading', 'blocked'].includes(this.searchStates[category].status))
233-
this.unblockAllCategories(categories)
234-
if (hasPendingCategories) {
235-
this.startRevealTimer()
236-
}
264+
this.revealWindowOpen = false
265+
this.unblockAllCategories(Object.keys(this.searchStates))
237266
}, REVEAL_INTERVAL_MS)
238267
}
239268

240269
private stopRevealTimer(): void {
270+
this.revealWindowOpen = false
241271
if (this.revealTimer) {
242272
clearTimeout(this.revealTimer)
243273
this.revealTimer = null
@@ -275,7 +305,8 @@ export class UnifiedSearchController {
275305
}
276306

277307
private shouldBlockCategory(category: string, categories: string[]): boolean {
278-
if (!this.searchStates[category]) {
308+
// Once the window has closed, ordered reveal is over for this search.
309+
if (!this.revealWindowOpen || !this.searchStates[category]) {
279310
return false
280311
}
281312

@@ -285,10 +316,28 @@ export class UnifiedSearchController {
285316
})
286317
}
287318

319+
/**
320+
* Keep the display order in step with what is on screen. Losing its results frees a
321+
* category's slot, so the list closes the gap instead of leaving a hole.
322+
*
323+
* @param category the category id that just changed
324+
* @param state its merged state
325+
*/
326+
private syncRevealOrder(category: string, state: CategorySearchState): void {
327+
const at = this.revealOrder.indexOf(category)
328+
const visible = isCategoryVisible(state)
329+
if (visible && at === -1) {
330+
this.revealOrder.push(category)
331+
} else if (!visible && at !== -1) {
332+
this.revealOrder.splice(at, 1)
333+
}
334+
}
335+
288336
private patchStates(next: Record<string, Partial<CategorySearchState>>): void {
289337
Object.keys(next).forEach((category) => {
290338
const categoryState = { ...this.searchStates[category], ...next[category] }
291339
this.searchStates[category] = categoryState
340+
this.syncRevealOrder(category, categoryState)
292341
})
293342
this.onChange?.(this.getSnapshot())
294343
}

0 commit comments

Comments
 (0)