Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions src/pagination/paginators/BasePaginator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,8 @@ export abstract class BasePaginator<T, Q, R = PaginationQueryReturnValue<T>> {
protected _executeQueryDebounced!: DebouncedExecQueryFunction<Q>;
/** 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
Expand Down Expand Up @@ -645,6 +647,18 @@ export abstract class BasePaginator<T, Q, R = PaginationQueryReturnValue<T>> {
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;
}
Expand Down Expand Up @@ -2682,6 +2696,8 @@ export abstract class BasePaginator<T, Q, R = PaginationQueryReturnValue<T>> {
newState.hasMoreTail = interval.hasMoreTail;
}

// Before the publish below: subscribers read `hasLoadedPage` from inside it.
this._pageSetWithoutQuery = true;
return newState;
});

Expand Down Expand Up @@ -3450,13 +3466,15 @@ export abstract class BasePaginator<T, Q, R = PaginationQueryReturnValue<T>> {
}

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;
}

/**
Expand Down
77 changes: 77 additions & 0 deletions test/unit/pagination/paginators/BasePaginator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TestItem>({
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<TestItem>({
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', () => {
Expand Down
Loading