diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index 4ee69790a..6197f8af8 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -529,6 +529,8 @@ export abstract class BasePaginator> { protected _executeQueryDebounced!: DebouncedExecQueryFunction; /** Last effective query shape produced by the subclass for the most recent request. */ protected _lastQueryShape?: Q; + /** Set by `setItems`, which loads a page (even an empty one) without a query. */ + private _pageSetWithoutQuery = false; /** * Stable, purely data-driven comparison (age, timestamps, ...). Default for @@ -645,6 +647,18 @@ export abstract class BasePaginator> { return typeof this._lastQueryShape !== 'undefined'; } + /** + * True once a page was loaded: by a query, or by `setItems`, including an empty page (e.g. a + * catch-all list seeded so it never runs its own query). Items that live events ingested into a + * never-queried list are not a loaded page, although they make `items` defined: a UI deciding + * whether to run the first query reads this, not `items === undefined`. Unlike `isInitialized`, + * an empty `setItems` page counts, so connection recovery (which re-runs initialized lists' + * queries) still skips such a list. Cleared by `resetState`. + */ + get hasLoadedPage() { + return this._pageSetWithoutQuery || this.isInitialized; + } + get isOfflineSupportEnabled() { return false; } @@ -2682,6 +2696,8 @@ export abstract class BasePaginator> { newState.hasMoreTail = interval.hasMoreTail; } + // Before the publish below: subscribers read `hasLoadedPage` from inside it. + this._pageSetWithoutQuery = true; return newState; }); @@ -3450,13 +3466,15 @@ export abstract class BasePaginator> { } resetState() { + // Nothing is loaded, so the next query is a first page again. A stale shape would make a + // re-query with an unchanged shape a continuation, ingested without `isHead`. Cleared before + // the publish below, whose subscribers read `isInitialized` / `hasLoadedPage`. + this._lastQueryShape = undefined; + this._pageSetWithoutQuery = false; this.state.next(this.initialState); this.setIntervals([]); this.setActiveInterval(undefined); this.clearIntervalViews(); - // Nothing is loaded, so the next query is a first page again. A stale shape would make a - // re-query with an unchanged shape a continuation, ingested without `isHead`. - this._lastQueryShape = undefined; } /** diff --git a/test/unit/pagination/paginators/BasePaginator.test.ts b/test/unit/pagination/paginators/BasePaginator.test.ts index f35a84659..9d16a7066 100644 --- a/test/unit/pagination/paginators/BasePaginator.test.ts +++ b/test/unit/pagination/paginators/BasePaginator.test.ts @@ -4534,6 +4534,83 @@ describe('BasePaginator', () => { // @ts-expect-error accessing protected property expect(paginator._activeIntervalId).toBe(intervals[0].id); }); + + describe('hasLoadedPage', () => { + it('is false until a page is loaded', () => { + const paginator = new Paginator(); + expect(paginator.hasLoadedPage).toBe(false); + }); + + it('counts an empty page as loaded, without initializing the query shape', () => { + const paginator = new Paginator(); + + // e.g. a catch-all list seeded so it never runs its own query + paginator.setItems({ isLastPage: true, valueOrFactory: [] }); + + expect(paginator.hasLoadedPage).toBe(true); + // still skipped by connection recovery: there is no query to re-run + expect(paginator.isInitialized).toBe(false); + }); + + it('does not count items ingested into a list that was never queried', () => { + const paginator = new Paginator({ itemIndex }); + paginator.sortComparator = makeComparator({ + sort: [{ field: 'age', direction: -1 }], + }); + + paginator.ingestItem(v); + + // the item is shown, but no page was loaded: the first query still has to run + expect(paginator.items).toEqual([v]); + expect(paginator.hasLoadedPage).toBe(false); + + paginator.removeItem({ item: v }); + + expect(paginator.items).toEqual([]); + expect(paginator.hasLoadedPage).toBe(false); + }); + + it('is true after a query placed a page and false again after resetState', () => { + const paginator = new Paginator({ itemIndex }); + paginator.sortComparator = makeComparator({ + sort: [{ field: 'age', direction: -1 }], + }); + + paginator.placeQueryPage({ + direction: 'tailward', + isFirstPage: true, + queryShape: defaultNextQueryShape, + requestedPageSize: 3, + results: { items: [] }, + }); + expect(paginator.hasLoadedPage).toBe(true); + + paginator.resetState(); + expect(paginator.hasLoadedPage).toBe(false); + + paginator.setItems({ valueOrFactory: [] }); + paginator.resetState(); + expect(paginator.hasLoadedPage).toBe(false); + }); + + it('is already current when the state change is published', () => { + // a UI selector reads it from inside the state subscription + const paginator = new Paginator(); + const seen: boolean[] = []; + const unsubscribe = paginator.state.subscribe(() => { + seen.push(paginator.hasLoadedPage); + }); + + paginator.setItems({ valueOrFactory: [] }); + paginator.resetState(); + paginator.setItems({ valueOrFactory: [a] }); + paginator.resetState(); + unsubscribe(); + + // initial call on subscribe, then each setItems / resetState + expect(seen).toEqual([false, true, false, true, false]); + }); + }); }); describe('reload', () => {