fix(web): guard chat window fetch races on session switch
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
weichen
co-authored by
Cursor
parent
7c830146f5
commit
62ab734021
@@ -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<ChatWindowResult> {
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -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<number, Resolver>();
|
||||
|
||||
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');
|
||||
});
|
||||
@@ -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<MessageCategory | 'all'>('all');
|
||||
const [draft, setDraftState] = useState<string>(() => loadSessionDraft(sessionId));
|
||||
const generationRef = useRef(0);
|
||||
const abortRef = useRef<AbortController | null>(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 补偿)
|
||||
|
||||
Reference in New Issue
Block a user