perf(sidebar): 保留兜底定位并限制全树查询频率
- 缓存节点连接期间按 1.5s safety-net 周期完整复核 - html style 变化时立即绕过 fast path 重新定位 - 覆盖流式热路径、定时复核、HMR 信号与节点替换行为
This commit is contained in:
committed by
Menghuan1918
parent
a6c7cf63c1
commit
f1da0fe345
+71
-11
@@ -1,22 +1,82 @@
|
|||||||
/** Stable selector for the DSH AppFrame conversation slot. */
|
/** Stable selector for the DSH AppFrame conversation slot. */
|
||||||
const CENTER_COLUMN_SELECTOR = '#root [data-slot="conversation"]'
|
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 <html style> 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<HTMLElement, number>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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<Document, string | null>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the AppFrame center column while keeping streaming mutations cheap.
|
||||||
*
|
*
|
||||||
* Streaming chat output mutates `#root` at token cadence, so Sidebar's
|
* Sidebar intentionally keeps both recovery mechanisms that predate #403:
|
||||||
* MutationObserver can call this once per animation frame. Once the center
|
* the `#root` subtree MutationObserver catches boot/HMR DOM swaps, and the
|
||||||
* column has been found, its DOM identity is stable for the conversation
|
* 1.5s interval is a last-resort safety net for interleavings no observer is
|
||||||
* lifecycle; checking `isConnected` is enough to reuse it without scanning
|
* guaranteed to see (#248). The expensive part was letting every scheduled
|
||||||
* the whole app tree again. Boot/HMR replacement still falls through to the
|
* locate scan `#root` with querySelector at streaming-token cadence.
|
||||||
* document query as soon as the cached node is detached.
|
*
|
||||||
|
* 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;
|
||||||
|
* - `<html style>` 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(
|
export function resolveCenterColumn(
|
||||||
current: HTMLElement | null,
|
current: HTMLElement | null,
|
||||||
query: () => Element | null = () => document.querySelector(CENTER_COLUMN_SELECTOR),
|
options: CenterColumnResolveOptions = {},
|
||||||
): HTMLElement | undefined {
|
): 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
|
const previousStyle = documentStyleState.get(doc)
|
||||||
return col !== null && col !== undefined && col.isConnected ? col : undefined
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,103 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { resolveCenterColumn } from '../src/client/center-column.ts'
|
import {
|
||||||
|
CENTER_COLUMN_REVALIDATE_MS,
|
||||||
|
resolveCenterColumn,
|
||||||
|
} from '../src/client/center-column.ts'
|
||||||
|
|
||||||
afterEach(() => {
|
interface Fixture {
|
||||||
document.body.innerHTML = ''
|
doc: Document
|
||||||
})
|
col: HTMLDivElement
|
||||||
|
slot: HTMLDivElement
|
||||||
|
}
|
||||||
|
|
||||||
function connectedColumn(): { col: HTMLDivElement; slot: HTMLDivElement } {
|
function connectedColumn(doc = document.implementation.createHTMLDocument()): Fixture {
|
||||||
const col = document.createElement('div')
|
const col = doc.createElement('div')
|
||||||
const slot = document.createElement('div')
|
const slot = doc.createElement('div')
|
||||||
slot.dataset.slot = 'conversation'
|
slot.dataset.slot = 'conversation'
|
||||||
col.append(slot)
|
col.append(slot)
|
||||||
document.body.append(col)
|
doc.body.append(col)
|
||||||
return { col, slot }
|
return { doc, col, slot }
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('center-column locator (issue #403)', () => {
|
describe('center-column locator (issue #403)', () => {
|
||||||
it('reuses a connected cached column without querying the app tree', () => {
|
it('reuses a connected cached column without querying during the hot-path window', () => {
|
||||||
const { col } = connectedColumn()
|
const { doc, col, slot } = connectedColumn()
|
||||||
const query = vi.fn<() => Element | null>(() => null)
|
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()
|
expect(query).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('queries and adopts the replacement after the cached column detaches', () => {
|
it('fully revalidates a connected cache at the 1.5s safety-net boundary', () => {
|
||||||
const stale = connectedColumn().col
|
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()
|
stale.remove()
|
||||||
const { col: replacement, slot } = connectedColumn()
|
const { col: replacement, slot } = connectedColumn(first.doc)
|
||||||
const query = vi.fn<() => Element | null>(() => slot)
|
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)
|
expect(query).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('queries when no column has been cached yet', () => {
|
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)
|
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)
|
expect(query).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects a missing or detached query result', () => {
|
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 detachedCol = doc.createElement('div')
|
||||||
const detachedSlot = document.createElement('div')
|
const detachedSlot = doc.createElement('div')
|
||||||
detachedCol.append(detachedSlot)
|
detachedCol.append(detachedSlot)
|
||||||
expect(resolveCenterColumn(null, () => detachedSlot)).toBeUndefined()
|
expect(resolveCenterColumn(null, {
|
||||||
|
document: doc,
|
||||||
|
query: () => detachedSlot,
|
||||||
|
now: () => 1,
|
||||||
|
})).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user