mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user