mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user