Stop hidden tooltips and breakpoints listening to resize (#2724)

On `/proxmox` with 140 workload rows, 859 window `resize` listeners were
active after load (203 at phone width with 36 rows), so every window
resize ran one callback per listener:

- 699 from `useTooltipState.ts` (`useTooltipLayoutState`), which
registered a listener for every tooltip instance, including the hidden
`TooltipPortal` on each row's metric bars and sparklines.
- 156 from `useBreakpoint.ts`, where every call added its own listener
and width signal.

## Change

- Tooltip layout state listens only while its tooltip is visible and
re-reads the viewport when it opens. `useTooltipPortalState` uses the
same visibility gate as the portal's render (`when &&
supportsHoverTooltips()`), so a portal suppressed on a touch device
holds no listener either.
- `useBreakpoint` keeps one module-level width signal and shared memos,
fed by a single rAF-debounced listener while at least one caller is
mounted. A new caller syncs to the live `innerWidth`, so a caller
created after resizes nobody heard starts correct.

## Evidence

Live pass on a private mock-mode stack (headless Chromium,
add/removeEventListener wrapped to track live listeners by registering
frame):

| | before | after |
|---|---|---|
| 1440×900, 140 rows | 859 active, 859 callbacks per resize | 5 active,
5 callbacks |
| 390×844, 36 rows | 203 active | 5 active |

- An open row tooltip adds exactly one listener, stays open through
window resizes (its viewBox follows 1440×700 and 1380×640), and its
listener is removed on close.
- Crossing the phone breakpoint in both directions still switches the
workload table (`workload-table--desktop` ↔ `--mobile`) and each row's
layout.
- New tests: `useBreakpoint.test.ts` (one listener for 50 callers,
back-to-back resizes leave one pending frame, every caller crosses a
breakpoint, a new caller starts from the live width) and
`TooltipPortal.test.tsx` (hidden and touch-suppressed portals add no
listener, an open portal re-clamps on resize, a portal opened after an
unheard resize uses the live viewport). The listener assertions fail
against main.
- 198 frontend test files / 3620 tests pass across `components/shared`,
`components/Workloads`, the hooks, AppLayout, AIChat, Docker tables and
the boundary tests; prettier, eslint and tsc are clean.

Contract notes: `frontend-primitives.md` (tooltip owner) and
`performance-and-scalability.md` (new current-state section).
`useBreakpoint.ts` has no owning subsystem, so its rule lives in the
performance contract.
This commit is contained in:
rcourtman
2026-10-08 02:23:06 +01:00
committed by GitHub
8 changed files with 386 additions and 63 deletions
@@ -6236,6 +6236,13 @@ When a shared portal tooltip is already visible, that same owner must
reschedule positioning on live coordinate and viewport changes so chart hover
tooltips keep following the active pointer instead of sticking to their first
anchor.
Only a visible tooltip holds a window resize listener. Hidden instances (one
per metric bar or sparkline on every table row) and portals suppressed on a
touch device register none and re-read the viewport when they open, so the
listener count no longer grows with rows. `TooltipPortal.test.tsx` proves that
hidden and touch-suppressed portals add no listener, that an open one re-clamps
on a window resize, and that one opened after an unheard resize starts from the
live viewport.
Floating hover tooltips are a fine-pointer interaction only. The shared
tooltip hook, portal, and singleton API must suppress them when the primary
device reports no hover capability or a coarse pointer, because touch browsers
@@ -1801,6 +1801,22 @@ final measurements and host-load limitation remain recorded in
`records/resource-payload-static-metadata-2026-08-24.md`. They do not establish
a controlled performance improvement or satisfy the open SLO qualification.
### Window resize listeners do not grow with rows
Table rows must not each register a window `resize` listener.
`frontend-modern/src/hooks/useBreakpoint.ts` keeps one module-level width
signal, fed by a single rAF-debounced listener while at least one caller is
mounted, and a new caller starts from the live `innerWidth`. Shared tooltip
state in `frontend-modern/src/components/shared/useTooltipState.ts` listens
only while its tooltip is visible. Measured on 2026-10-07 on a mock-mode
`/proxmox` page with 140 workload rows at 1440 px, the active listener count
fell from 859 (699 tooltip instances, 156 breakpoint callers) to 5; at 390 px
with 36 rows it fell from 203 to 5. An open tooltip adds one listener and
removes it on close, and crossing the phone breakpoint in either direction
still switches the workload table and row layout. `useBreakpoint.test.ts`
proves one listener for 50 callers, that back-to-back resizes leave one pending
frame, and that every caller crosses a breakpoint when that frame runs.
### Large API responses negotiate gzip without corrupting edge cases
The main listener compresses eligible JSON and frontend text assets for
@@ -0,0 +1,42 @@
{
"version": 1,
"base_sha": "36d3f8c5571daa1e041abf41c4f7c394ca14111f",
"verified_at": "2026-10-07T22:02:19Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/shared/useTooltipState.ts",
"frontend-modern/src/hooks/useBreakpoint.ts"
],
"content_sha256": {
"frontend-modern/src/components/shared/useTooltipState.ts": "d3726fcd6498be9d1ca56d6116a741f1000e0ba4e645b3c0eee009b5017ec494",
"frontend-modern/src/hooks/useBreakpoint.ts": "e2f67292f467ea5f1fb3d36db1abee117c87e68f481f29aeb20e9cf3f764d865"
},
"routes": [
"/proxmox"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Private mock-mode stack on this branch (backend :7901, Vite :5401), headless Chromium with an init script wrapping EventTarget add/removeEventListener to track live window resize listeners by registering stack frame",
"Before (origin/main 77d2119792), 1440x900, 140 workload rows: 859 active resize listeners (699 from useTooltipState useTooltipLayoutState, 156 from useBreakpoint, 4 page-level); one dispatched resize ran 859 callbacks",
"Before, 390x844, 36 rows: 203 active (153 tooltip, 46 useBreakpoint, 4 page-level); 203 callbacks per resize",
"After, 1440x900, 140 rows: 5 active (1 retainViewportWidthListener in useBreakpoint plus the 4 page-level listeners); 5 callbacks per resize; unchanged after scrolling the table",
"After, 390x844, 36 rows: 5 active, 5 callbacks per resize",
"After, open memory sparkline tooltip on a workload row: active count 6 while open; after resizing to 1440x700 and then 1380x640 the tooltip stayed open, its svg viewBox read '0 0 1440 700' then '0 0 1380 640' and it stayed inside the viewport; after moving the pointer away the tooltip closed and the count returned to 5",
"After, breakpoint crossing 1440 -> 390 -> 1440 and 390 -> 1440 -> 390: the workload table class switched workload-table--desktop <-> workload-table--mobile and every row's name cell style switched 'width: 27.6596%' <-> 'width: 38%' each way; listener count stayed 5 throughout; no page errors"
],
"interactions": [
"Loaded /proxmox at 1440x900 and at 390x844, counted active window resize listeners and their registering sources, dispatched one synthetic resize and counted callbacks",
"Scrolled the workload table down in twelve 600px steps and back to the top, recounted listeners",
"Hovered row cells right to left until the memory sparkline tooltip opened, resized the window twice while it stayed open, moved the pointer away to close it, recounted listeners",
"Resized the viewport across the md breakpoint (desktop to phone and back, phone to desktop and back) and read the workload table mode and row layout after each step"
]
}
@@ -8407,6 +8407,11 @@ describe('shared primitive guardrails', () => {
expect(tooltipStateSource).toContain('resolveTooltipPosition');
expect(tooltipStateSource).toContain('sanitizeTooltipContent');
expect(tooltipStateSource).toContain('supportsHoverTooltips');
// Hidden tooltips sit on every table row; only an open one may hold a
// window resize listener (TooltipPortal.test.tsx proves the behaviour).
expect(tooltipStateSource).toMatch(
/if \(typeof window === 'undefined' \|\| !options\.visible\(\)\) return;\s*updateViewport\(\);\s*const handleResize/,
);
expect(sharedTooltipHookSource).toContain('supportsHoverTooltips');
expect(hoverCapabilitySource).toContain('(hover: hover) and (pointer: fine)');
@@ -1,4 +1,4 @@
import { createSignal } from 'solid-js';
import { For, createSignal } from 'solid-js';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, waitFor } from '@solidjs/testing-library';
import { TooltipPortal } from '@/components/shared/TooltipPortal';
@@ -20,6 +20,7 @@ describe('TooltipPortal', () => {
media: '(hover: hover) and (pointer: fine)',
}),
);
const addListener = vi.spyOn(window, 'addEventListener');
render(() => (
<TooltipPortal when x={120} y={80}>
@@ -28,6 +29,7 @@ describe('TooltipPortal', () => {
));
expect(document.body.querySelector('[data-tooltip-portal="true"]')).toBeNull();
expect(addListener.mock.calls.filter(([type]) => type === 'resize')).toHaveLength(0);
});
it('keeps tooltip portal on shell and runtime owners', () => {
@@ -172,4 +174,143 @@ describe('TooltipPortal', () => {
expect(position.y).toBeGreaterThan(initialPosition.y + 80);
});
});
it('holds a window resize listener only while the portal is visible', () => {
const addListener = vi.spyOn(window, 'addEventListener');
const removeListener = vi.spyOn(window, 'removeEventListener');
const resizeCalls = (spy: { mock: { calls: unknown[][] } }) =>
spy.mock.calls.filter(([type]) => type === 'resize').length;
const [when, setWhen] = createSignal(false);
render(() => (
<>
<For each={[1, 2, 3]}>
{() => (
<TooltipPortal when={false} x={40} y={40}>
<span>Hidden row tooltip</span>
</TooltipPortal>
)}
</For>
<TooltipPortal when={when()} x={120} y={80}>
<span>Memory Composition</span>
</TooltipPortal>
</>
));
expect(resizeCalls(addListener)).toBe(0);
setWhen(true);
expect(resizeCalls(addListener)).toBe(1);
expect(resizeCalls(removeListener)).toBe(0);
setWhen(false);
expect(resizeCalls(addListener)).toBe(1);
expect(resizeCalls(removeListener)).toBe(1);
});
it('follows a window resize while the portal is open', async () => {
const innerWidth = Object.getOwnPropertyDescriptor(window, 'innerWidth');
const setInnerWidth = (value: number) =>
Object.defineProperty(window, 'innerWidth', { configurable: true, value, writable: true });
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(
(callback: FrameRequestCallback) => {
callback(0);
return 1;
},
);
vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockReturnValue({
width: 180,
height: 40,
left: 0,
top: 0,
right: 180,
bottom: 40,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect);
try {
setInnerWidth(1024);
render(() => (
<TooltipPortal when x={900} y={120}>
<span>Memory Composition</span>
</TooltipPortal>
));
const readPortal = () => {
const portal = document.body.querySelector('foreignObject');
return {
viewBox: portal?.closest('svg')?.getAttribute('viewBox'),
x: Number.parseFloat(portal?.getAttribute('x') ?? 'NaN'),
};
};
await waitFor(() => {
expect(readPortal().viewBox).toBe(`0 0 1024 ${window.innerHeight}`);
expect(readPortal().x).toBe(810);
});
setInnerWidth(600);
window.dispatchEvent(new Event('resize'));
await waitFor(() => {
expect(readPortal().viewBox).toBe(`0 0 600 ${window.innerHeight}`);
// Clamped inside the narrower viewport: 600 - 180 wide - 4 padding.
expect(readPortal().x).toBe(416);
});
} finally {
if (innerWidth) Object.defineProperty(window, 'innerWidth', innerWidth);
}
});
it('opens against the live viewport after resizes it did not listen to', async () => {
const innerWidth = Object.getOwnPropertyDescriptor(window, 'innerWidth');
const setInnerWidth = (value: number) =>
Object.defineProperty(window, 'innerWidth', { configurable: true, value, writable: true });
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(
(callback: FrameRequestCallback) => {
callback(0);
return 1;
},
);
vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockReturnValue({
width: 180,
height: 40,
left: 0,
top: 0,
right: 180,
bottom: 40,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect);
try {
setInnerWidth(1024);
const [when, setWhen] = createSignal(false);
render(() => (
<TooltipPortal when={when()} x={900} y={120}>
<span>Memory Composition</span>
</TooltipPortal>
));
setInnerWidth(600);
window.dispatchEvent(new Event('resize'));
setWhen(true);
await waitFor(() => {
const portal = document.body.querySelector('foreignObject');
expect(portal?.closest('svg')?.getAttribute('viewBox')).toBe(
`0 0 600 ${window.innerHeight}`,
);
expect(Number.parseFloat(portal?.getAttribute('x') ?? 'NaN')).toBe(416);
});
} finally {
if (innerWidth) Object.defineProperty(window, 'innerWidth', innerWidth);
}
});
});
@@ -86,14 +86,15 @@ function useTooltipLayoutState(options: TooltipLayoutStateOptions): {
});
};
// Only an open tooltip follows the viewport. Hidden instances (one per metric
// bar or sparkline on every table row) hold no window listener; the viewport
// is re-read when they open.
createEffect(() => {
if (typeof window === 'undefined') return;
if (typeof window === 'undefined' || !options.visible()) return;
updateViewport();
const handleResize = () => {
updateViewport();
if (options.visible()) {
schedulePositionUpdate();
}
schedulePositionUpdate();
};
window.addEventListener('resize', handleResize);
onCleanup(() => window.removeEventListener('resize', handleResize));
@@ -179,7 +180,8 @@ export function useTooltipPortalState(options: TooltipPortalStateOptions): {
align: () => options.align,
direction: () => options.direction,
maxWidth: () => options.maxWidth ?? 320,
visible: () => options.when,
// Matches the portal's render gate: a touch device never shows it.
visible: () => options.when && supportsHoverTooltips(),
x: () => options.x,
y: () => options.y,
});
@@ -1,9 +1,12 @@
import { describe, it, expect } from 'vitest';
import { afterEach, describe, it, expect, vi } from 'vitest';
import { createRoot } from 'solid-js';
import {
BREAKPOINTS,
PRIORITY_BREAKPOINTS,
useBreakpoint,
type Breakpoint,
type ColumnPriority,
type UseBreakpointReturn,
} from '../useBreakpoint';
const getBreakpointName = (width: number): Breakpoint => {
@@ -162,3 +165,84 @@ describe('useBreakpoint (pure functions)', () => {
});
});
});
describe('useBreakpoint (shared viewport width)', () => {
const innerWidth = Object.getOwnPropertyDescriptor(window, 'innerWidth');
const setInnerWidth = (value: number) =>
Object.defineProperty(window, 'innerWidth', { configurable: true, value, writable: true });
const disposers: Array<() => void> = [];
const mountCallers = (count: number): UseBreakpointReturn[] =>
Array.from({ length: count }, () =>
createRoot((dispose) => {
disposers.push(dispose);
return useBreakpoint();
}),
);
afterEach(() => {
disposers.splice(0).forEach((dispose) => dispose());
vi.restoreAllMocks();
if (innerWidth) Object.defineProperty(window, 'innerWidth', innerWidth);
});
it('adds one window resize listener however many callers are mounted', () => {
const addListener = vi.spyOn(window, 'addEventListener');
const removeListener = vi.spyOn(window, 'removeEventListener');
const resizeCalls = (spy: { mock: { calls: unknown[][] } }) =>
spy.mock.calls.filter(([type]) => type === 'resize').length;
mountCallers(50);
expect(resizeCalls(addListener)).toBe(1);
disposers.splice(0, 49).forEach((dispose) => dispose());
expect(resizeCalls(removeListener)).toBe(0);
disposers.splice(0).forEach((dispose) => dispose());
expect(resizeCalls(removeListener)).toBe(1);
});
it('moves every caller across a breakpoint after one debounced frame', () => {
const frames = new Map<number, FrameRequestCallback>();
let nextFrame = 0;
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
nextFrame += 1;
frames.set(nextFrame, callback);
return nextFrame;
});
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => {
frames.delete(id);
});
setInnerWidth(1440);
const callers = mountCallers(3);
expect(callers.map((caller) => caller.isDesktop())).toEqual([true, true, true]);
setInnerWidth(390);
window.dispatchEvent(new Event('resize'));
expect(frames.size).toBe(1);
// A second resize in the same frame replaces the pending update.
window.dispatchEvent(new Event('resize'));
expect(frames.size).toBe(1);
expect(callers.map((caller) => caller.isDesktop())).toEqual([true, true, true]);
[...frames.values()].forEach((frame) => frame(0));
frames.clear();
expect(callers.map((caller) => caller.isMobile())).toEqual([true, true, true]);
expect(callers.map((caller) => caller.breakpoint())).toEqual(['xs', 'xs', 'xs']);
expect(callers[0].isVisible('secondary')).toBe(false);
});
it('starts a new caller from the live width after resizes nobody heard', () => {
setInnerWidth(1440);
const [first] = mountCallers(1);
expect(first.width()).toBe(1440);
disposers.splice(0).forEach((dispose) => dispose());
setInnerWidth(700);
const [second] = mountCallers(1);
expect(second.width()).toBe(700);
expect(second.breakpoint()).toBe('sm');
});
});
+82 -56
View File
@@ -1,4 +1,4 @@
import { createSignal, onMount, onCleanup, createMemo, Accessor } from 'solid-js';
import { createSignal, onMount, onCleanup, createMemo, createRoot, Accessor } from 'solid-js';
/**
* Tailwind CSS breakpoint values (in pixels)
@@ -62,11 +62,87 @@ export interface UseBreakpointReturn {
isDesktop: Accessor<boolean>;
}
const readViewportWidth = (): number => (typeof window !== 'undefined' ? window.innerWidth : 0);
/**
* One viewport width shared by every useBreakpoint() caller. Workload rows call
* the hook once or twice each, so a per-call listener made every window resize
* run one callback per row. A single rAF-debounced listener feeds this signal
* while at least one caller is mounted.
*/
const [viewportWidth, setViewportWidth] = createSignal(readViewportWidth());
let mountedCallers = 0;
let resizeFrame: number | undefined;
const handleViewportResize = () => {
if (resizeFrame !== undefined) {
window.cancelAnimationFrame(resizeFrame);
}
resizeFrame = window.requestAnimationFrame(() => {
resizeFrame = undefined;
setViewportWidth(window.innerWidth);
});
};
function retainViewportWidthListener(): () => void {
setViewportWidth(window.innerWidth);
mountedCallers += 1;
if (mountedCallers === 1) {
window.addEventListener('resize', handleViewportResize, { passive: true });
}
return () => {
mountedCallers -= 1;
if (mountedCallers > 0) return;
window.removeEventListener('resize', handleViewportResize);
if (resizeFrame !== undefined) {
window.cancelAnimationFrame(resizeFrame);
resizeFrame = undefined;
}
};
}
const sharedBreakpoint = createRoot((): UseBreakpointReturn => {
const breakpoint = createMemo(() => getBreakpointName(viewportWidth()));
const isAtLeast = (bp: Breakpoint): boolean => {
return viewportWidth() >= BREAKPOINTS[bp];
};
const isBelow = (bp: Breakpoint): boolean => {
return viewportWidth() < BREAKPOINTS[bp];
};
const isVisible = (priority: ColumnPriority): boolean => {
const minBreakpoint = PRIORITY_BREAKPOINTS[priority];
return viewportWidth() >= BREAKPOINTS[minBreakpoint];
};
const isMobile = createMemo(() => viewportWidth() < BREAKPOINTS.md);
const isTablet = createMemo(
() => viewportWidth() >= BREAKPOINTS.md && viewportWidth() < BREAKPOINTS.xl,
);
const isDesktop = createMemo(() => viewportWidth() >= BREAKPOINTS.xl);
return {
width: viewportWidth,
breakpoint,
isAtLeast,
isBelow,
isVisible,
isMobile,
isTablet,
isDesktop,
};
});
/**
* Reactive hook for tracking viewport width and breakpoints.
*
* Use this for conditional rendering based on screen size,
* which is more performant than rendering and hiding with CSS.
* Every caller reads the same shared width; mounting a caller never adds
* another window resize listener.
*
* @example
* ```tsx
@@ -80,63 +156,13 @@ export interface UseBreakpointReturn {
* ```
*/
export function useBreakpoint(): UseBreakpointReturn {
// Initialize with current window width (or 0 for SSR)
const initialWidth = typeof window !== 'undefined' ? window.innerWidth : 0;
const [width, setWidth] = createSignal(initialWidth);
// The shared width goes stale while no caller is mounted (and for up to a
// frame after a resize), so a new caller starts from the live width.
setViewportWidth(readViewportWidth());
onMount(() => {
// Debounce resize events to avoid excessive re-renders
let resizeTimeout: number | undefined;
const handleResize = () => {
if (resizeTimeout) {
window.cancelAnimationFrame(resizeTimeout);
}
resizeTimeout = window.requestAnimationFrame(() => {
setWidth(window.innerWidth);
});
};
// Set initial width in case it changed between SSR and mount
setWidth(window.innerWidth);
window.addEventListener('resize', handleResize, { passive: true });
onCleanup(() => {
window.removeEventListener('resize', handleResize);
if (resizeTimeout) {
window.cancelAnimationFrame(resizeTimeout);
}
});
onCleanup(retainViewportWidthListener());
});
const breakpoint = createMemo(() => getBreakpointName(width()));
const isAtLeast = (bp: Breakpoint): boolean => {
return width() >= BREAKPOINTS[bp];
};
const isBelow = (bp: Breakpoint): boolean => {
return width() < BREAKPOINTS[bp];
};
const isVisible = (priority: ColumnPriority): boolean => {
const minBreakpoint = PRIORITY_BREAKPOINTS[priority];
return width() >= BREAKPOINTS[minBreakpoint];
};
const isMobile = createMemo(() => width() < BREAKPOINTS.md);
const isTablet = createMemo(() => width() >= BREAKPOINTS.md && width() < BREAKPOINTS.xl);
const isDesktop = createMemo(() => width() >= BREAKPOINTS.xl);
return {
width,
breakpoint,
isAtLeast,
isBelow,
isVisible,
isMobile,
isTablet,
isDesktop,
};
return sharedBreakpoint;
}