Repository navigation
feat!: remove messageSetToAddToIfDoesNotExist and let the message paginator place every page - #1926
Conversation
channel.query() no longer takes the message-set argument. It fetches without adding messages to the message paginator, which loads its own pages. watch() and create() load the channel's first page and report it as the latest message set. BREAKING CHANGE: channel.query(options, requestOptions) drops the second parameter, and the MessageSetType type is removed. Use channel.watch() or channel.create() to open a channel and load its latest messages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ssagesettoaddtoifdoesnotexist-in-llc
- a page queried without a cursor reaches the head, even when live messages newer than it joined its window - a page queried at a cursor only updates its own side's flags, and is not hinted as the head (or tail) at ingest - mergeNewestPage keeps the newest page as the head window under a jumped-to window instead of dropping it
… loaded one headItems/headmostItem returned the head-most loaded window, which after a jump with the newest messages not loaded is an older window. Unread counts, mark read, delivery receipts, the slow-mode cooldown and the last-message fallback then worked from that older window. - remove headItems/headmostItem; add anchoredHeadItem next to anchoredHeadItems, both read from interval storage - markReadLocally and delivery receipts use messagePaginator.lastMessage - countUnread(lastRead), countUnreadMentions, the cooldown timer and the last-message fallback use the head window, or the live messages while it is not loaded - findItemByTimestamp searches every loaded window - reload and the reconnect snapshots use the head window Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A page around a message, or at a cursor, that includes the newest message was not marked as the head window, so after opening a channel at its first unread message the newest messages counted as not loaded. - a page reaches the head when its newest message is at or after lastMessageAt (pageReachesHead), whatever query fetched it - a null headward/tailward cursor from the query marks that end as reached, also where the paginator derives its cursors; null is no longer turned into undefined - the server's last_message_at replaces the stored value instead of only advancing it, and is applied before the page of the same response - add getPagePaginationEdges Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… is active After a jump, newly received messages and a reconnect's newest page did not reach a window marked as the newest, so the newest messages counted as not loaded, and jumping back to the latest showed a stale window. - a new message at or after lastMessageAt starts the head window when none is loaded, together with the logical-head items ingested since the latest response (logicalHeadItemsSinceSeededLastMessageAt) - a reconnect while another window is active merges the newest page into the inactive head window (mergeNewestPageIntoInactiveHead): on overlap, or when the head window holds only messages that arrived after the page; across a gap the page becomes the head window - a head window holding only messages newer than the page is merged with it, not replaced, also while it is active - the last-message fallback after a hard delete leaves out-of-order older messages Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…paginator watch() assumed the page it fetched was the channel's newest page: a page at a cursor replaced the loaded newest messages, a limit: 0 watch emptied the list, and any page was reported as the latest message set. - the channel query is split into requestChannelState and applyChannelState; watch() and create() go through queryAndPlaceMessages, which places the page between the two, before read state is applied - the paginator owns the placement: preparePagePlacement fills in the page size and snapshots the head interval's ids before the request, and its place() puts the page where it belongs - seedFirstPageSync is renamed to placePageSync; a page other than the newest is stored as a separate interval (postQueryReconcile continuesActiveWindow), and is a first page only when nothing is loaded - only the newest page is reported as the latest message set - candidateIds is renamed to headIntervalItemIdsAtRequest; Thread.reload takes it from the head interval - query() never touches the message paginator Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every channel response re-seeds the pinned-messages list's first page. It was merged into whichever pinned window was active, so after jumping to an older pinned message the fresh first page was joined onto that older window, hiding the pinned messages between them. The newest page now continues the active window only when that window is the head window; otherwise it is stored as a separate interval, merged only with an interval it overlaps, which is the newest pinned window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BasePaginator kept v9's next/prev names next to the tail/head ones: the next/prev fields on query results, the hasNext/hasPrev getters, the next()/prev() methods and nextDebounced()/prevDebounced(). They are removed. The v9 to v10 guide lists every rename. BREAKING CHANGE: BasePaginator (and the paginators built on it) no longer has hasNext/hasPrev, next()/prev() or nextDebounced()/prevDebounced(); use hasMoreTail/hasMoreHead, toTail()/toHead() and toTailDebounced()/ toHeadDebounced(). A query() result's cursors are read only from tailward/headward, no longer from next/prev. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While a channel was not watched, the window marked as reaching the newest message kept that mark after a query reported a later last_message_at. The list then offered no "load newer", "jump to latest" showed that window, and the next message from an event was appended to it across the gap. - unmarkStaleHead() clears the head mark when the newest message the server knows of in the head window is older than the queried last_message_at. It runs after channel and channel-list responses that did not place the newest page, for channels that were not watched before the response. - unmarkHead() (BasePaginator) clears the mark and lets the window load toward the head. - The newest page merging into a window without the head mark removes the messages it proves were hard-deleted. The docs now say that latestMessage is the newest message the client holds, and how it differs from anchoredHeadItem. BREAKING CHANGE: renamed MessagePaginator members: - lastMessage -> latestMessage (getter and aggregateState field) - lastMessageAt -> latestMessageAt - seedLastMessageAt -> setQueriedLatestMessageAt - aggregateState.seededLastMessageAt -> queriedLatestMessageAt - trackLastMessage -> trackLatestMessage - shouldAdvanceLastMessage -> shouldAdvanceLatestMessage (protected) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…) from moving the message list BasePaginator.executeQuery runs every query in fixed steps: prepare, fetchQueryResponse, extractQueryPage, placeQueryPage and applyQueryResponseRest. Every step after the fetch runs whichever fails; the first error is recorded as lastQueryError and rejects only with throwErrors. The message paginator places a channel response's page before the channel applies the rest, on its own queries, on watch() and on the channel-list hydrate. On the paginator's own queries and on watch(), the stale-head check runs before the page is placed; the hydrate checks only when it placed no newest page. The unread snapshot is taken after the read state is applied. A page from a query the paginator did not run (watch(), the channel list), and the pinned list's first page from a channel response, are stored where they belong (placeExternalQueryPage), or a newest page reconciles the loaded newest messages (reconcileNewestMessages). Such a page never moves the reader to another window: it is shown only when nothing is shown yet or when it lands in the shown newest window, and the paginator's own loading flag, error and query shape are left alone. channel.create() places no messages, as it asks for no channel state. Channel queries, the message paginator's own included, run as tracked queries (runTrackedQuery), so the channel store keeps the channel until they end. BREAKING CHANGE: paginators no longer have a public query(): BasePaginator subclasses implement the protected fetchQueryResponse() and extractQueryPage(), and filterQueryResults() is optional. BasePaginator takes the response type as a third type parameter, so the message paginators are BasePaginator<LocalMessage, MessageQueryShape, MessageQueryResponse>, and PaginatorOptions.createItemIndex receives a BasePaginator<T, Q, unknown>. runQueryRetryable() is fetchQueryResponseWithRetries() and resolves to the response; the protected _nextQueryShape is gone (fetchQueryResponse() reads params.queryShape). postQueryReconcile() is placeQueryPage() and PostQueryReconcileParams is PlaceQueryPageParams. MessageIntervalPaginator: seedFirstPageSync() and SeedFirstPageOptions are replaced by placeExternalQueryPage(), which shows only a newest page, and reconcileNewestMessages() (guarded by canReconcileNewestMessages); mergeNewestPage() is reconcileNewestMessages(), and MergeNewestPageOptions is ReconcileNewestMessagesOptions, whose candidateIds is headIntervalItemIdsAtRequest. MessagePaginator takes the unread snapshot in applyQueryResponseRest(), not when placing a page. channel.watch() asked for a page at a cursor or around a message stores it without showing it (show it with messagePaginator.jumpToMessage()), and channel.create() places no messages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ls show the newest page - The latest navigation decides the shown window: jumps, reloads and queries at a caller-given shape claim the active interval (`claimsActiveInterval`); a page arriving after a later claim is only stored, and a superseded jump resolves `false` without an error. A refused query claims nothing. - A page continuing a window the reader left meanwhile is only stored; a page at a caller-given query shape is stored as a separate interval. - A fresh newest page separated from the head window by a gap becomes a head window of its own; messages from the page's oldest on, unsent ones included, move with it, so windows are never welded across a gap. A page that reached the channel start merges instead. - A stored page landing in the shown window is published, once. - A page proven to reach the newest message makes its window the only head window, unless a window reaching further contradicts it. - Cursors and more-pages flags use the outermost messages the server knows, never an unsent id. - A channel nobody reads (`channel.active` false) shows the newest page that `watch()`, reload, recovery or a channel-list hydrate brings; an active channel's reader is never moved. - `channel.reload()` re-ingests only offline-DB failed messages no interval holds; `thread.reload()` relies on reconciling, which never removes an unsent reply. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ndow When a page placed by the paginator merges the interval marked as the head (or the tail) into its own interval, the result is the head (or tail) interval, whatever the page alone says. Before, a "load newer" page that joined the reader's window to the newest window after a gap left the joined window unmarked, so the next live message started a separate newest window the reader never saw. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eact-1075-get-rid-of-messagesettoaddtoifdoesnotexist-in-llc Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| this.reconcileHeadAgainstPage(page, options); | ||
| if (newHead && !this.isBeingRead()) { | ||
| this.showHeadInterval(page, options); | ||
| } else if (this.isActiveInterval(keptHead)) { |
There was a problem hiding this comment.
When a reconnect brings back a newest page that doesn’t overlap the loaded newest window, the reader stays on the old window and the new page is stored as a separate head. rc.23 and v9 both show the newest messages here. With a channel open and 40 messages arriving while offline, the list just sits on the old messages after reconnecting, and only the scroll-to-bottom badge changes.
That has knock-on effects:
- A message the user sends right after lands in the hidden head and disappears from view.
- Failed or pending messages newer than the page’s oldest message get moved there too (movedMessages, line 1142).
- Windows seeded from the offline DB aren’t replaced anymore (client.ts:1557, plus the isBeingRead() path for an open channel).
- Messages deleted while offline stay in the kept window (line 1150).
I’d always replace the old window with the newest page in this case and carry unsent messages over, like rc.23 does. That covers all of the above, and it’s also what users already get currently.
| // Re-ingest the failed messages the watch() query dropped. | ||
| const visible = new Set((paginator.items ?? []).map((message) => message.id)); | ||
| // the user's unsent work no interval holds | ||
| const lostFailed = failedInDb.filter( |
There was a problem hiding this comment.
channel.reload() and thread.reload() used to snapshot failed messages from memory and the DB before the request and readd anything that wasn’t visible afterwards. Now only the DB copy is used and only for messages that aren’t in any loaded interval. Without offline support there’s nothing left to restore from, and a failed message sitting in a hidden window counts as fine. Can we bring back the rc.23 behaviour in both?
| * kept, as reconciling never removes a reply the server has not confirmed. Rethrows, except for | ||
| * a not-found answer on a thread that never had replies. | ||
| */ | ||
| public reload = async () => { |
There was a problem hiding this comment.
| : undefined, | ||
| targetIntervalId: isSeparateInterval ? undefined : this._activeIntervalId, | ||
| }); | ||
| const takenInEnds = interval |
There was a problem hiding this comment.
Inheriting isHead/isTail from any merged interval lets a wrong or stale mark override what the page says. Two cases break because of it.
ChannelManager.recover() merges page 1 into a fully loaded list with keepPreviousItems, so the list inherits “end of list” and stops paging, even when the server returned a tailward cursor.
The case this commit fixes only needs the head mark. Inheriting isHead on headward pages only, never overriding a cursor from the response and skipping keepPreviousItems first pages would cover it.
| return true; | ||
| }; | ||
|
|
||
| jumpToTheLatestMessage = async (options?: JumpToMessageOptions): Promise<boolean> => { |
There was a problem hiding this comment.
Without a head window (unmarkStaleHead can leave it that way), jumpToTheLatestMessage() does an id_gt load newer from the shown window instead of fetching the newest page. If more than a page is missing it never reaches the head, returns false and shows the jump-to-latest-failed notification. It should fetch the newest page without a cursor in that branch.
| // window as the head. While the channel was watched, events were arriving, and a message the | ||
| // head window lacks is still on its way. | ||
| if (!placesNewestPage && this.watchStatus !== ChannelWatchStatus.Watching) { | ||
| this.messagePaginator.unmarkStaleHead(); |
There was a problem hiding this comment.
A load older while the channel isn’t watched, for example right after a reconnect before the recovery reload lands, clears the head mark. The next message the user sends then goes through isNewNewestMessage/ingestAsNewestInterval, starts a separate head window and isn’t visible where they’re reading.
There was a problem hiding this comment.
The way I see it there are two things that need to happen to fix this.
First, unmarkStaleHead probably shouldn’t run while the channel is WasWatching (I mean the state, I swear I'm not illiterate :D ). Recovery is about to reload the channel anyway, so clearing the mark in that window only creates a state where new messages have nowhere visible to go. Limiting it to NotWatching keeps the case it was written for.
Second, a message the user sends should always end up where they’re looking. In ingestAsNewestInterval, taking over the view when the message is the current user’s own would do it, the same way the live head case already does. Once the reload lands, the always-replace change from comment 1 makes the newest window the shown one, so the message stays visible from then on.
| options?: ReconcileNewestMessagesOptions, | ||
| ): boolean { | ||
| const headIds = new Set(head.itemIds); | ||
| const sharesMessage = page.some((message) => headIds.has(this.getItemId(message))); |
There was a problem hiding this comment.
sharesMessage takes any shared message as proof that the page and the head window are contiguous, so a gap can still get merged over and the messages in between are lost for good. This is the same on rc.23, but it’s the case the gap rule is meant to prevent.
A message.new that arrives while the reload is in flight ends up at the end of both. Comparing against headIntervalItemIdsAtRequest, which is already in options, fixes that as long as page ids are also skipped in movedMessages so the message isn’t duplicated.
A send queued while offline replays before the reload and comes back as the newest server message, so it’s in both as well. Messages created on this device shouldn’t count as overlap, ever.
There was a problem hiding this comment.
With the old window always replaced on a gap, a false positive only means some loaded history gets dropped and refetched on scroll. A false negative merges over the gap and hides messages for good. So the overlap check can afford to be conservative a bit more.
For the queued send, I’d leave the user’s own messages out of sharesMessage altogether, or at least the ones sent since the last successful load of the channel. A message this device sent says nothing about whether the server pages around it are contiguous. Combined with comparing against headIntervalItemIdsAtRequest, the only messages that count as overlap are ones the window got from the server before the request went out, which is what the check is really asking.
… regressions Fixes the regressions the stream-chat-react-native team found in their review of this PR, and the follow-ups found while testing them: - A reconnect page that does not connect to the loaded messages replaces the windows and is shown, also after a jump; unsent and live messages are kept. A window's range comes from its server-confirmed messages only. - Whether a newest page connects is judged against what was loaded and server-confirmed when the request went out, never by the user's own messages. - channel.reload() and thread.reload() put back failed messages they lose; thread-only replies stay out of the channel list; a failing offline-DB read no longer fails the reload. - Only the head mark is inherited when a page joins windows, and only on load-newer pages, never over a cursor from the response (no early stop when scrolling up or paging lists). - unmarkStaleHead runs only when nothing else will refresh the channel. - The user's own new send always shows the newest window. - jumpToTheLatestMessage fetches the newest page when no newest window is loaded. - An empty channel that gets live messages has no older messages to load; a list queried with message_limit 0 stays not loaded instead of empty. - The pinned list keeps one newest window. - markReadLocally sends the newest server-confirmed message of the newest window. - Migration guide: the missing behaviour changes and renames. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…who wrote the overlap A reconnect whose only overlap with the loaded window was the user's own messages was read as a gap (own messages could not prove a connection), so all loaded history was dropped and a scrolled-up reader moved, although nothing was missed. E.g. page 25: the user wrote the last 10, others sent 20 while offline. The newest page now connects when its oldest server message was in the head window when the request went out (or was removed during it), when the head window holds no server message older than the page, or when the page reached the channel start. A send replayed from the offline queue before the reload still leaves a gap: the page's oldest message was never loaded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a gap A newest page separated from the head interval by a gap (reconnect reload, restored watch, recovery) used to replace every loaded interval and show the page, moving a reader who had jumped to an older message. Now: - if the active interval is an older one, it stays active and shown, untouched; the page is stored as the new head interval (with the unsent and live-arrived messages it keeps) and every other interval is dropped. Jump to the latest shows the stored page without a request, and loading newer from the kept interval joins it; - if the head interval is active, or the paginator is not active, the page replaces the intervals and is shown, as before. A paginator is active when the thread it lists, or without a thread its channel, is active (`isActivePaginator()`, reading the new `Thread.active` getter or `Channel.active`; the paginator takes its `thread`). Thread reply lists and the pinned list follow their own owner's flag instead of always counting as active. `isBeingRead` and `isOwnSendWhileOlderWindowIsRead` are renamed to `isActivePaginator` and `isOwnSendWhileOlderWindowIsActive`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ssagesettoaddtoifdoesnotexist-in-llc Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
## [10.0.0-rc.24](v10.0.0-rc.23...v10.0.0-rc.24) (2026-10-22) ### ⚠ BREAKING CHANGES * remove messageSetToAddToIfDoesNotExist and let the message paginator place every page (#1926) * open api hot fixes (#1927) ### Bug Fixes * apply thread reloads and queries to the registered thread ([#1929](#1929)) ([acb33c4](acb33c4)), closes [#1913](#1913) * open api hot fixes ([#1927](#1927)) ([4fb4b52](4fb4b52)) ### Features * remove messageSetToAddToIfDoesNotExist and let the message paginator place every page ([#1926](#1926)) ([2b7960d](2b7960d))
|
🎉 This PR is included in version 10.0.0-rc.24 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Purpose
REACT-1075: get rid of
messageSetToAddToIfDoesNotExistand let the message paginator decide where every page of messages goes.In v10 the message list lives in
channel.messagePaginator, which holds loaded messages in windows (intervals): disjoint ranges of contiguous messages, one of which is shown. Letting a caller say "add this page to that set" was a guess, and a wrong guess joins a page onto a window it doesn't continue — the list then claims contiguity over messages that were never loaded.The rule this PR settles on:
How it evolved
channel.query()stops adding messages;watch()/create()load the first page.anchoredHeadItems); opening at the first unread left the newest messages "not loaded" (pageReachesHead); after a jump, new messages and a reconnect's newest page went nowhere; the pinned list re-seeded into the wrong window.watch()'s page.watch()assumed its page was the newest page. The channel query is split into request → place → apply, and the paginator places the page in between.lastMessage→latestMessage,unmarkStaleHeadfor unwatched channels.watch()never moves the reader. Working out what the rule must handle — pages still in flight, gaps, races, unsent messages — produced the continued window, the gap rule, claims and the remaining fixes.channel.reload()andthread.reload()keep failed messages again (rc.23's safety net).markReadLocallyeach got a fix (see below).Thread.active,Channel.active); thread reply lists and the pinned list no longer count as always active.release-v10merged in, including fix: apply thread reloads and queries to the registered thread #1929 (thread reloads apply to the registered thread) and fix!: open api hot fixes #1927.Review rounds kept finding cases around unsent messages and the newest-window mark. The window range part of that is fixed here; the rest stays in REACT-1340.
Flows
The paginator's own query (scroll older/newer, jump, reload)
Every step after the fetch runs even if one fails; the first error becomes
lastQueryErrorand rejects only withthrowErrors.A page the paginator did not ask for (
watch(), reload, recovery, channel-list refresh)It is a gap unless one of these holds:
When unsure, it is a gap: a false gap only drops loaded history, which loads again on scroll, while a missed gap hides messages for good.
Example: reconnect
Example: jump race
Concepts and why
claimActiveInterval,claimsActiveInterval,decidesActiveInterval). Responses arrive in any order, so the latest navigation decides the shown window: jumps, reloads and queries at a caller-given shape take a claim; a page arriving after a later claim is only stored. A counter rather than "is the same window still shown?", because the reader can return to the same window another way and merges can change a window's id. A refused query (a double tap while the first request runs) claims nothing — otherwise it would cancel the first jump and show nothing.continuesIntervalId,isPageOnlyStored). A "load older/newer" page belongs next to the window it was requested from; if the reader jumped elsewhere meanwhile, attaching it there would join unrelated messages, so it is only stored.startsSeparateInterval).executeQuery({ queryShape })has no known position relative to the shown window, so its page starts a separate window and merges only with what it overlaps.isSeparatedFromHeadByGap,placeNewestPageAcrossGap). Merging a newest page that doesn't touch what's loaded would hide the messages in between for good. The stale newest window and any other window nobody is reading are dropped, as nothing brought them up to date. A reader of an older window keeps it, as it is (storeNewestPageBesideActiveInterval); otherwise the page is shown in place of the windows (replaceWindowsWithNewestPage). What must not be lost moves into the page's window: unsent messages, and messages that arrived during the request.getHeadIntervalItemIdsAtRequest,headIntervalItemIdsAtRequest,pageStartsInsideHead). A page connects when its oldest message was in the newest window, server-confirmed, before the request: the page then runs unbroken from a message the window held. Who wrote it does not matter. A live message that arrived during the request sits above messages the window never loaded, so it proves nothing.getIntervalSpan). An unsent message carries the device's clock; counting it stretched a window over messages that were never loaded and merged windows that don't touch.getFailedMessagesToRestore,restoreFailedMessages). Before the request,channel.reload()andthread.reload()note the failed messages of the shown and the newest window, and those in the offline DB (read best effort). After it, they add back any that no window holds. One a window still holds is left as it is, as it may have been resent.isActivePaginator(): the thread'sactive, or the channel'sactivefor a list without a thread). There is no reader to disturb, and a channel you come back to should open on its newest messages. The channel-list hydrate places the newest page for every channel nobody reads; an active channel's ownreload()owns its window. Withmessage_limit: 0the hydrate places nothing, so a list never loaded stays "not loaded" rather than empty.makeOnlyHead). Unread counts, mark read, delivery receipts, the cooldown, reload sizing and "jump to latest" all read the newest window; two candidates give wrong answers. A window reaching further wins over a contradicted claim. The pinned list now proves its newest page reaches the head (pageReachesHead), so it keeps one newest window too.inheritsHeadMark). When a "load newer" page joins a window to the newest window, the joined window is the newest one. Only the head mark is inherited, only on "load newer", only where cursors are derived from the window, and never over a cursor the server returned. Channel lists are not affected.getServerKnownEdges,latestServerMessageAt). The server does not know local ids, and their timestamps come from the device clock.publishStoredPage→publishActiveInterval; a batch publishes at exit only if the shown list is still stale). The UI renders every emission; an in-between state shows as a flicker or a wrong "load newer" button.runTrackedQuery). The channel store drops unused channels; tracking keeps a channel while its queries run.unmarkStaleHead). Only for a channel that is not watched (NotWatching): newer messages can exist on the server, and keeping the mark would hide "load newer" and append the next live message across the gap. A dropped connection (WasWatching) is left to the recovery reload.hasNoMessagesBeforeLiveMessages).isOwnSend,isOwnSendWhileOlderWindowIsActive). A new message the user sends switches the list to the newest window, which holds it, also while they read an older window or when no newest window is loaded; the switch claims the shown window, so an earlier jump returning later does not move them away. A message the list already holds (a retry, the offline queue replaying, the server's confirmation) and the user's message from another device leave the reader where they are.markReadLocally's message id (anchoredHeadServerConfirmedItem).last_read_message_idis the newest server-confirmed message of the newest window, whatever its type (rc.23's choice without local ids of unsent messages). It is left out while that window is not loaded.Breaking changes
channel.query(options, requestOptions)drops the message-set parameter;MessageSetTypeis removed. Open a channel withchannel.watch()/channel.create().channel.watch()with a page at a cursor or around a message stores it without showing it (show it withmessagePaginator.jumpToMessage());channel.create()places no messages. A newest page fromwatch(),reload()or recovery moves the reader only across a gap, and only a reader of the newest window.Thread.active, new, orChannel.active) instead of always counting as active: a closed thread shows a reloaded page past a gap.watch()and the message paginator's queries no longer go throughchannel.query(): an override of or a spy onquery()does not see them. Listen tochannels.queriedinstead.query(): subclasses implementfetchQueryResponse()andextractQueryPage()(filterQueryResults()optional), andBasePaginatortakes the response type as a third type parameter.runQueryRetryable()→fetchQueryResponseWithRetries();postQueryReconcile()→placeQueryPage();PostQueryReconcileParams→PlaceQueryPageParams. An override under an old name is never called, and TypeScript flags it only when it is declared withoverride. Returnednext/prevfields are ignored; anullheadward/tailwardalways means the page reaches that end.executeQuery({ queryShape })starts a separate window and claims the shown one.updateState: falsestores the page, and still publishes it when it lands in the shown window.MessageIntervalPaginator:seedFirstPageSync()→placeExternalQueryPage();mergeNewestPage()→reconcileNewestMessages();MergeNewestPageOptions→ReconcileNewestMessagesOptions, withcandidateIds→headIntervalItemIdsAtRequest;SeedFirstPageOptionsis removed.MessagePaginator:lastMessage→latestMessage(also onMessagePaginatorAggregateState),lastMessageAt→latestMessageAt,seededLastMessageAt→queriedLatestMessageAt,trackLastMessage()→trackLatestMessage(),seedLastMessageAt()→setQueriedLatestMessageAt()(each query result replaces the value, also with an earlier one);headItems/headmostItemremoved (useanchoredHeadItems/anchoredHeadItem).BasePaginator:hasNext/hasPrev,next()/prev()and their debounced variants removed (usehasMoreTail/hasMoreHead,toTail()/toHead()).message.read_locally:last_read_message_idis the newest server-confirmed message of the newest window, and is missing while that window is not loaded.ai-docs/v10-breaking-changes.mddescribes each of these, the renames since the release candidates included.Known limits
Testing
notificationTypes.test.ts, which depends on untracked local files and fails on the base too. Tests cover claims and jump races, the continued window, caller-given shapes, the gap rule and its request-time snapshot, unsent and failed messages across reloads, inactive channels, the channel-list hydrate, each repro from the React Native team's analysis, the user's own send, and the offline-queue reconnect.mastermerged in) type-checks with 0 errors and passes its full suite (3168 tests) against this branch.🤖 Generated with Claude Code