From 2cb1fb4ac650e625c413c02e3207c415af8dc72c Mon Sep 17 00:00:00 2001 From: CaliBrain Date: Tue, 3 Nov 2026 00:00:21 -0500 Subject: [PATCH] fix(tooltip): ignore sub-pixel overflow in the viewport clamp (#1651) Closes #1646. In Firefox, hovering the Stop download button on an in-progress download blanked the page with React error #185. The Tooltip's layout effect moves a tooltip back inside the viewport and then runs again on the new position, expecting to measure no overflow. The Stop download tooltip runs about 10px past the right edge of the unpinned Activity sidebar. After the move, Firefox measures its right edge 0.0000153px past the limit (1594.0000153 against 1594), because it works out a transformed box in single precision. The effect moves it by that much, which is below what layout can represent, so the box stays put and the next pass measures the same overflow. After 50 passes React throws #185 and unmounts the app. Chromium lands at 1017.996 against 1018 and stops after one pass. The clamp now lives in getTooltipViewportNudge and treats overflow under half a pixel as none. Tooltip calls it with the same inputs as before. ## Summary by CodeRabbit * **Bug Fixes** * Tooltips are repositioned to stay within the viewport, including when they are wider or taller than the available space. * Minor overflow of less than half a pixel no longer triggers unnecessary repositioning. --- .../src/components/shared/Tooltip.tsx | 40 ++++---------- .../src/tests/tooltipViewportNudge.test.ts | 52 +++++++++++++++++++ .../src/utils/tooltipViewportNudge.ts | 38 ++++++++++++++ 3 files changed, 101 insertions(+), 29 deletions(-) create mode 100644 src/frontend/src/tests/tooltipViewportNudge.test.ts create mode 100644 src/frontend/src/utils/tooltipViewportNudge.ts diff --git a/src/frontend/src/components/shared/Tooltip.tsx b/src/frontend/src/components/shared/Tooltip.tsx index f13b277e..b1a4d5bc 100644 --- a/src/frontend/src/components/shared/Tooltip.tsx +++ b/src/frontend/src/components/shared/Tooltip.tsx @@ -3,6 +3,7 @@ import { useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useMountEffect } from '../../hooks/useMountEffect'; +import { getTooltipViewportNudge } from '../../utils/tooltipViewportNudge'; interface TooltipProps { content: ReactNode; @@ -112,44 +113,25 @@ export function Tooltip({ return; } - const tooltipRect = tooltipRef.current.getBoundingClientRect(); - const viewportPadding = 6; + const nudge = getTooltipViewportNudge( + tooltipRef.current.getBoundingClientRect(), + window.innerWidth, + window.innerHeight, + ); - let deltaX = 0; - let deltaY = 0; - - if (tooltipRect.width + viewportPadding * 2 > window.innerWidth) { - deltaX = viewportPadding - tooltipRect.left; - } else { - if (tooltipRect.left < viewportPadding) { - deltaX = viewportPadding - tooltipRect.left; - } else if (tooltipRect.right > window.innerWidth - viewportPadding) { - deltaX = window.innerWidth - viewportPadding - tooltipRect.right; - } - } - - if (tooltipRect.height + viewportPadding * 2 > window.innerHeight) { - deltaY = viewportPadding - tooltipRect.top; - } else { - if (tooltipRect.top < viewportPadding) { - deltaY = viewportPadding - tooltipRect.top; - } else if (tooltipRect.bottom > window.innerHeight - viewportPadding) { - deltaY = window.innerHeight - viewportPadding - tooltipRect.bottom; - } - } - - if (deltaX !== 0 || deltaY !== 0) { + if (nudge.x !== 0 || nudge.y !== 0) { // Genuine measure-and-adjust: the tooltip must be laid out before we know // whether it overflows the viewport. The loop converges in one pass because - // the corrected position yields deltaX/deltaY of 0 on the next run. + // the corrected position measures as no nudge on the next run, which holds + // only because the nudge ignores sub-pixel overflow. // oxlint-disable-next-line react/set-state-in-effect setCoords((current) => { if (!current) { return current; } return { - top: current.top + deltaY, - left: current.left + deltaX, + top: current.top + nudge.y, + left: current.left + nudge.x, }; }); } diff --git a/src/frontend/src/tests/tooltipViewportNudge.test.ts b/src/frontend/src/tests/tooltipViewportNudge.test.ts new file mode 100644 index 00000000..7c0ee1dc --- /dev/null +++ b/src/frontend/src/tests/tooltipViewportNudge.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; + +import { getTooltipViewportNudge } from '../utils/tooltipViewportNudge'; + +const box = (left: number, top: number, width: number, height: number) => ({ + left, + top, + width, + height, + right: left + width, + bottom: top + height, +}); + +describe('getTooltipViewportNudge', () => { + it('leaves a tooltip that fits where it is', () => { + expect(getTooltipViewportNudge(box(100, 100, 80, 30), 1600, 900)).toEqual({ x: 0, y: 0 }); + }); + + // The two measurements Firefox took of the "Stop download" tooltip in #1646, + // before and after the nudge, in a 1600x900 window. + it('moves a tooltip that runs past the right edge back inside', () => { + const rect = box(1536.2166748046875, 185, 67.566680908203125, 35.5); + const nudge = getTooltipViewportNudge(rect, 1600, 900); + expect(nudge.x).toBeCloseTo(1594 - rect.right, 10); + expect(nudge.y).toBe(0); + }); + + it('leaves a tooltip that measures a fraction of a pixel past the edge', () => { + const rect = box(1526.433349609375, 185, 67.56666564941406, 35.5); + expect(rect.right).toBeGreaterThan(1594); + expect(getTooltipViewportNudge(rect, 1600, 900)).toEqual({ x: 0, y: 0 }); + }); + + it('still moves a tooltip that is half a pixel over', () => { + expect(getTooltipViewportNudge(box(1514, 100, 80.5, 30), 1600, 900)).toEqual({ + x: -0.5, + y: 0, + }); + }); + + it('moves a tooltip past the left or top edge back inside', () => { + expect(getTooltipViewportNudge(box(-20, -10, 80, 30), 1600, 900)).toEqual({ x: 26, y: 16 }); + }); + + it('moves a tooltip past the bottom edge up', () => { + expect(getTooltipViewportNudge(box(100, 880, 80, 30), 1600, 900)).toEqual({ x: 0, y: -16 }); + }); + + it('pins a tooltip wider than the viewport to the left edge', () => { + expect(getTooltipViewportNudge(box(40, 100, 400, 30), 390, 900)).toEqual({ x: -34, y: 0 }); + }); +}); diff --git a/src/frontend/src/utils/tooltipViewportNudge.ts b/src/frontend/src/utils/tooltipViewportNudge.ts new file mode 100644 index 00000000..ce819966 --- /dev/null +++ b/src/frontend/src/utils/tooltipViewportNudge.ts @@ -0,0 +1,38 @@ +type MeasuredBox = Pick; + +interface ViewportNudge { + x: number; + y: number; +} + +const VIEWPORT_PADDING = 6; + +// Firefox measures a transformed box in single precision, so a tooltip already moved flush +// to the edge can read 0.00001px past it. Moving it by that much changes nothing, the next +// measurement shows the same overflow, and the tooltip's layout effect loops until React +// gives up with error #185 and the page goes blank (#1646). +const SUBPIXEL_TOLERANCE = 0.5; + +const nudgeAxis = (start: number, end: number, size: number, viewport: number): number => { + let delta = 0; + if (size + VIEWPORT_PADDING * 2 > viewport || start < VIEWPORT_PADDING) { + delta = VIEWPORT_PADDING - start; + } else if (end > viewport - VIEWPORT_PADDING) { + delta = viewport - VIEWPORT_PADDING - end; + } + return Math.abs(delta) < SUBPIXEL_TOLERANCE ? 0 : delta; +}; + +/** + * How far to move a tooltip so it stays inside the viewport, keeping a small gap + * from each edge. A tooltip bigger than the viewport is pinned to the top/left edge. + * Overflow under half a pixel counts as none. + */ +export const getTooltipViewportNudge = ( + rect: MeasuredBox, + viewportWidth: number, + viewportHeight: number, +): ViewportNudge => ({ + x: nudgeAxis(rect.left, rect.right, rect.width, viewportWidth), + y: nudgeAxis(rect.top, rect.bottom, rect.height, viewportHeight), +});