perf(sidebar): 避免已定位会话列时重复查询 DOM

- 缓存会话列仍连接时跳过 #root 全树 querySelector
- 保留 detached/HMR 回退定位与 1.5s safety net
- 补充 locator 行为测试覆盖缓存复用与节点替换
This commit is contained in:
Kunping Wang
2026-08-29 15:31:05 +08:00
committed by Menghuan1918
parent 2088ef6891
commit a6c7cf63c1
3 changed files with 84 additions and 5 deletions
+9 -5
View File
@@ -47,6 +47,7 @@ import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
import { isNarrowWidth, useViewportSize } from './breakpoints.ts'
import { layoutPushSize } from './layout-push.ts'
import { resolveCenterColumn } from './center-column.ts'
import { parseDesktopEnv } from './desktop-env.ts'
import { getWcoSnapshot, subscribeWco } from './wco.ts'
import { getShellPreset } from './shell-presets.ts'
@@ -738,8 +739,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// (observed: a 1px sliver at the viewport's left edge).
const locate = (): void => {
if (disposed) return
const col = document.querySelector('#root [data-slot="conversation"]')
?.parentElement as HTMLElement | undefined
// Hot path (#403): streaming output mutates #root at token cadence.
// Reuse a still-connected center column and only query after boot/HMR
// detached the cached node.
const col = resolveCenterColumn(centerColRef.current)
if (col === undefined || !col.isConnected) {
if (centerColRef.current !== null) {
centerColRef.current = null
@@ -771,7 +774,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// Mid-drag every frame writes --dsh-sidebar-* on <html>'s style
// attribute, which is the mutation this watcher observes — relocating
// per drag frame is pointless (the center column node cannot change
// while the pointer is captured) and adds a querySelector to every
// while the pointer is captured) and adds locator work to every
// frame's budget (#315). The 1.5s retry below still covers any node
// swap that somehow lands mid-drag.
if (draggingRef.current) return
@@ -798,8 +801,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// may end up byte-identical, and the col may be swapped before the
// subtree watcher attaches). A slow unconditional re-locate makes the
// panel converge on the real column within a couple of seconds no
// matter what sequence the shell used. locate() is cheap when nothing
// changed (one querySelector + an identity compare; no forced layout).
// matter what sequence the shell used. locate() is query-free while the
// cached column stays connected; only a detached/missing cache falls
// back to the document selector.
const retry = window.setInterval(locate, 1500)
return () => {
disposed = true
+22
View File
@@ -0,0 +1,22 @@
/** Stable selector for the DSH AppFrame conversation slot. */
const CENTER_COLUMN_SELECTOR = '#root [data-slot="conversation"]'
/**
* Resolve the AppFrame center column while keeping the hot path query-free.
*
* Streaming chat output mutates `#root` at token cadence, so Sidebar's
* MutationObserver can call this once per animation frame. Once the center
* column has been found, its DOM identity is stable for the conversation
* lifecycle; checking `isConnected` is enough to reuse it without scanning
* the whole app tree again. Boot/HMR replacement still falls through to the
* document query as soon as the cached node is detached.
*/
export function resolveCenterColumn(
current: HTMLElement | null,
query: () => Element | null = () => document.querySelector(CENTER_COLUMN_SELECTOR),
): HTMLElement | undefined {
if (current !== null && current.isConnected) return current
const col = query()?.parentElement
return col !== null && col !== undefined && col.isConnected ? col : undefined
}
+53
View File
@@ -0,0 +1,53 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { resolveCenterColumn } from '../src/client/center-column.ts'
afterEach(() => {
document.body.innerHTML = ''
})
function connectedColumn(): { col: HTMLDivElement; slot: HTMLDivElement } {
const col = document.createElement('div')
const slot = document.createElement('div')
slot.dataset.slot = 'conversation'
col.append(slot)
document.body.append(col)
return { col, slot }
}
describe('center-column locator (issue #403)', () => {
it('reuses a connected cached column without querying the app tree', () => {
const { col } = connectedColumn()
const query = vi.fn<() => Element | null>(() => null)
expect(resolveCenterColumn(col, query)).toBe(col)
expect(query).not.toHaveBeenCalled()
})
it('queries and adopts the replacement after the cached column detaches', () => {
const stale = connectedColumn().col
stale.remove()
const { col: replacement, slot } = connectedColumn()
const query = vi.fn<() => Element | null>(() => slot)
expect(resolveCenterColumn(stale, query)).toBe(replacement)
expect(query).toHaveBeenCalledTimes(1)
})
it('queries when no column has been cached yet', () => {
const { col, slot } = connectedColumn()
const query = vi.fn<() => Element | null>(() => slot)
expect(resolveCenterColumn(null, query)).toBe(col)
expect(query).toHaveBeenCalledTimes(1)
})
it('rejects a missing or detached query result', () => {
expect(resolveCenterColumn(null, () => null)).toBeUndefined()
const detachedCol = document.createElement('div')
const detachedSlot = document.createElement('div')
detachedCol.append(detachedSlot)
expect(resolveCenterColumn(null, () => detachedSlot)).toBeUndefined()
})
})