@@ -237,6 +237,16 @@ export type PaginatorState<T> = {
237237 offset ?: number ;
238238} ;
239239
240+ /**
241+ * What to do with a sideloaded item's sideload record when the *same* item is later delivered by
242+ * pagination (a duplicate). Both keep the displayed list identical right now (it is always deduped);
243+ * they differ only for a future re-query that no longer returns the item.
244+ * - `keepSideloaded` (default): the sideload record stands, so the item survives future re-queries.
245+ * - `dropSideloaded`: the sideload was only a bridge until pagination caught up — drop it once a page
246+ * delivers the item, after which it behaves like any ordinary paginated member.
247+ */
248+ export type OncePaginated = 'keepSideloaded' | 'dropSideloaded' ;
249+
240250// todo: think whether plugins are necessary. Maybe we could just document how to add
241251
242252export type PaginatorItemsChangeProcessor < T > = ( params : {
@@ -313,6 +323,10 @@ export type PaginatorOptions<T, Q> = {
313323 * It does not guarantee global stability across interval changes or page jumps.
314324 */
315325 lockItemOrder ?: boolean ;
326+ /**
327+ * Default policy for sideloaded items (see `sideloadItem`) when a sideloaded item is later delivered by * pagination. Defaults to `keepSideloaded`. Overridable per `sideloadItem` call.
328+ */
329+ oncePaginated ?: OncePaginated ;
316330 /** The item page size to be requested from the server. */
317331 pageSize ?: number ;
318332 /** Prevent silencing the errors thrown during the pagination execution. Default is false. */
@@ -326,6 +340,7 @@ type OptionalPaginatorConfigFields =
326340 | 'initialOffset'
327341 | 'itemIndex'
328342 | 'itemOrderComparator'
343+ | 'oncePaginated'
329344 | 'throwErrors' ;
330345
331346export type BasePaginatorConfig < T , Q > = Pick <
@@ -396,6 +411,21 @@ export abstract class BasePaginator<T, Q> {
396411 protected boosts = new Map < string , { until : number ; seq : number } > ( ) ;
397412 protected _maxBoostSeq = 0 ;
398413
414+ /**
415+ * Sideloaded items (flat mode) — ids of items kept in the list even though pagination did not
416+ * deliver them (a deep-link restore, a search result, a new DM). Value is the per-item
417+ * `OncePaginated` policy. Entities themselves live in `_itemIndex` (single source of
418+ * truth); this holds only ids + policy. Presence only — ordering stays with the comparator/boost.
419+ */
420+ protected sideloadedItems = new Map < string , OncePaginated > ( ) ;
421+ /**
422+ * Reactive membership of sideloaded item ids, kept **separate** from `state` so the paginated list
423+ * stays the pure server truth (no merge, no offset impact). Consumers observe this store and
424+ * render sideloaded items wherever they like, resolving ids -> entities via `getItem` and ordering
425+ * them with the paginator sort (`effectiveComparator`). Order here is not significant.
426+ */
427+ readonly sideloadedState = new StateStore < { itemIds : string [ ] } > ( { itemIds : [ ] } ) ;
428+
399429 /**
400430 * Describes how `interval.itemIds` are oriented relative to pagination semantics.
401431 *
@@ -705,6 +735,51 @@ export abstract class BasePaginator<T, Q> {
705735 return ! ! ( boost && Date . now ( ) <= boost . until ) ;
706736 }
707737
738+ // ---------------------------------------------------------------------------
739+ // Sideloaded items (flat mode) — presence for items not delivered by pagination
740+ // ---------------------------------------------------------------------------
741+
742+ /**
743+ * Merge sideloaded items (resolved from `_itemIndex`, still matching the filter, and not already
744+ * present) into a server-ordered list, each at its `effectiveComparator` position (boost then
745+ * sort) — without reordering the server items. Returns the input unchanged when nothing is
746+ * sideloaded, so lists that never sideload behave exactly as before.
747+ */
748+ /** Publish the current sideloaded membership to the reactive `sideloadedState` store. */
749+ protected syncSideloadedState ( ) {
750+ this . sideloadedState . partialNext ( { itemIds : [ ...this . sideloadedItems . keys ( ) ] } ) ;
751+ }
752+
753+ /**
754+ * Keep an item present regardless of pagination — a deep-link restore, a search result, a new
755+ * DM. Sideloaded items are tracked in the separate `sideloadedState` store (they are NOT merged into
756+ * the paginated `state.items`, so pagination/offset are untouched); consumers observe that store
757+ * and render them wherever they like. The entity is stored once in `_itemIndex`; this holds only
758+ * the id + `oncePaginated` policy. Flat-list mode only.
759+ */
760+ sideloadItem ( item : T , opts ?: { oncePaginated ?: OncePaginated } ) {
761+ if ( this . usesItemIntervalStorage ) return ;
762+ const id = this . getItemId ( item ) ;
763+ this . _itemIndex . setOne ( item ) ;
764+ this . sideloadedItems . set (
765+ id ,
766+ opts ?. oncePaginated ?? this . config . oncePaginated ?? 'keepSideloaded' ,
767+ ) ;
768+ this . syncSideloadedState ( ) ;
769+ }
770+
771+ /** Stop sideloading an item. Does not touch the paginated list — an item that pagination also
772+ * delivered stays there on its own. */
773+ removeSideloadedItem ( id : string ) {
774+ if ( this . usesItemIntervalStorage ) return ;
775+ if ( ! this . sideloadedItems . delete ( id ) ) return ;
776+ this . syncSideloadedState ( ) ;
777+ }
778+
779+ isSideloaded ( id : string ) {
780+ return this . sideloadedItems . has ( id ) ;
781+ }
782+
708783 // ---------------------------------------------------------------------------
709784 // Interval manipulation
710785 // ---------------------------------------------------------------------------
@@ -1231,7 +1306,7 @@ export abstract class BasePaginator<T, Q> {
12311306 /**
12321307 * Splits a logical interval by checking each item individually.
12331308 * Items overlapping anchoredInterval are merged into it.
1234- * Others stay in a retained logical interval.
1309+ * Others stay in a sideloaded logical interval.
12351310 */
12361311 protected mergeItemsFromLogicalInterval (
12371312 logical : LogicalInterval ,
@@ -1512,12 +1587,19 @@ export abstract class BasePaginator<T, Q> {
15121587 const nextItems = items . slice ( ) ;
15131588 if ( hadItem ) nextItems . splice ( existingIndex , 1 ) ;
15141589
1515- // If it no longer matches the filter, we only commit the removal (if any).
1590+ // If it no longer matches the filter, we only commit the removal (if any). A sideloaded item
1591+ // that stops matching (archived/muted out, deleted) also drops from the sideloaded store.
15161592 if ( ! this . matchesFilter ( ingestedItem ) ) {
1593+ this . _itemIndex . remove ( id ) ;
1594+ if ( this . sideloadedItems . delete ( id ) ) this . syncSideloadedState ( ) ;
15171595 if ( hadItem ) this . state . partialNext ( { items : nextItems } ) ;
15181596 return hadItem ;
15191597 }
15201598
1599+ // Keep the id-addressable entity store current in flat mode too (the sideload record resolves
1600+ // ids -> entities via it). This does not enable interval storage.
1601+ this . _itemIndex . setOne ( ingestedItem ) ;
1602+
15211603 // Determine insertion index against the list without the old snapshot.
15221604 const insertionIndex =
15231605 binarySearch ( {
@@ -1759,24 +1841,30 @@ export abstract class BasePaginator<T, Q> {
17591841 if ( ! id && ! inputItem ) return noAction ;
17601842
17611843 const item = inputItem ?? this . getItem ( id ) ;
1844+ const removedId = id ?? ( item ? this . getItemId ( item ) : undefined ) ;
17621845
1846+ let result : ItemCoordinates = noAction ;
17631847 if ( item ) {
17641848 const coords = this . locateByItem ( item ) ;
1765- if ( ! coords . state && ! coords . interval ) return noAction ;
1766- return this . removeItemAtCoordinates ( coords ) ;
1767- }
1768-
1769- // Fallback for state-only mode (sequential scan in state.items)
1770- if ( ! this . usesItemIntervalStorage ) {
1849+ if ( coords . state || coords . interval ) result = this . removeItemAtCoordinates ( coords ) ;
1850+ } else if ( ! this . usesItemIntervalStorage ) {
1851+ // Fallback for state-only mode (sequential scan in state.items).
17711852 const index = this . items ?. findIndex ( ( i ) => this . getItemId ( i ) === id ) ?? - 1 ;
1772- if ( index === - 1 ) return noAction ;
1773- const newItems = [ ...( this . items ?? [ ] ) ] ;
1774- newItems . splice ( index , 1 ) ;
1775- this . state . partialNext ( { items : newItems } ) ;
1776- return { state : { currentIndex : index , insertionIndex : - 1 } } ;
1853+ if ( index !== - 1 ) {
1854+ const newItems = [ ...( this . items ?? [ ] ) ] ;
1855+ newItems . splice ( index , 1 ) ;
1856+ this . state . partialNext ( { items : newItems } ) ;
1857+ result = { state : { currentIndex : index , insertionIndex : - 1 } } ;
1858+ }
17771859 }
17781860
1779- return noAction ;
1861+ // Flat-mode bookkeeping: a removed item is no longer paginated, sideloaded, or in the entity
1862+ // store. (Interval mode keeps `_itemIndex` authoritative via `removeItemAtCoordinates`.)
1863+ if ( ! this . usesItemIntervalStorage && removedId ) {
1864+ if ( this . sideloadedItems . delete ( removedId ) ) this . syncSideloadedState ( ) ;
1865+ this . _itemIndex . remove ( removedId ) ;
1866+ }
1867+ return result ;
17801868 }
17811869
17821870 /** Sets the items in the state. If intervals are kept, the active interval will be updated */
@@ -2016,14 +2104,30 @@ export abstract class BasePaginator<T, Q> {
20162104 const filteredItems = await this . filterQueryResults ( items ) ;
20172105 stateUpdate . items = filteredItems ;
20182106
2019- // State-only mode: merge pages into a single list.
2107+ // State-only mode: merge pages into a single list (the pure server list — sideloaded items live
2108+ // in their own store, not here).
20202109 if ( ! this . usesItemIntervalStorage ) {
2110+ // Keep the id-addressable entity store current so sideloaded ids can resolve to entities.
2111+ filteredItems . forEach ( ( item ) => this . _itemIndex . setOne ( item ) ) ;
2112+
20212113 const currentItems = this . items ?? [ ] ;
20222114 if ( ! isFirstPage ) {
2023- // In state-only mode we treat pagination as a growing list.
2024- // Both directions extend the same list (cursor semantics are expressed by the cursor, not by list "side").
20252115 stateUpdate . items = [ ...currentItems , ...filteredItems ] ;
20262116 }
2117+
2118+ // oncePaginated: a sideloaded item now delivered by pagination drops its sideload
2119+ // record when its policy is `dropSideloaded` (default `keepSideloaded` leaves it in place).
2120+ let sideloadedChanged = false ;
2121+ filteredItems . forEach ( ( item ) => {
2122+ const id = this . getItemId ( item ) ;
2123+ if (
2124+ this . sideloadedItems . get ( id ) === 'dropSideloaded' &&
2125+ this . sideloadedItems . delete ( id )
2126+ ) {
2127+ sideloadedChanged = true ;
2128+ }
2129+ } ) ;
2130+ if ( sideloadedChanged ) this . syncSideloadedState ( ) ;
20272131 }
20282132
20292133 const isJumpQuery = ! ! queryShape && this . isJumpQueryShape ( queryShape ) ;
0 commit comments