Do an imitation of protecting our butts (race conditions in message loading, websocket defensiveness, optimistic UI update rollback handling

This commit is contained in:
Jack Kingsman
2026-01-19 11:47:20 -08:00
parent bf03d76c33
commit 0138233743
12 changed files with 553 additions and 91 deletions
+67 -10
View File
@@ -1,6 +1,6 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { toast } from '../components/ui/sonner';
import { api } from '../api';
import { api, isAbortError } from '../api';
import type { Conversation, Message, MessagePath } from '../types';
const MESSAGE_PAGE_SIZE = 200;
@@ -33,26 +33,49 @@ export function useConversationMessages(
// Track seen message content for deduplication
const seenMessageContent = useRef<Set<string>>(new Set());
// AbortController for cancelling in-flight requests on conversation change
const abortControllerRef = useRef<AbortController | null>(null);
// Ref to track the conversation ID being fetched to prevent stale responses
const fetchingConversationIdRef = useRef<string | null>(null);
// Fetch messages for active conversation
// Note: This is called manually and from the useEffect. The useEffect handles
// cancellation via AbortController; manual calls (e.g., after sending a message)
// don't need cancellation.
const fetchMessages = useCallback(
async (showLoading = false) => {
async (showLoading = false, signal?: AbortSignal) => {
if (!activeConversation || activeConversation.type === 'raw') {
setMessages([]);
setHasOlderMessages(false);
return;
}
// Track which conversation we're fetching for
const conversationId = activeConversation.id;
if (showLoading) {
setMessagesLoading(true);
// Clear messages first so MessageList resets scroll state for new conversation
setMessages([]);
}
try {
const data = await api.getMessages({
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
conversation_key: activeConversation.id,
limit: MESSAGE_PAGE_SIZE,
});
const data = await api.getMessages(
{
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
conversation_key: activeConversation.id,
limit: MESSAGE_PAGE_SIZE,
},
signal
);
// Check if this response is still for the current conversation
// This handles the race where the conversation changed while awaiting
if (fetchingConversationIdRef.current !== conversationId) {
// Stale response - conversation changed while we were fetching
return;
}
setMessages(data);
// Track seen content for new messages
seenMessageContent.current.clear();
@@ -62,6 +85,10 @@ export function useConversationMessages(
// If we got a full page, there might be more
setHasOlderMessages(data.length >= MESSAGE_PAGE_SIZE);
} catch (err) {
// Don't show error toast for aborted requests (user switched conversations)
if (isAbortError(err)) {
return;
}
console.error('Failed to fetch messages:', err);
toast.error('Failed to load messages', {
description: err instanceof Error ? err.message : 'Check your connection',
@@ -114,10 +141,40 @@ export function useConversationMessages(
}
}, [activeConversation, loadingOlder, hasOlderMessages, messages.length]);
// Fetch messages when conversation changes
// Fetch messages when conversation changes, with proper cancellation
useEffect(() => {
fetchMessages(true);
}, [fetchMessages]);
// Abort any previous in-flight request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Track which conversation we're now fetching
fetchingConversationIdRef.current = activeConversation?.id ?? null;
// Clear state for new conversation
if (!activeConversation || activeConversation.type === 'raw') {
setMessages([]);
setHasOlderMessages(false);
return;
}
// Create new AbortController for this fetch
const controller = new AbortController();
abortControllerRef.current = controller;
// Fetch messages with the abort signal
fetchMessages(true, controller.signal);
// Cleanup: abort request if conversation changes or component unmounts
return () => {
controller.abort();
};
// NOTE: Intentionally omitting fetchMessages and activeConversation from deps:
// - fetchMessages is recreated when activeConversation changes, which would cause infinite loops
// - activeConversation object identity changes on every render; we only care about id/type
// - We use fetchingConversationIdRef and AbortController to handle stale responses safely
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeConversation?.id, activeConversation?.type]);
// Add a message if it's new (deduplication)
// Returns true if the message was added, false if it was a duplicate