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 0a665ac87..1f5e95f70 100644
--- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
+++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md
@@ -801,6 +801,15 @@ change may globally weaken the Task 03 lifecycle-state idempotency invariant.
already-spaced header. This keeps total table height stable while groups
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 viewport
+ sync may prewarm that runway from one passive wheel listener before the
+ corresponding scroll event. 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
diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json
index 156b2b482..6ca8379d7 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -1,17 +1,17 @@
{
"version": 1,
- "base_sha": "d8fe2a935875675b22a87c5471ffc923f7a0ab51",
- "verified_at": "2026-08-23T16:50:00Z",
+ "base_sha": "dc129cd3a403e9fd2f9177ddbfcf18e5042445e5",
+ "verified_at": "2026-08-23T16:03:35Z",
"result": "passed",
"changed_paths": [
- "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
- "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx",
- "frontend-modern/src/features/proxmox/proxmoxPageModel.ts"
+ "frontend-modern/src/components/Workloads/WorkloadPanel.tsx",
+ "frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts",
+ "frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts"
],
"content_sha256": {
- "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "5b2dce1ff4cae9624fa106ad9c01f29e0025b33e0def87cee30798e74fe31329",
- "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "27d2077c659d40e2c51472086e745311221d771129455a4bae2be816ab116dbe",
- "frontend-modern/src/features/proxmox/proxmoxPageModel.ts": "f80b8e01fd31e7f2de55cda5a2f5e58005068b07daa77c3356811ad7f5919051"
+ "frontend-modern/src/components/Workloads/WorkloadPanel.tsx": "ea56e3d8a4e8c63baf1f99e1dea82d8e6ea2dd801b8eafaf4cb849ebf8612bad",
+ "frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts": "76fd82b5a0baefdf09e37af1713681e4424c2762ece99652b2d998011a457ce8",
+ "frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts": "0f2315ddf875206f5de6a97fc9a600b5b61aaa2ae910cc4353c5eb78eb5bfd18"
},
"routes": [
"/proxmox/overview"
@@ -22,22 +22,23 @@
"height": 720
},
{
- "width": 390,
- "height": 844
+ "width": 375,
+ "height": 812
}
],
"states": [
- "Default desktop Proxmox overview renders the 50-node mock estate with 8 clusters and 2 standalone nodes",
- "Desktop search for pve31 renders one matching node as 1 of 50 and keeps the matching guest inventory visible",
- "Narrow search for pve31 renders one matching node with the native pve31 identifier beside its friendly name",
- "Narrow filtered layout has no horizontal overflow and retains the responsive node and workload columns",
- "The browser verification run reported no console errors"
+ "The large Proxmox mock estate renders 929 guests while retaining the 140-row mounted DOM budget",
+ "After an 8000-pixel desktop wheel burst, 25 workload rows cover the full 1280x720 viewport from -6 to 723 pixels without exposing a spacer",
+ "After a 2800-pixel phone wheel burst, 23 workload rows cover the full 375x812 viewport from -21 to 821 pixels without exposing a spacer",
+ "Desktop scroll height remains 29470 pixels and phone scroll height remains 36372 pixels while virtual windows move",
+ "Rapid-scroll screenshots show continuous populated rows with the back-to-top control remaining above mobile navigation",
+ "The desktop and phone rapid-scroll runs reported no console errors"
],
"interactions": [
- "Loaded the Proxmox overview and inspected the default 50-node desktop table",
- "Entered pve31 in the shared workload search and verified the node table reduced to 1 of 50",
- "Verified the same query left the matching Analytics A host and its pve31 workload rows visible",
- "Applied a temporary 390x844 viewport override, reloaded the overview, entered pve31, and verified the narrow DOM and screenshot",
- "Reset the temporary viewport override after verification"
+ "Loaded the large desktop mock estate and confirmed its initial 140-row virtual runway",
+ "Applied an 8000-pixel wheel burst and immediately measured and captured continuous visible-row coverage",
+ "Applied a temporary 375x812 viewport override and reloaded the 929-guest estate",
+ "Applied a 2800-pixel phone wheel burst and immediately measured and captured continuous visible-row coverage",
+ "Checked runtime error logs, reset the temporary viewport override, and closed the temporary test tab"
]
}
diff --git a/frontend-modern/src/components/Workloads/WorkloadPanel.tsx b/frontend-modern/src/components/Workloads/WorkloadPanel.tsx
index 9d12dfb7d..7f66e02c6 100644
--- a/frontend-modern/src/components/Workloads/WorkloadPanel.tsx
+++ b/frontend-modern/src/components/Workloads/WorkloadPanel.tsx
@@ -1,4 +1,4 @@
-import { createMemo, Index, Show } from 'solid-js';
+import { createMemo, For, Show } from 'solid-js';
import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
import { EnhancedCPUBar } from '@/components/Workloads/EnhancedCPUBar';
@@ -373,29 +373,33 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
- >}>
+ >}>
{(groupKey) => {
- const groupGuests = () => props.windowedGroupedGuests()[groupKey()] || [];
- const fullGroupGuests = () => props.groupedGuests()[groupKey()] || [];
- const node = () => props.nodeByInstance()[groupKey()];
+ const groupGuests = () => props.windowedGroupedGuests()[groupKey] || [];
+ const fullGroupGuests = () => props.groupedGuests()[groupKey] || [];
+ const groupGuestById = createMemo(
+ () => new Map(groupGuests().map((guest) => [getCanonicalWorkloadId(guest), guest])),
+ );
+ const groupGuestIds = createMemo(() => groupGuests().map(getCanonicalWorkloadId));
+ const node = () => props.nodeByInstance()[groupKey];
const groupSummaryScope = createMemo(() => {
if (props.groupingMode() !== 'grouped') {
return null;
}
return buildWorkloadSummaryGroupScope(
- groupKey(),
+ groupKey,
fullGroupGuests(),
- props.getGroupLabel(groupKey(), fullGroupGuests()),
+ props.getGroupLabel(groupKey, fullGroupGuests()),
);
});
const isSummaryGroupHighlighted = createMemo(
- () => props.activeSummaryWorkloadGroupScope()?.id === groupKey(),
+ () => props.activeSummaryWorkloadGroupScope()?.id === groupKey,
);
const shouldShowNodeDrawer = createMemo(
() =>
props.groupNodeDrawerMode() === 'inline' &&
Boolean(node()) &&
- props.focusedSummaryWorkloadGroupId() === groupKey() &&
+ props.focusedSummaryWorkloadGroupId() === groupKey &&
props.selectedGuestId() === null,
);
const canOpenNodeDrawer = () => props.groupNodeDrawerMode() === 'inline';
@@ -438,7 +442,7 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
? getInteractiveGroupedTableRowClass()
: getGroupedTableRowClass()
}
- data-summary-group-id={groupKey()}
+ data-summary-group-id={groupKey}
data-summary-group-series-count={String(
groupSummaryScope()?.seriesIds.length ?? 0,
)}
@@ -451,9 +455,9 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
class={getGroupedTableRowCellClass()}
>
{(() => {
- const label = props.getGroupLabel(groupKey(), fullGroupGuests());
+ const label = props.getGroupLabel(groupKey, fullGroupGuests());
const badges = props.groupLabelBadges();
- const badge = badges[groupKey()] ?? badges[groupKey().toLowerCase()];
+ const badge = badges[groupKey] ?? badges[groupKey.toLowerCase()];
const badgeLabel = badge?.label || label.type;
const badgeClass = badge?.classes || GROUPED_TABLE_ROW_BADGE_CLASS;
return (
@@ -500,7 +504,7 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
? 'true'
: 'false'
: undefined,
- 'data-summary-group-id': groupKey(),
+ 'data-summary-group-id': groupKey,
'data-summary-group-series-count': String(
groupSummaryScope()?.seriesIds.length ?? 0,
),
@@ -513,7 +517,7 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
- >}>
- {(guest) => {
- const guestId = createMemo(() => getCanonicalWorkloadId(guest()));
+ >}>
+ {(keyedGuestId) => {
+ const guest = () => groupGuestById().get(keyedGuestId)!;
+ const guestId = () => keyedGuestId;
const metadataIdCandidates = createMemo(() =>
getWorkloadMetadataIdCandidates(guest()),
);
@@ -606,11 +611,11 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
);
}}
-
+
>
);
}}
-
+
0}>
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 382f465cc..0ddb39e9d 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
@@ -915,11 +915,18 @@ 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(workloadPanelSource).toContain('groupGuests()[0] === fullGroupGuests()[0]');
expect(workloadPanelSource).toContain('class="h-0 !p-0 !border-0 leading-[0]"');
+ expect(workloadPanelSource).toContain(' {
expect(workloadsStateSource).not.toContain('const DEFAULT_ENABLE_THRESHOLD =');
expect(workloadsStateSource).not.toContain('const DEFAULT_OVERSCAN_ROWS =');
expect(workloadsSource).not.toContain('createMemo(() => getCanonicalWorkloadId(guest()))');
- expect(workloadPanelSource).toContain('createMemo(() => getCanonicalWorkloadId(guest()))');
+ expect(workloadPanelSource).toContain('groupGuests().map(getCanonicalWorkloadId)');
+ expect(workloadPanelSource).toContain('groupGuestById().get(keyedGuestId)');
expect(workloadPanelSource).toContain('buildWorkloadSummaryGroupScope');
expect(workloadPanelSource).toContain('data-summary-group-id');
expect(workloadPanelSource).toContain('setHoveredWorkloadGroupScope');
@@ -1647,7 +1655,7 @@ describe('Workloads performance contract', () => {
expect(workloadTableHeaderSource).not.toContain('NodeGroupHeader');
expect(workloadPanelSource).toContain('NodeGroupHeader');
expect(workloadPanelSource).toContain('GuestDrawer');
- expect(workloadPanelSource).toContain('createMemo(() => getCanonicalWorkloadId(guest()))');
+ expect(workloadPanelSource).toContain('groupGuests().map(getCanonicalWorkloadId)');
expect(workloadPanelSource).toContain('createSummaryInteractiveRowPreviewHandlers');
expect(workloadPanelSource).toContain('resolveSummaryGroupMemberInteractionState');
expect(workloadPanelSource).toContain('getInteractiveGroupedTableRowClass');
diff --git a/frontend-modern/src/components/Workloads/__tests__/useGroupedTableWindowing.test.ts b/frontend-modern/src/components/Workloads/__tests__/useGroupedTableWindowing.test.ts
index f47e3f079..67184a585 100644
--- a/frontend-modern/src/components/Workloads/__tests__/useGroupedTableWindowing.test.ts
+++ b/frontend-modern/src/components/Workloads/__tests__/useGroupedTableWindowing.test.ts
@@ -169,7 +169,7 @@ describe('useGroupedTableWindowing', () => {
// onScroll
// ──────────────────────────────────────────────────────────────
describe('onScroll', () => {
- it('moves window based on scroll position', () => {
+ it('keeps the current window while the viewport has a safe forward runway', () => {
const hook = setup({
totalRowCount: () => 1000,
enabled: () => true,
@@ -178,11 +178,42 @@ describe('useGroupedTableWindowing', () => {
// Scroll down: scrollTop=2000, containerHeight=400, rowHeight=40
// firstVisibleRow = floor(2000/40) = 50
- // overscan = min(20, max(0, 100 - ceil(400/40))) = min(20, max(0,90)) = 20
- // start = 50 - 20 = 30
+ // 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);
- expect(hook.startIndex()).toBe(30);
- expect(hook.endIndex()).toBe(130);
+ expect(hook.startIndex()).toBe(0);
+ expect(hook.endIndex()).toBe(100);
+ });
+
+ it('moves once the viewport reaches the forward runway and then holds steady', () => {
+ const hook = setup({
+ totalRowCount: () => 1000,
+ enabled: () => true,
+ windowSize: 100,
+ });
+
+ hook.onScroll(3200, 400, 40);
+ expect(hook.startIndex()).toBe(56);
+ expect(hook.endIndex()).toBe(156);
+
+ hook.onScroll(3600, 400, 40);
+ expect(hook.startIndex()).toBe(56);
+ expect(hook.endIndex()).toBe(156);
+ });
+
+ it('keeps most spare rows behind the viewport while scrolling upward', () => {
+ const hook = setup({
+ totalRowCount: () => 1000,
+ enabled: () => true,
+ windowSize: 100,
+ });
+
+ hook.onScroll(8000, 400, 40);
+ expect(hook.startIndex()).toBe(176);
+
+ hook.onScroll(6800, 400, 40);
+ expect(hook.startIndex()).toBe(104);
+ expect(hook.endIndex()).toBe(204);
});
it('clamps start to 0 when scrolled near top', () => {
@@ -226,7 +257,7 @@ describe('useGroupedTableWindowing', () => {
});
// rowHeight=0 → safeRowHeight=40, same calc as normal
hook.onScroll(2000, 400, 0);
- expect(hook.startIndex()).toBe(30);
+ expect(hook.startIndex()).toBe(0);
});
it('handles negative scrollTop gracefully', () => {
@@ -265,8 +296,8 @@ describe('useGroupedTableWindowing', () => {
hook.onScroll(3200, 400, 32);
expect(rowIndexAtOffset).toHaveBeenCalledWith(3200, 32);
- expect(hook.startIndex()).toBe(60);
- expect(hook.endIndex()).toBe(160);
+ expect(hook.startIndex()).toBe(56);
+ expect(hook.endIndex()).toBe(156);
});
it('falls back to fixed row math when the grouped offset resolver is non-finite', () => {
@@ -279,7 +310,7 @@ describe('useGroupedTableWindowing', () => {
hook.onScroll(2000, 400, 40);
- expect(hook.startIndex()).toBe(30);
+ expect(hook.startIndex()).toBe(0);
});
});
@@ -490,8 +521,9 @@ describe('useGroupedTableWindowing', () => {
enabled: () => true,
windowSize: 100,
});
- // Scroll: window starts at 30
- hook.onScroll(2000, 400, 40);
+ // Reveal a row that centers the window at 30.
+ hook.revealIndex(500);
+ hook.revealIndex(80);
// startIndex=30, endIndex=130
// Group at index 10..60 → sliceStart = max(0, 30-10) = 20, sliceEnd = min(50, 130-10) = 50
const slice = hook.getVisibleSlice('group-partial', guests, 10);
diff --git a/frontend-modern/src/components/Workloads/__tests__/useWorkloadViewportSync.test.tsx b/frontend-modern/src/components/Workloads/__tests__/useWorkloadViewportSync.test.tsx
index d27cefa5d..997ab78c4 100644
--- a/frontend-modern/src/components/Workloads/__tests__/useWorkloadViewportSync.test.tsx
+++ b/frontend-modern/src/components/Workloads/__tests__/useWorkloadViewportSync.test.tsx
@@ -66,6 +66,9 @@ describe('useWorkloadViewportSync', () => {
expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function), {
passive: true,
});
+ expect(addEventListenerSpy).toHaveBeenCalledWith('wheel', expect.any(Function), {
+ passive: true,
+ });
expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function));
window.dispatchEvent(new Event('scroll'));
@@ -76,6 +79,7 @@ describe('useWorkloadViewportSync', () => {
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('wheel', expect.any(Function));
expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function));
});
@@ -169,6 +173,11 @@ describe('useWorkloadViewportSync', () => {
horizontalTableWrapper.dispatchEvent(new Event('scroll'));
expect(onScroll).toHaveBeenCalledTimes(2);
+
+ appScrollContainer.dispatchEvent(new WheelEvent('wheel', { deltaY: 120 }));
+ await waitFor(() => {
+ expect(onScroll).toHaveBeenLastCalledWith(680, 400, 32);
+ });
});
it('exposes one app-shell back-to-top action after sustained workload scrolling', async () => {
diff --git a/frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts b/frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts
index a6431d5f8..7f28d23ae 100644
--- a/frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts
+++ b/frontend-modern/src/components/Workloads/useGroupedTableWindowing.ts
@@ -40,11 +40,13 @@ const DEFAULT_WINDOW_SIZE = 140;
// metric-heavy rows is already enough to cause long layout and paint tasks.
const DEFAULT_ENABLE_THRESHOLD = 250;
const DEFAULT_OVERSCAN_ROWS = 20;
+const DEFAULT_EDGE_RUNWAY_ROWS = 24;
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)),
@@ -90,10 +92,6 @@ export const useGroupedTableWindowing = (
const safeRowHeight = rowHeight > 0 ? rowHeight : 40;
const safeContainerHeight = containerHeight > 0 ? containerHeight : safeRowHeight;
const rowsInView = Math.max(1, Math.ceil(safeContainerHeight / safeRowHeight));
- const overscan = Math.min(
- DEFAULT_OVERSCAN_ROWS,
- Math.max(0, normalizedWindowSize() - rowsInView),
- );
const resolvedFirstVisibleRow = options.rowIndexAtOffset?.(
Math.max(0, scrollTop),
safeRowHeight,
@@ -101,7 +99,38 @@ export const useGroupedTableWindowing = (
const firstVisibleRow = Number.isFinite(resolvedFirstVisibleRow)
? Math.max(0, Math.floor(resolvedFirstVisibleRow!))
: Math.floor(Math.max(0, scrollTop) / safeRowHeight);
- setClampedStart(firstVisibleRow - overscan);
+ 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 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))
+ ) {
+ 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);
};
const getVisibleSlice = (
diff --git a/frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts b/frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts
index 83accb439..7808b39bb 100644
--- a/frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts
+++ b/frontend-modern/src/components/Workloads/useWorkloadViewportSync.ts
@@ -5,6 +5,13 @@ import type { UseGroupedTableWindowingResult } from './useGroupedTableWindowing'
const SCROLLABLE_OVERFLOW_PATTERN = /(?:auto|scroll|overlay)/;
const MIN_VERTICAL_SCROLL_RANGE_PX = 1;
const SCROLL_TO_TOP_VISIBILITY_THRESHOLD_PX = 640;
+const WHEEL_LINE_HEIGHT_PX = 16;
+
+const wheelDeltaInPixels = (event: WheelEvent, viewportHeight: 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;
+ return event.deltaY;
+};
const findScrollContainer = (element: HTMLElement): HTMLElement | null => {
let parent = element.parentElement;
@@ -35,7 +42,7 @@ interface WorkloadsWorkloadViewportSyncOptions {
export function useWorkloadViewportSync(options: WorkloadsWorkloadViewportSyncOptions) {
const [isScrollToTopVisible, setIsScrollToTopVisible] = createSignal(false);
- const syncGuestWindowToViewport = (measureRows = false) => {
+ const syncGuestWindowToViewport = (measureRows = false, projectedScrollDelta = 0) => {
if (typeof window === 'undefined') return;
const body = options.tableBodyRef();
if (!body) return;
@@ -60,7 +67,7 @@ export function useWorkloadViewportSync(options: WorkloadsWorkloadViewportSyncOp
const containerRect = scrollContainer.getBoundingClientRect();
const scrollTop = Math.max(0, containerRect.top - rect.top);
options.groupedWindowing.onScroll(
- scrollTop,
+ Math.max(0, scrollTop + projectedScrollDelta),
scrollContainer.clientHeight || window.innerHeight,
options.rowHeight(),
);
@@ -70,7 +77,7 @@ export function useWorkloadViewportSync(options: WorkloadsWorkloadViewportSyncOp
setIsScrollToTopVisible(window.scrollY > SCROLL_TO_TOP_VISIBILITY_THRESHOLD_PX);
if (!options.groupedWindowing.isWindowed()) return;
options.groupedWindowing.onScroll(
- Math.max(0, -rect.top),
+ Math.max(0, -rect.top + projectedScrollDelta),
window.innerHeight,
options.rowHeight(),
);
@@ -84,6 +91,15 @@ export function useWorkloadViewportSync(options: WorkloadsWorkloadViewportSyncOp
const handleViewportScroll = () => {
syncGuestWindowToViewport();
};
+ const handleViewportWheel = (event: Event) => {
+ const wheelEvent = event as WheelEvent;
+ if (!options.groupedWindowing.isWindowed() || wheelEvent.deltaY === 0) return;
+ const viewportHeight =
+ scrollTarget instanceof HTMLElement
+ ? scrollTarget.clientHeight || window.innerHeight
+ : window.innerHeight;
+ syncGuestWindowToViewport(false, wheelDeltaInPixels(wheelEvent, viewportHeight));
+ };
const handleViewportResize = () => {
syncGuestWindowToViewport(true);
};
@@ -92,9 +108,11 @@ export function useWorkloadViewportSync(options: WorkloadsWorkloadViewportSyncOp
const scrollContainer = findScrollContainer(options.tableBodyRef()!);
const scrollTarget = scrollContainer ?? window;
scrollTarget.addEventListener('scroll', handleViewportScroll, { passive: true });
+ scrollTarget.addEventListener('wheel', handleViewportWheel, { passive: true });
window.addEventListener('resize', handleViewportResize);
onCleanup(() => {
scrollTarget.removeEventListener('scroll', handleViewportScroll);
+ scrollTarget.removeEventListener('wheel', handleViewportWheel);
window.removeEventListener('resize', handleViewportResize);
});
});