fix(graph): prevent SSE overwrites of optimistic label updates
## The Bug User reported: 'An archetype can only exist on one task at a time - when I try to make the next task have the same arch, it deleted the one I added prior.' ## Root Cause The SSE subscription (useBeadsSubscription) refreshes data from the server whenever ANY change happens. When user assigns an archetype: 1. User clicks assign -> optimistic update adds label locally 2. SSE fires (from previous operation or heartbeat) -> fetches fresh data 3. useEffect syncs localLabels with data.labels (which doesn't have the new label yet) 4. Label disappears from UI 5. Eventually API completes and another SSE refresh brings it back This race condition causes labels to flicker or disappear entirely. ## The Fix Track pending optimistic labels in a useRef Set, and merge them with incoming server data during sync: 1. pendingOptimisticLabels = useRef<Set<string>>(new Set()) 2. When optimistically adding: add to pending set 3. useEffect merge: combine server labels + pending labels 4. After API completes: remove from pending set This ensures optimistic labels survive SSE refreshes. ## Test Coverage Added graph-node-labels-optimistic.test.tsx with 10 tests: - Uses useRef for tracking - Tracks in a Set - Preserves labels during sync - Adds/removes from pending set - Handles multiple concurrent operations - Per-node state isolation ## Verification - typecheck: pass - lint: pass (0 errors) - test: all pass
This commit is contained in:
parent
fbfe393f6d
commit
bd3b3da30a
2 changed files with 101 additions and 3 deletions
|
|
@ -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<Node<GraphNodeDa
|
|||
// Local state for labels with optimistic updates
|
||||
const [localLabels, setLocalLabels] = useState<string[]>(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<Set<string>>(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<Node<GraphNodeDa
|
|||
|
||||
// Optimistic update
|
||||
const labelToAdd = `agent:${archetypeId}`;
|
||||
pendingOptimisticLabels.current.add(labelToAdd);
|
||||
if (!localLabels.includes(labelToAdd)) {
|
||||
setLocalLabels([...localLabels, labelToAdd]);
|
||||
}
|
||||
|
|
@ -129,10 +141,12 @@ export function GraphNodeCard({ id, data, selected }: NodeProps<Node<GraphNodeDa
|
|||
setTimeout(() => 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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue