perf(sidebar): 消除开关/拖拽的每帧 React 重渲染

- 根因:开关过渡期 ResizeObserver→setCenterRect 每帧重渲染整棵 Sidebar;TabContent 无 memo;内部 Divider/文件树 dock 每 pointermove 全量更新;拖拽残余为 `#root` 每帧 reflow(动画固有成本,A/B 实证非回归)。
- 修复:centerRect 改 ref + 底栏 DOM 直写(零 React 渲染);TabContent memo(显式比较器,locale/registry/paneId 保持新鲜);新增 frame-batcher 对 Divider/dock 拖拽按帧合并;拖拽期跳过无意义 locate。
- 验证:单测 76 文件 773 通过;`pnpm test:mount` 12/12(含新 toggle-layout e2e:开关过渡底栏逐帧 0.0px 贴合);4x CPU 节流 A/B:开关 >17ms 帧 collapse 19→6 / expand 24→4~6,p95 21ms→15ms;拖拽不变(非回归)。
- CR(@codex P1)已修复:`moveTab` 复用同一 tab 对象,比较器改显式比较 `paneId`(含回归单测)。
- 本地 DSH Web 实测通过后按用户要求合入。

fixes #315
This commit is contained in:
Atlas Zeng
2026-08-22 22:50:13 +08:00
committed by GitHub
parent 924f377fa1
commit 4148130aac
9 changed files with 558 additions and 27 deletions
+16 -1
View File
@@ -31,6 +31,7 @@ import { api, mediaUrl, type SessionScope } from './api.ts'
import { BinaryDownload } from './binary-download.tsx'
import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts'
import { baseName } from './FileTree.tsx'
import { createFrameBatcher } from './frame-batcher.ts'
import { openSidebarFile } from './intercept.tsx'
import { TreePanel } from './TreePanel.tsx'
import { t } from './locales.ts'
@@ -165,8 +166,16 @@ export function EditorHost(props: {
// (no window listeners — the captured pointer keeps tracking even off the
// handle). Local width while dragging, persisted into meta.treeWidth on
// release. The panel docks right, so dragging LEFT widens it.
// Moves are BATCHED per frame (createFrameBatcher): applying every
// pointermove is a setState that re-renders this host AND the editor
// viewer below it (CodeMirror re-lays out on the width change) at event
// cadence — the visible drag lag on slower CPUs (#315). The batch applies
// the latest width once per frame; release flushes it and commits.
const [dragWidth, setDragWidth] = useState<number | null>(null)
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
const pendingWidthRef = useRef(0)
const dragBatcher = useRef(createFrameBatcher()).current
useEffect(() => () => dragBatcher.dispose(), [dragBatcher])
const treeWidth = dragWidth ?? treeWidthOf(tab)
const onResizeStart = (event: React.PointerEvent): void => {
@@ -178,11 +187,17 @@ export function EditorHost(props: {
const onResizeMove = (event: React.PointerEvent): void => {
const drag = dragRef.current
if (drag === null) return
setDragWidth(clampTreeWidth(drag.startWidth + (drag.startX - event.clientX)))
pendingWidthRef.current = clampTreeWidth(drag.startWidth + (drag.startX - event.clientX))
dragBatcher.schedule(() => setDragWidth(pendingWidthRef.current))
}
const onResizeEnd = (event: React.PointerEvent): void => {
const drag = dragRef.current
if (drag === null) return
// Flush the last pending frame (a release can land with the final move
// still queued; without the flush a stray frame would re-apply the
// drag width AFTER the null below). Both setStates batch into this same
// event, so the committed treeWidth wins visually.
dragBatcher.flushNow()
dragRef.current = null
setDragWidth(null)
const finalWidth = clampTreeWidth(drag.startWidth + (drag.startX - event.clientX))
+62 -23
View File
@@ -28,7 +28,7 @@
* drawer floats). Widening does not migrate back: the tabs keep living in
* the right tree.
*/
import { createElement, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type ReactNode } from 'react'
import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type ReactNode } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -52,6 +52,7 @@ import type { TabDragPayload } from './TabBar.tsx'
import { relativeTo } from './paths.ts'
import { OrphanedTab } from './OrphanedTab.tsx'
import { RenderBoundary } from './RenderBoundary.tsx'
import { tabContentCompare, type TabContentMemoKey } from './tab-content-memo.ts'
import { detectNewDirectSubagent } from './subagent-detect.ts'
import { detectNewJob } from './subagent-jobs.ts'
import { t } from './locales.ts'
@@ -113,23 +114,25 @@ function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElem
return tag
}
/** Render the content of one tab (dispatched by type). */
function TabContent(props: {
tab: SidebarTab
sessionId: string
cwd: string | undefined
expanded: string[]
/** Props of one tab's content cell = the memo key (tab-content-memo.ts) plus
* the runtime objects/callbacks the cell renders with. The memo comparator
* is the pure `tabContentCompare`; anything in the key decides a re-render
* must propagate, anything outside it must be a stable object (ctx/store)
* or covered by a compared field (paneId covers onOpenDiff's captured
* pane; sessionId/cwd cover onReferenceFile). */
interface TabContentProps extends TabContentMemoKey {
onToggleDir: (path: string) => void
onReferenceFile: (path: string) => void
ctx: Context
store: SidebarStore
/** Whether this tab is the active one AND the panel is open (live views pause otherwise). */
visible: boolean
/** Fired before a topology node jumps to its child session (see Sidebar). */
onSubagentJump: (childSessionId: string) => void
/** Open a diff tab from the git panel (placement handled by the store). */
onOpenDiff: (tab: SidebarTab) => void
}) {
}
/** Render the content of one tab (dispatched by type). */
const TabContent = memo(function TabContent(props: TabContentProps) {
const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
const scope = { sessionId, cwd }
const descriptor = ctx.betterSidebar?.getTab(tab.type)
@@ -152,7 +155,7 @@ function TabContent(props: {
onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
}),
)
}
}, tabContentCompare)
/** The + menu options for the current state, driven by the tab registry.
* Hidden tabs (editor/diff) never show; `available` returning false shows
@@ -185,6 +188,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
)
void localeRevision
// Tab-registry revision: TabContent memo cells must pick up a descriptor
// a plugin registers/disposes after mount (the + menu / icons already read
// the registry at render). Rare events (plugin (un)mount), so one full
// re-render per change is fine — this is what keeps the memoized cells
// from going stale, mirroring the localeRevision mechanism above.
const [tabsVersion, setTabsVersion] = useState(0)
useEffect(() => {
const service = ctx.betterSidebar
if (service === undefined) return
return service.subscribe(() => setTabsVersion(version => version + 1))
}, [ctx])
// Narrow (mobile) viewports collapse the two panels into one: the right
// panel becomes a full-width drawer holding BOTH workbenches, the bottom
// panel (and its toggle button) disappears, and the layout push is
@@ -497,7 +512,16 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// horizontal edges — including the animated margin-right push while the
// right panel opens/closes; a frame that never appears keeps the initial
// zero-size fallback (the panel renders at 0 width until measured).
const [centerRect, setCenterRect] = useState({ left: 0, right: 0 })
// The rect lives in a REF (not state): the open/close transition resizes
// the center column EVERY frame for its duration, and reacting per frame
// with setState re-renders the whole Sidebar (every mounted tab) at
// animation cadence — the visible toggle jank (#315). measureCenter
// writes the bottom panel's edges directly (same DOM-write pattern as
// applyDrag), so the panel still tracks the column per frame with zero
// React work; `centerMeasured` flips ONCE to gate the hidden→visible
// first-paint fallback.
const centerRectRef = useRef({ left: 0, right: 0 })
const [centerMeasured, setCenterMeasured] = useState(false)
// Refs keep the measure step stable across renders and let it skip work
// mid-drag: during a width/corner drag the layout push resizes the center
// column every frame, and reacting (setCenterRect → re-render) would
@@ -518,13 +542,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
return
}
const rect = col.getBoundingClientRect()
// The bottom panel only cares about the horizontal edges: a pure height
// change (the bottom panel itself opening/closing) must not re-render,
// so keep the previous object when left/right are unchanged.
setCenterRect(prev =>
prev.left === rect.left && prev.right === rect.right
? prev
: { left: rect.left, right: rect.right })
// Ref + direct DOM write (see the centerRectRef comment): the bottom
// panel keeps tracking the center column per frame during the right
// panel's open/close animation without re-rendering the shell. The
// one-shot measured flip renders the panel visible once (a stale
// {0,0} fallback would flash full-width).
centerRectRef.current = { left: rect.left, right: rect.right }
const bottom = bottomRef.current
if (bottom !== null) {
bottom.style.setProperty('left', `${rect.left}px`)
bottom.style.setProperty('right', `${window.innerWidth - rect.right}px`)
}
setCenterMeasured(prev => (prev ? prev : true))
}, [])
useEffect(() => {
let disposed = false
@@ -571,6 +600,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
let locateFrame: number | null = null
const scheduleLocate = (): void => {
if (locateFrame !== null) return
// 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
// frame's budget (#315). The 1.5s retry below still covers any node
// swap that somehow lands mid-drag.
if (draggingRef.current) return
locateFrame = requestAnimationFrame(() => {
locateFrame = null
locate()
@@ -708,7 +744,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// width (innerWidth - state.width - detailsWidth), so this equals
// `width + detailsWidth` — derived from the measured column, keeping the
// drag write-only (no React re-render mid-drag).
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRect.right) + (width - (state?.width ?? 0))}px`)
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRectRef.current.right) + (width - (state?.width ?? 0))}px`)
writeGeometry(width, height)
}
@@ -1033,6 +1069,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const renderTab = (tab: SidebarTab, active: boolean, paneId: string, bottom = false) => (
<TabContent
tab={tab}
paneId={paneId}
sessionId={sessionId}
cwd={cwd}
expanded={state.expanded}
@@ -1043,6 +1080,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
visible={bottom ? state.bottomOpen && active : state.panelOpen && active}
onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
localeRevision={localeRevision}
tabsVersion={tabsVersion}
/>
)
@@ -1231,7 +1270,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
data-dsh-bottom-panel
style={{
height: Math.min(state.bottomHeight, window.innerHeight),
left: centerRect.left,
left: centerRectRef.current.left,
// Keep the panel above the on-screen keyboard when the visual
// viewport shrinks (see the keyboardInset effect).
bottom: keyboardInset > 0 ? `${keyboardInset}px` : undefined,
@@ -1240,14 +1279,14 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// details column's left edge (the details column sits between the
// center and the right panel, and the right panel's margin-right
// push is already baked into centerRect.right).
right: window.innerWidth - centerRect.right,
right: window.innerWidth - centerRectRef.current.right,
// The seam against the open right panel needs its own hairline
// (the right panel's border-left alone is covered by this panel's
// fill — without it the corner looks cut off).
borderRight: state.panelOpen ? '1px solid var(--dsw-alias-border-l2)' : undefined,
// Unmeasured center column → keep the panel invisible (zero-size
// geometry would flash full-width overflow instead).
visibility: centerRect.right > 0 ? undefined : 'hidden',
visibility: centerMeasured ? undefined : 'hidden',
}}
data-dragging={(draggingBottom || draggingCorner) || undefined}
+56
View File
@@ -0,0 +1,56 @@
/**
* Per-frame task coalescing for pointer-driven interactions.
*
* Pointer streams fire several times faster than the display refresh, and
* applying every event (a React setState, a store reduce) re-renders whole
* trees at event cadence — the visible drag lag on slower CPUs (#315). This
* batcher keeps the LATEST scheduled task, runs it at most once per
* animation frame, and lets the caller flush synchronously on release (so
* the final pointer position is never lost to a cancelled frame).
*
* Callers compose the pending value in a ref before scheduling; the
* scheduled task reads that ref, so replacing the task on every event is
* cheap (one ref write + one rAF check).
*/
export interface FrameBatcher {
/** Schedule (or replace) the per-frame task. Safe to call on every event. */
schedule(task: () => void): void
/** Cancel any pending frame and run the scheduled task synchronously
* (no-op when nothing is scheduled). */
flushNow(): void
/** Cancel any pending frame and drop the task without running it. */
dispose(): void
}
export function createFrameBatcher(): FrameBatcher {
let frame: number | null = null
let task: (() => void) | null = null
const run = (): void => {
frame = null
const current = task
task = null
current?.()
}
return {
schedule(next) {
task = next
if (frame === null) frame = requestAnimationFrame(run)
},
flushNow() {
if (frame !== null) {
cancelAnimationFrame(frame)
frame = null
}
run()
},
dispose() {
if (frame !== null) {
cancelAnimationFrame(frame)
frame = null
}
task = null
},
}
}
+25 -2
View File
@@ -15,6 +15,7 @@ import clsx from 'clsx'
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
import type { DropZone } from './state.ts'
import { TabBar, type NewTabOption, parseDrag, type TabDragPayload } from './TabBar.tsx'
import { createFrameBatcher } from './frame-batcher.ts'
import css from './sidebar.module.css'
/** Actions the workbench needs (bound to the store by the sidebar shell). */
@@ -34,7 +35,18 @@ export interface WorkbenchActions {
* Deltas are incremental — each move reports the displacement since the
* previous move — because the store adds every reported delta to the pane
* sizes; a cumulative (since-pointer-down) delta would be re-added on each
* move and the divider would run away from the cursor. */
* move and the divider would run away from the cursor.
*
* The moves are BATCHED per frame (createFrameBatcher): a pointer stream
* fires faster than the display refresh, and applying each move is a store
* reduce that re-renders both workbenches (terminals, editors, trees) per
* event — the visible drag lag on slower CPUs (#315). The batch accumulates
* the incremental deltas in a ref and applies the summed fraction at most
* once per frame; the sum equals what the per-event application would have
* produced (the reducer clamps each application, and at a settled position
* a clamped sum is clamped to the same boundary), so the result is
* indistinguishable at rest and at most one frame behind the cursor.
*/
function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => void }) {
const { dir, onResize } = props
// `last` is the previous pointer position while dragging; `size` is the
@@ -42,6 +54,9 @@ function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => v
// normalize the px delta into a fraction.
const last = useRef({ x: 0, y: 0, size: 0 })
const [dragging, setDragging] = useState(false)
const pendingDelta = useRef(0)
const batcher = useRef(createFrameBatcher()).current
useEffect(() => () => batcher.dispose(), [batcher])
return (
<div
@@ -60,12 +75,20 @@ function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => v
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const delta = dir === 'row' ? event.clientX - last.current.x : event.clientY - last.current.y
onResize(delta / Math.max(1, last.current.size))
pendingDelta.current += delta
batcher.schedule(() => {
const accumulated = pendingDelta.current
pendingDelta.current = 0
if (accumulated !== 0) onResize(accumulated / Math.max(1, last.current.size))
})
last.current.x = event.clientX
last.current.y = event.clientY
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
// Flush any pending baked delta so the final pointer position lands
// (the release event is not a move; dropping it would lose the tail).
batcher.flushNow()
event.currentTarget.releasePointerCapture(event.pointerId)
setDragging(false)
}}
+46
View File
@@ -0,0 +1,46 @@
/**
* The memo comparator for one tab's content cell (TabContent in Sidebar.tsx).
*
* Kept as a pure module so the "what re-renders a tab cell" contract is unit
* testable: geometry/store re-renders of the Sidebar shell must NOT reconcile
* every mounted tab (xterm/CodeMirror/FileTree subtrees — issue #315), while
* locale switches (localeRevision), tab-registry updates (tabsVersion), and
* a tab MOVING BETWEEN PANES (paneId — onOpenDiff closes over the pane id,
* and moveTab reuses the SAME tab object, so only the pane id identifies the
* move) must re-render the cell.
*/
import type { SidebarTab } from './state.ts'
/** The fields whose change invalidates a tab cell's memo. Anything a cell's
* render (or its callbacks) depends on must be listed here. */
export interface TabContentMemoKey {
tab: SidebarTab
/** The pane this tab currently lives in. onOpenDiff closes over it, and
* moveTab reuses the same tab object when the tab moves panes — without
* comparing paneId the cell would keep the source pane's callback. */
paneId: string
sessionId: string
cwd: string | undefined
visible: boolean
expanded: string[]
localeRevision: string
tabsVersion: number
}
/** True when the cell may skip a re-render (all render-affecting fields
* unchanged). Callback/context identities are deliberately ignored: their
* captured dependencies are stable or covered by the compared fields
* (onReferenceFile → sessionId/cwd, onSubagentJump/onToggleDir → stable
* refs/closures, onOpenDiff → paneId). */
export function tabContentCompare(prev: TabContentMemoKey, next: TabContentMemoKey): boolean {
return (
prev.tab === next.tab &&
prev.paneId === next.paneId &&
prev.sessionId === next.sessionId &&
prev.cwd === next.cwd &&
prev.visible === next.visible &&
prev.expanded === next.expanded &&
prev.localeRevision === next.localeRevision &&
prev.tabsVersion === next.tabsVersion
)
}