fix(client): key the hover tip's measurement on its inputs (#163)

Fix the touch-triggered React #185 in the insights hover tip: key the measure pass on its inputs (side, label, rect, viewport) instead of tip identity, and re-measure a relabelled tip at the same rect.

Based on the original fix direction from @nexw.

Fixes #162
This commit is contained in:
Johnny authored and GitHub committed 2026-10-18 15:22:08 +08:00
1 parent 679dc669f4
commit a2d14dbad1
2 files changed
+76 -11

No files matched your search

+11 -11
View File
@@ -22,10 +22,11 @@ interface ActiveTip {
label: string
side: 'top' | 'bottom'
rect: { left: number; top: number; width: number; height: number; bottom: number }
signature: string
}
interface Measured {
tip: ActiveTip
signature: string
left: number
top: number
}
@@ -38,12 +39,12 @@ function tipOf(anchor: Element): ActiveTip | null {
const label = anchor.getAttribute('data-lc-tip')
if (label === null || label === '') return null
const r = anchor.getBoundingClientRect()
return {
anchor,
label,
side: anchor.getAttribute('data-lc-tip-side') === 'bottom' ? 'bottom' : 'top',
rect: { left: r.left, top: r.top, width: r.width, height: r.height, bottom: r.bottom },
}
const side = anchor.getAttribute('data-lc-tip-side') === 'bottom' ? 'bottom' : 'top'
const rect = { left: r.left, top: r.top, width: r.width, height: r.height, bottom: r.bottom }
// The pass reads side, the rect edges and the viewport; the label sets the bubble width. Key it on those inputs,
// not the tip object: React can replay the show updater, and an equivalent rebuilt tip must not re-measure.
const signature = JSON.stringify([side, label, rect.left, rect.top, rect.width, rect.bottom, window.innerWidth, window.innerHeight])
return { anchor, label, side, rect, signature }
}
export interface HoverTipZone {
@@ -107,9 +108,8 @@ export function useHoverTip(): HoverTipZone {
return () => { window.removeEventListener('resize', retract) }
}, [tip, hide])
// Measured once per shown tip (keyed on tip identity, so a same-anchor mousemove never re-hides).
useLayoutEffect(() => {
if (tip === null || measured?.tip === tip) return
if (tip === null || measured?.signature === tip.signature) return
const el = bubbleRef.current
/* v8 ignore next -- unreachable: the portal commits its ref before layout effects run. */
if (el === null) return
@@ -120,10 +120,10 @@ export function useHoverTip(): HoverTipZone {
let top = tip.side === 'top' ? rect.top - h - GAP : rect.bottom + GAP
if (tip.side === 'top' && top < VIEWPORT_MARGIN) top = rect.bottom + GAP
else if (tip.side === 'bottom' && top + h > window.innerHeight - VIEWPORT_MARGIN) top = rect.top - h - GAP
setMeasured({ tip, left, top: Math.max(VIEWPORT_MARGIN, top) })
setMeasured({ signature: tip.signature, left, top: Math.max(VIEWPORT_MARGIN, top) })
}, [tip, measured])
const shown = measured !== null && measured.tip === tip ? measured : null
const shown = tip !== null && measured?.signature === tip.signature ? measured : null
return {
zoneProps: { onMouseOver, onMouseOut, onFocusCapture, onBlurCapture, onScrollCapture },
bubble: tip === null ? null : createPortal(
+65
View File
@@ -45,6 +45,13 @@ function pinRect(anchor: Element, rect: { left: number; top: number; width: numb
})
}
/** A zone that counts its renders; its single anchor's tip can be rewritten by a test. */
function CountingZone(props: { onRender: () => void }): ReactElement {
props.onRender()
const { zoneProps, bubble } = useHoverTip()
return h('div', zoneProps, h('button', { id: 'a', 'data-lc-tip': 'tip A' }, 'A'), bubble)
}
afterEach(() => {
// A leaked portal from a failed test never reaches the next one.
bubble()?.remove()
@@ -166,6 +173,64 @@ describe('useHoverTip', () => {
await m.unmount()
})
test('a re-shown tip reuses its measurement while the inputs hold', async () => {
// A touch tap can hide then re-show the same anchor, rebuilding the tip object; keying the measure pass on
// identity would commit once per replay until React throws #185 (dsh-context#162).
let renders = 0
const m = await mount(h(CountingZone, { onRender: () => { renders += 1 } }))
const a = query(m.container, '#a')
pinRect(a, { left: 100, top: 100, width: 40, height: 20 })
await mouseover(a)
assert.equal(bubble()?.style.left, '120px')
renders = 0
await mouseout(a, m.container)
assert.equal(bubble(), null)
assert.equal(renders, 1, 'hiding commits once')
renders = 0
await mouseover(a)
assert.equal(bubble()?.textContent, 'tip A')
assert.equal(bubble()?.style.left, '120px', 'the measured spot is reused')
assert.equal(renders, 1, 'an equivalent tip does not re-measure')
// A genuinely moved anchor still measures again.
pinRect(a, { left: 300, top: 100, width: 40, height: 20 })
await mouseout(a, m.container)
await mouseover(a)
assert.equal(bubble()?.style.left, '320px')
// A relabelled anchor at the same geometry measures again: the bubble's width follows its label.
a.setAttribute('data-lc-tip', 'tip A, longer')
await mouseout(a, m.container)
renders = 0
await mouseover(a)
assert.equal(bubble()?.textContent, 'tip A, longer')
assert.equal(renders, 2, 'a relabelled tip re-measures (show + measure)')
await m.unmount()
})
test('a hidden tip re-measures when the viewport it was clamped against changes', async () => {
let renders = 0
const m = await mount(h(CountingZone, { onRender: () => { renders += 1 } }))
const a = query(m.container, '#a')
pinRect(a, { left: 100, top: 100, width: 40, height: 20 })
await mouseover(a)
await mouseout(a, m.container)
const width = window.innerWidth
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width - 400 })
try {
renders = 0
await mouseover(a)
assert.equal(renders, 2, 'a changed viewport re-measures (show + measure)')
} finally {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width })
}
await m.unmount()
})
test('unmounting the zone removes the portaled bubble', async () => {
const m: Mounted = await mount(h(Zone))
await mouseover(query(m.container, '#a'))