diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 32fce6c09..2e23bbaa4 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -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 diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 202666efa..96f8ea934 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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" ] } diff --git a/frontend-modern/src/components/Infrastructure/__tests__/useTableWindowing.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/useTableWindowing.test.ts index a5df3bf8c..92ece620d 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/useTableWindowing.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/useTableWindowing.test.ts @@ -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); diff --git a/frontend-modern/src/components/Infrastructure/useTableWindowing.ts b/frontend-modern/src/components/Infrastructure/useTableWindowing.ts index e39a138d9..28ba4d5e3 100644 --- a/frontend-modern/src/components/Infrastructure/useTableWindowing.ts +++ b/frontend-modern/src/components/Infrastructure/useTableWindowing.ts @@ -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) => { diff --git a/frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts b/frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts index 547445b94..70c7ffe00 100644 --- a/frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts +++ b/frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts @@ -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) { diff --git a/frontend-modern/src/components/Storage/useStoragePoolsTableWindowing.ts b/frontend-modern/src/components/Storage/useStoragePoolsTableWindowing.ts index 4fb4578de..268499f37 100644 --- a/frontend-modern/src/components/Storage/useStoragePoolsTableWindowing.ts +++ b/frontend-modern/src/components/Storage/useStoragePoolsTableWindowing.ts @@ -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(':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, diff --git a/frontend-modern/src/components/Workloads/MetricBar.test.tsx b/frontend-modern/src/components/Workloads/MetricBar.test.tsx index 0e6ab6d05..269ec0a04 100644 --- a/frontend-modern/src/components/Workloads/MetricBar.test.tsx +++ b/frontend-modern/src/components/Workloads/MetricBar.test.tsx @@ -132,8 +132,10 @@ describe('MetricBar', () => { }); it('updates sublabel visibility on resize', async () => { - Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 20 }); render(() => ); + // 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?.( diff --git a/frontend-modern/src/components/Workloads/StackedMemoryBar.test.tsx b/frontend-modern/src/components/Workloads/StackedMemoryBar.test.tsx index 834514257..456c35442 100644 --- a/frontend-modern/src/components/Workloads/StackedMemoryBar.test.tsx +++ b/frontend-modern/src/components/Workloads/StackedMemoryBar.test.tsx @@ -227,6 +227,11 @@ describe('StackedMemoryBar', () => { it('shows sublabel when space permits and total > 0', () => { render(() => ); + // 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(() => ); + // 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 diff --git a/frontend-modern/src/components/Workloads/WorkloadPanel.tsx b/frontend-modern/src/components/Workloads/WorkloadPanel.tsx index c7e63ba7b..f3e3e904f 100644 --- a/frontend-modern/src/components/Workloads/WorkloadPanel.tsx +++ b/frontend-modern/src/components/Workloads/WorkloadPanel.tsx @@ -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]; diff --git a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx index 941bc975e..d07bf9b43 100644 --- a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx @@ -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(' { 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)'); diff --git a/frontend-modern/src/components/Workloads/__tests__/useGroupedTableWindowing.test.ts b/frontend-modern/src/components/Workloads/__tests__/useGroupedTableWindowing.test.ts index e7a3b47dd..0ae211f05 100644 --- a/frontend-modern/src/components/Workloads/__tests__/useGroupedTableWindowing.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/useGroupedTableWindowing.test.ts @@ -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); diff --git a/frontend-modern/src/components/Workloads/__tests__/useWorkloadViewportSync.test.tsx b/frontend-modern/src/components/Workloads/__tests__/useWorkloadViewportSync.test.tsx index 2676c55ee..05415a298 100644 --- a/frontend-modern/src/components/Workloads/__tests__/useWorkloadViewportSync.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/useWorkloadViewportSync.test.tsx @@ -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, diff --git a/frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts b/frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts index fb84f2f31..a113d9c7b 100644 --- a/frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts +++ b/frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts @@ -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 = ( diff --git a/frontend-modern/src/components/Workloads/useMetricBarState.ts b/frontend-modern/src/components/Workloads/useMetricBarState.ts index 6348456b7..f5da360d3 100644 --- a/frontend-modern/src/components/Workloads/useMetricBarState.ts +++ b/frontend-modern/src/components/Workloads/useMetricBarState.ts @@ -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); diff --git a/frontend-modern/src/components/Workloads/useStackedDiskBarState.ts b/frontend-modern/src/components/Workloads/useStackedDiskBarState.ts index 18326305c..7a1341319 100644 --- a/frontend-modern/src/components/Workloads/useStackedDiskBarState.ts +++ b/frontend-modern/src/components/Workloads/useStackedDiskBarState.ts @@ -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); diff --git a/frontend-modern/src/components/Workloads/useStackedMemoryBarState.ts b/frontend-modern/src/components/Workloads/useStackedMemoryBarState.ts index 43d8b1e1e..398dcf562 100644 --- a/frontend-modern/src/components/Workloads/useStackedMemoryBarState.ts +++ b/frontend-modern/src/components/Workloads/useStackedMemoryBarState.ts @@ -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); diff --git a/frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts b/frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts index 7dd47487e..9831d5f43 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts @@ -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( diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index e14511523..cb4d5e0c7 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -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 [ diff --git a/frontend-modern/src/components/shared/windowedPageScroll.test.ts b/frontend-modern/src/components/shared/windowedPageScroll.test.ts index 4568f7045..1bf587787 100644 --- a/frontend-modern/src/components/shared/windowedPageScroll.test.ts +++ b/frontend-modern/src/components/shared/windowedPageScroll.test.ts @@ -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"); diff --git a/frontend-modern/src/components/shared/windowedPageScroll.ts b/frontend-modern/src/components/shared/windowedPageScroll.ts index 0294cf31f..628bde575 100644 --- a/frontend-modern/src/components/shared/windowedPageScroll.ts +++ b/frontend-modern/src/components/shared/windowedPageScroll.ts @@ -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 () => { diff --git a/frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts b/frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts index 1222edd6b..990595351 100644 --- a/frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts +++ b/frontend-modern/src/features/platformPage/usePlatformWindowedItems.ts @@ -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(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(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,