From 5c11c1db337478268e1d16568746ffeacfcf252c Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 1 Apr 2026 00:41:50 +0000 Subject: [PATCH] fix: prevent stale selectChat async results from clobbering new session task lane Race condition: on page remount, selectChat(oldId) loads session data async. If the user clicks New Chat before the API returns, the old session's pending_task_lane was being applied to the new session's state, showing stale tasks and blocking new ones from appearing. Fix: currentChatRef tracks the most recently requested chat ID synchronously. All chat-creation paths (selectChat, handleNewChat, handleResumeNew) update it immediately. After each await in selectChat, bail if the ref no longer matches. Also documents the pattern as Lesson 106 in CLAUDE.md for future reference. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 2 +- frontend/src/pages/AssistantChatPage.tsx | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 78ab6de5..3ebb8970 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -373,7 +373,7 @@ gh run view --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi **105. `npm run build` fails with `EACCES: permission denied` on `dist/` in code-server:** This is a filesystem permission issue in the Docker environment, not a TypeScript error — the TS compilation completes successfully. Use `npx tsc -b` to verify TypeScript cleanly without needing to write to `dist/`. ---- +**106. Guard async "select item → load data → apply state" flows with a ref:** When a component lets the user switch between items (chat sessions, flows, scripts) and loads data asynchronously on each switch, the load for item A can complete *after* the user has already switched to item B — overwriting B's state with A's stale data. Fix pattern: keep a `currentSelectionRef = useRef(initialId)` and update it synchronously whenever the selection changes (in every creation/switch path). After every `await`, bail out if `currentSelectionRef.current !== thisItemId`. See `AssistantChatPage.tsx` `selectChat` for the reference implementation (`currentChatRef`). ## RBAC & Permissions diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index da71faa2..9b6169cb 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -81,6 +81,9 @@ export default function AssistantChatPage() { const fileInputRef = useRef(null) const dragCounterRef = useRef(0) const prefillHandledRef = useRef(false) + // Tracks the most recently requested active chat ID so in-flight selectChat + // calls that complete after the user switches chats don't clobber new state. + const currentChatRef = useRef(activeChatId) // Persist active chat ID to sessionStorage useEffect(() => { @@ -214,6 +217,7 @@ export default function AssistantChatPage() { } const selectChat = useCallback(async (chatId: string) => { + currentChatRef.current = chatId setActiveChatId(chatId) // Clear TaskLane when switching chats — will restore from backend if available setShowTaskLane(false) @@ -221,6 +225,10 @@ export default function AssistantChatPage() { setActiveActions([]) try { const detail = await aiSessionsApi.getSession(chatId) + // Guard: if the user switched to a different chat while this API call was + // in flight (e.g. clicked "New Chat"), discard stale results so we don't + // clobber the new session's task lane state. + if (currentChatRef.current !== chatId) return setMessages( (detail.conversation_messages || []).map(m => ({ role: m.role as 'user' | 'assistant', @@ -264,6 +272,7 @@ export default function AssistantChatPage() { created_at: new Date().toISOString(), updated_at: new Date().toISOString(), } + currentChatRef.current = session.session_id setChats(prev => [chatItem, ...prev]) setActiveChatId(session.session_id) setMessages([]) @@ -430,6 +439,7 @@ export default function AssistantChatPage() { created_at: new Date().toISOString(), updated_at: new Date().toISOString(), } + currentChatRef.current = session.session_id setChats(prev => [chatItem, ...prev]) setActiveChatId(session.session_id) setMessages([{ role: 'user', content: resumePrompt }])