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:
rcourtman
2026-08-26 16:06:44 +01:00
parent 8d573cd0ac
commit c154fa7aed
21 changed files with 265 additions and 179 deletions
@@ -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
+45 -31
View File
@@ -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,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];
@@ -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)');
@@ -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,