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: noneare 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
- Single Responsibility: One component should handle global notifications
- Tab Awareness: Pass active state to components that need it
- Event Scoping: Scope event handlers to only active/relevant components
Files Modified
src/components/MainContent.jsx- Added activeTab/currentTab propssrc/components/ChatInterface.jsx- Added conditional toast displaydocs/tech/lessons-learned/toast-notification-deduplication.md- This document
Testing
To verify the fix:
- Create a markdown document using Claude
- Observe exactly 1 toast notification appears
- Switch between tabs and repeat - only active tab shows toasts
- 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.