From 62ab73402194185881d256d9aa5bc0e436dabc27 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Fri, 11 Sep 2026 13:17:00 +0800 Subject: [PATCH] fix(web): guard chat window fetch races on session switch Co-authored-by: Cursor --- web/src/api/chatWindow.ts | 5 +- web/src/hooks/useChatWindow.race.test.ts | 70 ++++++++++++++++++++++++ web/src/hooks/useChatWindow.ts | 29 +++++++++- 3 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 web/src/hooks/useChatWindow.race.test.ts diff --git a/web/src/api/chatWindow.ts b/web/src/api/chatWindow.ts index ff276079..95c7d33c 100644 --- a/web/src/api/chatWindow.ts +++ b/web/src/api/chatWindow.ts @@ -100,14 +100,15 @@ export function mapRawBucket(b: RawChatDayBucket): ChatDayBucket { export async function fetchChatWindow( sessionId: string, days: number = 7, - beforeDay?: string + beforeDay?: string, + signal?: AbortSignal, ): Promise { let url = `/api/v1/chat/messages/window?session_id=${encodeURIComponent(sessionId)}&days=${days}`; if (beforeDay) { url += `&before_day=${encodeURIComponent(beforeDay)}`; } - const res = await fetch(url); + const res = await fetch(url, { signal }); if (!res.ok) { throw new Error(`fetchChatWindow failed: HTTP ${res.status}`); } diff --git a/web/src/hooks/useChatWindow.race.test.ts b/web/src/hooks/useChatWindow.race.test.ts new file mode 100644 index 00000000..a6996a0a --- /dev/null +++ b/web/src/hooks/useChatWindow.race.test.ts @@ -0,0 +1,70 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +/** 模拟 A→B→A 窗口拉取竞态:B 晚到不得覆盖 A */ +test('window fetch race: stale response ignored by generation', async () => { + type Resolver = (value: { sessionId: string; days: { dayKey: string; messages: { id: string }[] }[] }) => void; + + let generation = 0; + const pending = new Map(); + + const fetchForSession = (sessionId: string, gen: number) => + new Promise<{ sessionId: string; days: { dayKey: string; messages: { id: string }[] }[] }>((resolve) => { + pending.set(gen, resolve); + }).then((res) => { + if (gen !== generation) return null; + if (res.sessionId !== sessionId) return null; + return res; + }); + + let dayStack: { dayKey: string; messages: { id: string }[] }[] = []; + + const applyIfCurrent = ( + gen: number, + sessionId: string, + res: { sessionId: string; days: typeof dayStack } | null, + ) => { + if (!res || gen !== generation) return; + if (res.sessionId !== sessionId) return; + dayStack = res.days; + }; + + // A1 发出 + generation = 1; + const a1Gen = 1; + const a1Promise = fetchForSession('session-A', a1Gen); + + // 切 B + generation = 2; + const bGen = 2; + const bPromise = fetchForSession('session-B', bGen); + + // 再切回 A + generation = 3; + const a2Gen = 3; + const a2Promise = fetchForSession('session-A', a2Gen); + + // A2 先返回 + pending.get(a2Gen)!({ + sessionId: 'session-A', + days: [{ dayKey: '2026-09-11', messages: [{ id: 'msg-a2' }] }], + }); + applyIfCurrent(a2Gen, 'session-A', await a2Promise); + assert.deepEqual(dayStack[0].messages[0].id, 'msg-a2'); + + // B 晚到 — 应被 generation 丢弃 + pending.get(bGen)!({ + sessionId: 'session-B', + days: [{ dayKey: '2026-09-11', messages: [{ id: 'msg-b-stale' }] }], + }); + applyIfCurrent(bGen, 'session-B', await bPromise); + assert.deepEqual(dayStack[0].messages[0].id, 'msg-a2'); + + // A1 更晚到 — 同样丢弃 + pending.get(a1Gen)!({ + sessionId: 'session-A', + days: [{ dayKey: '2026-09-11', messages: [{ id: 'msg-a1-stale' }] }], + }); + applyIfCurrent(a1Gen, 'session-A', await a1Promise); + assert.deepEqual(dayStack[0].messages[0].id, 'msg-a2'); +}); diff --git a/web/src/hooks/useChatWindow.ts b/web/src/hooks/useChatWindow.ts index 2bdb4c03..9ccc19ee 100644 --- a/web/src/hooks/useChatWindow.ts +++ b/web/src/hooks/useChatWindow.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { ChatMessage, ChatDayBucket, MessageCategory } from '../types/workbench'; import { fetchChatWindow, @@ -75,6 +75,20 @@ export function useChatWindow({ const [selectedCategory, setSelectedCategory] = useState('all'); const [draft, setDraftState] = useState(() => loadSessionDraft(sessionId)); + const generationRef = useRef(0); + const abortRef = useRef(null); + + // session 切换时立即恢复该 session 的窗口缓存骨架,避免展示上一 session 内容 + useEffect(() => { + abortRef.current?.abort(); + generationRef.current += 1; + const cached = loadSessionWindowCache(sessionId); + setDayStack(cached && cached.length > 0 ? cached : []); + setHasMoreBefore(true); + setOldestLoadedDay(''); + setPhase(cached && cached.length > 0 ? 'ready' : 'loading_window'); + setDraftState(loadSessionDraft(sessionId)); + }, [sessionId]); const setDraft = useCallback( (newDraft: string) => { @@ -88,10 +102,17 @@ export function useChatWindow({ // 初次拉取窗口 const loadInitialWindow = useCallback(async () => { if (!sessionId) return; + const generation = ++generationRef.current; + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + setPhase('loading_window'); try { - const res = await fetchChatWindow(sessionId, days); + const res = await fetchChatWindow(sessionId, days, undefined, controller.signal); + if (controller.signal.aborted || generation !== generationRef.current) return; + if (res.sessionId !== sessionId) return; if (res.days.length > 0) { setDayStack(res.days); setHasMoreBefore(res.hasMoreBefore); @@ -118,6 +139,7 @@ export function useChatWindow({ } setPhase('ready'); } catch (err) { + if (controller.signal.aborted || generation !== generationRef.current) return; console.warn('[useChatWindow] fetch window failed, falling back to local cache/fallback', err); // 若已有本地快照则维持 const cached = loadSessionWindowCache(sessionId); @@ -142,6 +164,9 @@ export function useChatWindow({ useEffect(() => { loadInitialWindow(); + return () => { + abortRef.current?.abort(); + }; }, [loadInitialWindow]); // 触顶加载更早日 (P3: 滚动位置由虚拟化器 scrollToIndex 恢复,无需 DOM 补偿)