fix(terminal): stop overlay fallback measure-fit loop (#10026)

Coalesce identical and sub-pixel fallback overlay measurements so ResizeObserver and xterm fit cannot sustain a render feedback loop, while preserving precise committed geometry.

Adds regression coverage for stable measurements, sub-pixel jitter across integer boundaries, and genuine resizes.
This commit is contained in:
OrcaWin 2026-07-22 21:22:53 -04:00 committed by GitHub
parent 72a0a4dbb8
commit 3b86405201
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 180 additions and 3 deletions

View File

@ -0,0 +1,166 @@
/** @vitest-environment happy-dom */
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
let terminalPaneRenderCount = 0
vi.mock('./TerminalPane', () => ({
default: () => {
terminalPaneRenderCount += 1
return null
}
}))
vi.mock('../../store', () => ({
useAppStore: Object.assign(() => undefined, {
getState: () => ({ pendingStartupByTabId: {} })
})
}))
import { TerminalOverlaySlot } from './TerminalPaneOverlayLayer'
const GROUP_ID = 'group-react185'
const TAB_ID = 'tab-react185'
function createRect({
top = 0,
left = 0,
width = 800,
height = 600
}: Partial<Pick<DOMRect, 'top' | 'left' | 'width' | 'height'>> = {}): DOMRect {
return {
top,
left,
right: left + width,
bottom: top + height,
width,
height,
x: left,
y: top,
toJSON: () => ({})
}
}
const PARENT_RECT = createRect()
let capturedResizeCallback: (() => void) | null = null
let container: HTMLDivElement
let bodyEl: HTMLDivElement
let bodyRect: DOMRect
let root: Root
class CapturingResizeObserver {
constructor(cb: () => void) {
capturedResizeCallback = cb
}
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
function renderSlot(): void {
root = createRoot(container)
act(() => {
root.render(
<TerminalOverlaySlot
terminalTabId={TAB_ID}
terminalGeneration={0}
worktreeId="wt-1"
worktreePath="wt-1"
startupCwd={undefined}
groupId={GROUP_ID}
isWorktreeActive
isVisible
isActive
activityTerminalPortal={null}
onFocusOwningGroup={vi.fn()}
consumeSuppressedPtyExit={() => false}
leaveWorktreeIfEmpty={vi.fn()}
/>
)
})
}
beforeEach(() => {
terminalPaneRenderCount = 0
capturedResizeCallback = null
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
vi.stubGlobal('ResizeObserver', CapturingResizeObserver)
container = document.createElement('div')
container.getBoundingClientRect = () => PARENT_RECT
document.body.appendChild(container)
bodyEl = document.createElement('div')
bodyEl.setAttribute('data-tab-group-body-id', GROUP_ID)
bodyRect = createRect({ top: 32, height: 568 })
bodyEl.getBoundingClientRect = () => bodyRect
document.body.appendChild(bodyEl)
})
afterEach(() => {
act(() => {
root?.unmount()
})
container?.remove()
bodyEl?.remove()
vi.unstubAllGlobals()
delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__
})
describe('TerminalPaneOverlayLayer fallback measure<->fit loop (React #185)', () => {
it('does not re-render on ResizeObserver ticks with an unchanged rect', () => {
renderSlot()
expect(capturedResizeCallback).toBeTypeOf('function')
const rendersAfterMount = terminalPaneRenderCount
for (let i = 0; i < 50; i += 1) {
act(() => {
capturedResizeCallback?.()
})
}
expect(terminalPaneRenderCount - rendersAfterMount).toBe(0)
})
it('settles sub-pixel jitter across an integer boundary without losing precision', () => {
bodyRect = createRect({ top: 32.1, left: 0.1, width: 799.1, height: 567.1 })
renderSlot()
const overlay = container.querySelector<HTMLElement>('[data-terminal-overlay-tab-id]')
expect(overlay?.style.top).toBe('32.1px')
expect(overlay?.style.width).toBe('799.1px')
const rendersAfterMount = terminalPaneRenderCount
for (let i = 0; i < 50; i += 1) {
bodyRect = createRect({ top: 32.9, left: 0.9, width: 799.9, height: 567.9 })
act(() => {
capturedResizeCallback?.()
})
bodyRect = createRect({ top: 32.1, left: 0.1, width: 799.1, height: 567.1 })
act(() => {
capturedResizeCallback?.()
})
}
expect(terminalPaneRenderCount - rendersAfterMount).toBe(0)
expect(overlay?.style.top).toBe('32.1px')
expect(overlay?.style.width).toBe('799.1px')
})
it('commits a genuine geometry change', () => {
renderSlot()
const overlay = container.querySelector<HTMLElement>('[data-terminal-overlay-tab-id]')
const rendersAfterMount = terminalPaneRenderCount
bodyRect = createRect({ top: 34, width: 760, height: 566 })
act(() => {
capturedResizeCallback?.()
})
expect(terminalPaneRenderCount - rendersAfterMount).toBe(1)
expect(overlay?.style.top).toBe('34px')
expect(overlay?.style.width).toBe('760px')
})
})

View File

@ -33,6 +33,7 @@ const HAS_CSS_ANCHOR_POSITIONING =
CSS.supports('width', 'anchor-size(--orca-terminal-overlay-probe width)')
const MIN_OVERLAY_FIT_WIDTH_PX = 48
const MIN_OVERLAY_FIT_HEIGHT_PX = 24
const FALLBACK_RECT_MIN_CHANGE_PX = 1
function shouldUseCssAnchorPositioning(): boolean {
return (
@ -64,7 +65,7 @@ type TerminalOverlaySlotProps = {
leaveWorktreeIfEmpty: () => void
}
const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
terminalTabId,
terminalGeneration,
worktreeId,
@ -116,12 +117,22 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
}
const parentRect = parent.getBoundingClientRect()
const bodyRect = body.getBoundingClientRect()
setMeasuredFallbackRect({
const next: MeasuredFallbackRect = {
top: bodyRect.top - parentRect.top,
left: bodyRect.left - parentRect.left,
width: bodyRect.width,
height: bodyRect.height
})
}
// Why: ResizeObserver and xterm fit can otherwise amplify sub-pixel jitter forever.
setMeasuredFallbackRect((prev) =>
prev &&
Math.abs(prev.top - next.top) < FALLBACK_RECT_MIN_CHANGE_PX &&
Math.abs(prev.left - next.left) < FALLBACK_RECT_MIN_CHANGE_PX &&
Math.abs(prev.width - next.width) < FALLBACK_RECT_MIN_CHANGE_PX &&
Math.abs(prev.height - next.height) < FALLBACK_RECT_MIN_CHANGE_PX
? prev
: next
)
}
updateRect()