Skip to main content

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 — when conversationIdParam changes, sets activeConversationId.
  • Effect B ([id, activeTab, activeConversationId, conversationIdParam, navigate]): state→URL — when activeConversationId changes, syncs the URL via navigate().

When the user clicked a sidebar conversation item, the following race occurred:

  1. navigate('/bots/:id/chat/:newConvId') is called → URL changes → conversationIdParam = newConvId.
  2. React re-renders. Both effects fire for this render.
  3. Effect A1 fires first: setActiveConversationId(newConvId) — schedules a second render.
  4. Effect B fires (from the same render): its closure still has activeConversationId = oldConvId and conversationIdParam = newConvId. Since they differ, it calls navigate('/bots/:id/chat/:oldConvId') — reverting the URL!
  5. 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 activeConversationIdRef guard)
  • Effect A2 (unchanged, correct — only runs once when activeConversationId === null)
  • Pre-existing intentional conversationIdParam exclusion from the initial load effect dep array