diff --git a/frontend/src/hooks/useConversationMessages.ts b/frontend/src/hooks/useConversationMessages.ts index 9c507d8..e0ba482 100644 --- a/frontend/src/hooks/useConversationMessages.ts +++ b/frontend/src/hooks/useConversationMessages.ts @@ -137,6 +137,7 @@ export function useConversationMessages( const abortControllerRef = useRef(null); const fetchingConversationIdRef = useRef(null); + const latestReconcileRequestIdRef = useRef(0); const messagesRef = useRef([]); const hasOlderMessagesRef = useRef(false); const hasNewerMessagesRef = useRef(false); @@ -215,7 +216,7 @@ export function useConversationMessages( ); const reconcileFromBackend = useCallback( - (conversation: Conversation, signal: AbortSignal) => { + (conversation: Conversation, signal: AbortSignal, requestId: number) => { const conversationId = conversation.id; api .getMessages( @@ -228,6 +229,7 @@ export function useConversationMessages( ) .then((data) => { if (fetchingConversationIdRef.current !== conversationId) return; + if (latestReconcileRequestIdRef.current !== requestId) return; const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); const merged = messageCache.reconcile(messagesRef.current, dataWithPendingAck); @@ -352,7 +354,9 @@ export function useConversationMessages( const triggerReconcile = useCallback(() => { if (!isMessageConversation(activeConversation)) return; const controller = new AbortController(); - reconcileFromBackend(activeConversation, controller.signal); + const requestId = latestReconcileRequestIdRef.current + 1; + latestReconcileRequestIdRef.current = requestId; + reconcileFromBackend(activeConversation, controller.signal, requestId); }, [activeConversation, reconcileFromBackend]); useEffect(() => { @@ -365,6 +369,7 @@ export function useConversationMessages( const conversationChanged = prevId !== newId; fetchingConversationIdRef.current = newId; prevConversationIdRef.current = newId; + latestReconcileRequestIdRef.current = 0; // Preserve around-loaded context on the same conversation when search clears targetMessageId. if (!conversationChanged && !targetMessageId) { @@ -433,7 +438,9 @@ export function useConversationMessages( seenMessageContent.current = new Set(cached.seenContent); setHasOlderMessages(cached.hasOlderMessages); setMessagesLoading(false); - reconcileFromBackend(activeConversation, controller.signal); + const requestId = latestReconcileRequestIdRef.current + 1; + latestReconcileRequestIdRef.current = requestId; + reconcileFromBackend(activeConversation, controller.signal, requestId); } else { void fetchLatestMessages(true, controller.signal); } diff --git a/frontend/src/test/useConversationMessages.race.test.ts b/frontend/src/test/useConversationMessages.race.test.ts index e56399a..38e3d44 100644 --- a/frontend/src/test/useConversationMessages.race.test.ts +++ b/frontend/src/test/useConversationMessages.race.test.ts @@ -224,6 +224,48 @@ describe('useConversationMessages conversation switch', () => { }); }); +describe('useConversationMessages background reconcile ordering', () => { + beforeEach(() => { + mockGetMessages.mockReset(); + messageCache.clear(); + }); + + it('ignores stale reconnect reconcile responses that finish after newer ones', async () => { + const conv = createConversation(); + mockGetMessages.mockResolvedValueOnce([ + createMessage({ id: 42, text: 'initial snapshot', acked: 0 }), + ]); + + const { result } = renderHook(() => useConversationMessages(conv)); + + await waitFor(() => expect(result.current.messagesLoading).toBe(false)); + expect(result.current.messages[0].text).toBe('initial snapshot'); + + const firstReconcile = createDeferred(); + const secondReconcile = createDeferred(); + mockGetMessages + .mockReturnValueOnce(firstReconcile.promise) + .mockReturnValueOnce(secondReconcile.promise); + + act(() => { + result.current.triggerReconcile(); + result.current.triggerReconcile(); + }); + + secondReconcile.resolve([createMessage({ id: 42, text: 'newer snapshot', acked: 2 })]); + await waitFor(() => expect(result.current.messages[0].text).toBe('newer snapshot')); + expect(result.current.messages[0].acked).toBe(2); + + firstReconcile.resolve([createMessage({ id: 42, text: 'stale snapshot', acked: 1 })]); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(result.current.messages[0].text).toBe('newer snapshot'); + expect(result.current.messages[0].acked).toBe(2); + }); +}); + describe('useConversationMessages forward pagination', () => { beforeEach(() => { mockGetMessages.mockReset();