@@ -24,7 +24,7 @@ import React, {
24
24
useState
25
25
} from 'react' ;
26
26
import { Rect , Size } from '@react-stately/virtualizer' ;
27
- import { useLayoutEffect , useResizeObserver } from '@react-aria/utils' ;
27
+ import { useEffectEvent , useLayoutEffect , useResizeObserver } from '@react-aria/utils' ;
28
28
import { useLocale } from '@react-aria/i18n' ;
29
29
30
30
interface ScrollViewProps extends HTMLAttributes < HTMLElement > {
@@ -38,8 +38,6 @@ interface ScrollViewProps extends HTMLAttributes<HTMLElement> {
38
38
scrollDirection ?: 'horizontal' | 'vertical' | 'both'
39
39
}
40
40
41
- let isOldReact = React . version . startsWith ( '16.' ) || React . version . startsWith ( '17.' ) ;
42
-
43
41
function ScrollView ( props : ScrollViewProps , ref : RefObject < HTMLDivElement > ) {
44
42
let {
45
43
contentSize,
@@ -124,7 +122,7 @@ function ScrollView(props: ScrollViewProps, ref: RefObject<HTMLDivElement>) {
124
122
// eslint-disable-next-line react-hooks/exhaustive-deps
125
123
} , [ ] ) ;
126
124
127
- let updateSize = useCallback ( ( ) => {
125
+ let updateSize = useEffectEvent ( ( flush : typeof flushSync ) => {
128
126
let dom = ref . current ;
129
127
if ( ! dom ) {
130
128
return ;
@@ -133,8 +131,10 @@ function ScrollView(props: ScrollViewProps, ref: RefObject<HTMLDivElement>) {
133
131
let isTestEnv = process . env . NODE_ENV === 'test' && ! process . env . VIRT_ON ;
134
132
let isClientWidthMocked = Object . getOwnPropertyNames ( window . HTMLElement . prototype ) . includes ( 'clientWidth' ) ;
135
133
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 ;
138
138
139
139
if ( sizeToFit && contentSize . width > 0 && contentSize . height > 0 ) {
140
140
if ( sizeToFit === 'width' ) {
@@ -147,32 +147,38 @@ function ScrollView(props: ScrollViewProps, ref: RefObject<HTMLDivElement>) {
147
147
if ( state . width !== w || state . height !== h ) {
148
148
state . width = w ;
149
149
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
+ }
151
166
}
152
- } , [ onVisibleRectChange , ref , state , sizeToFit , contentSize ] ) ;
167
+ } ) ;
153
168
154
169
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 ( ) ) ;
156
175
} , [ updateSize ] ) ;
157
- let raf = useRef < ReturnType < typeof requestAnimationFrame > | null > ( ) ;
158
176
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 ) ;
167
178
} , [ 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} ) ;
176
182
177
183
let style : React . CSSProperties = {
178
184
// Reset padding so that relative positioning works correctly. Padding will be done in JS layout.
0 commit comments