Skip to content

Commit 83cb20d

Browse files
authored
Fix ResizeObserver crash in Virtualizer due to scrollbar flapping (#6508)
1 parent 23c5ba1 commit 83cb20d

File tree

3 files changed

+106
-30
lines changed

3 files changed

+106
-30
lines changed

packages/@react-aria/utils/src/useResizeObserver.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@ function hasResizeObserver() {
66

77
type useResizeObserverOptionsType<T> = {
88
ref: RefObject<T | undefined> | undefined,
9+
box?: ResizeObserverBoxOptions,
910
onResize: () => void
1011
}
1112

1213
export function useResizeObserver<T extends Element>(options: useResizeObserverOptionsType<T>) {
13-
const {ref, onResize} = options;
14+
const {ref, box, onResize} = options;
1415

1516
useEffect(() => {
1617
let element = ref?.current;
@@ -32,7 +33,7 @@ export function useResizeObserver<T extends Element>(options: useResizeObserverO
3233

3334
onResize();
3435
});
35-
resizeObserverInstance.observe(element);
36+
resizeObserverInstance.observe(element, {box});
3637

3738
return () => {
3839
if (element) {
@@ -41,5 +42,5 @@ export function useResizeObserver<T extends Element>(options: useResizeObserverO
4142
};
4243
}
4344

44-
}, [onResize, ref]);
45+
}, [onResize, ref, box]);
4546
}

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

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import React, {
2424
useState
2525
} from 'react';
2626
import {Rect, Size} from '@react-stately/virtualizer';
27-
import {useLayoutEffect, useResizeObserver} from '@react-aria/utils';
27+
import {useEffectEvent, useLayoutEffect, useResizeObserver} from '@react-aria/utils';
2828
import {useLocale} from '@react-aria/i18n';
2929

3030
interface 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-
4341
function 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.

packages/@react-spectrum/card/stories/GridCardView.stories.tsx

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {action} from '@storybook/addon-actions';
1414
import {ActionButton} from '@react-spectrum/button';
1515
import {Card, CardView, GridLayout} from '../';
1616
import {ComponentStoryObj} from '@storybook/react';
17-
import {Content} from '@react-spectrum/view';
17+
import {Content, View} from '@react-spectrum/view';
1818
import {Flex} from '@react-spectrum/layout';
1919
import {getImageFullData} from './utils';
2020
import {GridLayoutOptions} from '../src/GridLayout';
@@ -530,3 +530,72 @@ export function CustomLayout(props: SpectrumCardViewProps<object> & LayoutOption
530530
</div>
531531
);
532532
}
533+
534+
export function ResizeObserverCrash() {
535+
const shots = [
536+
{id: 1, src: 'https://i.imgur.com/Z7AzH2c.jpg', alt: 'foo', label: 'foo'},
537+
{id: 2, src: 'https://i.imgur.com/Z7AzH2c.jpg', alt: 'bar', label: 'bar'},
538+
{id: 3, src: 'https://i.imgur.com/Z7AzH2c.jpg', alt: 'baz', label: 'baz'},
539+
{id: 4, src: 'https://i.imgur.com/Z7AzH2c.jpg', alt: 'qux', label: 'qux'},
540+
{id: 5, src: 'https://i.imgur.com/Z7AzH2c.jpg', alt: 'foobar', label: 'foobar'},
541+
{id: 6, src: 'https://i.imgur.com/Z7AzH2c.jpg', alt: 'foobaz', label: 'foobaz'}
542+
];
543+
544+
return (
545+
<View backgroundColor="gray-75" width="100vw" height="100vh">
546+
<div style={{position: 'relative', height: '100%', width: '100%'}}>
547+
<div
548+
style={{display: 'flex', flexDirection: 'column', height: '100%'}}>
549+
<React.Fragment key="foo">
550+
<Flex alignItems="center" gap="size-200" margin="size-350">
551+
<Heading level={3} margin="size-0">
552+
My Demo Asset
553+
</Heading>
554+
</Flex>
555+
<CardView
556+
items={shots}
557+
width="100%"
558+
flex
559+
position="relative"
560+
layout={GridLayout}
561+
selectionMode="none">
562+
{(shot: any) => (
563+
<Card key={shot.id}>
564+
<Flex
565+
UNSAFE_style={{
566+
position: 'absolute'
567+
}}
568+
direction="column"
569+
alignItems="center"
570+
justifyContent="center"
571+
gap="size-150"
572+
width="100%"
573+
height="calc(100% - 78px)">
574+
<Image
575+
src={shot.src}
576+
alt={shot.alt}
577+
width="200px"
578+
height="200px" />
579+
</Flex>
580+
<Flex
581+
direction="column"
582+
alignItems="start"
583+
marginTop="size-100">
584+
<Heading
585+
level={4}
586+
alignSelf="auto"
587+
UNSAFE_style={{
588+
fontWeight: 500
589+
}}>
590+
{shot.label}
591+
</Heading>
592+
</Flex>
593+
</Card>
594+
)}
595+
</CardView>
596+
</React.Fragment>
597+
</div>
598+
</div>
599+
</View>
600+
);
601+
}

0 commit comments

Comments
 (0)