修复:更早历史读取按世代号失效旧请求

loadEarlierHistory 的成功 / 失败 / finally 守卫从「项目路径相等」改成「世代号相等」,A→B→A 之后在飞的旧读取不会再清掉新一代的加载态

新增 historyLoadTokenRef,切项目与每次读取各推进一格,旧世代落地时整段丢弃:不合并条目、不写游标、不关并发闸门

新增一例控制器用例驱动 A→B→A 与同路径新读取在飞,修正前在「旧读取落地」处确实红

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-02 19:57:04 +08:00
parent 97e8f1b652
commit e3c3091ec4
2 changed files with 74 additions and 7 deletions
@@ -138,6 +138,61 @@ describe('更早历史加载态的项目归属', () => {
});
});
describe('更早历史读取的世代守卫', () => {
it('同一项目切走再切回后,上一世代的读取落地不能清掉新加载态', async () => {
const tauri = installDeferredHistoryInvoke();
const { result, rerender } = renderChatController('/projects/a');
// /a 首屏:拿到「还有更早」,游标停在 a2。
const firstScreenA = await tauri.take('/projects/a');
await act(async () => {
firstScreenA.resolve(slice([userItem('a2')], true, 'a2'));
});
await waitFor(() => expect(result.current.historyHasMore).toBe(true));
// 在 /a 滚到顶:第一条更早历史读取挂起。
await act(async () => {
void result.current.loadEarlierHistory();
});
const staleRead = await tauri.take('/projects/a', 'a2');
expect(result.current.historyLoading).toBe(true);
// 切到 /b 再切回 /a:两次切项目都重置加载态,并各拉一次首屏。
rerender({ projectPath: '/projects/b' });
const firstScreenB = await tauri.take('/projects/b');
await act(async () => {
firstScreenB.resolve(slice([userItem('b2')], true, 'b2'));
});
await waitFor(() => expect(result.current.historyHasMore).toBe(true));
rerender({ projectPath: '/projects/a' });
const firstScreenA2 = await tauri.take('/projects/a');
await act(async () => {
firstScreenA2.resolve(slice([userItem('a2')], true, 'a2'));
});
await waitFor(() => expect(result.current.historyHasMore).toBe(true));
// 回到 /a 后再滚到顶:新一代的更早历史读取挂起。
await act(async () => {
void result.current.loadEarlierHistory();
});
const currentRead = await tauri.take('/projects/a', 'a2');
expect(result.current.historyLoading).toBe(true);
// 第一条 /a 读取此刻才落地:路径和现在一样,但它属于上一世代,不能收掉新一代的加载态。
await act(async () => {
staleRead.resolve(slice([userItem('a1')], false, 'a1'));
});
expect(result.current.historyLoading).toBe(true);
// 新一代自己的读取落地后,加载态由它自己收口。
await act(async () => {
currentRead.resolve(slice([userItem('a1')], false, 'a1'));
});
expect(result.current.historyLoading).toBe(false);
});
});
describe('更早历史失败的挂起态', () => {
it('宿主没给出失败消息时,挂起态仍是可判定的非空值', async () => {
const tauri = installDeferredHistoryInvoke();
@@ -203,6 +203,13 @@ export function useDirectProjectChatController({
const historyLoadingRef = useRef(false);
const historyErrorRef = useRef<string | null>(null);
const historyOldestItemIdRef = useRef<string | null>(null);
/**
* 更早历史读取的世代号:新起一次读取、以及切换项目都推进一格。
*
* 只比项目路径不够——A→B→A 之后在飞的旧 A 读取又落回同一个路径,会把新一代的加载态与游标
* 一起改掉。世代号让任何非最新一次读取在落地时整段失效(不合并、不写游标、不关加载态)。
*/
const historyLoadTokenRef = useRef(0);
const projectPathRef = useRef<string | null>(projectPath);
projectPathRef.current = projectPath;
const directTurnRunningRef = useRef(currentTurnRunning);
@@ -220,6 +227,8 @@ export function useDirectProjectChatController({
historyErrorRef.current = null;
setHistoryError(null);
historyOldestItemIdRef.current = null;
// 换项目即换代:上一项目的在飞读取(停在旧世代号上)落地后整段失效。
historyLoadTokenRef.current += 1;
}, [projectPath]);
// 回合期间的平台会话保活由 Rust 持有:Direct 回合的占用登记时启动,占用释放(回合收口)即停止;
@@ -691,6 +700,9 @@ export function useDirectProjectChatController({
return;
const invoke = resolveTauriInvoke();
if (!invoke) return;
// 本次读取的世代号:切项目或另起一次读取都会把它推进,落地时过期就读作失效。
const loadToken = (historyLoadTokenRef.current += 1);
const isStaleLoad = () => historyLoadTokenRef.current !== loadToken;
historyLoadingRef.current = true;
setHistoryLoading(true);
try {
@@ -705,7 +717,7 @@ export function useDirectProjectChatController({
: { limit: DIRECT_HISTORY_PAGE_SIZE }),
}),
});
if (projectPathRef.current !== nextProjectPath) return;
if (isStaleLoad()) return;
directThread.mergeHistoryItems(pages.items);
if (!pages.error) setHistoryHasMore(pages.hasMore);
historyOldestItemIdRef.current =
@@ -714,18 +726,18 @@ export function useDirectProjectChatController({
historyErrorRef.current = null;
setHistoryError(null);
} catch (error) {
// 与加载成功路径同样按项目守卫:切项目后在飞的读取失败不能算到新项目头上。
if (projectPathRef.current !== nextProjectPath) return;
// 与加载成功路径同样按世代守卫:切项目后在飞的读取失败不能算到新项目头上。
if (isStaleLoad()) return;
const message =
(error instanceof Error ? error.message : String(error)) ||
DIRECT_HISTORY_LOAD_ERROR_FALLBACK;
historyErrorRef.current = message;
setHistoryError(message);
} finally {
// 与成功 / 失败路径同样按项目守卫:切项目后在飞的旧读取落地时,不能关掉新项目自己那次
// 加载的加载态——否则加载行提前消失,`historyLoadingRef` 这道并发闸门也会被重新打开,
// 同一个项目上就能再并起第二次读取。
if (projectPathRef.current === nextProjectPath) {
// 与成功 / 失败路径同样按世代守卫:旧世代读取落地时不能关掉当代的加载态——否则加载行
// 提前消失,`historyLoadingRef` 这道并发闸门也会被重新打开,还能再并起第二次读取。
// 只比路径不够:A→B→A 的旧读取又落回同一个路径,靠世代号才区分得开。
if (!isStaleLoad()) {
historyLoadingRef.current = false;
setHistoryLoading(false);
}