Skip to content

Commit 250ea66

Browse files
committed
feat(core): reduce unifieid search reveal interval
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
1 parent f40e305 commit 250ea66

2 files changed

Lines changed: 51 additions & 82 deletions

File tree

core/src/services/UnifiedSearchController.ts

Lines changed: 21 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ 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
@@ -32,9 +32,10 @@ export const REVEAL_INTERVAL_MS = 1500
3232
export const PAGE_SIZE = 10
3333

3434
/**
35-
* Whether a category has anything for the user to look at. Blocked is deliberately
36-
* withheld, failed carries no entries, and a loading category keeps its previous page up
37-
* (stale-while-revalidate) so it stays visible through a refetch.
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.
3839
*
3940
* Exported so the one definition also serves the Vue-side test doubles; the controller is
4041
* the only place that decides category-level visibility.
@@ -74,32 +75,20 @@ export class UnifiedSearchController {
7475
*/
7576
async search(query: string, categories: string[], params?: Record<string, CategorySearchParams>): Promise<void> {
7677
this.cancelPendingRequests()
77-
// Stale-while-revalidate: keep the previous page on screen while the new search is in
78-
// flight, so refining a query swaps results in place instead of flashing an empty panel.
79-
// Each recurring category is reseeded with its prior entries below; dropped ones vanish.
80-
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.
8182
this.searchStates = {}
82-
// Prune rather than clear: survivors keep the slots they already hold, so refining a query
83-
// never re-sorts rendered results back to priority order. A category the new search
84-
// dropped is reseeded invisible if it ever returns, so it re-enters at the bottom. This
85-
// cannot cover the window while the states below are still being reseeded one at a time;
86-
// getRevealOrder() does that.
87-
this.revealOrder = this.revealOrder.filter((category) => categories.includes(category))
83+
this.revealOrder = []
8884
this.searchGeneration++
8985
const generation = this.searchGeneration
9086
this.query = query
9187
this.params = params || {}
9288

9389
this.startRevealTimer()
9490

95-
await Promise.allSettled(categories.map((category) => {
96-
const prev = previous[category]
97-
// Only entries that were actually on screen seed the stale view. A blocked or failed
98-
// category's entries were fetched but never rendered, so they must not carry over
99-
// (and must not let the category skip the ordered reveal).
100-
const staleEntries = prev && isCategoryVisible(prev) ? prev.entries : []
101-
return this.searchCategory(category, generation, categories, staleEntries)
102-
}))
91+
await Promise.allSettled(categories.map((category) => this.searchCategory(category, generation, categories)))
10392
}
10493

10594
/**
@@ -165,17 +154,18 @@ export class UnifiedSearchController {
165154
/**
166155
* The ids of the categories currently on screen, in display order.
167156
*
168-
* Append-only, so a category never moves up into a slot another one already occupies: a
169-
* result that arrives late renders below what the user is already reading, however high
170-
* its priority. Read this rather than the snapshot's key order, which is the priority
171-
* order and an input to blocking, not a rendering order.
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.
172162
*
173-
* Every id is indexable in the same snapshot, so a caller can map without guarding.
163+
* Only ever names categories the current snapshot holds, so a caller can map without guarding.
174164
*
175165
* @return visible category ids, top to bottom
176166
*/
177167
getRevealOrder(): string[] {
178-
return this.revealOrder.filter((category) => category in this.searchStates)
168+
return [...this.revealOrder]
179169
}
180170

181171
dispose(): void {
@@ -196,13 +186,10 @@ export class UnifiedSearchController {
196186
category: string,
197187
generation: number,
198188
categories: string[],
199-
staleEntries: unknown[] = [],
200189
): Promise<void> {
201-
// Seed with the prior page (stale-while-revalidate) so it stays visible under the
202-
// spinner until the fresh page replaces it. Empty on a first search.
203190
this.patchStates({ [category]: {
204191
status: 'loading',
205-
entries: staleEntries,
192+
entries: [],
206193
cursor: null,
207194
hasMore: false,
208195
loadMoreFailed: false,
@@ -227,12 +214,9 @@ export class UnifiedSearchController {
227214

228215
const { entries, cursor, isPaginated } = response.data.ocs.data
229216
// Decide blocked vs loaded once, here at settle. Reconcile only promotes after this
230-
// (never re-blocks), so this is the only place a category becomes blocked. A category
231-
// that carried stale results skips blocking: it is already on screen, so blocking it
232-
// would blink it off until its predecessors clear. Ordered reveal is only for the
233-
// first paint, when nothing is shown yet.
217+
// (never re-blocks), so this is the only place a category becomes blocked.
234218
this.patchStates({ [category]: {
235-
status: (staleEntries.length === 0 && this.shouldBlockCategory(category, categories)) ? 'blocked' : 'loaded',
219+
status: this.shouldBlockCategory(category, categories) ? 'blocked' : 'loaded',
236220
entries,
237221
cursor,
238222
hasMore: this.hasMorePages(isPaginated, cursor),

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

Lines changed: 30 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -315,30 +315,29 @@ describe('UnifiedSearchController', () => {
315315
expect(searchController.getRevealOrder()).toEqual(['talk', 'deck'])
316316
})
317317

318-
it('keeps reveal positions across a refined query', async () => {
318+
it('restarts the reveal order from priority on a new query', async () => {
319319
const first = mockProviders(['files', 'talk'])
320320

321321
const searchController = new UnifiedSearchController()
322322
searchController.search('old', ['files', 'talk'])
323323

324-
// talk gets on screen first, so the session order is talk before files.
324+
// talk got on screen first, so this query renders talk above files.
325325
first.talk.resolve(['Old talk'])
326326
await vi.advanceTimersByTimeAsync(REVEAL_INTERVAL_MS)
327327
first.files.resolve(['Old files'])
328328
await vi.advanceTimersByTimeAsync(0)
329329
expect(searchController.getRevealOrder()).toEqual(['talk', 'files'])
330330

331-
// Refining must not re-sort what is already on screen back to priority order.
332-
// Both categories stay rendered throughout (stale-while-revalidate), so moving
333-
// them would be a displacement with identical content.
331+
// A new query hides everything: the results are about to be different, so there is
332+
// nothing on screen to protect and the next paint starts from priority order again.
334333
const second = mockProviders(['files', 'talk'])
335334
searchController.search('new', ['files', 'talk'])
336-
expect(searchController.getRevealOrder()).toEqual(['talk', 'files'])
335+
expect(searchController.getRevealOrder()).toEqual([])
337336

338337
second.files.resolve(['New files'])
339338
second.talk.resolve(['New talk'])
340339
await vi.advanceTimersByTimeAsync(0)
341-
expect(searchController.getRevealOrder()).toEqual(['talk', 'files'])
340+
expect(searchController.getRevealOrder()).toEqual(['files', 'talk'])
342341
})
343342

344343
it('releases a slot when a category loses its results, and appends it again if it returns', async () => {
@@ -360,17 +359,17 @@ describe('UnifiedSearchController', () => {
360359
await vi.advanceTimersByTimeAsync(0)
361360
expect(searchController.getRevealOrder()).toEqual(['talk'])
362361

363-
// It has results again on the next query, so it comes back as a fresh reveal:
364-
// at the end, not back at its old priority slot.
362+
// The next query is a clean slate, so it comes back in preferred order rather than
363+
// staying demoted for the rest of the session.
365364
const third = mockProviders(['files', 'talk'])
366365
searchController.search('c', ['files', 'talk'])
367366
third.files.resolve(['Files c'])
368367
third.talk.resolve(['Talk c'])
369368
await vi.advanceTimersByTimeAsync(0)
370-
expect(searchController.getRevealOrder()).toEqual(['talk', 'files'])
369+
expect(searchController.getRevealOrder()).toEqual(['files', 'talk'])
371370
})
372371

373-
it('re-appends a category that left the search entirely instead of reclaiming its old slot', async () => {
372+
it('recovers preferred order after a provider filter round trip', async () => {
374373
const first = mockProviders(['files', 'talk'])
375374

376375
const searchController = new UnifiedSearchController()
@@ -380,24 +379,22 @@ describe('UnifiedSearchController', () => {
380379
await vi.advanceTimersByTimeAsync(0)
381380
expect(searchController.getRevealOrder()).toEqual(['files', 'talk'])
382381

383-
// A provider filter narrows the search: files leaves the category list altogether,
384-
// which is a different exit from losing its results (that one goes through
385-
// syncRevealOrder; this one goes through the prune in search()).
382+
// A provider filter narrows the search: files leaves the category list altogether.
386383
const second = mockProviders(['talk'])
387384
searchController.search('foo', ['talk'])
388385
second.talk.resolve(['Talk result'])
389386
await vi.advanceTimersByTimeAsync(0)
390387
expect(searchController.getRevealOrder()).toEqual(['talk'])
391388

392-
// The filter comes off. talk never left the screen, so files has to come back below
393-
// it: reclaiming slot 0 would shove a rendered group down.
389+
// The filter comes off. Each search stands on its own, so files is back on top
390+
// instead of being stuck below talk until the popover closes.
394391
const third = mockProviders(['files', 'talk'])
395392
searchController.search('foo', ['files', 'talk'])
396393
third.files.resolve(['Files result'])
397394
third.talk.resolve(['Talk result'])
398395
await vi.advanceTimersByTimeAsync(0)
399396

400-
expect(searchController.getRevealOrder()).toEqual(['talk', 'files'])
397+
expect(searchController.getRevealOrder()).toEqual(['files', 'talk'])
401398
})
402399

403400
it('never hands out a category the snapshot cannot index, part-way through a search', async () => {
@@ -443,41 +440,39 @@ describe('UnifiedSearchController', () => {
443440
first.talk.resolve(['Talk result'])
444441
await vi.advanceTimersByTimeAsync(0)
445442

446-
// A narrower search replaces the first. The dropped categories must
447-
// not linger in the snapshot, nor in the display order: the view maps the
448-
// order straight onto the snapshot and would hit a missing category.
443+
// A narrower search replaces the first. The dropped categories must not linger in
444+
// the snapshot, and nothing from the previous query stays on screen.
449445
mockProviders(['files'])
450446
searchController.search('second', ['files'])
451447

452448
expect(searchController.getSnapshot()).toEqual({
453-
files: { status: 'loading', entries: ['Files result'], cursor: null, hasMore: false, loadMoreFailed: false },
449+
files: loading,
454450
})
455-
expect(searchController.getRevealOrder()).toEqual(['files'])
451+
expect(searchController.getRevealOrder()).toEqual([])
456452
})
457453
})
458454

459-
describe('stale-while-revalidate', () => {
460-
it('keeps the previous results visible while a refetch is in flight', async () => {
455+
describe('changing the query', () => {
456+
it('drops the previous results as soon as the query changes', async () => {
461457
const first = mockProviders(['files'])
462458

463459
const searchController = new UnifiedSearchController()
464460
searchController.search('old', ['files'])
465461
first.files.resolve(['Old result'])
466462
await vi.advanceTimersByTimeAsync(0)
467463

468-
// A refined query starts a new search. The prior entries must stay on screen
469-
// (status loading, entries kept) so the panel does not flash empty mid-request.
464+
// The new query is about to return different results, so keeping the old ones up
465+
// would only let them shift under the user once the real ones land. Hide, then show.
470466
const second = mockProviders(['files'])
471467
searchController.search('new', ['files'])
472468
expect(searchController.getSnapshot().files).toEqual({
473469
status: 'loading',
474-
entries: ['Old result'],
470+
entries: [],
475471
cursor: null,
476472
hasMore: false,
477473
loadMoreFailed: false,
478474
})
479475

480-
// The fresh page replaces them once it lands.
481476
second.files.resolve(['New result'])
482477
await vi.advanceTimersByTimeAsync(0)
483478
expect(searchController.getSnapshot().files).toEqual({
@@ -489,7 +484,7 @@ describe('UnifiedSearchController', () => {
489484
})
490485
})
491486

492-
it('settles a refetched category that carried results straight to loaded, never blocked', async () => {
487+
it('puts every category back through the ordered reveal on a new query', async () => {
493488
const first = mockProviders(['files', 'talk'])
494489

495490
const searchController = new UnifiedSearchController()
@@ -499,23 +494,15 @@ describe('UnifiedSearchController', () => {
499494
first.talk.resolve(['Old talk'])
500495
await vi.advanceTimersByTimeAsync(0)
501496

502-
// Refine. talk (lower priority) comes back before files this time. It already had
503-
// results, so it must not drop into blocked (which excludes it from the rendered
504-
// set and blinks it off screen); it stays visible by settling straight to loaded.
497+
// Refine. talk comes back first this time. Nothing is on screen to protect any more,
498+
// so it takes its turn in the queue again instead of skipping the reveal.
505499
const second = mockProviders(['files', 'talk'])
506500
searchController.search('new', ['files', 'talk'])
507501
second.talk.resolve(['New talk'])
508502
await vi.advanceTimersByTimeAsync(0)
509503

510-
expect(searchController.getSnapshot().talk.status).toBe('loaded')
511-
// files is still fetching; its stale page stays up meanwhile.
512-
expect(searchController.getSnapshot().files).toEqual({
513-
status: 'loading',
514-
entries: ['Old files'],
515-
cursor: null,
516-
hasMore: false,
517-
loadMoreFailed: false,
518-
})
504+
expect(searchController.getSnapshot().talk.status).toBe('blocked')
505+
expect(searchController.getRevealOrder()).toEqual([])
519506
})
520507
})
521508

@@ -1070,10 +1057,8 @@ describe('UnifiedSearchController', () => {
10701057
const searchController = new UnifiedSearchController()
10711058
searchController.search('first', ['files', 'talk'])
10721059

1073-
// The first search spends its window on talk, then stands the timer down. talk
1074-
// comes back empty so it carries no stale results into the second search, which
1075-
// would otherwise settle it straight to loaded and never block it.
1076-
first.talk.resolve([])
1060+
// The first search spends its window on talk, then stands the timer down.
1061+
first.talk.resolve(['First talk'])
10771062
await vi.advanceTimersByTimeAsync(REVEAL_INTERVAL_MS)
10781063
expect(searchController.getSnapshot().talk.status).toBe('loaded')
10791064
expect(vi.getTimerCount()).toBe(0)

0 commit comments

Comments
 (0)