Eliminate workload virtualization scroll gaps

This commit is contained in:
rcourtman
2026-08-23 17:05:15 +01:00
parent dc129cd3a4
commit 4182f79028
8 changed files with 171 additions and 60 deletions
@@ -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
+21 -20
View File
@@ -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"
]
}
@@ -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) {
</TableCell>
</TableRow>
</Show>
<Index each={props.visibleGroupKeys()} fallback={<></>}>
<For each={props.visibleGroupKeys()} fallback={<></>}>
{(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<SummarySeriesGroupScope | null>(() => {
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) {
<Show when={shouldShowNodeDrawer()}>
<InlineDetailTableRow
colspan={props.totalColumns()}
data-inline-node-detail-for={groupKey()}
data-inline-node-detail-for={groupKey}
>
<NodeDrawer
node={node()!}
@@ -522,9 +526,10 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
</InlineDetailTableRow>
</Show>
</Show>
<Index each={groupGuests()} fallback={<></>}>
{(guest) => {
const guestId = createMemo(() => getCanonicalWorkloadId(guest()));
<For each={groupGuestIds()} fallback={<></>}>
{(keyedGuestId) => {
const guest = () => groupGuestById().get(keyedGuestId)!;
const guestId = () => keyedGuestId;
const metadataIdCandidates = createMemo(() =>
getWorkloadMetadataIdCandidates(guest()),
);
@@ -606,11 +611,11 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
</ComponentErrorBoundary>
);
}}
</Index>
</For>
</>
);
}}
</Index>
</For>
<Show when={props.groupedWindowing.isWindowed() && props.bottomSpacerHeight() > 0}>
<TableRow aria-hidden="true" class="h-0 !border-0">
<TableCell colspan={props.totalColumns()} class="h-0 !p-0 !border-0 leading-[0]">
@@ -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('<For each={props.visibleGroupKeys()}');
expect(workloadPanelSource).toContain('<For each={groupGuestIds()}');
expect(workloadPanelSource).toContain('groupGuests().map(getCanonicalWorkloadId)');
expect(workloadsWorkloadViewportSyncSource).toContain(
'SCROLL_TO_TOP_VISIBILITY_THRESHOLD_PX',
);
expect(workloadsWorkloadViewportSyncSource).toContain("addEventListener('wheel'");
expect(workloadsWorkloadViewportSyncSource).toContain('wheelDeltaInPixels');
expect(workloadsWorkloadViewportSyncSource).toContain(
"scrollContainer.scrollTo({ top: 0, behavior: 'smooth' })",
);
@@ -1327,7 +1334,8 @@ describe('Workloads performance contract', () => {
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');
@@ -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);
@@ -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 () => {
@@ -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 = (
@@ -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);
});
});