Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e2db8b3
refactor(channel): migrate readers and reactions to messagePaginator
MartinCupela Jul 20, 2026
e99f427
Merge branch 'feat/message-paginator-master-merge' into checkpoint/ch…
MartinCupela Jul 20, 2026
caba066
refactor(channel_state): remove main message-list storage
MartinCupela Jul 20, 2026
c238323
test: align unit suite with main message-list removal
MartinCupela Jul 20, 2026
d04ddee
docs(spec): record Task 11 step 3a completion and 3b plan
MartinCupela Jul 20, 2026
b9c4ed6
refactor(channel_state): remove channel.state.threads, re-home reply …
MartinCupela Jul 20, 2026
be4ac9d
docs(spec): record Task 11 step 3b completion
MartinCupela Jul 20, 2026
fea2407
refactor(types): drop dead MessageSet type, message-set pagination de…
MartinCupela Jul 20, 2026
8114a2c
docs(spec): mark Task 12 done and add Task 19 (reactive headItems)
MartinCupela Jul 20, 2026
6ae0569
feat(pagination): add reactive headItems to PaginatorState on BasePag…
MartinCupela Jul 20, 2026
3122355
docs(spec): mark Task 19 done; add Task 20 and addToMessageList clean…
MartinCupela Jul 20, 2026
ca61f93
refactor(pagination): extract MessageIntervalPaginator base from Mess…
MartinCupela Jul 20, 2026
ecd84fa
feat(pagination): add PinnedMessagePaginator
MartinCupela Jul 20, 2026
031c5ca
feat(channel): populate channel.pinnedMessagesPaginator from events (…
MartinCupela Jul 21, 2026
c94b2a4
refactor(channel_state): remove channel.state.pinnedMessages and pinn…
MartinCupela Jul 21, 2026
8361a08
docs(spec): mark Task 18 done (pinned messages → PinnedMessagePaginator)
MartinCupela Jul 21, 2026
5c3ca21
test: drop remaining stale channel.state reads from the suite (Task 1…
MartinCupela Jul 21, 2026
3b52134
docs(spec): mark Task 13 done; refresh stale Task 11/12 statuses
MartinCupela Jul 21, 2026
7d79352
refactor(channel): derive last_message_at from paginator; drop isUpTo…
MartinCupela Jul 21, 2026
2e37f40
docs: point event examples and CLAUDE.md at messagePaginator (Task 14)
MartinCupela Jul 21, 2026
0eef85e
refactor: remove unused MessageReplyPaginator
MartinCupela Jul 21, 2026
2846f06
docs: update the v14-v15 breaking-changes list
MartinCupela Jul 21, 2026
696fa2e
fix(pagination): sort null values to the tail regardless of direction
MartinCupela Jul 22, 2026
8bc9243
refactor(pagination): unify Reminder/UserGroup/Channel paginators on …
MartinCupela Jul 22, 2026
b71a920
fix(pagination): keep the same MessagePaginator.lastMessage updated w…
MartinCupela Jul 22, 2026
8071125
refactor(pagination): remove _usesItemIntervalStorage from BasePaginator
MartinCupela Jul 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ This is a single-package SDK with **no monorepo**. The public surface is everyth
### Module map of `src/`

- **`client.ts` — `StreamChat` facade.** ~5k-line class. Prefer `StreamChat.getInstance(key, secret?, options?)` — the constructor exists for advanced uses but `getInstance` is what `connectUser` warnings and most docs assume. Owns: the axios instance, WS connection lifecycle, `TokenManager`, and a registry of subsystem managers (`threads`, `polls`, `notifications`, `reminders`, `moderation`, `uploadManager`, `messageDeliveryReporter`, plus an optional `offlineDb` injected via `setOfflineDBApi`). New REST endpoints are added here as methods that call `axiosInstance` and return a type from `types.ts`.
- **`channel.ts` (~2.5k lines) + `channel_state.ts` (~1.1k) + `channel_manager.ts` + `channel_batch_updater.ts`** — per-channel object, its in-memory state, and the manager that orchestrates collections of channels (query/sort/filter, pagination, archived/pinned handling).
- **`channel.ts` (~2.5k lines) + `channel_state.ts` (~1.1k) + `channel_manager.ts` + `channel_batch_updater.ts`** — per-channel object, its in-memory state, and the manager that orchestrates collections of channels (query/sort/filter, pagination, archived/pinned handling). **Messages are NOT stored on `channel.state`.** The message list, thread replies, and pinned messages each live in a paginator — `channel.messagePaginator`, `thread.messagePaginator`, and `channel.pinnedMessagesPaginator` — which are the single source of truth (interval storage + a canonical `ItemIndex`). Read them via `channel.messagePaginator.state.items` / `.getItem(id)` / `.headmostItem` (newest loaded item), and mutate via the paginator (`ingestItem` / `removeItem`), never a legacy `channel.state.addMessageSorted()` / `state.messages` (removed). `channel.state.last_message_at` was **removed**; the channel's latest-message timestamp lives on `channel.messagePaginator.lastMessageAt` (its `aggregateState` store — seeded from `ChannelResponse.last_message_at`, then advanced monotonically as messages are ingested). See `docs/breaking-changes-v14-v15.md`.
- **`connection.ts` (`StableWSConnection`) + `connection_fallback.ts` (`WSConnectionFallback`)** — realtime transport. Primary WS implementation does its own 25s ping / 35s health-check loop and reconnects on close/error/offline events; the fallback long-polls over HTTP. The client picks between them based on first-connect outcome; both emit `connection.changed` / `transport.changed` events into the client's local event bus.
- **`store.ts` — `StateStore`.** Reactive primitive (see "State and subscription patterns" below).
- **`signing.ts` — webhook + token helpers.** Server-side primitives `verifyAndParseWebhook`, `parseSqs`, `parseSns`, `verifySignature` (recent CHA-3071 added compressed-payload support). These are re-exported through `client.ts`. **The HMAC is always computed over the uncompressed JSON bytes** — gzip detection uses the `1f 8b` magic bytes, not headers, so the same handler works whether your platform middleware auto-decompressed or not. `CheckSignature` is deprecated in favor of `verifySignature` purely to fix parameter order; new code should use `verifySignature(body, signature, secret)`.
Expand Down
164 changes: 164 additions & 0 deletions docs/breaking-changes-v14-v15.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Breaking Changes: v14 → v15 (stream-chat JS)

Consumer-facing **breaking changes** on the v15 line of the JS SDK — the `message-paginator`
initiative (`feat/message-paginator-master-merge` and the follow-on
`refactor/remove-legacy-channelstate-storage`), beyond the master ⇄ PR merge itself. This is the
source for the v14 → v15 migration guide / release notes. Append newest at the top. Keep each entry
self-contained: what changed, before → after, how to migrate, why.

> Scope note: this file tracks **public API / observable behavior** changes. Internal refactors with
> identical output belong in `decisions.md`, not here.

---

## `ChannelState` message / thread / pinned storage removed — paginators are the source of truth

**Area:** `ChannelState`, `Channel`, `utils` · **Status:** implemented

The channel's messages, thread replies, and pinned messages are no longer stored on `channel.state`.
Each list now lives in a paginator that is the single source of truth (interval storage + a canonical
`ItemIndex`):

- **Main message list** → `channel.messagePaginator`
- **Thread replies** → `thread.messagePaginator` (via `client.threads` / the `Thread` object)
- **Pinned messages** → `channel.pinnedMessagesPaginator`

### Removed from `ChannelState`

Properties / getters: `messages`, `latestMessages`, `messageSets`, `messagePagination`, `threads`,
`pinnedMessages`. (Also `isUpToDate` and the `last_message_at` setter — see the dedicated entries
below.)

Methods: `addMessageSorted`, `addMessagesSorted`, `removeMessage`, `findMessage`,
`findMessageByTimestamp`, `filterErrorMessages`, `loadMessageIntoState`, `clearMessages`,
`initMessages`, `pruneOldest`, `addReaction`, `removeReaction`, `updateUserMessages`,
`deleteUserMessages`, `addPinnedMessages`, `addPinnedMessage`, `removePinnedMessage`,
`removeQuotedMessageReferences` (plus the internal `_updateMessage` / `_updateQuotedMessageReferences`
/ `_add*`/`_remove*` reaction helpers).

### Removed from `utils` (re-exported through the package root)

`addToMessageList`, `messageSetPagination`, `binarySearchByDateEqualOrNearestGreater`,
`deleteUserMessages`, and the `MessageSet` / message-set pagination types.

### Migrate

| Before (v14) | After (v15) |
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `channel.state.messages` | `channel.messagePaginator.state.items` (reactive) / `channel.messagePaginator.items` |
| `channel.state.latestMessages` | `channel.messagePaginator.headItems` / `.lastMessage` |
| `channel.state.messagePagination` | `channel.messagePaginator.state` (`hasMoreHead` / `hasMoreTail` / `cursor`) |
| `channel.state.threads[parentId]` | `thread.messagePaginator.state.items` (resolve the `Thread` via `client.threads`) |
| `channel.state.pinnedMessages` | `channel.pinnedMessagesPaginator.state.items` |
| `channel.state.addMessageSorted(m)` / `addMessagesSorted(ms)` | `channel.messagePaginator.ingestItem(m)` |
| `channel.state.removeMessage({ id })` | `channel.messagePaginator.removeItem({ id })` |
| `channel.state.findMessage(id)` | `channel.messagePaginator.getItem(id)` |

Reactive reads use `useStateStore(channel.messagePaginator.state, …)` (or the paginator's `state`
store directly). Pin/unpin, reactions, user updates, and deletions are applied to the paginators by
the SDK's own event handlers — application code should not call the removed mutators.

### Why

Messages were previously stored twice — a flat `ChannelState` list plus message-set/pagination
bookkeeping — which had to be kept in sync with the paginators and could drift. Consolidating on the
paginators removes the dual-write, gives one API/behavior across the main list, threads, and pinned
messages (dedup-by-id, interval merge, `getItem`/`removeItem`, head-window semantics), and lets
`last_message_at` be derived rather than separately maintained.

## `ChannelState.last_message_at` removed — use `channel.messagePaginator.lastMessageAt`

**Area:** `ChannelState` · **Status:** implemented

`channel.state.last_message_at` was **removed entirely** (it was briefly a read-only getter earlier
in v15; that getter is gone too). The channel's latest-message timestamp is now owned by the message
paginator as a whole-collection aggregate.

- **Before:** `channel.state.last_message_at` (writable, then a derived getter). Internally
maintained by the now-removed `Channel._trackLatestMessage`.
- **After:** `channel.messagePaginator.lastMessageAt` — a `Date | null` **derived** getter over the
paginator's `aggregateState` store (`MessagePaginatorAggregateState = { lastMessage,
seededLastMessageAt }`). It returns `max(lastMessage?.created_at, seededLastMessageAt)`:
`lastMessage` is the newest loaded/received message (advanced on ingest), `seededLastMessageAt` is
the server floor **seeded from `ChannelResponse.last_message_at`** (for channels whose newest
message isn't loaded). Deriving the sort key from the two independent facts means it can never drift
from the display message. Subscribe to `channel.messagePaginator.aggregateState` for reactivity.
- **Migrate:** replace `channel.state.last_message_at` reads with
`channel.messagePaginator.lastMessageAt`. It is not writable; the value is derived from ingested
messages and the server seed.
- **Why:** the message paginator is the single source of truth for messages; `last_message_at` is an
aggregate over them (the dual of pagination). Deriving it through a `ChannelState` getter that
reached into the paginator's message index risked stale/mixed-basis sorting (a seeded-but-stale
paginator preferred over a fresher server value); a single seeded-then-advanced value on the
paginator removes that hazard.
- **Tracking relocated:** `MessageIntervalPaginator`'s `state.latestMessageId` and the `latestMessage`
getter (id resolved from the pagination `state`) were replaced. The tracked latest now lives on
`MessagePaginator.aggregateState.lastMessage`, advanced on every ingest.
`MessagePaginator.lastMessage` remains as a convenience getter but now reads `aggregateState`.
This matters for reactivity: pagination `state` only emits when the **active** interval is impacted,
so a WS message landing in the (non-active) head interval would not notify a `state`-derived
latest; `aggregateState` is written directly on each advance and emits regardless — subscribe to it
(e.g. for a channel/thread list item's latest-message display). `aggregateState.lastMessage` is a
LIVE reference: refreshed in place on edit/soft-delete/reaction of the current latest and recomputed
on hard-remove, and it honors `skip_last_msg_update_for_system_msgs` (system messages neither
reorder a channel nor become its displayed latest). A consumer that previously showed the unfiltered
newest message (`headmostItem`) as the channel-list preview will now skip system messages under that
config — a deliberate behavior change so the preview and the channel's sort position agree.

## Newest-loaded window is exposed as computed getters `headItems` / `headmostItem`

**Area:** `BasePaginator` · **Status:** implemented

The newest-loaded window is exposed as **computed getters** on the paginator — `paginator.headItems`
(the window; `[]` before the first load) and `paginator.headmostItem` (its single newest item),
derived from the intervals on read.

- **Migrate:** the v14 `channel.state.latestMessages` reactive array becomes
`channel.messagePaginator.headItems`. Because these are getters, **not** `PaginatorState` fields,
they are not subscribable via `useStateStore(paginator.state, (s) => s.headItems)` — read them
directly, or subscribe to `channel.messagePaginator.aggregateState` for the reactive last-message
signal.
- **Why:** the value is derivable from the intervals on demand, so it does not need to be materialized
(and re-emitted) into pagination state.

## `Channel.lastMessage()` removed — use `channel.messagePaginator.headmostItem`

**Area:** `Channel` · **Status:** implemented

`channel.lastMessage()` was removed. It returned the newest loaded message (the head edge of the
message paginator's latest window); read `channel.messagePaginator.headmostItem` directly instead.

- **Migrate:** `channel.lastMessage()` → `channel.messagePaginator.headmostItem`.
- **Note:** `headmostItem` is the newest _loaded_ message, **unfiltered** (includes system messages) —
distinct from `channel.messagePaginator.lastMessage`, the filtered chronological latest (honors
`skip_last_msg_update_for_system_msgs`) that backs `lastMessageAt`. Use `headmostItem` for "the newest
message on screen"; use `lastMessage` / `lastMessageAt` for channel-list ordering.
- **Why:** it was a thin wrapper over `headmostItem`, and sharing the name `lastMessage` with the
differently-filtered paginator getter was misleading.

## `ChannelState.isUpToDate` / `setIsUpToDate` removed

**Area:** `ChannelState` · **Status:** implemented

The `isUpToDate` flag and its `setIsUpToDate(boolean)` setter were removed. They gated whether an
incoming `message.new` was appended to the visible message list when the user had scrolled to older
history.

- **Before:** UI SDKs set `channel.state.setIsUpToDate(false)` when jumping to an older window so live
messages were not forced onto the visible list, and read `channel.state.isUpToDate` to decide
whether to show a "jump to latest" affordance.
- **After:** neither exists. Message routing is handled structurally by the message paginator: a live
message newer than the loaded head lands in the head (or logical-head) interval, which is not the
active window when the viewer has jumped away, so the visible window is preserved with no flag.
- **Migrate:**
- "Am I viewing the newest window?" → `channel.messagePaginator.isActiveIntervalAtHead` (getter).
- "Are there newer messages not yet loaded?" → `channel.messagePaginator.hasMoreHead` (reactive
via `channel.messagePaginator.state`).
- "Jump to the latest" → `channel.messagePaginator.jumpToTheLatestMessage()`.
- **Behavioral note:** `last_message_at` now advances on every incoming message regardless of the
viewer's scroll position (the old `isUpToDate` suppression is gone) — it is a channel-level fact,
independent of what the UI is currently viewing.
- **Why:** the flag duplicated state the paginator already models, and was only ever set `false` on
disconnect on this branch — its message-list responsibility had already moved to the paginator.

---
41 changes: 41 additions & 0 deletions specs/migrate-offline-to-messagepaginator/decisions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Decisions — Offline migration

## D-OFF-1 — Enhancement tranche (offline older-page pagination)

**Status:** OPEN

**Question:** Do we add the cursor-aware `AbstractOfflineDB.getChannelMessages` read +
`MessagePaginator.preloadFirstPageFromOfflineDb` (Task O7), enabling paginating older messages while
offline — or ship parity only (O1–O6)?

**Trade-off:** O7 adds a new **abstract** method to the injection interface, which **breaks every
concrete offline DB implementation (RN/mobile) until they implement it** and must be released in
coordination with those SDKs. Parity (O1–O6) adds **no** abstract method and is safe for existing
implementers; it preserves today's behavior (offline shows the last-known latest page via channel
hydration; older-page loads need the network).

**Recommendation:** **Ship parity (O1–O6) first**; schedule O7 as a follow-up with the RN/mobile
teams. Rationale: parity unblocks the parent plan's storage deletion without a cross-SDK breaking
change, and legacy offline didn't support older-page pagination either — so O7 is a net-new feature,
not a regression to avoid.

## D-OFF-2 — `hydrateActiveChannels` paginator seed source (verify)

**Status:** OPEN (verification)

**Question:** Does the `hydrateActiveChannels` offline seed read the raw `ChannelAPIResponse.messages`
(the loop variable) or the just-populated `c.state.messages`? Mapping flagged this ambiguously.

**Action:** Confirm by reading `client.ts:2324-2333` before Task O4/parent Task 10. If it reads
`c.state.messages`, O4 must repoint it to the response so removing legacy `_initializeState` message
population doesn't empty the offline seed.

## D-OFF-3 — Persist paginated page loads (`upsertMessages`) now or later

**Status:** OPEN

**Question:** Include the `populateOfflineDbAfterQuery` → `upsertMessages` page-persist in O1, or defer?

**Recommendation:** **Include in O1.** It closes a real gap (older pages fetched online are currently
not persisted for offline) using an existing abstract method (`upsertMessages`) — no interface break —
and makes O7 (offline read of those pages) actually useful later.
Loading