From a731536eccfe924fc5b6784cca7e4f3657b41340 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:05:15 +0800 Subject: [PATCH] perf: ChatNodeSeat use seperated source --- .../ui-chat/src/client/chat/ChatNodeSeat.tsx | 9 +-- .../ui-chat/src/client/chat/ChatView.tsx | 1 + .../ui-chat/src/client/contract/snapshot.ts | 16 +++++ .../chat-snapshot-builder.ts | 57 +++++++++++++++-- packages/client/ui-chat/src/client/index.ts | 2 +- .../ui-chat/tests/chat-apply.client.spec.tsx | 7 ++- .../tests/chat-node-source.client.spec.ts | 51 ++++++++++++++++ .../tests/chat-snapshot-fixture.client.ts | 61 ++++++++++++++++--- .../tests/tool-details-render.client.tsx | 1 + .../tests/plan-review-panel.client.spec.tsx | 3 +- .../user-questions-composer.client.spec.tsx | 3 +- 11 files changed, 190 insertions(+), 21 deletions(-) create mode 100644 packages/client/ui-chat/tests/chat-node-source.client.spec.ts diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 00eb9fd70d..cfa4b52f13 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -1,8 +1,8 @@ -import { memo, useCallback, useMemo } from 'react' +import { memo, useCallback, useMemo, useSyncExternalStore } from 'react' import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts' import type { ChatNode } from '../contract/chat-nodes.ts' -import type { ChatNodeStore } from '../contract/snapshot.ts' +import type { ChatNodeSource, ChatNodeStore } from '../contract/snapshot.ts' import { decodeTurnProcess, TURN_PROCESS_INDEPENDENT_KINDS, turnProcessGeneration, type TurnProcessSpec, @@ -13,6 +13,7 @@ import css from './ChatView.module.css' interface ChatNodeSeatProps extends ChatNodeOwnerProps { readonly nodeKey: string + readonly nodeSource: ChatNodeSource readonly historyIncomplete: boolean readonly compactTranscript: boolean readonly useChat: ChatViewSlotProps['useChat'] @@ -78,11 +79,11 @@ function turnProcessLayout( /** Subscribe, apply Turn-process visibility, and dispatch one stable Context key. */ export const ChatNodeSeat = memo(function ChatNodeSeat({ - nodeKey, historyIncomplete, compactTranscript, + nodeKey, nodeSource, historyIncomplete, compactTranscript, selectedCallId, cwd, openFile, inspectCall, forkAt, renderMessageImages, fileMentions, useChat, useStore, actions, renderSlot, t, }: ChatNodeSeatProps) { - const node = useChat(snapshot => snapshot.nodes.get(nodeKey)) + const node = useSyncExternalStore(nodeSource.subscribe, nodeSource.getSnapshot) const processSignature = useChat((snapshot) => { const current = snapshot.nodes.get(nodeKey) const location = current?.location diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 75ff66b500..259687ee00 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -746,6 +746,7 @@ export function ChatView({ void): () => void +} + /** Stable live per-key reader for Chat nodes. */ export interface ChatNodeStore { /** @param key - stable Conversation Context key. @returns current Node, when visible or hidden. */ get(key: string): ChatConversationViewNode | undefined + /** @param key - stable Conversation Context key. @returns its identity-stable observable source. */ + source(key: string): ChatNodeSource /** @returns all currently materialized Nodes without imposing render order. */ values(): readonly ChatConversationViewNode[] } @@ -75,12 +86,17 @@ declare module '@deepseek-ai/dsh-client-ui-conversation/client' { const EMPTY_LIST: readonly never[] = [] const EMPTY_TIMELINE: ConversationTimelineSnapshot = { turnOrder: EMPTY_LIST, turns: new Map() } +const EMPTY_NODE_SOURCE: ChatNodeSource = { + getSnapshot: () => undefined, + subscribe: () => () => {}, +} /** Empty Chat target used before a view builder is registered. */ export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = { order: EMPTY_LIST, nodes: { get: () => undefined, + source: () => EMPTY_NODE_SOURCE, values: () => EMPTY_LIST, }, locations: { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index 74fa8c6ac8..1f1c7bb2a6 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -1,4 +1,5 @@ import type { Context } from '@deepseek-ai/cordis' +import { notifySubscribers } from '@deepseek-ai/dsh-client-store' import type { ConversationLocation, ConversationTimelineSnapshot, ConversationViewBuilder, ConversationViewDefinition, @@ -6,7 +7,7 @@ import type { import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts' import { isRunningTool } from '../contract/chat-nodes.ts' import type { - ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex, ConversationNode, + ChatLocationNodeIndex, ChatNodeSource, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex, ConversationNode, LegacyConversationSlice, PartialAssistant, RunningToolCall, TurnNavigationItem, } from '../contract/snapshot.ts' import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts' @@ -22,8 +23,30 @@ function sameReferences(left: readonly T[], right: readonly T[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]) } +class MutableChatNodeSource implements ChatNodeSource { + private readonly listeners = new Set<() => void>() + + constructor( + private readonly store: MutableChatNodeStore, + private readonly key: string, + ) {} + + readonly getSnapshot = (): ChatConversationViewNode | undefined => this.store.get(this.key) + + readonly subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + publish(): void { + notifySubscribers(this.listeners, `[ui-chat] node source ${this.key}`) + } +} + class MutableChatNodeStore implements ChatNodeStore { private readonly byKey = new Map() + private readonly sources = new Map() + private readonly dirtyKeys = new Set() private valuesCache: readonly ChatConversationViewNode[] = EMPTY_LIST private valuesDirty = false @@ -31,6 +54,15 @@ class MutableChatNodeStore implements ChatNodeStore { return this.byKey.get(key) } + source(key: string): ChatNodeSource { + let source = this.sources.get(key) + if (source === undefined) { + source = new MutableChatNodeSource(this, key) + this.sources.set(key, source) + } + return source + } + values(): readonly ChatConversationViewNode[] { if (this.valuesDirty) { this.valuesCache = [...this.byKey.values()] @@ -40,8 +72,14 @@ class MutableChatNodeStore implements ChatNodeStore { } replace(nodes: readonly ChatConversationViewNode[]): void { + const previous = new Map(this.byKey) this.byKey.clear() - for (const node of nodes) this.byKey.set(node.key, node) + for (const node of nodes) { + this.byKey.set(node.key, node) + if (previous.get(node.key) !== node) this.dirtyKeys.add(node.key) + previous.delete(node.key) + } + for (const key of previous.keys()) this.dirtyKeys.add(key) this.valuesCache = [...this.byKey.values()] this.valuesDirty = false } @@ -51,10 +89,17 @@ class MutableChatNodeStore implements ChatNodeStore { for (const node of nodes) { if (this.byKey.get(node.key) === node) continue this.byKey.set(node.key, node) + this.dirtyKeys.add(node.key) changed = true } if (changed) this.valuesDirty = true } + + publish(): void { + const dirty = [...this.dirtyKeys] + this.dirtyKeys.clear() + for (const key of dirty) this.sources.get(key)?.publish() + } } class MutableChatLocationIndex implements ChatLocationNodeIndex { @@ -652,7 +697,9 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder { ) const data = { get: (key: string) => key === 'metric' ? 42 : undefined } const turn = { data } + const emptySource = { getSnapshot: () => undefined, subscribe: () => () => {} } snapshot = chatSnapshot({ nodes: { get: () => ({ location: { kind: 'turn', turn } }), values: () => [] } as never, @@ -176,7 +177,11 @@ describe('Chat apply wiring', () => { }) expect(useTurnData('metric')).toBeUndefined() snapshot = chatSnapshot({ - nodes: { get: () => undefined, values: () => [] }, + nodes: { + get: () => undefined, + source: () => emptySource, + values: () => [], + }, }) expect(useTurnData('metric')).toBeUndefined() diff --git a/packages/client/ui-chat/tests/chat-node-source.client.spec.ts b/packages/client/ui-chat/tests/chat-node-source.client.spec.ts new file mode 100644 index 0000000000..71f2cecf12 --- /dev/null +++ b/packages/client/ui-chat/tests/chat-node-source.client.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + ChatConversationViewNode, ChatNodeSource, +} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snapshot-builder.ts' + +const timeline: ConversationTimelineSnapshot = { turnOrder: [], turns: new Map() } + +function userNode(index: number, text = `message ${String(index)}`): ChatConversationViewNode { + return { + key: `user:${String(index)}`, + id: String(index), + target: 'chat', + kind: 'user', + anchorSeq: index, + location: { kind: 'session' }, + visibility: 'visible', + data: { + kind: 'user', + messageId: `message-${String(index)}`, + seq: index, + time: index, + content: [{ type: 'text', text }], + source: null, + }, + } as ChatConversationViewNode +} + +describe('Chat Node keyed sources', () => { + it('notifies only the updated key among 4,000 mounted sources', () => { + const builder = new ChatSnapshotBuilder() + const nodes = Array.from({ length: 4_000 }, (_, index) => userNode(index + 1)) + const initial = builder.replace({ nodes, timeline }) + const listeners = nodes.map(() => vi.fn()) + const sources: ChatNodeSource[] = nodes.map((node, index) => { + const source = initial.nodes.source(node.key) + source.subscribe(listeners[index]!) + return source + }) + + const target = 2_347 + const changed = userNode(target + 1, 'streamed update') + const next = builder.apply({ upserts: [changed], timeline }) + + expect(listeners[target]).toHaveBeenCalledOnce() + expect(listeners.reduce((count, listener) => count + listener.mock.calls.length, 0)).toBe(1) + expect(next.nodes.source(changed.key)).toBe(sources[target]) + expect(next.nodes.get(changed.key)).toBe(changed) + }) +}) diff --git a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts index 311e2545a8..ce29f6f529 100644 --- a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts +++ b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts @@ -1,6 +1,6 @@ import type { AssistantChatData, AssistantMessageNode, ChatConversationViewNode, ChatSnapshot, ConversationNode, - ChatLocationNodeIndex, ChatNodeStore, CompactionSummaryNode, FinalAssistantChatData, + ChatLocationNodeIndex, ChatNodeSource, ChatNodeStore, CompactionSummaryNode, FinalAssistantChatData, LegacyConversationSlice, PartialAssistant, RunningToolCall, ToolCallBlock, TurnNavigationItem, } from '@deepseek-ai/dsh-client-ui-chat/client' import type { @@ -63,35 +63,77 @@ function toolCallName(call: ToolCallBlock): string | null { return 'name' in call ? call.name : call.call?.name ?? null } +class FixtureNodeSource implements ChatNodeSource { + private readonly listeners = new Set<() => void>() + + constructor( + private readonly store: FixtureNodeStore, + private readonly key: string, + ) {} + + readonly getSnapshot = (): ChatConversationViewNode | undefined => this.store.get(this.key) + + readonly subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + publish(): void { + for (const listener of [...this.listeners]) listener() + } +} + class FixtureNodeStore implements ChatNodeStore { private byKey = new Map() + private readonly sources = new Map() + private readonly dirtyKeys = new Set() private list: readonly ChatConversationViewNode[] = EMPTY get(key: string): ChatConversationViewNode | undefined { return this.byKey.get(key) } + source(key: string): ChatNodeSource { + let source = this.sources.get(key) + if (source === undefined) { + source = new FixtureNodeSource(this, key) + this.sources.set(key, source) + } + return source + } + values(): readonly ChatConversationViewNode[] { return this.list } replace(candidates: readonly ChatConversationViewNode[]): void { + const previous = this.byKey const next = new Map() const list = candidates.map((candidate) => { - const previous = this.byKey.get(candidate.key) - const node = previous !== undefined - && previous.kind === candidate.kind - && previous.anchorSeq === candidate.anchorSeq - && sameFixtureLocation(previous.location, candidate.location) - && previous.visibility === candidate.visibility - && nodeSource(previous) === nodeSource(candidate) - ? previous + const existing = previous.get(candidate.key) + const node = existing !== undefined + && existing.kind === candidate.kind + && existing.anchorSeq === candidate.anchorSeq + && sameFixtureLocation(existing.location, candidate.location) + && existing.visibility === candidate.visibility + && nodeSource(existing) === nodeSource(candidate) + ? existing : candidate next.set(node.key, node) return node }) this.byKey = next this.list = sameValues(this.list, list) ? this.list : list + const keys = new Set([...previous.keys(), ...next.keys()]) + for (const key of keys) { + if (previous.get(key) !== next.get(key)) this.dirtyKeys.add(key) + } + } + + publish(): void { + const dirty = [...this.dirtyKeys] + this.dirtyKeys.clear() + for (const key of dirty) this.sources.get(key)?.publish() } } @@ -420,6 +462,7 @@ export function chatSnapshotFixture(input: { && derived.every((item, index) => sameTurnNavigationItem(kept[index], item)) ? kept : derived + store.publish() return { order, nodes: store, diff --git a/packages/client/ui-tool/tests/tool-details-render.client.tsx b/packages/client/ui-tool/tests/tool-details-render.client.tsx index fb2d60c6bf..5488ac5470 100644 --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx @@ -50,6 +50,7 @@ export function toolChatSnapshot( order: nodes.map(node => node.key), nodes: { get: key => byKey.get(key), + source: key => ({ getSnapshot: () => byKey.get(key), subscribe: () => () => {} }), values: () => nodes, }, locations: { diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 0667f16376..101e5cd6d8 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -64,9 +64,10 @@ const conversationState: ConversationState = { activeTargets: new Set(), } const emptyKeys: readonly string[] = [] +const emptyNodeSource = { getSnapshot: () => undefined, subscribe: () => () => {} } const chatState: ChatState = { order: emptyKeys, - nodes: { get: () => undefined, values: () => [] }, + nodes: { get: () => undefined, source: () => emptyNodeSource, values: () => [] }, locations: { getTurn: () => emptyKeys, getStep: () => emptyKeys }, navigation: { items: () => [] }, timeline: { turnOrder: [], turns: new Map() }, diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index 0ae174caca..e472e6d132 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -63,9 +63,10 @@ const conversationState: ConversationState = { activeTargets: new Set(), } const emptyKeys: readonly string[] = [] +const emptyNodeSource = { getSnapshot: () => undefined, subscribe: () => () => {} } const chatState: ChatState = { order: emptyKeys, - nodes: { get: () => undefined, values: () => [] }, + nodes: { get: () => undefined, source: () => emptyNodeSource, values: () => [] }, locations: { getTurn: () => emptyKeys, getStep: () => emptyKeys }, navigation: { items: () => [] }, timeline: { turnOrder: [], turns: new Map() },