perf: stabilize renders (transcript row reuse, tree Sets, shared + options, batched drags)

SideChat transcript: every 2s poll rebuilt ALL row objects, so every
row (markdown parse + DOMPurify included) re-rendered even when the
log had not changed. transcriptRows now takes the previous poll's
rows and re-adopts the position-aligned rows whose content is
identical (reuseRows) — only the changed tail rebuilds. The view
feeds the memoized array back through a ref; unit tests pin the
identity contract (unchanged rows keep object identity, changed rows
and everything past the change rebuild, tool-result landings never
re-adopt).

FileTree: expanded/revealed membership was an includes() scan per
rendered row (O(rows × expanded) per render on deep trees); one Set
per render keeps it O(1) per row.

Shell: buildNewTabOptions was inlined in the two Workbench JSX
sites — two fresh arrays per render (drag state, resizes) re-rendered
every LeafView's + affordance. One shared memo now.

DiffPane resize / TerminalView ResizeObserver: pointer and resize
streams fired a setState / glyph-measuring fit() per event; both ride
per-frame batching now (frame-batcher in the core, a local rAF gate
in the lazy terminal chunk). SessionLens sizes each row's content
with a Blob (a UTF-8 encode of the whole edit) — that moved from
per-row-per-render into one memoized pass per changed fold.
This commit is contained in:
Menghuan1918
2026-09-01 16:01:34 +08:00
parent 427552e4f9
commit b7af498427
8 changed files with 207 additions and 20 deletions
+8 -1
View File
@@ -21,6 +21,7 @@ import { DiffFiles } from '../diff/DiffFiles.tsx'
import { langOfPath } from '../diff/highlight.ts'
import { buildDiffSegments, diffLines, diffStats, parseUnifiedDiff, unifiedSegments, type DiffRow } from '../diff/rows.ts'
import { parseReadLines, type FileOp } from './ops.ts'
import { createFrameBatcher } from '../frame-batcher.ts'
import css from './changes.module.css'
import diffCss from '../diff/diff.module.css'
@@ -176,17 +177,23 @@ export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExp
const paneHeight = dragHeight ?? height
const clamp = (value: number): number => Math.min(Math.max(value, HEIGHT_MIN), Math.round(window.innerHeight * 0.7))
const dragOrigin = useRef<{ y: number; h: number } | null>(null)
// Pointer streams fire several times per frame; one setState per event
// re-rendered the whole pane at event cadence (see frame-batcher).
const dragBatcher = useRef(createFrameBatcher()).current
useEffect(() => () => dragBatcher.dispose(), [dragBatcher])
const onHandleDown = (event: ReactPointerEvent<HTMLDivElement>): void => {
event.preventDefault()
dragOrigin.current = { y: event.clientY, h: paneHeight }
const onMove = (ev: PointerEvent): void => {
if (dragOrigin.current === null) return
setDragHeight(clamp(dragOrigin.current.h + (dragOrigin.current.y - ev.clientY)))
const next = clamp(dragOrigin.current.h + (dragOrigin.current.y - ev.clientY))
dragBatcher.schedule(() => { setDragHeight(next) })
}
const onUp = (): void => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
dragOrigin.current = null
dragBatcher.flushNow()
setDragHeight(current => {
if (current !== null) onHeightCommit(current)
return null