fix(grid): v3 review fixes — split-null crash, monitor race, drag-safe gutters

Adversarial review (4 lenses → per-finding verify) of v3 confirmed 6 issues:

- HIGH: splitForLayout({"grid-4":null}) threw a TypeError — null passed the
  `!== undefined` guard, then null.cols threw. Since splitForLayout runs on every
  grid render, one corrupt localStorage entry would brick the tab UI. Now guards
  `!== null && typeof === 'object'`.
- MED: a monitor toggled before the session finished attaching (id still null)
  showed the button active while the pane stayed live and sent a resize, and did
  not self-correct. onSessionId now reconciles a pending monitor once the id lands.
- MED: renderGutters destroyed + recreated handles on every applyLayout, dropping
  the drag listeners if a re-render fired mid-drag. A draggingGutter flag now skips
  the rebuild while dragging.
- LOW: grid-presets hardcoded the 1024px breakpoint → now uses GRID_MIN_WIDTH.

Regression tests added (null-split no-throw, attach reconcile, drag survives a
concurrent re-render). typecheck + build:web clean, 1615 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2026-07-11 20:25:04 +02:00
parent 007e598802
commit 733c8a8318
6 changed files with 68 additions and 4 deletions

View File

@@ -1526,4 +1526,41 @@ describe('TabApp — split-grid watch board (v1)', () => {
const saved = JSON.parse(localStorage.getItem('web-terminal:grid-splits')!)
expect(saved['grid-4'].cols[0]).toBeCloseTo(1.2)
})
it('a re-render during a gutter drag does not destroy the dragged handle', () => {
const { app, paneHost } = withTabs(4)
app.focusTab(0)
app.setGridLayout('grid-4')
vi.spyOn(paneHost, 'getBoundingClientRect').mockReturnValue({
width: 800,
height: 600,
left: 0,
top: 0,
right: 800,
bottom: 600,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect)
const gutter = paneHost.querySelector<HTMLElement>('.grid-gutter-col')!
gutter.dispatchEvent(new MouseEvent('pointerdown', { clientX: 400, bubbles: true }))
app.toggleMaximize() // triggers applyLayout → renderGutters (must skip while dragging)
expect(paneHost.contains(gutter)).toBe(true) // same handle survives
gutter.dispatchEvent(new MouseEvent('pointermove', { clientX: 480, bubbles: true }))
gutter.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
expect(paneHost.style.gridTemplateColumns).toBe('1.2fr 0.8fr')
})
it('a monitor toggled before attach engages once the session id arrives', () => {
const { app } = withTabs(3) // fresh sessions — ids are null until attached
app.focusTab(0)
app.setGridLayout('grid-4')
const inst = FakeTerminalSession.instances[1]!
inst.el.closest('.term-cell')!.querySelector<HTMLButtonElement>('.cell-monitor-btn')!.click()
expect(mountCellMonitor).not.toHaveBeenCalled() // id still null → not engaged
inst.id = 'sess-1' // simulate 'attached'
;(inst.cbs as { onSessionId?: (id: string) => void }).onSessionId?.('sess-1')
expect(mountCellMonitor).toHaveBeenCalled()
expect(mountCellMonitor.mock.lastCall?.[1]).toBe('sess-1')
})
})