修复 AGC 壳首页无限 setState 循环
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 6m52s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m19s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 6m25s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 6m29s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m1s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m45s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m29s
Project CI / Frontend tests (pull_request) Failing after 9m36s
Project CI / Native shell tests (pull_request) Successful in 13m30s

- WorkspaceLauncher 发布活动项目面板的 effect 不再依赖每次渲染都换身份的 openActiveProject:回调改走 ref,依赖只留数据
- useDirectActiveTurns 轮询只在快照内容变化时更新状态,空态保持引用稳定,避免每 5 秒换数组身份带动下游 effect
- WindowChrome 的 context value 用 useMemo 收口,避免所有 useWindowChrome 消费方被无谓重渲染
- 新增回归测试 directActiveTurns.test.tsx:轮询返回值不变时快照引用必须不变
- pitfalls 记录现象/根因/定位手段(console.error 里抓调用栈)与验证数据:修复后 35 秒 0 条深度错误、renderer 工作集 4.2GB -> 254MB
This commit is contained in:
kdletters
2026-09-17 16:53:34 +08:00
parent 066d0fe6f9
commit 13d272c2d4
5 changed files with 114 additions and 96 deletions
@@ -1,6 +1,12 @@
import { getCurrentWindow } from '@tauri-apps/api/window';
import { Copy, Minus, Square, X } from 'lucide-react';
import { type ReactNode, useCallback, useEffect, useState } from 'react';
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png';
import { ActiveProjectRunsPanel } from '../features/app-shell/ActiveProjectRunsPanel';
@@ -49,14 +55,21 @@ export function WindowChrome({ children }: WindowChromeProps) {
setTitleState(normalizedTitle || WINDOW_CHROME_DEFAULT_TITLE);
}, []);
const contextValue: WindowChromeContextValue = {
isWindowChrome: true,
title,
setTitle,
walletSlot,
activeProjectRuns,
setActiveProjectRuns,
};
/**
* context value 必须 memo:内联对象会让所有 `useWindowChrome()` 消费方在标题栏
* 每次渲染时都重新拿到新对象,进而连带重跑它们依赖 context 的 effect。
*/
const contextValue = useMemo<WindowChromeContextValue>(
() => ({
isWindowChrome: true,
title,
setTitle,
walletSlot,
activeProjectRuns,
setActiveProjectRuns,
}),
[title, setTitle, walletSlot, activeProjectRuns],
);
const [isMaximized, setIsMaximized] = useState(false);
@@ -38,6 +38,14 @@ export function useDirectActiveTurns({
const [snapshotReadFailed, setSnapshotReadFailed] = useState(false);
const mountedRef = useRef(true);
const inFlightRef = useRef<Promise<void> | null>(null);
/**
* 上一次成功读取到的快照签名。
*
* 轮询每 5 秒跑一次,如果每次都 `setActiveTurns(新数组)`,即使内容一模一样也会
* 换掉数组身份:所有依赖 `activeTurns` 的 effect 都会跟着重跑(窗口标题栏的活动项目
* 面板就是这么被反复重发布的)。这里只在内容真的变了才更新状态。
*/
const lastSnapshotSignatureRef = useRef<string>('');
useEffect(() => {
mountedRef.current = true;
@@ -67,7 +75,12 @@ export function useDirectActiveTurns({
if (!mountedRef.current) {
return;
}
setActiveTurns(Array.isArray(turns) ? turns : []);
const nextTurns = Array.isArray(turns) ? turns : [];
const nextSignature = JSON.stringify(nextTurns);
if (nextSignature !== lastSnapshotSignatureRef.current) {
lastSnapshotSignatureRef.current = nextSignature;
setActiveTurns(nextTurns);
}
setSnapshotReadFailed(false);
inFlightRef.current = null;
return;
@@ -94,8 +107,10 @@ export function useDirectActiveTurns({
useEffect(() => {
if (!enabled || !invoke) {
setActiveTurns([]);
setSnapshotReadFailed(false);
lastSnapshotSignatureRef.current = '';
// 空态也要保持引用稳定:已经空了就不要再换一个新数组。
setActiveTurns((current) => (current.length === 0 ? current : []));
setSnapshotReadFailed((current) => (current ? false : current));
return;
}
void refreshActiveTurns();
@@ -216,18 +216,30 @@ export function WorkspaceLauncherShell({
[openProject, setProjectPath],
);
/**
* 项目卡片面板发布给窗口标题栏的回调必须走 ref。
*
* `openProject` 来自 `useHomeProjectCreation` 的普通函数(每次渲染都是新身份),
* 所以 `openActiveProject` 的引用每渲染都变;如果它进 effect 依赖,就会变成
* 「effect 每渲染重跑 → cleanup/setActiveProjectRuns 改 WindowChrome 状态 → 重新渲染」
* 的无限 setState 循环(React 报 `Maximum update depth exceeded`)。
* 这里只让 effect 依赖真正的数据,回调通过 ref 取最新实现。
*/
const openActiveProjectRef = useRef(openActiveProject);
openActiveProjectRef.current = openActiveProject;
useEffect(() => {
setActiveProjectRuns({
activeTurns,
currentProjectPath: currentProjectContext?.projectPath ?? null,
readFailed: snapshotReadFailed,
onOpenProject: openActiveProject,
onOpenProject: (projectPath: string) =>
openActiveProjectRef.current(projectPath),
});
return () => setActiveProjectRuns(null);
}, [
activeTurns,
currentProjectContext?.projectPath,
openActiveProject,
setActiveProjectRuns,
snapshotReadFailed,
]);
@@ -1,89 +1,59 @@
// @vitest-environment jsdom
import { act, renderHook, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, expect, it, vi } from 'vitest';
import type { GameCreatorDirectActiveTurn } from '../src/app/types';
import {
DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS,
useDirectActiveTurns,
} from '../src/features/agent-runtime/directActiveTurns';
import { ActiveProjectRunsPanel } from '../src/features/app-shell/ActiveProjectRunsPanel';
const ACTIVE_TURN = {
projectPath: 'C:/projects/demo',
agentId: 'project-supervisor',
runId: 'run-1',
} as unknown as GameCreatorDirectActiveTurn;
afterEach(() => cleanup());
describe('useDirectActiveTurns', () => {
it('keeps the snapshot identity when the poll returns the same content', async () => {
// 回归点:轮询每次都 setActiveTurns(新数组) 会让所有依赖 activeTurns 的 effect
// 反复重跑(窗口标题栏的活动项目面板曾因此无限 setState)。
const invoke = vi.fn(async () => [ACTIVE_TURN]) as never;
const { result } = renderHook(() =>
useDirectActiveTurns({
invoke,
enabled: true,
pollIntervalMs: DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS,
}),
);
it('按开始时间展示正在运行的项目并支持进入项目', () => {
const onOpenProject = vi.fn();
render(
<ActiveProjectRunsPanel
activeTurns={[
{
projectPath: 'C:/projects/later',
projectName: '后开始',
turnId: 'turn-later',
startedAt: 200,
status: 'streaming',
updatedAt: 220,
sequence: 2,
},
{
projectPath: 'C:/projects/first',
projectName: '先开始',
turnId: 'turn-first',
startedAt: 100,
status: 'running',
updatedAt: 120,
sequence: 1,
},
]}
onOpenProject={onOpenProject}
/>,
);
await waitFor(() => {
expect(result.current.activeTurns).toHaveLength(1);
});
const firstSnapshot = result.current.activeTurns;
const items = screen.getAllByRole('button');
expect(items.map((item) => item.textContent?.includes('先开始'))).toEqual([
true,
false,
]);
fireEvent.click(items[0]);
expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first');
});
it('读取失败时保留明确的读取提示,不伪装成没有运行项目', () => {
render(<ActiveProjectRunsPanel activeTurns={[]} readFailed />);
expect(screen.getByRole('status').textContent).toBe('未能读取正在运行的项目');
});
it('标题栏入口只显示最后开始的项目,展开后列出全部项目', () => {
const onOpenProject = vi.fn();
render(
<ActiveProjectRunsPanel
placement="titlebar"
activeTurns={[
{
projectPath: 'C:/projects/first',
projectName: '先开始',
turnId: 'turn-first',
startedAt: 100,
status: 'running',
updatedAt: 120,
sequence: 1,
},
{
projectPath: 'C:/projects/later',
projectName: '后开始',
turnId: 'turn-later',
startedAt: 200,
status: 'streaming',
updatedAt: 220,
sequence: 2,
},
]}
onOpenProject={onOpenProject}
/>,
);
expect(screen.getByRole('button', { name: /后开始/ })).toBeTruthy();
expect(screen.queryByRole('menu')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /后开始/ }));
expect(screen.getByRole('menu')).toBeTruthy();
expect(screen.getAllByRole('menuitem')).toHaveLength(2);
fireEvent.click(screen.getByRole('menuitem', { name: /先开始/ }));
expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first');
await act(async () => {
await result.current.refreshActiveTurns();
await result.current.refreshActiveTurns();
});
expect(result.current.activeTurns).toBe(firstSnapshot);
});
it('clears to a stable empty snapshot when a new turn set arrives', async () => {
const invoke = vi.fn(
async () => [] as GameCreatorDirectActiveTurn[],
) as never;
const { result, rerender } = renderHook(
({ enabled }: { enabled: boolean }) =>
useDirectActiveTurns({ invoke, enabled, pollIntervalMs: 60_000 }),
{ initialProps: { enabled: true } },
);
await waitFor(() => {
expect(result.current.activeTurns).toEqual([]);
});
const emptySnapshot = result.current.activeTurns;
rerender({ enabled: false });
expect(result.current.activeTurns).toBe(emptySnapshot);
});
});