diff --git a/apps/ai-game-creator-shell/src/components/WindowChrome.tsx b/apps/ai-game-creator-shell/src/components/WindowChrome.tsx index 70f9eb496..b82edbc3f 100644 --- a/apps/ai-game-creator-shell/src/components/WindowChrome.tsx +++ b/apps/ai-game-creator-shell/src/components/WindowChrome.tsx @@ -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( + () => ({ + isWindowChrome: true, + title, + setTitle, + walletSlot, + activeProjectRuns, + setActiveProjectRuns, + }), + [title, setTitle, walletSlot, activeProjectRuns], + ); const [isMaximized, setIsMaximized] = useState(false); diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts index 0eadbeacd..3758451bb 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts @@ -38,6 +38,14 @@ export function useDirectActiveTurns({ const [snapshotReadFailed, setSnapshotReadFailed] = useState(false); const mountedRef = useRef(true); const inFlightRef = useRef | null>(null); + /** + * 上一次成功读取到的快照签名。 + * + * 轮询每 5 秒跑一次,如果每次都 `setActiveTurns(新数组)`,即使内容一模一样也会 + * 换掉数组身份:所有依赖 `activeTurns` 的 effect 都会跟着重跑(窗口标题栏的活动项目 + * 面板就是这么被反复重发布的)。这里只在内容真的变了才更新状态。 + */ + const lastSnapshotSignatureRef = useRef(''); 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(); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index aea971d8f..437c11da8 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -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, ]); diff --git a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx index 5a000bf39..afe50639c 100644 --- a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx +++ b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx @@ -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( - , - ); + 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(); - - expect(screen.getByRole('status').textContent).toBe('未能读取正在运行的项目'); -}); - -it('标题栏入口只显示最后开始的项目,展开后列出全部项目', () => { - const onOpenProject = vi.fn(); - render( - , - ); - - 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); + }); }); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 74092e946..fd60a015c 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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.2–4.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`。