mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
Eliminate large-estate scroll hitches in windowed tables
Wheel scrolling the Proxmox overview on a 929-guest estate hitched for 200-260ms per runway rebalance. Four compounding causes, all fixed: - The windowing runway policy rebalanced in ~53-row jumps inside a non-passive wheel listener, so the compositor waited on a multi-frame remount. Both windowing owners now keep a symmetric runway topped up in dead-band batches (one bounded mutation frame per ~8 rows of travel), every windowed-page listener is passive, and only a viewport that leaves the mounted window entirely re-centers it in one jump. - The Workloads viewport-sync effect tracked the windowing signals its own measurement pass moves, so every shift re-ran row measurement (two forced reflows) and listener binding. The measurement pass is now untracked, matching the unified-resource sync owner; the platform and storage windowing owners had the same latent tracking and are untracked too. - WorkloadPanel built its guest-id lookup from the windowed slice, so a shift re-validated every mounted row's memo chain. The lookup now derives from the full group and only changes when data changes. - The CPU/memory/disk bar hooks read offsetWidth in onMount, forcing a full-table reflow per mounted bar during every top-up. Width now comes solely from the ResizeObserver's initial pre-paint delivery. Prewarmed-but-hidden platform tabs also ran viewport sync per scroll event and paid the visible surface's layout flush; hidden surfaces now skip sync via a shared checkVisibility guard. Measured on the 50-node/929-guest mock estate at 2560x1240: a 4-row top-up fell from ~230ms to ~7ms, scroll-jitter oscillation produces zero frames over 25ms, and sustained fast scrolling holds native frame cadence with one bounded mutation frame per dead-band. The remaining scroll-time long tasks are realtime websocket merge ticks, which the realtime-merge lane already owns.
This commit is contained in:
@@ -908,20 +908,26 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
|
||||
cross the virtual window and prevents the visible estate from bumping by a
|
||||
header row during continuous scrolling.
|
||||
Virtualization must remain visually invisible under rapid wheel and
|
||||
trackpad input. The windowing owner keeps a hysteretic directional runway
|
||||
instead of shifting the slice for every visible-row change, and wheel
|
||||
viewport sync may prewarm that runway before the compositor advances the
|
||||
viewport. Every windowed surface must route listener lifecycle, scroll-
|
||||
ancestor selection, and wheel-delta normalization through
|
||||
`frontend-modern/src/components/shared/windowedPageScroll.ts`. That shared
|
||||
owner may register a non-passive wheel listener, but it must not register a
|
||||
touch listener: phone gestures remain compositor-native and advance the
|
||||
keyed runway only through the passive native scroll event after the page
|
||||
moves. Group and guest iteration must remain keyed so overlapping rows
|
||||
survive window shifts rather than rebinding every mounted row. This
|
||||
anticipation must preserve the existing bounded mounted-row budget;
|
||||
rendering the whole estate or adding per-row observers, timers, or scroll
|
||||
listeners is forbidden.
|
||||
trackpad input, and must never block that input. The windowing owner keeps
|
||||
a symmetric runway and tops up only the depleted side, in dead-band-sized
|
||||
batches: a mutation frame costs a near-constant style/layout pass on the
|
||||
mounted table whatever the shift size, so batching keeps most scroll
|
||||
frames compositor-only while the dead-band absorbs sub-row scroll jitter;
|
||||
only a viewport that leaves the mounted window entirely re-centers it in
|
||||
one jump. Wheel viewport sync may prewarm
|
||||
that runway with the projected delta. Every windowed surface must route
|
||||
listener lifecycle, scroll-ancestor selection, and wheel-delta
|
||||
normalization through
|
||||
`frontend-modern/src/components/shared/windowedPageScroll.ts`. Every
|
||||
listener that shared owner registers must be passive — the compositor
|
||||
must never wait on windowing work — and it must not register a touch
|
||||
listener: phone gestures remain compositor-native and advance the keyed
|
||||
runway only through the passive native scroll event after the page moves.
|
||||
Group and guest iteration must remain keyed so overlapping rows survive
|
||||
window shifts rather than rebinding every mounted row. This anticipation
|
||||
must preserve the existing bounded mounted-row budget; rendering the whole
|
||||
estate or adding per-row observers, timers, or scroll listeners is
|
||||
forbidden.
|
||||
That same viewport-sync owner may expose one passive app-shell scroll
|
||||
position signal and a smooth back-to-top action for the Workloads surface.
|
||||
The control must remain absent near the top, sit above the mobile navigation
|
||||
@@ -1940,11 +1946,11 @@ which owns the Workloads table body measurement and the scroll/resize listener
|
||||
lifecycle. Future viewport sync changes must extend through that hook rather
|
||||
than rebuilding browser-event wiring or table-body geometry reads inside
|
||||
`frontend-modern/src/components/Workloads/useWorkloadsDerivedState.ts`.
|
||||
Its wheel and vertical touch-move prewarm listeners are deliberately
|
||||
non-passive so the browser compositor cannot advance beyond the mounted runway
|
||||
before the keyed guest window moves; they do not cancel native input, while the
|
||||
ordinary scroll-position listener remains passive. This preserves continuous
|
||||
rapid scrolling without increasing the bounded mounted-row budget.
|
||||
Its wheel prewarm and scroll-position listeners are all passive: the window
|
||||
tops its runway up in bounded dead-band batches, so the compositor never waits
|
||||
on windowing work and a busy main thread degrades to a briefly delayed top-up
|
||||
instead of a frozen wheel. This preserves continuous rapid scrolling without
|
||||
increasing the bounded mounted-row budget.
|
||||
The canonical public demo intentionally exercises this production hot path:
|
||||
its default Proxmox estate contains 50 nodes and more than 900 guests, crossing
|
||||
the mounted-row threshold while retaining the hook's 140-row desktop and
|
||||
|
||||
@@ -1,51 +1,65 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "9e9c3413a8ae4dcd6da789668a4c6f385f4a7f15",
|
||||
"verified_at": "2026-08-26T14:30:55Z",
|
||||
"base_sha": "8d573cd0ac341f794f88bd940a01db229c54bfbb",
|
||||
"verified_at": "2026-08-26T15:06:01Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerOperationalModel.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts",
|
||||
"frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx",
|
||||
"frontend-modern/src/hooks/useUnifiedResources.ts",
|
||||
"frontend-modern/src/types/api.ts",
|
||||
"frontend-modern/src/types/resource.ts",
|
||||
"frontend-modern/src/utils/resourceStateAdapters.ts"
|
||||
"frontend-modern/src/components/Infrastructure/useTableWindowing.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts",
|
||||
"frontend-modern/src/components/Storage/useStoragePoolsTableWindowing.ts",
|
||||
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx",
|
||||
"frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts",
|
||||
"frontend-modern/src/components/Workloads/useMetricBarState.ts",
|
||||
"frontend-modern/src/components/Workloads/useStackedDiskBarState.ts",
|
||||
"frontend-modern/src/components/Workloads/useStackedMemoryBarState.ts",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts",
|
||||
"frontend-modern/src/components/shared/windowedPageScroll.ts",
|
||||
"frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "9ea9ab621b2a434f4233750aa422baf2965302f795f01e01d0532a14f6d01ad4",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerOperationalModel.ts": "2ad021573bd3bca4fd0f6745cddb5a1ea8954e19f289bc1813cc8539e604c39e",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "51710198c2bffedb4349949909045540bfb30e6069790497f7d55262fe494676",
|
||||
"frontend-modern/src/components/Workloads/NodeDrawerOverview.tsx": "8113086bacb237aa08e62bc0d715925a24a06ca1fde13f0fc242ef2488c4db3b",
|
||||
"frontend-modern/src/hooks/useUnifiedResources.ts": "634b152af0bce3519c2c5481bb69ac8a23ac20fec20cc394004ad75dd85eb58b",
|
||||
"frontend-modern/src/types/api.ts": "46e442f3fdbe40aed6f1b39a7c9887234d096aa131ebb06d24206ae67f721c03",
|
||||
"frontend-modern/src/types/resource.ts": "10221bcff8ecac650f61be41d81b433795a0aa7562f178c1fa39a21c3e49a1de",
|
||||
"frontend-modern/src/utils/resourceStateAdapters.ts": "56bf97edf5e1d9a9b4920a3503289dba1a1d698f036433dc9a0d2d4a740e24df"
|
||||
"frontend-modern/src/components/Infrastructure/useTableWindowing.ts": "6eea3c4a17afd2d4238e88331e12e98c034a08cb2c104f1d3e46bed92993deff",
|
||||
"frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts": "91f650cd432ce8929c9fb6d108e6e37b0cfbf40103955ae9a2fcdb5734910721",
|
||||
"frontend-modern/src/components/Storage/useStoragePoolsTableWindowing.ts": "4e30ae20adf83f0b0f1dfe41d87331f0b52b641b29b0d457f392a1c00f185aa6",
|
||||
"frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "d29985e9245a3f60bbb9ca82e3ea330b3f07b08a9b11a079232cba27a6df57ff",
|
||||
"frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts": "187b4b20e623e75d52be00de396e5fef6be9e4c85384207a827768e37d8e0b39",
|
||||
"frontend-modern/src/components/Workloads/useMetricBarState.ts": "1dcfac801c35a81151a0f2776830089067f9bebe5237ccc5f7df53c20d7433bf",
|
||||
"frontend-modern/src/components/Workloads/useStackedDiskBarState.ts": "329a108849bc84fd0cd4c45524ea3cee8208bb01f16217254e3a9f4b772eda2a",
|
||||
"frontend-modern/src/components/Workloads/useStackedMemoryBarState.ts": "dc505c9daa2aebcaf614892cc584a325ea320febc346ce0e177ff4cf37894c85",
|
||||
"frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts": "4b441530125c7544093fcc19247ad0a2037dcaf1b064c1a087c755f1b0f1f6bf",
|
||||
"frontend-modern/src/components/shared/windowedPageScroll.ts": "6749cde8e9a17316ab53e691268e40dfc081c14d8d41b1eb1e331c9b5151ca80",
|
||||
"frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts": "9c89637fcd267edf335e8d516266bd6f9a3965fed2c4289bcc287d63c383543c"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox/overview"
|
||||
"/proxmox/overview",
|
||||
"/proxmox/storage",
|
||||
"/docker"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 900
|
||||
"width": 2560,
|
||||
"height": 1240
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
"width": 700,
|
||||
"height": 900
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"exact 75b6655d6e development build with the mock Proxmox estate connected",
|
||||
"expanded Analytics A Proxmox node with its technical details visible",
|
||||
"Network section populated from the richer linked-agent interface source",
|
||||
"narrow expanded node details with the Network section and address fully inside the viewport"
|
||||
"large mock estate: 50 nodes and 929 guests with the guest table windowed to a 140-row mounted budget",
|
||||
"storage pools table windowed and scrolled while visible on the Storage tab",
|
||||
"storage surface prewarmed but hidden behind the Overview tab while the guest table scrolls",
|
||||
"overview surface hidden behind the Storage tab, then restored at a deep scroll position",
|
||||
"narrow-viewport mobile layout with bottom navigation and compact guest rows",
|
||||
"docker containers page with grouped host/container tables"
|
||||
],
|
||||
"interactions": [
|
||||
"opened Proxmox Overview and expanded the Analytics A node at desktop width",
|
||||
"confirmed the Network section rendered eth0 and its address while preserving linked-agent precedence over provider inventory",
|
||||
"resized the same expanded state to 390 by 844 and confirmed the Network row remained visible, readable, and unclipped",
|
||||
"confirmed the narrow document and body scroll widths both remained exactly 390 pixels"
|
||||
"continuous mouse-wheel scrolling through the full 929-guest table with long-animation-frame and rAF cadence instrumentation: runway top-ups now cost 15-31ms in dead-band batches (previously 200-260ms blocking wheel handler)",
|
||||
"isolation battery on the guest table: +/-8px per-frame oscillation (0 frames over 25ms), 600px/s and 2600px/s and 6000px/s scripted continuous scrolls (p50 at native 13.3ms frame time, bounded mutation frames only)",
|
||||
"single synthetic wheel shifts at multiple scroll depths measuring per-shift cost (5-12ms for 4-10 row top-ups)",
|
||||
"scrollbar-style teleport jumps re-centering the mounted window without blanks",
|
||||
"tab switch Overview -> Storage -> Overview with deep scroll in between: hidden surface skips viewport sync, restored surface refills the viewport without spacer blanks",
|
||||
"storage pools table wheel-scrolled while visible: windowed rows advance with correct content",
|
||||
"narrow-viewport scroll through the guest table: windowing intact, metric bars sized by ResizeObserver initial delivery",
|
||||
"docker page scrolled end to end with zero console errors"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -181,12 +181,12 @@ describe('useTableWindowing', () => {
|
||||
enabled: () => true,
|
||||
windowSize: 100,
|
||||
});
|
||||
// A jump beyond the initial runway moves the window with 24 rows ahead.
|
||||
// firstVisible = 100 is outside [0, 100) → teleport to 100-45 = 55.
|
||||
hook.onScroll(4000, 400, 40);
|
||||
expect(hook.isVisible(75)).toBe(false);
|
||||
expect(hook.isVisible(76)).toBe(true);
|
||||
expect(hook.isVisible(175)).toBe(true);
|
||||
expect(hook.isVisible(176)).toBe(false);
|
||||
expect(hook.isVisible(54)).toBe(false);
|
||||
expect(hook.isVisible(55)).toBe(true);
|
||||
expect(hook.isVisible(154)).toBe(true);
|
||||
expect(hook.isVisible(155)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -201,10 +201,10 @@ describe('useTableWindowing', () => {
|
||||
windowSize: 100,
|
||||
});
|
||||
|
||||
// firstVisibleRow=100 and the directional runway keeps 24 rows ahead.
|
||||
// firstVisibleRow=100 is outside [0, 100) → teleport re-centers to 55.
|
||||
hook.onScroll(4000, 400, 40);
|
||||
expect(hook.startIndex()).toBe(76);
|
||||
expect(hook.endIndex()).toBe(176);
|
||||
expect(hook.startIndex()).toBe(55);
|
||||
expect(hook.endIndex()).toBe(155);
|
||||
});
|
||||
|
||||
it('clamps start to 0 when scrolled near top', () => {
|
||||
@@ -214,7 +214,7 @@ describe('useTableWindowing', () => {
|
||||
windowSize: 100,
|
||||
});
|
||||
|
||||
// scrollTop=100 → firstVisible = 2, start = 2-20 = -18 → clamped to 0
|
||||
// scrollTop=100 → firstVisible = 2, leading deficit clamps at start 0
|
||||
hook.onScroll(100, 400, 40);
|
||||
expect(hook.startIndex()).toBe(0);
|
||||
});
|
||||
@@ -226,8 +226,8 @@ describe('useTableWindowing', () => {
|
||||
windowSize: 100,
|
||||
});
|
||||
|
||||
// scrollTop=6000, firstVisible = 150, start = 150-20 = 130
|
||||
// maxStart = 200-100 = 100 → clamped to 100
|
||||
// scrollTop=6000, firstVisible = 150 is outside [0, 100) → teleport
|
||||
// to 150-45 = 105, clamped to maxStart = 200-100 = 100
|
||||
hook.onScroll(6000, 400, 40);
|
||||
expect(hook.startIndex()).toBe(100);
|
||||
expect(hook.endIndex()).toBe(200);
|
||||
@@ -246,9 +246,9 @@ describe('useTableWindowing', () => {
|
||||
enabled: () => true,
|
||||
windowSize: 100,
|
||||
});
|
||||
// rowHeight=0 → safeRowHeight=40, same calc as normal
|
||||
// rowHeight=0 → safeRowHeight=40, same calc as normal (teleport to 55)
|
||||
hook.onScroll(4000, 400, 0);
|
||||
expect(hook.startIndex()).toBe(76);
|
||||
expect(hook.startIndex()).toBe(55);
|
||||
});
|
||||
|
||||
it('handles negative scrollTop gracefully', () => {
|
||||
@@ -268,10 +268,9 @@ describe('useTableWindowing', () => {
|
||||
windowSize: 100,
|
||||
});
|
||||
// containerHeight=0 → safeContainerHeight = safeRowHeight = 40
|
||||
// rowsInView = ceil(40/40) = 1
|
||||
// overscan = min(20, max(0, 100-1)) = 20
|
||||
// rowsInView = ceil(40/40) = 1, target = floor(99/2) = 49
|
||||
hook.onScroll(800, 0, 40);
|
||||
// firstVisible = 800/40 = 20, start = 20-20 = 0
|
||||
// firstVisible = 20, leading deficit of 29 clamps at start 0
|
||||
expect(hook.startIndex()).toBe(0);
|
||||
});
|
||||
|
||||
@@ -282,8 +281,8 @@ describe('useTableWindowing', () => {
|
||||
windowSize: 100,
|
||||
});
|
||||
hook.onScroll(4000, 400, -10);
|
||||
// safeRowHeight = 40 (negative not > 0)
|
||||
expect(hook.startIndex()).toBe(76);
|
||||
// safeRowHeight = 40 (negative not > 0), teleport to 55
|
||||
expect(hook.startIndex()).toBe(55);
|
||||
});
|
||||
|
||||
it('handles large scrollTop values without error', () => {
|
||||
@@ -606,14 +605,14 @@ describe('useTableWindowing', () => {
|
||||
expect(result.startIndex()).toBe(0);
|
||||
});
|
||||
|
||||
it('overscan becomes 0 when rowsInView >= windowSize', () => {
|
||||
it('pins the window to the viewport start when rowsInView >= windowSize', () => {
|
||||
const hook = setup({
|
||||
totalCount: () => 1000,
|
||||
enabled: () => true,
|
||||
windowSize: 5,
|
||||
});
|
||||
// containerHeight=400, rowHeight=40 → rowsInView = ceil(400/40) = 10
|
||||
// overscan = min(20, max(0, 5 - 10)) = min(20, 0) = 0
|
||||
// containerHeight=400, rowHeight=40 → rowsInView = 10 > windowSize 5,
|
||||
// so buffer/target are 0 and the teleport path lands on firstVisible.
|
||||
// firstVisible = floor(2000/40) = 50, start = 50 - 0 = 50
|
||||
hook.onScroll(2000, 400, 40);
|
||||
expect(hook.startIndex()).toBe(50);
|
||||
|
||||
@@ -34,8 +34,11 @@ const DEFAULT_WINDOW_SIZE = 140;
|
||||
// row budget so consumers cannot accidentally leave a 200-500 row gap between
|
||||
// "small table" and "large estate" behaviour.
|
||||
const DEFAULT_ENABLE_THRESHOLD = DEFAULT_WINDOW_SIZE;
|
||||
const DEFAULT_OVERSCAN_ROWS = 20;
|
||||
const DEFAULT_EDGE_RUNWAY_ROWS = 24;
|
||||
// A mutation frame costs a near-constant style/layout pass on the mounted
|
||||
// table whatever the shift size, so runway top-ups run in dead-band-sized
|
||||
// batches: most scroll frames stay compositor-only, and sub-row scroll jitter
|
||||
// cannot thrash one-row shifts in alternating directions.
|
||||
const TOP_UP_DEADBAND_ROWS = 8;
|
||||
|
||||
export const useTableWindowing = (options: UseTableWindowingOptions): UseTableWindowingResult => {
|
||||
const [windowStart, setWindowStart] = createSignal(0);
|
||||
@@ -51,8 +54,6 @@ export const useTableWindowing = (options: UseTableWindowingOptions): UseTableWi
|
||||
return enabled && total > 0;
|
||||
});
|
||||
|
||||
let lastFirstVisibleRow = 0;
|
||||
|
||||
const maxStart = createMemo(() => Math.max(0, options.totalCount() - normalizedWindowSize()));
|
||||
|
||||
const startIndex = createMemo(() => {
|
||||
@@ -87,33 +88,28 @@ export const useTableWindowing = (options: UseTableWindowingOptions): UseTableWi
|
||||
const firstVisibleRow = Math.floor(Math.max(0, scrollTop) / safeRowHeight);
|
||||
const visibleEnd = Math.min(options.totalCount(), firstVisibleRow + rowsInView);
|
||||
const availableBuffer = Math.max(0, normalizedWindowSize() - rowsInView);
|
||||
const edgeRunway = Math.min(
|
||||
Math.floor(availableBuffer / 2),
|
||||
Math.max(DEFAULT_EDGE_RUNWAY_ROWS, rowsInView),
|
||||
);
|
||||
const direction = Math.sign(firstVisibleRow - lastFirstVisibleRow);
|
||||
lastFirstVisibleRow = firstVisibleRow;
|
||||
const targetRunway = Math.floor(availableBuffer / 2);
|
||||
|
||||
const leadingRunway = firstVisibleRow - startIndex();
|
||||
const trailingRunway = endIndex() - visibleEnd;
|
||||
const viewportIsMounted = leadingRunway >= 0 && trailingRunway >= 0;
|
||||
if (
|
||||
viewportIsMounted &&
|
||||
((direction > 0 && trailingRunway > edgeRunway) ||
|
||||
(direction < 0 && leadingRunway > edgeRunway) ||
|
||||
(direction === 0 && leadingRunway >= edgeRunway && trailingRunway >= edgeRunway))
|
||||
) {
|
||||
if (leadingRunway < 0 || trailingRunway < 0) {
|
||||
// Teleport (scrollbar drag, deep jump): the viewport left the mounted
|
||||
// window entirely, so re-center the window on it.
|
||||
setClampedStart(firstVisibleRow - targetRunway);
|
||||
return;
|
||||
}
|
||||
|
||||
const directionalRunway = Math.max(edgeRunway, availableBuffer - edgeRunway);
|
||||
const rowsBeforeViewport =
|
||||
direction < 0
|
||||
? directionalRunway
|
||||
: direction > 0
|
||||
? edgeRunway
|
||||
: Math.min(DEFAULT_OVERSCAN_ROWS, availableBuffer);
|
||||
setClampedStart(firstVisibleRow - rowsBeforeViewport);
|
||||
// Top up only the depleted side, and only once its deficit clears the
|
||||
// dead-band, restoring that side to the full target. Leading and trailing
|
||||
// runway always sum to the spare buffer, so at most one side is below
|
||||
// target. Steady scrolling therefore pays one bounded mutation frame per
|
||||
// dead-band of travel instead of a slice update on every scroll event.
|
||||
const topUpThreshold = Math.min(TOP_UP_DEADBAND_ROWS, Math.max(1, Math.ceil(targetRunway / 2)));
|
||||
if (targetRunway - trailingRunway >= topUpThreshold) {
|
||||
setClampedStart(startIndex() + (targetRunway - trailingRunway));
|
||||
} else if (targetRunway - leadingRunway >= topUpThreshold) {
|
||||
setClampedStart(startIndex() - (targetRunway - leadingRunway));
|
||||
}
|
||||
};
|
||||
|
||||
const isVisible = (index: number) => {
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@ import { createEffect, createSignal, onCleanup, untrack, type Accessor } from 's
|
||||
import {
|
||||
bindWindowedPageScrollEvents,
|
||||
findWindowedPageScrollContainer,
|
||||
isWindowedSurfaceHidden,
|
||||
wheelDeltaInPixels,
|
||||
} from '@/components/shared/windowedPageScroll';
|
||||
import { useTableWindowing } from './useTableWindowing';
|
||||
@@ -21,7 +22,7 @@ export function useUnifiedResourceTableViewportSync(
|
||||
const syncHostWindowToViewport = (projectedScrollDelta = 0) => {
|
||||
if (!hostWindowing.isWindowed() || typeof window === 'undefined') return;
|
||||
const body = hostBodyRef();
|
||||
if (!body) return;
|
||||
if (!body || isWindowedSurfaceHidden(body)) return;
|
||||
const rect = body.getBoundingClientRect();
|
||||
const scrollContainer = findWindowedPageScrollContainer(body);
|
||||
if (scrollContainer) {
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { batch, createEffect, createMemo, createSignal, onCleanup, type Accessor } from 'solid-js';
|
||||
import {
|
||||
batch,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
untrack,
|
||||
type Accessor,
|
||||
} from 'solid-js';
|
||||
import { createStore, reconcile, type SetStoreFunction } from 'solid-js/store';
|
||||
|
||||
import { useTableWindowing } from '@/components/Infrastructure/useTableWindowing';
|
||||
import {
|
||||
bindWindowedPageScrollEvents,
|
||||
findWindowedPageScrollContainer,
|
||||
isWindowedSurfaceHidden,
|
||||
wheelDeltaInPixels,
|
||||
} from '@/components/shared/windowedPageScroll';
|
||||
import type { StorageRecord } from '@/features/storageBackups/models';
|
||||
@@ -120,7 +129,7 @@ export const useStoragePoolsTableWindowing = (options: UseStoragePoolsTableWindo
|
||||
const syncWindowToViewport = (projectedScrollDelta = 0, measureRows = false) => {
|
||||
if (typeof window === 'undefined' || !windowing.isWindowed()) return;
|
||||
const body = bodyRef();
|
||||
if (!body) return;
|
||||
if (!body || isWindowedSurfaceHidden(body)) return;
|
||||
if (measureRows) {
|
||||
const measuredHeight = body
|
||||
.querySelector<HTMLTableRowElement>(':scope > tr[data-summary-series-id]')
|
||||
@@ -164,7 +173,10 @@ export const useStoragePoolsTableWindowing = (options: UseStoragePoolsTableWindo
|
||||
};
|
||||
const handleResize = () => syncWindowToViewport(0, true);
|
||||
|
||||
handleResize();
|
||||
// The initial measurement reads windowing signals the pass itself moves.
|
||||
// Keep those reads outside this setup effect's dependency graph so runway
|
||||
// top-ups during scrolling cannot re-run measurement and listener binding.
|
||||
untrack(handleResize);
|
||||
onCleanup(
|
||||
bindWindowedPageScrollEvents({
|
||||
scrollTarget,
|
||||
|
||||
@@ -132,8 +132,10 @@ describe('MetricBar', () => {
|
||||
});
|
||||
|
||||
it('updates sublabel visibility on resize', async () => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 20 });
|
||||
render(() => <MetricBar value={50} label="Label" sublabel="Sub" />);
|
||||
// Width flows only from the ResizeObserver, so the initial narrow
|
||||
// measurement arrives through its callback.
|
||||
resizeCallback?.([{ contentRect: { width: 20 } } as ResizeObserverEntry], {} as ResizeObserver);
|
||||
expect(screen.queryByText('(Sub)')).not.toBeInTheDocument();
|
||||
|
||||
resizeCallback?.(
|
||||
|
||||
@@ -227,6 +227,11 @@ describe('StackedMemoryBar', () => {
|
||||
|
||||
it('shows sublabel when space permits and total > 0', () => {
|
||||
render(() => <StackedMemoryBar used={4 * 1024 ** 3} total={8 * 1024 ** 3} />);
|
||||
// Width flows only from the ResizeObserver's initial delivery.
|
||||
resizeCallback?.(
|
||||
[{ contentRect: { width: 200 } } as ResizeObserverEntry],
|
||||
{} as ResizeObserver,
|
||||
);
|
||||
// Should show sublabel in parentheses with bytes format
|
||||
const sublabel = screen.getByText(/4\.00 GB/);
|
||||
expect(sublabel).toBeInTheDocument();
|
||||
@@ -247,8 +252,10 @@ describe('StackedMemoryBar', () => {
|
||||
});
|
||||
|
||||
it('updates sublabel visibility on resize', async () => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 20 });
|
||||
render(() => <StackedMemoryBar used={4 * 1024 ** 3} total={8 * 1024 ** 3} />);
|
||||
// Width flows only from the ResizeObserver, so the initial narrow
|
||||
// measurement arrives through its callback.
|
||||
resizeCallback?.([{ contentRect: { width: 20 } } as ResizeObserverEntry], {} as ResizeObserver);
|
||||
expect(screen.queryByText(/4\.00 GB/)).not.toBeInTheDocument();
|
||||
|
||||
// Simulate resize to wider container
|
||||
|
||||
@@ -379,8 +379,12 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
|
||||
{(groupKey) => {
|
||||
const groupGuests = () => props.windowedGroupedGuests()[groupKey] || [];
|
||||
const fullGroupGuests = () => props.groupedGuests()[groupKey] || [];
|
||||
// Build the id lookup from the full group, not the windowed slice:
|
||||
// it only rebuilds when the group's data changes, so a runway top-up
|
||||
// leaves every mounted row's guest() dependency untouched instead of
|
||||
// revalidating ~140 rows' memo chains per scroll event.
|
||||
const groupGuestById = createMemo(
|
||||
() => new Map(groupGuests().map((guest) => [getCanonicalWorkloadId(guest), guest])),
|
||||
() => new Map(fullGroupGuests().map((guest) => [getCanonicalWorkloadId(guest), guest])),
|
||||
);
|
||||
const groupGuestIds = createMemo(() => groupGuests().map(getCanonicalWorkloadId));
|
||||
const node = () => props.nodeByInstance()[groupKey];
|
||||
|
||||
+4
-4
@@ -919,8 +919,8 @@ describe('Workloads performance contract', () => {
|
||||
expect(workloadsWorkloadDerivedStateSource).toContain('guestIndexAtVirtualOffset');
|
||||
expect(workloadsWorkloadDerivedStateSource).toContain('WORKLOADS_TABLE_DIVIDER_HEIGHT');
|
||||
expect(groupedTableWindowingSource).toContain('rowIndexAtOffset');
|
||||
expect(groupedTableWindowingSource).toContain('DEFAULT_EDGE_RUNWAY_ROWS');
|
||||
expect(groupedTableWindowingSource).toContain('viewportIsMounted');
|
||||
expect(groupedTableWindowingSource).toContain('targetRunway');
|
||||
expect(groupedTableWindowingSource).toContain('trailingRunway');
|
||||
expect(workloadPanelSource).toContain('groupGuests()[0] === fullGroupGuests()[0]');
|
||||
expect(workloadPanelSource).toContain('class="h-0 !p-0 !border-0 leading-[0]"');
|
||||
expect(workloadPanelSource).toContain('<For each={props.visibleGroupKeys()}');
|
||||
@@ -1330,13 +1330,13 @@ describe('Workloads performance contract', () => {
|
||||
expect(workloadSelectionModelSource).toContain('workloadsHasHoveredWorkload');
|
||||
expect(groupedTableWindowingSource).toContain('DEFAULT_WINDOW_SIZE');
|
||||
expect(groupedTableWindowingSource).toContain('DEFAULT_ENABLE_THRESHOLD');
|
||||
expect(groupedTableWindowingSource).toContain('DEFAULT_OVERSCAN_ROWS');
|
||||
expect(groupedTableWindowingSource).toContain('targetRunway');
|
||||
expect(groupedTableWindowingSource).toContain('getVisibleSlice');
|
||||
expect(groupedTableWindowingSource).toContain('onScroll');
|
||||
expect(groupedTableWindowingSource).toContain('revealIndex');
|
||||
expect(workloadsStateSource).not.toContain('const DEFAULT_WINDOW_SIZE =');
|
||||
expect(workloadsStateSource).not.toContain('const DEFAULT_ENABLE_THRESHOLD =');
|
||||
expect(workloadsStateSource).not.toContain('const DEFAULT_OVERSCAN_ROWS =');
|
||||
expect(workloadsStateSource).not.toContain('targetRunway');
|
||||
expect(workloadsSource).not.toContain('createMemo(() => getCanonicalWorkloadId(guest()))');
|
||||
expect(workloadPanelSource).toContain('groupGuests().map(getCanonicalWorkloadId)');
|
||||
expect(workloadPanelSource).toContain('groupGuestById().get(keyedGuestId)');
|
||||
|
||||
+42
-29
@@ -176,51 +176,60 @@ describe('useGroupedTableWindowing', () => {
|
||||
// onScroll
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
describe('onScroll', () => {
|
||||
it('keeps the current window while the viewport has a safe forward runway', () => {
|
||||
it('keeps the current window while both runways meet the target', () => {
|
||||
const hook = setup({
|
||||
totalRowCount: () => 1000,
|
||||
enabled: () => true,
|
||||
windowSize: 100,
|
||||
});
|
||||
|
||||
// Scroll down: scrollTop=2000, containerHeight=400, rowHeight=40
|
||||
// firstVisibleRow = floor(2000/40) = 50
|
||||
// The visible range [50, 60) remains inside [0, 100) with 40 rows
|
||||
// ahead, so the window stays mounted instead of churning every row.
|
||||
hook.onScroll(2000, 400, 40);
|
||||
// scrollTop=1800, containerHeight=400, rowHeight=40
|
||||
// firstVisibleRow = 45, rowsInView = 10, targetRunway = floor(90/2) = 45
|
||||
// leading = 45, trailing = 100 - 55 = 45 → both at target, no churn.
|
||||
hook.onScroll(1800, 400, 40);
|
||||
expect(hook.startIndex()).toBe(0);
|
||||
expect(hook.endIndex()).toBe(100);
|
||||
});
|
||||
|
||||
it('moves once the viewport reaches the forward runway and then holds steady', () => {
|
||||
it('tops up the trailing runway by the full deficit once it clears the dead-band', () => {
|
||||
const hook = setup({
|
||||
totalRowCount: () => 1000,
|
||||
enabled: () => true,
|
||||
windowSize: 100,
|
||||
});
|
||||
|
||||
// firstVisible = 80, trailing = 100 - 90 = 10, target = 45 → shift 35.
|
||||
hook.onScroll(3200, 400, 40);
|
||||
expect(hook.startIndex()).toBe(56);
|
||||
expect(hook.endIndex()).toBe(156);
|
||||
expect(hook.startIndex()).toBe(35);
|
||||
expect(hook.endIndex()).toBe(135);
|
||||
|
||||
// firstVisible = 90, trailing = 135 - 100 = 35 → the 10-row deficit
|
||||
// clears the 8-row dead-band, so the window shifts by the full deficit.
|
||||
hook.onScroll(3600, 400, 40);
|
||||
expect(hook.startIndex()).toBe(56);
|
||||
expect(hook.endIndex()).toBe(156);
|
||||
expect(hook.startIndex()).toBe(45);
|
||||
expect(hook.endIndex()).toBe(145);
|
||||
|
||||
// Same position again: both runways sit at target → no further movement.
|
||||
hook.onScroll(3600, 400, 40);
|
||||
expect(hook.startIndex()).toBe(45);
|
||||
expect(hook.endIndex()).toBe(145);
|
||||
});
|
||||
|
||||
it('keeps most spare rows behind the viewport while scrolling upward', () => {
|
||||
it('re-centers on a teleport and tops up the leading runway while scrolling upward', () => {
|
||||
const hook = setup({
|
||||
totalRowCount: () => 1000,
|
||||
enabled: () => true,
|
||||
windowSize: 100,
|
||||
});
|
||||
|
||||
// firstVisible = 200 is fully outside [0, 100) → teleport to 200 - 45.
|
||||
hook.onScroll(8000, 400, 40);
|
||||
expect(hook.startIndex()).toBe(176);
|
||||
expect(hook.startIndex()).toBe(155);
|
||||
|
||||
// firstVisible = 170, leading = 15, target = 45 → shift up by 30.
|
||||
hook.onScroll(6800, 400, 40);
|
||||
expect(hook.startIndex()).toBe(104);
|
||||
expect(hook.endIndex()).toBe(204);
|
||||
expect(hook.startIndex()).toBe(125);
|
||||
expect(hook.endIndex()).toBe(225);
|
||||
});
|
||||
|
||||
it('clamps start to 0 when scrolled near top', () => {
|
||||
@@ -230,7 +239,7 @@ describe('useGroupedTableWindowing', () => {
|
||||
windowSize: 100,
|
||||
});
|
||||
|
||||
// scrollTop=100 → firstVisible = 2, start = 2-20 = -18 → clamped to 0
|
||||
// scrollTop=100 → firstVisible = 2, leading deficit clamps at start 0
|
||||
hook.onScroll(100, 400, 40);
|
||||
expect(hook.startIndex()).toBe(0);
|
||||
});
|
||||
@@ -242,8 +251,8 @@ describe('useGroupedTableWindowing', () => {
|
||||
windowSize: 100,
|
||||
});
|
||||
|
||||
// scrollTop = 6000, firstVisible = 150, start = 150-20 = 130
|
||||
// maxStart = 200-100 = 100 → clamped to 100
|
||||
// scrollTop = 6000, firstVisible = 150 is outside [0, 100) → teleport
|
||||
// to 150-45 = 105, clamped to maxStart = 200-100 = 100
|
||||
hook.onScroll(6000, 400, 40);
|
||||
expect(hook.startIndex()).toBe(100);
|
||||
expect(hook.endIndex()).toBe(200);
|
||||
@@ -262,7 +271,8 @@ describe('useGroupedTableWindowing', () => {
|
||||
enabled: () => true,
|
||||
windowSize: 100,
|
||||
});
|
||||
// rowHeight=0 → safeRowHeight=40, same calc as normal
|
||||
// rowHeight=0 → safeRowHeight=40, same calc as normal:
|
||||
// firstVisible = 50, trailing deficit is 5, inside the dead-band → hold.
|
||||
hook.onScroll(2000, 400, 0);
|
||||
expect(hook.startIndex()).toBe(0);
|
||||
});
|
||||
@@ -284,10 +294,9 @@ describe('useGroupedTableWindowing', () => {
|
||||
windowSize: 100,
|
||||
});
|
||||
// containerHeight=0 → safeContainerHeight=40 (fallback to rowHeight)
|
||||
// rowsInView = ceil(40/40) = 1
|
||||
// overscan = min(20, max(0, 100-1)) = 20
|
||||
// rowsInView = ceil(40/40) = 1, target = floor(99/2) = 49
|
||||
hook.onScroll(800, 0, 40);
|
||||
// firstVisible = 800/40 = 20, start = 20-20 = 0
|
||||
// firstVisible = 20, leading deficit of 29 clamps at start 0
|
||||
expect(hook.startIndex()).toBe(0);
|
||||
});
|
||||
|
||||
@@ -303,8 +312,10 @@ describe('useGroupedTableWindowing', () => {
|
||||
hook.onScroll(3200, 400, 32);
|
||||
|
||||
expect(rowIndexAtOffset).toHaveBeenCalledWith(3200, 32);
|
||||
expect(hook.startIndex()).toBe(56);
|
||||
expect(hook.endIndex()).toBe(156);
|
||||
// firstVisible = 80 (resolver), rowsInView = 13, target = floor(87/2) = 43
|
||||
// trailing = 100 - 93 = 7 → shift the 36-row deficit.
|
||||
expect(hook.startIndex()).toBe(36);
|
||||
expect(hook.endIndex()).toBe(136);
|
||||
});
|
||||
|
||||
it('falls back to fixed row math when the grouped offset resolver is non-finite', () => {
|
||||
@@ -315,6 +326,8 @@ describe('useGroupedTableWindowing', () => {
|
||||
rowIndexAtOffset: () => Number.NaN,
|
||||
});
|
||||
|
||||
// firstVisible = 50 via fixed math; the 5-row trailing deficit sits
|
||||
// inside the dead-band, so the window holds.
|
||||
hook.onScroll(2000, 400, 40);
|
||||
|
||||
expect(hook.startIndex()).toBe(0);
|
||||
@@ -502,9 +515,9 @@ describe('useGroupedTableWindowing', () => {
|
||||
enabled: () => true,
|
||||
windowSize: 100,
|
||||
});
|
||||
// Scroll to [400, 500). Group at index 0..50 is fully before the window.
|
||||
// Teleport far down. Group at index 0..50 is fully before the window.
|
||||
hook.onScroll(16800, 400, 40);
|
||||
// firstVisible = 420, overscan=20, start=400
|
||||
// firstVisible = 420 → teleport to 420-45 = 375, window [375, 475)
|
||||
const slice = hook.getVisibleSlice('group-early', guests, 0);
|
||||
expect(slice).toEqual([]);
|
||||
});
|
||||
@@ -701,14 +714,14 @@ describe('useGroupedTableWindowing', () => {
|
||||
expect(result.endIndex()).toBe(100);
|
||||
});
|
||||
|
||||
it('overscan becomes 0 when rowsInView >= windowSize', () => {
|
||||
it('pins the window to the viewport start when rowsInView >= windowSize', () => {
|
||||
const hook = setup({
|
||||
totalRowCount: () => 1000,
|
||||
enabled: () => true,
|
||||
windowSize: 5,
|
||||
});
|
||||
// containerHeight=400, rowHeight=40 → rowsInView = ceil(400/40) = 10
|
||||
// overscan = min(20, max(0, 5 - 10)) = min(20, 0) = 0
|
||||
// containerHeight=400, rowHeight=40 → rowsInView = 10 > windowSize 5,
|
||||
// so buffer/target are 0 and the teleport path lands on firstVisible.
|
||||
// firstVisible = floor(2000/40) = 50, start = 50 - 0 = 50
|
||||
hook.onScroll(2000, 400, 40);
|
||||
expect(hook.startIndex()).toBe(50);
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('useWorkloadViewportSync', () => {
|
||||
passive: true,
|
||||
});
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('wheel', expect.any(Function), {
|
||||
passive: false,
|
||||
passive: true,
|
||||
});
|
||||
expect(addEventListenerSpy.mock.calls.some(([type]) => String(type).startsWith('touch'))).toBe(
|
||||
false,
|
||||
|
||||
@@ -41,14 +41,16 @@ const DEFAULT_WINDOW_SIZE = 140;
|
||||
// Keep even medium estates virtualized. On mobile, mounting a few hundred
|
||||
// metric-heavy rows is already enough to cause long layout and paint tasks.
|
||||
const DEFAULT_ENABLE_THRESHOLD = DEFAULT_WINDOW_SIZE;
|
||||
const DEFAULT_OVERSCAN_ROWS = 20;
|
||||
const DEFAULT_EDGE_RUNWAY_ROWS = 24;
|
||||
// A mutation frame costs a near-constant style/layout pass on the mounted
|
||||
// table whatever the shift size, so runway top-ups run in dead-band-sized
|
||||
// batches: most scroll frames stay compositor-only, and sub-row scroll jitter
|
||||
// cannot thrash one-row shifts in alternating directions.
|
||||
const TOP_UP_DEADBAND_ROWS = 8;
|
||||
|
||||
export const useGroupedTableWindowing = (
|
||||
options: UseGroupedTableWindowingOptions,
|
||||
): UseGroupedTableWindowingResult => {
|
||||
const [windowStart, setWindowStart] = createSignal(0);
|
||||
let lastFirstVisibleRow = 0;
|
||||
|
||||
const normalizedWindowSize = createMemo(() =>
|
||||
Math.max(1, Math.floor(options.windowSize ?? DEFAULT_WINDOW_SIZE)),
|
||||
@@ -104,36 +106,28 @@ export const useGroupedTableWindowing = (
|
||||
: Math.floor(Math.max(0, scrollTop) / safeRowHeight);
|
||||
const visibleEnd = Math.min(options.totalRowCount(), firstVisibleRow + rowsInView);
|
||||
const availableBuffer = Math.max(0, normalizedWindowSize() - rowsInView);
|
||||
const edgeRunway = Math.min(
|
||||
Math.floor(availableBuffer / 2),
|
||||
Math.max(DEFAULT_EDGE_RUNWAY_ROWS, rowsInView),
|
||||
);
|
||||
const direction = Math.sign(firstVisibleRow - lastFirstVisibleRow);
|
||||
lastFirstVisibleRow = firstVisibleRow;
|
||||
const targetRunway = Math.floor(availableBuffer / 2);
|
||||
|
||||
const leadingRunway = firstVisibleRow - startIndex();
|
||||
const trailingRunway = endIndex() - visibleEnd;
|
||||
const viewportIsMounted = leadingRunway >= 0 && trailingRunway >= 0;
|
||||
if (
|
||||
viewportIsMounted &&
|
||||
((direction > 0 && trailingRunway > edgeRunway) ||
|
||||
(direction < 0 && leadingRunway > edgeRunway) ||
|
||||
(direction === 0 && leadingRunway >= edgeRunway && trailingRunway >= edgeRunway))
|
||||
) {
|
||||
if (leadingRunway < 0 || trailingRunway < 0) {
|
||||
// Teleport (scrollbar drag, deep jump): the viewport left the mounted
|
||||
// window entirely, so re-center the window on it.
|
||||
setClampedStart(firstVisibleRow - targetRunway);
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep most of the spare window in the active scroll direction. Unlike
|
||||
// tracking every visible row, this runway only moves when the viewport
|
||||
// approaches an edge, avoiding a full reactive slice update per wheel tick.
|
||||
const directionalRunway = Math.max(edgeRunway, availableBuffer - edgeRunway);
|
||||
const rowsBeforeViewport =
|
||||
direction < 0
|
||||
? directionalRunway
|
||||
: direction > 0
|
||||
? edgeRunway
|
||||
: Math.min(DEFAULT_OVERSCAN_ROWS, availableBuffer);
|
||||
setClampedStart(firstVisibleRow - rowsBeforeViewport);
|
||||
// Top up only the depleted side, and only once its deficit clears the
|
||||
// dead-band, restoring that side to the full target. Leading and trailing
|
||||
// runway always sum to the spare buffer, so at most one side is below
|
||||
// target. Steady scrolling therefore pays one bounded mutation frame per
|
||||
// dead-band of travel instead of a slice update on every scroll event.
|
||||
const topUpThreshold = Math.min(TOP_UP_DEADBAND_ROWS, Math.max(1, Math.ceil(targetRunway / 2)));
|
||||
if (targetRunway - trailingRunway >= topUpThreshold) {
|
||||
setClampedStart(startIndex() + (targetRunway - trailingRunway));
|
||||
} else if (targetRunway - leadingRunway >= topUpThreshold) {
|
||||
setClampedStart(startIndex() - (targetRunway - leadingRunway));
|
||||
}
|
||||
};
|
||||
|
||||
const getVisibleSlice = (
|
||||
|
||||
@@ -13,8 +13,10 @@ export function useMetricBarState(props: MetricBarProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setContainerWidth(containerRef.offsetWidth);
|
||||
|
||||
// Width comes only from the ResizeObserver: its initial delivery fires in
|
||||
// the same frame as observe(), after layout and before paint. A sync
|
||||
// offsetWidth read here would force a full table reflow per mounted bar,
|
||||
// which the virtualized tables pay on every runway top-up while scrolling.
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
|
||||
@@ -15,8 +15,10 @@ export function useStackedDiskBarState(props: StackedDiskBarProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setContainerWidth(containerRef.offsetWidth);
|
||||
|
||||
// Width comes only from the ResizeObserver: its initial delivery fires in
|
||||
// the same frame as observe(), after layout and before paint. A sync
|
||||
// offsetWidth read here would force a full table reflow per mounted bar,
|
||||
// which the virtualized tables pay on every runway top-up while scrolling.
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
|
||||
@@ -18,8 +18,10 @@ export function useStackedMemoryBarState(props: StackedMemoryBarProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setContainerWidth(containerRef.offsetWidth);
|
||||
|
||||
// Width comes only from the ResizeObserver: its initial delivery fires in
|
||||
// the same frame as observe(), after layout and before paint. A sync
|
||||
// offsetWidth read here would force a full table reflow per mounted bar,
|
||||
// which the virtualized tables pay on every runway top-up while scrolling.
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createEffect, createSignal, onCleanup, type Accessor } from 'solid-js';
|
||||
import { createEffect, createSignal, onCleanup, untrack, type Accessor } from 'solid-js';
|
||||
|
||||
import { findInlineDetailElement } from '@/components/shared/contextualFocus';
|
||||
import {
|
||||
bindWindowedPageScrollEvents,
|
||||
findWindowedPageScrollContainer,
|
||||
isWindowedSurfaceHidden,
|
||||
wheelDeltaInPixels,
|
||||
} from '@/components/shared/windowedPageScroll';
|
||||
|
||||
@@ -35,7 +36,7 @@ export function useWorkloadViewportSync(options: WorkloadsWorkloadViewportSyncOp
|
||||
const syncGuestWindowToViewport = (measureRows = false, projectedScrollDelta = 0) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const body = options.tableBodyRef();
|
||||
if (!body) return;
|
||||
if (!body || isWindowedSurfaceHidden(body)) return;
|
||||
const selectedGuestId = options.selectedGuestId?.();
|
||||
if (selectedGuestId) {
|
||||
reportExpandedDetailHeight(body, selectedGuestId);
|
||||
@@ -129,7 +130,11 @@ export function useWorkloadViewportSync(options: WorkloadsWorkloadViewportSyncOp
|
||||
syncGuestWindowToViewport(true);
|
||||
};
|
||||
|
||||
handleViewportResize();
|
||||
// The initial measurement pass reads windowing signals (startIndex via
|
||||
// groupedWindowing.onScroll) that the pass itself moves. Tracking them
|
||||
// would re-run this effect — remeasuring rows with forced reflows and
|
||||
// rebinding listeners — on every runway top-up during scrolling.
|
||||
untrack(() => handleViewportResize());
|
||||
const scrollContainer = findWindowedPageScrollContainer(options.tableBodyRef()!);
|
||||
const scrollTarget = scrollContainer ?? window;
|
||||
onCleanup(
|
||||
|
||||
@@ -1399,7 +1399,7 @@ describe('shared primitive guardrails', () => {
|
||||
"addEventListener('scroll', options.onScroll, { passive: true })",
|
||||
);
|
||||
expect(windowedPageScrollSource).toContain(
|
||||
"addEventListener('wheel', options.onWheel, { passive: false })",
|
||||
"addEventListener('wheel', options.onWheel, { passive: true })",
|
||||
);
|
||||
expect(windowedPageScrollSource).not.toContain("addEventListener('touch");
|
||||
for (const source of [
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('windowedPageScroll', () => {
|
||||
});
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', onScroll, { passive: true });
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('wheel', onWheel, { passive: false });
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('wheel', onWheel, { passive: true });
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('resize', onResize);
|
||||
expect(windowedPageScrollSource).not.toContain("addEventListener('touch");
|
||||
|
||||
|
||||
@@ -29,6 +29,17 @@ export const findWindowedPageScrollContainer = (element: HTMLElement): HTMLEleme
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* True when a windowed surface sits in a hidden subtree (e.g. a prewarmed but
|
||||
* inactive platform tab). Hidden surfaces must skip viewport sync entirely:
|
||||
* their geometry reads would force the layout flush for mutations other
|
||||
* surfaces just made, billing an invisible table for the visible one's work.
|
||||
* checkVisibility reads computed style without forcing layout; browsers
|
||||
* without it simply keep syncing.
|
||||
*/
|
||||
export const isWindowedSurfaceHidden = (element: HTMLElement): boolean =>
|
||||
typeof element.checkVisibility === 'function' && !element.checkVisibility();
|
||||
|
||||
export const wheelDeltaInPixels = (event: WheelEvent, viewportHeight: number): number => {
|
||||
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) return event.deltaY * WHEEL_LINE_HEIGHT_PX;
|
||||
if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) return event.deltaY * viewportHeight;
|
||||
@@ -38,6 +49,11 @@ export const wheelDeltaInPixels = (event: WheelEvent, viewportHeight: number): n
|
||||
/**
|
||||
* Canonical input lifecycle for virtualized content inside the native page scroller.
|
||||
*
|
||||
* Every listener is passive: the compositor must never wait on windowing work,
|
||||
* so a busy main thread degrades to a briefly delayed runway top-up instead of
|
||||
* a frozen wheel. The wheel listener exists only to prewarm the runway with the
|
||||
* projected delta in the same tick the gesture arrives.
|
||||
*
|
||||
* Touch intentionally has no listener here. The compositor moves the page first,
|
||||
* then the passive scroll event advances the keyed-row runway without allowing
|
||||
* scroll anchoring to consume the operator's gesture.
|
||||
@@ -46,7 +62,7 @@ export const bindWindowedPageScrollEvents = (
|
||||
options: WindowedPageScrollEventsOptions,
|
||||
): (() => void) => {
|
||||
options.scrollTarget.addEventListener('scroll', options.onScroll, { passive: true });
|
||||
options.scrollTarget.addEventListener('wheel', options.onWheel, { passive: false });
|
||||
options.scrollTarget.addEventListener('wheel', options.onWheel, { passive: true });
|
||||
window.addEventListener('resize', options.onResize);
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from 'solid-js';
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
untrack,
|
||||
type Accessor,
|
||||
} from 'solid-js';
|
||||
|
||||
import { useTableWindowing } from '@/components/Infrastructure/useTableWindowing';
|
||||
import {
|
||||
bindWindowedPageScrollEvents,
|
||||
findWindowedPageScrollContainer,
|
||||
isWindowedSurfaceHidden,
|
||||
wheelDeltaInPixels,
|
||||
} from '@/components/shared/windowedPageScroll';
|
||||
|
||||
@@ -57,7 +65,7 @@ export function usePlatformWindowedItems<Item>(options: PlatformWindowedItemsOpt
|
||||
const syncWindowToViewport = (projectedScrollDelta = 0, measureItems = false) => {
|
||||
if (typeof window === 'undefined' || !windowing.isWindowed()) return;
|
||||
const anchor = anchorRef();
|
||||
if (!anchor) return;
|
||||
if (!anchor || isWindowedSurfaceHidden(anchor)) return;
|
||||
|
||||
if (measureItems) {
|
||||
const measuredHeight = anchor.nextElementSibling?.getBoundingClientRect().height;
|
||||
@@ -100,7 +108,10 @@ export function usePlatformWindowedItems<Item>(options: PlatformWindowedItemsOpt
|
||||
};
|
||||
const handleResize = () => syncWindowToViewport(0, true);
|
||||
|
||||
handleResize();
|
||||
// The initial measurement reads windowing signals the pass itself moves.
|
||||
// Keep those reads outside this setup effect's dependency graph so runway
|
||||
// top-ups during scrolling cannot re-run measurement and listener binding.
|
||||
untrack(handleResize);
|
||||
onCleanup(
|
||||
bindWindowedPageScrollEvents({
|
||||
scrollTarget,
|
||||
|
||||
Reference in New Issue
Block a user