@@ -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 */
3232export 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 */
3856export 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