@@ -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
254275export type PaginatorItemsChangeProcessor < T > = ( params : {
@@ -361,6 +382,15 @@ export const DEFAULT_PAGINATION_OPTIONS: BasePaginatorConfig<any, any> = {
361382
362383export 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' > = { } ) =>
0 commit comments