修复 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);
});
});
@@ -5649,3 +5649,11 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **处理(现行口径)**:不要把重写结果当改动提交。跑过 `cargo test` 或构建后先 `git checkout -- apps/ai-game-creator-shell/src/features/project-workspace/generated`,再删掉多出来的 `DirectCodexUserMessageEnvelope.ts`,然后才做 typecheck / 打包;绑定与前端形状冲突时以**已提交的绑定 + 前端**为基准排查。
- **验证**:恢复提交版本后 `npm run ai-game-creator-shell:typecheck` exit 0`[skill-pack] OK`);保留重写结果时同一条命令 exit 2。release 构建本身还会在 `src/features/ui-editor/types/` 落下 `BindingChange.ts` / `BindingDTO.ts` 两个无人引用的未跟踪文件,属同类生成产物。
- **关联**`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/`ts-rs 导出源)、`apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts``apps/ai-game-creator-shell/scripts/build-release.mjs``beforeBuildCommand`)。
## 2026-09-17 AGC 壳首页无限 setState:effect 依赖了每次渲染都换身份的普通函数
- **现象**:dev 客户端停在首页、不点任何东西也会持续刷 `WEBVIEW error webview: Maximum update depth exceeded …`(5 秒涨 ~8.5 KB 日志),对应 WebView2 renderer 工作集涨到 **4.2 GB**、CPU 持续累计(约 0.7–1.5 核);表现上很像"模板库卡片太多/滚动卡",实际与页面内容无关。
- **原因**`WorkspaceLauncher` 里发布"活动项目面板"数据的 effect,依赖数组里带了 `openActiveProject`;它由 `useCallback([openProject, setProjectPath])` 生成,而 `openProject` 来自 `useHomeProjectCreation` 的**普通函数声明**(每次渲染都是新身份)→ `openActiveProject` 每渲染都变 → effect 每渲染重跑 → cleanup/主体调 `setActiveProjectRuns``WindowChrome` 的 state → 标题栏重渲染 → 又一轮。`WindowChrome` 的 context value 当时还是内联对象,进一步放大了连带重渲染。日志里没有组件栈,是靠在 `console.error` 包装里抓 `new Error().stack`(该日志与 setState 同栈)才定位到 `WorkspaceLauncher.tsx``commitHookEffectListUnmount → dispatchSetState`
- **处理(现行口径)**:① 依赖里只放数据,回调走 ref`openActiveProjectRef`)——effect 不再因回调换身份而重跑;② `useDirectActiveTurns` 轮询只在快照内容变化时才 `setActiveTurns`(并给空态做引用稳定),避免每 5 秒换一次数组身份去带动下游 effect;③ `WindowChrome` 的 context value 用 `useMemo` 收口。判断类问题的通行判据:**凡是把"每次渲染新生成的函数/对象"写进 effect 依赖的,一律视为 bug**。
- **验证**:修复后同一台机器、同一路径下 35 秒内新增 `Maximum update depth` **0 条**renderer 工作集 **254 MB**(修复前 4.24.4 GB);`apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx` 断言轮询返回值不变时快照引用不变。
- **关联**`apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx``apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts``apps/ai-game-creator-shell/src/components/WindowChrome.tsx``apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`