Skip to content

Bug: ownReactions field resets when adding multiple reactions #57

@andrewmikhniuk

Description

@andrewmikhniuk

Race Condition: ownReactions Reset When Adding Multiple Reactions

Description

When adding multiple reactions to an activity in quick succession, the ownReactions field gets reset to an empty or incomplete list after the second reaction is added. This prevents users from seeing their own reactions correctly in the UI.

Environment

  • SDK Version: stream_feeds: ^0.3.1
  • Dart SDK: 3.9.0

Steps to Reproduce

  1. Connect to a feed and load activities
  2. Add a reaction to an activity using feed.addActivityReaction()
  3. Immediately add a second reaction (different type) to the same activity
  4. Observe that ActivityData.ownReactions is reset/incomplete after the second reaction

Code Example:

// First reaction - works correctly
await feed.addActivityReaction(
  activityId: activity.id,
  request: AddReactionRequest(
    type: 'like',
    createNotificationActivity: false,
    enforceUnique: false,
  ),
);

// Second reaction - causes ownReactions to reset
await feed.addActivityReaction(
  activityId: activity.id,
  request: AddReactionRequest(
    type: 'heart',
    createNotificationActivity: false,
    enforceUnique: false,
  ),
);

// After second reaction, activity.ownReactions is incomplete or empty

Expected Behavior

After adding multiple reactions, ActivityData.ownReactions should contain all reactions added by the current user:

// Expected after adding two reactions
activity.ownReactions.length == 2  // Contains both 'like' and 'heart'

Actual Behavior

After adding the second reaction, ActivityData.ownReactions is reset to an empty list or contains only the most recent reaction:

// Actual behavior
activity.ownReactions.length == 0  // or == 1 (only latest reaction)

Root Cause Analysis

The issue appears to be a race condition between WebSocket events in the state management layer:

  1. User adds reaction → addActivityReaction() API call (no optimistic update)
  2. WebSocket receives ActivityReactionAddedEventonReactionAdded() correctly updates ownReactions
  3. Then WebSocket receives ActivityUpdatedEvent (or similar) → onActivityUpdated() replaces the entire activity with potentially stale data ❌

The problem occurs in the state notifiers:

Both methods use sortedUpsert() which replaces the entire ActivityData object, including user-specific fields like ownReactions and ownBookmarks that may be more up-to-date in the local state than in the WebSocket event payload.

Impact

  • Users cannot see their own reactions after adding multiple reactions
  • Similar issue likely affects ownBookmarks
  • Impacts any user-specific activity fields that can change rapidly
  • Creates poor UX as reactions appear to disappear

Proposed Solution

Modify onActivityUpdated() in both state notifiers to intelligently merge incoming updates with existing state, preserving user-specific fields that might be more current locally:

High-Level Approach

  1. Before updating, check if the activity already exists in state
  2. If it exists, compare user-specific fields (ownReactions, ownBookmarks) between existing and incoming data
  3. Preserve the version with more items (reasonable heuristic for "more up-to-date")
  4. Apply the merge before calling sortedUpsert()

Example Implementation

void onActivityUpdated(ActivityData activity) {
  // Find existing activity in state
  final existingActivity = state.activities.firstWhereOrNull(
    (it) => it.id == activity.id,
  );
  
  // Merge with existing to preserve user-specific fields
  final mergedActivity = existingActivity != null
      ? activity.copyWith(
          ownReactions: _mergeList(
            existing: existingActivity.ownReactions,
            incoming: activity.ownReactions,
          ),
          ownBookmarks: _mergeList(
            existing: existingActivity.ownBookmarks,
            incoming: activity.ownBookmarks,
          ),
        )
      : activity;
  
  final updatedActivities = state.activities.sortedUpsert(
    mergedActivity,
    key: (it) => it.id,
    compare: activitiesSort.compare,
  );

  state = state.copyWith(activities: updatedActivities);
}

// Helper: Keep the list with more items (assumes it's more up-to-date)
List<T> _mergeList<T>({
  required List<T> existing,
  required List<T> incoming,
}) {
  if (incoming.length > existing.length) return incoming;
  if (existing.length > incoming.length) return existing;
  return incoming; // Same length, prefer incoming
}

Alternative Solutions

  1. Optimistic Updates: Update local state immediately when calling addActivityReaction(), before WebSocket confirmation
  2. Event Timestamp Comparison: Only apply updates if incoming event timestamp is newer than local state
  3. Debouncing: Debounce onActivityUpdated() to reduce race condition window

Workaround

As a temporary workaround, you could implement optimistic updates in your application code:

// Manually update state before API call
final optimisticActivity = activity.addReaction(newReaction, currentUserId);
// Update your local state with optimisticActivity

await feed.addActivityReaction(...);

However, this is not ideal as it requires application-level state management duplication.

Additional Context

This issue is particularly noticeable when:

  • Users add multiple reactions quickly (e.g., like + heart)
  • Network latency causes WebSocket events to arrive close together
  • Working with high-frequency user interactions

Files Affected

  • packages/stream_feeds/lib/src/state/feed_state.dart (line 122-139)
  • packages/stream_feeds/lib/src/state/activity_list_state.dart (line 61-70)
  • Event handlers in packages/stream_feeds/lib/src/state/event/ (indirect)

I'm happy to provide more details or test a fix if needed. Thank you for maintaining this excellent SDK!

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions