Fix: Bot Chat Page Crash on Conversation Switch
File: services/frontend/src/routes/bots/BotDetailPage.tsx
Problem
Switching between conversations on the Bot Chat Page caused a hard crash (React error #185 — too many re-renders / invalid state during concurrent reconciliation) plus a flood of 500/404 errors for the artifacts endpoint.
Root Cause: Effect B Ping-Pong
The component has three interacting effects:
- Effect A1 (
[id, activeTab, conversationIdParam]): URL→state — whenconversationIdParamchanges, setsactiveConversationId. - Effect B (
[id, activeTab, activeConversationId, conversationIdParam, navigate]): state→URL — whenactiveConversationIdchanges, syncs the URL vianavigate().
When the user clicked a sidebar conversation item, the following race occurred:
navigate('/bots/:id/chat/:newConvId')is called → URL changes →conversationIdParam = newConvId.- React re-renders. Both effects fire for this render.
- Effect A1 fires first:
setActiveConversationId(newConvId)— schedules a second render. - Effect B fires (from the same render): its closure still has
activeConversationId = oldConvIdandconversationIdParam = newConvId. Since they differ, it callsnavigate('/bots/:id/chat/:oldConvId')— reverting the URL! - URL reverts to
oldConvId→ another render → Effect A1 fires again → loop → crash.
The comment in the code already named this pattern (React error #185) and partially fixed it with a ref for activeConversationId in Effect A1. But Effect B itself was still reactive to conversationIdParam, which was the actual trigger.
Fix
1. conversationIdParamRef added alongside existing refs
const conversationIdParamRef = React.useRef(conversationIdParam);
conversationIdParamRef.current = conversationIdParam;
2. Effect B — removed conversationIdParam from dep array, reads via ref
React.useEffect(() => {
if (!id || activeTab !== 'chat') return;
if (!activeConversationId) {
if (conversationIdParamRef.current) navigate(`/bots/${id}/chat`, { replace: true });
return;
}
if (conversationIdParamRef.current === activeConversationId) return;
navigate(`/bots/${id}/chat/${activeConversationId}`, { replace: false });
// biome-ignore lint/react-hooks/exhaustive-deps: conversationIdParam read via ref to prevent ping-pong
}, [id, activeTab, activeConversationId, navigate]);
Effect B now only fires when activeConversationId changes (programmatic switch), not when the URL changes (user navigation). It reads the current conversationIdParam via ref which is always up-to-date at render time.
3. Re-seed effect — clear chatHistory before async fetch
Added setChatHistory([]) synchronously at the start of the re-seed effect so the old conversation's messages are never visible while the new conversation's messages are loading.
4. StreamingChatPanel — added key={activeConversationId ?? 'no-conv'}
Without a key, React reuses the same StreamingChatPanel instance across conversation switches, leaking internal state: isStreaming, typeahead buffer/timers, input text, abortRef, etc. Adding key forces a clean mount per conversation.
What Was Not Changed
- Effect A1 (unchanged, already correct with
activeConversationIdRefguard) - Effect A2 (unchanged, correct — only runs once when
activeConversationId === null) - Pre-existing intentional
conversationIdParamexclusion from the initial load effect dep array