diff --git a/src/client/components/hoverTip.tsx b/src/client/components/hoverTip.tsx index 71d0861..6afcdea 100644 --- a/src/client/components/hoverTip.tsx +++ b/src/client/components/hoverTip.tsx @@ -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( diff --git a/tests/client/components/hoverTip.spec.ts b/tests/client/components/hoverTip.spec.ts index a436d95..160fe83 100644 --- a/tests/client/components/hoverTip.spec.ts +++ b/tests/client/components/hoverTip.spec.ts @@ -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'))