Skip to content

feat!: remove messageSetToAddToIfDoesNotExist and let the message paginator place every page - #1926

Merged
MartinCupela merged 20 commits into
release-v10from
martincupela/react-1075-get-rid-of-messagesettoaddtoifdoesnotexist-in-llc
Oct 22, 2026
Merged

MartinCupela merged 20 commits into
release-v10from
martincupela/react-1075-get-rid-of-messagesettoaddtoifdoesnotexist-in-llc

Conversation

@MartinCupela

@MartinCupela MartinCupela commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Purpose

REACT-1075: get rid of messageSetToAddToIfDoesNotExist and 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:

Only the paginator's own navigation moves the reader. Anything else stores what it fetched where it belongs.

One exception: a newest page that does not connect to what is loaded (a gap). If the reader is on the newest window, or nobody reads the list, the page replaces the loaded windows and is shown. If the reader is on an older window (after a jump), that window stays shown as it is, the page is stored as the new newest window and every other window is dropped. Unsent messages are kept.

How it evolved

  1. Remove the parameter. channel.query() stops adding messages; watch() / create() load the first page.
  2. Fix what that exposed. Once the paginator owned placement, its "newest window" handling showed bugs: pages not marked as reaching the newest; after a jump, unread counts / mark read / the cooldown read an older window (now 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.
  3. Let the paginator place 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.
  4. One query pipeline for all paginators, plus cleanup: the next/prev aliases removed, lastMessage → latestMessage, unmarkStaleHead for unwatched channels.
  5. The rule above. 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.
  6. A channel nobody is reading moves to the newest page.
  7. Fixes from the React Native team's regression analysis. The RN team compared this PR with rc.23 and with RN on v9 and reported regressions with repro steps. Every fix in this round comes from that analysis, and each repro is now a unit test:
    • A reconnect across a gap shows the newest page again, as rc.23 did, instead of keeping the reader on a window that can no longer be brought up to date. This is the exception to the rule above.
    • channel.reload() and thread.reload() keep failed messages again (rc.23's safety net).
    • The gap check judges against what was loaded and server-confirmed when the request went out.
    • A window's range comes from server-confirmed messages only.
    • Inheriting an end mark is limited to the head mark on "load newer".
    • "Jump to latest", the user's own send, a channel that starts empty, the pinned list and markReadLocally each got a fix (see below).
  8. Follow-ups found while testing those fixes in the scenarios dialog of the React example app:
    • The user's own new send shows the newest window, also while they read an older one.
    • The user's own messages never prove that a newest page connects. With offline support the queue replays before the recovery reload, so a queued send is already confirmed when the reload asks, and it glued the windows over the missed messages.
  9. Round 3 of the review:
    • Whether a newest page connects is judged by where it starts, not by who wrote the overlap: it connects when its oldest message was in the newest window, server-confirmed, when the request went out. The own-message rule from step 8 made a normal reconnect look like a gap when the user had written the last messages.
    • A reconnect across a gap keeps a reader of an older window where they are (the page is stored as the newest window; "jump to latest" shows it without a request). A reader of the newest window still gets the page shown. Other windows are dropped either way.
    • A paginator is active when the thread it lists, or without a thread its channel, is active (Thread.active, Channel.active); thread reply lists and the pinned list no longer count as always active.
  10. release-v10 merged 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

flows

The paginator's own query (scroll older/newer, jump, reload)

prepare   take a claim / note the window this query continues
  → fetch → extract the page
  → place: show it, unless the reader moved to another window or a later jump won
           → then only store it
  → apply the rest of the response (channel state, read state, unread snapshot)

Every step after the fetch runs even if one fails; the first error becomes lastQueryError and rejects only with throwErrors.

A page the paginator did not ask for (watch(), reload, recovery, channel-list refresh)

page at a cursor or around a message ──→ stored as its own window
newest page:
  no newest window loaded ─────────────→ becomes the newest window
  separated from it by a gap:
    reader on an older window ─────────→ that window stays shown; the page becomes the newest
                                          window, every other window is dropped
    otherwise ─────────────────────────→ replaces the loaded windows and is shown
                                          (unsent messages move into the page's window)
  otherwise ───────────────────────────→ merged into the newest window, proven deletions removed;
                                          shown only when nothing is shown yet or nobody reads

It is a gap unless one of these holds:

  • the page's oldest message was in the newest window, server-confirmed, when the request went out (a message that arrived live during the request proves nothing);
  • the newest window holds no server message older than the page;
  • the page reached the channel start.

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

shown m8–m10 · "hi" fails while offline · meanwhile m11–m40 are sent
reload returns m21–m40 (no shared message, older messages loaded → a gap)
  → shows m21–m40 with "hi", m8–m10 is dropped, "load older" fetches m20 and older
  → a reader who had jumped to m2 stays on m1–m3; m21–m40 is stored as the newest window,
    "jump to latest" shows it without a request
had only m11–m12 been sent, the page holds m8–m10 too (no gap)
  → merged; the reader stays where they are, unless nobody reads the channel

Example: jump race

tap quote m20 (not loaded)  → claim 1, request pending
tap quote m2 (loaded)       → claim 2, shows m1–m3
m20's page arrives          → claim 1 overtaken → stored only, m1–m3 stays,
                              the m20 jump resolves false without an error

Concepts and why

  • Claims on the active window (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.
  • Continued window (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.
  • Caller-given query shapes (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.
  • The gap rule (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.
  • What was known when the request went out (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.
  • A window's range comes from server-confirmed messages (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.
  • Failed messages survive a reload (getFailedMessagesToRestore, restoreFailedMessages). Before the request, channel.reload() and thread.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.
  • Nobody reading → newest page (isActivePaginator(): the thread's active, or the channel's active for 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 own reload() owns its window. With message_limit: 0 the hydrate places nothing, so a list never loaded stays "not loaded" rather than empty.
  • One newest window (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.
  • Inheriting the newest mark (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.
  • Unsent messages never become cursors (getServerKnownEdges, latestServerMessageAt). The server does not know local ids, and their timestamps come from the device clock.
  • A short page never deletes older loaded messages. A page can be short because of a server cap or filtering, not only because the channel starts there; only the explicit "anything older?" probe proves deletion.
  • One publish per change (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.
  • Tracked channel queries (runTrackedQuery). The channel store drops unused channels; tracking keeps a channel while its queries run.
  • Clearing a stale newest mark (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.
  • No newest window loaded. "Jump to latest" then fetches the newest page (no cursor) and shows it, however many pages are missing. A channel whose last response had no messages and then gets live ones has nothing older to load, so the first scroll up sends no empty request (hasNoMessagesBeforeLiveMessages).
  • The user's own send shows the newest window (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_id is 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; MessageSetType is removed. Open a channel with channel.watch() / channel.create().
  • channel.watch() with a page at a cursor or around a message stores it without showing it (show it with messagePaginator.jumpToMessage()); channel.create() places no messages. A newest page from watch(), reload() or recovery moves the reader only across a gap, and only a reader of the newest window.
  • Thread reply lists and the pinned list follow their owner's active flag (Thread.active, new, or Channel.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 through channel.query(): an override of or a spy on query() does not see them. Listen to channels.queried instead.
  • Paginators have no public query(): subclasses implement fetchQueryResponse() and extractQueryPage() (filterQueryResults() optional), and BasePaginator takes 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 with override. Returned next / prev fields are ignored; a null headward / tailward always means the page reaches that end.
  • executeQuery({ queryShape }) starts a separate window and claims the shown one. updateState: false stores the page, and still publishes it when it lands in the shown window.
  • MessageIntervalPaginator: seedFirstPageSync() → placeExternalQueryPage(); mergeNewestPage() → reconcileNewestMessages(); MergeNewestPageOptions → ReconcileNewestMessagesOptions, with candidateIds → headIntervalItemIdsAtRequest; SeedFirstPageOptions is removed.
  • MessagePaginator: lastMessage → latestMessage (also on MessagePaginatorAggregateState), lastMessageAt → latestMessageAt, seededLastMessageAt → queriedLatestMessageAt, trackLastMessage() → trackLatestMessage(), seedLastMessageAt() → setQueriedLatestMessageAt() (each query result replaces the value, also with an earlier one); headItems / headmostItem removed (use anchoredHeadItems / anchoredHeadItem).
  • BasePaginator: hasNext/hasPrev, next()/prev() and their debounced variants removed (use hasMoreTail/hasMoreHead, toTail()/toHead()).
  • A new message the user sends shows the newest window, also to a reader of an older window.
  • message.read_locally: last_read_message_id is the newest server-confirmed message of the newest window, and is missing while that window is not loaded.

ai-docs/v10-breaking-changes.md describes each of these, the renames since the release candidates included.

Known limits

  • A failed message older than the shown window is put back on reload but lands outside every window, so it stays out of view until a page reaches it. The same holds for a failed message stored between two loaded windows.
  • The pinned list has no gap rule: its newest page merges into the stored newest window even across a gap.
  • Side effects of "when unsure, it is a gap":
    • A newest page made only of messages that arrived live during the request counts as a gap, so the loaded history is dropped (it loads again on scroll). This needs at least a page of live messages during one request, such as a channel-list refresh of a busy channel nobody reads.
    • When two newest pages are in flight and the first replaces the windows, the second is judged a gap too, and the window shrinks to the second page. This needs a channel nobody reads that two channel lists recover at the same time.
  • A window a reader is kept on across a gap is not brought up to date: a message deleted or edited while offline shows as it was loaded until that window loads again.
  • Open issue: more than a page of sends queued offline while more than a page of other messages arrives. The confirmed sends are placed after the newest loaded message, their events restore the watch, and its newest page, made only of those sends, starts at a message the window held, so it is joined across the missed messages until the next reload replaces the window. Reproduced by scenario [CHAT-537] enable autocomplete tests #37 of the React example's scenarios dialog; a fix is in progress.
  • If a jump inside the shown window overtakes "jump to latest", that page is stored apart until the next "load newer" joins it.
  • Unsent messages still need special cases (they belong with the newest messages wherever their time falls) — REACT-1340.

Testing

  • Unit suite: 4740 pass, 1 fails: 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.
  • stream-chat-react (the REACT-1075 branch, master merged in) type-checks with 0 errors and passes its full suite (3168 tests) against this branch.
  • A "Pagination scenarios" dialog in the React example app (same React branch) reproduces these cases locally against a fake in-memory backend: 46 scenarios, all passing against this build except [CHAT-537] enable autocomplete tests #37, the open issue above.

🤖 Generated with Claude Code

MartinCupela and others added 14 commits October 13, 2026 11:09
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>
- 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>
MartinCupela and others added 2 commits October 20, 2026 15:34
…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)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/channel.ts Outdated
// 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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread src/thread.ts
* 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 () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

: undefined,
targetIntervalId: isSeparateInterval ? undefined : this._activeIntervalId,
});
const takenInEnds = interval

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/channel.ts
// 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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

MartinCupela and others added 4 commits October 21, 2026 11:09
… 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>
@MartinCupela
MartinCupela merged commit 2b7960d into release-v10 Oct 22, 2026
8 checks passed
@MartinCupela
MartinCupela deleted the martincupela/react-1075-get-rid-of-messagesettoaddtoifdoesnotexist-in-llc branch October 22, 2026 11:57
github-actions Bot pushed a commit that referenced this pull request Oct 22, 2026
## [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))
@stream-ci-bot

Copy link
Copy Markdown

🎉 This PR is included in version 10.0.0-rc.24 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants