Last updated: Sep 1, 2025, 01:10 PM UTC

Toast Notification Deduplication - Lessons Learned

Date: 2025-01-20
Issue: Multiple duplicate toast notifications appearing when Claude creates markdown documents
Status: Resolved

Problem Description

When Claude Code created markdown documents, users experienced 3 identical toast notifications appearing simultaneously in the UI instead of the expected single notification.

Initial investigation focused on server-side deduplication, assuming Claude was sending multiple WebSocket messages for the same document operation.

Root Cause Analysis

Initial Hypothesis (Incorrect)

  • Server-side deduplication failure
  • Multiple WebSocket messages being sent
  • Race conditions in document detection logic

Actual Root Cause (Correct)

Multiple ChatInterface components were mounted simultaneously, each listening to the same WebSocket messages:

// MainContent.jsx - 3 ChatInterface instances
<div className={`${activeTab === 'chat' ? 'block' : 'hidden'}`}>
  <ChatInterface /> {/* Instance 1 - Chat tab */}
</div>
<div className={`${activeTab === 'guides' ? 'block' : 'hidden'}`}>
  <ChatInterface /> {/* Instance 2 - Guides tab */}
</div>
<div className={`${activeTab === 'personas' ? 'block' : 'hidden'}`}>
  <ChatInterface /> {/* Instance 3 - Personas tab */}
</div>

Even though only one was visible (using CSS display: none), all 3 were mounted and processing WebSocket events.

Solution Implemented

1. Add Tab Awareness to Components

Pass active tab information to each ChatInterface:

<ChatInterface
  activeTab={activeTab}
  currentTab="chat" // Unique identifier for this instance
  // ... other props
/>

2. Conditional Toast Display

Only show toasts when the component's tab is active:

case 'document-created':
  // Only show toast if this ChatInterface component is currently active
  if (activeTab === currentTab) {
    showToast(`${emoji} Document ${operation}: ${readableName}`, {
      type: 'success',
      duration: 7000
    });
  }
  break;

3. Enhanced Debugging

Added logging to track which component handles notifications:

console.log(`Document notification: ${readableName} - shown by ${currentTab} tab`);
// vs
console.log(`Document notification: ${readableName} - ignored by ${currentTab} tab`);

Key Lessons Learned

1. React Component Lifecycle Misunderstanding

  • Hidden β‰  Unmounted: Components with display: none are still mounted and active
  • Event listeners and useEffect hooks continue running in hidden components
  • Consider using conditional rendering ({condition && <Component />}) vs CSS hiding

2. WebSocket Event Handling Patterns

  • Multiple components can subscribe to the same WebSocket events
  • Global events (like notifications) need careful coordination between components
  • Consider implementing event deduplication at the application level, not just server level

3. Debugging Complex UI Issues

  • Look at component architecture first before diving into complex logic
  • Check for multiple instances of the same component
  • UI issues often have simpler causes than suspected
  • Server-side logs can be misleading when the issue is client-side

4. React Patterns for Tab-Based UIs

// ❌ Problematic: All components mounted
{tabs.map(tab => (
  <div className={activeTab === tab ? 'block' : 'hidden'}>
    <Component />
  </div>
))}

// βœ… Better: Conditional rendering
{activeTab === 'chat' && <ChatInterface />}
{activeTab === 'guides' && <GuidesInterface />}

// βœ… Also good: Tab awareness
<Component 
  isActive={activeTab === currentTab}
  onGlobalEvent={isActive ? handleEvent : undefined}
/>

Alternative Solutions Considered

1. Conditional Rendering

Replace CSS hiding with conditional mounting:

{activeTab === 'chat' && <ChatInterface />}

Pros: Simpler, no duplicate listeners
Cons: Loses component state when switching tabs

2. Global Event Management

Centralize WebSocket event handling at App level:

// App.jsx handles all WebSocket events
// Pass down only relevant data to components

Pros: Single source of truth
Cons: More complex prop drilling

3. Event Deduplication Hook

Create a custom hook to deduplicate events:

const useDeduplicatedEvent = (eventType, handler, deps) => {
  // Only call handler from one component instance
}

Pros: Reusable pattern
Cons: Added complexity

Implementation Impact

  • Fixed: Single toast notification per document operation
  • Improved: Clear debugging logs for notification flow
  • Maintained: All existing functionality preserved
  • Performance: No negative impact, potentially better (fewer toast renders)

Prevention Guidelines

Code Review Checklist

  • Check for multiple instances of components with global event listeners
  • Verify conditional rendering vs CSS hiding implications
  • Ensure WebSocket event handlers are properly scoped
  • Test notification/toast systems across all UI states

Architecture Patterns

  1. Single Responsibility: One component should handle global notifications
  2. Tab Awareness: Pass active state to components that need it
  3. Event Scoping: Scope event handlers to only active/relevant components

Files Modified

  • src/components/MainContent.jsx - Added activeTab/currentTab props
  • src/components/ChatInterface.jsx - Added conditional toast display
  • docs/tech/lessons-learned/toast-notification-deduplication.md - This document

Testing

To verify the fix:

  1. Create a markdown document using Claude
  2. Observe exactly 1 toast notification appears
  3. Switch between tabs and repeat - only active tab shows toasts
  4. Check browser console for proper logging

Key Takeaway: When debugging UI issues with multiple identical symptoms, check for multiple component instances before assuming complex backend problems. The simplest explanation is often correct.