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:
1 parent
679dc669f4
commit
a2d14dbad1
2 files changed
+76
-11
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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'))
|
||||
|
||||
Reference in new issue
Block a user