Skip to content

Commit f79182a

Browse files
committed
Update layout when content size changes might cause scrollbars to appear or disappear
1 parent 179f36c commit f79182a

File tree

2 files changed

+35
-18
lines changed

2 files changed

+35
-18
lines changed

.eslintrc.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,8 @@ module.exports = {
120120
'AsyncIterable': 'readonly',
121121
'FileSystemFileEntry': 'readonly',
122122
'FileSystemDirectoryEntry': 'readonly',
123-
'FileSystemEntry': 'readonly'
123+
'FileSystemEntry': 'readonly',
124+
'globalThis': 'readonly'
124125
},
125126
settings: {
126127
jsdoc: {

packages/@react-aria/virtualizer/src/ScrollView.tsx

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)