1212
1313// @ts -ignore
1414import { flushSync } from 'react-dom' ;
15- import { getEventTarget , useEffectEvent , useEvent , useLayoutEffect , useObjectRef , useResizeObserver } from '@react-aria/utils' ;
15+ import { getEventTarget , nodeContains , useEffectEvent , useLayoutEffect , useObjectRef , useResizeObserver } from '@react-aria/utils' ;
1616import { getScrollLeft } from './utils' ;
17+ import { Point , Rect , Size } from '@react-stately/virtualizer' ;
1718import React , {
1819 CSSProperties ,
1920 ForwardedRef ,
@@ -25,17 +26,17 @@ import React, {
2526 useRef ,
2627 useState
2728} from 'react' ;
28- import { Rect , Size } from '@react-stately/virtualizer' ;
2929import { useLocale } from '@react-aria/i18n' ;
3030
31- interface ScrollViewProps extends HTMLAttributes < HTMLElement > {
31+ interface ScrollViewProps extends Omit < HTMLAttributes < HTMLElement > , 'onScroll' > {
3232 contentSize : Size ,
3333 onVisibleRectChange : ( rect : Rect ) => void ,
3434 children ?: ReactNode ,
3535 innerStyle ?: CSSProperties ,
3636 onScrollStart ?: ( ) => void ,
3737 onScrollEnd ?: ( ) => void ,
38- scrollDirection ?: 'horizontal' | 'vertical' | 'both'
38+ scrollDirection ?: 'horizontal' | 'vertical' | 'both' ,
39+ onScroll ?: ( e : Event ) => void
3940}
4041
4142function ScrollView ( props : ScrollViewProps , ref : ForwardedRef < HTMLDivElement | null > ) {
@@ -70,39 +71,76 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject<HTMLElement
7071 onScrollStart,
7172 onScrollEnd,
7273 scrollDirection = 'both' ,
74+ onScroll : onScrollProp ,
7375 ...otherProps
7476 } = props ;
7577
7678 let state = useRef ( {
77- scrollTop : 0 ,
78- scrollLeft : 0 ,
79+ // Internal scroll position of the scroll view.
80+ scrollPosition : new Point ( ) ,
81+ // Size of the scroll view.
82+ size : new Size ( ) ,
83+ // Offset of the scroll view relative to the window viewport.
84+ viewportOffset : new Point ( ) ,
85+ // Size of the window viewport.
86+ viewportSize : new Size ( ) ,
7987 scrollEndTime : 0 ,
8088 scrollTimeout : null as ReturnType < typeof setTimeout > | null ,
81- width : 0 ,
82- height : 0 ,
8389 isScrolling : false
8490 } ) . current ;
8591 let { direction} = useLocale ( ) ;
8692
93+ let updateVisibleRect = useCallback ( ( ) => {
94+ // Intersect the window viewport with the scroll view itself to find the actual visible rectangle.
95+ // This allows virtualized components to have unbounded height but still virtualize when scrolled with the page.
96+ // While there may be other scrollable elements between the <body> and the scroll view, we do not take
97+ // their sizes into account for performance reasons. Their scroll positions are accounted for in viewportOffset
98+ // though (due to getBoundingClientRect). This may result in more rows than absolutely necessary being rendered,
99+ // but no more than the entire height of the viewport which is good enough for virtualization use cases.
100+ let visibleRect = new Rect (
101+ state . viewportOffset . x + state . scrollPosition . x ,
102+ state . viewportOffset . y + state . scrollPosition . y ,
103+ Math . max ( 0 , Math . min ( state . size . width - state . viewportOffset . x , state . viewportSize . width ) ) ,
104+ Math . max ( 0 , Math . min ( state . size . height - state . viewportOffset . y , state . viewportSize . height ) )
105+ ) ;
106+ onVisibleRectChange ( visibleRect ) ;
107+ } , [ state , onVisibleRectChange ] ) ;
108+
87109 let [ isScrolling , setScrolling ] = useState ( false ) ;
88110
89- let onScroll = useCallback ( ( e ) => {
90- if ( getEventTarget ( e ) !== e . currentTarget ) {
111+ let onScroll = useCallback ( ( e : Event ) => {
112+ let target = getEventTarget ( e ) as Element ;
113+ if ( ! nodeContains ( target , ref . current ! ) ) {
91114 return ;
92115 }
93116
94- if ( props . onScroll ) {
95- props . onScroll ( e ) ;
117+ if ( onScrollProp && target === ref . current ) {
118+ onScrollProp ( e ) ;
96119 }
97120
98- flushSync ( ( ) => {
99- let scrollTop = e . currentTarget . scrollTop ;
100- let scrollLeft = getScrollLeft ( e . currentTarget , direction ) ;
121+ if ( target !== ref . current ) {
122+ // An ancestor element or the window was scrolled. Update the position of the scroll view relative to the viewport.
123+ let boundingRect = ref . current ! . getBoundingClientRect ( ) ;
124+ let x = boundingRect . x < 0 ? - boundingRect . x : 0 ;
125+ let y = boundingRect . y < 0 ? - boundingRect . y : 0 ;
126+ if ( x === state . viewportOffset . x && y === state . viewportOffset . y ) {
127+ return ;
128+ }
101129
130+ state . viewportOffset = new Point ( x , y ) ;
131+ } else {
132+ // The scroll view itself was scrolled. Update the local scroll position.
102133 // Prevent rubber band scrolling from shaking when scrolling out of bounds
103- state . scrollTop = Math . max ( 0 , Math . min ( scrollTop , contentSize . height - state . height ) ) ;
104- state . scrollLeft = Math . max ( 0 , Math . min ( scrollLeft , contentSize . width - state . width ) ) ;
105- onVisibleRectChange ( new Rect ( state . scrollLeft , state . scrollTop , state . width , state . height ) ) ;
134+ let scrollTop = target . scrollTop ;
135+ let scrollLeft = getScrollLeft ( target , direction ) ;
136+ state . scrollPosition = new Point (
137+ Math . max ( 0 , Math . min ( scrollLeft , contentSize . width - state . size . width ) ) ,
138+ Math . max ( 0 , Math . min ( scrollTop , contentSize . height - state . size . height ) )
139+ ) ;
140+ }
141+
142+ flushSync ( ( ) => {
143+ updateVisibleRect ( ) ;
106144
107145 if ( ! state . isScrolling ) {
108146 state . isScrolling = true ;
@@ -138,10 +176,13 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject<HTMLElement
138176 } , 300 ) ;
139177 }
140178 } ) ;
141- } , [ props , direction , state , contentSize , onVisibleRectChange , onScrollStart , onScrollEnd ] ) ;
179+ } , [ onScrollProp , ref , direction , state , contentSize , updateVisibleRect , onScrollStart , onScrollEnd ] ) ;
142180
143- // Attach event directly to ref so RAC Virtualizer doesn't need to send props upward.
144- useEvent ( ref , 'scroll' , onScroll ) ;
181+ // Attach a document-level capturing scroll listener so we can account for scrollable ancestors.
182+ useEffect ( ( ) => {
183+ document . addEventListener ( 'scroll' , onScroll , true ) ;
184+ return ( ) => document . removeEventListener ( 'scroll' , onScroll , true ) ;
185+ } , [ onScroll ] ) ;
145186
146187 useEffect ( ( ) => {
147188 return ( ) => {
@@ -175,11 +216,18 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject<HTMLElement
175216 let w = isTestEnv && ! isClientWidthMocked ? Infinity : clientWidth ;
176217 let h = isTestEnv && ! isClientHeightMocked ? Infinity : clientHeight ;
177218
178- if ( state . width !== w || state . height !== h ) {
179- state . width = w ;
180- state . height = h ;
219+ // Update the window viewport size.
220+ let viewportWidth = window . innerWidth ;
221+ let viewportHeight = window . innerHeight ;
222+ let viewportSizeChanged = state . viewportSize . width !== viewportWidth || state . viewportSize . height !== viewportHeight ;
223+ if ( viewportSizeChanged ) {
224+ state . viewportSize = new Size ( viewportWidth , viewportHeight ) ;
225+ }
226+
227+ if ( state . size . width !== w || state . size . height !== h || viewportSizeChanged ) {
228+ state . size = new Size ( w , h ) ;
181229 flush ( ( ) => {
182- onVisibleRectChange ( new Rect ( state . scrollLeft , state . scrollTop , w , h ) ) ;
230+ updateVisibleRect ( ) ;
183231 } ) ;
184232
185233 // If the clientWidth or clientHeight changed, scrollbars appeared or disappeared as
@@ -188,18 +236,30 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject<HTMLElement
188236 // again, resulting in extra padding. We stop after a maximum of two layout passes to avoid
189237 // an infinite loop. This matches how browsers behavior with native CSS grid layout.
190238 if ( ! isTestEnv && clientWidth !== dom . clientWidth || clientHeight !== dom . clientHeight ) {
191- state . width = dom . clientWidth ;
192- state . height = dom . clientHeight ;
239+ state . size = new Size ( dom . clientWidth , dom . clientHeight ) ;
193240 flush ( ( ) => {
194- onVisibleRectChange ( new Rect ( state . scrollLeft , state . scrollTop , state . width , state . height ) ) ;
241+ updateVisibleRect ( ) ;
195242 } ) ;
196243 }
197244 }
198245
199246 isUpdatingSize . current = false ;
200- } , [ ref , state , onVisibleRectChange ] ) ;
247+ } , [ ref , state , updateVisibleRect ] ) ;
201248 let updateSizeEvent = useEffectEvent ( updateSize ) ;
202249
250+ // Track the size of the entire window viewport, which is used to bound the size of the virtualizer's visible rectangle.
251+ useLayoutEffect ( ( ) => {
252+ // Initialize viewportRect before updating size for the first time.
253+ state . viewportSize = new Size ( window . innerWidth , window . innerHeight ) ;
254+
255+ let onWindowResize = ( ) => {
256+ updateSizeEvent ( flushSync ) ;
257+ } ;
258+
259+ window . addEventListener ( 'resize' , onWindowResize ) ;
260+ return ( ) => window . removeEventListener ( 'resize' , onWindowResize ) ;
261+ } , [ state ] ) ;
262+
203263 // Update visible rect when the content size changes, in case scrollbars need to appear or disappear.
204264 let lastContentSize = useRef < Size | null > ( null ) ;
205265 let [ update , setUpdate ] = useState ( { } ) ;
@@ -250,7 +310,7 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject<HTMLElement
250310 if ( scrollDirection === 'horizontal' ) {
251311 style . overflowX = 'auto' ;
252312 style . overflowY = 'hidden' ;
253- } else if ( scrollDirection === 'vertical' || contentSize . width === state . width ) {
313+ } else if ( scrollDirection === 'vertical' || contentSize . width === state . size . width ) {
254314 // Set overflow-x: hidden if content size is equal to the width of the scroll view.
255315 // This prevents horizontal scrollbars from flickering during resizing due to resize observer
256316 // firing slower than the frame rate, which may cause an infinite re-render loop.
0 commit comments