From a6c7cf63c136b8a79e24c5308a9d3e3210ff42b0 Mon Sep 17 00:00:00 2001 From: Kunping Wang <146500293+wang-kaopu@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:13:52 +0800 Subject: [PATCH 1/2] =?UTF-8?q?perf(sidebar):=20=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E5=B7=B2=E5=AE=9A=E4=BD=8D=E4=BC=9A=E8=AF=9D=E5=88=97=E6=97=B6?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E6=9F=A5=E8=AF=A2=20DOM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 缓存会话列仍连接时跳过 #root 全树 querySelector - 保留 detached/HMR 回退定位与 1.5s safety net - 补充 locator 行为测试覆盖缓存复用与节点替换 --- src/client/Sidebar.tsx | 14 +++++--- src/client/center-column.ts | 22 ++++++++++++ tests/center-column-locator.spec.ts | 53 +++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 src/client/center-column.ts create mode 100644 tests/center-column-locator.spec.ts diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 8897571..1190a6f 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -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 '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 diff --git a/src/client/center-column.ts b/src/client/center-column.ts new file mode 100644 index 0000000..ed9f5ab --- /dev/null +++ b/src/client/center-column.ts @@ -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 +} diff --git a/tests/center-column-locator.spec.ts b/tests/center-column-locator.spec.ts new file mode 100644 index 0000000..d1f9f90 --- /dev/null +++ b/tests/center-column-locator.spec.ts @@ -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() + }) +}) From f1da0fe345eff166314a73ec9b0d7d37a67ca979 Mon Sep 17 00:00:00 2001 From: Kunping Wang <146500293+wang-kaopu@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:21:08 +0800 Subject: [PATCH 2/2] =?UTF-8?q?perf(sidebar):=20=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E5=AE=9A=E4=BD=8D=E5=B9=B6=E9=99=90=E5=88=B6?= =?UTF-8?q?=E5=85=A8=E6=A0=91=E6=9F=A5=E8=AF=A2=E9=A2=91=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 缓存节点连接期间按 1.5s safety-net 周期完整复核 - html style 变化时立即绕过 fast path 重新定位 - 覆盖流式热路径、定时复核、HMR 信号与节点替换行为 --- src/client/center-column.ts | 82 ++++++++++++++++++++---- tests/center-column-locator.spec.ts | 98 ++++++++++++++++++++++------- 2 files changed, 145 insertions(+), 35 deletions(-) diff --git a/src/client/center-column.ts b/src/client/center-column.ts index ed9f5ab..5bf7e73 100644 --- a/src/client/center-column.ts +++ b/src/client/center-column.ts @@ -1,22 +1,82 @@ /** Stable selector for the DSH AppFrame conversation slot. */ const CENTER_COLUMN_SELECTOR = '#root [data-slot="conversation"]' +/** Match Sidebar's existing last-resort retry cadence (issue #248). */ +export const CENTER_COLUMN_REVALIDATE_MS = 1500 + +interface CenterColumnResolveOptions { + /** Explicit document for tests / callers that do not yet have a cached node. */ + document?: Document + /** Injectable DOM lookup for tests. */ + query?: () => Element | null + /** Injectable clock for deterministic retry-boundary tests. */ + now?: () => number + /** Injectable fingerprint for deterministic HMR tests. */ + htmlStyle?: () => string | null + /** Override only for tests; production follows the 1.5s safety-net cadence. */ + revalidateMs?: number +} + +/** Last full DOM validation for each center-column node. Weak keys avoid leaks. */ +const validatedAt = new WeakMap() + /** - * Resolve the AppFrame center column while keeping the hot path query-free. + * The html-style watcher is an HMR/layout resync signal. Remember the last + * fingerprint per document so a style change can bypass the connected-node + * fast path immediately, without requiring Sidebar to run a second locator. + */ +const documentStyleState = new WeakMap() + +/** + * Resolve the AppFrame center column while keeping streaming mutations cheap. * - * 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. + * Sidebar intentionally keeps both recovery mechanisms that predate #403: + * the `#root` subtree MutationObserver catches boot/HMR DOM swaps, and the + * 1.5s interval is a last-resort safety net for interleavings no observer is + * guaranteed to see (#248). The expensive part was letting every scheduled + * locate scan `#root` with querySelector at streaming-token cadence. + * + * Once a connected column is cached, normal calls therefore reuse it without + * a document query. A full query is still forced when either: + * - the cached node disconnects; + * - `` changes (the existing HMR/layout resync signal); or + * - 1.5s has elapsed since the last full validation (the safety-net cadence). + * + * This preserves recovery semantics while bounding whole-tree selector work + * to low-frequency validation instead of chat mutation frequency. */ export function resolveCenterColumn( current: HTMLElement | null, - query: () => Element | null = () => document.querySelector(CENTER_COLUMN_SELECTOR), + options: CenterColumnResolveOptions = {}, ): HTMLElement | undefined { - if (current !== null && current.isConnected) return current + const doc = options.document ?? current?.ownerDocument ?? document + const now = (options.now ?? Date.now)() + const revalidateMs = options.revalidateMs ?? CENTER_COLUMN_REVALIDATE_MS + const htmlStyle = (options.htmlStyle ?? (() => doc.documentElement.getAttribute('style')))() - const col = query()?.parentElement - return col !== null && col !== undefined && col.isConnected ? col : undefined + const previousStyle = documentStyleState.get(doc) + const styleChanged = previousStyle !== undefined && previousStyle !== htmlStyle + documentStyleState.set(doc, htmlStyle) + + if (current !== null && current.isConnected) { + const lastValidation = validatedAt.get(current) + + // A connected node passed in for the first time is already a trustworthy + // cache hit. Start its safety-net window without paying an eager query. + if (lastValidation === undefined && !styleChanged) { + validatedAt.set(current, now) + return current + } + + if (!styleChanged && lastValidation !== undefined && now - lastValidation < revalidateMs) { + return current + } + } + + const query = options.query ?? (() => doc.querySelector(CENTER_COLUMN_SELECTOR)) + const col = query()?.parentElement as HTMLElement | null | undefined + if (col === null || col === undefined || !col.isConnected) return undefined + + validatedAt.set(col, now) + return col } diff --git a/tests/center-column-locator.spec.ts b/tests/center-column-locator.spec.ts index d1f9f90..026ab8b 100644 --- a/tests/center-column-locator.spec.ts +++ b/tests/center-column-locator.spec.ts @@ -1,53 +1,103 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it, vi } from 'vitest' -import { resolveCenterColumn } from '../src/client/center-column.ts' +import { describe, expect, it, vi } from 'vitest' +import { + CENTER_COLUMN_REVALIDATE_MS, + resolveCenterColumn, +} from '../src/client/center-column.ts' -afterEach(() => { - document.body.innerHTML = '' -}) +interface Fixture { + doc: Document + col: HTMLDivElement + slot: HTMLDivElement +} -function connectedColumn(): { col: HTMLDivElement; slot: HTMLDivElement } { - const col = document.createElement('div') - const slot = document.createElement('div') +function connectedColumn(doc = document.implementation.createHTMLDocument()): Fixture { + const col = doc.createElement('div') + const slot = doc.createElement('div') slot.dataset.slot = 'conversation' col.append(slot) - document.body.append(col) - return { col, slot } + doc.body.append(col) + return { doc, 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) + it('reuses a connected cached column without querying during the hot-path window', () => { + const { doc, col, slot } = connectedColumn() + let now = 0 + const query = vi.fn<() => Element | null>(() => slot) + const options = { document: doc, query, now: () => now } - expect(resolveCenterColumn(col, query)).toBe(col) + expect(resolveCenterColumn(col, options)).toBe(col) + now = CENTER_COLUMN_REVALIDATE_MS - 1 + expect(resolveCenterColumn(col, options)).toBe(col) expect(query).not.toHaveBeenCalled() }) - it('queries and adopts the replacement after the cached column detaches', () => { - const stale = connectedColumn().col + it('fully revalidates a connected cache at the 1.5s safety-net boundary', () => { + const { doc, col, slot } = connectedColumn() + let now = 0 + const query = vi.fn<() => Element | null>(() => slot) + const options = { document: doc, query, now: () => now } + + expect(resolveCenterColumn(col, options)).toBe(col) + expect(query).not.toHaveBeenCalled() + + now = CENTER_COLUMN_REVALIDATE_MS + expect(resolveCenterColumn(col, options)).toBe(col) + expect(query).toHaveBeenCalledTimes(1) + }) + + it('revalidates immediately when the html style signal changes', () => { + const { doc, col, slot } = connectedColumn() + let now = 0 + const query = vi.fn<() => Element | null>(() => slot) + const options = { document: doc, query, now: () => now } + + expect(resolveCenterColumn(col, options)).toBe(col) + expect(query).not.toHaveBeenCalled() + + doc.documentElement.style.setProperty('--dsh-sidebar-width', '320px') + now = 1 + expect(resolveCenterColumn(col, options)).toBe(col) + expect(query).toHaveBeenCalledTimes(1) + + // The same fingerprint is cheap again; one style change causes one full + // validation instead of turning later streaming mutations into queries. + now = 2 + expect(resolveCenterColumn(col, options)).toBe(col) + expect(query).toHaveBeenCalledTimes(1) + }) + + it('queries and adopts the replacement as soon as the cached column detaches', () => { + const first = connectedColumn() + const stale = first.col stale.remove() - const { col: replacement, slot } = connectedColumn() + const { col: replacement, slot } = connectedColumn(first.doc) const query = vi.fn<() => Element | null>(() => slot) - expect(resolveCenterColumn(stale, query)).toBe(replacement) + expect(resolveCenterColumn(stale, { document: first.doc, query, now: () => 1 })).toBe(replacement) expect(query).toHaveBeenCalledTimes(1) }) it('queries when no column has been cached yet', () => { - const { col, slot } = connectedColumn() + const { doc, col, slot } = connectedColumn() const query = vi.fn<() => Element | null>(() => slot) - expect(resolveCenterColumn(null, query)).toBe(col) + expect(resolveCenterColumn(null, { document: doc, query, now: () => 0 })).toBe(col) expect(query).toHaveBeenCalledTimes(1) }) it('rejects a missing or detached query result', () => { - expect(resolveCenterColumn(null, () => null)).toBeUndefined() + const doc = document.implementation.createHTMLDocument() + expect(resolveCenterColumn(null, { document: doc, query: () => null, now: () => 0 })).toBeUndefined() - const detachedCol = document.createElement('div') - const detachedSlot = document.createElement('div') + const detachedCol = doc.createElement('div') + const detachedSlot = doc.createElement('div') detachedCol.append(detachedSlot) - expect(resolveCenterColumn(null, () => detachedSlot)).toBeUndefined() + expect(resolveCenterColumn(null, { + document: doc, + query: () => detachedSlot, + now: () => 1, + })).toBeUndefined() }) })