perf(tabs): take split-divider drag off the store (STA-3328)

Every pointermove committed a global store write (60-120 publications/s
against every subscriber) plus a forced reflow from per-move
getBoundingClientRect. The drag now writes the two panes' flex styles
directly (identical visuals) and commits setTabGroupSplitRatio once on
release/unmount; the action bails without minting state when the ratio is
unchanged.
This commit is contained in:
Brennan Benson
2026-08-03 19:19:46 -07:00
parent 194e1a8d4d
commit 97c038fe5e
4 changed files with 209 additions and 2 deletions
@@ -0,0 +1,148 @@
// @vitest-environment happy-dom
// Why: the sibling TabGroupSplitLayout.test.ts calls components as plain
// functions and cannot exercise the drag gesture; these tests real-render the
// handle to pin the STA-3328 contract — pointermove writes pane styles
// directly and the store is committed exactly once, on release.
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
globalThis.IS_REACT_ACT_ENVIRONMENT = true
const setTabGroupSplitRatioMock = vi.fn()
const recordFeatureInteractionMock = vi.fn()
vi.mock('../../store', () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
recordFeatureInteraction: recordFeatureInteractionMock,
setTabGroupSplitRatio: setTabGroupSplitRatioMock
})
}))
vi.mock('./TabGroupPanel', () => ({
default: ({ groupId }: { groupId: string }) => <div data-testid={`panel-${groupId}`} />
}))
vi.mock('./useTabDragSplit', () => ({
useTabDragSplit: () => ({
activeDrag: null,
collisionDetection: vi.fn(),
hoveredDropTarget: null,
hoveredTabInsertion: null,
isTabDragActiveRef: { current: false },
onDragCancel: vi.fn(),
onDragEnd: vi.fn(),
onDragMove: vi.fn(),
onDragOver: vi.fn(),
onDragStart: vi.fn(),
sensors: [],
setDragRootNode: vi.fn()
})
}))
import TabGroupSplitLayout from './TabGroupSplitLayout'
function pointerEvent(type: string, init: { pointerId?: number; clientX?: number }): Event {
const event = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(event, 'pointerId', { value: init.pointerId ?? 1 })
Object.defineProperty(event, 'clientX', { value: init.clientX ?? 0 })
Object.defineProperty(event, 'clientY', { value: 0 })
return event
}
describe('TabGroupSplitLayout divider drag', () => {
let container: HTMLDivElement
let root: Root
let handle: HTMLElement
let firstPane: HTMLElement
let secondPane: HTMLElement
beforeEach(async () => {
setTabGroupSplitRatioMock.mockClear()
recordFeatureInteractionMock.mockClear()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root.render(
<TabGroupSplitLayout
layout={{
type: 'split',
direction: 'horizontal',
ratio: 0.5,
first: { type: 'leaf', groupId: 'left-group' },
second: { type: 'leaf', groupId: 'right-group' }
}}
worktreeId="wt-1"
focusedGroupId="right-group"
isWorktreeActive={true}
/>
)
})
handle = container.querySelector('.tab-group-split-resize-handle') as HTMLElement
expect(handle).not.toBeNull()
firstPane = handle.previousElementSibling as HTMLElement
secondPane = handle.nextElementSibling as HTMLElement
let captured = false
Object.assign(handle, {
setPointerCapture: () => {
captured = true
},
releasePointerCapture: () => {
captured = false
},
hasPointerCapture: () => captured
})
const splitContainer = handle.parentElement as HTMLElement
splitContainer.getBoundingClientRect = () =>
({ left: 0, top: 0, width: 1000, height: 500, right: 1000, bottom: 500 }) as DOMRect
})
afterEach(() => {
act(() => root.unmount())
container.remove()
})
it('writes pane styles per move and commits the store once on release', async () => {
await act(async () => {
handle.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1 }))
})
expect(recordFeatureInteractionMock).toHaveBeenCalledWith('terminal-panes')
await act(async () => {
handle.dispatchEvent(pointerEvent('pointermove', { pointerId: 1, clientX: 300 }))
handle.dispatchEvent(pointerEvent('pointermove', { pointerId: 1, clientX: 320 }))
handle.dispatchEvent(pointerEvent('pointermove', { pointerId: 1, clientX: 340 }))
})
// Why: the drag must never publish store updates per move (STA-3328) —
// the panes track the pointer via direct style writes instead.
expect(setTabGroupSplitRatioMock).not.toHaveBeenCalled()
expect(firstPane.style.flex).toBe('0.34 1 0%')
expect(secondPane.style.flex).toBe('0.66 1 0%')
await act(async () => {
handle.dispatchEvent(pointerEvent('pointerup', { pointerId: 1 }))
})
expect(setTabGroupSplitRatioMock).toHaveBeenCalledTimes(1)
expect(setTabGroupSplitRatioMock).toHaveBeenCalledWith('wt-1', '', 0.34)
})
it('clamps the committed ratio and skips the commit when nothing moved', async () => {
await act(async () => {
handle.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1 }))
handle.dispatchEvent(pointerEvent('pointerup', { pointerId: 1 }))
})
expect(setTabGroupSplitRatioMock).not.toHaveBeenCalled()
await act(async () => {
handle.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1 }))
handle.dispatchEvent(pointerEvent('pointermove', { pointerId: 1, clientX: 20 }))
handle.dispatchEvent(pointerEvent('pointerup', { pointerId: 1 }))
})
expect(setTabGroupSplitRatioMock).toHaveBeenCalledTimes(1)
expect(setTabGroupSplitRatioMock).toHaveBeenCalledWith('wt-1', '', 0.15)
})
})
@@ -39,20 +39,34 @@ function ResizeHandle({
if (!container) {
return
}
const firstPane = handle.previousElementSibling as HTMLElement | null
const secondPane = handle.nextElementSibling as HTMLElement | null
if (!firstPane || !secondPane) {
return
}
activeResizeCleanupRef.current?.()
onResizeStart()
setDragging(true)
handle.setPointerCapture(event.pointerId)
// Why: measure once — the container can't resize mid-drag, and per-move
// getBoundingClientRect forces a reflow against freshly written styles.
const rect = container.getBoundingClientRect()
let draggedRatio: number | null = null
const onPointerMove = (moveEvent: PointerEvent): void => {
if (!handle.hasPointerCapture(event.pointerId)) {
return
}
const rect = container.getBoundingClientRect()
const ratio = isHorizontal
? (moveEvent.clientX - rect.left) / rect.width
: (moveEvent.clientY - rect.top) / rect.height
onRatioChange(Math.min(MAX_RATIO, Math.max(MIN_RATIO, ratio)))
const clamped = Math.min(MAX_RATIO, Math.max(MIN_RATIO, ratio))
draggedRatio = clamped
// Why: direct style writes keep the drag off the store — a commit per
// pointermove published 60-120 global store updates/s against every
// subscriber (STA-3328). React re-applies identical flex on commit.
firstPane.style.flex = `${clamped} 1 0%`
secondPane.style.flex = `${1 - clamped} 1 0%`
}
let cleaned = false
@@ -61,6 +75,9 @@ function ResizeHandle({
return
}
cleaned = true
if (draggedRatio !== null) {
onRatioChange(draggedRatio)
}
if (updateDragging) {
setDragging(false)
}
@@ -949,6 +949,32 @@ describe('TabsSlice', () => {
expect(layout.ratio).toBe(0.5)
expect(layout.second.ratio).toBe(0.7)
})
it('keeps state identity when the ratio is unchanged', () => {
store.setState({
layoutByWorktree: {
[WT]: {
type: 'split',
direction: 'horizontal',
ratio: 0.5,
first: { type: 'leaf', groupId: 'g-1' },
second: { type: 'leaf', groupId: 'g-2' }
}
}
})
const before = store.getState().layoutByWorktree
// Why: an unchanged commit must not mint fresh root state — every store
// subscriber wakes on the new reference (STA-3328).
store.getState().setTabGroupSplitRatio(WT, '', 0.5)
expect(store.getState().layoutByWorktree).toBe(before)
store.getState().setTabGroupSplitRatio(WT, '', 0.5004)
expect(store.getState().layoutByWorktree).toBe(before)
store.getState().setTabGroupSplitRatio(WT, '', 0.7)
expect(store.getState().layoutByWorktree).not.toBe(before)
})
})
describe('move/copy/merge group operations', () => {
+16
View File
@@ -1792,6 +1792,22 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
if (!currentLayout) {
return {}
}
// Why: an unchanged ratio must not mint fresh root state — every store
// subscriber wakes on the new reference (STA-3328).
let targetNode: TabGroupLayoutNode | undefined = currentLayout
for (const segment of nodePath.length > 0 ? nodePath.split('.') : []) {
targetNode =
targetNode && targetNode.type === 'split' && (segment === 'first' || segment === 'second')
? targetNode[segment]
: undefined
}
if (
targetNode &&
targetNode.type === 'split' &&
Math.abs((targetNode.ratio ?? 0.5) - ratio) < 0.0005
) {
return {}
}
return {
layoutByWorktree: {
...state.layoutByWorktree,