Track message reconciliation and don't fire on stale returns

This commit is contained in:
Jack Kingsman
2026-03-11 19:20:38 -07:00
parent 5bd3205de5
commit 4363fd2a73
2 changed files with 52 additions and 3 deletions
+10 -3
View File
@@ -137,6 +137,7 @@ export function useConversationMessages(
const abortControllerRef = useRef<AbortController | null>(null);
const fetchingConversationIdRef = useRef<string | null>(null);
const latestReconcileRequestIdRef = useRef(0);
const messagesRef = useRef<Message[]>([]);
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);
}
@@ -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<Message[]>();
const secondReconcile = createDeferred<Message[]>();
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();