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
17 changes: 4 additions & 13 deletions src/ChannelManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,19 +177,10 @@ const updateLists: EventHandlerPipelineHandler<EventHandlerContext> = async ({
return;
}

// Selected owner: optionally boost then ingest
const channelBoost = paginator.getBoost(channel.cid);
if (
[
'message.new',
'notification.message_new',
'notification.added_to_channel',
'channel.visible',
].includes(event.type) &&
(!channelBoost || channelBoost.seq < paginator.maxBoostSeq)
) {
paginator.boost(channel.cid, { seq: paginator.maxBoostSeq + 1 });
}
// Selected owner: ingest. The manager never boosts by default on any event — the sort is the
// single source of truth for order, so a channel that just became relevant (new message, added,
// unhidden) relocates via its updated sort key. Boosting remains a public per-paginator primitive
// (`paginator.boost`) for integrators to opt into for specific channels (VIP/mention/deep-link).
paginator.ingestItem(channel);
});
};
Expand Down
13 changes: 11 additions & 2 deletions src/pagination/paginators/BasePaginator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2546,11 +2546,20 @@ export abstract class BasePaginator<T, Q> {
this._itemIndex.clear();
this.clearIntervalViews();
this.state.next(this.getStateBeforeFirstQuery());
} else if (reset === 'yes' && !forcedQueryShape) {
// A `keepPreviousItems` refresh (reconnect / pull-to-refresh) must still restart pagination from
// page 1, but WITHOUT clearing the loaded window — the list stays visible while the fresh first
// page loads. Reset only the pagination position; `getNextQueryShape()` below reads it from state.
this.state.partialNext({
cursor: this.config.initialCursor,
offset: this.config.initialOffset ?? 0,
});
}

const queryShape = forcedQueryShape ?? this.getNextQueryShape({ direction });

const isFirstPage = this.isFirstPageQuery({ queryShape, reset });

if (isFirstPage && !keepPreviousItems) {
const state = this.getStateBeforeFirstQuery();
let items: T[] | undefined = undefined;
Expand All @@ -2569,8 +2578,8 @@ export abstract class BasePaginator<T, Q> {
this.state.next({ ...state, items });
}
} else if (!silent) {
// Non-first-page, or a keepPreviousItems refresh: surface loading without blanking the list.
// The freshly fetched page is merged into the active interval in postQueryReconcile.
// Non-first-page, or a keepPreviousItems refresh: surface loading without blanking the list. The
// freshly fetched page is merged into the still-loaded intervals in postQueryReconcile.
this.state.partialNext({ isLoading: true });
}

Expand Down
33 changes: 24 additions & 9 deletions src/pagination/paginators/ChannelPaginator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,22 +648,37 @@ export class ChannelPaginator extends BasePaginator<Channel, ChannelQueryShape>
if (!shouldDeferUntilSynced) return await super.executeQuery(params);

if (!this.isInitialized) {
const state = this.getStateBeforeFirstQuery();
const cachedChannels = await this.preloadFirstPageFromOfflineDb({
...params,
queryShape,
});
// `isLoading: false` — nothing is in flight while we wait for the sync, and leaving it set would
// make `canExecuteQuery` reject the query this schedules below.
this.state.next({
...state,
isLoading: false,
items: cachedChannels ?? state.items,
});
if (cachedChannels?.length) {
// Seed via `setItems` (which ingests the page into the interval/index storage), so that the items
// actually appear within the adequate index.
// TODO: Maybe find a better way to do this rather than 2 partialNext invocations
// running. This all happens so fast it should never be noticeable but it's
// a microoptimization.
this.setItems({ valueOrFactory: cachedChannels, isFirstPage: true });
}
// Nothing is in flight while we wait for the sync; leaving `isLoading` true would make
// `canExecuteQuery` reject the query scheduled below.
this.state.partialNext({ isLoading: false });
}

// Check if everything is synced up already and if so, just run the actual queryChannels request.
// Otherwise, the sync status change will never fire and so `executeQuery` will never really be
// run.
if (offlineDb.syncManager.syncStatus) {
return await super.executeQuery({ ...params, keepPreviousItems: true });
}

// When the sync completes, run the real query — but as a NON-DESTRUCTIVE refresh
// (`keepPreviousItems`) so the channels we already surfaced from the offline DB stay visible while it
// runs. Without this the re-run goes through the first-page reset path and re-preloads from the DB;
// if the sync invalidated the offline query cache (i.e. a channel changed while the app was closed),
// that re-preload returns nothing and the list blanks to a second skeleton before the fresh page lands.
offlineDb.syncManager.scheduleSyncStatusChangeCallback(this.id, async () => {
await this.executeQuery(params);
await this.executeQuery({ ...params, keepPreviousItems: true });
});
}

Expand Down
75 changes: 6 additions & 69 deletions test/unit/ChannelManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1526,77 +1526,14 @@ describe('ChannelManager', () => {
});

it.each([
'message.new',
'notification.message_new',
// The manager never boosts by default on ANY event — the sort is the single source of truth for
// order. A new message bumps last_message_at and the sort relocates the channel on its own (see the
// in-place relocate fix 60566820); an added / unhidden channel likewise relocates via its sort key.
// Boosting stays a public per-paginator primitive integrators can opt into (see ChannelManager.updateLists).
'notification.added_to_channel',
'channel.visible',
] as EventTypes[])(
'boosts ingested channel on %s if the item is not already boosted at the top',
async (eventType) => {
vi.useFakeTimers();
const now = new Date('2025-01-01T00:00:00Z');
vi.setSystemTime(now);
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now.getTime());

const channelManager = new ChannelManager({ client });
const ch = makeChannel('messaging:5');
client.activeChannels[ch.cid] = ch;

const paginator = new ChannelPaginator({ client });
const matchesFilterSpy = vi.spyOn(paginator, 'matchesFilter').mockReturnValue(true);

channelManager.insertPaginator({ paginator });
channelManager.registerSubscriptions();

// @ts-expect-error accessing protected property
expect(paginator.boosts.size).toBe(0);

client.dispatchEvent({ type: eventType, cid: ch.cid });

await vi.waitFor(() => {
// @ts-expect-error accessing protected property
expect(Array.from(paginator.boosts.entries())).toEqual([
[ch.cid, { seq: 1, until: now.getTime() + 15000 }],
]);
});

client.dispatchEvent({ type: eventType, cid: ch.cid });
await vi.waitFor(() => {
// already at the top
// @ts-expect-error accessing protected property
expect(Array.from(paginator.boosts.entries())).toEqual([
[ch.cid, { seq: 1, until: now.getTime() + 15000 }],
]);
});

matchesFilterSpy.mockReturnValue(false);
client.dispatchEvent({ type: eventType, cid: ch.cid });

await vi.waitFor(() => {
// @ts-expect-error accessing protected property
expect(Array.from(paginator.boosts.entries())).toEqual([
[ch.cid, { seq: 1, until: now.getTime() + 15000 }],
]);
});

matchesFilterSpy.mockReturnValue(true);
// @ts-expect-error accessing protected property
paginator._maxBoostSeq = 1000;
client.dispatchEvent({ type: eventType, cid: ch.cid });
await vi.waitFor(() => {
// some other channel has a higher boost
// @ts-expect-error accessing protected property
expect(Array.from(paginator.boosts.entries())).toEqual([
[ch.cid, { seq: 1001, until: now.getTime() + 15000 }],
]);
});

nowSpy.mockRestore();
vi.useRealTimers();
},
);

it.each([
'message.new',
'notification.message_new',
'channel.updated',
'channel.truncated',
'member.updated',
Expand Down
88 changes: 79 additions & 9 deletions test/unit/pagination/paginators/BasePaginator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,38 +364,108 @@ describe('BasePaginator', () => {
expect(paginator.mockClientQuery).toHaveBeenCalledTimes(3);
});

it('keeps hasMoreHead unchanged on a keepPreviousItems first-page refresh (offset)', async () => {
// Regression: hasMoreHead is derived from the start offset only when the first page resets
// the window (isFirstPage && !keepPreviousItems). A keepPreviousItems refresh is isFirstPage
// but does NOT reset the offset, so it must not re-derive hasMoreHead from the grown offset.
it('reload() re-fetches the FIRST page (offset 0) after paginating, not the current page', async () => {
// Regression: executeQuery derived the query shape (which carries `offset`) BEFORE the reset
// restored the initial offset, so reload() on an already-paginated list re-fetched the current
// page (e.g. page 2) and replaced page 1 with it. Probe `this.offset` when the shape is derived
// to prove the reset applies to the OUTGOING request, not just to state after the fact.
const offsetAtQueryShape: number[] = [];
class OffsetProbePaginator extends IncompletePaginator {
getNextQueryShape = vi.fn(() => {
offsetAtQueryShape.push(this.offset);
return defaultNextQueryShape;
});
}
const paginator = new OffsetProbePaginator({ pageSize: 1 });

// Page 1 (offset 0 -> 1)
let nextPromise = paginator.toTail();
await sleep(0);
paginator.queryResolve({ items: [{ id: 'id1' }] });
await nextPromise;
expect(paginator.offset).toBe(1);

// Page 2 (offset 1 -> 2)
nextPromise = paginator.toTail();
paginator.queryResolve({ items: [{ id: 'id2' }] });
await nextPromise;
expect(paginator.offset).toBe(2);

// reload() must derive its request from offset 0, not the paginated 2.
nextPromise = paginator.reload();
await sleep(0);
expect(offsetAtQueryShape.at(-1)).toBe(0);

paginator.queryResolve({ items: [{ id: 'id1' }] });
await nextPromise;
expect(paginator.items).toEqual([{ id: 'id1' }]);
});

it('keepPreviousItems + reset is non-destructive: resets to page 1 but keeps the loaded items (offset)', async () => {
// `reset` restores the first-page offset (so the refresh fetches page 1, not the paginated page),
// while `keepPreviousItems` keeps the loaded items AND their storage intact — the fresh page is
// MERGED in, not blanked/collapsed. This is what keeps the channel list stable on a pull-to-refresh
// / reconnect refresh. Guards: (a) offset resets to page 1; (b) items stay visible during the fetch;
// (c) the list is merged (not collapsed to just page 1); (d) hasMoreHead stays anchored at 0.
const paginator = new Paginator({ pageSize: 1 });

// First page from offset 0 -> head is loaded.
// Page 1 (offset 0 -> 1)
let nextPromise = paginator.toTail();
await sleep(0);
paginator.queryResolve({ items: [{ id: 'id1' }] });
await nextPromise;
expect(paginator.hasMoreHead).toBe(false);
expect(paginator.offset).toBe(1);

// Grow the tail so the offset is well past 0.
// Page 2 (offset 1 -> 2)
nextPromise = paginator.toTail();
paginator.queryResolve({ items: [{ id: 'id2' }] });
await nextPromise;
expect(paginator.offset).toBe(2);
expect(paginator.hasMoreHead).toBe(false);
expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]);

// A non-destructive first-page refresh (isFirstPage via reset, keepPreviousItems) must NOT
// flip hasMoreHead to true off the grown offset (2 > 0) — the window still starts at 0.
// keepPreviousItems reset: offset resets to page 1, items stay visible during the fetch.
const refreshPromise = paginator.executeQuery({
keepPreviousItems: true,
reset: 'yes',
});
expect(paginator.offset).toBe(0); // fetches page 1, not the paginated offset
expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); // still visible during the fetch

// The fresh page 1 merges in non-destructively — the loaded list is NOT collapsed to just page 1.
paginator.queryResolve({ items: [{ id: 'id1' }] });
await refreshPromise;
expect(paginator.items?.map((i) => i.id).sort()).toEqual(['id1', 'id2']);
expect(paginator.hasMoreHead).toBe(false);
});

it('keepPreviousItems + reset keeps the loaded items when an item is ingested mid-refresh (no rebuild-from-empty)', async () => {
// Regression: a keepPreviousItems refresh must NOT clear the item index. Otherwise an item ingested
// concurrently while the refresh query is in flight (e.g. a message.new from the offline-send replay
// on reconnect) rebuilds the list from an empty index and collapses it to just that one item until
// the query resolves. The previously-loaded items must survive the mid-refresh ingest.
const paginator = new Paginator({ pageSize: 3 });
const loadPromise = paginator.toTail();
await sleep(0);
paginator.queryResolve({ items: [a, b, c] });
await loadPromise;
expect(paginator.items).toEqual([a, b, c]);

// Start a keepPreviousItems refresh (query in flight, not yet resolved).
const refreshPromise = paginator.executeQuery({
keepPreviousItems: true,
reset: 'yes',
});

// A concurrent ingest lands mid-refresh — the already-loaded items must NOT be wiped.
paginator.ingestItem(v);
const idsMidRefresh = paginator.items?.map((item) => item.id) ?? [];
expect(idsMidRefresh).toEqual(expect.arrayContaining(['a', 'b', 'c']));

paginator.queryResolve({ items: [a, b, c] });
await refreshPromise;
});

it('anchors hasMoreHead from a new start offset when the window is re-established via reset (offset)', async () => {
// To start a window mid-list, set the start offset and reset. isFirstPage is true,
// getStateBeforeFirstQuery runs, and hasMoreHead is anchored from the (new) start offset.
Expand Down
Loading