diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx new file mode 100644 index 000000000..faa8a2ba0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx @@ -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> = {}): 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( + 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('[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('[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') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 0bf780b80..30b0855cf 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -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()