From 1fbed53c8241323dfb03ea52e59fdb27b7dd0d30 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 11 Mar 2026 15:07:04 -0700 Subject: [PATCH 1/5] feat: Automatically bound virtualizer visible rectangle to window viewport --- .../virtualizer/src/ScrollView.tsx | 120 +++++++++++++----- .../card/test/CardView.test.js | 8 +- .../list/test/ListView.test.js | 7 + .../s2/stories/TableView.stories.tsx | 47 ++++++- .../@react-spectrum/table/test/TableTests.js | 7 + .../table/test/TreeGridTable.test.tsx | 7 + 6 files changed, 161 insertions(+), 35 deletions(-) diff --git a/packages/@react-aria/virtualizer/src/ScrollView.tsx b/packages/@react-aria/virtualizer/src/ScrollView.tsx index ba6c460104f..bc843c7952d 100644 --- a/packages/@react-aria/virtualizer/src/ScrollView.tsx +++ b/packages/@react-aria/virtualizer/src/ScrollView.tsx @@ -12,8 +12,9 @@ // @ts-ignore import {flushSync} from 'react-dom'; -import {getEventTarget, useEffectEvent, useEvent, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils'; +import {getEventTarget, nodeContains, useEffectEvent, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils'; import {getScrollLeft} from './utils'; +import {Point, Rect, Size} from '@react-stately/virtualizer'; import React, { CSSProperties, ForwardedRef, @@ -25,17 +26,17 @@ import React, { useRef, useState } from 'react'; -import {Rect, Size} from '@react-stately/virtualizer'; import {useLocale} from '@react-aria/i18n'; -interface ScrollViewProps extends HTMLAttributes { +interface ScrollViewProps extends Omit, 'onScroll'> { contentSize: Size, onVisibleRectChange: (rect: Rect) => void, children?: ReactNode, innerStyle?: CSSProperties, onScrollStart?: () => void, onScrollEnd?: () => void, - scrollDirection?: 'horizontal' | 'vertical' | 'both' + scrollDirection?: 'horizontal' | 'vertical' | 'both', + onScroll?: (e: Event) => void } function ScrollView(props: ScrollViewProps, ref: ForwardedRef) { @@ -70,39 +71,76 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject | null, - width: 0, - height: 0, isScrolling: false }).current; let {direction} = useLocale(); + let updateVisibleRect = useCallback(() => { + // Intersect the window viewport with the scroll view itself to find the actual visible rectangle. + // This allows virtualized components to have unbounded height but still virtualize when scrolled with the page. + // While there may be other scrollable elements between the and the scroll view, we do not take + // their sizes into account for performance reasons. Their scroll positions are accounted for in viewportOffset + // though (due to getBoundingClientRect). This may result in more rows than absolutely necessary being rendered, + // but no more than the entire height of the viewport which is good enough for virtualization use cases. + let visibleRect = new Rect( + state.viewportOffset.x + state.scrollPosition.x, + state.viewportOffset.y + state.scrollPosition.y, + Math.max(0, Math.min(state.size.width - state.viewportOffset.x, state.viewportSize.width)), + Math.max(0, Math.min(state.size.height - state.viewportOffset.y, state.viewportSize.height)) + ); + onVisibleRectChange(visibleRect); + }, [state, onVisibleRectChange]); + let [isScrolling, setScrolling] = useState(false); - let onScroll = useCallback((e) => { - if (getEventTarget(e) !== e.currentTarget) { + let onScroll = useCallback((e: Event) => { + let target = getEventTarget(e) as Element; + if (!nodeContains(target, ref.current!)) { return; } - if (props.onScroll) { - props.onScroll(e); + if (onScrollProp && target === ref.current) { + onScrollProp(e); } - flushSync(() => { - let scrollTop = e.currentTarget.scrollTop; - let scrollLeft = getScrollLeft(e.currentTarget, direction); + if (target !== ref.current) { + // An ancestor element or the window was scrolled. Update the position of the scroll view relative to the viewport. + let boundingRect = ref.current!.getBoundingClientRect(); + let x = boundingRect.x < 0 ? -boundingRect.x : 0; + let y = boundingRect.y < 0 ? -boundingRect.y : 0; + if (x === state.viewportOffset.x && y === state.viewportOffset.y) { + return; + } + state.viewportOffset = new Point(x, y); + } else { + // The scroll view itself was scrolled. Update the local scroll position. // Prevent rubber band scrolling from shaking when scrolling out of bounds - state.scrollTop = Math.max(0, Math.min(scrollTop, contentSize.height - state.height)); - state.scrollLeft = Math.max(0, Math.min(scrollLeft, contentSize.width - state.width)); - onVisibleRectChange(new Rect(state.scrollLeft, state.scrollTop, state.width, state.height)); + let scrollTop = target.scrollTop; + let scrollLeft = getScrollLeft(target, direction); + state.scrollPosition = new Point( + Math.max(0, Math.min(scrollLeft, contentSize.width - state.size.width)), + Math.max(0, Math.min(scrollTop, contentSize.height - state.size.height)) + ); + } + + flushSync(() => { + updateVisibleRect(); if (!state.isScrolling) { state.isScrolling = true; @@ -138,10 +176,13 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject { + document.addEventListener('scroll', onScroll, true); + return () => document.removeEventListener('scroll', onScroll, true); + }, [onScroll]); useEffect(() => { return () => { @@ -175,11 +216,18 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject { - onVisibleRectChange(new Rect(state.scrollLeft, state.scrollTop, w, h)); + updateVisibleRect(); }); // If the clientWidth or clientHeight changed, scrollbars appeared or disappeared as @@ -188,18 +236,30 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject { - onVisibleRectChange(new Rect(state.scrollLeft, state.scrollTop, state.width, state.height)); + updateVisibleRect(); }); } } isUpdatingSize.current = false; - }, [ref, state, onVisibleRectChange]); + }, [ref, state, updateVisibleRect]); let updateSizeEvent = useEffectEvent(updateSize); + // Track the size of the entire window viewport, which is used to bound the size of the virtualizer's visible rectangle. + useLayoutEffect(() => { + // Initialize viewportRect before updating size for the first time. + state.viewportSize = new Size(window.innerWidth, window.innerHeight); + + let onWindowResize = () => { + updateSizeEvent(flushSync); + }; + + window.addEventListener('resize', onWindowResize); + return () => window.removeEventListener('resize', onWindowResize); + }, [state]); + // Update visible rect when the content size changes, in case scrollbars need to appear or disappear. let lastContentSize = useRef(null); let [update, setUpdate] = useState({}); @@ -250,7 +310,7 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject