From 129630ec9b86accd0f133a826156bed4e16dcc81 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 11 Aug 2026 14:43:21 +0200 Subject: [PATCH 01/31] feat: implement destructive reconciliation --- .../paginators/MessageIntervalPaginator.ts | 208 +++++++- src/pagination/paginators/MessagePaginator.ts | 1 + src/thread.ts | 20 +- .../paginators/MessagePaginator.test.ts | 471 ++++++++++++++++++ 4 files changed, 688 insertions(+), 12 deletions(-) diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index 75348e423f..c74a8049ef 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -142,6 +142,37 @@ export type MessagePaginatorOptions = { paginatorOptions?: PaginatorOptions; }; +/** + * Options for {@link MessageIntervalPaginator.mergeNewestPage} that enable destructive + * reconciliation — removing messages that were hard-deleted (by anyone) while the client was + * offline. A hard delete emits no event to other clients, and the merge is otherwise additive, so + * without this such a message lingers as a ghost after reconnect. + * + * With NO options, `mergeNewestPage` still prunes any loaded message that falls WITHIN the returned + * page's `created_at` span but is absent from it — unconditionally safe (a message that arrived live + * during the caller's fetch is always strictly newer than the newest returned message, so it can + * never fall in that span). The options widen the reconcilable window: + */ +export type MergeNewestPageOptions = { + /** + * The `limit` the caller passed to the query that produced the page. Lets reconciliation tell + * "the page reached the channel's oldest message" (a returned count short of the request) from + * "the page is full and older messages remain". Only then may it prune loaded messages OLDER than + * the oldest returned message (e.g. the oldest loaded message was the one deleted). Clamped to the + * server's max page size so an over-request cannot be mistaken for reaching the start. + */ + requestedLimit?: number; + /** + * A snapshot of the loaded message ids taken BEFORE the caller's fetch await. Required to prune + * messages NEWER than the newest returned message (a hard-deleted newest message) and to reconcile + * an empty page (a fully-emptied channel): at/above that top edge a just-deleted message and a + * message that arrived live during the fetch are indistinguishable by timestamp — only the + * pre-fetch snapshot separates them (a live arrival is not in it). Must be captured before the + * await; the paginator's own items at merge time already include any live arrival. + */ + candidateIds?: ReadonlySet; +}; + /** * MessageIntervalPaginator allows configuring backend request sort, while keeping internal item ordering stable. * Filtering of ingested items is still limited to local predicates (`filterQueryResults`). @@ -549,23 +580,30 @@ export class MessageIntervalPaginator extends BasePaginator< * page is new). Merge in place: existing items are reconciled by id (edits, soft deletes), new * items are appended and every already loaded item (including older pages already paged in) is * kept. `hasMoreTail`/`cursor.tailward` are left as-is so the page can be any size, so deriving - * "has older items" from its length would wrongly clear it while older items remain. + * "has older items" from its length would wrongly clear it while older items remain. Then + * destructive reconciliation ({@link reconcileLoadedAgainstPage}) removes any loaded message + * that the authoritative page proves was hard-deleted while offline (see that method + the + * {@link MergeNewestPageOptions} for the exact, safe window). * * 2. DISJOINT - the incoming page shares no id with the loaded head (at least a full page is new). * Merging would weld the two across the gap (the interval merge treats two head intervals as * overlapping when one reaches further headward), hiding the items in between with no way to * reach them. Instead the loaded set is discarded and rebuilt from the incoming page as a fresh * contiguous head (`hasMoreTail: true`, cursor reanchored to the page's oldest item) so the - * gap and older history load again when paginating older. + * gap and older history load again when paginating older. No separate reconciliation is needed: + * the rebuilt window IS the server truth, so any hard-deleted message is simply absent from it. + * + * Never blanks the loaded set. Noop unless the newest slice is both loaded AND the interval + * currently in view (the head interval is anchored at the head and active); when the caller has + * jumped to a separate older window the merge is skipped so their position is preserved, and the + * incoming page is picked up on a later load. An empty page never wipes the list on its own, but + * — given a pre-fetch snapshot via `options.candidateIds` — is reconciled as "the channel has no + * messages" (every server-confirmed loaded message removed). * - * Both paths emit exactly once and never blank the loaded set. Noop unless the page is non empty - * and the newest slice is both loaded AND the interval currently in view (the head interval is - * anchored at the head and active); when the caller has jumped to a separate older window the - * merge is skipped so their position is preserved, and the incoming page is picked up on a later - * load. + * @param page - The fetched newest window (may be empty). `created_at` order is normalized on ingest. + * @param options - See {@link MergeNewestPageOptions}. Omit to prune only within the page's own span. */ - mergeNewestPage = (page: LocalMessage[]) => { - if (!page?.length) return; + mergeNewestPage = (page: LocalMessage[], options?: MergeNewestPageOptions) => { const headInterval = this.itemIntervals[0] as Interval | undefined; if (!headInterval?.isHead) return; // Only reconcile when the head is the interval currently in view. If the caller jumped to a @@ -574,6 +612,14 @@ export class MessageIntervalPaginator extends BasePaginator< // their position (the newest page is picked up on scroll / a later load). if (!this.isActiveInterval(headInterval)) return; + if (!page?.length) { + // Empty page: never blanks the list by itself. Only when the caller supplied a pre-fetch + // snapshot do we treat it as authoritative "channel emptied" and remove ghosts (a message that + // arrived live during the fetch is excluded by the snapshot). + this.reconcileLoadedAgainstPage([], options); + return; + } + const loadedIds = new Set(headInterval.itemIds); const overlapsLoadedHead = page.some((item) => loadedIds.has(this.getItemId(item))); @@ -614,8 +660,152 @@ export class MessageIntervalPaginator extends BasePaginator< // nothing newer to load. hasMoreTail / cursor are deliberately preserved (see above). hasMoreHead: false, }); + + // With the newest page merged in, drop any loaded message the page proves was hard-deleted. + this.reconcileLoadedAgainstPage(page, options); }; + /** + * Whether a loaded message is server-confirmed and therefore eligible to be reconciled away when + * absent from an authoritative page. Excludes local-only messages the server has never + * acknowledged — optimistic (`sending`) and `failed` sends, and client-side `error` placeholders — + * so a legitimately-unsent message is never mistaken for a hard delete. + */ + protected isServerConfirmedMessage(message: LocalMessage): boolean { + return ( + message.status !== 'sending' && + message.status !== 'failed' && + message.type !== 'error' + ); + } + + /** + * Destructive half of {@link mergeNewestPage}: remove loaded messages that the freshly-fetched + * newest `page` proves were hard-deleted while offline (a hard delete emits no event to other + * clients, and the merge is additive, so they would otherwise linger forever). + * + * The reconcilable window is derived ENTIRELY from what the page returned — never a hardcoded page + * size — so it can only ever remove messages the page actually covers: + * + * - WITHIN the page's span (`oldest returned < created_at < newest returned`): a server-confirmed + * loaded message absent from the page was hard-deleted. Safe with no snapshot — a message that + * arrived live during the caller's fetch is always strictly newer than the newest returned + * message, so it can never fall in this span. + * - BELOW the oldest returned message: only reconcilable when the page reached the channel's oldest + * message (`requestedLimit` given and the page came back short, clamped to the server max page + * size). Otherwise older messages simply were not fetched and are left untouched. + * - AT/ABOVE the newest returned message (a hard-deleted newest message) and the empty-page case: + * only reconcilable with a pre-fetch `candidateIds` snapshot, which alone distinguishes a ghost + * from a live arrival at that top edge. + * + * Removal goes through {@link removeItem} (batched) so the item index, intervals, shared message + * store and — via the {@link MessagePaginator} override — the tracked last message all stay + * correct, then the active window is re-emitted once. + */ + protected reconcileLoadedAgainstPage( + page: LocalMessage[], + options?: MergeNewestPageOptions, + ) { + const headInterval = this.itemIntervals[0] as Interval | undefined; + if (!headInterval?.isHead || !this.isActiveInterval(headInterval)) return; + + const loadedIds = headInterval.itemIds; + const candidateIds = options?.candidateIds; + + // Empty page → the channel has no messages. Every server-confirmed loaded message is gone, but + // only remove ids from the pre-fetch snapshot so a message that landed during the fetch survives. + if (!page.length) { + if (!candidateIds) return; + const toRemove = loadedIds.filter((id) => { + if (!candidateIds.has(id)) return false; + const message = this.getItem(id); + return !!message && this.isServerConfirmedMessage(message); + }); + this.removeReconciledIds(toRemove); + return; + } + + const pageIds = new Set(page.map((message) => this.getItemId(message))); + const newestReturnedTs = getMessageCreatedAtTimestamp(page[page.length - 1]); + const oldestReturnedTs = getMessageCreatedAtTimestamp(page[0]); + + // Only extend below the oldest returned message when the page proves it reached the channel's + // oldest message: it came back shorter than requested. Clamp the request to the server's max page + // size so asking for MORE than one page can return (an over-request) is not mistaken for reaching + // the start — in that case the shortfall is the server capping, not the channel ending. + const { requestedLimit } = options ?? {}; + const reachedChannelStart = + typeof requestedLimit === 'number' && + page.length < Math.min(requestedLimit, DEFAULT_CHANNEL_MESSAGE_LIST_PAGE_SIZE); + const windowLowTs = reachedChannelStart + ? Number.NEGATIVE_INFINITY + : (oldestReturnedTs ?? Number.POSITIVE_INFINITY); + + const toRemove: string[] = []; + for (const id of loadedIds) { + if (pageIds.has(id)) continue; // present on the server → keep + const message = this.getItem(id); + if (!message || !this.isServerConfirmedMessage(message)) continue; // local-only → keep + const ts = getMessageCreatedAtTimestamp(message); + if (ts === null) continue; // no server timestamp (optimistic) → keep + + if (newestReturnedTs !== null && ts >= newestReturnedTs) { + // At/above the newest returned message: a hard-deleted newest message and a message that + // arrived live during the fetch are indistinguishable here. Only remove ids present in the + // pre-fetch snapshot, which excludes live arrivals. (The newest returned message itself is + // in the page, so it is already skipped above.) + if (candidateIds?.has(id)) toRemove.push(id); + continue; + } + // Strictly within the page's span (below the newest returned message): a live arrival can never + // be here, so the absence is a hard delete regardless of a snapshot. + if (ts > windowLowTs) toRemove.push(id); + } + + this.removeReconciledIds(toRemove); + } + + /** + * Remove a set of reconciled (hard-deleted) ids in one batch — coalescing the shared-store fan-out + * to a single flush — then flush the deferred window publish so the list drops the ghosts + * synchronously (blanking to `[]` if the active window emptied, per {@link flushWindowPublish}). + * Finally, mirror the removal into the offline DB so a cold start does not re-seed the ghosts from + * SQLite. No-op for an empty set, so an unaffected merge does not touch state a second time. + */ + private removeReconciledIds(ids: string[]) { + if (!ids.length) return; + this._itemIndex.batch(() => { + for (const id of ids) this.removeItem({ id }); + }); + this.flushPendingPublishes(); + this.purgeReconciledFromOfflineDb(ids); + } + + /** + * Mirror a destructive reconciliation into the offline DB. A hard delete performed while the client + * was offline reaches it via no event, so the offline store never ran its own hard-delete for these + * ids; the reconnect query re-hydrates the DB by UPSERT (which never removes what is absent from the + * page), so without this the ghosts survive in SQLite and a cold start would re-seed them after the + * in-memory list already dropped them. + * + * Owned by the state layer, NOT the SDK: the SDK only supplies the platform `offlineDb` + * implementation and never orchestrates DB writes for reconciliation. Fire-and-forget and + * best-effort — the in-memory removal is the source of truth for the live list, so a failed/absent + * DB write is no worse than before (the ghost merely re-appears on a cold start until the next + * reconcile). No-op when offline support is off (`client.offlineDb` undefined). The ids are already + * server-confirmed (the reconcile excludes pending/failed/optimistic), so a plain hard delete — + * without the pending-task teardown of a local failed message — is correct. + */ + private purgeReconciledFromOfflineDb(ids: string[]) { + const offlineDb = this.channel.getClient?.()?.offlineDb; + if (!offlineDb) return; + Promise.all(ids.map((id) => offlineDb.hardDeleteMessage({ id, execute: false }))) + .then((queryBatches) => offlineDb.executeSqlBatch(queryBatches.flat())) + .catch(() => { + // best-effort persistence cleanup — see doc comment; the live list is already correct. + }); + } + protected resolveUnreadBoundaryIdsByTimestamp = ({ lastReadAt, messages, diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index 51519b4d1b..853a403919 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -15,6 +15,7 @@ import { StateStore } from '../../store'; export type { JumpToMessageOptions, + MergeNewestPageOptions, MessageFocusReason, MessageFocusSignal, MessageFocusSignalState, diff --git a/src/thread.ts b/src/thread.ts index 6039a5bfb5..156397905f 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -32,6 +32,7 @@ import { MessageComposer } from './messageComposer'; import { MessageOperations } from './messageOperations'; import { WithSubscriptions } from './utils/WithSubscriptions'; import { MessagePaginator } from './pagination'; +import type { MergeNewestPageOptions } from './pagination'; import type { PipelineEvent } from './EventHandlerPipeline'; export type ThreadState = { @@ -325,17 +326,29 @@ export class Thread extends WithSubscriptions { try { const loadedReplyCount = this.messagePaginator.items?.length ?? 0; + const requestedReplyLimit = loadedReplyCount || this.messagePaginator.pageSize; + const reconcileCandidateIds = new Set( + (this.messagePaginator.items ?? []).map((reply) => reply.id), + ); const thread = await this.client.getThreadAndHydrate(this.id, { watch: true, - reply_limit: loadedReplyCount || this.messagePaginator.pageSize, + reply_limit: requestedReplyLimit, + }); + this.hydrateState(thread, { + reconcile: { + requestedLimit: requestedReplyLimit, + candidateIds: reconcileCandidateIds, + }, }); - this.hydrateState(thread); } finally { this.state.partialNext({ isLoading: false }); } }; - public hydrateState = (thread: Thread) => { + public hydrateState = ( + thread: Thread, + options?: { reconcile?: MergeNewestPageOptions }, + ) => { if (thread === this) { // skip if the instances are the same return; @@ -383,6 +396,7 @@ export class Thread extends WithSubscriptions { this.messagePaginator.mergeNewestPage( thread.messagePaginator.state.getLatestValue().items ?? [], + options?.reconcile, ); pendingReplies.forEach((reply) => this.messagePaginator.ingestItem(reply)); // Carry the re-queried thread's last-activity floor so lastMessageAt stays fresh even when the diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index 865df238a4..bd23ae5a6b 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -1913,6 +1913,477 @@ describe('MessagePaginator', () => { }); }); + describe('mergeNewestPage() — destructive reconciliation', () => { + // A channel (main list) message with a distinct created_at derived from `minute`, so ordering and + // the reconciliation window are unambiguous. Server-confirmed ('received') unless overridden. + const msg = (id: string, minute: number, overrides: Partial = {}) => + createMessage({ + cid: 'channel-id', + id, + created_at: new Date(Date.UTC(2020, 0, 1, 0, minute, 0)).toISOString(), + ...overrides, + }); + + // Loads a newest (head-anchored, active) window from `messages` (any order; sorted on ingest). + const loadHead = ( + messages: LocalMessage[], + { isTail = false }: { isTail?: boolean } = {}, + ) => { + const paginator = new MessagePaginator({ + channel, + itemIndex: new StoreBackedItemIndex({ + getEntityId: (message) => message.id, + }), + }); + paginator.ingestPage({ page: messages, isHead: true, isTail, setActive: true }); + return paginator; + }; + + const ids = (paginator: MessagePaginator) => + paginator.items?.map((message) => message.id); + + // ── WITHIN the returned page's span (default, no options — unconditionally safe) ────────────── + + it('default (no options): drops a hard-deleted message within the returned page span', () => { + const paginator = loadHead([ + msg('m1', 1), + msg('m2', 2), + msg('m3', 3), + msg('m4', 4), + msg('m5', 5), + ]); + // m3 hard-deleted while offline: the authoritative newest page comes back without it. + paginator.mergeNewestPage([msg('m1', 1), msg('m2', 2), msg('m4', 4), msg('m5', 5)]); + expect(ids(paginator)).toEqual(['m1', 'm2', 'm4', 'm5']); + expect(paginator.getItem('m3')).toBeUndefined(); + }); + + it('drops several hard-deleted messages in one pass', () => { + const paginator = loadHead([ + msg('m1', 1), + msg('m2', 2), + msg('m3', 3), + msg('m4', 4), + msg('m5', 5), + msg('m6', 6), + ]); + // m2 and m4 hard-deleted. + paginator.mergeNewestPage([msg('m1', 1), msg('m3', 3), msg('m5', 5), msg('m6', 6)]); + expect(ids(paginator)).toEqual(['m1', 'm3', 'm5', 'm6']); + }); + + it('reconciles deletions AND additions delivered by the same page', () => { + const paginator = loadHead([ + msg('m1', 1), + msg('m2', 2), + msg('m3', 3), + msg('m4', 4), + ]); + // m3 hard-deleted; m5 and m6 arrived while offline — all in the one authoritative page. + paginator.mergeNewestPage([ + msg('m1', 1), + msg('m2', 2), + msg('m4', 4), + msg('m5', 5), + msg('m6', 6), + ]); + expect(ids(paginator)).toEqual(['m1', 'm2', 'm4', 'm5', 'm6']); + expect(paginator.getItem('m3')).toBeUndefined(); + }); + + it('keeps a soft-deleted message (the server still returns it, so it is in the page)', () => { + const paginator = loadHead([msg('m1', 1), msg('m2', 2), msg('m3', 3)]); + paginator.mergeNewestPage([ + msg('m1', 1), + msg('m2', 2, { type: 'deleted', deleted_at: '2020-01-01T00:10:00.000Z' }), + msg('m3', 3), + ]); + expect(paginator.getItem('m2')).toBeDefined(); + expect(paginator.getItem('m2')?.type).toBe('deleted'); + expect(ids(paginator)).toEqual(['m1', 'm2', 'm3']); + }); + + // ── OLDER than the returned page (must be left untouched unless the page reached the start) ─── + + it('leaves loaded messages older than the returned page untouched (full page, start not reached)', () => { + const paginator = loadHead([ + msg('m1', 1), + msg('m2', 2), + msg('m3', 3), + msg('m4', 4), + msg('m5', 5), + msg('m6', 6), + ]); + // A full page (returned === requested) covering only the newest four; m4 was hard-deleted, so + // m2 slid into the window: page = [m2,m3,m5,m6]. Older m1 is below the page and MUST stay. + paginator.mergeNewestPage( + [msg('m2', 2), msg('m3', 3), msg('m5', 5), msg('m6', 6)], + { + requestedLimit: 4, + }, + ); + expect(paginator.getItem('m4')).toBeUndefined(); // within-window delete removed + expect(paginator.getItem('m1')).toBeDefined(); // older-than-page kept + expect(ids(paginator)).toEqual(['m1', 'm2', 'm3', 'm5', 'm6']); + }); + + it('removes the OLDEST loaded message once the page proves it reached the channel start', () => { + // Whole channel loaded. m1 (oldest) hard-deleted → a request for four returns only three. + const paginator = loadHead( + [msg('m1', 1), msg('m2', 2), msg('m3', 3), msg('m4', 4)], + { + isTail: true, + }, + ); + paginator.mergeNewestPage([msg('m2', 2), msg('m3', 3), msg('m4', 4)], { + requestedLimit: 4, + }); + expect(paginator.getItem('m1')).toBeUndefined(); + expect(ids(paginator)).toEqual(['m2', 'm3', 'm4']); + }); + + it('keeps the oldest loaded message when the page did NOT prove it reached the start', () => { + const paginator = loadHead( + [msg('m1', 1), msg('m2', 2), msg('m3', 3), msg('m4', 4)], + { + isTail: true, + }, + ); + // A FULL page (returned === requested) that simply does not reach m1 → cannot claim m1 deleted. + paginator.mergeNewestPage([msg('m2', 2), msg('m3', 3), msg('m4', 4)], { + requestedLimit: 3, + }); + expect(paginator.getItem('m1')).toBeDefined(); + expect(ids(paginator)).toEqual(['m1', 'm2', 'm3', 'm4']); + }); + + // ── AT/ABOVE the newest returned message: trailing deletes (need a pre-fetch snapshot) ──────── + + it('keeps a hard-deleted NEWEST message without a snapshot (documented limitation)', () => { + const paginator = loadHead([ + msg('m1', 1), + msg('m2', 2), + msg('m3', 3), + msg('m4', 4), + msg('m5', 5), + ]); + // m5 (newest) deleted; the page's newest is now m4. No snapshot ⇒ the top edge is ambiguous. + paginator.mergeNewestPage( + [msg('m1', 1), msg('m2', 2), msg('m3', 3), msg('m4', 4)], + { + requestedLimit: 5, + }, + ); + expect(paginator.getItem('m5')).toBeDefined(); + }); + + it('drops a hard-deleted NEWEST message WITH a snapshot and recomputes lastMessage', () => { + const loaded = [ + msg('m1', 1), + msg('m2', 2), + msg('m3', 3), + msg('m4', 4), + msg('m5', 5), + ]; + const paginator = loadHead(loaded); + expect(paginator.lastMessage?.id).toBe('m5'); + const candidateIds = new Set(loaded.map((message) => message.id)); + + paginator.mergeNewestPage( + [msg('m1', 1), msg('m2', 2), msg('m3', 3), msg('m4', 4)], + { + candidateIds, + requestedLimit: 5, + }, + ); + + expect(ids(paginator)).toEqual(['m1', 'm2', 'm3', 'm4']); + expect(paginator.getItem('m5')).toBeUndefined(); + expect(paginator.lastMessage?.id).toBe('m4'); // tracked latest fell back to the newest survivor + }); + + it('drops multiple hard-deleted trailing messages with a snapshot', () => { + const loaded = [ + msg('m1', 1), + msg('m2', 2), + msg('m3', 3), + msg('m4', 4), + msg('m5', 5), + ]; + const paginator = loadHead(loaded); + const candidateIds = new Set(loaded.map((message) => message.id)); + + paginator.mergeNewestPage([msg('m1', 1), msg('m2', 2), msg('m3', 3)], { + candidateIds, + requestedLimit: 5, + }); + + expect(ids(paginator)).toEqual(['m1', 'm2', 'm3']); + expect(paginator.lastMessage?.id).toBe('m3'); + }); + + // ── The live-race: a message that arrived during the fetch must never be pruned ────────────── + + it('keeps a message that arrived live during the fetch while dropping a trailing ghost', () => { + // Loaded before the fetch: m1..m4 plus a soon-to-be-deleted newest ghost m5. + const loadedBefore = [ + msg('m1', 1), + msg('m2', 2), + msg('m3', 3), + msg('m4', 4), + msg('m5', 5), + ]; + const paginator = loadHead(loadedBefore); + const candidateIds = new Set(loadedBefore.map((message) => message.id)); // snapshot BEFORE fetch + + // During the fetch a brand-new message m6 arrives via WS and is ingested into the head. + paginator.ingestItem(msg('m6', 6)); + expect(paginator.getItem('m6')).toBeDefined(); + + // The server's authoritative page (computed before m6 existed) has m5 deleted and lacks m6. + paginator.mergeNewestPage( + [msg('m1', 1), msg('m2', 2), msg('m3', 3), msg('m4', 4)], + { + candidateIds, + requestedLimit: 5, + }, + ); + + // m5 (ghost, in the snapshot) removed; m6 (live arrival, NOT in the snapshot) kept. + expect(paginator.getItem('m5')).toBeUndefined(); + expect(paginator.getItem('m6')).toBeDefined(); + expect(ids(paginator)).toEqual(['m1', 'm2', 'm3', 'm4', 'm6']); + }); + + // ── Provenance: never reconcile away local-only (unsent) messages ──────────────────────────── + + it('never removes optimistic (sending), failed, or error-type local messages', () => { + const loaded = [ + msg('m1', 1), + msg('sending', 2, { status: 'sending' }), + msg('failed', 3, { status: 'failed' }), + msg('err', 4, { type: 'error' }), + msg('m5', 5), + ]; + const paginator = loadHead(loaded); + const candidateIds = new Set(loaded.map((message) => message.id)); + + // The server page only has the confirmed m1 + m5; the local-only ones the server never saw. + paginator.mergeNewestPage([msg('m1', 1), msg('m5', 5)], { + candidateIds, + requestedLimit: 5, + }); + + expect(paginator.getItem('sending')).toBeDefined(); + expect(paginator.getItem('failed')).toBeDefined(); + expect(paginator.getItem('err')).toBeDefined(); + expect(paginator.getItem('m1')).toBeDefined(); + expect(paginator.getItem('m5')).toBeDefined(); + }); + + it('removes a hard-deleted confirmed message while keeping a co-located failed one', () => { + const loaded = [ + msg('m1', 1), + msg('failed', 2, { status: 'failed' }), + msg('m3', 3), + msg('m4', 4), + ]; + const paginator = loadHead(loaded); + const candidateIds = new Set(loaded.map((message) => message.id)); + + // m3 hard-deleted; the failed send was never on the server. Page: [m1, m4]. + paginator.mergeNewestPage([msg('m1', 1), msg('m4', 4)], { + candidateIds, + requestedLimit: 4, + }); + + expect(paginator.getItem('m3')).toBeUndefined(); + expect(paginator.getItem('failed')).toBeDefined(); + expect(ids(paginator)).toEqual(['m1', 'failed', 'm4']); + }); + + // ── Empty page: whole channel emptied (or a missed truncate) ───────────────────────────────── + + it('empties the list when the channel returns no messages (with a snapshot)', () => { + const loaded = [msg('m1', 1), msg('m2', 2), msg('m3', 3)]; + const paginator = loadHead(loaded); + const candidateIds = new Set(loaded.map((message) => message.id)); + + paginator.mergeNewestPage([], { candidateIds, requestedLimit: 3 }); + + expect(paginator.items).toEqual([]); + expect(paginator.lastMessage).toBeNull(); + }); + + it('does NOT blank on an empty page without a snapshot (safe default)', () => { + const paginator = loadHead([msg('m1', 1), msg('m2', 2), msg('m3', 3)]); + paginator.mergeNewestPage([]); + expect(ids(paginator)).toEqual(['m1', 'm2', 'm3']); + }); + + it('keeps a live arrival on an empty page (only snapshot ids are removed)', () => { + const loadedBefore = [msg('m1', 1), msg('m2', 2)]; + const paginator = loadHead(loadedBefore); + const candidateIds = new Set(loadedBefore.map((message) => message.id)); + + // A live message arrives during the fetch, then the (stale) empty page comes back. + paginator.ingestItem(msg('m3', 3)); + paginator.mergeNewestPage([], { candidateIds, requestedLimit: 2 }); + + expect(paginator.getItem('m1')).toBeUndefined(); + expect(paginator.getItem('m2')).toBeUndefined(); + expect(paginator.getItem('m3')).toBeDefined(); + expect(ids(paginator)).toEqual(['m3']); + }); + + // ── Structural guards preserved ────────────────────────────────────────────────────────────── + + it('does not reconcile on a disjoint reset (the rebuilt window is already authoritative)', () => { + const loaded = [msg('m1', 1), msg('m2', 2), msg('m3', 3)]; + const paginator = loadHead(loaded); + const candidateIds = new Set(loaded.map((message) => message.id)); + + // A fully-disjoint newest window (100+ arrived). Rebuild replaces the loaded set; no extra prune. + paginator.mergeNewestPage([msg('m10', 10), msg('m11', 11), msg('m12', 12)], { + candidateIds, + requestedLimit: 3, + }); + + expect(ids(paginator)).toEqual(['m10', 'm11', 'm12']); + }); + + it('does not reconcile when the caller is viewing a separate older window', () => { + const paginator = new MessagePaginator({ + channel, + itemIndex: new StoreBackedItemIndex({ + getEntityId: (message) => message.id, + }), + }); + paginator.ingestPage({ + page: [msg('m8', 8), msg('m9', 9), msg('m10', 10)], + isHead: true, + isTail: false, + setActive: true, + }); + paginator.ingestPage({ + page: [msg('m1', 1), msg('m2', 2), msg('m3', 3)], + isHead: false, + isTail: false, + setActive: true, + }); + + // Active window is the older [m1,m2,m3]; the head holds m8,m9,m10 (m9 "deleted" server-side). + paginator.mergeNewestPage([msg('m8', 8), msg('m10', 10)], { + candidateIds: new Set(['m8', 'm9', 'm10']), + requestedLimit: 3, + }); + + // Skipped entirely: the older window is preserved and the head ghost m9 is untouched. + expect(ids(paginator)).toEqual(['m1', 'm2', 'm3']); + expect(paginator.getItem('m9')).toBeDefined(); + }); + + it('is idempotent — a second reconcile against the same page removes nothing more', () => { + const loaded = [msg('m1', 1), msg('m2', 2), msg('m3', 3), msg('m4', 4)]; + const paginator = loadHead(loaded); + + paginator.mergeNewestPage([msg('m1', 1), msg('m3', 3), msg('m4', 4)], { + candidateIds: new Set(loaded.map((message) => message.id)), + requestedLimit: 4, + }); + expect(ids(paginator)).toEqual(['m1', 'm3', 'm4']); + + paginator.mergeNewestPage([msg('m1', 1), msg('m3', 3), msg('m4', 4)], { + candidateIds: new Set(['m1', 'm3', 'm4']), + requestedLimit: 4, + }); + expect(ids(paginator)).toEqual(['m1', 'm3', 'm4']); + }); + + // ── The "100 point" is data-driven, never hardcoded: reconcile only within the returned page ── + + it('never reconciles messages older than what the page returns, even on an over-request (clamp)', () => { + // 105 loaded (whole channel). The server caps its response at 100 (the newest 100). We requested + // more (105) but only 100 come back — that shortfall must NOT be read as "reached the channel + // start", or the 5 oldest (beyond the page) would be wrongly deleted. + const all = Array.from({ length: 105 }, (_, i) => + msg(`msg-${String(i).padStart(3, '0')}`, i), + ); + const paginator = loadHead(all, { isTail: true }); + const page = all.slice(5); // the newest 100 — the server's capped response + + paginator.mergeNewestPage(page, { + candidateIds: new Set(all.map((message) => message.id)), + requestedLimit: 105, + }); + + // All 105 kept: nothing was actually deleted, and the 5 oldest are beyond the page's reach. + expect(paginator.items?.length).toBe(105); + expect(paginator.getItem('msg-000')).toBeDefined(); + expect(paginator.getItem('msg-004')).toBeDefined(); + }); + + it('reconciles a deletion inside the returned page while keeping messages beyond it', () => { + const all = Array.from({ length: 105 }, (_, i) => + msg(`msg-${String(i).padStart(3, '0')}`, i), + ); + const paginator = loadHead(all, { isTail: true }); + // msg-050 hard-deleted; the server's newest 100 now reaches one further back. + const survivors = all.filter((message) => message.id !== 'msg-050'); + const page = survivors.slice(survivors.length - 100); + + paginator.mergeNewestPage(page, { + candidateIds: new Set(all.map((message) => message.id)), + requestedLimit: 105, + }); + + expect(paginator.getItem('msg-050')).toBeUndefined(); // within-page delete removed + expect(paginator.getItem('msg-000')).toBeDefined(); // beyond the page → kept + }); + + // ── Offline DB is kept in lockstep, entirely from the LLC (no SDK orchestration) ───────────── + + it('mirrors reconciled ghosts into the offline DB in one batch (LLC-owned)', async () => { + const hardDeleteMessage = vi.fn().mockResolvedValue([]); + const executeSqlBatch = vi.fn().mockResolvedValue(undefined); + const channelWithOfflineDb = { + cid: 'channel-id', + getReplies: vi.fn(), + query: vi.fn(), + getClient: () => ({ offlineDb: { hardDeleteMessage, executeSqlBatch } }), + } as unknown as Channel; + const paginator = new MessagePaginator({ + channel: channelWithOfflineDb, + itemIndex: new StoreBackedItemIndex({ + getEntityId: (message) => message.id, + }), + }); + paginator.ingestPage({ + page: [msg('m1', 1), msg('m2', 2), msg('m3', 3), msg('m4', 4)], + isHead: true, + isTail: true, + setActive: true, + }); + + // m3 hard-deleted while offline: gone from the in-memory list AND from SQLite. + paginator.mergeNewestPage([msg('m1', 1), msg('m2', 2), msg('m4', 4)]); + + expect(paginator.getItem('m3')).toBeUndefined(); + expect(hardDeleteMessage).toHaveBeenCalledWith({ id: 'm3', execute: false }); + // The per-id delete queries are collected (execute:false) and run as a single transaction. + await Promise.resolve(); + await Promise.resolve(); + expect(executeSqlBatch).toHaveBeenCalledTimes(1); + }); + + it('reconciles in-memory without error when offline support is disabled (no offlineDb)', () => { + // The default mock channel has no getClient/offlineDb → the DB purge is a guarded no-op. + const paginator = loadHead([msg('m1', 1), msg('m2', 2), msg('m3', 3)]); + expect(() => paginator.mergeNewestPage([msg('m1', 1), msg('m3', 3)])).not.toThrow(); + expect(paginator.getItem('m2')).toBeUndefined(); + }); + }); + describe('trackLastMessage() / lastMessageAt', () => { let skipSystemMessages: boolean; let trackingChannel: Channel; From 4b34488108d5e47d2da2e6d78cdb8697ab31fa7b Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 11 Aug 2026 15:03:32 +0200 Subject: [PATCH 02/31] chore: move api to offline db api --- src/offline-support/offline_support_api.ts | 28 ++++++++++ .../paginators/MessageIntervalPaginator.ts | 9 ++-- .../offline_support_api.test.ts | 54 +++++++++++++++++++ .../paginators/MessagePaginator.test.ts | 16 +++--- 4 files changed, 92 insertions(+), 15 deletions(-) diff --git a/src/offline-support/offline_support_api.ts b/src/offline-support/offline_support_api.ts index ef478595b5..29b9098f39 100644 --- a/src/offline-support/offline_support_api.ts +++ b/src/offline-support/offline_support_api.ts @@ -746,6 +746,34 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { return queries; }; + /** + * Hard-delete a set of messages by id in a single transaction. A convenience over N individual + * {@link hardDeleteMessage} calls: collects each delete's queries (`execute: false`) and runs them + * as one batch. Used e.g. by destructive reconciliation on reconnect to mirror a set of in-memory + * removals into the DB. No-op for an empty set. + * + * @param payload.ids - The ids of the messages to hard-delete. + * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`). + */ + public hardDeleteMessages = async ({ + ids, + execute = true, + }: { + ids: string[]; + execute?: boolean; + }) => { + if (!ids.length) return []; + const queries = ( + await Promise.all(ids.map((id) => this.hardDeleteMessage({ id, execute: false }))) + ).flat(); + + if (execute) { + await this.executeSqlBatch(queries); + } + + return queries; + }; + /** * A utility method to handle read events. It will calculate the state of the reads if * present in the event, or optionally rely on the hard override in unreadMessages. diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index c74a8049ef..0e63628a27 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -799,11 +799,10 @@ export class MessageIntervalPaginator extends BasePaginator< private purgeReconciledFromOfflineDb(ids: string[]) { const offlineDb = this.channel.getClient?.()?.offlineDb; if (!offlineDb) return; - Promise.all(ids.map((id) => offlineDb.hardDeleteMessage({ id, execute: false }))) - .then((queryBatches) => offlineDb.executeSqlBatch(queryBatches.flat())) - .catch(() => { - // best-effort persistence cleanup — see doc comment; the live list is already correct. - }); + // The offline DB owns the batching (single transaction); we just hand it the reconciled ids. + offlineDb.hardDeleteMessages({ ids }).catch(() => { + // best-effort persistence cleanup — see doc comment; the live list is already correct. + }); } protected resolveUnreadBoundaryIdsByTimestamp = ({ diff --git a/test/unit/offline-support/offline_support_api.test.ts b/test/unit/offline-support/offline_support_api.test.ts index 10aec4e9c9..51c4caa0dd 100644 --- a/test/unit/offline-support/offline_support_api.test.ts +++ b/test/unit/offline-support/offline_support_api.test.ts @@ -816,6 +816,60 @@ describe('OfflineSupportApi', () => { }); }); + describe('hardDeleteMessages', () => { + beforeEach(() => { + offlineDb.hardDeleteMessage.mockResolvedValue([['DELETE hard']]); + offlineDb.executeSqlBatch.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('hard deletes every id and runs all queries as a single batch', async () => { + const result = await offlineDb.hardDeleteMessages({ ids: ['a', 'b'] }); + + expect(offlineDb.hardDeleteMessage).toHaveBeenCalledTimes(2); + expect(offlineDb.hardDeleteMessage).toHaveBeenCalledWith({ + id: 'a', + execute: false, + }); + expect(offlineDb.hardDeleteMessage).toHaveBeenCalledWith({ + id: 'b', + execute: false, + }); + // Each id's queries are collected (execute:false) and flushed in one transaction. + expect(offlineDb.executeSqlBatch).toHaveBeenCalledTimes(1); + expect(offlineDb.executeSqlBatch).toHaveBeenCalledWith([ + ['DELETE hard'], + ['DELETE hard'], + ]); + expect(result).toEqual([['DELETE hard'], ['DELETE hard']]); + }); + + it('returns the collected queries without executing them when execute is false', async () => { + const result = await offlineDb.hardDeleteMessages({ + ids: ['a'], + execute: false, + }); + + expect(offlineDb.hardDeleteMessage).toHaveBeenCalledWith({ + id: 'a', + execute: false, + }); + expect(offlineDb.executeSqlBatch).not.toHaveBeenCalled(); + expect(result).toEqual([['DELETE hard']]); + }); + + it('is a no-op for an empty id set (touches neither the delete nor the batch)', async () => { + const result = await offlineDb.hardDeleteMessages({ ids: [] }); + + expect(offlineDb.hardDeleteMessage).not.toHaveBeenCalled(); + expect(offlineDb.executeSqlBatch).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + }); + describe('handleRead', () => { let readEvent: Event; diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index bd23ae5a6b..a79bddadd2 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -2343,14 +2343,13 @@ describe('MessagePaginator', () => { // ── Offline DB is kept in lockstep, entirely from the LLC (no SDK orchestration) ───────────── - it('mirrors reconciled ghosts into the offline DB in one batch (LLC-owned)', async () => { - const hardDeleteMessage = vi.fn().mockResolvedValue([]); - const executeSqlBatch = vi.fn().mockResolvedValue(undefined); + it('mirrors reconciled ghosts into the offline DB via the DB batch API (LLC-owned)', () => { + const hardDeleteMessages = vi.fn().mockResolvedValue([]); const channelWithOfflineDb = { cid: 'channel-id', getReplies: vi.fn(), query: vi.fn(), - getClient: () => ({ offlineDb: { hardDeleteMessage, executeSqlBatch } }), + getClient: () => ({ offlineDb: { hardDeleteMessages } }), } as unknown as Channel; const paginator = new MessagePaginator({ channel: channelWithOfflineDb, @@ -2365,15 +2364,12 @@ describe('MessagePaginator', () => { setActive: true, }); - // m3 hard-deleted while offline: gone from the in-memory list AND from SQLite. + // m3 hard-deleted while offline: gone from the in-memory list AND from SQLite. The paginator + // just hands the reconciled ids to the DB's batch helper — it owns the transaction. paginator.mergeNewestPage([msg('m1', 1), msg('m2', 2), msg('m4', 4)]); expect(paginator.getItem('m3')).toBeUndefined(); - expect(hardDeleteMessage).toHaveBeenCalledWith({ id: 'm3', execute: false }); - // The per-id delete queries are collected (execute:false) and run as a single transaction. - await Promise.resolve(); - await Promise.resolve(); - expect(executeSqlBatch).toHaveBeenCalledTimes(1); + expect(hardDeleteMessages).toHaveBeenCalledWith({ ids: ['m3'] }); }); it('reconciles in-memory without error when offline support is disabled (no offlineDb)', () => { From 7be972e6de2832149cec98d36b0ed0a2786f2e03 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 12 Aug 2026 01:20:27 +0200 Subject: [PATCH 03/31] feat: generalize merging the newest page and destructive reconciliation --- src/channel.ts | 72 +++- src/client.ts | 2 + .../paginators/MessageIntervalPaginator.ts | 76 +++- src/pagination/paginators/MessagePaginator.ts | 1 + test/unit/channel.test.js | 143 +++++++ .../paginators/MessagePaginator.test.ts | 371 +++++++++++++++++- test/unit/threads.test.ts | 65 +++ 7 files changed, 714 insertions(+), 16 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 0a023e8ecb..6753a623dd 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -17,7 +17,6 @@ import { } from './utils'; import type { StreamChat } from './client'; import { chatLoggerSystem } from './logger'; -import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from './constants'; import type { AIState, APIResponse, @@ -175,6 +174,8 @@ export class Channel extends ChannelApi { lastTypingEvent: Date | null; isTyping: boolean; disconnected: boolean; + /** Re-entrancy guard for {@link Channel.reload} (mirrors Thread.reload's isLoading guard). */ + private _reloading = false; push_preferences?: Gen_ChannelPushPreferencesResponse; public readonly configState = new StateStore({}); public readonly messageComposer: MessageComposer; @@ -1297,6 +1298,46 @@ export class Channel extends ChannelApi { return state; } + /** + * Re-watch the channel and refresh its FULL loaded message window — the channel analog of + * {@link Thread.reload}. Used on reconnect to catch up AND reconcile hard deletes that happened + * while offline: a hard delete reaches other clients via no event, so an offline client only learns + * of it by diffing the re-queried page. + * + * This is intentionally thin: it only re-issues `watch()` with a limit sized to the loaded window + * (`items.length`, so the whole loaded window is refreshed — not the smaller channel-list page). The + * actual fold + destructive reconciliation happens inside `query()` → `seedFirstPageSync` + * (the same path the channel-list re-hydrate and React's `recoverState` use), driven by the loaded-id + * snapshot `query()` captures before its await. Owning that single path is what lets the SDK stop + * passing the reconciliation window/snapshot itself. + * + * Preserves failed (unsent) messages: an overlap merge keeps them (the reconcile's provenance guard + * never prunes a non-server message); only a disjoint rebuild can drop them, so any that actually + * fell out are re-ingested below. + */ + async reload() { + if (this._reloading || (!this.initialized && !this.offlineMode)) return; + this._reloading = true; + try { + const paginator = this.messagePaginator; + // Captured BEFORE the await: request our full loaded window (not the list's smaller page), and + // remember failed (unsent) messages so a disjoint rebuild does not silently drop them. + const requestedLimit = paginator.items?.length || paginator.pageSize; + const failedBefore = (paginator.items ?? []).filter( + (message) => message.status === 'failed', + ); + + await this.watch({ messages: { limit: requestedLimit } }); + this.offlineMode = false; + + for (const failed of failedBefore) { + if (!paginator.getItem(failed.id)) paginator.ingestItem(failed); + } + } finally { + this._reloading = false; + } + } + /** * Stops watching the channel. * @@ -1482,6 +1523,24 @@ export class Channel extends ChannelApi { options: ChannelGetOrCreateRequest = {}, messageSetToAddToIfDoesNotExist: MessageSetType = 'current', ) { + // Snapshot the loaded message ids BEFORE the network await, for a latest-window (re)seed only. + // When this query re-seeds an already-loaded window (reconnect / re-hydrate), seedFirstPageSync + // reconciles the fresh page against what is loaded, and the snapshot lets it tell an offline + // hard-delete (in the snapshot, absent from the page) from a message that arrives live + // during the fetch (not in the snapshot). Captured here — the only place with the pre-await state — + // so callers (channel.reload, watch) need not thread it. Empty on a cold open, so it is harmless. + const candidateIds = + messageSetToAddToIfDoesNotExist === 'latest' + ? new Set(this.messagePaginator.items?.map((message) => message.id) ?? []) + : undefined; + + // The INITIAL channel-open query honors the paginator's OWN pageSize (light on native, 25) rather + // than the server's larger default — opening loads the same page size it paginates by. A caller + // that already knows how much to fetch passes an explicit messages.limit, which is respected as-is: + // a reconnect/re-hydrate sizes it to the loaded window (channel.reload → items.length), and + // pagination/around pass their own cursors + limit. + const requestedPageSize = options?.messages?.limit ?? this.messagePaginator.pageSize; + // Make sure we wait for the connect promise if there is a pending one await this.getClient().wsPromise; @@ -1489,6 +1548,13 @@ export class Channel extends ChannelApi { data: this._data, state: true, ...options, + // Ask the server for exactly the initial-open page size (not its default), so the loaded window + // matches the paginator's pageSize. Explicit messages (reconnect/around/pagination) pass through. + messages: + options?.messages ?? + (messageSetToAddToIfDoesNotExist === 'latest' + ? { limit: requestedPageSize } + : undefined), }; const state = this.id @@ -1542,8 +1608,6 @@ export class Channel extends ChannelApi { // latest-page open paths (watch/create) pass 'latest' — the paginator's own pagination queries // use 'current' and must not be reseeded as a first page here. if (messageSetToAddToIfDoesNotExist === 'latest' && Array.isArray(state.messages)) { - const requestedPageSize = - options?.messages?.limit ?? DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE; // Pass the query's message pagination options through: a channel can be opened AROUND a // message (id_around / created_at_around), in which case the fetched page is a jump window, // not the latest page — the paginator must reconcile it with jump semantics. @@ -1551,6 +1615,8 @@ export class Channel extends ChannelApi { state.messages.map(formatMessage), requestedPageSize, options?.messages, + // Re-seed of an already-loaded window folds + reconciles instead of blanking (see above). + { candidateIds, reconcile: true }, ); } diff --git a/src/client.ts b/src/client.ts index be680e1fd8..e2e7d9c577 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1445,6 +1445,8 @@ export class StreamChat extends ChatApi { c.messagePaginator.seedFirstPageSync( channelState.messages.map(formatMessage), requestedPageSize, + undefined, + { reconcile: true }, ); } diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index 0e63628a27..6fbcbfb539 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -173,6 +173,28 @@ export type MergeNewestPageOptions = { candidateIds?: ReadonlySet; }; +/** + * Options for {@link MessageIntervalPaginator.seedFirstPageSync}, the synchronous channel-open seed. + */ +export type SeedFirstPageOptions = { + /** + * When true and the paginator has already been seeded once, fold the fresh page via + * {@link MessageIntervalPaginator.mergeNewestPage} — merge in place, reconcile offline hard-deletes, + * re-derive the tail cursor, rebuild on a disjoint window, and skip when jumped away from the head — + * instead of a plain first-page ingest. Lets callers (channel.reload, the channel-list hydrate, + * React's `recoverState`) share one reconciling seed path. Left false for the differently-sorted + * pinned list, and a no-op on a cold open (nothing loaded to fold). + */ + reconcile?: boolean; + /** + * Pre-fetch snapshot of loaded message ids (captured before the caller's network await), forwarded + * to {@link MergeNewestPageOptions.candidateIds} so a hard-deleted NEWEST message is reconciled + * without mistaking a message that arrived live during the fetch for a delete. Only consulted when + * {@link reconcile} is set and a window is already loaded. + */ + candidateIds?: ReadonlySet; +}; + /** * MessageIntervalPaginator allows configuring backend request sort, while keeping internal item ordering stable. * Filtering of ingested items is still limited to local predicates (`filterQueryResults`). @@ -430,12 +452,22 @@ export class MessageIntervalPaginator extends BasePaginator< messages: LocalMessage[], requestedPageSize: number, messagePaginationOptions?: MessagePaginationOptions, + options?: SeedFirstPageOptions, ) { const queryShape: MessageQueryShape = { ...messagePaginationOptions, limit: requestedPageSize, }; const isJump = this.isJumpQueryShape(queryShape); + + if (options?.reconcile && !isJump && typeof this.items !== 'undefined') { + this.mergeNewestPage(messages, { + candidateIds: options.candidateIds, + requestedLimit: requestedPageSize, + }); + return; + } + this.postQueryReconcile({ // A jump/around page spans both directions; a plain latest page paginates tailward (older). direction: isJump ? undefined : 'tailward', @@ -579,9 +611,11 @@ export class MessageIntervalPaginator extends BasePaginator< * 1. OVERLAP - the incoming page shares at least one id with the loaded head (fewer than a full * page is new). Merge in place: existing items are reconciled by id (edits, soft deletes), new * items are appended and every already loaded item (including older pages already paged in) is - * kept. `hasMoreTail`/`cursor.tailward` are left as-is so the page can be any size, so deriving - * "has older items" from its length would wrongly clear it while older items remain. Then - * destructive reconciliation ({@link reconcileLoadedAgainstPage}) removes any loaded message + * kept. The tail boundary (`hasMoreTail`/`cursor.tailward`) is taken from the MERGED interval: a + * partial page leaves it untouched, while a page reaching deeper than the loaded window (or a + * stale offline-DB cursor) re-anchors it to the true loaded oldest so "load older" keeps working — + * derived from the interval, never the page's length. Then destructive reconciliation + * ({@link reconcileLoadedAgainstPage}) removes any loaded message * that the authoritative page proves was hard-deleted while offline (see that method + the * {@link MergeNewestPageOptions} for the exact, safe window). * @@ -649,16 +683,46 @@ export class MessageIntervalPaginator extends BasePaginator< return; } - // Overlapping window: merge in place, preserving the older boundary. + // Overlapping window: merge in place, keeping every already-loaded (incl. older) item. const interval = this.ingestPage({ page, isHead: true, setActive: false }); if (!interval) return; + // Re-compute hasMoreTail from the FETCHED PAGE, not the merged interval. The interval's isTail is + // "sticky" — mergeTwoAnchoredIntervals ORs isTail — so a stale offline-DB window persisted as + // "complete" (isTail:true) keeps hasMoreTail=false through the merge, and "load older" stays dead. + // A full page (length == the limit we asked for) means older messages remain; a short page means we + // reached the channel start. Without a caller-supplied limit (a live-edit merge of unknown size) + // fall back to the interval's own flag. Pagination reads off STATE, so writing it here is what + // unblocks "load older"; a later executeQuery re-derives per page. Mirrors postQueryReconcile, + // which likewise derives this flag from the page rather than the interval. + const { requestedLimit } = options ?? {}; + const canDeriveTail = typeof requestedLimit === 'number'; + const reachedChannelStart = + canDeriveTail && + page.length < Math.min(requestedLimit, DEFAULT_CHANNEL_MESSAGE_LIST_PAGE_SIZE); + const hasMoreTail = canDeriveTail ? !reachedChannelStart : interval.hasMoreTail; + + // Correct the INTERVAL flags too, not just state. `intervalsOverlap` (the merge test in + // ingestPage) consults `interval.isTail`: a sticky `isTail:true` inherited from a "complete" + // offline-DB window makes ANY older page count as overlapping this head interval — so a far + // jump's load-older welds two pages-apart sets into one. Derive isTail from the page here like the + // state above; mirrors postQueryReconcile (`interval.isTail = hasMoreTail === false`). Only a + // genuine channel-start interval (no older messages) keeps isTail:true. + interval.hasMoreTail = hasMoreTail; + interval.isTail = hasMoreTail === false; + this.setActiveInterval(interval, { updateState: false }); this.state.partialNext({ items: this.intervalToItems(interval), - // The newest slice is loaded (head anchored), so after merging the head window there is - // nothing newer to load. hasMoreTail / cursor are deliberately preserved (see above). + // The newest slice is loaded (head anchored), so there is nothing newer to load. hasMoreHead: false, + hasMoreTail, + // Tailward = the oldest loaded id (interval item ids are created_at asc), null once we reached + // the channel start. + cursor: { + headward: null, + tailward: hasMoreTail ? (interval.itemIds[0] ?? null) : null, + }, }); // With the newest page merged in, drop any loaded message the page proves was hard-deleted. diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index 853a403919..b1b99a8d94 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -23,6 +23,7 @@ export type { MessagePaginatorSort, MessagePaginatorState, MessageQueryShape, + SeedFirstPageOptions, } from './MessageIntervalPaginator'; export { MessageIntervalPaginator } from './MessageIntervalPaginator'; diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 999a3662f5..6501405ae6 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -3458,3 +3458,146 @@ describe('share location', () => { }); }); }); + +describe('Channel.query — initial page size', () => { + let client; + let channel; + + beforeEach(() => { + client = new StreamChat('apiKey'); + client.user = { id: 'user' }; + const channelResponse = generateChannel(); + channel = client.channel(channelResponse.channel.type, channelResponse.channel.id); + channel.initialized = true; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('honors the paginator pageSize for the INITIAL open, not the server default', async () => { + channel.messagePaginator.pageSize = 25; + const getOrCreate = vi + .spyOn(channel, 'getOrCreate') + .mockResolvedValue( + generateChannel({ channel: { id: channel.id, type: channel.type } }), + ); + + await channel.query({}, 'latest'); + + // The initial open asks the server for exactly pageSize messages (not its larger default). + expect(getOrCreate).toHaveBeenCalledWith( + expect.objectContaining({ messages: { limit: 25 } }), + ); + }); + + it('respects an explicit messages.limit (reconnect sizes it to the loaded window)', async () => { + channel.messagePaginator.pageSize = 25; + const getOrCreate = vi + .spyOn(channel, 'getOrCreate') + .mockResolvedValue( + generateChannel({ channel: { id: channel.id, type: channel.type } }), + ); + + // e.g. channel.reload → watch({ messages: { limit: items.length } }) — passed through as-is. + await channel.query({ messages: { limit: 80 } }, 'latest'); + + expect(getOrCreate).toHaveBeenCalledWith( + expect.objectContaining({ messages: { limit: 80 } }), + ); + }); +}); + +describe('Channel.reload', () => { + let client; + let channel; + + const at = (minute) => new Date(Date.UTC(2020, 0, 1, 0, minute, 0)); + // Messages need the channel cid so the main-list paginator's ingestItem filter ({ cid }) accepts them. + const msg = (id, minute, overrides = {}) => + generateMsg({ id, cid: channel.cid, date: at(minute), ...overrides }); + + beforeEach(() => { + client = new StreamChat('apiKey'); + client.user = { id: 'user' }; + const channelResponse = generateChannel(); + channel = client.channel(channelResponse.channel.type, channelResponse.channel.id); + channel.initialized = true; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('reconciles a message hard-deleted while offline and keeps one that arrived during the fetch', async () => { + // m3 (newest loaded) is the one hard-deleted while offline. + seedLatestWindow(channel, [msg('m1', 1), msg('m2', 2), msg('m3', 3)]); + expect(channel.messagePaginator.items.map((m) => m.id)).toEqual(['m1', 'm2', 'm3']); + + // The fold + reconcile now lives in query() → seedFirstPageSync (shared with the channel-list + // re-hydrate and React's recoverState); reload() is just watch() with the full-window limit. + // query() snapshots the loaded ids BEFORE this fetch, so a brand-new message that lands via WS + // DURING it (below) — absent from the server page — must survive. This exercises the whole + // snapshot-before-await + reconcile chain end to end, not the paginator in isolation. + vi.spyOn(channel, 'getOrCreate').mockImplementation(async () => { + channel.messagePaginator.ingestItem(formatMessage(msg('m4', 4))); + return generateChannel({ + channel: { id: channel.id, type: channel.type }, + messages: [msg('m1', 1), msg('m2', 2)], + }); + }); + + await channel.reload(); + + // m3 (in the pre-fetch snapshot, absent from the page) removed; m4 (arrived after) kept. + expect(channel.messagePaginator.items.map((m) => m.id)).toEqual(['m1', 'm2', 'm4']); + }); + + it('requests the full loaded window (items.length), not the channel-list page size', async () => { + seedLatestWindow(channel, [msg('m1', 1), msg('m2', 2), msg('m3', 3)]); + const watchSpy = vi + .spyOn(channel, 'watch') + .mockResolvedValue({ messages: [msg('m1', 1), msg('m2', 2), msg('m3', 3)] }); + + await channel.reload(); + + expect(watchSpy).toHaveBeenCalledWith({ messages: { limit: 3 } }); + }); + + it('preserves a failed (unsent) message that a disjoint rebuild would otherwise drop', async () => { + seedLatestWindow(channel, [ + msg('m1', 1), + msg('failed', 2, { status: 'failed' }), + msg('m3', 3), + ]); + // A page that shares no id with the loaded window is disjoint, so the fold rebuilds and discards + // local-only messages — reload must re-ingest the failed one so it is not lost. + vi.spyOn(channel, 'getOrCreate').mockImplementation(async () => + generateChannel({ + channel: { id: channel.id, type: channel.type }, + messages: [msg('n8', 8), msg('n9', 9)], + }), + ); + + await channel.reload(); + + expect(channel.messagePaginator.getItem('failed')).toBeDefined(); + }); + + it('ignores a re-entrant reload while one is already in flight', async () => { + seedLatestWindow(channel, [msg('m1', 1)]); + let resolveWatch; + const watchSpy = vi.spyOn(channel, 'watch').mockReturnValue( + new Promise((resolve) => { + resolveWatch = () => resolve({ messages: [msg('m1', 1)] }); + }), + ); + + const inFlight = channel.reload(); + await channel.reload(); // guarded — returns immediately, must not call watch again + resolveWatch(); + await inFlight; + + expect(watchSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index a79bddadd2..12f2da25e3 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -1735,20 +1735,20 @@ describe('MessagePaginator', () => { expect(paginator.items?.map((message) => message.id)).toEqual(['m1', 'm2', 'm3']); }); - it('preserves hasMoreTail / cursor.tailward when merging a partial newest window', () => { + it('keeps hasMoreTail and anchors the tail cursor to the loaded oldest when merging a partial newest window', () => { // Only the newest window is loaded and older items still exist (hasMoreTail true). Merging a // short page (fewer than pageSize) whose first item is the set's first item must NOT clear - // hasMoreTail: re-deriving it from this page's length would wrongly break "load older", so the - // merge preserves the existing hasMoreTail / cursor instead. + // hasMoreTail — re-deriving it from the page's LENGTH would wrongly break "load older". The tail + // boundary is taken from the MERGED interval, so hasMoreTail stays true and the cursor anchors to + // the loaded oldest (m1) — the correct "load older" anchor. const { paginator, m1, m2 } = setupLoadedHead({ isTail: false }); expect(paginator.state.getLatestValue().hasMoreTail).toBe(true); - const tailwardBefore = paginator.state.getLatestValue().cursor?.tailward; const editedM3 = m('m3', '03', { text: 'edited' }); paginator.mergeNewestPage([m1, m2, editedM3]); expect(paginator.state.getLatestValue().hasMoreTail).toBe(true); - expect(paginator.state.getLatestValue().cursor?.tailward).toBe(tailwardBefore); + expect(paginator.state.getLatestValue().cursor?.tailward).toBe('m1'); expect(paginator.getItem('m3')?.text).toBe('edited'); }); @@ -1817,7 +1817,6 @@ describe('MessagePaginator', () => { it('treats a window sharing only the loaded newest id as OVERLAP, not disjoint (boundary)', () => { const { paginator, m3 } = setupLoadedHead({ isTail: false }); - const tailwardBefore = paginator.state.getLatestValue().cursor?.tailward; // Exactly one shared id (the loaded newest, m3): the minimal-overlap boundary. This must merge // (append m4/m5, keep older loadable), NOT reset to the window. paginator.mergeNewestPage([m3, m('m4', '04'), m('m5', '05')]); @@ -1831,7 +1830,8 @@ describe('MessagePaginator', () => { ]); expect(paginator.itemIntervals).toHaveLength(1); expect(paginator.state.getLatestValue().hasMoreTail).toBe(true); - expect(paginator.state.getLatestValue().cursor?.tailward).toBe(tailwardBefore); + // The tail cursor anchors to the loaded oldest (m1), derived from the merged interval. + expect(paginator.state.getLatestValue().cursor?.tailward).toBe('m1'); }); // Builds the "jumped away" shape: the newest slice is loaded as one interval, and a separate @@ -2380,6 +2380,363 @@ describe('MessagePaginator', () => { }); }); + // seedFirstPageSync is the synchronous channel-open seed (Channel.query / hydrateActiveChannels). + // With `options.reconcile` it doubles as the reconnect / re-hydrate fold: over an already-loaded + // window it delegates to mergeNewestPage (merge + destructive reconcile + disjoint rebuild), whose + // internals are covered above — these tests pin only the ROUTING decision (which branch it picks). + describe('seedFirstPageSync() — reconcile routing', () => { + const msg = (id: string, minute: number, overrides: Partial = {}) => + createMessage({ + cid: 'channel-id', + id, + created_at: new Date(Date.UTC(2020, 0, 1, 0, minute, 0)).toISOString(), + ...overrides, + }); + + // The plain-seed branch runs seedUnreadSnapshot (reads getClient().user); give the channel a + // benign client with no current user so it no-ops instead of throwing on the bare mock. + const reconcileChannel = { + cid: 'channel-id', + getReplies: vi.fn(), + query: vi.fn(), + getClient: () => ({ user: undefined }), + } as unknown as Channel; + + const makePaginator = () => + new MessagePaginator({ + channel: reconcileChannel, + itemIndex: new StoreBackedItemIndex({ + getEntityId: (message) => message.id, + }), + }); + + const loadHead = (messages: LocalMessage[]) => { + const paginator = makePaginator(); + paginator.ingestPage({ + page: messages, + isHead: true, + isTail: false, + setActive: true, + }); + return paginator; + }; + + const ids = (paginator: MessagePaginator) => + paginator.items?.map((message) => message.id); + + it('REPRO(interval): reconnect re-establishes hasMoreTail over a stale "complete" INTERVAL (offline DB)', () => { + const paginator = makePaginator(); + // Offline-DB window persisted as "complete" — the INTERVAL itself has isTail=true / hasMoreTail + // false, even though the channel has older messages the cache never held. (This is what my + // earlier state-only corruption failed to reproduce.) + paginator.ingestPage({ + page: [msg('m1', 1), msg('m2', 2), msg('m3', 3)], + isHead: true, + isTail: true, + setActive: true, + }); + expect(paginator.state.getLatestValue().hasMoreTail).toBe(false); + + // Reconnect fetches a FULL page (requestedLimit == page length) → older messages remain, so + // hasMoreTail must be RE-COMPUTED from the page (not read off the stale interval flag). + paginator.seedFirstPageSync( + [msg('m1', 1), msg('m2', 2), msg('m3', 3)], + 3, + undefined, + { + reconcile: true, + }, + ); + + expect(paginator.state.getLatestValue().hasMoreTail).toBe(true); + expect(paginator.state.getLatestValue().cursor?.tailward).toBe('m1'); + }); + + it('JOURNEY: a reconnect re-seed over a stale window keeps "load older" working end-to-end', async () => { + // The exact user flow that regressed: open a channel, its window is preloaded from the offline DB + // with a dead cursor, a reconnect re-seeds, then the user scrolls up. "Load older" must fetch and + // append the previous page — a component-level test (checking only which messages merged) missed + // this because the break was in the CURSOR, so drive the real executeQuery pagination here. + const older = [ + msg('m01', 1), + msg('m02', 2), + msg('m03', 3), + msg('m04', 4), + msg('m05', 5), + ]; + const doRequest = vi.fn().mockResolvedValue({ + items: older, + cursor: { tailward: 'm01', headward: 'm05' }, + }); + const paginator = new MessagePaginator({ + channel: reconcileChannel, + itemIndex: new StoreBackedItemIndex({ getEntityId: (m) => m.id }), + paginatorOptions: { doRequest }, + }); + + const head = [ + msg('m06', 6), + msg('m07', 7), + msg('m08', 8), + msg('m09', 9), + msg('m10', 10), + ]; + // Offline-DB window persisted as "complete" — the INTERVAL itself is isTail:true / hasMoreTail + // false (the real stale shape; corrupting only state would miss the bug). + paginator.ingestPage({ page: head, isHead: true, isTail: true, setActive: true }); + expect(paginator.state.getLatestValue().hasMoreTail).toBe(false); + paginator.seedFirstPageSync(head, 5, undefined, { reconcile: true }); // reconnect re-seed + + // The user scrolls up. If the re-seed left the dead cursor, executeQuery no-ops (hasMoreTail + // false) and nothing loads; with the cursor re-derived it fetches and appends the older page. + await paginator.executeQuery({ direction: 'tailward' }); + + expect(doRequest).toHaveBeenCalled(); + expect(paginator.items?.map((m) => m.id)).toEqual([ + 'm01', + 'm02', + 'm03', + 'm04', + 'm05', + 'm06', + 'm07', + 'm08', + 'm09', + 'm10', + ]); + }); + + it('a reconnect re-seed while JUMPED AWAY does not weld the newest page into the active older window', () => { + const paginator = makePaginator(); + // Head window (newest), loaded on open. + paginator.ingestPage({ + page: [msg('m080', 80), msg('m090', 90), msg('m100', 100)], + isHead: true, + isTail: false, + setActive: true, + }); + // Jump to a far, DISJOINT older window (like clicking a quoted message in another set) — it + // becomes the active interval and is NOT the head. + paginator.ingestPage({ + page: [msg('m020', 20), msg('m021', 21), msg('m022', 22)], + isHead: false, + isTail: false, + setActive: true, + }); + expect(paginator.isActiveIntervalAtHead).toBe(false); + const before = paginator.items?.map((m) => m.id); + + // A reconnect re-seeds the newest page (channel.reload → watch → seedFirstPageSync). It must NOT + // weld the newest into the jumped-away window — the two message sets stay separate. + paginator.seedFirstPageSync( + [msg('m080', 80), msg('m090', 90), msg('m100', 100)], + 3, + undefined, + { reconcile: true }, + ); + + expect(paginator.isActiveIntervalAtHead).toBe(false); // still on the jumped window + expect(paginator.items?.map((m) => m.id)).toEqual(before); // unchanged — no weld + }); + + it('AUDIT: disjoint reconnect rebuilds to the fresh page instead of welding across the gap', () => { + const paginator = loadHead([msg('m01', 1), msg('m02', 2), msg('m03', 3)]); + // 100+ new arrived while offline → the fetched newest page shares NO id with the loaded window. + paginator.seedFirstPageSync( + [msg('m10', 10), msg('m11', 11), msg('m12', 12)], + 3, + undefined, + { + reconcile: true, + }, + ); + // Must NOT weld m10..m12 across the gap into m01..m03 (which hides m04..m09 with no way to reach + // them). Rebuild to the fresh page so scrolling up reloads the gap contiguously. + expect(paginator.items?.map((m) => m.id)).toEqual(['m10', 'm11', 'm12']); + }); + + it('AUDIT: an empty reconnect page (no snapshot) does not blank the loaded window', () => { + const paginator = loadHead([msg('m01', 1), msg('m02', 2), msg('m03', 3)]); + // A transient empty page on reconnect must not wipe the list on its own. + paginator.seedFirstPageSync([], 3, undefined, { reconcile: true }); + expect(paginator.items?.map((m) => m.id)).toEqual(['m01', 'm02', 'm03']); + }); + + it('AUDIT e2e: after a disjoint rebuild, "load older" reloads the gap, not the discarded stale window', async () => { + const gap = [msg('m175', 175), msg('m176', 176), msg('m177', 177)]; + const doRequest = vi.fn().mockResolvedValue({ + items: gap, + cursor: { tailward: 'm175', headward: 'm177' }, + }); + const paginator = new MessagePaginator({ + channel: reconcileChannel, + itemIndex: new StoreBackedItemIndex({ getEntityId: (m) => m.id }), + paginatorOptions: { doRequest }, + }); + // The newest window loaded when the user went offline. + paginator.ingestPage({ + page: [msg('m078', 78), msg('m079', 79), msg('m080', 80)], + isHead: true, + isTail: false, + setActive: true, + }); + // Reconnect: 100+ new arrived, so the fetched newest page is DISJOINT from the loaded window. + paginator.seedFirstPageSync( + [msg('m178', 178), msg('m179', 179), msg('m180', 180)], + 3, + undefined, + { + reconcile: true, + }, + ); + // Scroll up: the rebuilt window must reload the gap contiguously — the stale m078..m080 are gone, + // not welded in with the in-between messages hidden. + await paginator.executeQuery({ direction: 'tailward' }); + const ids = paginator.items?.map((m) => m.id); + expect(ids).not.toContain('m078'); + expect(ids).toEqual(['m175', 'm176', 'm177', 'm178', 'm179', 'm180']); + }); + + it('a jump to a far disjoint message stays SEPARATE from the latest, even through a re-seed', async () => { + const around = [msg('m20', 20), msg('m21', 21), msg('m22', 22)]; + const doRequest = vi.fn().mockResolvedValue({ + items: around, + cursor: { tailward: 'm20', headward: 'm22' }, + }); + const paginator = new MessagePaginator({ + channel: reconcileChannel, + itemIndex: new StoreBackedItemIndex({ getEntityId: (m) => m.id }), + paginatorOptions: { doRequest }, + }); + paginator.ingestPage({ + page: [msg('m80', 80), msg('m90', 90), msg('m100', 100)], + isHead: true, + isTail: false, + setActive: true, + }); + await paginator.jumpToMessage('m21'); + // A latest-window re-seed (what watch() → seedFirstPageSync fires) must NOT weld the jumped + // window into the latest — mergeNewestPage skips because the head is not the active interval. + paginator.seedFirstPageSync( + [msg('m80', 80), msg('m90', 90), msg('m100', 100)], + 3, + undefined, + { + reconcile: true, + }, + ); + expect(paginator.items?.map((m) => m.id)).toEqual(['m20', 'm21', 'm22']); + expect(paginator.itemIntervals.length).toBe(2); + }); + + it('reconciling seed clears a sticky isTail so a far older page cannot weld across the gap', () => { + const paginator = new MessagePaginator({ + channel: reconcileChannel, + itemIndex: new StoreBackedItemIndex({ getEntityId: (m) => m.id }), + paginatorOptions: {}, + }); + // An offline-DB latest window rehydrated as "complete" — isTail:true even though older + // messages exist on the server (the stale offline window). This is the flag intervalsOverlap + // consults to decide a merge. + paginator.ingestPage({ + page: [msg('m90', 90), msg('m95', 95), msg('m100', 100)], + isHead: true, + isTail: true, + setActive: true, + }); + expect((paginator.itemIntervals[0] as { isTail?: boolean }).isTail).toBe(true); + + // The reconciling seed at the derived page size (a FULL page => older messages remain) must + // clear the sticky isTail — not just state's hasMoreTail. + paginator.seedFirstPageSync( + [msg('m90', 90), msg('m95', 95), msg('m100', 100)], + 3, + undefined, + { reconcile: true }, + ); + expect((paginator.itemIntervals[0] as { isTail?: boolean }).isTail).toBe(false); + expect(paginator.hasMoreTail).toBe(true); + + // Jump to a far OLDER window as a separate interval. + const island = paginator.ingestPage({ + page: [msg('m10', 10), msg('m11', 11), msg('m12', 12)], + isHead: false, + isTail: false, + setActive: true, + }); + expect(paginator.itemIntervals.length).toBe(2); + + // Load older from the island: a page older than it, nowhere near the latest window. With a + // sticky isTail on the latest, intervalsOverlap would (wrongly) treat this as overlapping the + // latest and weld the two pages-apart sets into one. + paginator.ingestPage({ + page: [msg('m7', 7), msg('m8', 8), msg('m9', 9)], + isTail: false, + setActive: false, + targetIntervalId: island?.id, + }); + + // Stays two separate intervals — the older page merges only into the island. + expect(paginator.itemIntervals.length).toBe(2); + }); + + it('reconcile + already-loaded: folds the fresh page and drops a within-span hard-delete', () => { + const paginator = loadHead([msg('m1', 1), msg('m2', 2), msg('m3', 3)]); + // m2 hard-deleted while offline; the re-seed's authoritative page comes back without it. + paginator.seedFirstPageSync([msg('m1', 1), msg('m3', 3)], 3, undefined, { + reconcile: true, + }); + expect(ids(paginator)).toEqual(['m1', 'm3']); + expect(paginator.getItem('m2')).toBeUndefined(); + }); + + it('reconcile + snapshot: drops a trailing ghost while keeping a message that arrived during the fetch', () => { + const paginator = loadHead([msg('m1', 1), msg('m2', 2), msg('m3', 3)]); + // A live message lands AFTER the pre-fetch snapshot was taken (so it is not in candidateIds). + paginator.ingestItem(msg('m4', 4)); + const candidateIds = new Set(['m1', 'm2', 'm3']); + // The page (missing m3 — the hard-deleted newest — and predating m4) is the server truth. + paginator.seedFirstPageSync([msg('m1', 1), msg('m2', 2)], 3, undefined, { + reconcile: true, + candidateIds, + }); + // m3 removed (in the snapshot, absent from the page, at the top edge); m4 kept (a live arrival). + expect(ids(paginator)).toEqual(['m1', 'm2', 'm4']); + }); + + it('reconcile on a cold (never-seeded) paginator: plain-seeds the page', () => { + const paginator = makePaginator(); + expect(paginator.items).toBeUndefined(); + paginator.seedFirstPageSync([msg('m1', 1), msg('m2', 2)], 25, undefined, { + reconcile: true, + }); + expect(ids(paginator)).toEqual(['m1', 'm2']); + }); + + it('WITHOUT the reconcile flag: plain-seeds and never reconciles (the pinned-list contract)', () => { + const paginator = loadHead([msg('m1', 1), msg('m2', 2), msg('m3', 3)]); + // Same missing-m2 page, but no reconcile flag → additive seed; m2 is NOT removed. + paginator.seedFirstPageSync([msg('m1', 1), msg('m3', 3)], 3); + expect(paginator.getItem('m2')).toBeDefined(); + }); + + it('reconcile + a jump/around re-seed: applies jump semantics, never reconciles the latest window', () => { + const paginator = loadHead([msg('m1', 1), msg('m2', 2), msg('m3', 3)]); + // An around open is not the latest window, so the loaded messages must not be reconciled away. + paginator.seedFirstPageSync( + [msg('m5', 5), msg('m6', 6)], + 25, + { id_around: 'm5' }, + { + reconcile: true, + }, + ); + expect(paginator.getItem('m1')).toBeDefined(); + expect(paginator.getItem('m2')).toBeDefined(); + expect(paginator.getItem('m3')).toBeDefined(); + }); + }); + describe('trackLastMessage() / lastMessageAt', () => { let skipSystemMessages: boolean; let trackingChannel: Channel; diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index 06427b0a9c..12d5e10c19 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -441,6 +441,35 @@ describe('Threads 2.0', () => { expect(repliesOf(thread).map((reply) => reply.id)).to.include(failedMessage.id); }); + it('re-derives a paginatable reply cursor over a stale window (Thread.reload stays paginatable offline)', () => { + const existingReply = generateMsg({ + parent_id: parentMessageResponse.id, + created_at: '2020-01-01T00:00:00.000Z', + }) as MessageResponse; + // Head-anchored, older replies still to load (reply_count > loaded). + const thread = createTestThread({ + latest_replies: [existingReply], + reply_count: 10, + }); + // Simulate a reply window preloaded with a stale/"complete" cursor (offline DB): "load older + // replies" is dead if the reconnect hydrate PRESERVES it instead of re-deriving. + thread.messagePaginator.state.partialNext({ + hasMoreTail: false, + cursor: { tailward: null, headward: null }, + }); + expect(thread.messagePaginator.state.getLatestValue().hasMoreTail).to.be.false; + + // Reconnect hydrate (Thread.reload → hydrateState → mergeNewestPage) must RE-DERIVE the cursor + // from the merged reply window so pagination works again. + const hydrationThread = createTestThread({ + latest_replies: [existingReply], + reply_count: 10, + }); + thread.hydrateState(hydrationThread); + + expect(thread.messagePaginator.state.getLatestValue().hasMoreTail).to.be.true; + }); + it('merges the incoming newest reply window into the reply paginator', () => { const existingReply = generateMsg({ parent_id: parentMessageResponse.id, @@ -584,6 +613,42 @@ describe('Threads 2.0', () => { expect(stub.secondCall.args[0]?.reply_limit).to.equal(7); expect(loadedThread.messagePaginator.pageSize).to.not.equal(7); }); + + it('removes a reply hard-deleted while offline and keeps one that arrived during the fetch', async () => { + // End-to-end through the REAL reload orchestration (not a hand-built snapshot): this is what + // proves the snapshot-before-await guarantee — the thing the paginator-level tests assume. + const r1 = makeReply({ id: 'r1', created_at: '2020-01-01T00:00:01.000Z' }); + const r2 = makeReply({ id: 'r2', created_at: '2020-01-01T00:00:02.000Z' }); + // r3 is the newest loaded reply — hard-deleted by someone else while we were offline. + const r3 = makeReply({ id: 'r3', created_at: '2020-01-01T00:00:03.000Z' }); + const thread = createTestThread({ + latest_replies: [r1, r2, r3], + reply_count: 3, + }); + expect(repliesOf(thread).map((reply) => reply.id)).to.eql(['r1', 'r2', 'r3']); + + // A brand-new reply that lands via WS DURING the reload fetch — after reload() snapshots the + // loaded ids, before hydrateState runs. Like the r3 ghost it is absent from the server page, + // so a naive "loaded − serverPage" would wrongly drop it; the pre-fetch snapshot must save it. + const r4 = makeReply({ id: 'r4', created_at: '2020-01-01T00:00:04.000Z' }); + + // The server's authoritative page (computed before r4 existed) has r3 hard-deleted, no r4. + const hydrationThread = createTestThread({ + latest_replies: [r1, r2], + reply_count: 2, + }); + + sinon.stub(client, 'getThreadAndHydrate').callsFake(async () => { + thread.messagePaginator.ingestItem(formatMessage(r4)); // live arrival during the await + return hydrationThread; + }); + + await thread.reload(); + + // r3 (in the pre-fetch snapshot, absent from the server page) → hard-delete, removed. + // r4 (arrived AFTER the snapshot) → not in the snapshot → kept. + expect(repliesOf(thread).map((reply) => reply.id)).to.eql(['r1', 'r2', 'r4']); + }); }); describe('deleteReplyLocally', () => { From 49509aaff1d160f2f47b1cc98fd34546cb631e1f Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 12 Aug 2026 01:55:52 +0200 Subject: [PATCH 04/31] fix: execute reconciliation even if not at head interval --- .../paginators/MessageIntervalPaginator.ts | 17 ++++--- .../paginators/MessagePaginator.test.ts | 48 +++++++++++++++++-- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index 6fbcbfb539..b3f9c5d60e 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -640,11 +640,16 @@ export class MessageIntervalPaginator extends BasePaginator< mergeNewestPage = (page: LocalMessage[], options?: MergeNewestPageOptions) => { const headInterval = this.itemIntervals[0] as Interval | undefined; if (!headInterval?.isHead) return; - // Only reconcile when the head is the interval currently in view. If the caller jumped to a - // separate (older) window, that window is active and the head is merely still-loaded underneath; - // reconciling would switch the view to the head and yank them to the newest. Skip to preserve - // their position (the newest page is picked up on scroll / a later load). - if (!this.isActiveInterval(headInterval)) return; + // If the caller jumped to a separate (older) window, that window is active and the head is merely + // still-loaded underneath. Don't MERGE the fresh page or switch the view (that would yank them to + // the newest) — but STILL prune offline hard-deletes out of the hidden head, otherwise returning to + // it later (scroll-to-latest) surfaces ghosts that were deleted while jumped away. Reconciliation + // targets the head interval regardless of which interval is active, and removing a hidden-head ghost + // leaves the active island's items untouched. + if (!this.isActiveInterval(headInterval)) { + this.reconcileLoadedAgainstPage(page, options); + return; + } if (!page?.length) { // Empty page: never blanks the list by itself. Only when the caller supplied a pre-fetch @@ -771,7 +776,7 @@ export class MessageIntervalPaginator extends BasePaginator< options?: MergeNewestPageOptions, ) { const headInterval = this.itemIntervals[0] as Interval | undefined; - if (!headInterval?.isHead || !this.isActiveInterval(headInterval)) return; + if (!headInterval?.isHead) return; const loadedIds = headInterval.itemIds; const candidateIds = options?.candidateIds; diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index 12f2da25e3..d5c44c3ec9 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -2252,7 +2252,7 @@ describe('MessagePaginator', () => { expect(ids(paginator)).toEqual(['m10', 'm11', 'm12']); }); - it('does not reconcile when the caller is viewing a separate older window', () => { + it('reconciles the hidden head but preserves the view when the caller jumped to a separate older window', () => { const paginator = new MessagePaginator({ channel, itemIndex: new StoreBackedItemIndex({ @@ -2278,9 +2278,10 @@ describe('MessagePaginator', () => { requestedLimit: 3, }); - // Skipped entirely: the older window is preserved and the head ghost m9 is untouched. + // The view (the older window) is preserved — no yank to the head — but the hidden-head ghost m9 + // is still pruned, so returning to the head later (scroll-to-latest) won't surface it. expect(ids(paginator)).toEqual(['m1', 'm2', 'm3']); - expect(paginator.getItem('m9')).toBeDefined(); + expect(paginator.getItem('m9')).toBeUndefined(); }); it('is idempotent — a second reconcile against the same page removes nothing more', () => { @@ -2629,6 +2630,47 @@ describe('MessagePaginator', () => { expect(paginator.itemIntervals.length).toBe(2); }); + it('reconnect while jumped away still reconciles offline hard-deletes out of the hidden head', () => { + const paginator = new MessagePaginator({ + channel: reconcileChannel, + itemIndex: new StoreBackedItemIndex({ getEntityId: (m) => m.id }), + paginatorOptions: {}, + }); + // Head/latest window loaded and at head. + paginator.ingestPage({ + page: [msg('m90', 90), msg('m95', 95), msg('m100', 100)], + isHead: true, + isTail: false, + setActive: true, + }); + // Jump to a far older island — now jumped away; the head is loaded-but-hidden underneath. + paginator.ingestPage({ + page: [msg('m10', 10), msg('m11', 11), msg('m12', 12)], + isHead: false, + isTail: false, + setActive: true, + }); + expect(paginator.isActiveIntervalAtHead).toBe(false); + + // Reconnect: the fresh newest page proves m100 (bottom-most head message) was hard-deleted while + // offline — it is absent from the page and above the newest returned message, so only the + // pre-fetch snapshot can prune it. + const candidateIds = new Set(['m90', 'm95', 'm100', 'm10', 'm11', 'm12']); + paginator.mergeNewestPage([msg('m90', 90), msg('m95', 95)], { + candidateIds, + requestedLimit: 3, + }); + + // View is preserved — still on the island, unchanged. + expect(paginator.isActiveIntervalAtHead).toBe(false); + expect(paginator.items?.map((m) => m.id)).toEqual(['m10', 'm11', 'm12']); + // ...but the ghost is pruned from the hidden head, so scroll-to-latest won't surface it. + expect(paginator.getItem('m100')).toBeUndefined(); + expect((paginator.itemIntervals[0] as { itemIds: string[] }).itemIds).not.toContain( + 'm100', + ); + }); + it('reconciling seed clears a sticky isTail so a far older page cannot weld across the gap', () => { const paginator = new MessagePaginator({ channel: reconcileChannel, From 26ed33b19caf47b991342887b0893d745588e6b1 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 12 Aug 2026 02:22:12 +0200 Subject: [PATCH 05/31] fix: use correct interval for candidate ids --- src/channel.ts | 2 +- .../paginators/MessagePaginator.test.ts | 53 +++++++++++++++---- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 6753a623dd..82682902e2 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -1531,7 +1531,7 @@ export class Channel extends ChannelApi { // so callers (channel.reload, watch) need not thread it. Empty on a cold open, so it is harmless. const candidateIds = messageSetToAddToIfDoesNotExist === 'latest' - ? new Set(this.messagePaginator.items?.map((message) => message.id) ?? []) + ? new Set(this.messagePaginator.headItems.map((message) => message.id)) : undefined; // The INITIAL channel-open query honors the paginator's OWN pageSize (light on native, 25) rather diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index d5c44c3ec9..7a4c373d06 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -2630,19 +2630,49 @@ describe('MessagePaginator', () => { expect(paginator.itemIntervals.length).toBe(2); }); - it('reconnect while jumped away still reconciles offline hard-deletes out of the hidden head', () => { + it('headItems is the hidden head window (not the active island) — the candidateIds source when jumped away', () => { const paginator = new MessagePaginator({ channel: reconcileChannel, itemIndex: new StoreBackedItemIndex({ getEntityId: (m) => m.id }), paginatorOptions: {}, }); - // Head/latest window loaded and at head. paginator.ingestPage({ page: [msg('m90', 90), msg('m95', 95), msg('m100', 100)], isHead: true, isTail: false, setActive: true, }); + paginator.ingestPage({ + page: [msg('m10', 10), msg('m11', 11), msg('m12', 12)], + isHead: false, + isTail: false, + setActive: true, + }); + // `items` follows the active view (the island) — the WRONG snapshot for reconciling the head... + expect(paginator.items?.map((m) => m.id)).toEqual(['m10', 'm11', 'm12']); + // ...`headItems` is the hidden head, which is what channel.query snapshots for candidateIds. + expect(paginator.headItems.map((m) => m.id)).toEqual(['m90', 'm95', 'm100']); + }); + + it('reconnect while jumped away prunes the whole trailing run from the hidden head', () => { + const paginator = new MessagePaginator({ + channel: reconcileChannel, + itemIndex: new StoreBackedItemIndex({ getEntityId: (m) => m.id }), + paginatorOptions: {}, + }); + // Head/latest window loaded and at head; m98,m99,m100 are the bottom-most (newest) messages. + paginator.ingestPage({ + page: [ + msg('m90', 90), + msg('m95', 95), + msg('m98', 98), + msg('m99', 99), + msg('m100', 100), + ], + isHead: true, + isTail: false, + setActive: true, + }); // Jump to a far older island — now jumped away; the head is loaded-but-hidden underneath. paginator.ingestPage({ page: [msg('m10', 10), msg('m11', 11), msg('m12', 12)], @@ -2652,23 +2682,24 @@ describe('MessagePaginator', () => { }); expect(paginator.isActiveIntervalAtHead).toBe(false); - // Reconnect: the fresh newest page proves m100 (bottom-most head message) was hard-deleted while - // offline — it is absent from the page and above the newest returned message, so only the - // pre-fetch snapshot can prune it. - const candidateIds = new Set(['m90', 'm95', 'm100', 'm10', 'm11', 'm12']); + // candidateIds is snapshotted by channel.query from `headItems` (the hidden head) — NOT `items` + // (the island). The whole trailing RUN m98,m99,m100 was hard-deleted offline; all three are above + // the newest survivor (m95), so only the head-derived snapshot can prune them. + const candidateIds = new Set(paginator.headItems.map((m) => m.id)); paginator.mergeNewestPage([msg('m90', 90), msg('m95', 95)], { candidateIds, requestedLimit: 3, }); - // View is preserved — still on the island, unchanged. + // View preserved (still on the island)... expect(paginator.isActiveIntervalAtHead).toBe(false); expect(paginator.items?.map((m) => m.id)).toEqual(['m10', 'm11', 'm12']); - // ...but the ghost is pruned from the hidden head, so scroll-to-latest won't surface it. + // ...and the ENTIRE trailing run is pruned from the hidden head — none surface on scroll-to-latest. + expect(paginator.getItem('m98')).toBeUndefined(); + expect(paginator.getItem('m99')).toBeUndefined(); expect(paginator.getItem('m100')).toBeUndefined(); - expect((paginator.itemIntervals[0] as { itemIds: string[] }).itemIds).not.toContain( - 'm100', - ); + const headIds = (paginator.itemIntervals[0] as { itemIds: string[] }).itemIds; + expect(headIds).toEqual(['m90', 'm95']); }); it('reconciling seed clears a sticky isTail so a far older page cannot weld across the gap', () => { From 2a06363790246d4bf19d908ecd56e1d3cc07f852 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 12 Aug 2026 03:17:41 +0200 Subject: [PATCH 06/31] fix: cold load reconciliation --- src/client.ts | 16 ++++++++++++++-- test/unit/client.test.js | 28 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/client.ts b/src/client.ts index e2e7d9c577..fe3b53a3d9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1332,6 +1332,12 @@ export class StreamChat extends ChatApi { options?: QueryChannelsRequest, stateOptions: ChannelStateOptions = {}, ): Promise { + const candidateIdsByCid = new Map>(); + for (const cid of Object.keys(this.activeChannels)) { + const head = this.activeChannels[cid]?.messagePaginator?.headItems; + if (head?.length) + candidateIdsByCid.set(cid, new Set(head.map((message) => message.id))); + } const queryChannelsResponse = await this.queryChannels(options); const channels = queryChannelsResponse.channels; @@ -1349,7 +1355,12 @@ export class StreamChat extends ChatApi { }); } - const hydratedChannels = this.hydrateActiveChannels(channels, stateOptions, options); + const hydratedChannels = this.hydrateActiveChannels( + channels, + stateOptions, + options, + candidateIdsByCid, + ); if (stateOptions.withResponse) { return { @@ -1405,6 +1416,7 @@ export class StreamChat extends ChatApi { channelsFromApi: ChannelStateResponseFields[] = [], stateOptions: ChannelStateOptions = {}, queryChannelsOptions?: ChannelOptions, + candidateIdsByCid?: Map>, ) { const { skipInitialization, offlineMode = false } = stateOptions; const channels: Channel[] = []; @@ -1446,7 +1458,7 @@ export class StreamChat extends ChatApi { channelState.messages.map(formatMessage), requestedPageSize, undefined, - { reconcile: true }, + { reconcile: true, candidateIds: candidateIdsByCid?.get(c.cid) }, ); } diff --git a/test/unit/client.test.js b/test/unit/client.test.js index fa851f4e0c..4e371cccf3 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -948,6 +948,34 @@ describe('StreamChat.queryChannels', async () => { stub.restore(); }); + it('reconciles a trailing offline hard-delete on channel-list re-hydrate (cold-boot path)', async () => { + const client = await getClientWithUser(); + const full = [ + generateMsg({ id: 'm5', created_at: '2023-11-14T12:00:05.000Z' }), + generateMsg({ id: 'm6', created_at: '2023-11-14T12:00:06.000Z' }), + generateMsg({ id: 'm7', created_at: '2023-11-14T12:00:07.000Z' }), + ]; + const stub = sinon.stub(client, 'queryChannels').resolves({ + channels: [{ ...mockChannelQueryResponse, messages: full }], + }); + + // First hydrate seeds the (cold) paginator with m5,m6,m7 — m7 is the newest / bottom-most. + const [channel] = await client.queryChannelsAndHydrate({ message_limit: 3 }); + expect(channel.messagePaginator.getItem('m7')).to.not.be.undefined; + + // While the app was closed, m7 (the last message) was hard-deleted. The next channel-list query + // returns the window WITHOUT it. m7 is above the newest returned message (m6), so only the + // pre-fetch head snapshot lets the reconcile prune it — the cold-boot path must supply it. + stub.resolves({ + channels: [{ ...mockChannelQueryResponse, messages: [full[0], full[1]] }], + }); + await client.queryChannelsAndHydrate({ message_limit: 3 }); + + expect(channel.messagePaginator.getItem('m7')).to.be.undefined; + + stub.restore(); + }); + it('seeds each queried channel paginator with its full message page', async () => { const client = await getClientWithUser(); const mockedChannelsQueryResponse = Array.from({ length: 10 }, (_, index) => From 894a7ccba6414bffcb4caaa15d0bbc0bf882df85 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 12 Aug 2026 03:19:21 +0200 Subject: [PATCH 07/31] chore: add potential todos --- src/channel.ts | 6 ++++++ src/client.ts | 2 ++ 2 files changed, 8 insertions(+) diff --git a/src/channel.ts b/src/channel.ts index 82682902e2..9143463f97 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -1529,6 +1529,12 @@ export class Channel extends ChannelApi { // hard-delete (in the snapshot, absent from the page) from a message that arrives live // during the fetch (not in the snapshot). Captured here — the only place with the pre-await state — // so callers (channel.reload, watch) need not thread it. Empty on a cold open, so it is harmless. + // TODO(perf/cleanup): `headItems` materializes full message objects (intervalToItems) just to map + // them down to ids. A cheaper, clearer equivalent is a straight copy of the paginator index's own + // id set — expose `memberIds` on StoreBackedItemIndex (e.g. `snapshotMembers()` returning + // `new Set(this.memberIds)`) and use it here AND in client.queryChannelsAndHydrate. The broader + // scope (all intervals vs just the head) is inert: the reconcile only consults head ids, older + // island ids are never at/above-newest, and local messages are guarded by isServerConfirmedMessage. const candidateIds = messageSetToAddToIfDoesNotExist === 'latest' ? new Set(this.messagePaginator.headItems.map((message) => message.id)) diff --git a/src/client.ts b/src/client.ts index fe3b53a3d9..3bdeda63ef 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1332,6 +1332,8 @@ export class StreamChat extends ChatApi { options?: QueryChannelsRequest, stateOptions: ChannelStateOptions = {}, ): Promise { + // TODO(perf/cleanup): prefer a `memberIds` snapshot over `headItems.map` here too — see the + // matching TODO in channel.query() for the full rationale. const candidateIdsByCid = new Map>(); for (const cid of Object.keys(this.activeChannels)) { const head = this.activeChannels[cid]?.messagePaginator?.headItems; From 3a235c28e1fd85adee741bf7c0bd408a8761e44f Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 12 Aug 2026 11:49:52 +0200 Subject: [PATCH 08/31] fix: remove redndant comment --- src/channel.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 9143463f97..6b15643ef9 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -1320,8 +1320,6 @@ export class Channel extends ChannelApi { this._reloading = true; try { const paginator = this.messagePaginator; - // Captured BEFORE the await: request our full loaded window (not the list's smaller page), and - // remember failed (unsent) messages so a disjoint rebuild does not silently drop them. const requestedLimit = paginator.items?.length || paginator.pageSize; const failedBefore = (paginator.items ?? []).filter( (message) => message.status === 'failed', From ad291a72cbc23cef970263b38df94c1e98475e7e Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 12 Aug 2026 14:26:24 +0200 Subject: [PATCH 09/31] feat: expose batching and batch failed messages --- src/channel.ts | 11 +++++++--- src/pagination/paginators/BasePaginator.ts | 22 +++++++++++++++++++ .../paginators/MessageIntervalPaginator.ts | 16 ++++++++------ 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 6b15643ef9..0a5b71e84c 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -1328,9 +1328,14 @@ export class Channel extends ChannelApi { await this.watch({ messages: { limit: requestedLimit } }); this.offlineMode = false; - for (const failed of failedBefore) { - if (!paginator.getItem(failed.id)) paginator.ingestItem(failed); - } + paginator.batch( + () => { + for (const failed of failedBefore) { + if (!paginator.getItem(failed.id)) paginator.ingestItem(failed); + } + }, + { flush: true }, + ); } finally { this._reloading = false; } diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index 67118d96c6..2baf4d50ff 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -2264,6 +2264,28 @@ export abstract class BasePaginator { return true; } + /** + * Run `fn` as one batched mutation over this paginator's items, collapsing the redundant emits a + * naive per item loop would produce, in two independent ways: + * + * - **Shared-store fan-out** (the `_itemIndex.batch` wrapper): per-item store notifications fold + * into a single flush, so sibling holders of the same ids (e.g. a thread / pinned paginator + * sharing the entity) re-project once, not once per item. Inert for single-home paginators + * (channels, reminders, user groups) whose index is over a private store with no sibling + * subscribers — there it is a plain passthrough. + * - **This paginator's own active window** (`flush: true`): when state throttling is on (the message + * list in production), each `ingestItem` / `removeItem` inside `fn` defers its window publish via + * {@link scheduleWindowPublish}; the trailing {@link flushPendingPublishes} then emits the settled + * window exactly once. Pass it for oneshot operations (reconciliation) that must settle + * synchronously. Leave it `false` inside WS event handlers so successive events keep coalescing + * across the throttle trailing edge instead of each forcing an emit. Flushing is of course still + * possible if that is deemed necessary at a certain point. + */ + batch(fn: () => void, { flush = false }: { flush?: boolean } = {}): void { + this._itemIndex.batch(fn); + if (flush) this.flushPendingPublishes(); + } + // --------------------------------------------------------------------------- // Remove / contains // --------------------------------------------------------------------------- diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index b3f9c5d60e..a2fa9eed08 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -843,10 +843,12 @@ export class MessageIntervalPaginator extends BasePaginator< */ private removeReconciledIds(ids: string[]) { if (!ids.length) return; - this._itemIndex.batch(() => { - for (const id of ids) this.removeItem({ id }); - }); - this.flushPendingPublishes(); + this.batch( + () => { + for (const id of ids) this.removeItem({ id }); + }, + { flush: true }, + ); this.purgeReconciledFromOfflineDb(ids); } @@ -1099,7 +1101,7 @@ export class MessageIntervalPaginator extends BasePaginator< // Batch: one logical operation touches many messages; coalesce the shared-store fan-out to a // single flush (sibling holders are notified once) instead of once per affected message. - this._itemIndex.batch(() => { + this.batch(() => { for (const message of loadedMessages) { if (message.user?.id === userId) { if (hardDelete) { @@ -1145,7 +1147,7 @@ export class MessageIntervalPaginator extends BasePaginator< // Batch: several cached messages may quote the updated one; coalesce the shared-store fan-out // to a single flush instead of one per re-ingested quoting message. - this._itemIndex.batch(() => { + this.batch(() => { for (const cachedMessage of cachedMessages) { if (cachedMessage.quoted_message_id !== message.id) continue; @@ -1171,7 +1173,7 @@ export class MessageIntervalPaginator extends BasePaginator< let activeAffected = false; // Batch: a user rename can touch many messages; coalesce the shared-store fan-out to sibling // holders into a single flush. This paginator's own active window is re-emitted once below. - this._itemIndex.batch(() => { + this.batch(() => { for (const message of this._itemIndex.values()) { if (message.user?.id !== user.id) continue; this._itemIndex.setOne({ ...message, user }); From a93784f5d88532e57df4959e68e88c19b544a9a5 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 12 Aug 2026 14:56:08 +0200 Subject: [PATCH 10/31] chore: add test --- .../paginators/MessagePaginator.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index 7a4c373d06..5629ad071e 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -2324,6 +2324,49 @@ describe('MessagePaginator', () => { expect(paginator.getItem('msg-004')).toBeDefined(); }); + it('over-request: a server-capped page keeps hasMoreTail and anchors the tail cursor to the true loaded oldest', () => { + const all = Array.from({ length: 105 }, (_, i) => + msg(`msg-${String(i).padStart(3, '0')}`, i), + ); + const paginator = loadHead(all, { isTail: true }); + + paginator.mergeNewestPage(all.slice(5), { + candidateIds: new Set(all.map((message) => message.id)), + requestedLimit: 105, + }); + + const state = paginator.state.getLatestValue(); + expect(state.hasMoreTail).toBe(true); + expect(state.cursor?.tailward).toBe('msg-000'); + }); + + it('reached channel start: a page shorter than the clamped limit clears hasMoreTail, nulls the tail cursor and sets isTail', () => { + // The complementary branch: the loaded window believes older messages exist (isTail:false → + // hasMoreTail true), then the newest two are hard-deleted so the reconnect page comes back short + // of the requested limit. A short page (3 < min(5,100)) proves we reached the channel start, so + // hasMoreTail drops to false, the tail cursor nulls, and the interval's isTail flips true. + const paginator = loadHead([ + msg('m1', 1), + msg('m2', 2), + msg('m3', 3), + msg('m4', 4), + msg('m5', 5), + ]); + expect(paginator.state.getLatestValue().hasMoreTail).toBe(true); + + // m4 + m5 hard-deleted while offline: only the surviving newest come back. + paginator.mergeNewestPage([msg('m1', 1), msg('m2', 2), msg('m3', 3)], { + candidateIds: new Set(['m1', 'm2', 'm3', 'm4', 'm5']), + requestedLimit: 5, + }); + + const state = paginator.state.getLatestValue(); + expect(ids(paginator)).toEqual(['m1', 'm2', 'm3']); + expect(state.hasMoreTail).toBe(false); + expect(state.cursor?.tailward).toBeNull(); + expect((paginator.itemIntervals[0] as { isTail?: boolean }).isTail).toBe(true); + }); + it('reconciles a deletion inside the returned page while keeping messages beyond it', () => { const all = Array.from({ length: 105 }, (_, i) => msg(`msg-${String(i).padStart(3, '0')}`, i), From f7626b731971ccc24d7e4b56daba66566447ae31 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 12 Aug 2026 16:33:39 +0200 Subject: [PATCH 11/31] feat: add list level synchronous batching --- src/channel.ts | 2 +- src/pagination/paginators/BasePaginator.ts | 89 ++++++++++++++----- .../paginators/MessageIntervalPaginator.ts | 2 +- .../paginators/MessagePaginator.test.ts | 85 ++++++++++++++++++ 4 files changed, 153 insertions(+), 25 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 0a5b71e84c..d7d94bc82c 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -1334,7 +1334,7 @@ export class Channel extends ChannelApi { if (!paginator.getItem(failed.id)) paginator.ingestItem(failed); } }, - { flush: true }, + { coalesce: true }, ); } finally { this._reloading = false; diff --git a/src/pagination/paginators/BasePaginator.ts b/src/pagination/paginators/BasePaginator.ts index 2baf4d50ff..a4c2ab9849 100644 --- a/src/pagination/paginators/BasePaginator.ts +++ b/src/pagination/paginators/BasePaginator.ts @@ -443,6 +443,15 @@ export abstract class BasePaginator { /** Changed ids buffered since the last {@link flushIntervalViewPublish} (throttled paginators only). */ private _pendingViewChangedIds = new Set(); + /** + * Depth of active {@link batch} `coalesce` scopes. While > 0, `ingestItem` / `removeItem` record + * that the active window changed (see {@link _suspendedWindowDirty}) instead of publishing it, so + * the whole batch produces a single `state.items` emit — independent of state throttling. + */ + private _windowPublishSuspendDepth = 0; + /** Set by a suspended op that changed the active window, so {@link batch} publishes once on exit. */ + private _suspendedWindowDirty = false; + /** * Intervals keep items in disconnected ranges. * That is a scenario of jumping to non-sequential pages. @@ -896,6 +905,11 @@ export abstract class BasePaginator { ); } + /** True while a coalescing {@link batch} scope is suspending this paginator's own window publishes. */ + protected get isWindowPublishSuspended(): boolean { + return this._windowPublishSuspendDepth > 0; + } + /** Re-project the active window from its (live, source-of-truth) interval. `undefined` when inactive. */ private projectActiveWindow(): T[] | undefined { if (!this._activeIntervalId) return undefined; @@ -2108,7 +2122,9 @@ export abstract class BasePaginator { // 3. If it no longer matches the filter, we’re done (it has been removed above). if (!this.matchesFilter(ingestedItem)) { // Throttled: the removal above deferred its emit — publish the (settled) window once. - if (this.isStateThrottled && itemHasBeenRemoved) this.scheduleWindowPublish(); + // Suspended (coalescing batch): removeItemAtCoordinates recorded it; batch() emits once on exit. + if (!this.isWindowPublishSuspended && this.isStateThrottled && itemHasBeenRemoved) + this.scheduleWindowPublish(); return itemHasBeenRemoved; } @@ -2190,7 +2206,12 @@ export abstract class BasePaginator { // Falls somewhere *inside* the global bounds, but we don't have that page loaded. // We’ve already removed any old occurrence, so from the paginator's perspective // this item won't be visible again until the relevant page is fetched. - if (this.isStateThrottled && itemHasBeenRemoved) this.scheduleWindowPublish(); + if ( + !this.isWindowPublishSuspended && + this.isStateThrottled && + itemHasBeenRemoved + ) + this.scheduleWindowPublish(); return itemHasBeenRemoved; } } @@ -2211,7 +2232,10 @@ export abstract class BasePaginator { activeIntervalIdBeforeRemoval === removedIntervalId && targetInterval.id === removedIntervalId ) { - this.setActiveInterval(targetInterval); + this.setActiveInterval( + targetInterval, + this.isWindowPublishSuspended ? { updateState: false } : undefined, + ); } const addedNewInterval = !this._itemIntervals.has(targetInterval.id); @@ -2228,7 +2252,10 @@ export abstract class BasePaginator { this._activeIntervalId, ) ) { - if (this.isStateThrottled) { + if (this.isWindowPublishSuspended) { + // Coalescing batch: record the change; batch() emits the settled window once on exit. + this._suspendedWindowDirty = true; + } else if (this.isStateThrottled) { this.scheduleWindowPublish(); } else { const items = this.items ?? []; @@ -2266,24 +2293,37 @@ export abstract class BasePaginator { /** * Run `fn` as one batched mutation over this paginator's items, collapsing the redundant emits a - * naive per item loop would produce, in two independent ways: + * naive per-item loop would produce: * - * - **Shared-store fan-out** (the `_itemIndex.batch` wrapper): per-item store notifications fold - * into a single flush, so sibling holders of the same ids (e.g. a thread / pinned paginator - * sharing the entity) re-project once, not once per item. Inert for single-home paginators - * (channels, reminders, user groups) whose index is over a private store with no sibling - * subscribers — there it is a plain passthrough. - * - **This paginator's own active window** (`flush: true`): when state throttling is on (the message - * list in production), each `ingestItem` / `removeItem` inside `fn` defers its window publish via - * {@link scheduleWindowPublish}; the trailing {@link flushPendingPublishes} then emits the settled - * window exactly once. Pass it for oneshot operations (reconciliation) that must settle - * synchronously. Leave it `false` inside WS event handlers so successive events keep coalescing - * across the throttle trailing edge instead of each forcing an emit. Flushing is of course still - * possible if that is deemed necessary at a certain point. - */ - batch(fn: () => void, { flush = false }: { flush?: boolean } = {}): void { - this._itemIndex.batch(fn); - if (flush) this.flushPendingPublishes(); + * - **Shared-store fan-out** (always, via the `_itemIndex.batch` wrapper): per-item store + * notifications fold into a single flush, so sibling holders of the same ids (e.g. a thread / + * pinned paginator sharing the entity) re-project once, not once per item. Inert for single-home + * paginators (channels, reminders, user groups) whose index is over a private store with no + * sibling subscribers — there it is a plain passthrough. + * - **This paginator's own active window** (`coalesce: true`): every `ingestItem` / `removeItem` + * inside `fn` records that the window changed instead of publishing it, then `batch` emits the + * settled window exactly once on exit via {@link flushWindowPublish}. This is deterministic and + * independent of state throttling — unlike leaving it off, where an un-throttled paginator emits + * once per item and a throttled one merely coalesces within its 500ms window. Use it for one-shot + * operations (reconciliation, reload re-ingest) that must settle in a single update. Omit it in WS + * event handlers, where per-event publishes should ride the throttle so successive events coalesce + * across its trailing edge rather than each forcing a synchronous emit. + */ + batch(fn: () => void, { coalesce = false }: { coalesce?: boolean } = {}): void { + if (!coalesce) { + this._itemIndex.batch(fn); + return; + } + this._windowPublishSuspendDepth += 1; + try { + this._itemIndex.batch(fn); + } finally { + this._windowPublishSuspendDepth -= 1; + } + if (this._windowPublishSuspendDepth === 0 && this._suspendedWindowDirty) { + this._suspendedWindowDirty = false; + this.flushWindowPublish(); + } } // --------------------------------------------------------------------------- @@ -2317,7 +2357,9 @@ export abstract class BasePaginator { // 2) Remove from visible state.items, if present if (stateLocation && stateLocation.currentIndex > -1) { - if (!this.isStateThrottled) { + if (this.isWindowPublishSuspended) { + this._suspendedWindowDirty = true; + } else if (!this.isStateThrottled) { const newItems = [...(this.items ?? [])]; newItems.splice(stateLocation.currentIndex, 1); this.state.partialNext({ items: newItems }); @@ -2355,7 +2397,8 @@ export abstract class BasePaginator { const result = this.removeItemAtCoordinates(coords); this._itemIndex.remove(this.getItemId(item)); // Throttled: removeItemAtCoordinates deferred its emit — publish the (settled) window once. - if (this.isStateThrottled) this.scheduleWindowPublish(); + if (!this.isWindowPublishSuspended && this.isStateThrottled) + this.scheduleWindowPublish(); return result; } diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index a2fa9eed08..edd3f4e7c1 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -847,7 +847,7 @@ export class MessageIntervalPaginator extends BasePaginator< () => { for (const id of ids) this.removeItem({ id }); }, - { flush: true }, + { coalesce: true }, ); this.purgeReconciledFromOfflineDb(ids); } diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index 5629ad071e..b2df3fb40c 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -2367,6 +2367,91 @@ describe('MessagePaginator', () => { expect((paginator.itemIntervals[0] as { isTail?: boolean }).isTail).toBe(true); }); + describe('batch({ coalesce: true }) — single deterministic window publish', () => { + it('coalesces N removals into a single state publish', () => { + const paginator = loadHead([ + msg('m1', 1), + msg('m2', 2), + msg('m3', 3), + msg('m4', 4), + ]); + const spy = vi.spyOn(paginator.state, 'partialNext'); + + paginator.batch( + () => { + paginator.removeItem({ id: 'm1' }); + paginator.removeItem({ id: 'm2' }); + paginator.removeItem({ id: 'm3' }); + }, + { coalesce: true }, + ); + + expect(spy).toHaveBeenCalledTimes(1); + expect(ids(paginator)).toEqual(['m4']); + }); + + it('coalesces N in-place updates into a single state publish', () => { + const paginator = loadHead([msg('m1', 1), msg('m2', 2), msg('m3', 3)]); + const spy = vi.spyOn(paginator.state, 'partialNext'); + + paginator.batch( + () => { + paginator.ingestItem(msg('m1', 1, { text: 'a' })); + paginator.ingestItem(msg('m2', 2, { text: 'b' })); + paginator.ingestItem(msg('m3', 3, { text: 'c' })); + }, + { coalesce: true }, + ); + + expect(spy).toHaveBeenCalledTimes(1); + expect(paginator.getItem('m1')?.text).toBe('a'); + expect(paginator.getItem('m3')?.text).toBe('c'); + }); + + it('coalesces a mixed remove + ingest batch into a single state publish', () => { + const paginator = loadHead([msg('m1', 1), msg('m2', 2), msg('m3', 3)]); + const spy = vi.spyOn(paginator.state, 'partialNext'); + + paginator.batch( + () => { + paginator.removeItem({ id: 'm2' }); + paginator.ingestItem(msg('m3', 3, { text: 'edited' })); + }, + { coalesce: true }, + ); + + expect(spy).toHaveBeenCalledTimes(1); + expect(ids(paginator)).toEqual(['m1', 'm3']); + expect(paginator.getItem('m3')?.text).toBe('edited'); + }); + + it('without coalesce, the same removals publish once per item (proves the scope does the work)', () => { + const paginator = loadHead([msg('m1', 1), msg('m2', 2), msg('m3', 3)]); + const spy = vi.spyOn(paginator.state, 'partialNext'); + + paginator.batch(() => { + paginator.removeItem({ id: 'm1' }); + paginator.removeItem({ id: 'm2' }); + }); + + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('does not publish when the coalesced batch leaves the active window unchanged', () => { + const paginator = loadHead([msg('m1', 1), msg('m2', 2)]); + const spy = vi.spyOn(paginator.state, 'partialNext'); + + paginator.batch( + () => { + paginator.removeItem({ id: 'does-not-exist' }); + }, + { coalesce: true }, + ); + + expect(spy).not.toHaveBeenCalled(); + }); + }); + it('reconciles a deletion inside the returned page while keeping messages beyond it', () => { const all = Array.from({ length: 105 }, (_, i) => msg(`msg-${String(i).padStart(3, '0')}`, i), From 05953603678ef5da31f2dfa5a63eff9543712ecb Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 12 Aug 2026 16:37:15 +0200 Subject: [PATCH 12/31] fix: rename --- .../paginators/MessageIntervalPaginator.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index edd3f4e7c1..cdec7719b3 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -615,7 +615,7 @@ export class MessageIntervalPaginator extends BasePaginator< * partial page leaves it untouched, while a page reaching deeper than the loaded window (or a * stale offline-DB cursor) re-anchors it to the true loaded oldest so "load older" keeps working — * derived from the interval, never the page's length. Then destructive reconciliation - * ({@link reconcileLoadedAgainstPage}) removes any loaded message + * ({@link reconcileHeadAgainstPage}) removes any loaded message * that the authoritative page proves was hard-deleted while offline (see that method + the * {@link MergeNewestPageOptions} for the exact, safe window). * @@ -647,7 +647,7 @@ export class MessageIntervalPaginator extends BasePaginator< // targets the head interval regardless of which interval is active, and removing a hidden-head ghost // leaves the active island's items untouched. if (!this.isActiveInterval(headInterval)) { - this.reconcileLoadedAgainstPage(page, options); + this.reconcileHeadAgainstPage(page, options); return; } @@ -655,7 +655,7 @@ export class MessageIntervalPaginator extends BasePaginator< // Empty page: never blanks the list by itself. Only when the caller supplied a pre-fetch // snapshot do we treat it as authoritative "channel emptied" and remove ghosts (a message that // arrived live during the fetch is excluded by the snapshot). - this.reconcileLoadedAgainstPage([], options); + this.reconcileHeadAgainstPage([], options); return; } @@ -731,7 +731,7 @@ export class MessageIntervalPaginator extends BasePaginator< }); // With the newest page merged in, drop any loaded message the page proves was hard-deleted. - this.reconcileLoadedAgainstPage(page, options); + this.reconcileHeadAgainstPage(page, options); }; /** @@ -749,9 +749,9 @@ export class MessageIntervalPaginator extends BasePaginator< } /** - * Destructive half of {@link mergeNewestPage}: remove loaded messages that the freshly-fetched - * newest `page` proves were hard-deleted while offline (a hard delete emits no event to other - * clients, and the merge is additive, so they would otherwise linger forever). + * Destructive half of {@link mergeNewestPage}: remove messages in the loaded head interval that the + * freshly-fetched newest `page` proves were hard-deleted while offline (a hard delete emits no event + * to other clients, and the merge is additive, so they would otherwise linger forever). * * The reconcilable window is derived ENTIRELY from what the page returned — never a hardcoded page * size — so it can only ever remove messages the page actually covers: @@ -771,7 +771,7 @@ export class MessageIntervalPaginator extends BasePaginator< * store and — via the {@link MessagePaginator} override — the tracked last message all stay * correct, then the active window is re-emitted once. */ - protected reconcileLoadedAgainstPage( + protected reconcileHeadAgainstPage( page: LocalMessage[], options?: MergeNewestPageOptions, ) { From fa09af4f5a04835fcb2aed7d45f6d2212c9f74b4 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 12 Aug 2026 17:04:24 +0200 Subject: [PATCH 13/31] chore: rename api --- .../paginators/MessageIntervalPaginator.ts | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index cdec7719b3..85916ef5a9 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -790,7 +790,7 @@ export class MessageIntervalPaginator extends BasePaginator< const message = this.getItem(id); return !!message && this.isServerConfirmedMessage(message); }); - this.removeReconciledIds(toRemove); + this.removeReconciledItems(toRemove); return; } @@ -831,17 +831,28 @@ export class MessageIntervalPaginator extends BasePaginator< if (ts > windowLowTs) toRemove.push(id); } - this.removeReconciledIds(toRemove); + this.removeReconciledItems(toRemove); } /** - * Remove a set of reconciled (hard-deleted) ids in one batch — coalescing the shared-store fan-out - * to a single flush — then flush the deferred window publish so the list drops the ghosts - * synchronously (blanking to `[]` if the active window emptied, per {@link flushWindowPublish}). - * Finally, mirror the removal into the offline DB so a cold start does not re-seed the ghosts from - * SQLite. No-op for an empty set, so an unaffected merge does not touch state a second time. + * Remove a set of reconciled (hard-deleted) ids from this paginator's loaded state in one coalesced + * batch. Each id goes through {@link removeItem}, which drops it from THREE places — not a single + * interval: + * + * 1. the item index — unlinks this paginator's membership from the shared entity store (GC'ing the + * content if this was the last holder); + * 2. the interval that holds it — located by id, so it is NOT head-specific in general; in practice + * it is always the head interval, because the sole caller ({@link reconcileHeadAgainstPage}) + * sources these ids from `headInterval.itemIds` and an item has single-interval membership; + * 3. the active window (`state.items`) if visible — blanking to `[]` when the window empties, per + * {@link flushWindowPublish}. + * + * The coalesced batch collapses all of that into a single `state.items` publish. Then mirror the + * removal into the offline DB (see {@link purgeReconciledFromOfflineDb}) so a cold start does not + * re-seed the ghosts from SQLite. No-op for an empty set, so an unaffected merge does not touch + * state a second time. */ - private removeReconciledIds(ids: string[]) { + private removeReconciledItems(ids: string[]) { if (!ids.length) return; this.batch( () => { From 269f4d6ea828d75cdc3b6f82c8d92a1004a84d0d Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 12 Aug 2026 17:08:32 +0200 Subject: [PATCH 14/31] fix: clarify docs --- .../paginators/MessageIntervalPaginator.ts | 66 ++++++++++--------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index 85916ef5a9..6870a59744 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -148,27 +148,29 @@ export type MessagePaginatorOptions = { * offline. A hard delete emits no event to other clients, and the merge is otherwise additive, so * without this such a message lingers as a ghost after reconnect. * - * With NO options, `mergeNewestPage` still prunes any loaded message that falls WITHIN the returned - * page's `created_at` span but is absent from it — unconditionally safe (a message that arrived live - * during the caller's fetch is always strictly newer than the newest returned message, so it can - * never fall in that span). The options widen the reconcilable window: + * Throughout, `page` is the newest window the server returned for `mergeNewestPage`, and "`page`'s + * newest / oldest message" are its bounds by `created_at`. With NO options, `mergeNewestPage` still + * prunes any loaded message that falls WITHIN `page`'s `created_at` span but is absent from it — + * unconditionally safe (a message that arrived live during the caller's fetch is always strictly + * newer than `page`'s newest message, so it can never fall in that span). The options widen the + * reconcilable window: */ export type MergeNewestPageOptions = { /** - * The `limit` the caller passed to the query that produced the page. Lets reconciliation tell - * "the page reached the channel's oldest message" (a returned count short of the request) from - * "the page is full and older messages remain". Only then may it prune loaded messages OLDER than - * the oldest returned message (e.g. the oldest loaded message was the one deleted). Clamped to the + * The `limit` the caller passed to the query that produced `page`. Lets reconciliation tell "`page` + * reached the channel's own oldest message" (it came back shorter than requested) from "`page` is + * full and older messages remain". Only in the former may it prune loaded messages OLDER than + * `page`'s oldest message (e.g. the oldest loaded message was the one deleted). Clamped to the * server's max page size so an over-request cannot be mistaken for reaching the start. */ requestedLimit?: number; /** * A snapshot of the loaded message ids taken BEFORE the caller's fetch await. Required to prune - * messages NEWER than the newest returned message (a hard-deleted newest message) and to reconcile - * an empty page (a fully-emptied channel): at/above that top edge a just-deleted message and a - * message that arrived live during the fetch are indistinguishable by timestamp — only the - * pre-fetch snapshot separates them (a live arrival is not in it). Must be captured before the - * await; the paginator's own items at merge time already include any live arrival. + * messages NEWER than `page`'s newest message (a hard-deleted newest message) and to reconcile an + * empty `page` (a fully-emptied channel): at/above that top edge a just-deleted message and a + * message that arrived live during the fetch are indistinguishable by timestamp — only the pre-fetch + * snapshot separates them (a live arrival is not in it). Must be captured before the await; the + * paginator's own items at merge time already include any live arrival. */ candidateIds?: ReadonlySet; }; @@ -749,27 +751,31 @@ export class MessageIntervalPaginator extends BasePaginator< } /** - * Destructive half of {@link mergeNewestPage}: remove messages in the loaded head interval that the - * freshly-fetched newest `page` proves were hard-deleted while offline (a hard delete emits no event - * to other clients, and the merge is additive, so they would otherwise linger forever). + * Destructive half of {@link mergeNewestPage}: remove messages in the loaded head interval that + * `page` proves were hard-deleted while offline (a hard delete emits no event to other clients, and + * the merge is additive, so they would otherwise linger forever). * - * The reconcilable window is derived ENTIRELY from what the page returned — never a hardcoded page - * size — so it can only ever remove messages the page actually covers: + * Here `page` is the freshly-fetched newest window the server returned for the query that produced + * it. Its two bounds — used throughout below — are its NEWEST message (`page`'s last item by + * `created_at`) and its OLDEST message (`page`'s first item). The reconcilable window is derived + * ENTIRELY from those two bounds — never a hardcoded page size — so it can only ever remove messages + * `page` actually covers. Three regions, by a loaded message's `created_at`: * - * - WITHIN the page's span (`oldest returned < created_at < newest returned`): a server-confirmed - * loaded message absent from the page was hard-deleted. Safe with no snapshot — a message that - * arrived live during the caller's fetch is always strictly newer than the newest returned - * message, so it can never fall in this span. - * - BELOW the oldest returned message: only reconcilable when the page reached the channel's oldest - * message (`requestedLimit` given and the page came back short, clamped to the server max page - * size). Otherwise older messages simply were not fetched and are left untouched. - * - AT/ABOVE the newest returned message (a hard-deleted newest message) and the empty-page case: - * only reconcilable with a pre-fetch `candidateIds` snapshot, which alone distinguishes a ghost - * from a live arrival at that top edge. + * - WITHIN `page` (strictly between `page`'s oldest and newest message): a server-confirmed loaded + * message absent from `page` was hard-deleted. Safe with no snapshot — a message that arrived live + * during the caller's fetch is always strictly newer than `page`'s newest message, so it can never + * fall in this span. + * - BELOW `page`'s oldest message: only reconcilable when `page` reached the channel's own oldest + * message (`requestedLimit` given and `page` came back shorter than requested, clamped to the + * server's max page size). Otherwise older messages simply were not fetched, so they are left + * untouched. + * - AT/ABOVE `page`'s newest message (a hard-deleted newest message), and the empty-`page` case: + * only reconcilable with a pre-fetch `candidateIds` snapshot, which alone tells a just-deleted + * message from one that arrived live during the fetch at that top edge. * * Removal goes through {@link removeItem} (batched) so the item index, intervals, shared message - * store and — via the {@link MessagePaginator} override — the tracked last message all stay - * correct, then the active window is re-emitted once. + * store and — via the {@link MessagePaginator} override — the tracked last message all stay correct; + * the active window is then re-emitted once. */ protected reconcileHeadAgainstPage( page: LocalMessage[], From 679e8e417d9af06435e56474a800d3bb09c08400 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 13 Aug 2026 14:45:40 +0200 Subject: [PATCH 15/31] fix: properly reconcile above the fold items --- src/channel.ts | 7 +- .../paginators/MessageIntervalPaginator.ts | 207 +++++++--- .../paginators/MessagePaginator.test.ts | 353 +++++++++++++++--- 3 files changed, 447 insertions(+), 120 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index d7d94bc82c..65f3485cd5 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -1320,10 +1320,9 @@ export class Channel extends ChannelApi { this._reloading = true; try { const paginator = this.messagePaginator; - const requestedLimit = paginator.items?.length || paginator.pageSize; - const failedBefore = (paginator.items ?? []).filter( - (message) => message.status === 'failed', - ); + const headItems = paginator.headItems; + const requestedLimit = headItems.length || paginator.pageSize; + const failedBefore = headItems.filter((message) => message.status === 'failed'); await this.watch({ messages: { limit: requestedLimit } }); this.offlineMode = false; diff --git a/src/pagination/paginators/MessageIntervalPaginator.ts b/src/pagination/paginators/MessageIntervalPaginator.ts index 6870a59744..1121d868d0 100644 --- a/src/pagination/paginators/MessageIntervalPaginator.ts +++ b/src/pagination/paginators/MessageIntervalPaginator.ts @@ -149,19 +149,24 @@ export type MessagePaginatorOptions = { * without this such a message lingers as a ghost after reconnect. * * Throughout, `page` is the newest window the server returned for `mergeNewestPage`, and "`page`'s - * newest / oldest message" are its bounds by `created_at`. With NO options, `mergeNewestPage` still - * prunes any loaded message that falls WITHIN `page`'s `created_at` span but is absent from it — + * newest / oldest message" are its bounds by `created_at`. With NO options, `mergeNewestPage` prunes + * any loaded message that falls WITHIN `page`'s `created_at` span but is absent from it — * unconditionally safe (a message that arrived live during the caller's fetch is always strictly - * newer than `page`'s newest message, so it can never fall in that span). The options widen the - * reconcilable window: + * newer than `page`'s newest message, so it can never fall in that span). `candidateIds` widens the + * reconcilable window at the TOP edge; `requestedLimit` only tunes the `hasMoreTail` affordance and + * can never remove a message: */ export type MergeNewestPageOptions = { /** - * The `limit` the caller passed to the query that produced `page`. Lets reconciliation tell "`page` - * reached the channel's own oldest message" (it came back shorter than requested) from "`page` is - * full and older messages remain". Only in the former may it prune loaded messages OLDER than - * `page`'s oldest message (e.g. the oldest loaded message was the one deleted). Clamped to the - * server's max page size so an over-request cannot be mistaken for reaching the start. + * The `limit` the caller passed to the query that produced `page`. Used ONLY to derive + * `hasMoreTail` — whether `page` reached the channel's own oldest message — by comparing it to + * `page.length`. It NEVER removes a message (reconciliation stays window-only regardless), so the + * worst a wrong value can do is show/hide the "load older" affordance for one settle. Trusted only + * when no larger than the paginator's own `pageSize` (see + * {@link MessageIntervalPaginator.pageReachedChannelStart}); a larger over-request — e.g. + * `channel.reload` re-fetching the whole loaded window to reconcile as much as possible — may have + * been silently server-capped, so its short page is ignored rather than mistaken for reaching the + * start. */ requestedLimit?: number; /** @@ -215,6 +220,8 @@ export class MessageIntervalPaginator extends BasePaginator< protected _requestSort = DEFAULT_BACKEND_SORT; protected _itemOrder: MessagePaginatorSort = DEFAULT_BACKEND_SORT; protected _nextQueryShape: MessageQueryShape | undefined; + /** Pending below-window reached-start probe (exposed for deterministic test awaiting). */ + private _belowWindowReconcile?: Promise; sortComparator: (a: LocalMessage, b: LocalMessage) => number; /** * Single source of truth for whether a message should be included in paginator intervals/state. @@ -613,10 +620,10 @@ export class MessageIntervalPaginator extends BasePaginator< * 1. OVERLAP - the incoming page shares at least one id with the loaded head (fewer than a full * page is new). Merge in place: existing items are reconciled by id (edits, soft deletes), new * items are appended and every already loaded item (including older pages already paged in) is - * kept. The tail boundary (`hasMoreTail`/`cursor.tailward`) is taken from the MERGED interval: a - * partial page leaves it untouched, while a page reaching deeper than the loaded window (or a - * stale offline-DB cursor) re-anchors it to the true loaded oldest so "load older" keeps working — - * derived from the interval, never the page's length. Then destructive reconciliation + * kept. The tail boundary (`hasMoreTail`/`cursor.tailward`) is set from whether the page reached + * the channel start ({@link pageReachedChannelStart}): a bounded page that came back short means + * no more older, otherwise "load older" stays enabled and the cursor re-anchors to the true loaded + * oldest (which also clears a stale offline-DB cursor). Then destructive reconciliation * ({@link reconcileHeadAgainstPage}) removes any loaded message * that the authoritative page proves was hard-deleted while offline (see that method + the * {@link MergeNewestPageOptions} for the exact, safe window). @@ -642,6 +649,10 @@ export class MessageIntervalPaginator extends BasePaginator< mergeNewestPage = (page: LocalMessage[], options?: MergeNewestPageOptions) => { const headInterval = this.itemIntervals[0] as Interval | undefined; if (!headInterval?.isHead) return; + // Captured BEFORE the merge overwrites state — inputs for the below-window reached-start probe + // (overlap branch only): did we believe we were at the channel start, and how much was loaded. + const wasAtChannelStart = !this.state.getLatestValue().hasMoreTail; + const preMergeLoadedCount = headInterval.itemIds.length; // If the caller jumped to a separate (older) window, that window is active and the head is merely // still-loaded underneath. Don't MERGE the fresh page or switch the view (that would yank them to // the newest) — but STILL prune offline hard-deletes out of the hidden head, otherwise returning to @@ -694,29 +705,17 @@ export class MessageIntervalPaginator extends BasePaginator< const interval = this.ingestPage({ page, isHead: true, setActive: false }); if (!interval) return; - // Re-compute hasMoreTail from the FETCHED PAGE, not the merged interval. The interval's isTail is - // "sticky" — mergeTwoAnchoredIntervals ORs isTail — so a stale offline-DB window persisted as - // "complete" (isTail:true) keeps hasMoreTail=false through the merge, and "load older" stays dead. - // A full page (length == the limit we asked for) means older messages remain; a short page means we - // reached the channel start. Without a caller-supplied limit (a live-edit merge of unknown size) - // fall back to the interval's own flag. Pagination reads off STATE, so writing it here is what - // unblocks "load older"; a later executeQuery re-derives per page. Mirrors postQueryReconcile, - // which likewise derives this flag from the page rather than the interval. - const { requestedLimit } = options ?? {}; - const canDeriveTail = typeof requestedLimit === 'number'; - const reachedChannelStart = - canDeriveTail && - page.length < Math.min(requestedLimit, DEFAULT_CHANNEL_MESSAGE_LIST_PAGE_SIZE); - const hasMoreTail = canDeriveTail ? !reachedChannelStart : interval.hasMoreTail; - - // Correct the INTERVAL flags too, not just state. `intervalsOverlap` (the merge test in - // ingestPage) consults `interval.isTail`: a sticky `isTail:true` inherited from a "complete" - // offline-DB window makes ANY older page count as overlapping this head interval — so a far - // jump's load-older welds two pages-apart sets into one. Derive isTail from the page here like the - // state above; mirrors postQueryReconcile (`interval.isTail = hasMoreTail === false`). Only a - // genuine channel-start interval (no older messages) keeps isTail:true. + // hasMoreTail: does the fetched page prove it reached the channel's own oldest message? Only a + // request bounded by our OWN pageSize gives that proof (a short page); an over-request may have + // been server-capped, so it biases to `true` — never asserting "no more older" from a page that + // may be truncated. See {@link pageReachedChannelStart} (no hardcoded max page size, per-paginator). + // This decides only the "load older" AFFORDANCE — it never removes a message (reconciliation stays + // window-only below). isTail mirrors it: `true` only when we RELIABLY reached the start, which also + // clears a stale offline-DB `isTail:true` and stops `intervalsOverlap` welding a far page across a + // gap. The tail cursor anchors to the oldest loaded id so "load older" stays contiguous. + const hasMoreTail = !this.pageReachedChannelStart(page, options); interval.hasMoreTail = hasMoreTail; - interval.isTail = hasMoreTail === false; + interval.isTail = !hasMoreTail; this.setActiveInterval(interval, { updateState: false }); this.state.partialNext({ @@ -732,10 +731,92 @@ export class MessageIntervalPaginator extends BasePaginator< }, }); - // With the newest page merged in, drop any loaded message the page proves was hard-deleted. - this.reconcileHeadAgainstPage(page, options); + // With the newest page merged in, drop any loaded message the page proves was hard-deleted, and + // collect the below-window leftovers it cannot decide from the page alone. + const belowWindow = this.reconcileHeadAgainstPage(page, options); + + // The leftovers are ambiguous (hard-deleted oldest vs server-capped over-request). Only a + // full-window reload (`requestedLimit >= loaded`, so NOT the small list hydrate) that we believed + // reached the start could have reached it — probe to settle them there; anything else can't, so skip. + const requestedLimit = options?.requestedLimit; + if ( + belowWindow.length && + wasAtChannelStart && + typeof requestedLimit === 'number' && + requestedLimit >= preMergeLoadedCount + ) { + this._belowWindowReconcile = this.pruneBelowWindowIfReachedStart( + belowWindow, + this.getItemId(page[0]), + ); + } }; + /** + * Whether `page` proves it reached the channel's own oldest message: the caller's request was + * bounded by this paginator's OWN `pageSize` AND `page` came back shorter than that request. + * + * `pageSize <= the server's max page size` is already the invariant normal pagination + * (`executeQuery`) relies on — `page.length < pageSize` ⟹ reached the end — so a request no larger + * than `pageSize` returns exactly what was asked unless the channel ended. This needs no hardcoded + * max page size and is per-paginator, so a thread reply paginator uses its own `pageSize`. + * + * An OVER-request (larger than `pageSize`, e.g. `channel.reload` re-fetching the whole loaded window + * to reconcile as much as possible) may have been silently capped by the server, so a short page + * there is NOT proof of reaching the start — return `false` and stay conservative. This only ever + * gates the `hasMoreTail` affordance; it never removes a message. + */ + private pageReachedChannelStart( + page: LocalMessage[], + options?: MergeNewestPageOptions, + ): boolean { + const requestedLimit = options?.requestedLimit; + return ( + typeof requestedLimit === 'number' && + requestedLimit <= this.pageSize && + page.length < requestedLimit + ); + } + + /** + * Settle the below-window leftovers {@link reconcileHeadAgainstPage} handed back (loaded messages + * older than `anchorId` — the returned page's oldest — and absent from the page). From the page alone + * they're ambiguous: a hard-deleted oldest vs a server-capped over-request. Ask the server "anything + * older than `anchorId`?" — the only cap-free way to tell. Nothing older ⇒ genuine deletes ⇒ remove + * them and settle the tail. A probe failure or a head change across the await keeps them (never a false + * delete). The caller decides WHEN this runs. + */ + private async pruneBelowWindowIfReachedStart( + belowWindow: string[], + anchorId: string, + ): Promise { + const headId = (this.itemIntervals[0] as Interval | undefined)?.id; + // older exists (or the probe failed), we we're not provably the start, so + // keep the leftovers + if (await this.hasMessagesOlderThan(anchorId).catch(() => true)) return; + // revalidate the head across the await (a jump/reset/newer merge aborts), then remove the leftovers + const head = this.itemIntervals[0] as Interval | undefined; + if (!head?.isHead || head.id !== headId) return; + this.removeReconciledItems(belowWindow); + head.hasMoreTail = false; // we now KNOW we reached the start, so settle the "load older" affordance + head.isTail = true; + if (this.isActiveInterval(head)) { + this.state.partialNext({ + hasMoreTail: false, + cursor: { headward: null, tailward: null }, + }); + } + } + + /** One `limit: 1` fetch of messages older than `id` (channel main list or thread replies). */ + private async hasMessagesOlderThan(id: string): Promise { + const pagination = { limit: 1, id_lt: id } as MessagePaginationParams; + const { messages } = this.parentMessageId + ? await this.channel.getReplies({ parent_id: this.parentMessageId, ...pagination }) + : await this.channel.query({ messages: pagination }); + return Array.isArray(messages) && messages.length > 0; + } + /** * Whether a loaded message is server-confirmed and therefore eligible to be reconciled away when * absent from an authoritative page. Excludes local-only messages the server has never @@ -756,33 +837,38 @@ export class MessageIntervalPaginator extends BasePaginator< * the merge is additive, so they would otherwise linger forever). * * Here `page` is the freshly-fetched newest window the server returned for the query that produced - * it. Its two bounds — used throughout below — are its NEWEST message (`page`'s last item by - * `created_at`) and its OLDEST message (`page`'s first item). The reconcilable window is derived - * ENTIRELY from those two bounds — never a hardcoded page size — so it can only ever remove messages - * `page` actually covers. Three regions, by a loaded message's `created_at`: + * it. Its two bounds are its NEWEST message (`page`'s last item by `created_at`) and its OLDEST + * message (`page`'s first item). Reconciliation removes ONLY messages `page` actually covers — no + * hardcoded page size is involved — in two regions, by a loaded message's `created_at`: * * - WITHIN `page` (strictly between `page`'s oldest and newest message): a server-confirmed loaded * message absent from `page` was hard-deleted. Safe with no snapshot — a message that arrived live * during the caller's fetch is always strictly newer than `page`'s newest message, so it can never * fall in this span. - * - BELOW `page`'s oldest message: only reconcilable when `page` reached the channel's own oldest - * message (`requestedLimit` given and `page` came back shorter than requested, clamped to the - * server's max page size). Otherwise older messages simply were not fetched, so they are left - * untouched. * - AT/ABOVE `page`'s newest message (a hard-deleted newest message), and the empty-`page` case: * only reconcilable with a pre-fetch `candidateIds` snapshot, which alone tells a just-deleted * message from one that arrived live during the fetch at that top edge. * + * A loaded message BELOW `page`'s oldest is NOT removed here (window-only) — from the page alone a + * hard-deleted oldest is indistinguishable from a server-capped over-request. Those messages are + * instead RETURNED (the below-window leftovers) so {@link pruneBelowWindowIfReachedStart} can settle + * them with a `limit: 1` "anything older?" probe — the only cap-free proof — and remove them only if + * the server confirms nothing older exists. Absent that proof they are kept (a later cold/fresh query + * omits any real delete). + * * Removal goes through {@link removeItem} (batched) so the item index, intervals, shared message * store and — via the {@link MessagePaginator} override — the tracked last message all stay correct; * the active window is then re-emitted once. + * + * @returns the below-window leftover ids (loaded, server-confirmed, older than `page`'s oldest, absent + * from it) for the caller's reached-start probe. Empty for the no-head / empty-page paths. */ protected reconcileHeadAgainstPage( page: LocalMessage[], options?: MergeNewestPageOptions, - ) { + ): string[] { const headInterval = this.itemIntervals[0] as Interval | undefined; - if (!headInterval?.isHead) return; + if (!headInterval?.isHead) return []; const loadedIds = headInterval.itemIds; const candidateIds = options?.candidateIds; @@ -790,33 +876,27 @@ export class MessageIntervalPaginator extends BasePaginator< // Empty page → the channel has no messages. Every server-confirmed loaded message is gone, but // only remove ids from the pre-fetch snapshot so a message that landed during the fetch survives. if (!page.length) { - if (!candidateIds) return; + if (!candidateIds) return []; const toRemove = loadedIds.filter((id) => { if (!candidateIds.has(id)) return false; const message = this.getItem(id); return !!message && this.isServerConfirmedMessage(message); }); this.removeReconciledItems(toRemove); - return; + return []; } const pageIds = new Set(page.map((message) => this.getItemId(message))); const newestReturnedTs = getMessageCreatedAtTimestamp(page[page.length - 1]); const oldestReturnedTs = getMessageCreatedAtTimestamp(page[0]); - // Only extend below the oldest returned message when the page proves it reached the channel's - // oldest message: it came back shorter than requested. Clamp the request to the server's max page - // size so asking for MORE than one page can return (an over-request) is not mistaken for reaching - // the start — in that case the shortfall is the server capping, not the channel ending. - const { requestedLimit } = options ?? {}; - const reachedChannelStart = - typeof requestedLimit === 'number' && - page.length < Math.min(requestedLimit, DEFAULT_CHANNEL_MESSAGE_LIST_PAGE_SIZE); - const windowLowTs = reachedChannelStart - ? Number.NEGATIVE_INFINITY - : (oldestReturnedTs ?? Number.POSITIVE_INFINITY); + // Window-only: never remove below the returned page's oldest here, because from the page alone a + // hard-deleted oldest is indistinguishable from a server-capped over-request. Below-window messages + // are collected into `belowWindow` and returned instead, for the reached-start probe to settle. + const windowLowTs = oldestReturnedTs ?? Number.POSITIVE_INFINITY; const toRemove: string[] = []; + const belowWindow: string[] = []; for (const id of loadedIds) { if (pageIds.has(id)) continue; // present on the server → keep const message = this.getItem(id); @@ -832,12 +912,15 @@ export class MessageIntervalPaginator extends BasePaginator< if (candidateIds?.has(id)) toRemove.push(id); continue; } - // Strictly within the page's span (below the newest returned message): a live arrival can never - // be here, so the absence is a hard delete regardless of a snapshot. + // Below the newest returned message: within the page's span (a live arrival can never be here, so + // absence = a hard delete) → remove; at/below the page's oldest → a below-window leftover the page + // cannot decide, returned for the reached-start probe. if (ts > windowLowTs) toRemove.push(id); + else belowWindow.push(id); } this.removeReconciledItems(toRemove); + return belowWindow; } /** diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index b2df3fb40c..5c2b94798f 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -1736,11 +1736,10 @@ describe('MessagePaginator', () => { }); it('keeps hasMoreTail and anchors the tail cursor to the loaded oldest when merging a partial newest window', () => { - // Only the newest window is loaded and older items still exist (hasMoreTail true). Merging a - // short page (fewer than pageSize) whose first item is the set's first item must NOT clear - // hasMoreTail — re-deriving it from the page's LENGTH would wrongly break "load older". The tail - // boundary is taken from the MERGED interval, so hasMoreTail stays true and the cursor anchors to - // the loaded oldest (m1) — the correct "load older" anchor. + // Only the newest window is loaded and older items still exist (hasMoreTail true). A live partial + // merge passes no requestedLimit, so the flag stays conservative (true) — hasMoreTail is only ever + // lowered by a caller-supplied requestedLimit bounded by pageSize that comes back short. Here it + // stays true and the cursor anchors to the loaded oldest (m1) — the correct "load older" anchor. const { paginator, m1, m2 } = setupLoadedHead({ isTail: false }); expect(paginator.state.getLatestValue().hasMoreTail).toBe(true); @@ -2018,28 +2017,29 @@ describe('MessagePaginator', () => { // m2 slid into the window: page = [m2,m3,m5,m6]. Older m1 is below the page and MUST stay. paginator.mergeNewestPage( [msg('m2', 2), msg('m3', 3), msg('m5', 5), msg('m6', 6)], - { - requestedLimit: 4, - }, + {}, ); expect(paginator.getItem('m4')).toBeUndefined(); // within-window delete removed expect(paginator.getItem('m1')).toBeDefined(); // older-than-page kept expect(ids(paginator)).toEqual(['m1', 'm2', 'm3', 'm5', 'm6']); }); - it('removes the OLDEST loaded message once the page proves it reached the channel start', () => { - // Whole channel loaded. m1 (oldest) hard-deleted → a request for four returns only three. + it('keeps a below-window delete even when the page proves reached-start — reconcile is window-only (data-loss safe)', () => { + // m1 (oldest) hard-deleted → a bounded re-fetch (requestedLimit 4 <= pageSize) returns [m2,m3,m4], + // short, which DOES prove reached-start (hasMoreTail goes false). But m1 sits BELOW the returned + // window, and reconcile is window-only — it never removes anything older than the page's oldest — + // so m1 is KEPT rather than risk deleting a merely-not-fetched message; the stale ghost self-heals + // on a cold load. requestedLimit moved only the flag, never a deletion. const paginator = loadHead( [msg('m1', 1), msg('m2', 2), msg('m3', 3), msg('m4', 4)], - { - isTail: true, - }, + { isTail: true }, ); paginator.mergeNewestPage([msg('m2', 2), msg('m3', 3), msg('m4', 4)], { requestedLimit: 4, }); - expect(paginator.getItem('m1')).toBeUndefined(); - expect(ids(paginator)).toEqual(['m2', 'm3', 'm4']); + expect(paginator.getItem('m1')).toBeDefined(); + expect(ids(paginator)).toEqual(['m1', 'm2', 'm3', 'm4']); + expect(paginator.state.getLatestValue().hasMoreTail).toBe(false); }); it('keeps the oldest loaded message when the page did NOT prove it reached the start', () => { @@ -2050,9 +2050,7 @@ describe('MessagePaginator', () => { }, ); // A FULL page (returned === requested) that simply does not reach m1 → cannot claim m1 deleted. - paginator.mergeNewestPage([msg('m2', 2), msg('m3', 3), msg('m4', 4)], { - requestedLimit: 3, - }); + paginator.mergeNewestPage([msg('m2', 2), msg('m3', 3), msg('m4', 4)], {}); expect(paginator.getItem('m1')).toBeDefined(); expect(ids(paginator)).toEqual(['m1', 'm2', 'm3', 'm4']); }); @@ -2070,9 +2068,7 @@ describe('MessagePaginator', () => { // m5 (newest) deleted; the page's newest is now m4. No snapshot ⇒ the top edge is ambiguous. paginator.mergeNewestPage( [msg('m1', 1), msg('m2', 2), msg('m3', 3), msg('m4', 4)], - { - requestedLimit: 5, - }, + {}, ); expect(paginator.getItem('m5')).toBeDefined(); }); @@ -2093,7 +2089,6 @@ describe('MessagePaginator', () => { [msg('m1', 1), msg('m2', 2), msg('m3', 3), msg('m4', 4)], { candidateIds, - requestedLimit: 5, }, ); @@ -2115,7 +2110,6 @@ describe('MessagePaginator', () => { paginator.mergeNewestPage([msg('m1', 1), msg('m2', 2), msg('m3', 3)], { candidateIds, - requestedLimit: 5, }); expect(ids(paginator)).toEqual(['m1', 'm2', 'm3']); @@ -2145,7 +2139,6 @@ describe('MessagePaginator', () => { [msg('m1', 1), msg('m2', 2), msg('m3', 3), msg('m4', 4)], { candidateIds, - requestedLimit: 5, }, ); @@ -2171,7 +2164,6 @@ describe('MessagePaginator', () => { // The server page only has the confirmed m1 + m5; the local-only ones the server never saw. paginator.mergeNewestPage([msg('m1', 1), msg('m5', 5)], { candidateIds, - requestedLimit: 5, }); expect(paginator.getItem('sending')).toBeDefined(); @@ -2194,7 +2186,6 @@ describe('MessagePaginator', () => { // m3 hard-deleted; the failed send was never on the server. Page: [m1, m4]. paginator.mergeNewestPage([msg('m1', 1), msg('m4', 4)], { candidateIds, - requestedLimit: 4, }); expect(paginator.getItem('m3')).toBeUndefined(); @@ -2209,7 +2200,7 @@ describe('MessagePaginator', () => { const paginator = loadHead(loaded); const candidateIds = new Set(loaded.map((message) => message.id)); - paginator.mergeNewestPage([], { candidateIds, requestedLimit: 3 }); + paginator.mergeNewestPage([], { candidateIds }); expect(paginator.items).toEqual([]); expect(paginator.lastMessage).toBeNull(); @@ -2228,7 +2219,7 @@ describe('MessagePaginator', () => { // A live message arrives during the fetch, then the (stale) empty page comes back. paginator.ingestItem(msg('m3', 3)); - paginator.mergeNewestPage([], { candidateIds, requestedLimit: 2 }); + paginator.mergeNewestPage([], { candidateIds }); expect(paginator.getItem('m1')).toBeUndefined(); expect(paginator.getItem('m2')).toBeUndefined(); @@ -2246,7 +2237,6 @@ describe('MessagePaginator', () => { // A fully-disjoint newest window (100+ arrived). Rebuild replaces the loaded set; no extra prune. paginator.mergeNewestPage([msg('m10', 10), msg('m11', 11), msg('m12', 12)], { candidateIds, - requestedLimit: 3, }); expect(ids(paginator)).toEqual(['m10', 'm11', 'm12']); @@ -2275,7 +2265,6 @@ describe('MessagePaginator', () => { // Active window is the older [m1,m2,m3]; the head holds m8,m9,m10 (m9 "deleted" server-side). paginator.mergeNewestPage([msg('m8', 8), msg('m10', 10)], { candidateIds: new Set(['m8', 'm9', 'm10']), - requestedLimit: 3, }); // The view (the older window) is preserved — no yank to the head — but the hidden-head ghost m9 @@ -2290,49 +2279,53 @@ describe('MessagePaginator', () => { paginator.mergeNewestPage([msg('m1', 1), msg('m3', 3), msg('m4', 4)], { candidateIds: new Set(loaded.map((message) => message.id)), - requestedLimit: 4, }); expect(ids(paginator)).toEqual(['m1', 'm3', 'm4']); paginator.mergeNewestPage([msg('m1', 1), msg('m3', 3), msg('m4', 4)], { candidateIds: new Set(['m1', 'm3', 'm4']), - requestedLimit: 4, }); expect(ids(paginator)).toEqual(['m1', 'm3', 'm4']); }); - // ── The "100 point" is data-driven, never hardcoded: reconcile only within the returned page ── + // ── Reconcile is window-only: nothing older than the returned page's oldest is ever removed (no cap) ── - it('never reconciles messages older than what the page returns, even on an over-request (clamp)', () => { - // 105 loaded (whole channel). The server caps its response at 100 (the newest 100). We requested - // more (105) but only 100 come back — that shortfall must NOT be read as "reached the channel - // start", or the 5 oldest (beyond the page) would be wrongly deleted. + it('keeps every loaded message older than the returned page — window-only reconcile never deletes below it (data-loss guard)', () => { + // DATA-LOSS GUARD. 105 loaded; a reload over-requests all 105 but the server caps the page at the + // newest 100 — the 5 oldest sit BELOW the returned page's oldest. Reconcile is window-only: it + // never removes anything older than the returned page's oldest, so none of the 5 are deleted. + // requestedLimit only tunes hasMoreTail, never a deletion — the window-only reconcile holds for ANY + // page that covers only part of the loaded window. const all = Array.from({ length: 105 }, (_, i) => msg(`msg-${String(i).padStart(3, '0')}`, i), ); const paginator = loadHead(all, { isTail: true }); - const page = all.slice(5); // the newest 100 — the server's capped response + const page = all.slice(5); // a page covering only the newest 100 of the 105 loaded paginator.mergeNewestPage(page, { + requestedLimit: all.length, // reload asks for all 105; the server caps the returned page at 100 candidateIds: new Set(all.map((message) => message.id)), - requestedLimit: 105, }); - // All 105 kept: nothing was actually deleted, and the 5 oldest are beyond the page's reach. + // All 105 kept: nothing was actually deleted, and the 5 oldest are below the returned page. expect(paginator.items?.length).toBe(105); expect(paginator.getItem('msg-000')).toBeDefined(); expect(paginator.getItem('msg-004')).toBeDefined(); }); - it('over-request: a server-capped page keeps hasMoreTail and anchors the tail cursor to the true loaded oldest', () => { + it('an over-request capped to the newest part keeps hasMoreTail and anchors the tail cursor to the true loaded oldest', () => { + // A reload over-requests all 105 but the server caps the page at the newest 100. An over-request + // (105 > pageSize 100) can be silently server-capped, so a short page CANNOT prove reached-start — + // hasMoreTail stays true and the tail cursor anchors to the true oldest LOADED (msg-000), not the + // page's oldest — so "load older" resumes contiguously from the real bottom of the window. const all = Array.from({ length: 105 }, (_, i) => msg(`msg-${String(i).padStart(3, '0')}`, i), ); const paginator = loadHead(all, { isTail: true }); paginator.mergeNewestPage(all.slice(5), { + requestedLimit: all.length, candidateIds: new Set(all.map((message) => message.id)), - requestedLimit: 105, }); const state = paginator.state.getLatestValue(); @@ -2340,11 +2333,12 @@ describe('MessagePaginator', () => { expect(state.cursor?.tailward).toBe('msg-000'); }); - it('reached channel start: a page shorter than the clamped limit clears hasMoreTail, nulls the tail cursor and sets isTail', () => { - // The complementary branch: the loaded window believes older messages exist (isTail:false → - // hasMoreTail true), then the newest two are hard-deleted so the reconnect page comes back short - // of the requested limit. A short page (3 < min(5,100)) proves we reached the channel start, so - // hasMoreTail drops to false, the tail cursor nulls, and the interval's isTail flips true. + it('a bounded short page reaches the channel start: trailing deletes still removed, hasMoreTail false, cursor cleared', () => { + // The newest two are hard-deleted so the reconnect page comes back short. The trailing deletes + // (m4, m5) are still removed — they are at/above the newest returned message and the pre-fetch + // snapshot proves them gone. The request was BOUNDED (requestedLimit 5 <= pageSize 100) and came + // back short, so it DOES prove reached-start: hasMoreTail goes false, the interval's isTail goes + // true, and the tail cursor is cleared. No spurious "load older". const paginator = loadHead([ msg('m1', 1), msg('m2', 2), @@ -2356,17 +2350,268 @@ describe('MessagePaginator', () => { // m4 + m5 hard-deleted while offline: only the surviving newest come back. paginator.mergeNewestPage([msg('m1', 1), msg('m2', 2), msg('m3', 3)], { - candidateIds: new Set(['m1', 'm2', 'm3', 'm4', 'm5']), requestedLimit: 5, + candidateIds: new Set(['m1', 'm2', 'm3', 'm4', 'm5']), }); const state = paginator.state.getLatestValue(); - expect(ids(paginator)).toEqual(['m1', 'm2', 'm3']); + expect(ids(paginator)).toEqual(['m1', 'm2', 'm3']); // trailing deletes removed via snapshot expect(state.hasMoreTail).toBe(false); - expect(state.cursor?.tailward).toBeNull(); + expect(state.cursor?.tailward).toBe(null); expect((paginator.itemIntervals[0] as { isTail?: boolean }).isTail).toBe(true); }); + // ── hasMoreTail derivation: bounded (reliable) vs over-request (conservative), no hardcoded cap ── + + describe('hasMoreTail derivation (requestedLimit vs pageSize)', () => { + it('a bounded short page proves reached-start → hasMoreTail false, cursor cleared (no spurious "load older")', () => { + // The channel is smaller than a page: a bounded open (requestedLimit <= pageSize) returns every + // message and comes back short. That reliably proves reached-start — pageSize <= the server's max + // page size is the same invariant executeQuery pagination relies on — so hasMoreTail is false. + // This is the fix for the spurious top spinner + double pagination on first open. + const paginator = loadHead([msg('m1', 1), msg('m2', 2), msg('m3', 3)]); + paginator.mergeNewestPage([msg('m1', 1), msg('m2', 2), msg('m3', 3)], { + requestedLimit: 25, + }); + const state = paginator.state.getLatestValue(); + expect(ids(paginator)).toEqual(['m1', 'm2', 'm3']); // nothing removed + expect(state.hasMoreTail).toBe(false); + expect(state.cursor?.tailward).toBe(null); + expect((paginator.itemIntervals[0] as { isTail?: boolean }).isTail).toBe(true); + }); + + it('a bounded FULL page does NOT assert reached-start → hasMoreTail true (older may remain)', () => { + // A bounded request that comes back FULL (length === requested) means older messages may still + // exist, so "load older" stays enabled and the cursor anchors to the loaded oldest. + const paginator = loadHead([msg('m1', 1), msg('m2', 2), msg('m3', 3)]); + paginator.mergeNewestPage([msg('m1', 1), msg('m2', 2), msg('m3', 3)], { + requestedLimit: 3, + }); + const state = paginator.state.getLatestValue(); + expect(state.hasMoreTail).toBe(true); + expect(state.cursor?.tailward).toBe('m1'); + }); + + it('an OVER-request short page (> pageSize, possibly server-capped) never asserts reached-start → hasMoreTail true, below-window kept', () => { + // reload re-fetches the whole loaded window to reconcile as much as possible; that over-request + // (> pageSize) can be silently server-capped, so a short page proves nothing about reaching the + // start. Bias to true so a merely-capped page is never mistaken for the channel's start — the + // data-loss-safe direction, needing no hardcoded max page size. + const paginator = new MessagePaginator({ + channel, + itemIndex: new StoreBackedItemIndex({ + getEntityId: (message) => message.id, + }), + paginatorOptions: { pageSize: 3 }, + }); + paginator.ingestPage({ + page: [msg('m1', 1), msg('m2', 2), msg('m3', 3), msg('m4', 4)], + isHead: true, + isTail: true, + setActive: true, + }); + // reload over-requests 4 (> pageSize 3); the server caps the returned page at the newest 3. + paginator.mergeNewestPage([msg('m2', 2), msg('m3', 3), msg('m4', 4)], { + requestedLimit: 4, + }); + expect(paginator.state.getLatestValue().hasMoreTail).toBe(true); + expect(paginator.getItem('m1')).toBeDefined(); // below-window delete never removed (data-loss safe) + }); + }); + + // ── reached-start probe: the ONLY cap-free way to prune the oldest-run below the returned window ── + + describe('reached-start probe (below-window reconciliation)', () => { + const mockQuery = () => channel.query as unknown as ReturnType; + const mockGetReplies = () => + channel.getReplies as unknown as ReturnType; + const flushProbe = (p: MessagePaginator) => + (p as unknown as { _belowWindowReconcile?: Promise })._belowWindowReconcile; + const makePaginator = (pageSize: number, parentMessageId?: string) => + new MessagePaginator({ + channel, + parentMessageId, + itemIndex: new StoreBackedItemIndex({ + getEntityId: (message) => message.id, + }), + paginatorOptions: { pageSize }, + }); + // Fully-loaded head (isTail → hasMoreTail false, so condition A holds). + const loadFull = (paginator: MessagePaginator, page: LocalMessage[]) => + paginator.ingestPage({ page, isHead: true, isTail: true, setActive: true }); + + it('probe empty → prunes the oldest-run ghost below the window and settles hasMoreTail', async () => { + // pageSize 3 so requestedLimit 5 is an OVER-request: the sync merge cannot prove reached-start + // (biases hasMoreTail true, keeps m1 window-only) — the probe is the SOLE driver here. + const paginator = makePaginator(3); + loadFull(paginator, [ + msg('m1', 1), + msg('m2', 2), + msg('m3', 3), + msg('m4', 4), + msg('m5', 5), + ]); + expect(paginator.state.getLatestValue().hasMoreTail).toBe(false); // A precondition + + // m1 (oldest) hard-deleted offline; reload over-requests all 5, server returns the survivors. + mockQuery().mockResolvedValue({ messages: [] }); // nothing older than m2 → reached start + paginator.mergeNewestPage( + [msg('m2', 2), msg('m3', 3), msg('m4', 4), msg('m5', 5)], + { requestedLimit: 5, candidateIds: new Set(['m1', 'm2', 'm3', 'm4', 'm5']) }, + ); + // Sync merge kept m1 (window-only) and biased hasMoreTail true (over-request): + expect(paginator.getItem('m1')).toBeDefined(); + expect(paginator.state.getLatestValue().hasMoreTail).toBe(true); + + await flushProbe(paginator); + + expect(channel.query).toHaveBeenCalledWith({ + messages: { limit: 1, id_lt: 'm2' }, + }); + expect(paginator.getItem('m1')).toBeUndefined(); // pruned by the probe + expect(ids(paginator)).toEqual(['m2', 'm3', 'm4', 'm5']); + expect(paginator.state.getLatestValue().hasMoreTail).toBe(false); // settled + expect(paginator.state.getLatestValue().cursor?.tailward).toBe(null); + }); + + it('probe returns an older message → keeps the below-window items (truncated, not the start)', async () => { + const paginator = makePaginator(3); + loadFull(paginator, [ + msg('m1', 1), + msg('m2', 2), + msg('m3', 3), + msg('m4', 4), + msg('m5', 5), + ]); + // Reload over-requests 5; server caps and returns only the newest 3 — m1,m2 fall below. + mockQuery().mockResolvedValue({ messages: [msg('m2', 2)] }); // older content exists + paginator.mergeNewestPage([msg('m3', 3), msg('m4', 4), msg('m5', 5)], { + requestedLimit: 5, + candidateIds: new Set(['m1', 'm2', 'm3', 'm4', 'm5']), + }); + + await flushProbe(paginator); + + expect(channel.query).toHaveBeenCalledWith({ + messages: { limit: 1, id_lt: 'm3' }, + }); + expect(paginator.getItem('m1')).toBeDefined(); // kept — no data loss on a truncated reload + expect(paginator.getItem('m2')).toBeDefined(); + expect(ids(paginator)).toEqual(['m1', 'm2', 'm3', 'm4', 'm5']); + }); + + it('does NOT probe when we were not at the channel start (hasMoreTail was true)', async () => { + const paginator = makePaginator(3); + // isTail false → hasMoreTail true → condition A fails. + paginator.ingestPage({ + page: [msg('m3', 3), msg('m4', 4), msg('m5', 5)], + isHead: true, + isTail: false, + setActive: true, + }); + expect(paginator.state.getLatestValue().hasMoreTail).toBe(true); + paginator.mergeNewestPage([msg('m4', 4), msg('m5', 5)], { + requestedLimit: 3, + candidateIds: new Set(['m3', 'm4', 'm5']), + }); + await flushProbe(paginator); + expect(channel.query).not.toHaveBeenCalled(); + expect(paginator.getItem('m3')).toBeDefined(); + }); + + it('does NOT probe a small-page caller (requestedLimit < loaded) — a list hydrate cannot reach the start', async () => { + const paginator = makePaginator(3); + loadFull(paginator, [ + msg('m1', 1), + msg('m2', 2), + msg('m3', 3), + msg('m4', 4), + msg('m5', 5), + ]); + // A list-hydrate-style page: asked for only 2, far fewer than the 5 loaded. + paginator.mergeNewestPage([msg('m4', 4), msg('m5', 5)], { + requestedLimit: 2, + candidateIds: new Set(['m1', 'm2', 'm3', 'm4', 'm5']), + }); + await flushProbe(paginator); + expect(channel.query).not.toHaveBeenCalled(); + expect(ids(paginator)).toEqual(['m1', 'm2', 'm3', 'm4', 'm5']); // nothing pruned + }); + + it('does NOT probe when the oldest is still in the page (a middle delete — within-span handles it)', async () => { + const paginator = makePaginator(3); + loadFull(paginator, [ + msg('m1', 1), + msg('m2', 2), + msg('m3', 3), + msg('m4', 4), + msg('m5', 5), + ]); + // m3 deleted; reload returns [m1,m2,m4,m5] — the oldest (m1) is still present. + paginator.mergeNewestPage( + [msg('m1', 1), msg('m2', 2), msg('m4', 4), msg('m5', 5)], + { + requestedLimit: 5, + candidateIds: new Set(['m1', 'm2', 'm3', 'm4', 'm5']), + }, + ); + await flushProbe(paginator); + expect(channel.query).not.toHaveBeenCalled(); + expect(paginator.getItem('m3')).toBeUndefined(); // removed by within-span, not the probe + expect(ids(paginator)).toEqual(['m1', 'm2', 'm4', 'm5']); + }); + + it('threads probe via getReplies', async () => { + const paginator = makePaginator(3, 'parent-1'); + const reply = (id: string, minute: number) => + msg(id, minute, { parent_id: 'parent-1' }); + loadFull(paginator, [ + reply('r1', 1), + reply('r2', 2), + reply('r3', 3), + reply('r4', 4), + ]); + mockGetReplies().mockResolvedValue({ messages: [] }); // nothing older than r2 + paginator.mergeNewestPage([reply('r2', 2), reply('r3', 3), reply('r4', 4)], { + requestedLimit: 4, + candidateIds: new Set(['r1', 'r2', 'r3', 'r4']), + }); + await flushProbe(paginator); + expect(channel.getReplies).toHaveBeenCalledWith({ + parent_id: 'parent-1', + limit: 1, + id_lt: 'r2', + }); + expect(channel.query).not.toHaveBeenCalled(); + expect(paginator.getItem('r1')).toBeUndefined(); + }); + + it('aborts the prune if the head interval changed while the probe was in flight', async () => { + const paginator = makePaginator(3); + loadFull(paginator, [ + msg('m1', 1), + msg('m2', 2), + msg('m3', 3), + msg('m4', 4), + msg('m5', 5), + ]); + let resolveProbe: () => void = () => undefined; + mockQuery().mockReturnValue( + new Promise((resolve) => { + resolveProbe = () => resolve({ messages: [] }); + }), + ); + paginator.mergeNewestPage( + [msg('m2', 2), msg('m3', 3), msg('m4', 4), msg('m5', 5)], + { requestedLimit: 5, candidateIds: new Set(['m1', 'm2', 'm3', 'm4', 'm5']) }, + ); + // The head interval goes away (a reset/jump) before the probe resolves. + paginator.setIntervals([]); + resolveProbe(); + await expect(flushProbe(paginator)).resolves.toBeUndefined(); // no throw, guard bailed + }); + }); + describe('batch({ coalesce: true }) — single deterministic window publish', () => { it('coalesces N removals into a single state publish', () => { const paginator = loadHead([ @@ -2463,7 +2708,6 @@ describe('MessagePaginator', () => { paginator.mergeNewestPage(page, { candidateIds: new Set(all.map((message) => message.id)), - requestedLimit: 105, }); expect(paginator.getItem('msg-050')).toBeUndefined(); // within-page delete removed @@ -2566,8 +2810,10 @@ describe('MessagePaginator', () => { }); expect(paginator.state.getLatestValue().hasMoreTail).toBe(false); - // Reconnect fetches a FULL page (requestedLimit == page length) → older messages remain, so - // hasMoreTail must be RE-COMPUTED from the page (not read off the stale interval flag). + // Reconnect re-seeds the head window. The merge biases hasMoreTail to `true` (isTail:false), + // which clears the stale "complete" flag so "load older" works again — not read off the stale + // interval flag. (If the channel really is fully loaded, the next load-older returns empty and + // settles it.) paginator.seedFirstPageSync( [msg('m1', 1), msg('m2', 2), msg('m3', 3)], 3, @@ -2816,7 +3062,6 @@ describe('MessagePaginator', () => { const candidateIds = new Set(paginator.headItems.map((m) => m.id)); paginator.mergeNewestPage([msg('m90', 90), msg('m95', 95)], { candidateIds, - requestedLimit: 3, }); // View preserved (still on the island)... From 20bb4d3920f31e0a25e6ca59d2ea7fc792dca68d Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 13 Aug 2026 16:14:29 +0200 Subject: [PATCH 16/31] chore: add test for both removal and receival --- .../paginators/MessagePaginator.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/unit/pagination/paginators/MessagePaginator.test.ts b/test/unit/pagination/paginators/MessagePaginator.test.ts index 5c2b94798f..c2f08fbc0c 100644 --- a/test/unit/pagination/paginators/MessagePaginator.test.ts +++ b/test/unit/pagination/paginators/MessagePaginator.test.ts @@ -2610,6 +2610,38 @@ describe('MessagePaginator', () => { resolveProbe(); await expect(flushProbe(paginator)).resolves.toBeUndefined(); // no throw, guard bailed }); + + it('trailing deletes covered by new arrivals become within-span: removes them, merges the new, keeps the below-window oldest', async () => { + // Loaded fully before going offline. + const paginator = makePaginator(3); + loadFull(paginator, [ + msg('m1', 1), + msg('m2', 2), + msg('m3', 3), + msg('m4', 4), + msg('m5', 5), + ]); + + mockQuery().mockResolvedValue({ messages: [msg('m1', 1)] }); // probe: m1 IS older than m2 → keep it + paginator.mergeNewestPage( + [msg('m2', 2), msg('m3', 3), msg('m6', 6), msg('m7', 7), msg('m8', 8)], + { requestedLimit: 5, candidateIds: new Set(['m1', 'm2', 'm3', 'm4', 'm5']) }, + ); + + // Sync: m4/m5 within-span → gone; m6/m7/m8 merged; m1 below the window kept pending the probe. + expect(paginator.getItem('m4')).toBeUndefined(); + expect(paginator.getItem('m5')).toBeUndefined(); + expect(ids(paginator)).toEqual(['m1', 'm2', 'm3', 'm6', 'm7', 'm8']); + + await flushProbe(paginator); + + // The below-window oldest (m1) was probed and is real → kept. Final = the server truth. + expect(channel.query).toHaveBeenCalledWith({ + messages: { limit: 1, id_lt: 'm2' }, + }); + expect(paginator.getItem('m1')).toBeDefined(); + expect(ids(paginator)).toEqual(['m1', 'm2', 'm3', 'm6', 'm7', 'm8']); + }); }); describe('batch({ coalesce: true }) — single deterministic window publish', () => { From b72eeee2fc247f91021511db2d5e7fe04ff87a15 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 13 Aug 2026 21:24:01 +0200 Subject: [PATCH 17/31] feat: channel state migration initial pass --- src/channel.ts | 69 ++++++++++- src/channel_state.ts | 108 +++++++++-------- src/client.ts | 4 + src/messageDelivery/MessageReceiptsTracker.ts | 21 ++-- test/unit/channel.test.js | 75 ++++++++++-- test/unit/channel_state.test.js | 112 ++++++++++++------ test/unit/client.test.js | 4 +- .../MessageReceiptsTracker.test.ts | 5 +- 8 files changed, 286 insertions(+), 112 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 65f3485cd5..d71ea66cc7 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -59,7 +59,7 @@ import type { UserResponse, } from './types'; import type { RoleName } from './permissions'; -import { StateStore } from './store'; +import { StateStore, type Unsubscribe } from './store'; import type { ChannelMemberRequest as Gen_ChannelMemberRequest, ChannelPushPreferencesResponse as Gen_ChannelPushPreferencesResponse, @@ -176,6 +176,10 @@ export class Channel extends ChannelApi { disconnected: boolean; /** Re-entrancy guard for {@link Channel.reload} (mirrors Thread.reload's isLoading guard). */ private _reloading = false; + /** Refcount backing the reactive `active` flag (a shared Channel instance can be mounted more than once). */ + private _activeRefCount = 0; + /** Teardown for the auto-mark-active-read subscription registered in the constructor. */ + private _unsubscribeMarkActiveRead?: Unsubscribe; push_preferences?: Gen_ChannelPushPreferencesResponse; public readonly configState = new StateStore({}); public readonly messageComposer: MessageComposer; @@ -241,6 +245,9 @@ export class Channel extends ChannelApi { this.messageReceiptsTracker = new MessageReceiptsTracker({ channel: this }); this.messageReceiptsTracker.registerSubscriptions(); + // Auto-mark-read while the channel is active (UI-driven; inert until a UI SDK calls activate()). + this._unsubscribeMarkActiveRead = this.subscribeMarkActiveChannelRead(); + this.cooldownTimer = new CooldownTimer({ channel: this }); this.messageOperations = new MessageOperations({ @@ -1202,6 +1209,63 @@ export class Channel extends ChannelApi { return await super.markRead(data); } + /** + * Whether the channel is currently mounted / actively viewed on-screen. Reactive — subscribe via + * `useStateStore(channel.state, (s) => ({ active: s.active }))`. + */ + get active() { + return this.state.getLatestValue().active; + } + + /** + * Marks the channel as actively viewed on-screen (UI-driven; mirrors `thread.activate()`). + * Refcounted so a Channel instance mounted in several places stays active until the last unmount. + * While active the channel auto-marks messages read and its message list is not re-seeded by + * channel-list hydration (its own `channel.reload()` owns that window). + */ + activate = () => { + this._activeRefCount += 1; + if (this._activeRefCount === 1) { + this.state.partialNext({ active: true }); + } + }; + + /** + * Marks the channel as no longer actively viewed (mirrors `thread.deactivate()`). Only flips + * `active` back to `false` once the last holder deactivates. + */ + deactivate = () => { + if (this._activeRefCount === 0) return; + this._activeRefCount -= 1; + if (this._activeRefCount === 0) { + this.state.partialNext({ active: false }); + } + }; + + private throttledMarkRead = () => { + this.getClient().messageDeliveryReporter.throttledMarkRead(this); + }; + + /** + * Auto-marks the channel read whenever it is active and has unread messages (mirrors + * `Thread.subscribeMarkActiveThreadRead`). Registered once in the constructor; inert until a + * UI SDK calls `activate()`. + */ + private subscribeMarkActiveChannelRead = () => + this.state.subscribeWithSelector( + (nextValue) => { + const userId = this.getClient().userID; + return { + active: nextValue.active, + ownUnreadCount: (userId && nextValue.read[userId]?.unread_messages) || 0, + }; + }, + ({ active, ownUnreadCount }) => { + if (!active || !ownUnreadCount) return; + this.throttledMarkRead(); + }, + ); + /** * Marks the channel as unread from `messageId`. Only works when the `read_events` setting is enabled. * @@ -1936,7 +2000,7 @@ export class Channel extends ChannelApi { let hasStateChanged = false; this.messageReceiptsTracker.setPendingReadStoreReconcileMeta(reconcileMeta); - this.state.readStore.next((currentReadStoreState) => { + this.state.next((currentReadStoreState) => { const nextReadState = patch(currentReadStoreState.read); if (nextReadState === currentReadStoreState.read) { @@ -2666,6 +2730,7 @@ export class Channel extends ChannelApi { this.disconnected = true; this.messageReceiptsTracker.unregisterSubscriptions(); + this._unsubscribeMarkActiveRead?.(); this.cooldownTimer.clearTimeout(); // Release the store-backed paginators so the message store no longer pins this removed channel // (and its whole message graph) through its subscriber registry. The channel is being discarded diff --git a/src/channel_state.ts b/src/channel_state.ts index 1d16d4479d..cb43d8e91d 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -36,10 +36,6 @@ export type ReadState = { read: ChannelReadStatus; }; -export type MutedUsersState = { - mutedUsers: Array; -}; - export type MembersState = { members: Record; memberCount: number; @@ -49,38 +45,57 @@ export type OwnCapabilitiesState = { ownCapabilities: string[]; }; +/** UI-driven channel lifecycle state (not returned by the API; set by the UI SDK). */ +export type ChannelUIState = { + /** + * Whether the channel is currently mounted / actively viewed on-screen. UI-driven and + * refcounted via `channel.activate()` / `channel.deactivate()`. While `active`, the channel + * auto-marks messages read, and channel-list hydration does NOT re-seed its message list + * (the channel's own `channel.reload()` owns that window). + */ + active: boolean; +}; + +/** + * The single, unified reactive state for a channel. All per-channel reactive state is published + * through this one `StateStore` and subscribed to with `useStateStore(channel.state, selector)` + * (mirroring `thread.state`). + * + * The shape is FLAT — subscribe to any slice via a selector, e.g. + * `useStateStore(channel.state, (s) => ({ read: s.read }))`. + */ +export type ChannelStateData = WatcherState & + TypingUsersState & + ReadState & + MembersState & + OwnCapabilitiesState & + ChannelUIState; + /** - * ChannelState - A container class for the channel state. + * ChannelState - the container for a channel's reactive state. + * + * It IS a `StateStore` (so `useStateStore(channel.state, selector)` works, + * mirroring `thread.state`) while additionally exposing convenience getters/setters + * (`members`, `read`, `typing`, `watchers`, …) that read/write the same unified store. */ -export class ChannelState { +export class ChannelState extends StateStore { _channel: Channel; - readonly watcherStore: StateStore; - readonly typingStore: StateStore; - readonly readStore: StateStore; - readonly membersStore: StateStore; - readonly ownCapabilitiesStore: StateStore; - // todo: is this actually used somewhere? - readonly mutedUsersStore: StateStore; pending_messages: Array; unreadCount: number; membership: ChannelMemberResponse; constructor(channel: Channel) { - this._channel = channel; - this.watcherStore = new StateStore({ + super({ watcherCount: 0, watchers: {}, - }); - this.typingStore = new StateStore({ typing: {}, - }); - this.readStore = new StateStore({ read: {} }); - // a list of users to hide messages from - this.mutedUsersStore = new StateStore({ mutedUsers: [] }); - this.membersStore = new StateStore({ members: {}, memberCount: 0 }); - this.ownCapabilitiesStore = new StateStore({ + read: {}, + members: {}, + memberCount: 0, ownCapabilities: [], + active: false, }); + this._channel = channel; this.syncMemberCountFromChannelData(channel?.data); this.syncOwnCapabilitiesFromChannelData(channel?.data); this.pending_messages = []; @@ -89,38 +104,37 @@ export class ChannelState { } get members() { - return this.membersStore.getLatestValue().members; + return this.getLatestValue().members; } set members(members: Record) { - this.membersStore.partialNext({ members }); + this.partialNext({ members }); } get member_count() { - return this.membersStore.getLatestValue().memberCount; + return this.getLatestValue().memberCount; } set member_count(memberCount: number) { - this.membersStore.partialNext({ memberCount }); + this.partialNext({ memberCount }); } get read() { - return this.readStore.getLatestValue().read; + return this.getLatestValue().read; } set read(read: ChannelReadStatus) { - this.readStore.next({ read }); + this.partialNext({ read }); } get typing() { return ( - this._channel?.messageComposer?.textComposer.typing ?? - this.typingStore.getLatestValue().typing + this._channel?.messageComposer?.textComposer.typing ?? this.getLatestValue().typing ); } set typing(typing: Record) { - this.typingStore.next({ typing }); + this.partialNext({ typing }); if (this._channel?.messageComposer) { this._channel.messageComposer.textComposer.setTyping(typing); @@ -134,10 +148,10 @@ export class ChannelState { const fallbackMemberCount = typeof fallbackData?.member_count === 'number' ? fallbackData.member_count - : this.membersStore.getLatestValue().memberCount; + : this.getLatestValue().memberCount; if (!data || typeof data !== 'object') { - this.membersStore.partialNext({ memberCount: fallbackMemberCount ?? 0 }); + this.partialNext({ memberCount: fallbackMemberCount ?? 0 }); return; } @@ -149,7 +163,7 @@ export class ChannelState { ? fallbackMemberCount : undefined; - this.membersStore.partialNext({ memberCount: memberCount ?? 0 }); + this.partialNext({ memberCount: memberCount ?? 0 }); Object.defineProperty(data, 'member_count', { configurable: true, @@ -157,7 +171,7 @@ export class ChannelState { get: () => memberCount, set: (nextMemberCount: number | undefined) => { memberCount = typeof nextMemberCount === 'number' ? nextMemberCount : undefined; - this.membersStore.partialNext({ memberCount: memberCount ?? 0 }); + this.partialNext({ memberCount: memberCount ?? 0 }); }, }); } @@ -167,7 +181,7 @@ export class ChannelState { fallbackData: Channel['data'] = this._channel?.data, ) { if (!data || typeof data !== 'object') { - this.ownCapabilitiesStore.next({ ownCapabilities: [] }); + this.partialNext({ ownCapabilities: [] }); return; } @@ -177,7 +191,7 @@ export class ChannelState { ? [...fallbackData.own_capabilities] : undefined; - this.ownCapabilitiesStore.next({ ownCapabilities: ownCapabilities ?? [] }); + this.partialNext({ ownCapabilities: ownCapabilities ?? [] }); // Keep the reactive getter/setter so backward-compatible assignments still sync to // the store, but return `undefined` until capabilities are actually known. Forcing @@ -191,7 +205,7 @@ export class ChannelState { ownCapabilities = Array.isArray(nextOwnCapabilities) ? [...nextOwnCapabilities] : undefined; - this.ownCapabilitiesStore.next({ ownCapabilities: ownCapabilities ?? [] }); + this.partialNext({ ownCapabilities: ownCapabilities ?? [] }); }, }); } @@ -208,28 +222,20 @@ export class ChannelState { this.typing = typing; } - get mutedUsers() { - return this.mutedUsersStore.getLatestValue().mutedUsers; - } - - set mutedUsers(mutedUsers: Array) { - this.mutedUsersStore.next({ mutedUsers }); - } - get watchers() { - return this.watcherStore.getLatestValue().watchers; + return this.getLatestValue().watchers; } set watchers(watchers: Record) { - this.watcherStore.partialNext({ watchers }); + this.partialNext({ watchers }); } get watcher_count() { - return this.watcherStore.getLatestValue().watcherCount; + return this.getLatestValue().watcherCount; } set watcher_count(watcherCount: number) { - this.watcherStore.partialNext({ watcherCount }); + this.partialNext({ watcherCount }); } /** diff --git a/src/client.ts b/src/client.ts index 3bdeda63ef..0fbd3dd71d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1452,8 +1452,12 @@ export class StreamChat extends ChatApi { // newest page to merge into that jumped interval across the gap (missing messages in the // middle). A cold paginator, or one still at the head (offline/at-latest), re-seeds normally so // cursors/hasMoreTail get (re)derived and pagination keeps working. + // Also skip the re-seed for an ACTIVE (on-screen) channel: its own `channel.reload()` owns the + // loaded window (up to 100 msgs), so a 25-msg list re-seed here is a redundant second update + // that could perturb the fuller window. (Inert until a UI SDK calls `channel.activate()`.) if ( willInitialize && + !c.active && (!c.messagePaginator.isInitialized || c.messagePaginator.isActiveIntervalAtHead) ) { c.messagePaginator.seedFirstPageSync( diff --git a/src/messageDelivery/MessageReceiptsTracker.ts b/src/messageDelivery/MessageReceiptsTracker.ts index c265051c6b..efb61b4c7c 100644 --- a/src/messageDelivery/MessageReceiptsTracker.ts +++ b/src/messageDelivery/MessageReceiptsTracker.ts @@ -196,14 +196,19 @@ export class MessageReceiptsTracker extends WithSubscriptions { if (this.hasSubscriptions) return; this.addUnsubscribeFunction( - this.channel.state.readStore.subscribe((next, prev) => { - this.reconcileFromReadStore({ - previousReadState: prev?.read, - nextReadState: next.read, - meta: this.pendingReadStoreReconcileMeta, - }); - this.pendingReadStoreReconcileMeta = undefined; - }), + // Subscribe to only the `read` slice of the unified channel state so this reconcile fires on + // read changes, not on every unrelated channel-state write (typing/members/…). + this.channel.state.subscribeWithSelector( + (currentState) => ({ read: currentState.read }), + (next, prev) => { + this.reconcileFromReadStore({ + previousReadState: prev?.read, + nextReadState: next.read, + meta: this.pendingReadStoreReconcileMeta, + }); + this.pendingReadStoreReconcileMeta = undefined; + }, + ), ); }; diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 6501405ae6..8d868bc624 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -7,7 +7,7 @@ import { getOrCreateChannelApi } from './test-utils/getOrCreateChannelApi'; import sinon from 'sinon'; import { mockChannelQueryResponse } from './test-utils/mockChannelQueryResponse'; -import { ChannelState, StreamChat } from '../../src'; +import { Channel, ChannelState, StreamChat } from '../../src'; import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from '../../src/constants'; import { MockOfflineDB } from './offline-support/MockOfflineDB'; import { formatMessage, generateUUIDv4 as uuidv4 } from '../../src/utils'; @@ -1638,13 +1638,16 @@ describe('Channel _handleChannelEvent', function () { it('should emit readStore subscription updates for single-user message.read events', () => { channel.state.read[user.id] = initialReadState; const changes = []; - const unsubscribe = channel.state.readStore.subscribe((next, prev) => { - if (!prev) return; - changes.push({ - next: next.read[user.id], - prev: prev.read[user.id], - }); - }); + const unsubscribe = channel.state.subscribeWithSelector( + (s) => ({ read: s.read }), + (next, prev) => { + if (!prev) return; + changes.push({ + next: next.read[user.id], + prev: prev.read[user.id], + }); + }, + ); channel._handleChannelEvent(messageReadEvent); unsubscribe(); @@ -3601,3 +3604,59 @@ describe('Channel.reload', () => { expect(watchSpy).toHaveBeenCalledTimes(1); }); }); + +describe('Channel active flag + auto-mark-read', () => { + let client; + let channel; + + beforeEach(() => { + client = getClientWithUser({ id: 'me' }); + channel = new Channel(client, 'messaging', 'active-x', {}); + client.activeChannels[channel.cid] = channel; + }); + + it('refcounts activate()/deactivate() behind the reactive active flag', () => { + expect(channel.active).to.equal(false); + expect(channel.state.getLatestValue().active).to.equal(false); + + channel.activate(); + expect(channel.active).to.equal(true); + + channel.activate(); // a second mount holds another ref + expect(channel.active).to.equal(true); + + channel.deactivate(); // one holder remains → still active + expect(channel.active).to.equal(true); + + channel.deactivate(); // last holder leaves → inactive + expect(channel.active).to.equal(false); + + channel.deactivate(); // underflow guard → stays inactive + expect(channel.active).to.equal(false); + }); + + it('auto-marks the channel read only while active AND unread', () => { + const spy = vi + .spyOn(client.messageDeliveryReporter, 'throttledMarkRead') + .mockImplementation(() => undefined); + + // unread but not active → no auto mark-read + channel.state.read = { + me: { last_read: new Date(0), unread_messages: 3, user: { id: 'me' } }, + }; + expect(spy).not.toHaveBeenCalled(); + + // becomes active while unread → marks read once, for this channel + channel.activate(); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(channel); + + spy.mockClear(); + + // still active but unread cleared → no further mark-read + channel.state.read = { + me: { last_read: new Date(1), unread_messages: 0, user: { id: 'me' } }, + }; + expect(spy).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/channel_state.test.js b/test/unit/channel_state.test.js index 55daadec2f..c59c2c5c61 100644 --- a/test/unit/channel_state.test.js +++ b/test/unit/channel_state.test.js @@ -52,7 +52,10 @@ describe('ChannelState members store', () => { expect(state.members).to.eql({}); expect(state.member_count).to.equal(0); - expect(state.membersStore.getLatestValue()).to.eql({ members: {}, memberCount: 0 }); + expect(state.getLatestValue()).to.deep.include({ + members: {}, + memberCount: 0, + }); }); it('keeps members getter/setter backward compatible while syncing the store', () => { @@ -64,7 +67,7 @@ describe('ChannelState members store', () => { state.members = members; expect(state.members).to.equal(members); - expect(state.membersStore.getLatestValue()).to.eql({ + expect(state.getLatestValue()).to.deep.include({ memberCount: 0, members, }); @@ -76,7 +79,7 @@ describe('ChannelState members store', () => { state.member_count = 42; expect(state.member_count).to.equal(42); - expect(state.membersStore.getLatestValue()).to.eql({ + expect(state.getLatestValue()).to.deep.include({ memberCount: 42, members: {}, }); @@ -90,7 +93,7 @@ describe('ChannelState member count bridge', () => { const state = channel.state; expect(state.member_count).to.equal(3); - expect(state.membersStore.getLatestValue()).to.eql({ + expect(state.getLatestValue()).to.deep.include({ memberCount: 3, members: {}, }); @@ -106,7 +109,7 @@ describe('ChannelState member count bridge', () => { state.syncMemberCountFromChannelData(channel.data); expect(state.member_count).to.equal(7); - expect(state.membersStore.getLatestValue()).to.eql({ + expect(state.getLatestValue()).to.deep.include({ memberCount: 7, members: {}, }); @@ -121,7 +124,7 @@ describe('ChannelState member count bridge', () => { channel.data.member_count = 5; expect(state.member_count).to.equal(5); - expect(state.membersStore.getLatestValue()).to.eql({ + expect(state.getLatestValue()).to.deep.include({ memberCount: 5, members: {}, }); @@ -134,7 +137,7 @@ describe('ChannelState read store', () => { const state = new ChannelState(); expect(state.read).to.eql({}); - expect(state.readStore.getLatestValue()).to.eql({ read: {} }); + expect(state.getLatestValue()).to.deep.include({ read: {} }); }); it('keeps read getter/setter backward compatible while syncing the store', () => { @@ -150,7 +153,7 @@ describe('ChannelState read store', () => { state.read = read; expect(state.read).to.equal(read); - expect(state.readStore.getLatestValue()).to.eql({ read }); + expect(state.getLatestValue()).to.deep.include({ read }); }); }); @@ -159,7 +162,7 @@ describe('ChannelState watcher count store', () => { const state = new ChannelState(); expect(state.watcher_count).to.equal(0); - expect(state.watcherStore.getLatestValue()).to.eql({ + expect(state.getLatestValue()).to.deep.include({ watcherCount: 0, watchers: {}, }); @@ -171,7 +174,7 @@ describe('ChannelState watcher count store', () => { state.watcher_count = 42; expect(state.watcher_count).to.equal(42); - expect(state.watcherStore.getLatestValue()).to.eql({ + expect(state.getLatestValue()).to.deep.include({ watcherCount: 42, watchers: {}, }); @@ -183,7 +186,7 @@ describe('ChannelState watchers store', () => { const state = new ChannelState(); expect(state.watchers).to.eql({}); - expect(state.watcherStore.getLatestValue()).to.eql({ + expect(state.getLatestValue()).to.deep.include({ watcherCount: 0, watchers: {}, }); @@ -198,38 +201,19 @@ describe('ChannelState watchers store', () => { state.watchers = watchers; expect(state.watchers).to.equal(watchers); - expect(state.watcherStore.getLatestValue()).to.eql({ + expect(state.getLatestValue()).to.deep.include({ watcherCount: 0, watchers, }); }); }); -describe('ChannelState muted users store', () => { - it('initializes muted users store with an empty list', () => { - const state = new ChannelState(); - - expect(state.mutedUsers).to.eql([]); - expect(state.mutedUsersStore.getLatestValue()).to.eql({ mutedUsers: [] }); - }); - - it('keeps mutedUsers getter/setter backward compatible while syncing the store', () => { - const state = new ChannelState(); - const mutedUsers = [{ id: 'alice' }]; - - state.mutedUsers = mutedUsers; - - expect(state.mutedUsers).to.equal(mutedUsers); - expect(state.mutedUsersStore.getLatestValue()).to.eql({ mutedUsers }); - }); -}); - describe('ChannelState typing store', () => { it('initializes typing store with an empty typing map', () => { const state = new ChannelState(); expect(state.typing).to.eql({}); - expect(state.typingStore.getLatestValue()).to.eql({ typing: {} }); + expect(state.getLatestValue()).to.deep.include({ typing: {} }); }); it('keeps typing store and textComposer typing in sync via setTypingEvent/removeTypingEvent', () => { @@ -244,13 +228,13 @@ describe('ChannelState typing store', () => { state.setTypingEvent('alice', typingStartEvent); expect(state.typing).to.have.property('alice'); - expect(state.typingStore.getLatestValue().typing).to.have.property('alice'); + expect(state.getLatestValue().typing).to.have.property('alice'); expect(channel.messageComposer.textComposer.typing).to.have.property('alice'); state.removeTypingEvent('alice'); expect(state.typing).to.not.have.property('alice'); - expect(state.typingStore.getLatestValue().typing).to.not.have.property('alice'); + expect(state.getLatestValue().typing).to.not.have.property('alice'); expect(channel.messageComposer.textComposer.typing).to.not.have.property('alice'); }); }); @@ -276,7 +260,7 @@ describe('ChannelState own capabilities store', () => { }); const state = channel.state; - expect(state.ownCapabilitiesStore.getLatestValue()).to.eql({ + expect(state.getLatestValue()).to.deep.include({ ownCapabilities: ['send-message', 'upload-file'], }); expect(channel.data?.own_capabilities).to.eql(['send-message', 'upload-file']); @@ -295,7 +279,7 @@ describe('ChannelState own capabilities store', () => { }; state.syncOwnCapabilitiesFromChannelData(channel.data); - expect(state.ownCapabilitiesStore.getLatestValue()).to.eql({ + expect(state.getLatestValue()).to.deep.include({ ownCapabilities: ['pin-message'], }); expect(channel.data?.own_capabilities).to.eql(['pin-message']); @@ -308,7 +292,7 @@ describe('ChannelState own capabilities store', () => { channel.data.own_capabilities = ['delete-message']; - expect(state.ownCapabilitiesStore.getLatestValue()).to.eql({ + expect(state.getLatestValue()).to.deep.include({ ownCapabilities: ['delete-message'], }); expect(channel.data.own_capabilities).to.eql(['delete-message']); @@ -360,8 +344,60 @@ describe('ChannelState own capabilities store', () => { expect(channel.data.hidden).to.equal(true); expect(channel.data.member_count).to.equal(5); expect(state.member_count).to.equal(5); - expect(state.ownCapabilitiesStore.getLatestValue()).to.eql({ + expect(state.getLatestValue()).to.deep.include({ ownCapabilities: ['pin-message'], }); }); }); + +describe('ChannelState unified store', () => { + it('publishes all slices through one StateStore and preserves siblings on single-key writes', () => { + const state = new ChannelState(); + const members = { alice: { user: { id: 'alice' }, user_id: 'alice' } }; + const read = { + alice: { last_read: new Date(0), unread_messages: 2, user: { id: 'alice' } }, + }; + const watchers = { bob: { id: 'bob' } }; + + state.members = members; + state.read = read; + state.watchers = watchers; + state.watcher_count = 3; + state.member_count = 5; + state.typing = { carol: { type: 'typing.start', user: { id: 'carol' } } }; + + // Every slice must survive: the setters go through partialNext, so a single-key write + // is a shallow merge — NOT a full replace that would wipe siblings. (Teeth-check: if any + // setter used `.next({ slice })` these references would be lost and this test would fail.) + const snapshot = state.getLatestValue(); + expect(snapshot.members).to.equal(members); + expect(snapshot.read).to.equal(read); + expect(snapshot.watchers).to.equal(watchers); + expect(snapshot.watcherCount).to.equal(3); + expect(snapshot.memberCount).to.equal(5); + expect(snapshot.typing).to.have.property('carol'); + }); + + it('is subscribable directly via subscribeWithSelector (useStateStore(channel.state, …))', () => { + const state = new ChannelState(); + const seen = []; + const unsubscribe = state.subscribeWithSelector( + (next) => ({ read: next.read }), + ({ read }) => { + seen.push(read); + }, + ); + + const read = { + alice: { last_read: new Date(0), unread_messages: 1, user: { id: 'alice' } }, + }; + state.read = read; + // a non-read write must NOT emit to a read selector + state.watcher_count = 9; + unsubscribe(); + + // initial emit + the read change; the watcher_count write is filtered out by the selector + expect(seen).to.have.length(2); + expect(seen[1]).to.equal(read); + }); +}); diff --git a/test/unit/client.test.js b/test/unit/client.test.js index 4e371cccf3..5edef48356 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -883,7 +883,7 @@ describe('StreamChat.queryChannels', async () => { const [channel] = await client.queryChannelsAndHydrate(); expect(channel.state.member_count).to.equal(7); - expect(channel.state.ownCapabilitiesStore.getLatestValue()).to.eql({ + expect(channel.state.getLatestValue()).to.deep.include({ ownCapabilities: ['send-message', 'read-events'], }); @@ -891,7 +891,7 @@ describe('StreamChat.queryChannels', async () => { channel.data.own_capabilities = ['send-message']; expect(channel.state.member_count).to.equal(8); - expect(channel.state.ownCapabilitiesStore.getLatestValue()).to.eql({ + expect(channel.state.getLatestValue()).to.deep.include({ ownCapabilities: ['send-message'], }); diff --git a/test/unit/messageDelivery/MessageReceiptsTracker.test.ts b/test/unit/messageDelivery/MessageReceiptsTracker.test.ts index a43c055391..e338b25ad5 100644 --- a/test/unit/messageDelivery/MessageReceiptsTracker.test.ts +++ b/test/unit/messageDelivery/MessageReceiptsTracker.test.ts @@ -42,9 +42,8 @@ const createChannelMock = ({ return { channel: { - state: { - readStore, - }, + // `channel.state` is the reactive store itself; the tracker subscribes to its `read` slice. + state: readStore, // The default receipts locator now resolves timestamps via the message paginator; this mock // fn (still named findMessageByTimestamp in tests) backs messagePaginator.findItemByTimestamp. messagePaginator: { From c5d1623be512397bf71a974963dbc5a66e01b443 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 13 Aug 2026 23:41:35 +0200 Subject: [PATCH 18/31] feat: extend channel state with missing properties --- src/channel.ts | 121 ++++++++++++++++++++------ src/channel_state.ts | 148 +++++++++++++++++++++----------- src/client.ts | 20 ++++- test/unit/channel.test.js | 89 ++++++++++++++++++- test/unit/channel_state.test.js | 140 ++++++++++++++++++++++-------- test/unit/client.test.js | 20 ++++- 6 files changed, 416 insertions(+), 122 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index d71ea66cc7..b1a382396f 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -155,25 +155,9 @@ export class Channel extends ChannelApi { /** */ listeners: Map>; state: ChannelState; - /** - * This boolean is a vague indication of whether the channel exists on chat backend. - * - * If the value is true, then that means the channel has been initialized by either calling - * channel.create() or channel.query() or channel.watch(). - * - * If the value is false, then channel may or may not exist on the backend. The only way to ensure - * is by calling channel.create() or channel.query() or channel.watch(). - */ - initialized: boolean; - /** - * Indicates whether channel has been initialized by manually populating the state with some messages, members etc. - * Static state indicates that channel exists on backend, but is not being watched yet. - */ - offlineMode: boolean; lastKeyStroke?: Date; lastTypingEvent: Date | null; isTyping: boolean; - disconnected: boolean; /** Re-entrancy guard for {@link Channel.reload} (mirrors Thread.reload's isLoading guard). */ private _reloading = false; /** Refcount backing the reactive `active` flag (a shared Channel instance can be mounted more than once). */ @@ -225,11 +209,8 @@ export class Channel extends ChannelApi { this.listeners = new Map(); // perhaps the state variable should be private this.state = new ChannelState(this); - this.initialized = false; - this.offlineMode = false; this.lastTypingEvent = null; this.isTyping = false; - this.disconnected = false; this.messageComposer = new MessageComposer({ client: this._client, @@ -314,6 +295,11 @@ export class Channel extends ChannelApi { }, }, }); + + // Seed the reactive mute state from the client's current `mutedChannels` (a channel created + // after connect may already be muted). Kept in sync afterwards by the client fan-out on + // `notification.channel_mutes_updated` / `health.check`. + this._syncMuteStatus(); } /** @@ -1050,6 +1036,27 @@ export class Channel extends ChannelApi { return this.getClient()._muteStatus(this.cid); } + /** + * Recomputes this channel's reactive `state.muteStatus` from the client's current `mutedChannels` + * and publishes it only when it actually changed — so the frequent `health.check` fan-out does not + * churn subscribers. Called from the constructor and by the client whenever `mutedChannels` + * updates. Unlike `muteStatus()`, this does not require the channel to be initialized. + */ + _syncMuteStatus() { + if (this.disconnected) return; + + const next = this.getClient()._muteStatus(this.cid); + const previous = this.state.getLatestValue().muteStatus; + const unchanged = + previous.muted === next.muted && + (previous.createdAt?.getTime() ?? null) === (next.createdAt?.getTime() ?? null) && + (previous.expiresAt?.getTime() ?? null) === (next.expiresAt?.getTime() ?? null); + + if (unchanged) return; + + this.state.partialNext({ muteStatus: next }); + } + sendAction(messageId: string, formData: Record) { this._checkInitialized(); if (!messageId) { @@ -1209,6 +1216,43 @@ export class Channel extends ChannelApi { return await super.markRead(data); } + /** + * A vague indication of whether the channel exists on the chat backend — `true` once + * `create()`/`query()`/`watch()` has run. Store-backed and reactive: subscribe via + * `useStateStore(channel.state, (s) => ({ initialized: s.initialized }))`. + */ + get initialized() { + return this.state.getLatestValue().initialized; + } + + set initialized(initialized: boolean) { + this.state.partialNext({ initialized }); + } + + /** + * Whether the channel was initialized by manually populating its state (offline hydration) rather + * than a live watch. Store-backed and reactive. + */ + get offlineMode() { + return this.state.getLatestValue().offlineMode; + } + + set offlineMode(offlineMode: boolean) { + this.state.partialNext({ offlineMode }); + } + + /** + * Whether the channel has been torn down / evicted (deleted, or the current user removed). + * Store-backed and reactive. + */ + get disconnected() { + return this.state.getLatestValue().disconnected; + } + + set disconnected(disconnected: boolean) { + this.state.partialNext({ disconnected }); + } + /** * Whether the channel is currently mounted / actively viewed on-screen. Reactive — subscribe via * `useStateStore(channel.state, (s) => ({ active: s.active }))`. @@ -2043,6 +2087,33 @@ export class Channel extends ChannelApi { return nextUserReadState; } + /** + * Resets the current user's unread count consistently across BOTH `state.unreadCount` and the + * reactive `read[userId].unread_messages` — the latter is what the unread badge actually reads. + * Channel-wide resets (`channel.truncated`, "all channels read") historically wrote only the + * former, leaving the badge stale (TODO #29); routing them through here keeps the two in sync. + */ + _setOwnUnreadCount(unreadCount: number) { + if (this.disconnected) return; + + this.state.unreadCount = unreadCount; + + const userId = this.getClient().userID; + if (!userId) return; + + const currentUserReadState = this.state.read[userId]; + // only reconcile an existing read entry; never fabricate one just to store a count + if (!currentUserReadState || currentUserReadState.unread_messages === unreadCount) { + return; + } + + this._upsertReadState( + userId, + () => ({ ...currentUserReadState, unread_messages: unreadCount }), + { changedUserIds: [userId] }, + ); + } + _handleChannelEvent(event: Event) { // eslint-disable-next-line @typescript-eslint/no-this-alias const channel = this; @@ -2313,7 +2384,7 @@ export class Channel extends ChannelApi { if (event.channel?.truncated_at) { const truncatedAtDate = new Date(event.channel.truncated_at); - channelState.unreadCount = this.countUnread(truncatedAtDate); + this._setOwnUnreadCount(this.countUnread(truncatedAtDate)); // Partial truncation: keep messages newer than the cutoff. clearStateAndCache would wipe // the whole paginator (readers now source from it), so use the partial truncate. The // channel-wide read/unread context is reset by the truncation, so drop the unread snapshot @@ -2322,7 +2393,7 @@ export class Channel extends ChannelApi { this.messagePaginator.clearUnreadSnapshot(); this.pinnedMessagesPaginator.truncate({ truncatedAt: truncatedAtDate }); } else { - channelState.unreadCount = 0; + this._setOwnUnreadCount(0); this.messagePaginator.clearStateAndCache(); this.pinnedMessagesPaginator.clearStateAndCache(); } @@ -2572,8 +2643,7 @@ export class Channel extends ChannelApi { data: Channel['data'], fallbackData: Channel['data'] = this.data, ) { - this.state.syncOwnCapabilitiesFromChannelData(data, fallbackData); - this.state.syncMemberCountFromChannelData(data, fallbackData); + this.state.syncStateFromChannelData(data, fallbackData); } _initializeState(state: ChannelStateResponseFields) { @@ -2728,9 +2798,12 @@ export class Channel extends ChannelApi { _disconnect() { logger.withExtraTags('_disconnect', this.cid).info('Disconnecting the channel.'); - this.disconnected = true; + // Tear down the channel.state subscriptions BEFORE flipping `disconnected` — that setter now + // publishes to the store, and the auto-mark-active-read subscription's selector calls + // getClient(), which throws once the channel is disconnected. this.messageReceiptsTracker.unregisterSubscriptions(); this._unsubscribeMarkActiveRead?.(); + this.disconnected = true; this.cooldownTimer.clearTimeout(); // Release the store-backed paginators so the message store no longer pins this removed channel // (and its whole message graph) through its subscriber registry. The channel is being discarded diff --git a/src/channel_state.ts b/src/channel_state.ts index cb43d8e91d..e542d7aea3 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -45,6 +45,55 @@ export type OwnCapabilitiesState = { ownCapabilities: string[]; }; +/** + * The channel's server-provided `data` (name, image, frozen, hidden, blocked, config, + * `member_count`, `own_capabilities`, …), mirrored reactively so consumers can subscribe to + * channel-level changes via `useStateStore(channel.state, (s) => ({ data: s.data }))`. + */ +export type ChannelDataState = { + data: Channel['data']; +}; + +/** Whether THIS channel is muted for the current user, mirrored from `client.mutedChannels`. */ +export type ChannelMuteStatus = { + muted: boolean; + createdAt: Date | null; + expiresAt: Date | null; +}; + +/** + * Reactive channel-mute state — is this channel muted for the current user. Mirrors the client-owned + * `client.mutedChannels` (updated on `notification.channel_mutes_updated` + `health.check`) and is + * subscribable via `useStateStore(channel.state, (s) => ({ muteStatus: s.muteStatus }))`. Muted + * USERS remain client-global on `client.mutedUsersStore` and are NOT part of channel state. + */ +export type MuteStatusState = { + muteStatus: ChannelMuteStatus; +}; + +/** + * Connection / initialization lifecycle flags for the channel. Previously plain fields on `Channel`; + * now store-backed so consumers can react to them via `useStateStore(channel.state, selector)`. + * Read/written through the `channel.initialized` / `channel.offlineMode` / `channel.disconnected` + * getters/setters, which proxy this slice. + */ +export type ChannelLifecycleState = { + /** + * A vague indication of whether the channel exists on the chat backend. `true` once the channel + * has been initialized by `channel.create()` / `channel.query()` / `channel.watch()`. `false` + * means the channel may or may not exist — only those calls confirm it. + */ + initialized: boolean; + /** + * Whether the channel was initialized by manually populating its state (e.g. offline hydration) + * rather than a live watch. Such static state means the channel exists on the backend but is not + * being watched yet. + */ + offlineMode: boolean; + /** Whether the channel has been torn down / evicted (deleted, or the current user removed). */ + disconnected: boolean; +}; + /** UI-driven channel lifecycle state (not returned by the API; set by the UI SDK). */ export type ChannelUIState = { /** @@ -69,6 +118,9 @@ export type ChannelStateData = WatcherState & ReadState & MembersState & OwnCapabilitiesState & + ChannelDataState & + MuteStatusState & + ChannelLifecycleState & ChannelUIState; /** @@ -93,11 +145,15 @@ export class ChannelState extends StateStore { members: {}, memberCount: 0, ownCapabilities: [], + data: channel?.data, + muteStatus: { muted: false, createdAt: null, expiresAt: null }, + initialized: false, + offlineMode: false, + disconnected: false, active: false, }); this._channel = channel; - this.syncMemberCountFromChannelData(channel?.data); - this.syncOwnCapabilitiesFromChannelData(channel?.data); + this.syncStateFromChannelData(channel?.data); this.pending_messages = []; this.membership = {} as ChannelMemberResponse; this.unreadCount = 0; @@ -141,7 +197,21 @@ export class ChannelState extends StateStore { } } - syncMemberCountFromChannelData( + /** + * Reflects the channel's server-provided `data` into the unified store and derives the + * `memberCount` and `ownCapabilities` slices from it. + * + * `fallbackData` (the previous `channel.data`) makes both derived fields sticky: a data update + * that omits `member_count`/`own_capabilities` keeps the last known value rather than wiping it. + * The sticky value is written back onto `data` as a plain field (only when `data` itself is + * missing it) so raw readers — e.g. `channelHasReadEvents`, which inspects + * `channel.data.own_capabilities` directly — stay consistent with the store. `own_capabilities` + * is never coerced to `[]` while unknown, so "not yet loaded" is not mistaken for "explicitly no + * capabilities" (regression #1732). This replaces the previous `Object.defineProperty` machinery; + * direct in-place mutation of `channel.data.member_count`/`own_capabilities` no longer syncs to + * the store — reassign `channel.data` (as the WS handlers do) instead. + */ + syncStateFromChannelData( data: Channel['data'], fallbackData: Channel['data'] = this._channel?.data, ) { @@ -150,63 +220,41 @@ export class ChannelState extends StateStore { ? fallbackData.member_count : this.getLatestValue().memberCount; - if (!data || typeof data !== 'object') { - this.partialNext({ memberCount: fallbackMemberCount ?? 0 }); - return; - } - - const dataDescriptor = Object.getOwnPropertyDescriptor(data, 'member_count'); - let memberCount = - typeof data.member_count === 'number' + const memberCount = + typeof data?.member_count === 'number' ? data.member_count : typeof fallbackMemberCount === 'number' ? fallbackMemberCount : undefined; - this.partialNext({ memberCount: memberCount ?? 0 }); - - Object.defineProperty(data, 'member_count', { - configurable: true, - enumerable: dataDescriptor?.enumerable ?? false, - get: () => memberCount, - set: (nextMemberCount: number | undefined) => { - memberCount = typeof nextMemberCount === 'number' ? nextMemberCount : undefined; - this.partialNext({ memberCount: memberCount ?? 0 }); - }, - }); - } - - syncOwnCapabilitiesFromChannelData( - data: Channel['data'], - fallbackData: Channel['data'] = this._channel?.data, - ) { - if (!data || typeof data !== 'object') { - this.partialNext({ ownCapabilities: [] }); - return; - } - - let ownCapabilities: string[] | undefined = Array.isArray(data.own_capabilities) + const ownCapabilities = Array.isArray(data?.own_capabilities) ? [...data.own_capabilities] : Array.isArray(fallbackData?.own_capabilities) ? [...fallbackData.own_capabilities] : undefined; - this.partialNext({ ownCapabilities: ownCapabilities ?? [] }); - - // Keep the reactive getter/setter so backward-compatible assignments still sync to - // the store, but return `undefined` until capabilities are actually known. Forcing - // `[]` on an unloaded channel would make read-events–gated logic (e.g. unread - // counting, regression #1732) treat "not yet loaded" as "explicitly no capabilities". - Object.defineProperty(data, 'own_capabilities', { - configurable: true, - enumerable: true, - get: () => ownCapabilities, - set: (nextOwnCapabilities: string[] | undefined) => { - ownCapabilities = Array.isArray(nextOwnCapabilities) - ? [...nextOwnCapabilities] - : undefined; - this.partialNext({ ownCapabilities: ownCapabilities ?? [] }); - }, + // Carry a genuinely-known previous value forward onto the new `data` object when the update + // omits it — never fabricate one (an empty channel keeps `data === {}`, its `own_capabilities` + // undefined). This is a plain assignment, not an accessor. + if (data && typeof data === 'object') { + if ( + typeof data.member_count !== 'number' && + typeof fallbackData?.member_count === 'number' + ) { + data.member_count = fallbackData.member_count; + } + if ( + !Array.isArray(data.own_capabilities) && + Array.isArray(fallbackData?.own_capabilities) + ) { + data.own_capabilities = [...fallbackData.own_capabilities]; + } + } + + this.partialNext({ + data, + memberCount: memberCount ?? 0, + ownCapabilities: ownCapabilities ?? [], }); } diff --git a/src/client.ts b/src/client.ts index 0fbd3dd71d..fe8ebced22 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1004,6 +1004,7 @@ export class StreamChat extends ChatApi { client.user = event.me; client.state.updateUser(event.me); client.mutedChannels = event.me.channel_mutes; + client._reflectMutedChannelsToActiveChannels(); client.mutedUsers = event.me.mutes; client.blockedUsers.partialNext({ userIds: event.me.blocked_user_ids ?? [] }); } @@ -1015,6 +1016,7 @@ export class StreamChat extends ChatApi { if (event.type === 'notification.channel_mutes_updated' && event.me?.channel_mutes) { this.mutedChannels = event.me.channel_mutes; + this._reflectMutedChannelsToActiveChannels(); } if (event.type === 'notification.mutes_updated' && event.me?.mutes) { @@ -1023,9 +1025,10 @@ export class StreamChat extends ChatApi { if (event.type === 'notification.mark_read' && event.unread_channels === 0) { const activeChannelKeys = Object.keys(this.activeChannels); - activeChannelKeys.forEach( - (activeChannelKey) => - (this.activeChannels[activeChannelKey].state.unreadCount = 0), + activeChannelKeys.forEach((activeChannelKey) => + // resets both `state.unreadCount` and `read[userId].unread_messages` so the badge, which + // reads the latter, does not stay stale (TODO #29). + this.activeChannels[activeChannelKey]._setOwnUnreadCount(0), ); } @@ -1061,6 +1064,17 @@ export class StreamChat extends ChatApi { return postListenerCallbacks; } + /** + * Fans the client-owned `mutedChannels` out to every active channel's reactive `state.muteStatus`. + * Each channel republishes only when its own mute status actually changed, so this stays cheap on + * the frequent `health.check` path. + */ + _reflectMutedChannelsToActiveChannels() { + for (const cid in this.activeChannels) { + this.activeChannels[cid]?._syncMuteStatus(); + } + } + _muteStatus(cid: string) { let muteStatus; for (let i = 0; i < this.mutedChannels.length; i++) { diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 8d868bc624..88cf0f5587 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -958,6 +958,28 @@ describe('Channel _handleChannelEvent', function () { expect(channel.messagePaginator.headItems.length).to.be.equal(0); }); + it('resets read[userId].unread_messages together with unreadCount so the badge does not go stale (#29)', function () { + const userId = client.user.id; + channel.state.read = { + [userId]: { + unread_messages: 5, + last_read: new Date('2021-01-01T00:00:00.000Z'), + user: { id: userId }, + }, + }; + channel.state.unreadCount = 5; + + channel._handleChannelEvent({ + type: 'channel.truncated', + user: { id: userId }, + channel: {}, + }); + + expect(channel.state.unreadCount).to.equal(0); + // the badge reads read[userId].unread_messages — it must not stay at the stale 5 + expect(channel.state.read[userId].unread_messages).to.equal(0); + }); + it('message.truncate clears messagePaginator unread snapshot', function () { const cachedMessage = generateMsg({ date: '2020-01-01T00:00:00.000Z', @@ -2198,6 +2220,67 @@ describe('Uninitialized Channel', () => { }); }); +describe('reactive channel mute status', () => { + let client; + let channel; + + beforeEach(() => { + client = new StreamChat('apiKey'); + client.user = { id: 'me' }; + channel = client.channel('messaging', 'mute-reactivity'); + }); + + it('seeds state.muteStatus from client.mutedChannels at construction', () => { + const preMutedClient = new StreamChat('apiKey'); + preMutedClient.user = { id: 'me' }; + preMutedClient.mutedChannels = [ + { + channel: { cid: 'messaging:premuted' }, + created_at: '2024-01-01T00:00:00.000Z', + }, + ]; + + const preMuted = preMutedClient.channel('messaging', 'premuted'); + + expect(preMuted.state.getLatestValue().muteStatus.muted).to.be.true; + }); + + it('reactively reflects notification.channel_mutes_updated into state.muteStatus', () => { + expect(channel.state.getLatestValue().muteStatus.muted).to.be.false; + + const seen = []; + const unsubscribe = channel.state.subscribeWithSelector( + (s) => ({ muted: s.muteStatus.muted }), + ({ muted }) => { + seen.push(muted); + }, + ); + + client.dispatchEvent({ + type: 'notification.channel_mutes_updated', + me: { + channel_mutes: [ + { + channel: { cid: channel.cid }, + created_at: '2024-01-01T00:00:00.000Z', + }, + ], + }, + }); + expect(channel.state.getLatestValue().muteStatus.muted).to.be.true; + + client.dispatchEvent({ + type: 'notification.channel_mutes_updated', + me: { channel_mutes: [] }, + }); + unsubscribe(); + + expect(channel.state.getLatestValue().muteStatus.muted).to.be.false; + // the selector only fires on real transitions — no health.check churn + expect(seen).to.eql([false, true, false]); + }); +}); + describe('Channels - Constructor', function () { const client = new StreamChat('key', 'secret'); // client.channel() now requires a connected user (userId derives from client.user). @@ -2238,10 +2321,10 @@ describe('Channels - Constructor', function () { it('undefined ID no options', function () { const channel = client.channel('messaging', undefined); expect(channel.id).to.eql(undefined); - // own_capabilities stays undefined ("not yet loaded") until the channel is - // hydrated; the reactive getter is still defined (hence enumerable). + // own_capabilities stays undefined ("not yet loaded") until the channel is hydrated, + // and no fields are fabricated onto an empty channel's data. expect(channel.data.own_capabilities).to.be.undefined; - expect(Object.keys(channel.data)).to.eql(['own_capabilities']); + expect(Object.keys(channel.data)).to.eql([]); }); it('short version with options', function () { diff --git a/test/unit/channel_state.test.js b/test/unit/channel_state.test.js index c59c2c5c61..5aa67ba84a 100644 --- a/test/unit/channel_state.test.js +++ b/test/unit/channel_state.test.js @@ -106,7 +106,7 @@ describe('ChannelState member count bridge', () => { const state = channel.state; channel.data = { ...channel.data, member_count: 7 }; - state.syncMemberCountFromChannelData(channel.data); + state.syncStateFromChannelData(channel.data); expect(state.member_count).to.equal(7); expect(state.getLatestValue()).to.deep.include({ @@ -116,19 +116,22 @@ describe('ChannelState member count bridge', () => { expect(channel.data?.member_count).to.equal(7); }); - it('keeps backward-compatible channel.data.member_count assignments in sync', () => { + it('keeps the last known member_count when a data update omits it (sticky fallback)', () => { const client = new StreamChat(); - const channel = new Channel(client, 'type', 'id', {}); + const channel = new Channel(client, 'type', 'id', { member_count: 4 }); const state = channel.state; - channel.data.member_count = 5; + const previousData = channel.data; + channel.data = { name: 'renamed' }; + state.syncStateFromChannelData(channel.data, previousData); - expect(state.member_count).to.equal(5); + expect(state.member_count).to.equal(4); expect(state.getLatestValue()).to.deep.include({ - memberCount: 5, + memberCount: 4, members: {}, }); - expect(channel.data.member_count).to.equal(5); + // sticky value is written back onto the raw data so raw readers stay consistent + expect(channel.data.member_count).to.equal(4); }); }); @@ -277,7 +280,7 @@ describe('ChannelState own capabilities store', () => { ...channel.data, own_capabilities: ['pin-message'], }; - state.syncOwnCapabilitiesFromChannelData(channel.data); + state.syncStateFromChannelData(channel.data); expect(state.getLatestValue()).to.deep.include({ ownCapabilities: ['pin-message'], @@ -285,20 +288,34 @@ describe('ChannelState own capabilities store', () => { expect(channel.data?.own_capabilities).to.eql(['pin-message']); }); - it('keeps backward-compatible channel.data.own_capabilities assignments in sync', () => { + it('keeps the last known own_capabilities when a data update omits them (sticky fallback)', () => { const client = new StreamChat(); - const channel = new Channel(client, 'type', 'id', {}); + const channel = new Channel(client, 'type', 'id', { + own_capabilities: ['send-message'], + }); const state = channel.state; - channel.data.own_capabilities = ['delete-message']; + const previousData = channel.data; + channel.data = { name: 'renamed' }; + state.syncStateFromChannelData(channel.data, previousData); expect(state.getLatestValue()).to.deep.include({ - ownCapabilities: ['delete-message'], + ownCapabilities: ['send-message'], }); - expect(channel.data.own_capabilities).to.eql(['delete-message']); + // sticky value is written back onto the raw data so channelHasReadEvents stays consistent + expect(channel.data.own_capabilities).to.eql(['send-message']); + }); + + it('leaves own_capabilities undefined until known (#1732)', () => { + const client = new StreamChat(); + const channel = new Channel(client, 'type', 'id', {}); + + // unknown on the raw data, but the store slice defaults to an empty array + expect(channel.data.own_capabilities).to.be.undefined; + expect(channel.state.getLatestValue().ownCapabilities).to.eql([]); }); - it('only wraps own_capabilities and keeps other channel.data fields as value properties', () => { + it('exposes member_count / own_capabilities as plain value properties (no accessors)', () => { const client = new StreamChat(); const channel = new Channel(client, 'type', 'id', { hidden: false, @@ -306,29 +323,16 @@ describe('ChannelState own capabilities store', () => { own_capabilities: ['send-message'], }); - const ownCapabilitiesDescriptor = Object.getOwnPropertyDescriptor( - channel.data, - 'own_capabilities', - ); - const hiddenDescriptor = Object.getOwnPropertyDescriptor(channel.data, 'hidden'); - const memberCountDescriptor = Object.getOwnPropertyDescriptor( - channel.data, - 'member_count', - ); - - expect(ownCapabilitiesDescriptor).toBeDefined(); - expect('get' in ownCapabilitiesDescriptor).toBe(true); - expect('set' in ownCapabilitiesDescriptor).toBe(true); - expect(hiddenDescriptor).toBeDefined(); - expect('value' in hiddenDescriptor).toBe(true); - expect('get' in hiddenDescriptor).toBe(false); - expect('set' in hiddenDescriptor).toBe(false); - expect(memberCountDescriptor).toBeDefined(); - expect('get' in memberCountDescriptor).toBe(true); - expect('set' in memberCountDescriptor).toBe(true); + for (const key of ['own_capabilities', 'hidden', 'member_count']) { + const descriptor = Object.getOwnPropertyDescriptor(channel.data, key); + expect(descriptor).toBeDefined(); + expect('value' in descriptor).toBe(true); + expect('get' in descriptor).toBe(false); + expect('set' in descriptor).toBe(false); + } }); - it('does not overwrite non-capability fields when own_capabilities is updated', () => { + it('does not overwrite non-capability fields when channel.data is replaced', () => { const client = new StreamChat(); const channel = new Channel(client, 'type', 'id', { hidden: false, @@ -337,9 +341,14 @@ describe('ChannelState own capabilities store', () => { }); const state = channel.state; - channel.data.hidden = true; - channel.data.member_count = 5; - channel.data.own_capabilities = ['pin-message']; + const previousData = channel.data; + channel.data = { + ...channel.data, + hidden: true, + member_count: 5, + own_capabilities: ['pin-message'], + }; + state.syncStateFromChannelData(channel.data, previousData); expect(channel.data.hidden).to.equal(true); expect(channel.data.member_count).to.equal(5); @@ -400,4 +409,59 @@ describe('ChannelState unified store', () => { expect(seen).to.have.length(2); expect(seen[1]).to.equal(read); }); + + it('publishes channel.data reactively so name/image/frozen changes are subscribable', () => { + const client = new StreamChat(); + const channel = new Channel(client, 'type', 'id', { name: 'orig' }); + const state = channel.state; + + expect(state.getLatestValue().data).to.deep.include({ name: 'orig' }); + + const names = []; + const unsubscribe = state.subscribeWithSelector( + (next) => ({ name: next.data?.name }), + ({ name }) => { + names.push(name); + }, + ); + + const previousData = channel.data; + channel.data = { ...channel.data, name: 'renamed' }; + state.syncStateFromChannelData(channel.data, previousData); + unsubscribe(); + + expect(names).to.eql(['orig', 'renamed']); + }); + + it('proxies the lifecycle flags (initialized/offlineMode/disconnected) through the store', () => { + const client = new StreamChat(); + client.user = { id: 'me' }; + const channel = new Channel(client, 'messaging', 'lifecycle', {}); + client.activeChannels[channel.cid] = channel; + + expect(channel.initialized).to.equal(false); + expect(channel.offlineMode).to.equal(false); + expect(channel.disconnected).to.equal(false); + expect(channel.state.getLatestValue()).to.deep.include({ + initialized: false, + offlineMode: false, + disconnected: false, + }); + + const seen = []; + const unsubscribe = channel.state.subscribeWithSelector( + (s) => ({ initialized: s.initialized }), + ({ initialized }) => { + seen.push(initialized); + }, + ); + + // writing the getter/setter goes through the store, so subscribers are notified + channel.initialized = true; + unsubscribe(); + + expect(channel.initialized).to.equal(true); + expect(channel.state.getLatestValue().initialized).to.equal(true); + expect(seen).to.eql([false, true]); + }); }); diff --git a/test/unit/client.test.js b/test/unit/client.test.js index 5edef48356..5e4601d8d0 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -231,10 +231,17 @@ describe('Client active channels cache', () => { client.user = user; client.wsPromise = Promise.resolve(); }; + const makeChannelMock = (unreadCount) => ({ + state: { unreadCount }, + _setOwnUnreadCount(next) { + this.state.unreadCount = next; + }, + }); + beforeEach(() => { client.activeChannels = { - vish: { state: { unreadCount: 1 } }, - vish2: { state: { unreadCount: 2 } }, + vish: makeChannelMock(1), + vish2: makeChannelMock(2), }; }); @@ -887,8 +894,13 @@ describe('StreamChat.queryChannels', async () => { ownCapabilities: ['send-message', 'read-events'], }); - channel.data.member_count = 8; - channel.data.own_capabilities = ['send-message']; + const previousData = channel.data; + channel.data = { + ...channel.data, + member_count: 8, + own_capabilities: ['send-message'], + }; + channel._syncStateFromChannelData(channel.data, previousData); expect(channel.state.member_count).to.equal(8); expect(channel.state.getLatestValue()).to.deep.include({ From 6f4a5e6a99030f4b95da1615325c27fda4c7c032 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 14 Aug 2026 00:45:16 +0200 Subject: [PATCH 19/31] feat: add missing membership state --- src/channel_state.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/channel_state.ts b/src/channel_state.ts index e542d7aea3..d30bd5cda7 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -41,6 +41,11 @@ export type MembersState = { memberCount: number; }; +/** The current user's own membership in this channel (role, pinned_at, archived_at, …). */ +export type MembershipState = { + membership: ChannelMemberResponse; +}; + export type OwnCapabilitiesState = { ownCapabilities: string[]; }; @@ -117,6 +122,7 @@ export type ChannelStateData = WatcherState & TypingUsersState & ReadState & MembersState & + MembershipState & OwnCapabilitiesState & ChannelDataState & MuteStatusState & @@ -134,7 +140,6 @@ export class ChannelState extends StateStore { _channel: Channel; pending_messages: Array; unreadCount: number; - membership: ChannelMemberResponse; constructor(channel: Channel) { super({ @@ -144,6 +149,7 @@ export class ChannelState extends StateStore { read: {}, members: {}, memberCount: 0, + membership: {} as ChannelMemberResponse, ownCapabilities: [], data: channel?.data, muteStatus: { muted: false, createdAt: null, expiresAt: null }, @@ -155,10 +161,18 @@ export class ChannelState extends StateStore { this._channel = channel; this.syncStateFromChannelData(channel?.data); this.pending_messages = []; - this.membership = {} as ChannelMemberResponse; this.unreadCount = 0; } + /** The current user's own membership; store-backed so `useStateStore` can subscribe to it. */ + get membership() { + return this.getLatestValue().membership; + } + + set membership(membership: ChannelMemberResponse) { + this.partialNext({ membership }); + } + get members() { return this.getLatestValue().members; } From cef205655a9f127a15dcc6d5af9ab0d1105b090a Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 14 Aug 2026 13:47:36 +0200 Subject: [PATCH 20/31] chore: remove autoreads and delegate for later research --- src/channel.ts | 35 ++--------------------------------- test/unit/channel.test.js | 23 ++++++++--------------- 2 files changed, 10 insertions(+), 48 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index b1a382396f..26c366b3df 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -59,7 +59,7 @@ import type { UserResponse, } from './types'; import type { RoleName } from './permissions'; -import { StateStore, type Unsubscribe } from './store'; +import { StateStore } from './store'; import type { ChannelMemberRequest as Gen_ChannelMemberRequest, ChannelPushPreferencesResponse as Gen_ChannelPushPreferencesResponse, @@ -162,8 +162,6 @@ export class Channel extends ChannelApi { private _reloading = false; /** Refcount backing the reactive `active` flag (a shared Channel instance can be mounted more than once). */ private _activeRefCount = 0; - /** Teardown for the auto-mark-active-read subscription registered in the constructor. */ - private _unsubscribeMarkActiveRead?: Unsubscribe; push_preferences?: Gen_ChannelPushPreferencesResponse; public readonly configState = new StateStore({}); public readonly messageComposer: MessageComposer; @@ -226,9 +224,6 @@ export class Channel extends ChannelApi { this.messageReceiptsTracker = new MessageReceiptsTracker({ channel: this }); this.messageReceiptsTracker.registerSubscriptions(); - // Auto-mark-read while the channel is active (UI-driven; inert until a UI SDK calls activate()). - this._unsubscribeMarkActiveRead = this.subscribeMarkActiveChannelRead(); - this.cooldownTimer = new CooldownTimer({ channel: this }); this.messageOperations = new MessageOperations({ @@ -1286,30 +1281,6 @@ export class Channel extends ChannelApi { } }; - private throttledMarkRead = () => { - this.getClient().messageDeliveryReporter.throttledMarkRead(this); - }; - - /** - * Auto-marks the channel read whenever it is active and has unread messages (mirrors - * `Thread.subscribeMarkActiveThreadRead`). Registered once in the constructor; inert until a - * UI SDK calls `activate()`. - */ - private subscribeMarkActiveChannelRead = () => - this.state.subscribeWithSelector( - (nextValue) => { - const userId = this.getClient().userID; - return { - active: nextValue.active, - ownUnreadCount: (userId && nextValue.read[userId]?.unread_messages) || 0, - }; - }, - ({ active, ownUnreadCount }) => { - if (!active || !ownUnreadCount) return; - this.throttledMarkRead(); - }, - ); - /** * Marks the channel as unread from `messageId`. Only works when the `read_events` setting is enabled. * @@ -2799,10 +2770,8 @@ export class Channel extends ChannelApi { logger.withExtraTags('_disconnect', this.cid).info('Disconnecting the channel.'); // Tear down the channel.state subscriptions BEFORE flipping `disconnected` — that setter now - // publishes to the store, and the auto-mark-active-read subscription's selector calls - // getClient(), which throws once the channel is disconnected. + // publishes to the store, so no subscriber handler runs against a half-torn-down channel. this.messageReceiptsTracker.unregisterSubscriptions(); - this._unsubscribeMarkActiveRead?.(); this.disconnected = true; this.cooldownTimer.clearTimeout(); // Release the store-backed paginators so the message store no longer pins this removed channel diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 88cf0f5587..0e354406a0 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -3688,7 +3688,7 @@ describe('Channel.reload', () => { }); }); -describe('Channel active flag + auto-mark-read', () => { +describe('Channel active flag (mark-read stays UI-driven)', () => { let client; let channel; @@ -3718,28 +3718,21 @@ describe('Channel active flag + auto-mark-read', () => { expect(channel.active).to.equal(false); }); - it('auto-marks the channel read only while active AND unread', () => { + it('does NOT auto-mark the channel read (mark-read is UI-driven, matching v10 destructive-reconciliation)', () => { const spy = vi .spyOn(client.messageDeliveryReporter, 'throttledMarkRead') .mockImplementation(() => undefined); - // unread but not active → no auto mark-read + // Activate an unread channel and put it at the live edge — the channel must NOT auto-mark-read. + // Read is owned by the UI layer (MessageList marks read on viewability / scroll-to-bottom, + // gated on isViewingLive). A channel-level auto-read here would wipe the scroll-to-bottom unread + // badge while scrolled up — the "flash then vanish" regression. Parity guard so it can't return. + channel.activate(); + channel.messagePaginator.setViewingLive(true); channel.state.read = { me: { last_read: new Date(0), unread_messages: 3, user: { id: 'me' } }, }; - expect(spy).not.toHaveBeenCalled(); - - // becomes active while unread → marks read once, for this channel - channel.activate(); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith(channel); - - spy.mockClear(); - // still active but unread cleared → no further mark-read - channel.state.read = { - me: { last_read: new Date(1), unread_messages: 0, user: { id: 'me' } }, - }; expect(spy).not.toHaveBeenCalled(); }); }); From 8c23dc05c277faca4eb7e33bc44fca69f4d96190 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 17 Aug 2026 13:32:56 +0200 Subject: [PATCH 21/31] feat: ai state --- src/channel.ts | 23 +++++++ src/channel_state.ts | 32 +++++++-- src/client.ts | 12 ++++ src/types.ts | 13 +++- test/unit/channel.test.js | 134 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 209 insertions(+), 5 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 8c09a9ea31..8e9d437a90 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -59,6 +59,7 @@ import type { UpdateMessageOptions, UserResponse, } from './types'; +import { AIStates } from './types'; import type { RoleName } from './permissions'; import { StateStore } from './store'; import type { @@ -1341,6 +1342,17 @@ export class Channel extends ChannelApi { } this.state.clean(); + + // Clear a stuck AI indicator when we're offline (we'd miss the ending clear/stop). The cleaning + // interval keeps ticking through transient/internet drops; closeConnection stops it, and that + // path clears the indicator itself. Gate on health, not staleness — a healthy connection must + // never cut off a long-running response. + const client = this.getClient(); + const connectionHealthy = + client.wsConnection?.isHealthy || client.wsFallback?.isHealthy(); + if (!connectionHealthy) { + this.state.resetAIState(); + } } /** @@ -2105,6 +2117,17 @@ export class Channel extends ChannelApi { channelState.removeTypingEvent(event.user.id); } break; + case 'ai_indicator.update': + channelState.partialNext({ + aiState: (event.ai_state as AIState) ?? AIStates.Idle, + }); + break; + case 'ai_indicator.clear': + channelState.partialNext({ aiState: AIStates.Idle }); + break; + case 'ai_indicator.stop': + channelState.partialNext({ aiState: AIStates.Stop }); + break; // `message.read_locally` is the client-only event dispatched by `markReadLocally()` when read // events are disabled (e.g. livestreams with `isLocalUnreadCountEnabled`). It reuses the exact // `message.read` state logic so the read-state update lives in one place — only the diff --git a/src/channel_state.ts b/src/channel_state.ts index d30bd5cda7..fd64d80fb4 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -1,5 +1,6 @@ import type { Channel } from './channel'; import type { + AIState, ChannelMemberResponse, Event, LocalMessage, @@ -7,6 +8,7 @@ import type { PendingMessageResponse, UserResponse, } from './types'; +import { AIStates } from './types'; import { formatMessage } from './utils'; import { StateStore } from './store'; @@ -103,13 +105,23 @@ export type ChannelLifecycleState = { export type ChannelUIState = { /** * Whether the channel is currently mounted / actively viewed on-screen. UI-driven and - * refcounted via `channel.activate()` / `channel.deactivate()`. While `active`, the channel - * auto-marks messages read, and channel-list hydration does NOT re-seed its message list - * (the channel's own `channel.reload()` owns that window). + * refcounted via `channel.activate()` / `channel.deactivate()`. Channel-list hydration does NOT + * re-seed the message list of an `active` channel (the channel's own `channel.reload()` owns that + * window). */ active: boolean; }; +/** + * Reactive AI-indicator state — driven by the `ai_indicator.update` / `.clear` / `.stop` events (see + * `Channel._handleChannelEvent`). Seeded to `AIStates.Idle` and subscribable via + * `useStateStore(channel.state, (s) => ({ aiState: s.aiState }))`. Reset to `Idle` on unwatch / + * disconnect (a live server sends `ai_indicator.clear` when the AI response starts streaming). + */ +export type AIIndicatorState = { + aiState: AIState; +}; + /** * The single, unified reactive state for a channel. All per-channel reactive state is published * through this one `StateStore` and subscribed to with `useStateStore(channel.state, selector)` @@ -127,7 +139,8 @@ export type ChannelStateData = WatcherState & ChannelDataState & MuteStatusState & ChannelLifecycleState & - ChannelUIState; + ChannelUIState & + AIIndicatorState; /** * ChannelState - the container for a channel's reactive state. @@ -157,6 +170,7 @@ export class ChannelState extends StateStore { offlineMode: false, disconnected: false, active: false, + aiState: AIStates.Idle, }); this._channel = channel; this.syncStateFromChannelData(channel?.data); @@ -329,4 +343,14 @@ export class ChannelState extends StateStore { } } } + + /** + * Resets the AI indicator to `Idle`. Called when the connection drops, as while the connection is + * severed we miss the `ai_indicator.clear`/`.stop` that ends a response and it isn't replayed on + * reconnect, so the indicator would otherwise stay stuck on "Generating". Noop when already `Idle`. + */ + resetAIState() { + if (this.getLatestValue().aiState === AIStates.Idle) return; + this.partialNext({ aiState: AIStates.Idle }); + } } diff --git a/src/client.ts b/src/client.ts index 502c9bb0e5..c6f9c4d407 100644 --- a/src/client.ts +++ b/src/client.ts @@ -518,6 +518,8 @@ export class StreamChat extends ChatApi { * successful disconnection. See https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent (optional). */ closeConnection = async (timeout?: number) => { + this._resetAIStateOnActiveChannels(); + if (this.cleaningIntervalRef != null) { clearInterval(this.cleaningIntervalRef); this.cleaningIntervalRef = undefined; @@ -1071,6 +1073,16 @@ export class StreamChat extends ChatApi { } } + /** + * Resets the AI indicator state to `Idle` on every active channel. Invoked from `closeConnection` + * as it's a deliberate shutdown and will not natively trigger a WS event. + */ + _resetAIStateOnActiveChannels() { + for (const cid in this.activeChannels) { + this.activeChannels[cid]?.state.resetAIState(); + } + } + _muteStatus(cid: string) { let muteStatus; for (let i = 0; i < this.mutedChannels.length; i++) { diff --git a/src/types.ts b/src/types.ts index 3264c7b7d2..43265770e1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -872,12 +872,23 @@ export type ModerationFlagOptions = { }; export type AIState = + | 'AI_STATE_IDLE' | 'AI_STATE_ERROR' - | 'AI_STATE_CHECKING_SOURCES' + | 'AI_STATE_EXTERNAL_SOURCES' | 'AI_STATE_THINKING' | 'AI_STATE_GENERATING' + | 'AI_STATE_STOP' | (string & {}); +export const AIStates = { + Error: 'AI_STATE_ERROR', + ExternalSources: 'AI_STATE_EXTERNAL_SOURCES', + Generating: 'AI_STATE_GENERATING', + Idle: 'AI_STATE_IDLE', + Stop: 'AI_STATE_STOP', + Thinking: 'AI_STATE_THINKING', +} as const satisfies Record; + /** * An identifier containing information about the downstream SDK using stream-chat. It * is used to resolve the user agent. diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index ef3fb1abc6..84aa966d66 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -292,6 +292,140 @@ describe('Channel isViewingLive (unread bump gating)', function () { }); }); +describe('Channel AI indicator state (channel.state.aiState)', function () { + const setupChannel = () => { + const client = new StreamChat('apiKey'); + client.user = { id: 'user' }; + const channel = client.channel('messaging', 'ai-state-id'); + channel.initialized = true; + return { channel }; + }; + + it('defaults to AI_STATE_IDLE', () => { + const { channel } = setupChannel(); + expect(channel.state.getLatestValue().aiState).to.equal('AI_STATE_IDLE'); + }); + + it('reflects ai_indicator.update into channel.state.aiState', () => { + const { channel } = setupChannel(); + channel._handleChannelEvent({ + type: 'ai_indicator.update', + ai_state: 'AI_STATE_GENERATING', + }); + expect(channel.state.getLatestValue().aiState).to.equal('AI_STATE_GENERATING'); + }); + + it('resets to Idle on ai_indicator.clear', () => { + const { channel } = setupChannel(); + channel._handleChannelEvent({ + type: 'ai_indicator.update', + ai_state: 'AI_STATE_THINKING', + }); + channel._handleChannelEvent({ type: 'ai_indicator.clear' }); + expect(channel.state.getLatestValue().aiState).to.equal('AI_STATE_IDLE'); + }); + + it('sets AI_STATE_STOP on ai_indicator.stop', () => { + const { channel } = setupChannel(); + channel._handleChannelEvent({ + type: 'ai_indicator.update', + ai_state: 'AI_STATE_GENERATING', + }); + channel._handleChannelEvent({ type: 'ai_indicator.stop' }); + expect(channel.state.getLatestValue().aiState).to.equal('AI_STATE_STOP'); + }); + + it('is reactive via subscribeWithSelector', () => { + const { channel } = setupChannel(); + const seen = []; + channel.state.subscribeWithSelector( + (s) => ({ aiState: s.aiState }), + ({ aiState }) => seen.push(aiState), + ); + channel._handleChannelEvent({ + type: 'ai_indicator.update', + ai_state: 'AI_STATE_THINKING', + }); + expect(seen).to.include('AI_STATE_THINKING'); + }); + + it('clean() resets aiState to Idle when the WS connection is down', () => { + const { channel } = setupChannel(); + channel.getClient().wsConnection = { isHealthy: false }; + channel._handleChannelEvent({ + type: 'ai_indicator.update', + ai_state: 'AI_STATE_GENERATING', + }); + expect(channel.state.getLatestValue().aiState).to.equal('AI_STATE_GENERATING'); + + channel.clean(); + + expect(channel.state.getLatestValue().aiState).to.equal('AI_STATE_IDLE'); + }); + + it('clean() leaves a live aiState untouched while the WS connection is healthy', () => { + const { channel } = setupChannel(); + channel.getClient().wsConnection = { isHealthy: true }; + channel._handleChannelEvent({ + type: 'ai_indicator.update', + ai_state: 'AI_STATE_GENERATING', + }); + + channel.clean(); + + expect(channel.state.getLatestValue().aiState).to.equal('AI_STATE_GENERATING'); + }); + + it('closeConnection resets aiState to Idle across active channels', async () => { + const { channel } = setupChannel(); + const client = channel.getClient(); + client.activeChannels[channel.cid] = channel; + channel._handleChannelEvent({ + type: 'ai_indicator.update', + ai_state: 'AI_STATE_GENERATING', + }); + expect(channel.state.getLatestValue().aiState).to.equal('AI_STATE_GENERATING'); + + await client.closeConnection(); + + expect(channel.state.getLatestValue().aiState).to.equal('AI_STATE_IDLE'); + }); + + it('clean() does not reset when the primary WS is down but the fallback is healthy', () => { + const { channel } = setupChannel(); + channel.getClient().wsConnection = null; + channel.getClient().wsFallback = { isHealthy: () => true }; + channel._handleChannelEvent({ + type: 'ai_indicator.update', + ai_state: 'AI_STATE_GENERATING', + }); + + channel.clean(); + + expect(channel.state.getLatestValue().aiState).to.equal('AI_STATE_GENERATING'); + }); + + it('resetAIState() is a no-op (no notify) when already Idle', () => { + const { channel } = setupChannel(); + const seen = []; + channel.state.subscribeWithSelector( + (s) => ({ aiState: s.aiState }), + ({ aiState }) => seen.push(aiState), + ); + seen.length = 0; // drop the initial subscribe emission + + channel.state.resetAIState(); + + expect(seen).to.have.length(0); + }); + + it('falls back to Idle when ai_indicator.update carries no ai_state', () => { + const { channel } = setupChannel(); + channel._handleChannelEvent({ type: 'ai_indicator.update' }); + expect(channel.state.getLatestValue().aiState).to.equal('AI_STATE_IDLE'); + }); +}); + describe('Channel localized unread count (isLocalUnreadCountEnabled)', function () { const user = { id: 'user' }; const otherUser = { id: 'other-user' }; From 0da6c8fa2fe6b45d6dbcbd702323ec83331eae4d Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 17 Aug 2026 15:43:21 +0200 Subject: [PATCH 22/31] feat: introduce isDirectChannel state --- src/channel_state.ts | 31 ++++++++++++++++++++++- test/unit/channel_state.test.js | 44 +++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/channel_state.ts b/src/channel_state.ts index fd64d80fb4..b5cda6fcb8 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -40,9 +40,25 @@ export type ReadState = { export type MembersState = { members: Record; + /** + * Total member count, sourced from `channel.data.member_count`. Set on query / watch / hydration + * (incl. offline-DB rehydration) and on `channel.updated` — deliberately NOT on `member.added` / + * `member.removed`. `channel.updated` fires on every member change and is the authoritative count; + * incrementing per member event double-counted when several members changed at once. + * See https://github.com/GetStream/stream-chat-js/pull/1761. + */ memberCount: number; }; +/** + * Whether this is a 1:1 direct channel, derived from `memberCount === 2`. Kept as its own slice so a + * consumer (e.g. the message footer) can subscribe to just this boolean and never re-render on the + * far more frequent `members` churn (presence, watchers, adds/removes). + */ +export type DirectChannelState = { + isDirectChannel: boolean; +}; + /** The current user's own membership in this channel (role, pinned_at, archived_at, …). */ export type MembershipState = { membership: ChannelMemberResponse; @@ -134,6 +150,7 @@ export type ChannelStateData = WatcherState & TypingUsersState & ReadState & MembersState & + DirectChannelState & MembershipState & OwnCapabilitiesState & ChannelDataState & @@ -162,6 +179,7 @@ export class ChannelState extends StateStore { read: {}, members: {}, memberCount: 0, + isDirectChannel: false, membership: {} as ChannelMemberResponse, ownCapabilities: [], data: channel?.data, @@ -200,7 +218,17 @@ export class ChannelState extends StateStore { } set member_count(memberCount: number) { - this.partialNext({ memberCount }); + this.partialNext({ isDirectChannel: memberCount === 2, memberCount }); + } + + /** + * Non-reactive read of {@link DirectChannelState.isDirectChannel}. Use this when you only need the + * value once at render time and don't want a `useStateStore` subscription; use the reactive slice + * (`useStateStore(channel.state, (s) => ({ isDirectChannel: s.isDirectChannel }))`) when the UI + * must update if the channel flips between 1:1 and group. + */ + get isDirectChannel() { + return this.getLatestValue().isDirectChannel; } get read() { @@ -281,6 +309,7 @@ export class ChannelState extends StateStore { this.partialNext({ data, + isDirectChannel: (memberCount ?? 0) === 2, memberCount: memberCount ?? 0, ownCapabilities: ownCapabilities ?? [], }); diff --git a/test/unit/channel_state.test.js b/test/unit/channel_state.test.js index 5aa67ba84a..24bb36f908 100644 --- a/test/unit/channel_state.test.js +++ b/test/unit/channel_state.test.js @@ -135,6 +135,50 @@ describe('ChannelState member count bridge', () => { }); }); +describe('ChannelState isDirectChannel', () => { + it('is true when memberCount === 2 and false otherwise', () => { + const client = new StreamChat(); + expect( + new Channel(client, 'type', 'a', { member_count: 2 }).state.isDirectChannel, + ).to.equal(true); + expect( + new Channel(client, 'type', 'b', { member_count: 3 }).state.isDirectChannel, + ).to.equal(false); + expect( + new Channel(client, 'type', 'c', { member_count: 1 }).state.isDirectChannel, + ).to.equal(false); + }); + + it('tracks the member_count setter', () => { + const state = new ChannelState(); + expect(state.isDirectChannel).to.equal(false); + + state.member_count = 2; + expect(state.isDirectChannel).to.equal(true); + expect(state.getLatestValue().isDirectChannel).to.equal(true); + + state.member_count = 5; + expect(state.isDirectChannel).to.equal(false); + }); + + it('does NOT change on a members-only update (the perf win)', () => { + const client = new StreamChat(); + const state = new Channel(client, 'type', 'd', { member_count: 2 }).state; + const seen = []; + state.subscribeWithSelector( + (s) => ({ isDirectChannel: s.isDirectChannel }), + ({ isDirectChannel }) => seen.push(isDirectChannel), + ); + seen.length = 0; // drop the initial subscribe emission + + // a members map churn (presence/watchers/etc.) must not re-notify isDirectChannel subscribers + state.members = { alice: { user: { id: 'alice' } }, bob: { user: { id: 'bob' } } }; + + expect(seen).to.have.length(0); + expect(state.isDirectChannel).to.equal(true); + }); +}); + describe('ChannelState read store', () => { it('initializes read store with an empty read map', () => { const state = new ChannelState(); From c6e4a0e364f539f3a70d898a7ecceb2ed7941f08 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 20 Aug 2026 01:07:36 +0200 Subject: [PATCH 23/31] fix: dadress pr comments --- src/channel.ts | 119 +++++++++++++++++--------------- src/channel_state.ts | 65 +++++++++-------- src/client.ts | 8 +-- test/unit/channel.test.js | 60 +++++++++++----- test/unit/channel_state.test.js | 83 +++++++++++++--------- test/unit/client.test.js | 2 +- 6 files changed, 195 insertions(+), 142 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index a9933da837..d57d1d1ec6 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -154,7 +154,7 @@ export class Channel extends ChannelApi { isTyping: boolean; /** Re-entrancy guard for {@link Channel.reload} (mirrors Thread.reload's isLoading guard). */ private _reloading = false; - /** Refcount backing the reactive `active` flag (a shared Channel instance can be mounted more than once). */ + /** Refcount backing the reactive `active` flag (a shared Channel instance can have several consumers). */ private _activeRefCount = 0; push_preferences?: Gen_ChannelPushPreferencesResponse; public readonly configState = new StateStore({}); @@ -704,7 +704,7 @@ export class Channel extends ChannelApi { const previousData = this.data; const data = await super.update(...args); this.data = data.channel; - this._syncStateFromChannelData(this.data, previousData); + this.state.syncStateFromChannelData(this.data, previousData); return data; } @@ -734,7 +734,7 @@ export class Channel extends ChannelApi { const previousData = this.data; this.data = channel; - this._syncStateFromChannelData(this.data, previousData); + this.state.syncStateFromChannelData(this.data, previousData); // If the capabiltities are changed, we trigger the `capabilities.changed` event. if (capabilitiesChanged) { this.getClient().dispatchEvent({ @@ -1393,8 +1393,14 @@ export class Channel extends ChannelApi { } /** - * Whether the channel has been torn down / evicted (deleted, or the current user removed). - * Store-backed and reactive. + * Whether the channel has been torn down / evicted (deleted, the current user removed, or the + * client disconnected). Store-backed and reactive. + * + * One-way and terminal — there is no counterpart that revives the instance. A disconnected + * `Channel` is disposed of: it is skipped by the `client.activeChannels` lookups (so + * `client.channel(…)` mints a fresh instance), never re-watched on recovery, refused as a source + * of `channel.data` by the offline DB, and `getClient()` throws on it so a reference held across a + * `disconnectUser()` fails loudly instead of quietly requesting on a client with no user. */ get disconnected() { return this.state.getLatestValue().disconnected; @@ -1405,7 +1411,8 @@ export class Channel extends ChannelApi { } /** - * Whether the channel is currently mounted / actively viewed on-screen. Reactive — subscribe via + * Whether a consumer has declared this channel as the one it is currently consuming (see + * {@link Channel.activate}). Reactive — subscribe via * `useStateStore(channel.state, (s) => ({ active: s.active }))`. */ get active() { @@ -1413,10 +1420,12 @@ export class Channel extends ChannelApi { } /** - * Marks the channel as actively viewed on-screen (UI-driven; mirrors `thread.activate()`). - * Refcounted so a Channel instance mounted in several places stays active until the last unmount. - * While active the channel auto-marks messages read and its message list is not re-seeded by - * channel-list hydration (its own `channel.reload()` owns that window). + * Declares that a consumer is now consuming this channel's own state (mirrors + * `thread.activate()`). Refcounted, as a single `Channel` instance can be held by several + * consumers at once, so it stays active until the last one deactivates. + * + * While active, the channel's own state takes precedence over bulk state writes: channel-list + * hydration does not re-seed its message list (its own `channel.reload()` owns that window). */ activate = () => { this._activeRefCount += 1; @@ -1426,8 +1435,8 @@ export class Channel extends ChannelApi { }; /** - * Marks the channel as no longer actively viewed (mirrors `thread.deactivate()`). Only flips - * `active` back to `false` once the last holder deactivates. + * Declares that a consumer has stopped consuming this channel (mirrors `thread.deactivate()`). + * Only flips `active` back to `false` once the last holder deactivates. */ deactivate = () => { if (this._activeRefCount === 0) return; @@ -1536,7 +1545,7 @@ export class Channel extends ChannelApi { this.initialized = true; const previousData = this.data; this.data = state.channel; - this._syncStateFromChannelData(this.data, previousData); + this.state.syncStateFromChannelData(this.data, previousData); // The message paginator is seeded synchronously inside query() (before read-state hydration), // so a channel opened via watch() alone — a deep-link restore, a search result, a freshly @@ -1912,7 +1921,7 @@ export class Channel extends ChannelApi { .join(); const previousData = this.data; this.data = channel; - this._syncStateFromChannelData(this.data, previousData); + this.state.syncStateFromChannelData(this.data, previousData); this.offlineMode = false; this.cooldownTimer.refresh(); @@ -2202,16 +2211,16 @@ export class Channel extends ChannelApi { let hasStateChanged = false; this.messageReceiptsTracker.setPendingReadStoreReconcileMeta(reconcileMeta); - this.state.next((currentReadStoreState) => { - const nextReadState = patch(currentReadStoreState.read); + this.state.next((currentState) => { + const nextReadState = patch(currentState.read); - if (nextReadState === currentReadStoreState.read) { - return currentReadStoreState; + if (nextReadState === currentState.read) { + return currentState; } hasStateChanged = true; return { - ...currentReadStoreState, + ...currentState, read: nextReadState, }; }); @@ -2246,17 +2255,15 @@ export class Channel extends ChannelApi { } /** - * Resets the current user's unread count consistently across BOTH `state.unreadCount` and the - * reactive `read[userId].unread_messages` — the latter is what the unread badge actually reads. - * Channel-wide resets (`channel.truncated`, "all channels read") historically wrote only the - * former, leaving the badge stale (TODO #29); routing them through here keeps the two in sync. + * Sets the current user's unread count. The count lives in exactly one place — the reactive + * `read[userId].unread_messages`, which is what the unread badge reads and what + * `state.unreadCount` derives from — so channel-wide resets (`channel.truncated`, "all channels + * read") must route through here rather than writing a count of their own. */ _setOwnUnreadCount(unreadCount: number) { if (this.disconnected) return; - this.state.unreadCount = unreadCount; - - const userId = this.getClient().userID; + const userId = this.getClient().userId; if (!userId) return; const currentUserReadState = this.state.read[userId]; @@ -2351,7 +2358,6 @@ export class Channel extends ChannelApi { const isOwnEvent = event.user?.id === client.user?.id; if (isOwnEvent) { - channelState.unreadCount = 0; // Delivery reporting buffers a `markChannelsDelivered` network request; the local read // must not hit the backend, so only sync for the real server `message.read`. if (event.type === 'message.read') { @@ -2481,15 +2487,26 @@ export class Channel extends ChannelApi { } if (preventUnreadCountUpdate) break; + // The own unread count IS `read[ownUserId].unread_messages` (see + // `ChannelState.unreadCount`), so the own row is what carries the own-unread gating: a + // message that does not count as unread (silent/shadowed/muted) never bumps it, and neither + // does one arriving while the user is viewing the latest messages — the SDK is about to mark + // it read itself, and the count/snapshot would flash until it does. The other users' rows + // are receipt bookkeeping and keep their blanket bump. + const countsAsOwnUnread = + !this.messagePaginator.isViewingLive && + this._countMessageAsUnread(event.message); + if (event.user?.id) { const eventUser = event.user; const eventUserId = eventUser.id; const createdAt = new Date(event.created_at ?? Date.now()); const eventMessageId = event.message.id; + const ownUserId = client.userId; this._patchReadState( (currentReadState) => { const userIds = Object.keys(currentReadState); - if (!userIds.length) return currentReadState; + if (!userIds.length && !countsAsOwnUnread) return currentReadState; const nextReadState = { ...currentReadState }; @@ -2502,6 +2519,9 @@ export class Channel extends ChannelApi { last_delivered_at: createdAt, last_delivered_message_id: eventMessageId, }; + } else if (userId === ownUserId && !countsAsOwnUnread) { + // does not count towards the own unread count — leave the row untouched + continue; } else { nextReadState[userId] = { ...currentReadState[userId], @@ -2511,22 +2531,26 @@ export class Channel extends ChannelApi { } } + // Seed the own row when the channel has none yet — an uninitialized channel (see + // regression #1732) or one queried with `state: false`, where `_initializeState` + // never ran to seed it. Without this the own count has nowhere to live and stops + // accumulating. `last_read` is epoch: nothing has been read, which is exactly how + // every "no last read" consumer already treats a missing value. + if (ownUserId && countsAsOwnUnread && !currentReadState[ownUserId]) { + nextReadState[ownUserId] = { + last_read: new Date(0), + unread_messages: 1, + user: (client.user ?? { id: ownUserId }) as UserResponse, + }; + } + return nextReadState; }, { changedUserIds: Object.keys(channelState.read) }, ); } - // Skip the own-unread bump when the user is actively viewing the latest messages (app - // foregrounded + newest message on screen). Without this, a message read in real time - // momentarily bumps `unreadCount`/the snapshot — the "N new" separator/banner + the - // channel-list badge would flash until the SDK's mark-read resets it. The SDK reports the - // viewing state via `messagePaginator.setViewingLive` and marks the message read itself. - // Only the OWN unread accounting is gated; the per-user read/receipt tracking above is - // intentionally left intact. - const isViewingLive = this.messagePaginator.isViewingLive; - if (!isViewingLive && this._countMessageAsUnread(event.message)) { - channelState.unreadCount = channelState.unreadCount + 1; + if (countsAsOwnUnread) { this.messagePaginator.setUnreadSnapshot({ unreadCount: channelState.unreadCount, }); @@ -2645,8 +2669,6 @@ export class Channel extends ChannelApi { lastReadMessageId: channelState.read[event.user.id].last_read_message_id, unreadCount, }); - - channelState.unreadCount = unreadCount; break; } case 'channel.updated': @@ -2666,7 +2688,7 @@ export class Channel extends ChannelApi { event.channel?.own_capabilities ?? channel.data?.own_capabilities, }; channel.data = newChannelData; - channel._syncStateFromChannelData(channel.data, previousChannelData); + channel.state.syncStateFromChannelData(channel.data, previousChannelData); this.cooldownTimer.refresh(); } break; @@ -2730,7 +2752,7 @@ export class Channel extends ChannelApi { blocked: event.channel?.blocked ?? false, hidden: true, }; - channel._syncStateFromChannelData(channel.data, previousChannelData); + channel.state.syncStateFromChannelData(channel.data, previousChannelData); if (event.clear_history) { this.messagePaginator.clearStateAndCache(); this.pinnedMessagesPaginator.clearStateAndCache(); @@ -2745,7 +2767,7 @@ export class Channel extends ChannelApi { blocked: event.channel?.blocked ?? false, hidden: false, }; - channel._syncStateFromChannelData(channel.data, previousChannelData); + channel.state.syncStateFromChannelData(channel.data, previousChannelData); this.getClient().offlineDb?.handleChannelVisibilityEvent({ event }); break; } @@ -2808,13 +2830,6 @@ export class Channel extends ChannelApi { } } - _syncStateFromChannelData( - data: Channel['data'], - fallbackData: Channel['data'] = this.data, - ) { - this.state.syncStateFromChannelData(data, fallbackData); - } - _initializeState(state: ChannelStateResponseFields) { const { state: clientState, user, userID } = this.getClient(); @@ -2889,10 +2904,6 @@ export class Channel extends ChannelApi { unread_messages: read.unread_messages ?? 0, user: read.user, }; - - if (read.user.id === user?.id) { - this.state.unreadCount = readUpdates[read.user.id].unread_messages; - } } } diff --git a/src/channel_state.ts b/src/channel_state.ts index b5cda6fcb8..98868ce53a 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -50,15 +50,6 @@ export type MembersState = { memberCount: number; }; -/** - * Whether this is a 1:1 direct channel, derived from `memberCount === 2`. Kept as its own slice so a - * consumer (e.g. the message footer) can subscribe to just this boolean and never re-render on the - * far more frequent `members` churn (presence, watchers, adds/removes). - */ -export type DirectChannelState = { - isDirectChannel: boolean; -}; - /** The current user's own membership in this channel (role, pinned_at, archived_at, …). */ export type MembershipState = { membership: ChannelMemberResponse; @@ -113,17 +104,26 @@ export type ChannelLifecycleState = { * being watched yet. */ offlineMode: boolean; - /** Whether the channel has been torn down / evicted (deleted, or the current user removed). */ + /** + * Whether the channel has been torn down / evicted (deleted, the current user removed, or the + * client disconnected). One-way and terminal — see {@link Channel.disconnected}: the instance is + * disposed of, never revived. + */ disconnected: boolean; }; -/** UI-driven channel lifecycle state (not returned by the API; set by the UI SDK). */ -export type ChannelUIState = { +/** + * Whether the channel is currently being consumed — consumer-declared, never returned by the API. + * Mirrors `thread.state.active`. + */ +export type ChannelActivationState = { /** - * Whether the channel is currently mounted / actively viewed on-screen. UI-driven and - * refcounted via `channel.activate()` / `channel.deactivate()`. Channel-list hydration does NOT - * re-seed the message list of an `active` channel (the channel's own `channel.reload()` owns that - * window). + * Whether a consumer has declared this channel as the one it is currently reading, via + * `channel.activate()` / `channel.deactivate()` (refcounted, as a single `Channel` instance can + * be held by several consumers at once). It carries no rendering semantics; it tells the client + * that the channel's own state is being consumed and takes precedence over bulk state writes — + * channel-list hydration does not re-seed the message list of an `active` channel (the channel's + * own `channel.reload()` owns that window). */ active: boolean; }; @@ -150,13 +150,12 @@ export type ChannelStateData = WatcherState & TypingUsersState & ReadState & MembersState & - DirectChannelState & MembershipState & OwnCapabilitiesState & ChannelDataState & MuteStatusState & ChannelLifecycleState & - ChannelUIState & + ChannelActivationState & AIIndicatorState; /** @@ -169,7 +168,6 @@ export type ChannelStateData = WatcherState & export class ChannelState extends StateStore { _channel: Channel; pending_messages: Array; - unreadCount: number; constructor(channel: Channel) { super({ @@ -179,7 +177,6 @@ export class ChannelState extends StateStore { read: {}, members: {}, memberCount: 0, - isDirectChannel: false, membership: {} as ChannelMemberResponse, ownCapabilities: [], data: channel?.data, @@ -193,7 +190,20 @@ export class ChannelState extends StateStore { this._channel = channel; this.syncStateFromChannelData(channel?.data); this.pending_messages = []; - this.unreadCount = 0; + } + + /** + * The current user's unread message count, derived from the `read` slice + * (`read[ownUserId].unread_messages`) rather than stored a second time — there is exactly one + * place holding this value, so the count the unread badge reads can never drift from the count + * `channel.countUnread()` returns. Written through `Channel._setOwnUnreadCount`. + */ + get unreadCount() { + // read `_client` directly: `getClient()` throws on a disconnected channel, and reading a count + // off a disposed instance should yield 0, not blow up. + const userId = this._channel?._client?.userId; + if (!userId) return 0; + return this.getLatestValue().read[userId]?.unread_messages ?? 0; } /** The current user's own membership; store-backed so `useStateStore` can subscribe to it. */ @@ -218,17 +228,7 @@ export class ChannelState extends StateStore { } set member_count(memberCount: number) { - this.partialNext({ isDirectChannel: memberCount === 2, memberCount }); - } - - /** - * Non-reactive read of {@link DirectChannelState.isDirectChannel}. Use this when you only need the - * value once at render time and don't want a `useStateStore` subscription; use the reactive slice - * (`useStateStore(channel.state, (s) => ({ isDirectChannel: s.isDirectChannel }))`) when the UI - * must update if the channel flips between 1:1 and group. - */ - get isDirectChannel() { - return this.getLatestValue().isDirectChannel; + this.partialNext({ memberCount }); } get read() { @@ -309,7 +309,6 @@ export class ChannelState extends StateStore { this.partialNext({ data, - isDirectChannel: (memberCount ?? 0) === 2, memberCount: memberCount ?? 0, ownCapabilities: ownCapabilities ?? [], }); diff --git a/src/client.ts b/src/client.ts index 33eff4aac0..ebee866212 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1027,8 +1027,8 @@ export class StreamChat extends ChatApi { if (event.type === 'notification.mark_read' && event.unread_channels === 0) { const activeChannelKeys = Object.keys(this.activeChannels); activeChannelKeys.forEach((activeChannelKey) => - // resets both `state.unreadCount` and `read[userId].unread_messages` so the badge, which - // reads the latter, does not stay stale (TODO #29). + // resets `read[userId].unread_messages`, which is what the unread badge reads, so it does + // not stay stale. this.activeChannels[activeChannelKey]._setOwnUnreadCount(0), ); } @@ -1470,7 +1470,7 @@ export class StreamChat extends ChatApi { const c = this.channel(channelState.channel.type, channelState.channel.id); const previousData = c.data; c.data = channelState.channel; - c._syncStateFromChannelData(c.data, previousData); + c.state.syncStateFromChannelData(c.data, previousData); c.offlineMode = offlineMode; c.initialized = !offlineMode; c.push_preferences = channelState.push_preferences; @@ -1729,7 +1729,7 @@ export class StreamChat extends ChatApi { if (custom.custom !== undefined) { const previousData = channel.data; channel.data = { ...channel.data, custom: custom.custom }; - channel._syncStateFromChannelData(channel.data, previousData); + channel.state.syncStateFromChannelData(channel.data, previousData); channel._data = { ...channel._data, custom: custom.custom }; } return channel; diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 7e605318f1..5839a9d5a3 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -25,6 +25,21 @@ const seedLatestWindow = (channel, messages) => setActive: true, }); +// The own unread count is derived from `read[ownUserId].unread_messages` (there is no separate +// counter to assign), so seed it through the own read row. +const seedOwnUnreadCount = (channel, unread_messages) => { + const ownUser = channel.getClient().user; + channel.state.read = { + ...channel.state.read, + [ownUser.id]: { + last_read: new Date(0), + user: ownUser, + ...channel.state.read[ownUser.id], + unread_messages, + }, + }; +}; + describe('Channel count unread', function () { let lastRead; let ignoredMessages; @@ -119,9 +134,9 @@ describe('Channel count unread', function () { it('countUnread should return state.unreadCount without lastRead', function () { expect(channel.countUnread()).to.be.equal(channel.state.unreadCount); - channel.state.unreadCount = 10; + seedOwnUnreadCount(channel, 10); expect(channel.countUnread()).to.be.equal(10); - channel.state.unreadCount = 0; + seedOwnUnreadCount(channel, 0); }); it('countUnread should return correct count', function () { @@ -243,7 +258,6 @@ describe('Channel isViewingLive (unread bump gating)', function () { const channel = client.channel('messaging', 'live-mode-id'); channel.initialized = true; channel.data = { ...channel.data, own_capabilities: ['read-events'] }; - channel.state.unreadCount = 0; return { channel }; }; @@ -454,7 +468,6 @@ describe('Channel localized unread count (isLocalUnreadCountEnabled)', function it('message.new increments the unread count with read events off when the flag is set', function () { const { channel } = setupChannel({ isLocalUnreadCountEnabled: true }); - channel.state.unreadCount = 0; channel._handleChannelEvent({ type: 'message.new', @@ -471,9 +484,25 @@ describe('Channel localized unread count (isLocalUnreadCountEnabled)', function expect(channel.countUnread()).to.be.equal(2); }); + it('seeds the own read row when a message counts as unread and the channel has none yet', function () { + const { channel } = setupChannel({ isLocalUnreadCountEnabled: true }); + expect(channel.state.read[user.id]).to.be.undefined; + + channel._handleChannelEvent({ + type: 'message.new', + user: otherUser, + message: generateMsg({ user: otherUser }), + }); + + // the count lives in the read row, so the row has to exist for it to be counted at all + expect(channel.state.read[user.id]).to.be.ok; + expect(channel.state.read[user.id].unread_messages).to.be.equal(1); + expect(channel.state.read[user.id].user.id).to.be.equal(user.id); + expect(channel.state.read[user.id].last_read.getTime()).to.be.equal(0); + }); + it('message.new does not increment the unread count with read events off when the flag is not set', function () { const { channel } = setupChannel({ isLocalUnreadCountEnabled: false }); - channel.state.unreadCount = 0; channel._handleChannelEvent({ type: 'message.new', @@ -491,7 +520,6 @@ describe('Channel localized unread count (isLocalUnreadCountEnabled)', function .mockResolvedValue({ body: {}, metadata: {} }); const lastMsg = generateMsg({ user: otherUser }); seedLatestWindow(channel, [lastMsg]); - channel.state.unreadCount = 5; channel.state.read[user.id] = { last_read: new Date('2020-01-01T00:00:00'), unread_messages: 5, @@ -545,7 +573,6 @@ describe('Channel localized unread count (isLocalUnreadCountEnabled)', function .mockResolvedValue({ body: {}, metadata: {} }); const lastMsg = generateMsg({ user: otherUser }); seedLatestWindow(channel, [lastMsg]); - channel.state.unreadCount = 3; delete channel.state.read[user.id]; channel.markReadLocally(); @@ -671,7 +698,7 @@ describe('Channel _handleChannelEvent', function () { describe('message.new', () => { it('message.new does not reset the unreadCount for current user messages', function () { - channel.state.unreadCount = 100; + seedOwnUnreadCount(channel, 100); channel._handleChannelEvent({ type: 'message.new', user, @@ -682,7 +709,7 @@ describe('Channel _handleChannelEvent', function () { }); it('message.new does not reset the unreadCount for own thread replies', function () { - channel.state.unreadCount = 100; + seedOwnUnreadCount(channel, 100); channel._handleChannelEvent({ type: 'message.new', user, @@ -697,7 +724,7 @@ describe('Channel _handleChannelEvent', function () { }); it('message.new does not reset the unreadCount for others thread replies', function () { - channel.state.unreadCount = 100; + seedOwnUnreadCount(channel, 100); channel._handleChannelEvent({ type: 'message.new', user: { id: 'id' }, @@ -740,7 +767,7 @@ describe('Channel _handleChannelEvent', function () { }); it('message.new increment unreadCount properly', function () { - channel.state.unreadCount = 20; + seedOwnUnreadCount(channel, 20); channel._handleChannelEvent({ type: 'message.new', user: { id: 'id' }, @@ -756,7 +783,7 @@ describe('Channel _handleChannelEvent', function () { }); it('message.new skip increment for silent/shadowed/muted messages', function () { - channel.state.unreadCount = 30; + seedOwnUnreadCount(channel, 30); channel._handleChannelEvent({ type: 'message.new', user: { id: 'id' }, @@ -1101,7 +1128,6 @@ describe('Channel _handleChannelEvent', function () { user: { id: userId }, }, }; - channel.state.unreadCount = 5; channel._handleChannelEvent({ type: 'channel.truncated', @@ -1637,7 +1663,6 @@ describe('Channel _handleChannelEvent', function () { }); it('should update channel read state produced for current user', () => { - channel.state.unreadCount = initialCountUnread; channel.state.read[user.id] = initialReadState; const event = notificationMarkUnreadEvent; @@ -1686,7 +1711,6 @@ describe('Channel _handleChannelEvent', function () { }); it('should not update channel read state produced for another user or user is missing', () => { - channel.state.unreadCount = initialCountUnread; channel.state.read[user.id] = initialReadState; const { user: excludedUser, ...eventMissingUser } = notificationMarkUnreadEvent; const eventWithAnotherUser = { @@ -1740,7 +1764,6 @@ describe('Channel _handleChannelEvent', function () { }); it('should update channel read state produced for current user', () => { - channel.state.unreadCount = initialCountUnread; channel.state.read[user.id] = initialReadState; const event = messageReadEvent; @@ -1768,7 +1791,7 @@ describe('Channel _handleChannelEvent', function () { it('should update channel read state produced for another user', () => { const anotherUser = { id: 'another-user' }; - channel.state.unreadCount = initialCountUnread; + seedOwnUnreadCount(channel, initialCountUnread); channel.state.read[anotherUser.id] = initialReadState; const event = { ...messageReadEvent, user: anotherUser }; @@ -1845,7 +1868,6 @@ describe('Channel _handleChannelEvent', function () { }); it('should update channel read state produced for current user', () => { - channel.state.unreadCount = initialCountUnread; channel.state.read[user.id] = initialReadState; channel._handleChannelEvent(messageDeliveredEvent); @@ -1893,7 +1915,7 @@ describe('Channel _handleChannelEvent', function () { it('should update channel read state produced for another user', () => { const anotherUser = { id: 'another-user' }; - channel.state.unreadCount = initialCountUnread; + seedOwnUnreadCount(channel, initialCountUnread); channel.state.read[anotherUser.id] = initialReadState; const event = { ...messageDeliveredEvent, user: anotherUser }; diff --git a/test/unit/channel_state.test.js b/test/unit/channel_state.test.js index 24bb36f908..c008e4cd91 100644 --- a/test/unit/channel_state.test.js +++ b/test/unit/channel_state.test.js @@ -135,47 +135,23 @@ describe('ChannelState member count bridge', () => { }); }); -describe('ChannelState isDirectChannel', () => { - it('is true when memberCount === 2 and false otherwise', () => { - const client = new StreamChat(); - expect( - new Channel(client, 'type', 'a', { member_count: 2 }).state.isDirectChannel, - ).to.equal(true); - expect( - new Channel(client, 'type', 'b', { member_count: 3 }).state.isDirectChannel, - ).to.equal(false); - expect( - new Channel(client, 'type', 'c', { member_count: 1 }).state.isDirectChannel, - ).to.equal(false); - }); - - it('tracks the member_count setter', () => { - const state = new ChannelState(); - expect(state.isDirectChannel).to.equal(false); - - state.member_count = 2; - expect(state.isDirectChannel).to.equal(true); - expect(state.getLatestValue().isDirectChannel).to.equal(true); - - state.member_count = 5; - expect(state.isDirectChannel).to.equal(false); - }); - - it('does NOT change on a members-only update (the perf win)', () => { +describe('ChannelState memberCount subscribers', () => { + it('does NOT re-notify memberCount subscribers on a members-only update', () => { const client = new StreamChat(); const state = new Channel(client, 'type', 'd', { member_count: 2 }).state; const seen = []; state.subscribeWithSelector( - (s) => ({ isDirectChannel: s.isDirectChannel }), - ({ isDirectChannel }) => seen.push(isDirectChannel), + (s) => ({ memberCount: s.memberCount }), + ({ memberCount }) => seen.push(memberCount), ); seen.length = 0; // drop the initial subscribe emission - // a members map churn (presence/watchers/etc.) must not re-notify isDirectChannel subscribers + // a members map churn (presence/watchers/etc.) must not re-notify memberCount subscribers — + // this is what lets a consumer derive e.g. "is this a 1:1 channel" without paying for it state.members = { alice: { user: { id: 'alice' } }, bob: { user: { id: 'bob' } } }; expect(seen).to.have.length(0); - expect(state.isDirectChannel).to.equal(true); + expect(state.member_count).to.equal(2); }); }); @@ -204,6 +180,51 @@ describe('ChannelState read store', () => { }); }); +describe('ChannelState unreadCount', () => { + let client; + let channel; + + beforeEach(() => { + client = new StreamChat('apiKey'); + client.user = { id: 'me' }; + channel = new Channel(client, 'messaging', 'unread-count-id', {}); + }); + + it('derives the own count from the read state instead of storing it separately', () => { + expect(channel.state.unreadCount).to.equal(0); + + channel.state.read = { + me: { last_read: new Date(0), unread_messages: 7, user: { id: 'me' } }, + alice: { last_read: new Date(0), unread_messages: 3, user: { id: 'alice' } }, + }; + + expect(channel.state.unreadCount).to.equal(7); + expect(channel.countUnread()).to.equal(7); + }); + + it('is 0 while the current user has no read row', () => { + channel.state.read = { + alice: { last_read: new Date(0), unread_messages: 3, user: { id: 'alice' } }, + }; + + expect(channel.state.unreadCount).to.equal(0); + }); + + it('is 0 without a connected user, and does not throw on a disconnected channel', () => { + client.user = undefined; + expect(channel.state.unreadCount).to.equal(0); + + client.user = { id: 'me' }; + channel.state.read = { + me: { last_read: new Date(0), unread_messages: 4, user: { id: 'me' } }, + }; + channel.disconnected = true; + + expect(() => channel.state.unreadCount).not.to.throw(); + expect(channel.state.unreadCount).to.equal(4); + }); +}); + describe('ChannelState watcher count store', () => { it('initializes watcher count store with zero', () => { const state = new ChannelState(); diff --git a/test/unit/client.test.js b/test/unit/client.test.js index e99bfe7c1e..436bd502b5 100644 --- a/test/unit/client.test.js +++ b/test/unit/client.test.js @@ -900,7 +900,7 @@ describe('StreamChat.queryChannels', async () => { member_count: 8, own_capabilities: ['send-message'], }; - channel._syncStateFromChannelData(channel.data, previousData); + channel.state.syncStateFromChannelData(channel.data, previousData); expect(channel.state.member_count).to.equal(8); expect(channel.state.getLatestValue()).to.deep.include({ From fbdb99c3845c77abd639705a6105cd8d529733d2 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 20 Aug 2026 01:54:05 +0200 Subject: [PATCH 24/31] fix: rename channel.disconnected --- src/channel.ts | 49 ++++++++++++------- src/channel_state.ts | 18 +++---- src/client.ts | 4 +- src/offline-support/offline_support_api.ts | 2 +- src/pagination/paginators/MessagePaginator.ts | 4 +- test/unit/channel_state.test.js | 23 +++++++-- .../offline_support_api.test.ts | 4 +- 7 files changed, 67 insertions(+), 37 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index d57d1d1ec6..e55493ddf4 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -297,7 +297,7 @@ export class Channel extends ChannelApi { * @returns The chat client. */ getClient(): StreamChat { - if (this.disconnected === true) { + if (this.pendingDisposal === true) { throw Error(`You can't use a channel after client.disconnect() was called`); } return this._client; @@ -1149,7 +1149,7 @@ export class Channel extends ChannelApi { * updates. Unlike `muteStatus()`, this does not require the channel to be initialized. */ _syncMuteStatus() { - if (this.disconnected) return; + if (this.pendingDisposal) return; const next = this.getClient()._muteStatus(this.cid); const previous = this.state.getLatestValue().muteStatus; @@ -1393,21 +1393,36 @@ export class Channel extends ChannelApi { } /** - * Whether the channel has been torn down / evicted (deleted, the current user removed, or the - * client disconnected). Store-backed and reactive. + * Whether the channel has been torn down and is awaiting disposal (deleted, the current user + * removed, or the client disconnected). Store-backed and reactive. * - * One-way and terminal — there is no counterpart that revives the instance. A disconnected - * `Channel` is disposed of: it is skipped by the `client.activeChannels` lookups (so - * `client.channel(…)` mints a fresh instance), never re-watched on recovery, refused as a source - * of `channel.data` by the offline DB, and `getClient()` throws on it so a reference held across a - * `disconnectUser()` fails loudly instead of quietly requesting on a client with no user. + * One-way and terminal — there is no counterpart that revives the instance. Its resources are + * already released ({@link Channel._disconnect} disposes the paginators and unregisters the + * subscriptions) and the client drops it from `activeChannels` right after, so nothing should + * touch it: it is skipped by the `client.activeChannels` lookups (so `client.channel(…)` mints a + * fresh instance), never re-watched on recovery, refused as a source of `channel.data` by the + * offline DB, and `getClient()` throws on it so a reference held across a `disconnectUser()` + * fails loudly instead of quietly requesting on a client with no user. + */ + get pendingDisposal() { + return this.state.getLatestValue().pendingDisposal; + } + + set pendingDisposal(pendingDisposal: boolean) { + this.state.partialNext({ pendingDisposal }); + } + + /** + * @deprecated Renamed to {@link Channel.pendingDisposal} — the flag is one-way and the instance is + * never reconnected, which the old name implied. This alias proxies the same state and will be + * removed in the next major. */ get disconnected() { - return this.state.getLatestValue().disconnected; + return this.pendingDisposal; } - set disconnected(disconnected: boolean) { - this.state.partialNext({ disconnected }); + set disconnected(pendingDisposal: boolean) { + this.pendingDisposal = pendingDisposal; } /** @@ -2261,7 +2276,7 @@ export class Channel extends ChannelApi { * read") must route through here rather than writing a count of their own. */ _setOwnUnreadCount(unreadCount: number) { - if (this.disconnected) return; + if (this.pendingDisposal) return; const userId = this.getClient().userId; if (!userId) return; @@ -2978,14 +2993,14 @@ export class Channel extends ChannelApi { _disconnect() { logger.withExtraTags('_disconnect', this.cid).info('Disconnecting the channel.'); - // Tear down the channel.state subscriptions BEFORE flipping `disconnected` — that setter now - // publishes to the store, so no subscriber handler runs against a half-torn-down channel. + // Tear down the channel.state subscriptions BEFORE flipping `pendingDisposal` — that setter + // now publishes to the store, so no subscriber handler runs against a half-torn-down channel. this.messageReceiptsTracker.unregisterSubscriptions(); - this.disconnected = true; + this.pendingDisposal = true; this.cooldownTimer.clearTimeout(); // Release the store-backed paginators so the message store no longer pins this removed channel // (and its whole message graph) through its subscriber registry. The channel is being discarded - // here (disconnected + deleted from activeChannels, never reused), mirroring Thread teardown. + // here (pending disposal + deleted from activeChannels, never reused), mirroring Thread teardown. this.messagePaginator.dispose(); this.pinnedMessagesPaginator.dispose(); } diff --git a/src/channel_state.ts b/src/channel_state.ts index 98868ce53a..9aab0a4af0 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -88,8 +88,8 @@ export type MuteStatusState = { /** * Connection / initialization lifecycle flags for the channel. Previously plain fields on `Channel`; * now store-backed so consumers can react to them via `useStateStore(channel.state, selector)`. - * Read/written through the `channel.initialized` / `channel.offlineMode` / `channel.disconnected` - * getters/setters, which proxy this slice. + * Read/written through the `channel.initialized` / `channel.offlineMode` / + * `channel.pendingDisposal` getters/setters, which proxy this slice. */ export type ChannelLifecycleState = { /** @@ -105,11 +105,11 @@ export type ChannelLifecycleState = { */ offlineMode: boolean; /** - * Whether the channel has been torn down / evicted (deleted, the current user removed, or the - * client disconnected). One-way and terminal — see {@link Channel.disconnected}: the instance is - * disposed of, never revived. + * Whether the channel has been torn down and is awaiting disposal (deleted, the current user + * removed, or the client disconnected). One-way and terminal — see + * {@link Channel.pendingDisposal}: the instance is never revived. */ - disconnected: boolean; + pendingDisposal: boolean; }; /** @@ -183,7 +183,7 @@ export class ChannelState extends StateStore { muteStatus: { muted: false, createdAt: null, expiresAt: null }, initialized: false, offlineMode: false, - disconnected: false, + pendingDisposal: false, active: false, aiState: AIStates.Idle, }); @@ -199,8 +199,8 @@ export class ChannelState extends StateStore { * `channel.countUnread()` returns. Written through `Channel._setOwnUnreadCount`. */ get unreadCount() { - // read `_client` directly: `getClient()` throws on a disconnected channel, and reading a count - // off a disposed instance should yield 0, not blow up. + // read `_client` directly: `getClient()` throws on a channel pending disposal, and reading a + // count off a dead instance should yield 0, not blow up. const userId = this._channel?._client?.userId; if (!userId) return 0; return this.getLatestValue().read[userId]?.unread_messages ?? 0; diff --git a/src/client.ts b/src/client.ts index ebee866212..fc94ee6f24 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1663,7 +1663,7 @@ export class StreamChat extends ChatApi { // we will replace it with `cid` for (const key in this.activeChannels) { const channel = this.activeChannels[key]; - if (channel.disconnected) { + if (channel.pendingDisposal) { continue; } @@ -1717,7 +1717,7 @@ export class StreamChat extends ChatApi { if ( cid in this.activeChannels && this.activeChannels[cid] && - !this.activeChannels[cid].disconnected + !this.activeChannels[cid].pendingDisposal ) { const channel = this.activeChannels[cid]; // Only overwrite the existing channel's custom data when the caller actually provided some. diff --git a/src/offline-support/offline_support_api.ts b/src/offline-support/offline_support_api.ts index 845b051525..19f6fd944d 100644 --- a/src/offline-support/offline_support_api.ts +++ b/src/offline-support/offline_support_api.ts @@ -588,7 +588,7 @@ export abstract class AbstractOfflineDB implements OfflineDBApi { event_.channel_type, event_.channel_id, ); - if (channelFromState.initialized && !channelFromState.disconnected) { + if (channelFromState.initialized && !channelFromState.pendingDisposal) { channelData = channelFromState.data as unknown as ChannelResponse; } } diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index b1b99a8d94..7d9743f8c7 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -328,9 +328,9 @@ export class MessagePaginator extends MessageIntervalPaginator { seedUnreadSnapshot = () => { // A paginator query (BasePaginator.executeQuery) awaits the network before running its // synchronous postQueryReconcile, which calls this on the first page. If the channel was - // disconnected while that request was in flight, reading the client below throws ("You can't + // torn down while that request was in flight, reading the client below throws ("You can't // use a channel after client.disconnect()"), so guard against that. - if (this.channel.disconnected) return; + if (this.channel.pendingDisposal) return; const ownUserId = this.channel.getClient().user?.id; const ownReadState = ownUserId ? this.channel.state.read[ownUserId] : undefined; if (!ownReadState) return; diff --git a/test/unit/channel_state.test.js b/test/unit/channel_state.test.js index c008e4cd91..413d07cc0d 100644 --- a/test/unit/channel_state.test.js +++ b/test/unit/channel_state.test.js @@ -210,7 +210,7 @@ describe('ChannelState unreadCount', () => { expect(channel.state.unreadCount).to.equal(0); }); - it('is 0 without a connected user, and does not throw on a disconnected channel', () => { + it('is 0 without a connected user, and does not throw on a channel pending disposal', () => { client.user = undefined; expect(channel.state.unreadCount).to.equal(0); @@ -218,7 +218,7 @@ describe('ChannelState unreadCount', () => { channel.state.read = { me: { last_read: new Date(0), unread_messages: 4, user: { id: 'me' } }, }; - channel.disconnected = true; + channel.pendingDisposal = true; expect(() => channel.state.unreadCount).not.to.throw(); expect(channel.state.unreadCount).to.equal(4); @@ -498,7 +498,20 @@ describe('ChannelState unified store', () => { expect(names).to.eql(['orig', 'renamed']); }); - it('proxies the lifecycle flags (initialized/offlineMode/disconnected) through the store', () => { + it('keeps the deprecated `disconnected` alias writing through to `pendingDisposal`', () => { + const client = new StreamChat(); + client.user = { id: 'me' }; + const channel = new Channel(client, 'messaging', 'alias', {}); + + channel.disconnected = true; + expect(channel.pendingDisposal).to.equal(true); + expect(channel.state.getLatestValue().pendingDisposal).to.equal(true); + + channel.pendingDisposal = false; + expect(channel.disconnected).to.equal(false); + }); + + it('proxies the lifecycle flags (initialized/offlineMode/pendingDisposal) through the store', () => { const client = new StreamChat(); client.user = { id: 'me' }; const channel = new Channel(client, 'messaging', 'lifecycle', {}); @@ -506,11 +519,13 @@ describe('ChannelState unified store', () => { expect(channel.initialized).to.equal(false); expect(channel.offlineMode).to.equal(false); + expect(channel.pendingDisposal).to.equal(false); + // the deprecated `disconnected` alias proxies the same slice expect(channel.disconnected).to.equal(false); expect(channel.state.getLatestValue()).to.deep.include({ initialized: false, offlineMode: false, - disconnected: false, + pendingDisposal: false, }); const seen = []; diff --git a/test/unit/offline-support/offline_support_api.test.ts b/test/unit/offline-support/offline_support_api.test.ts index fe10b14280..568f467eb2 100644 --- a/test/unit/offline-support/offline_support_api.test.ts +++ b/test/unit/offline-support/offline_support_api.test.ts @@ -372,7 +372,7 @@ describe('OfflineSupportApi', () => { const mockChannelData = { id: '123', type: 'messaging' }; const mockChannel = { initialized: true, - disconnected: false, + pendingDisposal: false, data: mockChannelData, }; @@ -501,7 +501,7 @@ describe('OfflineSupportApi', () => { const mockChannelData = { id: '123', type: 'messaging' }; const mockChannel = { initialized: true, - disconnected: false, + pendingDisposal: false, data: mockChannelData, }; From b17ea6caed5603f00f576b28f489c2149276c762 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 20 Aug 2026 12:24:27 +0200 Subject: [PATCH 25/31] fix: post merge issue --- src/channel.ts | 4 +--- test/unit/channel.test.js | 14 -------------- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index cc3638a6af..ef6a40c468 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -1521,9 +1521,7 @@ export class Channel extends ChannelApi { // path clears the indicator itself. Gate on health, not staleness — a healthy connection must // never cut off a long-running response. const client = this.getClient(); - const connectionHealthy = - client.wsConnection?.isHealthy || client.wsFallback?.isHealthy(); - if (!connectionHealthy) { + if (!client.wsConnection?.isHealthy) { this.state.resetAIState(); } } diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 5839a9d5a3..b5d61b320f 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -405,20 +405,6 @@ describe('Channel AI indicator state (channel.state.aiState)', function () { expect(channel.state.getLatestValue().aiState).to.equal('AI_STATE_IDLE'); }); - it('clean() does not reset when the primary WS is down but the fallback is healthy', () => { - const { channel } = setupChannel(); - channel.getClient().wsConnection = null; - channel.getClient().wsFallback = { isHealthy: () => true }; - channel._handleChannelEvent({ - type: 'ai_indicator.update', - ai_state: 'AI_STATE_GENERATING', - }); - - channel.clean(); - - expect(channel.state.getLatestValue().aiState).to.equal('AI_STATE_GENERATING'); - }); - it('resetAIState() is a no-op (no notify) when already Idle', () => { const { channel } = setupChannel(); const seen = []; From 2c00558644b65ba5b9e4c4cfca6ef29fbfb2f6e7 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 20 Aug 2026 13:42:42 +0200 Subject: [PATCH 26/31] fix: remove deprecated api --- src/channel.ts | 13 ------------- test/unit/channel_state.test.js | 15 --------------- 2 files changed, 28 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index ef6a40c468..573d2df42c 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -1405,19 +1405,6 @@ export class Channel extends ChannelApi { this.state.partialNext({ pendingDisposal }); } - /** - * @deprecated Renamed to {@link Channel.pendingDisposal} — the flag is one-way and the instance is - * never reconnected, which the old name implied. This alias proxies the same state and will be - * removed in the next major. - */ - get disconnected() { - return this.pendingDisposal; - } - - set disconnected(pendingDisposal: boolean) { - this.pendingDisposal = pendingDisposal; - } - /** * Whether a consumer has declared this channel as the one it is currently consuming (see * {@link Channel.activate}). Reactive — subscribe via diff --git a/test/unit/channel_state.test.js b/test/unit/channel_state.test.js index 413d07cc0d..bb5c3b02ed 100644 --- a/test/unit/channel_state.test.js +++ b/test/unit/channel_state.test.js @@ -498,19 +498,6 @@ describe('ChannelState unified store', () => { expect(names).to.eql(['orig', 'renamed']); }); - it('keeps the deprecated `disconnected` alias writing through to `pendingDisposal`', () => { - const client = new StreamChat(); - client.user = { id: 'me' }; - const channel = new Channel(client, 'messaging', 'alias', {}); - - channel.disconnected = true; - expect(channel.pendingDisposal).to.equal(true); - expect(channel.state.getLatestValue().pendingDisposal).to.equal(true); - - channel.pendingDisposal = false; - expect(channel.disconnected).to.equal(false); - }); - it('proxies the lifecycle flags (initialized/offlineMode/pendingDisposal) through the store', () => { const client = new StreamChat(); client.user = { id: 'me' }; @@ -520,8 +507,6 @@ describe('ChannelState unified store', () => { expect(channel.initialized).to.equal(false); expect(channel.offlineMode).to.equal(false); expect(channel.pendingDisposal).to.equal(false); - // the deprecated `disconnected` alias proxies the same slice - expect(channel.disconnected).to.equal(false); expect(channel.state.getLatestValue()).to.deep.include({ initialized: false, offlineMode: false, From ca3251ec6de3b50f838386f98481a4743e0d6d7d Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 21 Aug 2026 00:02:26 +0200 Subject: [PATCH 27/31] feat: introduce watching state --- src/channel.ts | 23 ++++++ src/channel_state.ts | 23 +++++- src/client.ts | 26 ++++++ src/connection.ts | 4 + test/unit/channel.test.js | 166 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 240 insertions(+), 2 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 573d2df42c..a0def13774 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -1405,6 +1405,19 @@ export class Channel extends ChannelApi { this.state.partialNext({ pendingDisposal }); } + /** + * Whether this client currently holds a server-side watch on the channel — see + * {@link ChannelWatchState.watching}. Store-backed and reactive: subscribe via + * `useStateStore(channel.state, (s) => ({ watching: s.watching }))`. + */ + get watching() { + return this.state.getLatestValue().watching; + } + + set watching(watching: boolean) { + this.state.partialNext({ watching }); + } + /** * Whether a consumer has declared this channel as the one it is currently consuming (see * {@link Channel.activate}). Reactive — subscribe via @@ -1601,6 +1614,8 @@ export class Channel extends ChannelApi { override async stopWatching(...args: Parameters) { const response = await super.stopWatching(...args); + this.watching = false; + logger.withExtraTags('stopWatching', this.cid).info('Stopped watching the channel.'); return response; @@ -1890,6 +1905,13 @@ export class Channel extends ChannelApi { ); } + // The request carrying `watch: true` came back, so the server has registered this connection as + // a watcher. Only ever set here because a `watch: false` query does NOT unwatch server-side, so it + // must not clear the flag. + if (queryPayload.watch) { + this.watching = true; + } + // Seed read/members/pinned/thread-cleanup state; the message list is in the paginator. this._initializeState(state); // The queried page is the latest set unless this was a jump/around query. @@ -2974,6 +2996,7 @@ export class Channel extends ChannelApi { // Tear down the channel.state subscriptions BEFORE flipping `pendingDisposal` — that setter // now publishes to the store, so no subscriber handler runs against a half-torn-down channel. this.messageReceiptsTracker.unregisterSubscriptions(); + this.watching = false; this.pendingDisposal = true; this.cooldownTimer.clearTimeout(); // Release the store-backed paginators so the message store no longer pins this removed channel diff --git a/src/channel_state.ts b/src/channel_state.ts index 9aab0a4af0..f0b282508e 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -25,9 +25,27 @@ type ChannelReadStatus = Record< } >; -export type WatcherState = { +/** + * Everything about watching this channel: who else is watching it, and whether *we* are. + */ +export type ChannelWatchState = { watcherCount: number; watchers: Record; + /** + * Whether this client currently holds a server-side watch on the channel — i.e. whether channel + * events are being delivered to it. Set when a query carrying `watch: true` succeeds + * (`channel.watch()`, `channel.query({ watch: true })`, `client.queryChannels()`), cleared by + * `channel.stopWatching()`, by teardown, and by **any loss of the WS connection**. + * + * The server keys watches by connection ID, so a dropped socket ends every watch this client held + * — even if it reconnects moments later with a fresh ID. That is why this is not simply "did we + * ask to watch once": it answers "are events flowing right now", which is what a consumer needs + * in order to decide whether a channel has to be re-queried. + * + * Note `channel.watch()` silently downgrades to a non-watching query when the client has no + * connection ID; this flag is what makes that observable. + */ + watching: boolean; }; export type TypingUsersState = { @@ -146,7 +164,7 @@ export type AIIndicatorState = { * The shape is FLAT — subscribe to any slice via a selector, e.g. * `useStateStore(channel.state, (s) => ({ read: s.read }))`. */ -export type ChannelStateData = WatcherState & +export type ChannelStateData = ChannelWatchState & TypingUsersState & ReadState & MembersState & @@ -173,6 +191,7 @@ export class ChannelState extends StateStore { super({ watcherCount: 0, watchers: {}, + watching: false, typing: {}, read: {}, members: {}, diff --git a/src/client.ts b/src/client.ts index 2e0773a23f..5762947614 100644 --- a/src/client.ts +++ b/src/client.ts @@ -515,6 +515,7 @@ export class StreamChat extends ChatApi { */ closeConnection = async (timeout?: number) => { this._resetAIStateOnActiveChannels(); + this._markActiveChannelsUnwatched(); if (this.cleaningIntervalRef != null) { clearInterval(this.cleaningIntervalRef); @@ -1103,6 +1104,26 @@ export class StreamChat extends ChatApi { } } + /** + * Clears `state.watching` on every active channel. The server keys watches by connection ID, so + * losing the socket ends every watch this client held — a reconnect issues a NEW id and the + * channels have to be re-queried to watch again. + * + * Invoked from two places, because neither covers the other: `StableWSConnection._setHealth(false)` + * for an abnormal close/error (immediately — NOT via the `connection.changed` event, which is + * 5s-debounced when going offline and is skipped entirely on a quick flap, both of which would + * leave this flag lying), and `closeConnection()` for a deliberate shutdown (e.g. mobile + * backgrounding), which sets `isHealthy` directly and so never reaches `_setHealth`. + */ + _markActiveChannelsUnwatched() { + for (const cid in this.activeChannels) { + const channel = this.activeChannels[cid]; + if (channel && !channel.pendingDisposal) { + channel.watching = false; + } + } + } + _muteStatus(cid: string) { let muteStatus; for (let i = 0; i < this.mutedChannels.length; i++) { @@ -1460,6 +1481,11 @@ export class StreamChat extends ChatApi { c.state.syncStateFromChannelData(c.data, previousData); c.offlineMode = offlineMode; c.initialized = !offlineMode; + // Same precedence `queryChannels` applies to the request: an explicit caller choice wins, + // otherwise we watch only if there is a connection to watch on. Offline hydration populates + // state without a live watch, so it never counts. + c.watching = + !offlineMode && (queryChannelsOptions?.watch ?? this._hasConnectionID()); c.push_preferences = channelState.push_preferences; const willInitialize = diff --git a/src/connection.ts b/src/connection.ts index 9461a5f682..f35bb53546 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -650,6 +650,10 @@ export class StableWSConnection { return; } + // The server keys channel watches by connection ID, so they are gone the moment the socket is. + // Done here rather than off the `connection.changed` event below, which is debounced by 5s. + this.client._markActiveChannelsUnwatched(); + // we're offline, wait few seconds and fire and event if still offline setTimeout(() => { if (this.isHealthy) return; diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index b5d61b320f..72204e67ec 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -8,6 +8,7 @@ import sinon from 'sinon'; import { mockChannelQueryResponse } from './test-utils/mockChannelQueryResponse'; import { Channel, ChannelState, StreamChat } from '../../src'; +import { StableWSConnection } from '../../src/connection'; import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from '../../src/constants'; import { MockOfflineDB } from './offline-support/MockOfflineDB'; import { formatMessage, generateUUIDv4 as uuidv4 } from '../../src/utils'; @@ -306,6 +307,171 @@ describe('Channel isViewingLive (unread bump gating)', function () { }); }); +describe('Channel watching state (channel.state.watching)', function () { + const user = { id: 'user' }; + let client; + let channel; + + const mockQueryResponse = (channelResponse) => { + client.api.sendRequest = () => + Promise.resolve({ + body: getOrCreateChannelApi(channelResponse).response.data, + metadata: {}, + }); + }; + + beforeEach(() => { + client = new StreamChat('apiKey'); + client.user = user; + client.user = { id: user.id }; + client.wsPromise = Promise.resolve(); + // a connection id is what makes a watch possible at all + client._hasConnectionID = () => true; + client.connectionId = 'connection-id'; + channel = client.channel('messaging', 'watching-id'); + client.activeChannels[channel.cid] = channel; + mockQueryResponse(generateChannel({ channel: { id: 'watching-id' } })); + }); + + it('defaults to false before anything is queried', () => { + expect(channel.watching).to.equal(false); + expect(channel.state.getLatestValue().watching).to.equal(false); + }); + + it('is true after watch() resolves', async () => { + await channel.watch(); + + expect(channel.watching).to.equal(true); + }); + + it('is reactive via the store', async () => { + const seen = []; + const unsubscribe = channel.state.subscribeWithSelector( + (s) => ({ watching: s.watching }), + ({ watching }) => seen.push(watching), + ); + + await channel.watch(); + unsubscribe(); + + expect(seen).to.eql([false, true]); + }); + + it('stays false for a query that does not ask to watch', async () => { + await channel.query({ watch: false }); + + expect(channel.watching).to.equal(false); + }); + + it('is NOT cleared by a later non-watching query (a watch:false query does not unwatch)', async () => { + await channel.watch(); + expect(channel.watching).to.equal(true); + + await channel.query({ watch: false }); + + expect(channel.watching).to.equal(true); + }); + + it('stays false when watch() downgrades for lack of a connection id', async () => { + client._hasConnectionID = () => false; + + await channel.watch(); + + expect(channel.watching).to.equal(false); + }); + + it('is cleared by stopWatching()', async () => { + await channel.watch(); + + await channel.stopWatching(); + + expect(channel.watching).to.equal(false); + }); + + it('is cleared on teardown', async () => { + await channel.watch(); + + channel._disconnect(); + + expect(channel.state.getLatestValue().watching).to.equal(false); + }); + + it('is cleared on every active channel when the WS connection goes unhealthy', async () => { + await channel.watch(); + const other = client.channel('messaging', 'other-id'); + client.activeChannels[other.cid] = other; + other.watching = true; + + client._markActiveChannelsUnwatched(); + + expect(channel.watching).to.equal(false); + expect(other.watching).to.equal(false); + }); + + it('is set by channel-list hydration, which watches by default', () => { + const response = generateChannel({ channel: { id: 'hydrated-id' } }); + + const [hydrated] = client.hydrateActiveChannels([response]); + + expect(hydrated.watching).to.equal(true); + }); + + it('is NOT set by hydration when the caller opted out of watching', () => { + const response = generateChannel({ channel: { id: 'unwatched-id' } }); + + const [hydrated] = client.hydrateActiveChannels([response], {}, { watch: false }); + + expect(hydrated.watching).to.equal(false); + }); + + it('is NOT set by hydration without a connection id', () => { + client._hasConnectionID = () => false; + const response = generateChannel({ channel: { id: 'no-connection-id' } }); + + const [hydrated] = client.hydrateActiveChannels([response]); + + expect(hydrated.watching).to.equal(false); + }); + + it('is NOT set by offline hydration (state without a live watch)', () => { + const response = generateChannel({ channel: { id: 'offline-id' } }); + + const [hydrated] = client.hydrateActiveChannels([response], { offlineMode: true }); + + expect(hydrated.watching).to.equal(false); + expect(hydrated.offlineMode).to.equal(true); + }); + + it('is cleared via closeConnection (deliberate shutdown never reaches _setHealth)', async () => { + await channel.watch(); + expect(channel.watching).to.equal(true); + + await client.closeConnection(); + + expect(channel.watching).to.equal(false); + }); + + it('is cleared when the WS connection reports itself unhealthy', async () => { + await channel.watch(); + const sweep = vi.spyOn(client, '_markActiveChannelsUnwatched'); + const connection = new StableWSConnection({ client }); + connection.isHealthy = true; + + connection._setHealth(false); + + expect(sweep).toHaveBeenCalledTimes(1); + }); + + it('skips channels pending disposal when sweeping', async () => { + await channel.watch(); + channel._disconnect(); + + // reading/writing state on a disposed channel must not resurrect it + expect(() => client._markActiveChannelsUnwatched()).not.to.throw(); + expect(channel.state.getLatestValue().watching).to.equal(false); + }); +}); + describe('Channel AI indicator state (channel.state.aiState)', function () { const setupChannel = () => { const client = new StreamChat('apiKey'); From 85c749762c327d56fc94e3076cb03fe262d75d88 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 21 Aug 2026 13:34:02 +0200 Subject: [PATCH 28/31] fix: extend watch status --- src/channel.ts | 24 +++---- src/channel_state.ts | 40 ++++++++---- src/client.ts | 28 ++++---- src/connection.ts | 2 +- test/unit/channel.test.js | 133 +++++++++++++++++++++++++++----------- 5 files changed, 154 insertions(+), 73 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index a0def13774..f1826d9c8c 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -1,4 +1,4 @@ -import { ChannelState } from './channel_state'; +import { ChannelState, ChannelWatchStatus } from './channel_state'; import { CooldownTimer } from './CooldownTimer'; import { isEphemeral } from './errors'; import { applyReactionLocally } from './entityStore'; @@ -1406,16 +1406,16 @@ export class Channel extends ChannelApi { } /** - * Whether this client currently holds a server-side watch on the channel — see - * {@link ChannelWatchState.watching}. Store-backed and reactive: subscribe via - * `useStateStore(channel.state, (s) => ({ watching: s.watching }))`. + * Whether this client holds a server-side watch on the channel, and if not, whether it should be + * restored — see {@link ChannelWatchStatus}. Store-backed and reactive: subscribe via + * `useStateStore(channel.state, (s) => ({ watchStatus: s.watchStatus }))`. */ - get watching() { - return this.state.getLatestValue().watching; + get watchStatus() { + return this.state.getLatestValue().watchStatus; } - set watching(watching: boolean) { - this.state.partialNext({ watching }); + set watchStatus(watchStatus: ChannelWatchStatus) { + this.state.partialNext({ watchStatus }); } /** @@ -1614,7 +1614,8 @@ export class Channel extends ChannelApi { override async stopWatching(...args: Parameters) { const response = await super.stopWatching(...args); - this.watching = false; + // Deliberate: unlike a connection loss this must NOT be restored on reconnect. + this.watchStatus = ChannelWatchStatus.NotWatching; logger.withExtraTags('stopWatching', this.cid).info('Stopped watching the channel.'); @@ -1909,7 +1910,7 @@ export class Channel extends ChannelApi { // a watcher. Only ever set here because a `watch: false` query does NOT unwatch server-side, so it // must not clear the flag. if (queryPayload.watch) { - this.watching = true; + this.watchStatus = ChannelWatchStatus.Watching; } // Seed read/members/pinned/thread-cleanup state; the message list is in the paginator. @@ -2996,7 +2997,8 @@ export class Channel extends ChannelApi { // Tear down the channel.state subscriptions BEFORE flipping `pendingDisposal` — that setter // now publishes to the store, so no subscriber handler runs against a half-torn-down channel. this.messageReceiptsTracker.unregisterSubscriptions(); - this.watching = false; + // A deleted channel (or one the user was removed from) must not be re-watched — see #2599. + this.watchStatus = ChannelWatchStatus.NotWatching; this.pendingDisposal = true; this.cooldownTimer.clearTimeout(); // Release the store-backed paginators so the message store no longer pins this removed channel diff --git a/src/channel_state.ts b/src/channel_state.ts index f0b282508e..6da7c5caf7 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -25,6 +25,27 @@ type ChannelReadStatus = Record< } >; +/** + * Whether this client holds a server-side watch on the channel — i.e. whether channel events are + * being delivered to it. + * + * Three values rather than a boolean, because "not watching" alone cannot say whether a re-watch is + * wanted: a watch lost to a dead socket should be restored on reconnect, one the consumer stopped + * deliberately must not be. The server keys watches by connection ID, so a dropped socket ends every + * watch this client held — even if it reconnects moments later with a fresh ID. + */ +export const ChannelWatchStatus = { + /** Never watched, or the consumer stopped watching on purpose — do NOT re-watch. */ + NotWatching: 'notWatching', + /** Was watching until the connection dropped — the watch is gone and SHOULD be restored. */ + WasWatching: 'wasWatching', + /** Holding a live watch: events are flowing. */ + Watching: 'watching', +} as const; + +export type ChannelWatchStatus = + (typeof ChannelWatchStatus)[keyof typeof ChannelWatchStatus]; + /** * Everything about watching this channel: who else is watching it, and whether *we* are. */ @@ -32,20 +53,15 @@ export type ChannelWatchState = { watcherCount: number; watchers: Record; /** - * Whether this client currently holds a server-side watch on the channel — i.e. whether channel - * events are being delivered to it. Set when a query carrying `watch: true` succeeds - * (`channel.watch()`, `channel.query({ watch: true })`, `client.queryChannels()`), cleared by - * `channel.stopWatching()`, by teardown, and by **any loss of the WS connection**. - * - * The server keys watches by connection ID, so a dropped socket ends every watch this client held - * — even if it reconnects moments later with a fresh ID. That is why this is not simply "did we - * ask to watch once": it answers "are events flowing right now", which is what a consumer needs - * in order to decide whether a channel has to be re-queried. + * See {@link ChannelWatchStatus}. Goes to `Watching` when a query carrying `watch: true` succeeds + * (`channel.watch()`, `channel.query({ watch: true })`, `client.queryChannels()`); to + * `WasWatching` when the WS connection is lost (only from `Watching` — a deliberate stop is never + * resurrected); and to `NotWatching` on `channel.stopWatching()` or teardown. * * Note `channel.watch()` silently downgrades to a non-watching query when the client has no - * connection ID; this flag is what makes that observable. + * connection ID; this is what makes that observable. */ - watching: boolean; + watchStatus: ChannelWatchStatus; }; export type TypingUsersState = { @@ -191,7 +207,7 @@ export class ChannelState extends StateStore { super({ watcherCount: 0, watchers: {}, - watching: false, + watchStatus: ChannelWatchStatus.NotWatching, typing: {}, read: {}, members: {}, diff --git a/src/client.ts b/src/client.ts index 5762947614..c1a3feb022 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5,6 +5,7 @@ import type { AxiosInstance } from 'axios'; import axios from 'axios'; import { Channel } from './channel'; +import { ChannelWatchStatus } from './channel_state'; import { ClientState } from './client_state'; import { StableWSConnection } from './connection'; import { UploadManager } from './uploadManager'; @@ -515,7 +516,7 @@ export class StreamChat extends ChatApi { */ closeConnection = async (timeout?: number) => { this._resetAIStateOnActiveChannels(); - this._markActiveChannelsUnwatched(); + this._markActiveChannelsWatchInterrupted(); if (this.cleaningIntervalRef != null) { clearInterval(this.cleaningIntervalRef); @@ -1105,21 +1106,24 @@ export class StreamChat extends ChatApi { } /** - * Clears `state.watching` on every active channel. The server keys watches by connection ID, so - * losing the socket ends every watch this client held — a reconnect issues a NEW id and the - * channels have to be re-queried to watch again. + * Demotes every actively-watched channel to `WasWatching`. The server keys watches by connection + * ID, so losing the socket ends every watch this client held — a reconnect issues a NEW id and the + * channels have to be re-queried to watch again. `WasWatching` is what records that they should be. + * + * Only `Watching` is demoted: a channel the consumer stopped on purpose, or one that was torn + * down, stays `NotWatching` and must not be resurrected by a reconnect. * * Invoked from two places, because neither covers the other: `StableWSConnection._setHealth(false)` * for an abnormal close/error (immediately — NOT via the `connection.changed` event, which is * 5s-debounced when going offline and is skipped entirely on a quick flap, both of which would - * leave this flag lying), and `closeConnection()` for a deliberate shutdown (e.g. mobile + * leave the status lying), and `closeConnection()` for a deliberate shutdown (e.g. mobile * backgrounding), which sets `isHealthy` directly and so never reaches `_setHealth`. */ - _markActiveChannelsUnwatched() { + _markActiveChannelsWatchInterrupted() { for (const cid in this.activeChannels) { const channel = this.activeChannels[cid]; - if (channel && !channel.pendingDisposal) { - channel.watching = false; + if (channel?.watchStatus === ChannelWatchStatus.Watching) { + channel.watchStatus = ChannelWatchStatus.WasWatching; } } } @@ -1483,9 +1487,11 @@ export class StreamChat extends ChatApi { c.initialized = !offlineMode; // Same precedence `queryChannels` applies to the request: an explicit caller choice wins, // otherwise we watch only if there is a connection to watch on. Offline hydration populates - // state without a live watch, so it never counts. - c.watching = - !offlineMode && (queryChannelsOptions?.watch ?? this._hasConnectionID()); + // state without a live watch, so it never counts - and a query that did not watch leaves the + // status untouched (it neither starts nor ends a watch). + if (!offlineMode && (queryChannelsOptions?.watch ?? this._hasConnectionID())) { + c.watchStatus = ChannelWatchStatus.Watching; + } c.push_preferences = channelState.push_preferences; const willInitialize = diff --git a/src/connection.ts b/src/connection.ts index f35bb53546..34c4d369bf 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -652,7 +652,7 @@ export class StableWSConnection { // The server keys channel watches by connection ID, so they are gone the moment the socket is. // Done here rather than off the `connection.changed` event below, which is debounced by 5s. - this.client._markActiveChannelsUnwatched(); + this.client._markActiveChannelsWatchInterrupted(); // we're offline, wait few seconds and fire and event if still offline setTimeout(() => { diff --git a/test/unit/channel.test.js b/test/unit/channel.test.js index 72204e67ec..ff0a08ad87 100644 --- a/test/unit/channel.test.js +++ b/test/unit/channel.test.js @@ -7,7 +7,7 @@ import { getOrCreateChannelApi } from './test-utils/getOrCreateChannelApi'; import sinon from 'sinon'; import { mockChannelQueryResponse } from './test-utils/mockChannelQueryResponse'; -import { Channel, ChannelState, StreamChat } from '../../src'; +import { Channel, ChannelState, ChannelWatchStatus, StreamChat } from '../../src'; import { StableWSConnection } from '../../src/connection'; import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from '../../src/constants'; import { MockOfflineDB } from './offline-support/MockOfflineDB'; @@ -307,7 +307,7 @@ describe('Channel isViewingLive (unread bump gating)', function () { }); }); -describe('Channel watching state (channel.state.watching)', function () { +describe('Channel watch status (channel.state.watchStatus)', function () { const user = { id: 'user' }; let client; let channel; @@ -333,79 +333,125 @@ describe('Channel watching state (channel.state.watching)', function () { mockQueryResponse(generateChannel({ channel: { id: 'watching-id' } })); }); - it('defaults to false before anything is queried', () => { - expect(channel.watching).to.equal(false); - expect(channel.state.getLatestValue().watching).to.equal(false); + it('starts out NotWatching', () => { + expect(channel.watchStatus).to.equal(ChannelWatchStatus.NotWatching); + expect(channel.state.getLatestValue().watchStatus).to.equal( + ChannelWatchStatus.NotWatching, + ); }); - it('is true after watch() resolves', async () => { + it('is Watching after watch() resolves', async () => { await channel.watch(); - expect(channel.watching).to.equal(true); + expect(channel.watchStatus).to.equal(ChannelWatchStatus.Watching); }); it('is reactive via the store', async () => { const seen = []; const unsubscribe = channel.state.subscribeWithSelector( - (s) => ({ watching: s.watching }), - ({ watching }) => seen.push(watching), + (s) => ({ watchStatus: s.watchStatus }), + ({ watchStatus }) => seen.push(watchStatus), ); await channel.watch(); unsubscribe(); - expect(seen).to.eql([false, true]); + expect(seen).to.eql([ChannelWatchStatus.NotWatching, ChannelWatchStatus.Watching]); }); - it('stays false for a query that does not ask to watch', async () => { + it('stays NotWatching for a query that does not ask to watch', async () => { await channel.query({ watch: false }); - expect(channel.watching).to.equal(false); + expect(channel.watchStatus).to.equal(ChannelWatchStatus.NotWatching); }); - it('is NOT cleared by a later non-watching query (a watch:false query does not unwatch)', async () => { + it('is NOT demoted by a later non-watching query (a watch:false query does not unwatch)', async () => { await channel.watch(); - expect(channel.watching).to.equal(true); await channel.query({ watch: false }); - expect(channel.watching).to.equal(true); + expect(channel.watchStatus).to.equal(ChannelWatchStatus.Watching); }); - it('stays false when watch() downgrades for lack of a connection id', async () => { + it('stays NotWatching when watch() downgrades for lack of a connection id', async () => { client._hasConnectionID = () => false; await channel.watch(); - expect(channel.watching).to.equal(false); + expect(channel.watchStatus).to.equal(ChannelWatchStatus.NotWatching); }); - it('is cleared by stopWatching()', async () => { + it('goes to NotWatching on stopWatching() — a deliberate stop is not restored', async () => { await channel.watch(); await channel.stopWatching(); - expect(channel.watching).to.equal(false); + expect(channel.watchStatus).to.equal(ChannelWatchStatus.NotWatching); }); - it('is cleared on teardown', async () => { + it('goes to NotWatching on teardown', async () => { await channel.watch(); channel._disconnect(); - expect(channel.state.getLatestValue().watching).to.equal(false); + expect(channel.state.getLatestValue().watchStatus).to.equal( + ChannelWatchStatus.NotWatching, + ); }); - it('is cleared on every active channel when the WS connection goes unhealthy', async () => { + it('demotes Watching to WasWatching when the connection is interrupted', async () => { + await channel.watch(); + + client._markActiveChannelsWatchInterrupted(); + + expect(channel.watchStatus).to.equal(ChannelWatchStatus.WasWatching); + }); + + it('leaves a deliberately-stopped channel NotWatching when the connection is interrupted', async () => { + await channel.watch(); + await channel.stopWatching(); + + client._markActiveChannelsWatchInterrupted(); + + // the whole point of the third state: a reconnect must not resurrect this watch + expect(channel.watchStatus).to.equal(ChannelWatchStatus.NotWatching); + }); + + it('leaves a never-watched channel NotWatching when the connection is interrupted', () => { + client._markActiveChannelsWatchInterrupted(); + + expect(channel.watchStatus).to.equal(ChannelWatchStatus.NotWatching); + }); + + it('does not demote WasWatching any further on a second interruption', async () => { + await channel.watch(); + client._markActiveChannelsWatchInterrupted(); + + client._markActiveChannelsWatchInterrupted(); + + expect(channel.watchStatus).to.equal(ChannelWatchStatus.WasWatching); + }); + + it('returns to Watching when a re-watch succeeds after an interruption', async () => { + await channel.watch(); + client._markActiveChannelsWatchInterrupted(); + expect(channel.watchStatus).to.equal(ChannelWatchStatus.WasWatching); + + await channel.watch(); + + expect(channel.watchStatus).to.equal(ChannelWatchStatus.Watching); + }); + + it('is demoted across every active channel at once', async () => { await channel.watch(); const other = client.channel('messaging', 'other-id'); client.activeChannels[other.cid] = other; - other.watching = true; + other.watchStatus = ChannelWatchStatus.Watching; - client._markActiveChannelsUnwatched(); + client._markActiveChannelsWatchInterrupted(); - expect(channel.watching).to.equal(false); - expect(other.watching).to.equal(false); + expect(channel.watchStatus).to.equal(ChannelWatchStatus.WasWatching); + expect(other.watchStatus).to.equal(ChannelWatchStatus.WasWatching); }); it('is set by channel-list hydration, which watches by default', () => { @@ -413,7 +459,7 @@ describe('Channel watching state (channel.state.watching)', function () { const [hydrated] = client.hydrateActiveChannels([response]); - expect(hydrated.watching).to.equal(true); + expect(hydrated.watchStatus).to.equal(ChannelWatchStatus.Watching); }); it('is NOT set by hydration when the caller opted out of watching', () => { @@ -421,7 +467,18 @@ describe('Channel watching state (channel.state.watching)', function () { const [hydrated] = client.hydrateActiveChannels([response], {}, { watch: false }); - expect(hydrated.watching).to.equal(false); + expect(hydrated.watchStatus).to.equal(ChannelWatchStatus.NotWatching); + }); + + it('leaves WasWatching intact when hydration does not watch', async () => { + await channel.watch(); + client._markActiveChannelsWatchInterrupted(); + const response = generateChannel({ channel: { id: 'watching-id' } }); + + client.hydrateActiveChannels([response], {}, { watch: false }); + + // a non-watching hydrate neither starts nor ends a watch + expect(channel.watchStatus).to.equal(ChannelWatchStatus.WasWatching); }); it('is NOT set by hydration without a connection id', () => { @@ -430,7 +487,7 @@ describe('Channel watching state (channel.state.watching)', function () { const [hydrated] = client.hydrateActiveChannels([response]); - expect(hydrated.watching).to.equal(false); + expect(hydrated.watchStatus).to.equal(ChannelWatchStatus.NotWatching); }); it('is NOT set by offline hydration (state without a live watch)', () => { @@ -438,22 +495,21 @@ describe('Channel watching state (channel.state.watching)', function () { const [hydrated] = client.hydrateActiveChannels([response], { offlineMode: true }); - expect(hydrated.watching).to.equal(false); + expect(hydrated.watchStatus).to.equal(ChannelWatchStatus.NotWatching); expect(hydrated.offlineMode).to.equal(true); }); - it('is cleared via closeConnection (deliberate shutdown never reaches _setHealth)', async () => { + it('is demoted via closeConnection (deliberate shutdown never reaches _setHealth)', async () => { await channel.watch(); - expect(channel.watching).to.equal(true); await client.closeConnection(); - expect(channel.watching).to.equal(false); + expect(channel.watchStatus).to.equal(ChannelWatchStatus.WasWatching); }); - it('is cleared when the WS connection reports itself unhealthy', async () => { + it('is demoted when the WS connection reports itself unhealthy', async () => { await channel.watch(); - const sweep = vi.spyOn(client, '_markActiveChannelsUnwatched'); + const sweep = vi.spyOn(client, '_markActiveChannelsWatchInterrupted'); const connection = new StableWSConnection({ client }); connection.isHealthy = true; @@ -466,9 +522,10 @@ describe('Channel watching state (channel.state.watching)', function () { await channel.watch(); channel._disconnect(); - // reading/writing state on a disposed channel must not resurrect it - expect(() => client._markActiveChannelsUnwatched()).not.to.throw(); - expect(channel.state.getLatestValue().watching).to.equal(false); + expect(() => client._markActiveChannelsWatchInterrupted()).not.to.throw(); + expect(channel.state.getLatestValue().watchStatus).to.equal( + ChannelWatchStatus.NotWatching, + ); }); }); From b10fcdccd504716715303f900cf997d4465c27a2 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 21 Aug 2026 14:47:18 +0200 Subject: [PATCH 29/31] fix: comments and error msgs --- src/channel.ts | 9 ++++++--- src/pagination/paginators/MessagePaginator.ts | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index f1826d9c8c..0410e894ce 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -295,13 +295,16 @@ export class Channel extends ChannelApi { } /** - * Returns the chat client for this channel. Throws if `client.disconnect()` was called. + * Returns the chat client for this channel. Throws if the channel is pending disposal — see + * {@link Channel.pendingDisposal}. * * @returns The chat client. */ getClient(): StreamChat { - if (this.pendingDisposal === true) { - throw Error(`You can't use a channel after client.disconnect() was called`); + if (this.pendingDisposal) { + throw Error( + `Channel ${this.cid} is pending disposal and cannot be used. Get a fresh instance via client.channel().`, + ); } return this._client; } diff --git a/src/pagination/paginators/MessagePaginator.ts b/src/pagination/paginators/MessagePaginator.ts index 7d9743f8c7..0ffbc004b9 100644 --- a/src/pagination/paginators/MessagePaginator.ts +++ b/src/pagination/paginators/MessagePaginator.ts @@ -328,8 +328,8 @@ export class MessagePaginator extends MessageIntervalPaginator { seedUnreadSnapshot = () => { // A paginator query (BasePaginator.executeQuery) awaits the network before running its // synchronous postQueryReconcile, which calls this on the first page. If the channel was - // torn down while that request was in flight, reading the client below throws ("You can't - // use a channel after client.disconnect()"), so guard against that. + // torn down while that request was in flight, reading the client below throws (the channel is + // pending disposal), so guard against that. if (this.channel.pendingDisposal) return; const ownUserId = this.channel.getClient().user?.id; const ownReadState = ownUserId ? this.channel.state.read[ownUserId] : undefined; From 098047b323f7f50f2a059763bd4640e6b086960c Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 21 Aug 2026 14:48:41 +0200 Subject: [PATCH 30/31] fix: lint errors --- src/channel.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index 0410e894ce..115accff44 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -302,9 +302,7 @@ export class Channel extends ChannelApi { */ getClient(): StreamChat { if (this.pendingDisposal) { - throw Error( - `Channel ${this.cid} is pending disposal and cannot be used. Get a fresh instance via client.channel().`, - ); + throw Error(`Channel ${this.cid} is pending disposal and cannot be used`); } return this._client; } From c54834df4dd4a0b955669272922c6ffa37c9a1d3 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 21 Aug 2026 14:52:16 +0200 Subject: [PATCH 31/31] fix: bring back old comment --- src/channel.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/channel.ts b/src/channel.ts index 115accff44..0410e894ce 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -302,7 +302,9 @@ export class Channel extends ChannelApi { */ getClient(): StreamChat { if (this.pendingDisposal) { - throw Error(`Channel ${this.cid} is pending disposal and cannot be used`); + throw Error( + `Channel ${this.cid} is pending disposal and cannot be used. Get a fresh instance via client.channel().`, + ); } return this._client; }