Skip to content

Commit 896f499

Browse files
authored
feat(pagination): add intervalViews store (#1807)
1 parent 3d2a56a commit 896f499

3 files changed

Lines changed: 333 additions & 11 deletions

File tree

src/pagination/paginators/BasePaginator.ts

Lines changed: 166 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,27 @@ export type PaginatorState<T> = {
249249
offset?: number;
250250
};
251251

252+
/**
253+
* Reactive projections of specific (fixed-identity) intervals, published independently of the
254+
* paginated `state` (which tracks only the *active* interval). See {@link BasePaginator.intervalViews}.
255+
* A field is rewritten only when its own interval changes, so a `useStateStore`/`subscribeWithSelector`
256+
* consumer selecting one field only wakes when *that* interval changes. (The head-most window that can
257+
* be either a logical or an anchored interval is a derived *role*, not a fixed interval, so it is not
258+
* published here — read it one-shot via {@link BasePaginator.headItems} / {@link BasePaginator.headmostItem}.)
259+
*/
260+
export type PaginatorIntervalViews<T> = {
261+
/** Live logical-head interval — out-of-order items above the loaded window. */
262+
logicalHead: T[];
263+
/** Live logical-tail interval — out-of-order items below the loaded window. */
264+
logicalTail: T[];
265+
/**
266+
* Anchored head interval — the loaded page bounded at the dataset head (`isHead`), i.e. the newest
267+
* loaded page; empty when the head is not loaded. Its content updates when that page ingests/removes
268+
* an item, and its identity updates when a page's `isHead` flag flips during query reconciliation.
269+
*/
270+
anchoredHead: T[];
271+
};
272+
252273
// todo: think whether plugins are necessary. Maybe we could just document how to add
253274

254275
export type PaginatorItemsChangeProcessor<T> = (params: {
@@ -361,6 +382,15 @@ export const DEFAULT_PAGINATION_OPTIONS: BasePaginatorConfig<any, any> = {
361382

362383
export abstract class BasePaginator<T, Q> {
363384
state: StateStore<PaginatorState<T>>;
385+
/**
386+
* Reactive projections of specific intervals — see {@link PaginatorIntervalViews}. Unlike `state`
387+
* (which only re-emits when the *active* interval is impacted), a field here is rewritten whenever
388+
* its own interval changes, regardless of which interval is active — so consumers can reactively
389+
* render off-window "sideloaded" content (`logicalHead`/`logicalTail`) and the newest loaded page
390+
* (`anchoredHead`). Kept separate from `state` so the paginated-list contract stays focused on the
391+
* active window + pagination status.
392+
*/
393+
intervalViews: StateStore<PaginatorIntervalViews<T>>;
364394
config: BasePaginatorConfig<T, Q>;
365395

366396
/**
@@ -444,6 +474,11 @@ export abstract class BasePaginator<T, Q> {
444474
cursor: initialCursor,
445475
offset: initialOffset ?? 0,
446476
});
477+
this.intervalViews = new StateStore<PaginatorIntervalViews<T>>({
478+
logicalHead: [],
479+
logicalTail: [],
480+
anchoredHead: [],
481+
});
447482
this.setDebounceOptions({ debounceMs });
448483
this.sortComparator = noOrderChange;
449484
this._filterFieldToDataResolvers = [];
@@ -606,6 +641,120 @@ export abstract class BasePaginator<T, Q> {
606641
return itv && isLiveTailInterval(itv) ? itv : undefined;
607642
}
608643

644+
/**
645+
* The current contents of the live logical-head interval (items ingested out of pagination order).
646+
* Reads the same value published to {@link BasePaginator.intervalViews}.`logicalHead`.
647+
*/
648+
get logicalHeadItems(): T[] {
649+
return this.intervalViews.getLatestValue().logicalHead;
650+
}
651+
652+
/**
653+
* The current contents of the live logical-tail interval (out-of-order items below the loaded
654+
* window). Reads the same value published to {@link BasePaginator.intervalViews}.`logicalTail`.
655+
*/
656+
get logicalTailItems(): T[] {
657+
return this.intervalViews.getLatestValue().logicalTail;
658+
}
659+
660+
/**
661+
* The current contents of the anchored head interval (the loaded page bounded at the dataset head,
662+
* `isHead`). Reads the same value published to {@link BasePaginator.intervalViews}.`anchoredHead`.
663+
*/
664+
get anchoredHeadItems(): T[] {
665+
return this.intervalViews.getLatestValue().anchoredHead;
666+
}
667+
668+
/**
669+
* Commit an interval into storage. Single choke point for adding/updating an interval, so it also
670+
* republishes the matching {@link intervalViews} field when the committed interval is a tracked one
671+
* (logical head / logical tail / anchored head). Use this instead of writing `_itemIntervals`
672+
* directly — bulk re-sorting (which does not change any interval's membership) goes through
673+
* {@link setIntervals}.
674+
*/
675+
protected commitInterval(interval: AnyInterval) {
676+
this._itemIntervals.set(interval.id, interval);
677+
this.publishIntervalViewFor(interval);
678+
}
679+
680+
/** Drop an interval from storage, republishing the matching {@link intervalViews} field if tracked. */
681+
protected dropInterval(id: string) {
682+
const removed = this._itemIntervals.get(id);
683+
this._itemIntervals.delete(id);
684+
if (removed) this.publishIntervalViewFor(removed, { removed: true });
685+
}
686+
687+
/**
688+
* Republish the {@link intervalViews} field backed by the given interval — called from
689+
* {@link commitInterval} / {@link dropInterval} (i.e. when that interval ingests or removes an item).
690+
* A write to an untracked interval touches nothing here. (The anchored head is also published
691+
* directly via {@link publishAsAnchoredHead} from the reconciliation points that flip `isHead` —
692+
* see {@link postQueryReconcile}.)
693+
*/
694+
private publishIntervalViewFor(interval: AnyInterval, { removed = false } = {}) {
695+
if (interval.id === LOGICAL_HEAD_INTERVAL_ID) {
696+
this.intervalViews.partialNext({
697+
logicalHead: this.intervalItemsOrEmpty(this.liveHeadLogical),
698+
});
699+
} else if (interval.id === LOGICAL_TAIL_INTERVAL_ID) {
700+
this.intervalViews.partialNext({
701+
logicalTail: this.intervalItemsOrEmpty(this.liveTailLogical),
702+
});
703+
} else if ((interval as Interval).isHead) {
704+
// On removal the head page is gone (no other interval is `isHead`) → clear; otherwise the
705+
// committed page IS the head.
706+
this.publishAsAnchoredHead(removed ? undefined : interval);
707+
}
708+
}
709+
710+
/**
711+
* Publish `interval` as the anchored head — the loaded page bounded at the dataset head (`isHead`),
712+
* or `undefined` to clear it (the head page was removed or a page stopped being the head). Callers
713+
* pass the interval they already have, so this does not re-scan storage for the head. Its content
714+
* changes via ingest/remove (routed through {@link commitInterval}/{@link dropInterval}) and its
715+
* identity changes when a page's `isHead` flag flips during query reconciliation — both call here.
716+
*/
717+
protected publishAsAnchoredHead(interval: AnyInterval | undefined) {
718+
this.intervalViews.partialNext({ anchoredHead: this.intervalItemsOrEmpty(interval) });
719+
}
720+
721+
/**
722+
* Keep `anchoredHead` in sync after a page's `isHead` flag was (re)computed during query
723+
* reconciliation, given its value `wasHead` beforehand. Acts only on an actual transition:
724+
* - became the head page → publish it as the anchored head;
725+
* - stopped being the head page → clear the anchored head;
726+
* - unchanged → nothing (a content change, if any, was already published when the interval was
727+
* committed — see {@link commitInterval}).
728+
*/
729+
protected syncAnchoredHeadAfterHeadFlip(interval: Interval, wasHead: boolean) {
730+
if (interval.isHead === wasHead) return;
731+
this.publishAsAnchoredHead(interval.isHead ? interval : undefined);
732+
}
733+
734+
private intervalItemsOrEmpty(interval: AnyInterval | undefined): T[] {
735+
return interval ? this.intervalToItems(interval) : [];
736+
}
737+
738+
/**
739+
* Empty every {@link intervalViews} field. Used by reset paths that clear intervals in bulk (via
740+
* {@link setIntervals}), which bypasses the per-interval {@link commitInterval}/{@link dropInterval}
741+
* publishing. No-ops when the views are already empty so a reset does not emit needlessly.
742+
*/
743+
protected clearIntervalViews() {
744+
const { logicalHead, logicalTail, anchoredHead } =
745+
this.intervalViews.getLatestValue();
746+
// Clear whenever any view holds items; skip only when all are already empty, so a reset on an
747+
// empty paginator does not emit a redundant empty→empty change (new `[]` refs would wake selectors).
748+
const alreadyEmpty =
749+
logicalHead.length === 0 && logicalTail.length === 0 && anchoredHead.length === 0;
750+
if (alreadyEmpty) return;
751+
this.intervalViews.partialNext({
752+
logicalHead: [],
753+
logicalTail: [],
754+
anchoredHead: [],
755+
});
756+
}
757+
609758
// ---------------------------------------------------------------------------
610759
// Abstracts
611760
// ---------------------------------------------------------------------------
@@ -1473,7 +1622,7 @@ export abstract class BasePaginator<T, Q> {
14731622
resultingInterval = merged;
14741623
for (const itv of toMerge) {
14751624
if (merged.id === itv.id) continue;
1476-
this._itemIntervals.delete(itv.id);
1625+
this.dropInterval(itv.id);
14771626
}
14781627
}
14791628

@@ -1489,9 +1638,9 @@ export abstract class BasePaginator<T, Q> {
14891638
isHead: false,
14901639
isTail: false,
14911640
};
1492-
this._itemIntervals.set(convertedInterval.id, convertedInterval);
1641+
this.commitInterval(convertedInterval);
14931642
} else {
1494-
this._itemIntervals.set(LOGICAL_HEAD_INTERVAL_ID, logicalHead);
1643+
this.commitInterval(logicalHead);
14951644
}
14961645
}
14971646

@@ -1506,13 +1655,13 @@ export abstract class BasePaginator<T, Q> {
15061655
isHead: false,
15071656
isTail: false,
15081657
};
1509-
this._itemIntervals.set(convertedInterval.id, convertedInterval);
1658+
this.commitInterval(convertedInterval);
15101659
} else {
1511-
this._itemIntervals.set(LOGICAL_TAIL_INTERVAL_ID, logicalTail);
1660+
this.commitInterval(logicalTail);
15121661
}
15131662
}
15141663

1515-
this._itemIntervals.set(resultingInterval.id, resultingInterval);
1664+
this.commitInterval(resultingInterval);
15161665
// keep the intervals sorted
15171666
this.setIntervals(this.sortIntervals(this.itemIntervals));
15181667

@@ -1652,7 +1801,7 @@ export abstract class BasePaginator<T, Q> {
16521801
}
16531802

16541803
const addedNewInterval = !this._itemIntervals.has(targetInterval.id);
1655-
this._itemIntervals.set(targetInterval.id, targetInterval);
1804+
this.commitInterval(targetInterval);
16561805

16571806
if (addedNewInterval) {
16581807
this.setIntervals(this.sortIntervals(this.itemIntervals));
@@ -1714,14 +1863,14 @@ export abstract class BasePaginator<T, Q> {
17141863
const { interval } = updatedInterval;
17151864
if (interval.itemIds.length === 0) {
17161865
// Drop empty interval
1717-
this._itemIntervals.delete(interval.id);
1866+
this.dropInterval(interval.id);
17181867

17191868
// If it was active -> clear active
17201869
if (this.isActiveInterval(interval)) {
17211870
this.setActiveInterval(undefined);
17221871
}
17231872
} else {
1724-
this._itemIntervals.set(updatedInterval.interval.id, updatedInterval.interval);
1873+
this.commitInterval(updatedInterval.interval);
17251874
}
17261875
result.interval = updatedInterval;
17271876
}
@@ -1973,6 +2122,7 @@ export abstract class BasePaginator<T, Q> {
19732122
this.setIntervals([]);
19742123
this.setActiveInterval(undefined);
19752124
this._itemIndex.clear();
2125+
this.clearIntervalViews();
19762126
}
19772127
let items: T[] | undefined = undefined;
19782128
if (!this.isInitialized) {
@@ -1998,7 +2148,6 @@ export abstract class BasePaginator<T, Q> {
19982148
reset,
19992149
retryCount,
20002150
});
2001-
20022151
return this.postQueryReconcile({
20032152
direction,
20042153
isFirstPage,
@@ -2136,10 +2285,13 @@ export abstract class BasePaginator<T, Q> {
21362285
? stateUpdate.hasMoreTail
21372286
: current.hasMoreTail;
21382287

2288+
const wasHead = interval.isHead;
21392289
interval.hasMoreHead = resolvedHasMoreHead;
21402290
interval.hasMoreTail = resolvedHasMoreTail;
21412291
interval.isHead = resolvedHasMoreHead === false;
21422292
interval.isTail = resolvedHasMoreTail === false;
2293+
// `isHead` is decided here (not at ingest); reflect any head-status flip in `anchoredHead`.
2294+
this.syncAnchoredHeadAfterHeadFlip(interval, wasHead);
21432295
} else if (!items.length && direction) {
21442296
// An empty directional response means the dataset edge was reached in `direction`, but
21452297
// `ingestPage` returns no interval for an empty page so the block above never runs. Flag the
@@ -2151,8 +2303,11 @@ export abstract class BasePaginator<T, Q> {
21512303
: undefined;
21522304
if (activeInterval && !isLogicalInterval(activeInterval)) {
21532305
if (direction === 'headward') {
2306+
const wasHead = activeInterval.isHead;
21542307
activeInterval.isHead = true;
21552308
activeInterval.hasMoreHead = false;
2309+
// The active page just reached the dataset head; reflect the flip in `anchoredHead`.
2310+
this.syncAnchoredHeadAfterHeadFlip(activeInterval, wasHead);
21562311
} else if (direction === 'tailward') {
21572312
activeInterval.isTail = true;
21582313
activeInterval.hasMoreTail = false;
@@ -2182,6 +2337,7 @@ export abstract class BasePaginator<T, Q> {
21822337
this.state.next(this.initialState);
21832338
this.setIntervals([]);
21842339
this.setActiveInterval(undefined);
2340+
this.clearIntervalViews();
21852341
}
21862342

21872343
toTail = (params: Omit<PaginationQueryParams<Q>, 'direction' | 'queryShape'> = {}) =>

src/pagination/paginators/MessagePaginator.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,8 @@ export class MessagePaginator extends MessageIntervalPaginator {
118118
* Auxiliary (non-pagination) state — see {@link MessagePaginatorAggregateState}. A store separate
119119
* from `state` so `lastMessageAt` can be advanced from inside a `state.next` updater
120120
* (`ingestPage`) without being clobbered, and so consumers subscribe to a quiet signal that only
121-
* emits when the aggregate actually changes (not on every scroll/pagination emission).
121+
* emits when the aggregate actually changes (not on every scroll/pagination emission). This is
122+
* distinct from the base's `intervalViews` interval-projection store.
122123
*/
123124
readonly aggregateState: StateStore<MessagePaginatorAggregateState>;
124125

0 commit comments

Comments
 (0)