Files
apes-sidebar/tests/frame-batcher.spec.ts
T
Atlas Zeng 4148130aac 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
2026-08-22 22:50:13 +08:00

94 lines
2.7 KiB
TypeScript

/**
* Frame-batcher: coalesce bursty schedules into one task per animation
* frame, flush synchronously on release, and drop pending work on dispose.
* Uses a deterministic rAF stub so the coalescing contract is exact.
*/
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createFrameBatcher } from '../src/client/frame-batcher.ts'
let frames: Map<number, () => void>
let nextId: number
function stepFrame(): void {
const callbacks = [...frames.values()]
frames.clear()
for (const callback of callbacks) callback()
}
beforeEach(() => {
frames = new Map()
nextId = 1
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => {
frames.set(nextId, callback)
return nextId++
})
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
frames.delete(id)
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('createFrameBatcher', () => {
it('coalesces many schedules into one task per frame (latest wins)', () => {
const batcher = createFrameBatcher()
const calls: number[] = []
let pending = 0
const apply = (value: number): void => { calls.push(value) }
// Caller pattern: compose into a ref, schedule a reader of that ref.
for (const value of [1, 2, 3, 4]) {
pending = value
batcher.schedule(() => apply(pending))
}
expect(calls).toEqual([])
stepFrame()
expect(calls).toEqual([4])
expect(frames.size).toBe(0)
// The next burst starts a fresh frame.
pending = 7
batcher.schedule(() => apply(pending))
stepFrame()
expect(calls).toEqual([4, 7])
})
it('flushNow runs the pending task synchronously and cancels the frame', () => {
const batcher = createFrameBatcher()
const calls: number[] = []
batcher.schedule(() => calls.push(1))
batcher.schedule(() => calls.push(2))
expect(frames.size).toBe(1) // still one pending frame
batcher.flushNow()
expect(calls).toEqual([2]) // latest task, synchronously
expect(frames.size).toBe(0) // frame cancelled
stepFrame() // the cancelled frame must not fire again
expect(calls).toEqual([2])
// flushNow when nothing is scheduled is a no-op.
batcher.flushNow()
expect(calls).toEqual([2])
})
it('dispose drops the pending task without running it', () => {
const batcher = createFrameBatcher()
const calls: number[] = []
batcher.schedule(() => calls.push(1))
batcher.dispose()
expect(frames.size).toBe(0)
stepFrame()
expect(calls).toEqual([])
// A disposed batcher accepts new schedules (React strict-mode remounts
// reuse the same ref-created instance semantics).
batcher.schedule(() => calls.push(2))
stepFrame()
expect(calls).toEqual([2])
})
})