- 根因:开关过渡期 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
94 lines
2.7 KiB
TypeScript
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])
|
|
})
|
|
})
|