diff --git a/src/components/graph/graph-node-card.tsx b/src/components/graph/graph-node-card.tsx index 917ca69..bf5d1b2 100644 --- a/src/components/graph/graph-node-card.tsx +++ b/src/components/graph/graph-node-card.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import { Loader2, ChevronDown, UserPlus, X } from 'lucide-react'; @@ -92,9 +92,20 @@ export function GraphNodeCard({ id, data, selected }: NodeProps(data.labels ?? []); - // Sync local labels when parent data changes (e.g., on page refresh) + // Track pending optimistic labels to prevent SSE overwrites + const pendingOptimisticLabels = useRef>(new Set()); + + // Sync local labels when parent data changes, but preserve pending optimistic updates useEffect(() => { - setLocalLabels(data.labels ?? []); + const serverLabels = data.labels ?? []; + const pending = pendingOptimisticLabels.current; + if (pending.size === 0) { + setLocalLabels(serverLabels); + } else { + // Merge: include pending labels that aren't yet in server data + const merged = new Set([...serverLabels, ...pending]); + setLocalLabels(Array.from(merged)); + } }, [data.labels]); const archetypes = data.archetypes ?? []; @@ -108,6 +119,7 @@ export function GraphNodeCard({ id, data, selected }: NodeProps setAssignSuccess(null), 2000); } catch (err) { // Revert on error + pendingOptimisticLabels.current.delete(labelToAdd); setLocalLabels(prev => prev.filter(l => l !== labelToAdd)); setAssignError(err instanceof Error ? err.message : 'Failed to assign agent'); setTimeout(() => setAssignError(null), 3000); } finally { + pendingOptimisticLabels.current.delete(labelToAdd); setIsAssigning(false); } }; diff --git a/tests/components/graph/graph-node-labels-optimistic.test.tsx b/tests/components/graph/graph-node-labels-optimistic.test.tsx new file mode 100644 index 0000000..5d7b70f --- /dev/null +++ b/tests/components/graph/graph-node-labels-optimistic.test.tsx @@ -0,0 +1,84 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import fs from 'fs'; +import path from 'path'; + +describe('GraphNodeCard Optimistic Label Updates', () => { + const filePath = path.join(process.cwd(), 'src/components/graph/graph-node-card.tsx'); + const source = fs.readFileSync(filePath, 'utf-8'); + + it('uses useRef to track pending optimistic labels', () => { + assert.ok(source.includes('useRef'), 'Should import and use useRef for tracking pending operations'); + }); + + it('tracks pending optimistic labels in a Set', () => { + assert.ok( + source.includes('pendingOptimisticLabels') || source.includes('Set'), + 'Should track pending labels in a Set to prevent SSE overwrites' + ); + }); + + it('preserves optimistic labels when data.labels sync happens', () => { + // Should merge server labels with pending optimistic labels + assert.ok( + source.includes('merged') || source.includes('pending') || source.includes('preserve'), + 'Should merge server data with pending optimistic labels during sync' + ); + }); + + it('adds label to pending set when optimistic update happens', () => { + // When optimistically adding, should also track in pending set + assert.ok( + source.includes('pending') && source.includes('add'), + 'Should add to pending set when optimistically adding a label' + ); + }); + + it('removes label from pending set after successful API response', () => { + // After API success, the label is now in server data, so remove from pending + assert.ok( + source.includes('delete') || source.includes('pending') && source.includes('finally'), + 'Should clean up pending set after API completes' + ); + }); + + it('handles multiple rapid assign/unassign operations', () => { + // The pending set approach should handle concurrent operations + assert.ok( + source.includes('pendingOptimisticLabels') || source.includes('pending'), + 'Should use a tracking mechanism that handles multiple concurrent operations' + ); + }); +}); + +describe('GraphNodeCard Label State Isolation', () => { + const filePath = path.join(process.cwd(), 'src/components/graph/graph-node-card.tsx'); + const source = fs.readFileSync(filePath, 'utf-8'); + + it('each node has its own localLabels state', () => { + // localLabels should be useState, not shared across nodes + assert.ok(source.includes('useState'), 'Should use useState for per-node label state'); + }); + + it('localLabels is initialized from data.labels', () => { + assert.ok( + source.includes('localLabels') && source.includes('data.labels'), + 'localLabels should be initialized from data.labels prop' + ); + }); + + it('syncs with data.labels when parent refreshes', () => { + assert.ok( + source.includes('useEffect') && source.includes('data.labels'), + 'Should have useEffect to sync with data.labels changes' + ); + }); + + it('prevents sync overwrite during optimistic operations', () => { + // This is the key fix - should not blindly overwrite during operations + assert.ok( + source.includes('pending') || source.includes('skip') || source.includes('preserve'), + 'Should prevent SSE sync from overwriting optimistic updates' + ); + }); +});