修复更早历史加载被旧项目读取提前关闭加载态

- loadEarlierHistory 的 finally 补上项目归属守卫,旧项目在飞的读取落地时不再清掉新项目的加载态与并发闸门
- 新增控制器回归用例:切项目后旧读取落地,新项目的加载态保持到它自己收口

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-10-02 18:52:33 +08:00
parent 238c3c60d7
commit 77401adfe8
2 changed files with 138 additions and 2 deletions
@@ -0,0 +1,131 @@
/** @vitest-environment jsdom */
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { HistorySlice } from '../generated/HistorySlice';
import type { ThreadItem } from '../generated/ThreadItem';
import { useDirectProjectChatController } from './useDirectProjectChatController';
type SliceRequest = {
projectPath: string;
beforeItemId: string | undefined;
resolve: (slice: HistorySlice) => void;
};
function userItem(itemId: string): ThreadItem {
return { itemType: 'message', itemId, role: 'user', text: itemId, at: 1000 };
}
function slice(
items: ThreadItem[],
hasMore: boolean,
firstItemId: string | null,
): HistorySlice {
return { items, hasMore, firstItemId };
}
/**
* 装一个可控的 Tauri invoke:历史切片读取全部挂起,用例自己决定哪一页何时落地。
*
* `resolveTauriInvoke` 只读 `window.__TAURI__.core.invoke`,这里就是控制器唯一的外部接缝。
*/
function installDeferredHistoryInvoke() {
const requests: SliceRequest[] = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command !== 'read_direct_project_history_slice') return undefined;
return new Promise<HistorySlice>((resolve) => {
requests.push({
projectPath: String(args?.projectPath ?? ''),
beforeItemId: args?.beforeItemId as string | undefined,
resolve,
});
});
},
);
window.__TAURI__ = { core: { invoke } };
return {
/** 取出一条挂起的读取;没有就等到出现为止。 */
async take(
projectPath: string,
beforeItemId?: string,
): Promise<SliceRequest> {
let found: SliceRequest | undefined;
await waitFor(() => {
found = requests.find(
(item) =>
item.projectPath === projectPath &&
item.beforeItemId === beforeItemId,
);
expect(found).toBeDefined();
});
return requests.splice(requests.indexOf(found!), 1)[0]!;
},
};
}
afterEach(() => {
cleanup();
delete window.__TAURI__;
});
describe('更早历史加载态的项目归属', () => {
it('切项目后旧项目在飞的读取才落地,不能清掉新项目正在进行的加载态', async () => {
const tauri = installDeferredHistoryInvoke();
const allowed = async () => true;
const { result, rerender } = renderHook(
({ projectPath }: { projectPath: string }) =>
useDirectProjectChatController({
enabled: true,
ensureConversationReadAllowed: allowed,
ensureConversationWriteAllowed: allowed,
onRuntimeError: () => undefined,
projectId: 'project-1',
projectPath,
refreshManifest: () => undefined,
}),
{ initialProps: { projectPath: '/projects/a' } },
);
// 首屏:/a 的第一页落地,拿到「还有更早」。
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:控制器重置加载态并重新拉首屏。
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));
// /b 也发起更早历史的读取:加载态再次打开。
await act(async () => {
void result.current.loadEarlierHistory();
});
const currentRead = await tauri.take('/projects/b', 'b2');
expect(result.current.historyLoading).toBe(true);
// 旧项目的读取此刻才落地:它属于 /a,不能清掉 /b 的加载态。
await act(async () => {
staleRead.resolve(slice([userItem('a1')], true, 'a1'));
});
expect(result.current.historyLoading).toBe(true);
// /b 自己的读取落地后,加载态由它自己收口。
await act(async () => {
currentRead.resolve(slice([userItem('b1')], false, 'b1'));
});
expect(result.current.historyLoading).toBe(false);
});
});
@@ -711,8 +711,13 @@ export function useDirectProjectChatController({
historyErrorRef.current = message;
setHistoryError(message);
} finally {
historyLoadingRef.current = false;
setHistoryLoading(false);
// 与成功 / 失败路径同样按项目守卫:切项目后在飞的旧读取落地时,不能关掉新项目自己那次
// 加载的加载态——否则加载行提前消失,`historyLoadingRef` 这道并发闸门也会被重新打开,
// 同一个项目上就能再并起第二次读取。
if (projectPathRef.current === nextProjectPath) {
historyLoadingRef.current = false;
setHistoryLoading(false);
}
}
}