@@ -143,12 +143,17 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject<HTMLElement
143143 // eslint-disable-next-line react-hooks/exhaustive-deps
144144 } , [ ] ) ;
145145
146+ let isUpdatingSize = useRef ( false ) ;
146147 let updateSize = useEffectEvent ( ( flush : typeof flushSync ) => {
147148 let dom = ref . current ;
148- if ( ! dom ) {
149+ if ( ! dom && ! isUpdatingSize . current ) {
149150 return ;
150151 }
151152
153+ // Prevent reentrancy when resize observer fires, triggers re-layout that results in
154+ // content size update, causing below layout effect to fire. This avoids infinite loops.
155+ isUpdatingSize . current = true ;
156+
152157 let isTestEnv = process . env . NODE_ENV === 'test' && ! process . env . VIRT_ON ;
153158 let isClientWidthMocked = Object . getOwnPropertyNames ( window . HTMLElement . prototype ) . includes ( 'clientWidth' ) ;
154159 let isClientHeightMocked = Object . getOwnPropertyNames ( window . HTMLElement . prototype ) . includes ( 'clientHeight' ) ;
@@ -177,27 +182,38 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject<HTMLElement
177182 } ) ;
178183 }
179184 }
185+
186+ isUpdatingSize . current = false ;
180187 } ) ;
181188
182- let didUpdateSize = useRef ( false ) ;
189+ // Update visible rect when the content size changes, in case scrollbars need to appear or disappear.
190+ let lastContentSize = useRef < Size | null > ( null ) ;
183191 useLayoutEffect ( ( ) => {
184- // React doesn't allow flushSync inside effects, so queue a microtask.
185- // We also need to wait until all refs are set (e.g. when passing a ref down from a parent).
186- queueMicrotask ( ( ) => {
187- if ( ! didUpdateSize . current ) {
188- didUpdateSize . current = true ;
189- updateSize ( flushSync ) ;
192+ if ( ! isUpdatingSize . current ) {
193+ // Detect when scrollbar state might have changed.
194+ let prevShowX = lastContentSize . current ?. width > state . width ;
195+ let prevShowY = lastContentSize . current ?. height > state . height ;
196+ let curShowX = contentSize . width > state . width ;
197+ let curShowY = contentSize . height > state . height ;
198+
199+ if ( lastContentSize . current && prevShowX === curShowX && prevShowY === curShowY ) {
200+ return ;
201+ }
202+
203+ // React doesn't allow flushSync inside effects, so queue a microtask.
204+ // We also need to wait until all refs are set (e.g. when passing a ref down from a parent).
205+ // If we are in an `act` environment, update immediately without a microtask so you don't need
206+ // to mock timers in tests. In this case, the update is synchronous already.
207+ if ( globalThis . IS_REACT_ACT_ENVIRONMENT ) {
208+ updateSize ( fn => fn ( ) ) ;
209+ } else {
210+ queueMicrotask ( ( ) => updateSize ( flushSync ) ) ;
190211 }
191- } ) ;
192- } , [ updateSize ] ) ;
193- useEffect ( ( ) => {
194- if ( ! didUpdateSize . current ) {
195- // If useEffect ran before the above microtask, we are in a synchronous render (e.g. act).
196- // Update the size here so that you don't need to mock timers in tests.
197- didUpdateSize . current = true ;
198- updateSize ( fn => fn ( ) ) ;
199212 }
200- } , [ updateSize ] ) ;
213+
214+ lastContentSize . current = contentSize ;
215+ } ) ;
216+
201217 let onResize = useCallback ( ( ) => {
202218 updateSize ( flushSync ) ;
203219 } , [ updateSize ] ) ;
0 commit comments