fix(renderer): harden overlay geometry latch after pane-column snap

Avoid false measured latches from hidden/zero-size overlays, scope body lookup by worktree, keep last good measured rect across reparent, and re-verify after rAF. Expand unit coverage for snap desync, hidden slots, and unmeasurable bodies.
This commit is contained in:
OrcaWin
2026-08-12 02:23:10 -07:00
parent bbe654a5c7
commit 29e65df95e
8 changed files with 250 additions and 40 deletions
@@ -74,6 +74,7 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({
const { measuredRect, useCssAnchors } = useOverlaySlotGeometry({
overlayRef,
groupId,
worktreeId: browserTab.worktreeId,
cssAnchorsSupported: shouldUseCssAnchorPositioning(),
isVisible: isPaintable
})
@@ -40,6 +40,7 @@ const SimulatorOverlaySlot = memo(function SimulatorOverlaySlot({
const { measuredRect, useCssAnchors } = useOverlaySlotGeometry({
overlayRef,
groupId,
worktreeId: tab.worktreeId,
cssAnchorsSupported: shouldUseCssAnchorPositioning(),
isVisible: isActive
})
@@ -3,12 +3,15 @@
import { describe, expect, it } from 'vitest'
import {
findTabGroupBodyElement,
isMeasurableOverlayRect,
isOverlaySlotGeometryMismatched,
measureOverlaySlotRect,
shouldPreferMeasuredOverlayGeometry
} from './overlay-slot-geometry'
function rect(partial: Partial<DOMRect> & Pick<DOMRect, 'top' | 'left' | 'width' | 'height'>): DOMRect {
function rect(
partial: Partial<DOMRect> & Pick<DOMRect, 'top' | 'left' | 'width' | 'height'>
): DOMRect {
const top = partial.top
const left = partial.left
const width = partial.width
@@ -27,16 +30,23 @@ function rect(partial: Partial<DOMRect> & Pick<DOMRect, 'top' | 'left' | 'width'
}
describe('overlay slot geometry', () => {
it('finds the tab-group body by group id', () => {
it('finds the tab-group body scoped by worktree when both attributes are set', () => {
const other = document.createElement('div')
other.dataset.tabGroupBodyId = 'group-a'
other.dataset.worktreeId = 'wt-other'
const body = document.createElement('div')
body.dataset.tabGroupBodyId = 'group-a'
document.body.appendChild(body)
expect(findTabGroupBodyElement('group-a')).toBe(body)
body.dataset.worktreeId = 'wt-1'
document.body.append(other, body)
expect(findTabGroupBodyElement('group-a', 'wt-1')).toBe(body)
expect(findTabGroupBodyElement('group-a', 'wt-missing')).toBeNull()
expect(findTabGroupBodyElement('group-a')).toBe(other)
expect(findTabGroupBodyElement('missing')).toBeNull()
other.remove()
body.remove()
})
it('measures body geometry relative to the overlay parent', () => {
it('measures body geometry relative to the overlay parent (absolute containing block)', () => {
const parent = document.createElement('div')
const body = document.createElement('div')
parent.getBoundingClientRect = () => rect({ top: 100, left: 40, width: 900, height: 700 })
@@ -69,9 +79,9 @@ describe('overlay slot geometry', () => {
const overlay = document.createElement('div')
const body = document.createElement('div')
body.dataset.tabGroupBodyId = 'group-snap'
body.dataset.worktreeId = 'wt-1'
parent.appendChild(overlay)
document.body.appendChild(parent)
document.body.appendChild(body)
document.body.append(parent, body)
// Why: after a column snap the overlay can still claim the pre-snap full-area
// box while the body is only the right half — clicks then land in chrome/tabs.
@@ -82,9 +92,12 @@ describe('overlay slot geometry', () => {
const result = shouldPreferMeasuredOverlayGeometry({
overlay,
groupId: 'group-snap',
forceMeasured: false
worktreeId: 'wt-1',
forceMeasured: false,
mayLatchDesync: true
})
expect(result.preferMeasured).toBe(true)
expect(result.bodyMeasurable).toBe(true)
expect(result.measured).toEqual({
top: 36,
left: 500,
@@ -102,8 +115,7 @@ describe('overlay slot geometry', () => {
const body = document.createElement('div')
body.dataset.tabGroupBodyId = 'group-ok'
parent.appendChild(overlay)
document.body.appendChild(parent)
document.body.appendChild(body)
document.body.append(parent, body)
const matched = rect({ top: 36, left: 0, width: 900, height: 700 })
parent.getBoundingClientRect = () => rect({ top: 0, left: 0, width: 900, height: 736 })
@@ -113,7 +125,8 @@ describe('overlay slot geometry', () => {
const result = shouldPreferMeasuredOverlayGeometry({
overlay,
groupId: 'group-ok',
forceMeasured: false
forceMeasured: false,
mayLatchDesync: true
})
expect(result.preferMeasured).toBe(false)
expect(result.measured).toEqual({
@@ -126,4 +139,90 @@ describe('overlay slot geometry', () => {
parent.remove()
body.remove()
})
it('does not latch desync from a display:none / zero-size overlay sample', () => {
const parent = document.createElement('div')
const overlay = document.createElement('div')
const body = document.createElement('div')
body.dataset.tabGroupBodyId = 'group-hidden'
parent.appendChild(overlay)
document.body.append(parent, body)
parent.getBoundingClientRect = () => rect({ top: 0, left: 0, width: 1000, height: 800 })
overlay.getBoundingClientRect = () => rect({ top: 0, left: 0, width: 0, height: 0 })
body.getBoundingClientRect = () => rect({ top: 36, left: 0, width: 1000, height: 764 })
expect(
shouldPreferMeasuredOverlayGeometry({
overlay,
groupId: 'group-hidden',
forceMeasured: false,
mayLatchDesync: true
}).preferMeasured
).toBe(false)
expect(
shouldPreferMeasuredOverlayGeometry({
overlay,
groupId: 'group-hidden',
forceMeasured: false,
mayLatchDesync: false
}).preferMeasured
).toBe(false)
parent.remove()
body.remove()
})
it('does not latch desync when the body is not yet measurable', () => {
const parent = document.createElement('div')
const overlay = document.createElement('div')
const body = document.createElement('div')
body.dataset.tabGroupBodyId = 'group-unlaid'
parent.appendChild(overlay)
document.body.append(parent, body)
parent.getBoundingClientRect = () => rect({ top: 0, left: 0, width: 1000, height: 800 })
overlay.getBoundingClientRect = () => rect({ top: 0, left: 0, width: 1000, height: 800 })
body.getBoundingClientRect = () => rect({ top: 0, left: 0, width: 0, height: 0 })
const result = shouldPreferMeasuredOverlayGeometry({
overlay,
groupId: 'group-unlaid',
forceMeasured: false,
mayLatchDesync: true
})
expect(result.bodyMeasurable).toBe(false)
expect(result.preferMeasured).toBe(false)
expect(isMeasurableOverlayRect({ width: 0, height: 0 })).toBe(false)
parent.remove()
body.remove()
})
it('honors forceMeasured even when geometry currently matches', () => {
const parent = document.createElement('div')
const overlay = document.createElement('div')
const body = document.createElement('div')
body.dataset.tabGroupBodyId = 'group-forced'
parent.appendChild(overlay)
document.body.append(parent, body)
const matched = rect({ top: 36, left: 0, width: 900, height: 700 })
parent.getBoundingClientRect = () => rect({ top: 0, left: 0, width: 900, height: 736 })
overlay.getBoundingClientRect = () => matched
body.getBoundingClientRect = () => matched
expect(
shouldPreferMeasuredOverlayGeometry({
overlay,
groupId: 'group-forced',
forceMeasured: true,
mayLatchDesync: true
}).preferMeasured
).toBe(true)
parent.remove()
body.remove()
})
})
@@ -12,14 +12,30 @@ export type OverlaySlotRect = {
}
export const OVERLAY_SLOT_GEOMETRY_MISMATCH_PX = 2
/** Body/overlay smaller than this is treated as not-yet-laid-out, not a desync. */
export const OVERLAY_SLOT_MIN_MEASURABLE_EDGE_PX = 8
export function findTabGroupBodyElement(groupId: string): HTMLElement | null {
for (const candidate of document.querySelectorAll<HTMLElement>('[data-tab-group-body-id]')) {
if (candidate.dataset.tabGroupBodyId === groupId) {
return candidate
}
function escapeCssAttrValue(value: string): string {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(value)
}
return null
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
}
/** Locate the tab-group body; prefer worktree-scoped match when available. */
export function findTabGroupBodyElement(
groupId: string,
worktreeId?: string
): HTMLElement | null {
const escapedGroupId = escapeCssAttrValue(groupId)
if (worktreeId) {
const escapedWorktreeId = escapeCssAttrValue(worktreeId)
// Why: never fall through to another worktree's body — hidden worktrees stay mounted.
return document.querySelector<HTMLElement>(
`[data-tab-group-body-id="${escapedGroupId}"][data-worktree-id="${escapedWorktreeId}"]`
)
}
return document.querySelector<HTMLElement>(`[data-tab-group-body-id="${escapedGroupId}"]`)
}
export function measureOverlaySlotRect(
@@ -36,6 +52,13 @@ export function measureOverlaySlotRect(
}
}
export function isMeasurableOverlayRect(
rect: Pick<DOMRect | OverlaySlotRect, 'width' | 'height'>,
minEdgePx = OVERLAY_SLOT_MIN_MEASURABLE_EDGE_PX
): boolean {
return rect.width >= minEdgePx && rect.height >= minEdgePx
}
export function isOverlaySlotGeometryMismatched(
overlayRect: Pick<DOMRect, 'top' | 'left' | 'width' | 'height'>,
bodyRect: Pick<DOMRect, 'top' | 'left' | 'width' | 'height'>,
@@ -49,28 +72,62 @@ export function isOverlaySlotGeometryMismatched(
)
}
export type OverlayGeometryDecision = {
preferMeasured: boolean
measured: OverlaySlotRect | null
/** True when body exists and is large enough to trust for measured layout. */
bodyMeasurable: boolean
}
/**
* Decide whether CSS-anchor layout is safe or measured body geometry is required.
* Viewport getBoundingClientRect comparison detects desync; measured offsets are
* parent-relative for `position:absolute` top/left/width/height.
*/
export function shouldPreferMeasuredOverlayGeometry(args: {
overlay: HTMLElement | null
groupId: string | undefined
worktreeId?: string
/** When true, stay on the measured path until the caller resets (e.g. new group). */
forceMeasured: boolean
}): { preferMeasured: boolean; measured: OverlaySlotRect | null } {
/**
* When false (hidden/unpainted slot), never latch onto measured from a desync
* sample — zero-size `display:none` overlays would otherwise false-positive.
*/
mayLatchDesync: boolean
}): OverlayGeometryDecision {
if (!args.groupId || !args.overlay) {
return { preferMeasured: args.forceMeasured, measured: null }
return {
preferMeasured: args.forceMeasured,
measured: null,
bodyMeasurable: false
}
}
const parent = args.overlay.parentElement
const body = findTabGroupBodyElement(args.groupId)
const body = findTabGroupBodyElement(args.groupId, args.worktreeId)
if (!parent || !body) {
return { preferMeasured: true, measured: null }
return {
preferMeasured: args.forceMeasured,
measured: null,
bodyMeasurable: false
}
}
const measured = measureOverlaySlotRect(parent, body)
const bodyMeasurable = isMeasurableOverlayRect(measured)
if (args.forceMeasured) {
return { preferMeasured: true, measured }
return { preferMeasured: true, measured, bodyMeasurable }
}
if (!args.mayLatchDesync || !bodyMeasurable) {
return { preferMeasured: false, measured, bodyMeasurable }
}
const bodyRect = body.getBoundingClientRect()
const overlayRect = args.overlay.getBoundingClientRect()
if (isOverlaySlotGeometryMismatched(overlayRect, bodyRect)) {
return { preferMeasured: true, measured }
// Why: skip until the overlay itself has a real box — pre-paint zeros are not desync.
if (!isMeasurableOverlayRect(overlayRect)) {
return { preferMeasured: false, measured, bodyMeasurable }
}
return { preferMeasured: false, measured }
if (isOverlaySlotGeometryMismatched(overlayRect, bodyRect)) {
return { preferMeasured: true, measured, bodyMeasurable }
}
return { preferMeasured: false, measured, bodyMeasurable }
}
@@ -1,4 +1,4 @@
import { useLayoutEffect, useState, type RefObject } from 'react'
import { useLayoutEffect, useRef, useState, type RefObject } from 'react'
import {
findTabGroupBodyElement,
measureOverlaySlotRect,
@@ -32,6 +32,7 @@ function stabilizeMeasuredRect(
export function useOverlaySlotGeometry(args: {
overlayRef: RefObject<HTMLElement | null>
groupId: string | undefined
worktreeId?: string
/** When false (e.g. web client), always use measured geometry. */
cssAnchorsSupported: boolean
isVisible: boolean
@@ -42,45 +43,55 @@ export function useOverlaySlotGeometry(args: {
} {
const [measuredRect, setMeasuredRect] = useState<OverlaySlotRect | null>(null)
const [forceMeasured, setForceMeasured] = useState(false)
const forceMeasuredRef = useRef(false)
forceMeasuredRef.current = forceMeasured
useLayoutEffect(() => {
setForceMeasured(false)
}, [args.groupId])
// Why: single layout effect owns group transitions + RO so forceMeasured
// reset and re-observe cannot race across two effects in the same commit.
useLayoutEffect(() => {
if (!args.groupId) {
return
}
forceMeasuredRef.current = false
setForceMeasured(false)
const update = (): void => {
const overlay = args.overlayRef.current
const parent = overlay?.parentElement
const body = findTabGroupBodyElement(args.groupId!)
const body = findTabGroupBodyElement(args.groupId!, args.worktreeId)
if (!parent || !body) {
setMeasuredRect(null)
// Why: mid-reparent body absence is transient; keep last good measured
// box so we do not expand to width 100% over tab chrome.
return
}
const next = measureOverlaySlotRect(parent, body)
setMeasuredRect((prev) => stabilizeMeasuredRect(prev, next))
if (!args.cssAnchorsSupported || forceMeasured) {
if (!args.cssAnchorsSupported) {
return
}
const decision = shouldPreferMeasuredOverlayGeometry({
overlay: overlay ?? null,
groupId: args.groupId,
forceMeasured: false
worktreeId: args.worktreeId,
forceMeasured: forceMeasuredRef.current,
mayLatchDesync: args.isVisible
})
if (decision.preferMeasured) {
if (decision.preferMeasured && !forceMeasuredRef.current) {
forceMeasuredRef.current = true
setForceMeasured(true)
if (decision.measured) {
setMeasuredRect((prev) => stabilizeMeasuredRect(prev, decision.measured!))
}
}
if (decision.measured) {
setMeasuredRect((prev) => stabilizeMeasuredRect(prev, decision.measured!))
}
}
update()
const body = findTabGroupBodyElement(args.groupId)
// Why: rAF catches the frame after a pane-column snap reflow when RO may
// not fire (body size unchanged but anchor resolution changed).
const rafId = requestAnimationFrame(update)
const body = findTabGroupBodyElement(args.groupId, args.worktreeId)
const parent = args.overlayRef.current?.parentElement
const resizeObserver = new ResizeObserver(update)
if (body) {
@@ -91,6 +102,7 @@ export function useOverlaySlotGeometry(args: {
}
window.addEventListener('resize', update)
return () => {
cancelAnimationFrame(rafId)
resizeObserver.disconnect()
window.removeEventListener('resize', update)
}
@@ -99,7 +111,7 @@ export function useOverlaySlotGeometry(args: {
args.groupId,
args.isVisible,
args.overlayRef,
forceMeasured
args.worktreeId
])
return {
@@ -109,6 +109,7 @@ beforeEach(() => {
bodyEl = document.createElement('div')
bodyEl.setAttribute('data-tab-group-body-id', GROUP_ID)
bodyEl.setAttribute('data-worktree-id', 'wt-1')
bodyRect = createRect({ top: 36, left: 500, width: 500, height: 764 })
bodyEl.getBoundingClientRect = () => bodyRect
document.body.appendChild(bodyEl)
@@ -142,4 +143,41 @@ describe('TerminalOverlaySlot geometry after pane-column snap', () => {
expect(overlay.style.width).toBe('500px')
expect(overlay.style.height).toBe('764px')
})
it('does not permanently latch measured geometry while the slot is hidden', () => {
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={false}
isActive={false}
activityTerminalPortal={null}
onFocusOwningGroup={vi.fn()}
consumeSuppressedPtyExit={() => false}
leaveWorktreeIfEmpty={vi.fn()}
/>
)
})
const overlay = container.querySelector<HTMLElement>('[data-terminal-overlay-tab-id]')
expect(overlay).not.toBeNull()
if (!overlay) {
throw new Error('overlay not mounted')
}
overlay.getBoundingClientRect = () => createRect({ width: 0, height: 0 })
act(() => {
resizeCallback?.()
})
// Why: hidden slots stay on the CSS-anchor path so a later reveal can still
// detect a real post-snap desync instead of inheriting a false latch.
expect(overlay.dataset.overlayGeometry).toBe('anchor')
})
})
@@ -63,6 +63,7 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
const { measuredRect, forceMeasured, useCssAnchors } = useOverlaySlotGeometry({
overlayRef,
groupId,
worktreeId,
cssAnchorsSupported: shouldUseCssAnchorPositioning(),
isVisible
})
@@ -95,6 +95,7 @@ beforeEach(() => {
bodyEl = document.createElement('div')
bodyEl.setAttribute('data-tab-group-body-id', GROUP_ID)
bodyEl.setAttribute('data-worktree-id', 'wt-1')
bodyRect = createRect({ top: 32, height: 568 })
bodyEl.getBoundingClientRect = () => bodyRect
document.body.appendChild(bodyEl)