diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index d22eb5785b..08c986f435 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 1ffbb21ed1..9b043ee2c6 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -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 diff --git a/frontend-modern/browser-verification/e8da575f050b77e8.json b/frontend-modern/browser-verification/e8da575f050b77e8.json new file mode 100644 index 0000000000..b85cea034a --- /dev/null +++ b/frontend-modern/browser-verification/e8da575f050b77e8.json @@ -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" + ] +} diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index a9da0806f2..af75efec6f 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -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)'); diff --git a/frontend-modern/src/components/shared/__tests__/TooltipPortal.test.tsx b/frontend-modern/src/components/shared/__tests__/TooltipPortal.test.tsx index 971faae70c..3b039d8cff 100644 --- a/frontend-modern/src/components/shared/__tests__/TooltipPortal.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/TooltipPortal.test.tsx @@ -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(() => ( @@ -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(() => ( + <> + + {() => ( + + Hidden row tooltip + + )} + + + Memory Composition + + + )); + + 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(() => ( + + Memory Composition + + )); + + 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(() => ( + + Memory Composition + + )); + + 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); + } + }); }); diff --git a/frontend-modern/src/components/shared/useTooltipState.ts b/frontend-modern/src/components/shared/useTooltipState.ts index 7cca48f4fe..ff395c0d89 100644 --- a/frontend-modern/src/components/shared/useTooltipState.ts +++ b/frontend-modern/src/components/shared/useTooltipState.ts @@ -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, }); diff --git a/frontend-modern/src/hooks/__tests__/useBreakpoint.test.ts b/frontend-modern/src/hooks/__tests__/useBreakpoint.test.ts index 5c2933306a..a20cbcafaf 100644 --- a/frontend-modern/src/hooks/__tests__/useBreakpoint.test.ts +++ b/frontend-modern/src/hooks/__tests__/useBreakpoint.test.ts @@ -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(); + 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'); + }); +}); diff --git a/frontend-modern/src/hooks/useBreakpoint.ts b/frontend-modern/src/hooks/useBreakpoint.ts index dae0472b6c..67df245f6a 100644 --- a/frontend-modern/src/hooks/useBreakpoint.ts +++ b/frontend-modern/src/hooks/useBreakpoint.ts @@ -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; } +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; }