Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions packages/stream_chat/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
## Upcoming

✅ Added

- Added support for sending and deleting reactions while offline.

🔄 Changed

- `Channel.sendReaction` and `Channel.deleteReaction` now keep the optimistic change on a transient/offline error and replay it when the connection recovers, instead of reverting it.

## 10.3.0

✅ Added
Expand Down
50 changes: 36 additions & 14 deletions packages/stream_chat/lib/src/client/channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'dart:math' as math;

import 'package:collection/collection.dart';
import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/src/client/reaction_pending_operation.dart';
import 'package:stream_chat/src/client/retry_queue.dart';
import 'package:stream_chat/src/core/util/utils.dart';
import 'package:stream_chat/stream_chat.dart';
Expand Down Expand Up @@ -1616,19 +1617,30 @@ class Channel {
state?.updateMessage(updatedMessage);

try {
final reactionResp = await _client.sendReaction(
return await _client.sendReaction(
messageId,
reaction,
skipPush: skipPush,
enforceUnique: enforceUnique,
);
return reactionResp;
} catch (_) {
// Reset the message if the update fails. Use replace (not merge)
// so the rollback wins over the optimistic local state — otherwise
// `Message.updateWith`'s enrichment preservation would keep the
// optimistic `ownReactions` for messages that previously had none.
state?.replaceMessage(message);
} catch (e) {
final retriable = e is StreamChatNetworkError && e.isRetriable;
if (retriable) {
Comment on lines +1627 to +1628

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.

Cross-SDK divergence — this predicate makes Flutter roll back exactly the errors React Native queues.

Raising this as its own thread (it was an aside in my PendingOperationsManager.replay() comment) because comparing against the RN/JS implementation turns it from a theoretical sharp edge into a behavioural inconsistency between Stream SDKs.

RN classifies with an explicit terminal allowlist (stream-chat/src/offline-support/offline_support_api.ts:1154):

private shouldSkipQueueingTask = (error: AxiosError<APIErrorResponse>) =>
  error?.response?.data?.code === 4 ||   // bad request data
  error?.response?.data?.code === 17;    // missing own_capabilities

Everything not on that list is queued and replayed. Flutter's isRetriable => data == null is the inverse: any response carrying a body is terminal. So for the same user action:

Failure RN This PR
Offline / connection error queued queued ✅
429 rate limited queued rolled back
5xx with the Stream error envelope queued rolled back
Bad request data (code 4) dropped dropped ✅
Missing capability (code 17) dropped rolled back ✅

429 and transient 5xx are the canonical "retry this later" responses, and they're the ones a real device hits under load — so the headline feature currently doesn't engage in the failure modes most likely to occur after plain offline. Worth deciding deliberately rather than inheriting it from data == null.

Two things RN does here that are worth borrowing:

  1. Enumerate the terminal cases instead of inferring them from body presence — an allowlist of codes/statuses fails safe (unknown error ⇒ keep the operation) where data == null fails destructive (unknown error with a body ⇒ discard the user's reaction).
  2. Pre-check the connection before spending a request. RN's queueTask (offline_support_api.ts:1127-1136) checks wsConnection?.isHealthy and throws an OfflineError without a network round-trip, so the common offline case never waits on a Dio timeout. Here every offline reaction pays a full timeout before being queued.

I'd leave isRetriable itself alone — it's pre-existing and shared with RetryQueue. The narrow fix is to not reuse it for this decision, since this is the first place it determines whether local state is kept or reverted rather than merely whether to retry a send.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

  1. Enumerate the terminal cases -> I think we can do this. Just to make sure I didn't misunderstand something, your idea is to use this new check, instead of the existing isRetriable?
  2. Pre-check the connection before spending a request - checks wsConnection?.isHealthy - this goes a bit against this comment (pt 3) -> where you mentioned that the connectWebSocket: false case would not work with the current implementation of the replay manager - we should align and decide how to proceed on this topic.

// Keep the optimistic reaction and queue it for replay on reconnect.
await _client.pendingOperationsManager.enqueue(
ReactionPendingOperation.add(
reaction,
skipPush: skipPush,
enforceUnique: enforceUnique,
),
);
} else {
// Reset the message on terminal failure. Use replace (not merge)
// so the rollback wins over the optimistic local state — otherwise
// `Message.updateWith`'s enrichment preservation would keep the
// optimistic `ownReactions` for messages that previously had none.
state?.replaceMessage(message);
}
rethrow;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not entirely sure if we should always rethrow here, maybe just in the non-retry-able case?

}
}
Expand All @@ -1647,15 +1659,25 @@ class Channel {
state?.updateMessage(updatedMessage);

try {
final deleteResponse = await _client.deleteReaction(
return await _client.deleteReaction(
message.id,
reaction.type,
);
return deleteResponse;
} catch (_) {
// Reset the message if the update fails. Use replace (not merge)
// for symmetry with `sendReaction` — see that method for context.
state?.replaceMessage(message);
} catch (e) {
final retriable = e is StreamChatNetworkError && e.isRetriable;
if (retriable) {
// Keep the optimistic removal and queue it for replay on reconnect.
await _client.pendingOperationsManager.enqueue(
ReactionPendingOperation.delete(
messageId: message.id,
reactionType: reaction.type,
),
);
} else {
// Reset the message on terminal failure. Use replace (not merge)
// for symmetry with `sendReaction` — see that method for context.
state?.replaceMessage(message);
}
rethrow;
}
}
Expand Down
17 changes: 17 additions & 0 deletions packages/stream_chat/lib/src/client/client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/src/client/channel.dart';
import 'package:stream_chat/src/client/channel_delivery_reporter.dart';
import 'package:stream_chat/src/client/event_resolvers.dart' as event_resolvers;
import 'package:stream_chat/src/client/pending_operations_manager.dart';
import 'package:stream_chat/src/client/query_channels_result.dart';
import 'package:stream_chat/src/client/retry_policy.dart';
import 'package:stream_chat/src/core/api/attachment_file_uploader.dart';
Expand Down Expand Up @@ -165,6 +166,11 @@ class StreamChatClient {
late final _appSettingsManager = AppSettingsManager(_chatApi.general);
static final _systemEnvironmentManager = SystemEnvironmentManager();

/// Owns the queue of pending operations (e.g. reactions added or removed
/// while offline) and replays them when the connection recovers.
@internal
late final pendingOperationsManager = PendingOperationsManager(this);

/// Updates the system environment information used by the client.
///
/// The passed [environment] is sanitized before being applied:
Expand Down Expand Up @@ -435,6 +441,8 @@ class StreamChatClient {
// Connect to persistence client if its set.
if (chatPersistenceClient != null) {
await openPersistenceConnection(ownUser);
// Restore any operations that were queued before process death.
await pendingOperationsManager.hydrate();
}

// Connect to websocket if [connectWebSocket] is true.
Expand Down Expand Up @@ -607,6 +615,11 @@ class StreamChatClient {
final connectionRecovered = !wasConnected && isConnected;

if (connectionRecovered) {
// Replay pending offline operations (e.g. reactions) BEFORE any
// server-state refresh, so the server has each mutation before a re-query
// returns state that would otherwise clobber the optimistic change.
await pendingOperationsManager.replay();
Comment on lines +618 to +621

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.

Major — the stated invariant only holds when replay succeeds; when it fails retriably the re-query wipes exactly the state this feature is meant to protect.

The comment is right about ordering, but it is load-bearing in a way the code doesn't guarantee. If replay() fails retriably, the operation stays queued and the optimistic reaction stays in local state — and then sync() (L627) and queryChannelsOnline() (L632) run immediately and overwrite the message from server truth.

Concretely, Message.updateWith returns other.copyWith(...) where other is the incoming server payload, and its preserve list is localCreatedAt/localUpdatedAt/localDeletedAt, deletedForMe, poll, sharedLocation, ownReactions, quotedMessage. reactionGroups and latestReactions are not in it, so both come wholesale from the server. Reaction chips render from reactionGroups (stream_message_reactions.dart:83; ownReactions only styles a chip that reactionGroups already says exists).

Net user-visible behaviour: the reaction added offline disappears on reconnect and reappears at some later recovery when replay finally lands. That is worse than the pre-PR rollback, which was at least immediate and self-consistent.

Two options: skip the re-query for channels that still have queued operations, or re-apply the optimistic state after reconciliation. Either way the invariant should be stated as "holds only if replay succeeded", because right now the failure path is the interesting one.

Separately, this await puts an unbounded queue on the critical path of connection recovery — it runs ahead of sync() and channel re-query, so N queued operations × per-request latency directly delays offline-event sync. Combined with the missing fail-fast in the replay loop, a flaky reconnect can block recovery for N × Dio timeout. Worth either bounding/coalescing the queue (there is no dedup of (messageId, type), so an offline user tapping the same reaction N times produces N operations and N round-trips) or moving replay off the blocking path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Valid, however I want to discuss the cases where the replay fails retry-ably: In most of the cases, this would be due to a local error (ex. connectivity), which means that most likely the following sync/queryChannels would fails as well, therefore not overriding the local state. I think that retriable failure from the retry loop is a very rare in practice.

Two options: skip the re-query for channels that still have queued operations

I don't think that this is possible - we would have to override the QueryChannels request params - something that we shouldn't do in general.

or re-apply the optimistic state after reconciliation

This is probably feasible, but far from trivial. I think we have to evaluate whether the effort is worth it. Also, I don't think any of the other platforms have such reconciliation logic.
But if you think this is a blocker - this is the solution I would like to implement.

Separately, this await puts an unbounded queue on the critical path of connection recovery — it runs ahead of sync() and channel re-query, so N queued operations × per-request latency directly delays offline-event sync.

This actually follows all other platforms: In real life, the pending operations queue would almost never be big. If we make this loop unawaited, we will introduce the complexity of having the replay + QueryChannels/GetOrCreateChannel reconciliation.


// connection recovered
final cids = [...state.channels.keys.toSet()];
if (cids.isNotEmpty) {
Expand Down Expand Up @@ -2543,6 +2556,10 @@ class StreamChatClient {
state.dispose();
state = ClientState(this);

// clearing the in-memory pending-operation queue so a user's queued
// operations never replay under the next connected user.
pendingOperationsManager.clear();

// clearing app settings cache.
_appSettingsManager.clear();

Expand Down
205 changes: 205 additions & 0 deletions packages/stream_chat/lib/src/client/pending_operations_manager.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import 'package:meta/meta.dart';
import 'package:stream_chat/src/client/client.dart';
import 'package:stream_chat/src/client/reaction_pending_operation.dart';
import 'package:stream_chat/src/core/error/error.dart';
import 'package:stream_chat/src/core/models/pending_operation.dart';
import 'package:stream_chat/src/core/models/reaction.dart';

/// Owns the queue of [PendingOperation]s and replays them against the server
/// when the connection is recovered.
///
/// The queue lives in memory for the current session and is the single source
/// replayed from. When persistence is enabled the queue is additionally
/// mirrored to [StreamChatClient.chatPersistenceClient], so operations survive
/// process death: [hydrate] loads them back into memory on the next connect.
/// Without persistence the queue is session-only, giving reactions same-session
/// replay across transient outages.
///
/// Replay is at-least-once: an operation is removed only after the server
/// accepts or terminally rejects it, so a crash between acceptance and removal
/// re-sends it on the next recovery. Every operation type handled by
/// [_replayCallFor] must therefore be idempotent on the server — e.g. reactions
/// dedupe by (message, type, user).
@internal
class PendingOperationsManager {
/// Creates a manager for [client]'s pending-operation queue.
PendingOperationsManager(this._client);

final StreamChatClient _client;

/// The in-memory queue, replayed in insertion order.
///
/// Every entry carries a non-null [PendingOperation.id]: a positive DB
/// autoincrement id when the operation is mirrored to persistence, or a
/// negative session id otherwise. The two ranges never collide.
final _operations = <PendingOperation>[];

// Source of negative, session-only ids for operations that are not persisted.
int _memorySeq = 0;
int _nextMemoryId() => --_memorySeq;

// Prevents overlapping replays.
bool _isReplaying = false;

/// Appends [operation] to the queue, mirroring it to persistence when
/// enabled so it survives process death.
Future<void> enqueue(PendingOperation operation) async {
int? id;
if (_client.persistenceEnabled) {
try {
id = await _client.chatPersistenceClient!.insertPendingOperation(
operation,
);
} catch (error, stk) {
// Keep the operation in memory so it still replays this session.
_client.logger.warning(
'Failed to persist pending operation',
error,
stk,
);
}
}
_operations.add(operation.copyWith(id: id ?? _nextMemoryId()));
}

/// Loads any persisted operations into the in-memory queue.
///
/// Called once per connect to restore operations that survived process
/// death. A no-op when persistence is disabled.
Future<void> hydrate() async {
if (!_client.persistenceEnabled) return;
try {
final stored = await _client.chatPersistenceClient!.getPendingOperations();
_operations
..clear()
..addAll(stored);
} catch (error, stk) {
_client.logger.warning(
'Failed to hydrate pending operations',
error,
stk,
);
}
}

/// Empties the in-memory queue.
///
/// Must be called on disconnect so a user's queued operations never replay
/// under a different user. The persisted mirror is user-scoped and closed
/// separately with the persistence connection.
void clear() {
_operations.clear();
_memorySeq = 0;
}
Comment on lines +90 to +93

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.

Major — resetting _memorySeq makes session ids collide, and a stale replay then silently deletes a new operation.

_nextMemoryId() returns --_memorySeq, so after clear() the next memory-only operation gets -1 again. _remove(id) matches purely on it.id == id (L98). Combined with the in-flight-replay issue above:

  1. Persistence off. m1 (id -1) and m2 (id -2) queued. replay() starts, m1 in flight.
  2. clear() (disconnect). New session enqueues m99 → id -1.
  3. m1 succeeds → the stale loop calls _remove(-1)removeWhere deletes m99.

The user's reaction is never sent, local optimistic state keeps showing it, and nothing is logged. Ids need to stay unique for the process lifetime:

Suggested change
void clear() {
_operations.clear();
_memorySeq = 0;
}
void clear() {
_operations.clear();
// Do NOT reset `_memorySeq`: session ids must stay unique for the whole
// process lifetime, otherwise a replay that is still draining its snapshot
// can `_remove` an id that now belongs to a new session's operation.
}

This is the demonstrated instance of a broader gap: nothing owns the id value domain. PendingOperation.id is documented as "the database autoincrement id" while this class also mints negative session ids; insertPendingOperation's contract never states that ids must be positive and unique; _remove overloads the sign as a storage-tier discriminator (L99); and hydrate() trusts id! unvalidated, so a custom ChatPersistenceClient returning a null id throws into the blanket catch and pins that operation in memory for the rest of the process. See the type-safety suggestion on pending_operation.dart for a fix that removes the whole class of problem.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Pretty related to remark 1, I will see what we can do here.


/// Removes the operation with the given [id] from memory and, when persisted,
/// from the mirror. A delete of a memory-only (negative) id is a no-op.
Future<void> _remove(int id) async {
_operations.removeWhere((it) => it.id == id);
if (!_client.persistenceEnabled || id < 0) return;
try {
await _client.chatPersistenceClient!.deletePendingOperation(id);
} catch (error, stk) {
_client.logger.warning(
'Failed to delete pending operation $id',
error,
stk,
);
}
}

/// Replays each queued operation against the server in insertion order.
Future<void> replay() async {
if (_isReplaying) return;
_isReplaying = true;
Comment on lines +111 to +114

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.

Major — replay has exactly one trigger, a dropped trigger is never rescheduled, and there is one supported mode where it never fires at all.

git grep pendingOperationsManager on this branch returns a single replay() call site: the connectionRecovered branch at client.dart:621. Three consequences:

  1. Retriable failure while the WebSocket stays up strands the queue. A receive timeout leaves operations queued, but there is no timer, no backoff and no RetryQueue/RetryPolicy hook — they wait for the next full disconnect → connect cycle.
  2. if (_isReplaying) return; drops the trigger silently. There is no "replay again when the current pass finishes" flag, so a recovery that lands while a replay is draining is simply lost. Combined with (1), operations can sit for the rest of the session while local state stays diverged — and, per the client.dart comment, visibly wrong after the re-query.
  3. connectUser(..., connectWebSocket: false) never produces a status transition, so queued operations never replay in connection-less mode. That is a documented public flag, and the feature is inert there.

(3) is the sharpest of the three — worth either handling explicitly or documenting as unsupported. The guard itself is also completely uncovered by tests; a test that calls replay() twice against an unresolved future would pin the intended behaviour.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

  1. Do we want to act on this? I feel like the next reconnect is fine trade off between retry in a sensible manner, and the implementation complexity. But if you think we would benefit from having some additional logic for this case, we can implement something.
  2. What is your suggestion here: Should we enqueue multiple calls to replay()?
  3. We could potentially add the following line at the end of _connectUser: if (!connectWebSocket) unawaited(pendingOperationsManager.replay()); - however unawaited might be risky here, as the calls could collide with the subsequent QueryChannels/GetOrCreateChannel calls, but on the other hand, I think we shouldn't have any other blocking operations in _connectUser, we should return from this method as soon as possible IMO.


try {
// Copy so removals during replay don't mutate the list being iterated.
final operations = List.of(_operations);
for (final operation in operations) {
Comment on lines +116 to +119

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.

Blocker — clear() does not cancel an in-flight replay, so operations can be sent under the next user's credentials.

replay() iterates this snapshot, awaiting one network call per operation. clear() (L90-93) only empties _operations — it sets no cancellation flag, the loop never re-checks membership, and _isReplaying self-clears in the finally, so nothing stops the iteration either.

Sequence (verified against this branch with a throwaway test):

  1. User A has [m1, m2] queued; connection recovers, replay() starts, sendReaction(m1) is in flight on a slow network.
  2. disconnectUser() runs → pendingOperationsManager.clear() (client.dart:2561). Queue is empty.
  3. m1 completes. The loop continues over its stale copy and calls _client.sendReaction(m2, ...).

verify(sendReaction('m2', ...)).called(1) passes after clear(). If connectUser(B) has landed by step 3, _client holds B's token, so A's reaction is posted as B.

Second-order effect on the same path: _remove(id) then calls deletePendingOperation(id) against B's database (L99-101 early-returns only on !persistenceEnabled || id < 0). Ids are per-DB autoincrement from 1, so A's id: 1 plausibly deletes B's row 1.

The disconnectUser regression test in client_test.dart only covers replay() invoked after clear(), not clear() during a replay — so this is not caught.

Suggested fix — an epoch bumped by clear() and checked per iteration and before _remove:

int _generation = 0;

void clear() {
  _operations.clear();
  _generation++;
}

// inside replay():
final generation = _generation;
for (final operation in operations) {
  if (generation != _generation) return; // cleared mid-replay
  ...
}

Checking _operations.any((it) => it.id == operation.id) before each call would also work, but an epoch is cheaper and additionally protects the _remove call.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sounds like a valid concern, I will try to implement your proposal!

try {
final Future<void> Function()? call;
try {
call = _replayCallFor(operation);
} catch (error, stk) {
// Malformed payload for a known type — can never be replayed.
_client.logger.warning(
'Dropping unreplayable pending operation ${operation.id}',
error,
stk,
);
await _remove(operation.id!);
continue;
}

if (call == null) {
// Unknown type (e.g. persisted by a newer app version) — drop it.
_client.logger.warning(
'Dropping unknown pending operation type "${operation.type}" '
'(${operation.id})',
);
await _remove(operation.id!);
continue;
}

try {
await call();
} on StreamChatNetworkError catch (error) {
// Keep transient failures for the next recovery.
if (error.isRetriable) continue;
}

// Accepted or terminally rejected by the server — drop it.
await _remove(operation.id!);
Comment on lines +145 to +153

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.

Major — no fail-fast on connection-level failures, and non-network errors pin an operation forever.

Two separate problems in this block:

1. The loop continues past connection errors instead of breaking. A replay that is doomed after operation #1 (still offline, DNS failure, captive portal) still attempts every remaining operation, each burning a full Dio timeout. With N queued operations that is N × timeout seconds — and because client.dart:621 awaits this whole thing, it blocks sync() and channel recovery for that entire time. Recommend breaking out on the first StreamChatNetworkErrorType.connectionError / *Timeout, since those say nothing about the next operation but everything about the connection.

2. Only StreamChatNetworkError is caught here. Anything else — a TypeError from an unexpected response shape, a raw DioException, a StateError — escapes to the outer catch at L154, gets logged, and the operation is neither removed nor marked. With no attempt counter on the row (see the PendingOperations table comment), such a poison operation is retried on every reconnect for the lifetime of the install. The observable symptom is "silently stuck", not a retry storm, because nothing re-triggers replay in between.

Worth noting the related isRetriable sharp edge, since this line is where it decides whether local state is kept: isRetriable => data == null classifies 429 and any enveloped 5xx as terminal, so those get rolled back rather than queued. True offline (connectionError, no body) does queue correctly, so the headline path works — but the canonical "retry later" responses currently don't.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

1. Valid concern, I will introduce detection of connectivity errors, and do an early exist in such cases.
2. Valid concern again - I think that in this case we should drop the operation? I am not sure that we can be certain it will ever recover, so perhaps the best option is to drop it?

} catch (error, stk) {
_client.logger.warning(
'Error replaying pending operation ${operation.id}',
error,
stk,
);
}
}
} catch (error, stk) {
_client.logger.severe(
'Error replaying pending operations',
error,
stk,
);
} finally {
_isReplaying = false;
}
}

/// Returns the server call that replays [operation], or `null` if its type
/// is unknown to this version.
Future<void> Function()? _replayCallFor(PendingOperation operation) {
switch (operation.type) {
case ReactionPendingOperation.addType:
final targetMessageId = operation.targetMessageId;
if (targetMessageId == null) {
throw StateError('Missing targetMessageId for ${operation.type}');
}
final reaction = Reaction.fromJson(
operation.payload[ReactionPendingOperation.reactionKey] as Map<String, dynamic>,
);
final skipPush = operation.payload[ReactionPendingOperation.skipPushKey] as bool? ?? false;
final enforceUnique = operation.payload[ReactionPendingOperation.enforceUniqueKey] as bool? ?? false;
return () => _client.sendReaction(
targetMessageId,
reaction,
skipPush: skipPush,
enforceUnique: enforceUnique,
);
case ReactionPendingOperation.deleteType:
final targetMessageId = operation.targetMessageId;
if (targetMessageId == null) {
throw StateError('Missing targetMessageId for ${operation.type}');
}
final reactionType = operation.payload[ReactionPendingOperation.reactionTypeKey] as String;
return () => _client.deleteReaction(targetMessageId, reactionType);
default:
// Unknown operation type — cannot be replayed by this version.
return null;
}
}
Comment on lines +175 to +204

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.

Major — inconsistent strictness at the parse boundary; the silent ?? false is a real divergence bug, not just a style issue.

Within one function there are three different policies for missing data:

  • targetMessageId missing → throw StateError (L179-181, L195-197)
  • reaction → unchecked as Map<String, dynamic> (L183)
  • skip_push / enforce_uniquesilent as bool? ?? false (L185-186)

The third one is the problem. enforceUnique is the flag the optimistic update already applied (channel.dart:1613 calls addMyReaction(..., enforceUnique: enforceUnique)), so if the key is ever missing or renamed, replay sends enforceUnique: false: local state shows one reaction replacing the previous one, while the server keeps both. Then the post-recovery re-query overwrites reactionGroups from server truth (see the client.dart thread) and the user ends up with two chips they never asked for. Silent, permanent, user-visible.

Also note the StateErrors exist only because targetMessageId is nullable on the model and in the DB column while being required by every operation type that exists — two of the eight manager tests exist purely to prove an impossible state is detected.

Minimum fix here is consistency — treat a missing enforce_unique/skip_push exactly like a missing targetMessageId, so it lands in the "unreplayable, drop it" path rather than silently changing semantics:

final skipPush = operation.payload[ReactionPendingOperation.skipPushKey] as bool;
final enforceUnique = operation.payload[ReactionPendingOperation.enforceUniqueKey] as bool;

The better fix is to stop parsing at replay time altogether — see the suggestion on pending_operation.dart, which makes this whole function exhaustive and cast-free.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These two fields are optional in the API being replayed. Channel.sendReaction(Message, Reaction, {bool skipPush = false, bool enforceUnique = false}) — so sendReaction(id, reaction) is sendReaction(id, reaction, skipPush: false, enforceUnique: false). "Absent ⇒ false" is the method's own contract, not a substitution replay invents.

Field In the replayed API Policy
targetMessageId required explicit StateError
reaction required strict cast → drop
reaction_type required (deleteReaction(String, String)) strict cast → drop
skip_push / enforce_unique optional, default false ?? false

I also think that renaming the keys is almost impossible in real life - it will be a breaking change. But if you disagree, I am happy to put the strict cast -> drop logic for skip_push / enforce_unique as well

}
Loading
Loading