From c873fc9d2ed2237ad3fd4e12ad3deaa21b46170a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 27 Aug 2026 13:08:17 +0800 Subject: [PATCH] fix(client): stop rebuilding the turn rail on every chat render TurnNavigator was an unmemoized component rendering one div and one button per loaded Turn, so every ChatView render rebuilt the whole rail: 143 button rebuilds per commit in a 300-Turn session against 8.4 in a 4-Turn one, while a streaming answer commits dozens of times. memo alone would not have helped, because navigateToTurn was rebuilt on every render and broke prop identity, so it moves into useCallback. --- .../ui-chat/src/client/chat/ChatView.tsx | 5 ++-- .../ui-chat/src/client/chat/TurnNavigator.tsx | 16 +++++++++-- .../ui-chat/tests/chat-view.client.spec.tsx | 28 +++++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index ade617c95e..ecce592e67 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -498,7 +498,8 @@ export function ChatView({ loadOlder() } - const navigateToTurn = (item: TurnNavigationItem): void => { + // Identity feeds the memoized rail; a fresh closure per render would defeat it. + const navigateToTurn = useCallback((item: TurnNavigationItem): void => { const local = listRef.current if (local === null) return const row = anchorElement(local, item.anchorKey) @@ -519,7 +520,7 @@ export function ChatView({ const position = isAtBottom ? null : scrollPosition(local, el) if (isAtBottom) chatScroll.save(null) else if (position !== null) chatScroll.save(position) - } + }, [loadingOlder, chatScroll]) return (
diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx index b2533e533c..55818f85cd 100644 --- a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx @@ -1,5 +1,5 @@ import { - useId, useState, type CSSProperties, type MouseEvent, type PointerEvent, + memo, useId, useState, type CSSProperties, type MouseEvent, type PointerEvent, } from 'react' import type { ChatViewSlotProps } from '../contract/slots.ts' import type { TurnNavigationItem } from '../contract/snapshot.ts' @@ -53,8 +53,7 @@ function itemAtPointer( return items[Math.round(ratio * (items.length - 1))] } -/** Compact rail of the currently loaded Turns with hover and focus previews. */ -export function TurnNavigator({ items, activeTurn, onNavigate, t }: TurnNavigatorProps) { +function TurnNavigatorRail({ items, activeTurn, onNavigate, t }: TurnNavigatorProps) { const [previewTurn, setPreviewTurn] = useState(null) const previewId = useId() if (items.length < 2) return null @@ -116,3 +115,14 @@ export function TurnNavigator({ items, activeTurn, onNavigate, t }: TurnNavigato
) } + +/** + * Compact rail of the currently loaded Turns with hover and focus previews. + * + * Memoized because it renders two host elements per loaded Turn while the + * enclosing view re-renders on every streaming delta: without the guard a long + * session rebuilds hundreds of marks per commit for a rail that only changes + * when a Turn is added, removed, or becomes active. Its props must therefore + * stay referentially stable across those commits. + */ +export const TurnNavigator = memo(TurnNavigatorRail) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index ec6af626ee..7de458c73c 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -412,6 +412,34 @@ describe('Chat node rendering', () => { }) describe('ChatView', () => { + it('leaves the turn rail unrendered when an unrelated Chat update commits', () => { + const snapshot = chatSnapshotFixture({ + nodes: [ + userInTurn(1, 'first prompt', 1), + assistant(2, 'first response', 1), + userInTurn(4, 'second prompt', 2), + assistant(5, 'second response', 2), + ], + turnEnds: new Map([[1, 3], [2, 6]]), + }) + const h = makeHarness({}, {}, snapshot) + // The rail asks for its own accessible name once per render, so counting + // that key counts renders without reaching into the component. + let railRenders = 0 + const translate = h.props.t + const counting = ((key: string, vars?: Record) => { + if (key === 'chat.turnNavigation.label') railRenders += 1 + return (translate as (k: string, v?: Record) => string)(key, vars) + }) as ChatViewSlotProps['t'] + render() + const afterMount = railRenders + expect(afterMount).toBeGreaterThan(0) + + act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) }) + + expect(railRenders).toBe(afterMount) + }) + it('projects loaded turns into prompt and response navigation previews', () => { const snapshot = chatSnapshotFixture({ nodes: [