diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.test.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.test.ts index a0b25e2e2..58a236cc5 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.test.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.test.ts @@ -1,10 +1,11 @@ /** @vitest-environment jsdom */ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { collectTurnBlocks, type ConversationTurnAnchor, findTurnBlock, + invalidateTurnBlocks, readTopVisibleTurnAnchor, restoreTurnAnchor, } from './conversationScrollAnchor'; @@ -52,6 +53,32 @@ describe('collectTurnBlocks', () => { const { list } = buildList(['t1', 't2']); expect(collectTurnBlocks(list)).toHaveLength(2); }); + + it('同一列表连续收集只查一次 DOM', () => { + const { list } = buildList(['t1', 't2']); + collectTurnBlocks(list); + const query = vi.spyOn(list, 'querySelectorAll'); + + // 一次滚动帧里读锚点、还原锚点会各查一遍:没失效就不该再碰 DOM。 + collectTurnBlocks(list); + + expect(query).not.toHaveBeenCalled(); + query.mockRestore(); + }); + + it('子元素变化并失效后重新收集', () => { + const { list } = buildList(['t1']); + const before = collectTurnBlocks(list); + const extra = document.createElement('div'); + extra.dataset.turnKey = 't2'; + list.appendChild(extra); + + invalidateTurnBlocks(list); + + const after = collectTurnBlocks(list); + expect(after).toHaveLength(2); + expect(after).not.toBe(before); + }); }); describe('readTopVisibleTurnAnchor', () => { diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.ts index 077b41d31..4511247e6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/conversationScrollAnchor.ts @@ -26,9 +26,27 @@ function turnKeyOf(element: Element): string | null { : null; } -/** 列表里所有带回合身份的块,按文档顺序。 */ +/** + * 块集合缓存:一次滚动或一次补偿里读锚点、还原锚点会各查一遍整个列表,长会话下就是每个滚动 + * 帧的 O(块数) DOM 查询。失效信号是列表子元素增删——只有子元素变化才可能改变「哪些块带回合 + * 身份」,而 `useConversationScroll` 的 `MutationObserver` 已经在听同一个信号。 + */ +const turnBlockCache = new WeakMap(); + +/** 列表里所有带回合身份的块,按文档顺序;子元素没变时复用上一次的结果。 */ export function collectTurnBlocks(list: HTMLElement): HTMLElement[] { - return Array.from(list.querySelectorAll(TURN_BLOCK_SELECTOR)); + const cached = turnBlockCache.get(list); + if (cached) return cached; + const blocks = Array.from( + list.querySelectorAll(TURN_BLOCK_SELECTOR), + ); + turnBlockCache.set(list, blocks); + return blocks; +} + +/** 列表子元素变了(回合追加、历史前插、加载行挂卸):丢掉缓存,下次重新收集。 */ +export function invalidateTurnBlocks(list: HTMLElement): void { + turnBlockCache.delete(list); } /** 按「回合 key + 块序号」定位具体块;找不到返回 null。 */ diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.ts index bf8c55c03..f71dc4216 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/useConversationScroll.ts @@ -11,6 +11,7 @@ import { import type { DirectChatTurn } from '../../conversation/directTurnPresentation'; import { type ConversationTurnAnchor, + invalidateTurnBlocks, readTopVisibleTurnAnchor, restoreTurnAnchor, } from './conversationScrollAnchor'; @@ -210,13 +211,15 @@ export function useConversationScroll({ }, [turns]); // 观察列表的直接子元素:展开 / 收起、流式正文增长、图片撑高、历史前插都只有这一个入口。 - // 子元素增删(回合追加、加载行挂卸)由 MutationObserver 触发重订阅。 + // 子元素增删(回合追加、加载行挂卸)由 MutationObserver 触发重订阅,同时让锚点的块集合缓存失效。 useEffect(() => { const list = listRef.current; if (!list || typeof ResizeObserver === 'undefined') return undefined; let observer: ResizeObserver | null = null; const subscribe = () => { + // 同一份子元素信号同时喂给两处:块集合缓存(锚点定位)与 ResizeObserver 的观察名单。 + invalidateTurnBlocks(list); observer?.disconnect(); observer = new ResizeObserver(() => compensateLayout()); for (const child of Array.from(list.children)) {