@@ -24,7 +24,7 @@ import React, {
2424 useState
2525} from 'react' ;
2626import { Rect , Size } from '@react-stately/virtualizer' ;
27- import { useLayoutEffect , useResizeObserver } from '@react-aria/utils' ;
27+ import { useEffectEvent , useLayoutEffect , useResizeObserver } from '@react-aria/utils' ;
2828import { useLocale } from '@react-aria/i18n' ;
2929
3030interface ScrollViewProps extends HTMLAttributes < HTMLElement > {
@@ -38,8 +38,6 @@ interface ScrollViewProps extends HTMLAttributes<HTMLElement> {
3838 scrollDirection ?: 'horizontal' | 'vertical' | 'both'
3939}
4040
41- let isOldReact = React . version . startsWith ( '16.' ) || React . version . startsWith ( '17.' ) ;
42-
4341function ScrollView ( props : ScrollViewProps , ref : RefObject < HTMLDivElement > ) {
4442 let {
4543 contentSize,
@@ -124,7 +122,7 @@ function ScrollView(props: ScrollViewProps, ref: RefObject<HTMLDivElement>) {
124122 // eslint-disable-next-line react-hooks/exhaustive-deps
125123 } , [ ] ) ;
126124
127- let updateSize = useCallback ( ( ) => {
125+ let updateSize = useEffectEvent ( ( flush : typeof flushSync ) => {
128126 let dom = ref . current ;
129127 if ( ! dom ) {
130128 return ;
@@ -133,8 +131,10 @@ function ScrollView(props: ScrollViewProps, ref: RefObject<HTMLDivElement>) {
133131 let isTestEnv = process . env . NODE_ENV === 'test' && ! process . env . VIRT_ON ;
134132 let isClientWidthMocked = Object . getOwnPropertyNames ( window . HTMLElement . prototype ) . includes ( 'clientWidth' ) ;
135133 let isClientHeightMocked = Object . getOwnPropertyNames ( window . HTMLElement . prototype ) . includes ( 'clientHeight' ) ;
136- let w = isTestEnv && ! isClientWidthMocked ? Infinity : dom . clientWidth ;
137- let h = isTestEnv && ! isClientHeightMocked ? Infinity : dom . clientHeight ;
134+ let clientWidth = dom . clientWidth ;
135+ let clientHeight = dom . clientHeight ;
136+ let w = isTestEnv && ! isClientWidthMocked ? Infinity : clientWidth ;
137+ let h = isTestEnv && ! isClientHeightMocked ? Infinity : clientHeight ;
138138
139139 if ( sizeToFit && contentSize . width > 0 && contentSize . height > 0 ) {
140140 if ( sizeToFit === 'width' ) {
@@ -147,32 +147,38 @@ function ScrollView(props: ScrollViewProps, ref: RefObject<HTMLDivElement>) {
147147 if ( state . width !== w || state . height !== h ) {
148148 state . width = w ;
149149 state . height = h ;
150- onVisibleRectChange ( new Rect ( state . scrollLeft , state . scrollTop , w , h ) ) ;
150+ flush ( ( ) => {
151+ onVisibleRectChange ( new Rect ( state . scrollLeft , state . scrollTop , w , h ) ) ;
152+ } ) ;
153+
154+ // If the clientWidth or clientHeight changed, scrollbars appeared or disappeared as
155+ // a result of the layout update. In this case, re-layout again to account for the
156+ // adjusted space. In very specific cases this might result in the scrollbars disappearing
157+ // again, resulting in extra padding. We stop after a maximum of two layout passes to avoid
158+ // an infinite loop. This matches how browsers behavior with native CSS grid layout.
159+ if ( ! isTestEnv && clientWidth !== dom . clientWidth || clientHeight !== dom . clientHeight ) {
160+ state . width = dom . clientWidth ;
161+ state . height = dom . clientHeight ;
162+ flush ( ( ) => {
163+ onVisibleRectChange ( new Rect ( state . scrollLeft , state . scrollTop , state . width , state . height ) ) ;
164+ } ) ;
165+ }
151166 }
152- } , [ onVisibleRectChange , ref , state , sizeToFit , contentSize ] ) ;
167+ } ) ;
153168
154169 useLayoutEffect ( ( ) => {
155- updateSize ( ) ;
170+ // React doesn't allow flushSync inside effects so pass an identity function instead.
171+ // This only happens on initial render. The resize observer will also call updateSize
172+ // once it initializes, but we need earlier initialization in a layout effect to avoid
173+ // a flash of missing content.
174+ updateSize ( fn => fn ( ) ) ;
156175 } , [ updateSize ] ) ;
157- let raf = useRef < ReturnType < typeof requestAnimationFrame > | null > ( ) ;
158176 let onResize = useCallback ( ( ) => {
159- if ( isOldReact ) {
160- raf . current ??= requestAnimationFrame ( ( ) => {
161- updateSize ( ) ;
162- raf . current = null ;
163- } ) ;
164- } else {
165- updateSize ( ) ;
166- }
177+ updateSize ( flushSync ) ;
167178 } , [ updateSize ] ) ;
168- useResizeObserver ( { ref, onResize} ) ;
169- useEffect ( ( ) => {
170- return ( ) => {
171- if ( raf . current ) {
172- cancelAnimationFrame ( raf . current ) ;
173- }
174- } ;
175- } , [ ] ) ;
179+ // Watch border-box instead of of content-box so that we don't go into
180+ // an infinite loop when scrollbars appear or disappear.
181+ useResizeObserver ( { ref, box : 'border-box' , onResize} ) ;
176182
177183 let style : React . CSSProperties = {
178184 // Reset padding so that relative positioning works correctly. Padding will be done in JS layout.
0 commit comments