Skip to content

fix: existing messages retry guards - #1982

Merged
isekovanic merged 5 commits into
release-v10from
fix/message-exists-retry-guard
Nov 3, 2026
Merged

isekovanic merged 5 commits into
release-v10from
fix/message-exists-retry-guard

Conversation

@isekovanic

@isekovanic isekovanic commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Ticket: https://linear.app/stream/issue/REACT-1311

CLA

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

Description of the changes, What, Why and How?

A send can reach the server while its response never reaches the client. The usual cause is the client's 3000ms request timeout, which matches the backend's write timeout, so a slow request can time out on the client after the server has already saved the message. The client then marks the message failed, and every later attempt with the same ID gets "already exists". This was handled for v9 in #1907, which was closed in favour of fixing it in v10. This PR covers every path a send takes in v10.

Commit Fix
7e82010b A send or retry rejected with "already exists" resolves instead of rejecting
3f403ed0 A send whose server copy arrived before the request failed stays sent
9bf9ca86 A queued send replayed after reconnecting marks the message received on "already exists"
a7ce5c68 Test coverage for the replay

"Already exists" on send and retry

Commit: 7e82010b

MessageOperationStatePolicy.failure() already marked the message received on "already exists", but MessageOperations.run() rethrew the error anyway. So composer.send() returned 'failed' and reported "Send message request failed" for a message that had been sent, and retry() rejected, so a resend could never succeed.

failure() now returns whether the operation succeeded after all, and run() only rethrows when it didn't. That's the case only for "already exists" on a send or retry. An edit or a delete rejected with the same code is treated as a failure, as before. A successful send also clears its entry in the failed-send cache.

The other half of the ticket, the default update request ignoring a handler's options, was already fixed by #1949.

Server copy before the failure

Commit: 3f403ed0

When the server saves the message just before the client's timeout fires, its message.new can arrive first and the message shows as sent. The timeout then reached failure(), which marked the message failed without checking what was held, so a sent message flipped back to failed.

A send or retry now counts as successful when the held message is already received. Since the optimistic step sets it to sending, a received copy can only come from the server.

Replay from the offline queue

Commit: 9bf9ca86

With offline support, a send that times out is also queued. When the queue replays it after reconnecting and the server answers "already exists", executePendingTasks deleted the task but left the message failed, until a reload or a retry fixed it.

QueueableOperation gets an onReplayRejected hook next to onReplay, and executePendingTasks calls it before deciding whether to keep the task. The send-message operation uses it: on "already exists", a held message that is still failed is upserted into client.messageStore as received, which updates every list holding it (the channel and, for a reply, its thread), and is written to the offline DB under the task's channel. A message that isn't failed any more, or isn't loaded, is left alone. The hook runs only for replays, so first attempts keep going through the message-operations layer.

Testing

Commit: a7ce5c68

  • Send and retry: "already exists" resolves and leaves the message received; an edit rejected with the same code still rejects and is marked failed; the composer returns 'sent' with no notification.
  • Server copy first: a request that stores a received copy and then times out resolves and keeps the server's copy, and the composer returns 'sent'.
  • Replay: a failed message and a failed thread reply end up received; another rejection leaves the message failed; a message that's already settled or isn't loaded is left alone; the offline DB gets the received message under the task's channel; executePendingTasks hands a rejected replay to the hook.
  • Each new test fails without its fix.
  • yarn test, yarn run types, eslint and prettier pass.

Changelog

  • Fix a send the server already has being reported as failed, on send, retry and offline replay.
  • Fix a sent message flipping back to failed when the request times out after its message.new arrived.

@isekovanic isekovanic changed the title fix: treat sends the server already has as sent fix: existing messages retry guards Nov 3, 2026
@MartinCupela

Copy link
Copy Markdown
Contributor

Reviewed at a7ce5c689 against REACT-1311; both parts of the ticket are covered. yarn test (4,895) and the type check pass locally. One bug: a retry that gets "already exists" ends up received but keeps the error from the failed attempt.

  • The "already exists" branch of MessageOperationStatePolicy.failure() builds the received copy from localMessage, the message the caller passed in. On a retry that's the failed message, which still has error. The optimistic step clears error on the stored copy ({ ...localMessage, error: undefined, status: 'sending' }), and this branch puts it back.
  • stream-chat-react treats a message with error as failed in its action menu (useBaseMessageActionSetFilter.ts:98). It keeps only resend, edit and delete, each under extra conditions, so a sent message can end up with no actions until it's replaced or the page reloads.
  • Example: a send times out after the server saved it, and the user taps Resend. The server answers "already exists" and retry() resolves. The message shows as sent, but its menu still behaves as if it failed.
  • Fix: { ...localMessage, error: undefined, status: 'received' }, as the replay hook already does. Adding expect(store.get('m1')?.error).toBeUndefined() to "resolves a retry that the server rejects with 'already exists'" fails today ("expected {} to be undefined").

@isekovanic

Copy link
Copy Markdown
Contributor Author

@MartinCupela fixed here

@MartinCupela MartinCupela left a comment

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.

755dcaf6e fixes the stale error on an "already exists" retry. The new assertion fails without the fix and passes with it, and all 4,895 tests and the type check pass.

@isekovanic
isekovanic merged commit c7c971f into release-v10 Nov 3, 2026
4 checks passed
@isekovanic
isekovanic deleted the fix/message-exists-retry-guard branch November 3, 2026 11:36
github-actions Bot pushed a commit that referenced this pull request Nov 3, 2026
## [10.0.0-rc.31](v10.0.0-rc.30...v10.0.0-rc.31) (2026-11-03)

### ⚠ BREAKING CHANGES

* the message composer installs the command middlewares by default: createActiveCommandGuardMiddleware and createCommandStringExtractionMiddleware in the text composer chain, createCommandInjectionMiddleware in the composition chain and createDraftCommandInjectionMiddleware in the draft composition chain. Typing `/giphy cats` activates giphy and leaves `cats` in the text, and a command picked with `textComposer.setCommand()` is sent with its name. Delete your own installs of these middlewares. A UI without a command chip removes all four by id: `stream-io/text-composer/active-command-guard` and `stream-io/text-composer/command-string-extraction` from `textComposer.middlewareExecutor`, `stream-io/message-composer-middleware/command-string-injection` from `compositionMiddlewareExecutor` and `stream-io/message-composer-middleware/draft-command-string-injection` from `draftCompositionMiddlewareExecutor`. See ai-docs/v10-breaking-changes.md#composer-command-middlewares-default.

### Bug Fixes

* existing messages retry guards ([#1982](#1982)) ([c7c971f](c7c971f))
* install the command middlewares in the composer by default ([#1985](#1985)) ([1dc25e2](1dc25e2))
* let an edit clear all custom fields ([#1983](#1983)) ([9437b09](9437b09))
* **pagination:** keep loaded items when filterQueryResults empties a later page ([#1986](#1986)) ([78a32c3](78a32c3))
* **search:** match message text by word prefix in MessageSearchSource ([#1987](#1987)) ([ef58ef5](ef58ef5))
@stream-ci-bot

Copy link
Copy Markdown

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

The release is available on:

Your semantic-release bot 📦🚀

isekovanic added a commit to GetStream/stream-chat-react-native that referenced this pull request Nov 3, 2026
Tickets:

- https://linear.app/stream/issue/REACT-1184
- https://linear.app/stream/issue/REACT-1400

## 🎯 Goal

Bumps `stream-chat` to `10.0.0-rc.31` and removes the SDK code it makes
redundant. rc.31 includes GetStream/stream-chat-js#1982,
GetStream/stream-chat-js#1985, GetStream/stream-chat-js#1986 and
GetStream/stream-chat-js#1987.

| Commit | Ticket | Change |
| --- | --- | --- |
|
[`328ecfb7e`](328ecfb)
| [REACT-1184](https://linear.app/stream/issue/REACT-1184) | Remove
`setupCommandUIMiddlewares` |
|
[`01671f45b`](01671f4)
| [REACT-1400](https://linear.app/stream/issue/REACT-1400) |
Pinned-message search uses the default text filter |
|
[`f34a0b01c`](f34a0b0)
| | Bump `stream-chat` to `^10.0.0-rc.31` |

## 🛠 Implementation details

### REACT-1184 - remove `setupCommandUIMiddlewares`

Commit:
[`328ecfb7e`](328ecfb)

GetStream/stream-chat-js#1985 installs the four command middlewares in
every composer by default: the active command guard and the command
string extraction around the commands middleware, and the command
injection after attachments, for messages and for drafts. Those are the
same middlewares, with the same ids and positions, that
`setupCommandUIMiddlewares` inserted, so the helper has nothing left to
do and is removed. SampleApp no longer calls it.

This is a breaking change. Apps that call `setupCommandUIMiddlewares`
delete the call. Apps that never called it now get the command behaviour
too: typing `/giphy cats` makes `giphy` the active command and leaves
`cats` in the input. `ai-docs/ai-migration-v9-to-v10.md` and
`ai-docs/v10-breaking-changes.md` are updated, and the note about the
REACT-1489 fix to this helper is dropped since the helper is gone.

### REACT-1400 - pinned-message search

Commit:
[`01671f45b`](01671f4)

`MessageSearchSource` matched message text by exact equality, so
`ChannelPinnedMessageListProvider` replaced that filter with its own
`text: { $q }`. GetStream/stream-chat-js#1987 makes the default `text: {
$autocomplete }`, so the override is removed. Pinned-message search now
matches by word prefix: "lun" finds "Lunch tomorrow?". An empty query
still lists every pinned message.

### Bump `stream-chat` to `10.0.0-rc.31`

Commit:
[`f34a0b01c`](f34a0b0)

The SDK and the four example apps move to `^10.0.0-rc.31`. Besides the
two changes above, it brings:

- GetStream/stream-chat-js#1982 (REACT-1311): a send or retry the server
already has is reported as sent instead of failed.
- GetStream/stream-chat-js#1986 (REACT-1443): a channel list no longer
empties when a later page only has channels another list owns. This only
matters for apps that set an ownership resolver on `ChannelManager`.

Neither needs a change here.

## 🎨 UI Changes

None.

## 🧪 Testing

- In SampleApp on iOS, typing `/giphy cats` shows the GIPHY chip with
`cats` in the input, and sending it shows the Giphy preview. Picking
Giphy from the commands menu and typing `dogs` does the same.
- In SampleApp on iOS, pinned-message search finds "offline send check"
for "offl" and "sen", and shows no results for "zzq".
- The full unit suite, `yarn typecheck`, `yarn lint` and `yarn build`
pass.

## ☑️ Checklist

- [x] I have signed the [Stream
CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform)
(required)
- [ ] PR targets the `develop` branch
- [x] Documentation is updated
- [ ] New code is tested in main example apps, including all possible
scenarios
  - [ ] SampleApp iOS and Android
  - [ ] Expo iOS and Android
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.

4 participants