fix(renderer): extract overlay geometry observers for React Doctor

Move RO/MO/resize/rAF out of the effect callback so effect-needs-cleanup no longer false-positives on nested observe, and sync isVisible via layout effect for render purity.
This commit is contained in:
OrcaWin
2026-08-12 03:10:12 -07:00
parent cd2e1b3e94
commit 0b9f31a790
@@ -24,14 +24,119 @@ function stabilizeMeasuredRect(
return next
}
/**
* Owns RO/MO/resize/rAF for overlay geometry. Lives outside the effect so
* react-doctor effect-needs-cleanup does not false-positive on nested
* `observe` calls; the effect returns this function's cleanup.
*/
function startOverlaySlotGeometryTracking(args: {
groupId: string
worktreeId?: string
cssAnchorsSupported: boolean
overlayRef: RefObject<HTMLElement | null>
isVisibleRef: RefObject<boolean>
onMeasuredRect: (next: OverlaySlotRect) => void
onForceMeasured: () => void
}): () => void {
let latchedMeasured = false
let observedBody: Element | null = null
let observedParent: Element | null = null
let rafId = 0
const resizeObserver = new ResizeObserver(() => {
update()
})
const syncObservation = (body: Element | null, parent: Element | null): void => {
if (body !== observedBody) {
if (observedBody) {
resizeObserver.unobserve(observedBody)
}
if (body) {
resizeObserver.observe(body)
}
observedBody = body
}
if (parent !== observedParent) {
if (observedParent) {
resizeObserver.unobserve(observedParent)
}
if (parent) {
resizeObserver.observe(parent)
}
observedParent = parent
}
}
const update = (): void => {
const overlay = args.overlayRef.current
const parent = overlay?.parentElement ?? null
const body = findTabGroupBodyElement(args.groupId, args.worktreeId)
// Why: body may mount after start (split leaf paint). Re-attach RO when
// the worktree-scoped query starts resolving without polling.
syncObservation(body, parent)
if (!parent || !body) {
// 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)
args.onMeasuredRect(next)
if (!args.cssAnchorsSupported) {
return
}
const decision = shouldPreferMeasuredOverlayGeometry({
overlay: overlay ?? null,
groupId: args.groupId,
worktreeId: args.worktreeId,
forceMeasured: latchedMeasured,
mayLatchDesync: args.isVisibleRef.current
})
if (decision.preferMeasured && !latchedMeasured) {
latchedMeasured = true
args.onForceMeasured()
}
if (decision.measured) {
args.onMeasuredRect(decision.measured)
}
}
update()
// Why: rAF catches the frame after a pane-column snap reflow when RO may
// not fire (body size unchanged but anchor resolution changed).
rafId = requestAnimationFrame(update)
// Why: tab-group bodies are siblings outside the overlay tree and often
// mount/replace after start; subtree mutations re-run attach for the
// scoped body only (findTabGroupBodyElement is worktree-scoped).
const mutationObserver = new MutationObserver(() => {
update()
})
mutationObserver.observe(document.documentElement, {
childList: true,
subtree: true
})
window.addEventListener('resize', update)
return () => {
cancelAnimationFrame(rafId)
window.removeEventListener('resize', update)
mutationObserver.disconnect()
resizeObserver.disconnect()
observedBody = null
observedParent = null
}
}
/**
* Tracks tab-group body geometry for a worktree-level overlay slot.
* When CSS anchors desync after a column snap, forceMeasured becomes true and
* the caller should pin the overlay with measured top/left/width/height.
*
* Why: render stays pure (no ref writes during render). isVisible only gates
* new latches — a proven forceMeasured survives hide/reveal. Observers always
* tear down via the effect cleanup return (React Doctor effect-needs-cleanup).
* new latches — a proven forceMeasured survives hide/reveal. Observers are
* owned by startOverlaySlotGeometryTracking (React Doctor effect-needs-cleanup).
*/
export function useOverlaySlotGeometry(args: {
overlayRef: RefObject<HTMLElement | null>
@@ -47,10 +152,13 @@ export function useOverlaySlotGeometry(args: {
} {
const [measuredRect, setMeasuredRect] = useState<OverlaySlotRect | null>(null)
const [forceMeasured, setForceMeasured] = useState(false)
// Why: read in observer callbacks without putting isVisible in the effect deps
// (deps would re-run the effect and clear forceMeasured on every hide/reveal).
// Why: observer callbacks read latest visibility without re-running the tracking
// effect (that would clear forceMeasured on hide/reveal). Sync in layout effect
// so render stays pure (React Doctor no ref writes during render).
const isVisibleRef = useRef(args.isVisible)
isVisibleRef.current = args.isVisible
useLayoutEffect(() => {
isVisibleRef.current = args.isVisible
}, [args.isVisible])
useLayoutEffect(() => {
// Why: only group/worktree/css support changes re-arm CSS anchors — not visibility.
@@ -61,95 +169,19 @@ export function useOverlaySlotGeometry(args: {
return () => {}
}
let latchedMeasured = false
let observedBody: Element | null = null
let observedParent: Element | null = null
let rafId = 0
const resizeObserver = new ResizeObserver(() => {
update()
})
const syncObservation = (body: Element | null, parent: Element | null): void => {
if (body !== observedBody) {
if (observedBody) {
resizeObserver.unobserve(observedBody)
}
if (body) {
resizeObserver.observe(body)
}
observedBody = body
}
if (parent !== observedParent) {
if (observedParent) {
resizeObserver.unobserve(observedParent)
}
if (parent) {
resizeObserver.observe(parent)
}
observedParent = parent
}
}
const update = (): void => {
const overlay = args.overlayRef.current
const parent = overlay?.parentElement ?? null
const body = findTabGroupBodyElement(args.groupId!, args.worktreeId)
// Why: body may mount after the effect (split leaf paint). Re-attach RO when
// the worktree-scoped query starts resolving without polling.
syncObservation(body, parent)
if (!parent || !body) {
// 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) {
return
}
const decision = shouldPreferMeasuredOverlayGeometry({
overlay: overlay ?? null,
groupId: args.groupId,
worktreeId: args.worktreeId,
forceMeasured: latchedMeasured,
mayLatchDesync: isVisibleRef.current
})
if (decision.preferMeasured && !latchedMeasured) {
latchedMeasured = true
return startOverlaySlotGeometryTracking({
groupId: args.groupId,
worktreeId: args.worktreeId,
cssAnchorsSupported: args.cssAnchorsSupported,
overlayRef: args.overlayRef,
isVisibleRef,
onMeasuredRect: (next) => {
setMeasuredRect((prev) => stabilizeMeasuredRect(prev, next))
},
onForceMeasured: () => {
setForceMeasured(true)
}
if (decision.measured) {
setMeasuredRect((prev) => stabilizeMeasuredRect(prev, decision.measured!))
}
}
update()
// Why: rAF catches the frame after a pane-column snap reflow when RO may
// not fire (body size unchanged but anchor resolution changed).
rafId = requestAnimationFrame(update)
// Why: tab-group bodies are siblings outside the overlay tree and often
// mount/replace after this effect; subtree mutations re-run attach for the
// scoped body only (findTabGroupBodyElement is worktree-scoped).
const mutationObserver = new MutationObserver(() => {
update()
})
mutationObserver.observe(document.documentElement, {
childList: true,
subtree: true
})
window.addEventListener('resize', update)
return () => {
cancelAnimationFrame(rafId)
window.removeEventListener('resize', update)
mutationObserver.disconnect()
resizeObserver.disconnect()
observedBody = null
observedParent = null
}
}, [args.cssAnchorsSupported, args.groupId, args.overlayRef, args.worktreeId])
return {