Skip to content

feat: optimistic reactions - #1808

Merged
isekovanic merged 63 commits into
release-v10from
feat/optimistic-reactions
Aug 5, 2026
Merged

feat: optimistic reactions#1808
isekovanic merged 63 commits into
release-v10from
feat/optimistic-reactions

Conversation

@isekovanic

@isekovanic isekovanic commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

CLA

  • I have signed the Stream CLA (required).
  • Code changes are tested

Description of the changes, What, Why and How?

Builds on the message paginator state layer with three related capabilities: a client-global reactive message store, optimistic reactions built on top of it and state-publish throttling for the message list.

1. Reactive collections - a client-global message store

Separate PR reference: #1811

TLDR:

A new MessageStore (client.messageStore) that holds exactly one canonical LocalMessage per id, plus a StoreBackedItemIndex that lets paginators keep message content in the shared store while membership stays local.

Previously every collection (channel message list, thread replies, thread parent) held its own copy of a message. A message shared across collections - e.g. a show_in_channel reply that lives in both the channel list and its thread - meant an edit/reaction had to be manually fanned out to each copy and copies drifted. There was no single source of truth for a message's content.

A paginator sees the same CRUD surface as a plain ItemIndex, but getItem(id) still means "does this paginator hold the id" while the object lives once in the store. Writing content links the writer as a holder (drives notification fanout + refcount GC) and notifies every other holder, which reprojects. Removal unlinks a holder rather than hard-deleting, so a message still held elsewhere survives and the store GCs it only when the last holder unlinks. Subscribers are notified at most once per transaction with just the subset of their watched ids that changed.

2. Optimistic reactions

Channel.addReactionWithLocalUpdate / deleteReactionWithLocalUpdate and the Thread equivalents cause reactions to render instantly and stay consistent across every collection holding the message.

Reactions previously waited on the server round trip and a reaction on a message held in multiple places (notably the thread parent, which lives in no paginator) had no single place to apply it consistently.

applyReactionLocally addresses the message purely by id in the store, folds the reaction into reaction_groups / latest_reactions (shared count helpers computeOwnReactions / messageWithReactionAdded / messageWithReactionRemoved, so paginator and paginator less thread parent compute identically) and writes it back and the store then reprojects every holder. It writes memory synchronously and the offline DB and returns an undo() that durably reverses both on a terminal server failure (respecting isEphemeral, so transient/offline failures stay queued rather than rolling back). The lower-level Channel.sendReaction / deleteReaction only queue the offline replay task; persistence of the optimistic row is owned by applyReactionLocally.

3. State publish throttling

A shared throttle() utility and a global switch (stateThrottling) that throttles the message list state publish in BasePaginator.

High frequency live mutations (rapid events, receipts) would publish to state on every change; coalescing the window publish cuts redundant rerenders - while a user's own optimistic action must still render immediately.

throttle() (leading/trailing, returning { throttledFn, cancelTimer, flush }, ported from the Feeds client and extended with flush()) coalesces publishes per window. Optimistic writes bypass the delay via the store's flushState() hook.

Changelog

  • Add client.messageStore (MessageStore) - a client-global, normalized, reactive store holding one canonical message per id; exported MessageStore, MessageStoreChangeBatch, MessageStoreSubscriber.
  • Add optimistic reactions: Channel.addReactionWithLocalUpdate / Channel.deleteReactionWithLocalUpdate and Thread.addReactionWithLocalUpdate / Thread.deleteReactionWithLocalUpdate, with durable rollback across memory + offline DB.
  • Share message content across collections (channel list, thread replies and thread parent) so edits/reactions on a shared message stay consistent; refcount GC keeps show_in_channel replies alive while any holder remains.
  • Add a throttled message list state publish (throttle utility + stateThrottling switch); local/optimistic writes bypass the throttle; disabled under test runners.

Support setting paginator items directly, optional request retries, offline DB in ChannelPaginator, identification of pagination restart based on query shape change.
…eat/message-paginator

# Conflicts:
#	src/channel.ts
#	src/client.ts
….messages.deleted and message.deleted events
isekovanic and others added 9 commits July 20, 2026 13:05
## CLA

- [ ] I have signed the [Stream
CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform)
(required).
- [ ] Code changes are tested

## Description of the changes, What, Why and How?

## Changelog

-
…ith paginators (#1806)

### Summary

Completes the `message-paginator` initiative on the LLC side: 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` (owned by the `Thread`
object)
- **Pinned messages** → `channel.pinnedMessagesPaginator` (new
`PinnedMessagePaginator`)

### What changed

- **`ChannelState` storage removed:** `messages`, `latestMessages`,
`messageSets`, `messagePagination`, `threads`, `pinnedMessages`, and
their mutators (`addMessageSorted`/`addMessagesSorted`, `removeMessage`,
`findMessage`, `findMessageByTimestamp`, `filterErrorMessages`,
`loadMessageIntoState`, `clearMessages`, `initMessages`, `pruneOldest`,
`addReaction`/`removeReaction`, `updateUserMessages`,
`deleteUserMessages`,
`addPinnedMessages`/`addPinnedMessage`/`removePinnedMessage`,
`removeQuotedMessageReferences`, + internal helpers).
- **`PinnedMessagePaginator`** added and populated from channel events;
pin/unpin falls out of `ingestItem` + a `{ pinned: true }` filter (no
bespoke branching).
- **`MessageIntervalPaginator`** extracted as the unread-free base of
`MessagePaginator`; it tracks the latest message (`latestMessageId` +
`latestMessage`), advanced on ingest and mirrored into reactive state.
- **`channel.state.last_message_at`** is now a **read-only getter**
derived from `messagePaginator.latestMessage` (setter + backing field
removed).
- **`channel.state.isUpToDate` / `setIsUpToDate` removed** —
live-message routing (don't disrupt a scrolled-away view) is handled
structurally by the paginator's interval model.
- **`Channel._trackLatestMessage` removed.** `user.updated` /
`user.deleted` propagation now scans active channels
(`reflectUserUpdate` / `applyMessageDeletionForUser` self-filter by
author id). A targeted user-reference index is planned
(`specs/user-reference-index`).
- **`utils` cleanup:** removed `addToMessageList`,
`messageSetPagination`, `binarySearchByDateEqualOrNearestGreater`,
`deleteUserMessages`, and the `MessageSet` / message-set pagination
types.
- **Reactive `headItems`** added to `PaginatorState` (newest-loaded
window).
- **Removed the unused `MessageReplyPaginator`** (dead sibling; the
thread reply list uses `MessagePaginator`).

### Breaking changes

Full list + before → after migration table in
**`docs/breaking-changes-v14-v15.md`**. Highlights:

| Before (v14) | After (v15) |
| --- | --- |
| `channel.state.messages` | `channel.messagePaginator.state.items` /
`.items` |
| `channel.state.threads[parentId]` |
`thread.messagePaginator.state.items` |
| `channel.state.pinnedMessages` |
`channel.pinnedMessagesPaginator.state.items` |
| `channel.state.addMessageSorted(m)` |
`channel.messagePaginator.ingestItem(m)` |
| `channel.state.removeMessage({ id })` |
`channel.messagePaginator.removeItem({ id })` |
| `channel.state.findMessage(id)` |
`channel.messagePaginator.getItem(id)` |
| `channel.state.isUpToDate` / `setIsUpToDate` |
`messagePaginator.isActiveIntervalAtHead` / `hasMoreHead` /
`jumpToTheLatestMessage()` |
| `channel.state.last_message_at = …` | read-only (derived); ingest a
message on the paginator |

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's a channel-level fact.

### Follow-ups (not in this PR)

- **`specs/user-reference-index`** — replace the active-channel scan on
`user.updated`/`user.deleted` with a `userId → message-reference` index
(design-gated).
-  Unifying the remaining flat paginators on interval storage
- Offline migration

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Size Change: +7.79 kB (+1.53%)

Total Size: 517 kB

📦 View Changed
Filename Size Change
dist/cjs/index.browser.js 172 kB +2.62 kB (+1.54%)
dist/cjs/index.node.js 174 kB +2.55 kB (+1.49%)
dist/esm/index.mjs 171 kB +2.62 kB (+1.55%)

compressed-size-action

isekovanic and others added 7 commits July 25, 2026 00:40
…o feat/message-paginator-master-merge

# Conflicts:
#	src/channel.ts
#	src/channel_state.ts
#	src/client.ts
#	src/messageComposer/messageComposer.ts
#	src/messageComposer/middleware/textComposer/types.ts
#	src/messageComposer/textComposer.ts
#	src/messageDelivery/MessageDeliveryReporter.ts
#	src/messageDelivery/MessageReceiptsTracker.ts
#	src/moderation.ts
#	src/pagination/BasePaginator.ts
#	src/pagination/ReminderPaginator.ts
#	src/pagination/UserGroupPaginator.ts
#	src/signing.ts
#	src/thread.ts
#	src/types.ts
#	src/utils.ts
#	test/unit/CooldownTimer.test.ts
#	test/unit/MessageComposer/messageComposer.test.ts
#	test/unit/MessageComposer/middleware/messageComposer/cleanData.test.ts
#	test/unit/MessageComposer/textComposer.test.ts
#	test/unit/channel.test.js
#	test/unit/channel_state.test.js
#	test/unit/client.test.js
#	test/unit/messageDelivery/MessageDeliveryReporter.test.ts
#	test/unit/messageDelivery/MessageReceiptsTracker.test.ts
#	test/unit/pagination/UserGroupPaginator.test.ts
#	test/unit/threads.test.ts
#	test/unit/utils.test.js
#	test/unit/utils.test.ts
## CLA

- [ ] I have signed the [Stream
CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform)
(required).
- [ ] Code changes are tested

## Description of the changes, What, Why and How?

## Changelog

-
Base automatically changed from feat/message-paginator-master-merge to release-v10 August 3, 2026 09:40
@isekovanic
isekovanic merged commit 54740f1 into release-v10 Aug 5, 2026
4 checks passed
@isekovanic
isekovanic deleted the feat/optimistic-reactions branch August 5, 2026 08:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants