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.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## 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.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
CaliBrain authored and GitHub committed 2026-11-03 00:00:21 -05:00
1 parent fe7073fdf6
commit 2cb1fb4ac6
3 files changed
+101 -29

No files matched your search

+11 -29
View File
@@ -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,
};
});
}
@@ -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 });
});
});
@@ -0,0 +1,38 @@
type MeasuredBox = Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom' | 'width' | 'height'>;
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),
});