Skip to content

Commit bbe43f0

Browse files
committed
feat(pagination): add support for sideloaded items
Add support populating itemIndex whenever it is available
1 parent c46639d commit bbe43f0

3 files changed

Lines changed: 241 additions & 18 deletions

File tree

src/ChannelPaginatorsOrchestrator.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { EventHandlerPipeline } from './EventHandlerPipeline';
22
import { WithSubscriptions } from './utils/WithSubscriptions';
33
import type { Event, EventTypes } from './types';
4-
import type { ChannelPaginator } from './pagination';
4+
import type { ChannelPaginator, OncePaginated } from './pagination';
55
import type { StreamChat } from './client';
66
import type { Unsubscribe } from './store';
77
import { StateStore } from './store';
@@ -404,6 +404,27 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions {
404404
});
405405
}
406406

407+
/**
408+
* Sideload a channel surfaced outside pagination (deep-link restore, search result, new DM) in its
409+
* owning list. Unlike `ingestChannel` — whose insert a fresh first-page query would overwrite —
410+
* a sideloaded channel is re-applied on every query reconcile, so it survives the initial load.
411+
* Data-semantic: how the channel came to be opened is the app's concern. See `BasePaginator.sideloadItem`.
412+
*/
413+
sideloadChannel(channel: Channel, opts?: { oncePaginated?: OncePaginated }) {
414+
const matchingPaginators = this.paginators.filter((p) => p.matchesFilter(channel));
415+
const ownerIds = this.resolveOwnership(channel, matchingPaginators);
416+
matchingPaginators.forEach((paginator) => {
417+
if (ownerIds.size === 0 || ownerIds.has(paginator.id)) {
418+
paginator.sideloadItem(channel, opts);
419+
}
420+
});
421+
}
422+
423+
/** Stop sideloading a channel (removes it from lists unless pagination also delivered it). */
424+
removeSideloadedChannel(cid: string) {
425+
this.paginators.forEach((paginator) => paginator.removeSideloadedItem(cid));
426+
}
427+
407428
/**
408429
* Filter a page of query results for a specific paginator according to ownership rules.
409430
* If no owners are specified by the resolver, all matching paginators keep the item.

src/pagination/paginators/BasePaginator.ts

Lines changed: 121 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -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

242252
export 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

331346
export 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);

test/unit/pagination/paginators/BasePaginator.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3737,3 +3737,101 @@ describe('BasePaginator', () => {
37373737
});
37383738
});
37393739
});
3740+
3741+
describe('BasePaginator sideloaded items (flat mode)', () => {
3742+
const sideloadedIds = (paginator: Paginator) =>
3743+
paginator.sideloadedState.getLatestValue().itemIds;
3744+
3745+
const loadFirstPage = async (paginator: Paginator, items: TestItem[]) => {
3746+
const promise = paginator.toTail();
3747+
await sleep(0);
3748+
paginator.queryResolve({ items });
3749+
await promise;
3750+
};
3751+
3752+
it('records a sideloaded item in the separate sideloadedState store, not in the paginated list', () => {
3753+
const paginator = new Paginator();
3754+
paginator.sideloadItem({ id: 'b' });
3755+
3756+
expect(sideloadedIds(paginator)).toEqual(['b']);
3757+
expect(paginator.isSideloaded('b')).toBe(true);
3758+
// Entity lives once in the shared index; the paginated list is untouched.
3759+
expect(paginator.getItem('b')).toEqual({ id: 'b' });
3760+
expect(paginator.items ?? []).not.toContainEqual({ id: 'b' });
3761+
});
3762+
3763+
it('leaves the paginated list and offset untouched by sideloading', async () => {
3764+
const paginator = new Paginator();
3765+
paginator.sideloadItem({ id: 'z' });
3766+
3767+
await loadFirstPage(paginator, [{ id: 'a' }, { id: 'b' }]);
3768+
3769+
// Paginated list is the pure server page; sideloaded item is only in its own store.
3770+
expect(paginator.items?.map((i) => i.id)).toEqual(['a', 'b']);
3771+
expect(sideloadedIds(paginator)).toEqual(['z']);
3772+
expect(paginator.offset).toBe(2);
3773+
});
3774+
3775+
it('removeSideloadedItem removes from the sideloaded store without touching the paginated list', async () => {
3776+
const paginator = new Paginator();
3777+
paginator.sideloadItem({ id: 'z' }); // never delivered by the page
3778+
paginator.sideloadItem({ id: 'a' }); // also delivered by the page
3779+
3780+
await loadFirstPage(paginator, [{ id: 'a' }, { id: 'b' }]);
3781+
expect(paginator.items?.map((i) => i.id)).toEqual(['a', 'b']);
3782+
expect(sideloadedIds(paginator).sort()).toEqual(['a', 'z']);
3783+
3784+
paginator.removeSideloadedItem('z');
3785+
expect(sideloadedIds(paginator)).toEqual(['a']);
3786+
expect(paginator.isSideloaded('z')).toBe(false);
3787+
3788+
paginator.removeSideloadedItem('a'); // 'a' still lives in the paginated list on its own
3789+
expect(sideloadedIds(paginator)).toEqual([]);
3790+
expect(paginator.items?.map((i) => i.id)).toEqual(['a', 'b']);
3791+
expect(paginator.offset).toBe(2);
3792+
});
3793+
3794+
it('keepSideloaded (default) keeps the sideload record after pagination delivers the item', async () => {
3795+
const paginator = new Paginator();
3796+
paginator.sideloadItem({ id: 'a' });
3797+
3798+
await loadFirstPage(paginator, [{ id: 'a' }, { id: 'b' }]);
3799+
3800+
expect(paginator.isSideloaded('a')).toBe(true);
3801+
expect(sideloadedIds(paginator)).toEqual(['a']);
3802+
});
3803+
3804+
it('dropSideloaded clears the sideload record once pagination delivers the item', async () => {
3805+
const paginator = new Paginator();
3806+
paginator.sideloadItem({ id: 'a' }, { oncePaginated: 'dropSideloaded' });
3807+
3808+
await loadFirstPage(paginator, [{ id: 'a' }, { id: 'b' }]);
3809+
3810+
expect(paginator.isSideloaded('a')).toBe(false);
3811+
expect(sideloadedIds(paginator)).toEqual([]);
3812+
// Still present in the paginated list — it's an ordinary paginated member now.
3813+
expect(paginator.items?.map((i) => i.id)).toEqual(['a', 'b']);
3814+
});
3815+
3816+
it('drops a sideloaded item that stops matching the filter (lifecycle removal)', () => {
3817+
const paginator = new Paginator();
3818+
// @ts-expect-error override protected method for the test
3819+
paginator.buildFilters = () => ({ blocked: false });
3820+
paginator.sideloadItem({ id: 'z', blocked: false }); // matches
3821+
expect(paginator.isSideloaded('z')).toBe(true);
3822+
3823+
// A later WS update makes it stop matching -> ingestItem drops it from the sideload record + index.
3824+
paginator.ingestItem({ id: 'z', blocked: true });
3825+
expect(paginator.isSideloaded('z')).toBe(false);
3826+
expect(sideloadedIds(paginator)).toEqual([]);
3827+
});
3828+
3829+
it('is a no-op in interval-storage mode', () => {
3830+
const paginator = new Paginator({
3831+
itemIndex: new ItemIndex<TestItem>({ getId: ({ id }) => id }),
3832+
});
3833+
paginator.sideloadItem({ id: 'x' });
3834+
expect(paginator.isSideloaded('x')).toBe(false);
3835+
expect(sideloadedIds(paginator)).toEqual([]);
3836+
});
3837+
});

0 commit comments

Comments
 (0)