Browse Source

perf(ui-chat): retain the stats resize observer

imccyu 1 month ago
parent
commit
aad1ce0c68

+ 40 - 27
packages/client/ui-chat/src/client/chat/StatsLine.tsx

@@ -2,7 +2,7 @@
 // Mounted on 'conversation.composer.dock' so it sticks with the composer in the
 // active conversation scrollport (see ConversationRoot data-conversation-scroll).
 
-import { Fragment, memo, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { Fragment, memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
 import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
@@ -122,6 +122,44 @@ export interface StatsLineProps {
   t: ChatViewSlotProps['t']
 }
 
+/** Render and measure one non-empty statistics line. */
+const StatsLineContent = memo(function StatsLineContent({
+  groups,
+  line,
+}: {
+  readonly groups: readonly string[]
+  readonly line: string
+}) {
+  const rootRef = useRef<HTMLDivElement | null>(null)
+  const [truncated, setTruncated] = useState(false)
+  const measure = useCallback(() => {
+    const el = rootRef.current
+    if (el === null) return
+    const next = el.scrollWidth > el.clientWidth
+    setTruncated(current => current === next ? current : next)
+  }, [])
+  useLayoutEffect(() => {
+    const el = rootRef.current
+    if (el === null || typeof ResizeObserver === 'undefined') return
+    const observer = new ResizeObserver(measure)
+    observer.observe(el)
+    return () => { observer.disconnect() }
+  }, [measure])
+  useLayoutEffect(measure, [line, measure])
+  return (
+    <Tooltip label={line} side="top" delayMs={500} disabled={!truncated}>
+      <div ref={rootRef} className={css.root}>
+        {groups.map((group, i) => (
+          <Fragment key={group}>
+            {i > 0 && <><span className={css.sep} aria-hidden>|</span>{' '}</>}
+            <span>{group}</span>
+          </Fragment>
+        ))}
+      </div>
+    </Tooltip>
+  )
+})
+
 export const StatsLine = memo(function StatsLine({ useChat, useProjection, t }: StatsLineProps) {
   const settledNodes = useChat(s => s.legacy.nodes)
   const usage = useProjection('tokenUsage')
@@ -166,31 +204,6 @@ export const StatsLine = memo(function StatsLine({ useChat, useProjection, t }:
     }))
   }
   const line = groups.join(' | ')
-  // The row elides with ellipsis when overlong; a delayed hover tooltip carries
-  // the full line, enabled only while content is actually clipped.
-  const rootRef = useRef<HTMLDivElement | null>(null)
-  const [truncated, setTruncated] = useState(false)
-  useLayoutEffect(() => {
-    const el = rootRef.current
-    if (el === null) return
-    const measure = () => { setTruncated(el.scrollWidth > el.clientWidth) }
-    measure()
-    if (typeof ResizeObserver === 'undefined') return
-    const observer = new ResizeObserver(measure)
-    observer.observe(el)
-    return () => { observer.disconnect() }
-  }, [line])
   if (groups.length === 0) return null
-  return (
-    <Tooltip label={line} side="top" delayMs={500} disabled={!truncated}>
-      <div ref={rootRef} className={css.root}>
-        {groups.map((group, i) => (
-          <Fragment key={group}>
-            {i > 0 && <><span className={css.sep} aria-hidden>|</span>{' '}</>}
-            <span>{group}</span>
-          </Fragment>
-        ))}
-      </div>
-    </Tooltip>
-  )
+  return <StatsLineContent groups={groups} line={line} />
 })

+ 19 - 0
packages/client/ui-chat/tests/chat-stats.client.spec.tsx

@@ -162,6 +162,25 @@ describe('StatsLine', () => {
     return { uncachedInputTokens, outputTokens: 1, cacheReadTokens, cacheWriteTokens: 0 }
   }
 
+  it('mounts one size observer only while a non-empty line exists', () => {
+    let observers = 0
+    class CountingResizeObserver {
+      constructor(_callback: ResizeObserverCallback) { observers++ }
+      observe(): void {}
+      disconnect(): void {}
+    }
+    vi.stubGlobal('ResizeObserver', CountingResizeObserver)
+    const { set, source } = makeSource()
+    render(<StatsLine {...props(source, {})} />)
+    expect(observers).toBe(0)
+
+    act(() => { set({ nodes: [assistant(1, 1)] }) })
+    expect(observers).toBe(1)
+
+    act(() => { set({ nodes: [assistant(1, 1), assistant(2, 1)] }) })
+    expect(observers).toBe(1)
+  })
+
   it('renders the grouped stats row and hides a brand-new empty session', () => {
     const { source } = makeSource({ nodes: [assistant(1, 1)] })
     const view = render(<StatsLine {...props(source)} />)