Skip to content

Commit 0bb9f21

Browse files
committed
refactor useLoadMore to get rid of scroll handlers
1 parent 2da7d04 commit 0bb9f21

File tree

15 files changed

+219
-92
lines changed

15 files changed

+219
-92
lines changed

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

Lines changed: 36 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
*/
1212

1313
import {Collection, Node} from '@react-types/shared';
14-
import {RefObject, useCallback, useRef} from 'react';
15-
import {useEvent} from './useEvent';
14+
import {RefObject, useRef} from 'react';
15+
import {useEffectEvent} from './useEffectEvent';
1616
import {useLayoutEffect} from './useLayoutEffect';
1717

1818
export interface LoadMoreProps {
@@ -28,82 +28,62 @@ export interface LoadMoreProps {
2828
* @default 1
2929
*/
3030
scrollOffset?: number,
31-
// TODO: will need to refactor the existing components that use items
32-
// this is a breaking change but this isn't documented and is in the react-aria/utils package so might be ok? Maybe can move this to a different package?
33-
// /** The data currently loaded. */
34-
// items?: any,
35-
collection?: Collection<Node<unknown>>
31+
// TODO: this is a breaking change but this isn't documented and is in the react-aria/utils package so might be ok? Maybe can move this to a different package?
32+
collection?: Collection<Node<unknown>>,
33+
/**
34+
* A ref to a sentinel element that is positioned at the end of the list's content. The visibility of this senetinel
35+
* with respect to the scrollable region and its offset determines if we should load more.
36+
*/
37+
sentinelRef: RefObject<HTMLElement | null>
3638
}
3739

3840
export function useLoadMore(props: LoadMoreProps, ref: RefObject<HTMLElement | null>) {
39-
let {isLoading, onLoadMore, scrollOffset = 1, collection} = props;
40-
41-
// Handle scrolling, and call onLoadMore when nearing the bottom.
42-
let onScroll = useCallback(() => {
43-
if (ref.current && !isLoading && onLoadMore) {
44-
let shouldLoadMore = ref.current.scrollHeight - ref.current.scrollTop - ref.current.clientHeight < ref.current.clientHeight * scrollOffset;
45-
46-
if (shouldLoadMore) {
47-
onLoadMore();
48-
}
49-
}
50-
}, [onLoadMore, isLoading, ref, scrollOffset]);
51-
41+
let {isLoading, onLoadMore, scrollOffset = 1, collection, sentinelRef} = props;
5242
let lastCollection = useRef(collection);
43+
5344
// If we are in a loading state when this hook is called and a collection is provided, we can assume that the collection will update in the future so we don't
5445
// want to trigger another loadMore until the collection has updated as a result of the load.
5546
// TODO: If the load doesn't end up updating the collection even after completion, this flag could get stuck as true. However, not tracking
5647
// this means we could end up calling onLoadMore multiple times if isLoading changes but the collection takes time to update
5748
let collectionAwaitingUpdate = useRef(collection && isLoading);
49+
let sentinelObserver = useRef<IntersectionObserver>(null);
50+
51+
let triggerLoadMore = useEffectEvent((entries: IntersectionObserverEntry[]) => {
52+
// Only one entry should exist so this should be ok. Also use "isIntersecting" over an equality check of 0 since it seems like there is cases where
53+
// a intersection ratio of 0 can be reported when isIntersecting is actually true
54+
if (entries[0].isIntersecting && !isLoading && !(collection && collectionAwaitingUpdate.current) && onLoadMore) {
55+
onLoadMore();
56+
if (collection !== null && lastCollection.current !== null) {
57+
collectionAwaitingUpdate.current = true;
58+
}
59+
}
60+
});
61+
62+
// TODO: maybe can optimize creating the intersection observer by adding it in a useLayoutEffect but would need said effect to run every render
63+
// so that we can catch when ref.current exists or is modified (maybe return a callbackRef?) and then would need to check if scrollOffset changed.
5864
useLayoutEffect(() => {
5965
if (!ref.current) {
6066
return;
6167
}
6268

63-
// Only update this flag if the collection changes when we aren't loading. Guard against the addition of a loading spinner when a load starts
64-
// which mutates the collection? Alternatively, the user might wipe the collection during load
65-
// If collection isn't provided, update flag
69+
// Only update this flag if the collection changes when we aren't loading. Guards against cases like the addition of a loading spinner when a load starts or if the user
70+
// temporarily wipes the collection during loading which isn't the collection update via fetch which we are waiting for.
71+
// If collection isn't provided (aka for RSP components which won't provide a collection), flip flag to false so we still trigger onLoadMore
6672
if (collection !== lastCollection.current && !isLoading) {
6773
collectionAwaitingUpdate.current = false;
6874
}
6975

70-
let observer = new IntersectionObserver((entries) => {
71-
let allItemsInView = true;
72-
entries.forEach((entry) => {
73-
// TODO this is problematic if the entry is a long row since a part of it will always out of view meaning the intersectionRatio is < 1
74-
if (entry.intersectionRatio < 1) {
75-
allItemsInView = false;
76-
}
77-
});
78-
79-
if (allItemsInView && !isLoading && !(collection && collectionAwaitingUpdate.current) && onLoadMore) {
80-
onLoadMore();
81-
if (collection !== null && lastCollection.current !== null) {
82-
collectionAwaitingUpdate.current = true;
83-
}
84-
}
85-
}, {root: ref.current});
86-
87-
// TODO: right now this is using the Virtualizer's div that wraps the rows, but perhaps should be the items themselves
88-
// This also has various problems because we'll need to figure out what is the proper element to compare the scroll container height to
89-
90-
let lastElement = ref.current.querySelector('[role="presentation"]');
91-
// let lastElement = ref.current.querySelector('[role="presentation"]>[role="presentation"]:last-child');
92-
if (lastElement) {
93-
observer.observe(lastElement);
76+
sentinelObserver.current = new IntersectionObserver(triggerLoadMore, {root: ref.current, rootMargin: `0px 0px ${100 * scrollOffset}% 0px`});
77+
if (sentinelRef?.current) {
78+
// console.log('observing', sentinelRef.current.outerHTML)
79+
sentinelObserver.current.observe(sentinelRef.current);
9480
}
9581

9682
lastCollection.current = collection;
9783
return () => {
98-
if (observer) {
99-
observer.disconnect();
84+
if (sentinelObserver.current) {
85+
sentinelObserver.current.disconnect();
10086
}
10187
};
102-
}, [isLoading, onLoadMore, props, ref, collection]);
103-
104-
105-
// TODO: maybe this should still just return scroll props?
106-
// Test against case where the ref isn't defined when this is called
107-
// Think this was a problem when trying to attach to the scrollable body of the table in OnLoadMoreTableBodyScroll
108-
useEvent(ref, 'scroll', onScroll);
88+
}, [isLoading, triggerLoadMore, ref, collection, scrollOffset, sentinelRef]);
10989
}

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
// @ts-ignore
1414
import {flushSync} from 'react-dom';
1515
import {getScrollLeft} from './utils';
16+
import {inertValue, useEffectEvent, useEvent, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils';
1617
import React, {
1718
CSSProperties,
1819
ForwardedRef,
@@ -25,7 +26,6 @@ import React, {
2526
useState
2627
} from 'react';
2728
import {Rect, Size} from '@react-stately/virtualizer';
28-
import {useEffectEvent, useEvent, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils';
2929
import {useLocale} from '@react-aria/i18n';
3030

3131
interface ScrollViewProps extends HTMLAttributes<HTMLElement> {
@@ -35,26 +35,32 @@ interface ScrollViewProps extends HTMLAttributes<HTMLElement> {
3535
innerStyle?: CSSProperties,
3636
onScrollStart?: () => void,
3737
onScrollEnd?: () => void,
38-
scrollDirection?: 'horizontal' | 'vertical' | 'both'
38+
scrollDirection?: 'horizontal' | 'vertical' | 'both',
39+
sentinelRef: React.RefObject<HTMLDivElement | null>
3940
}
4041

42+
interface ScrollViewOptions extends Omit<ScrollViewProps, 'sentinelRef'> {}
43+
4144
function ScrollView(props: ScrollViewProps, ref: ForwardedRef<HTMLDivElement | null>) {
45+
let {sentinelRef, ...otherProps} = props;
4246
ref = useObjectRef(ref);
43-
let {scrollViewProps, contentProps} = useScrollView(props, ref);
47+
let {scrollViewProps, contentProps} = useScrollView(otherProps, ref);
4448

4549
return (
4650
<div role="presentation" {...scrollViewProps} ref={ref}>
4751
<div {...contentProps}>
4852
{props.children}
4953
</div>
54+
{/* @ts-ignore - compatibility with React < 19 */}
55+
<div data-testid="loadMoreSentinel" ref={sentinelRef} style={{height: 1, width: 1}} inert={inertValue(true)} />
5056
</div>
5157
);
5258
}
5359

5460
const ScrollViewForwardRef = React.forwardRef(ScrollView);
5561
export {ScrollViewForwardRef as ScrollView};
5662

57-
export function useScrollView(props: ScrollViewProps, ref: RefObject<HTMLElement | null>) {
63+
export function useScrollView(props: ScrollViewOptions, ref: RefObject<HTMLElement | null>) {
5864
let {
5965
contentSize,
6066
onVisibleRectChange,
@@ -135,7 +141,7 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject<HTMLElement
135141
// Attach event directly to ref so RAC Virtualizer doesn't need to send props upward.
136142
useEvent(ref, 'scroll', onScroll);
137143

138-
144+
139145
useEffect(() => {
140146
return () => {
141147
if (state.scrollTimeout != null) {

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

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import {Collection, Key, RefObject} from '@react-types/shared';
1414
import {Layout, Rect, ReusableView, useVirtualizerState} from '@react-stately/virtualizer';
1515
import {mergeProps, useLoadMore, useObjectRef} from '@react-aria/utils';
16-
import React, {ForwardedRef, HTMLAttributes, ReactElement, ReactNode, useCallback} from 'react';
16+
import React, {ForwardedRef, HTMLAttributes, ReactElement, ReactNode, useCallback, useRef} from 'react';
1717
import {ScrollView} from './ScrollView';
1818
import {VirtualizerItem} from './VirtualizerItem';
1919

@@ -68,21 +68,25 @@ export const Virtualizer = React.forwardRef(function Virtualizer<T extends objec
6868
layoutOptions
6969
});
7070

71-
useLoadMore({isLoading, onLoadMore, scrollOffset: 1}, ref);
71+
let sentinelRef = useRef(null);
72+
useLoadMore({isLoading, onLoadMore, scrollOffset: 1, sentinelRef}, ref);
7273
let onVisibleRectChange = useCallback((rect: Rect) => {
7374
state.setVisibleRect(rect);
7475
}, [state]);
7576

7677
return (
77-
<ScrollView
78-
{...mergeProps(otherProps, {onVisibleRectChange})}
79-
ref={ref}
80-
contentSize={state.contentSize}
81-
onScrollStart={state.startScrolling}
82-
onScrollEnd={state.endScrolling}
83-
scrollDirection={scrollDirection}>
84-
{renderChildren(null, state.visibleViews, renderWrapper || defaultRenderWrapper)}
85-
</ScrollView>
78+
<>
79+
<ScrollView
80+
{...mergeProps(otherProps, {onVisibleRectChange})}
81+
ref={ref}
82+
sentinelRef={sentinelRef}
83+
contentSize={state.contentSize}
84+
onScrollStart={state.startScrolling}
85+
onScrollEnd={state.endScrolling}
86+
scrollDirection={scrollDirection}>
87+
{renderChildren(null, state.visibleViews, renderWrapper || defaultRenderWrapper)}
88+
</ScrollView>
89+
</>
8690
);
8791
}) as <T extends object, V, O>(props: VirtualizerProps<T, V, O> & {ref?: RefObject<HTMLDivElement | null>}) => ReactElement;
8892

packages/@react-spectrum/card/test/CardView.test.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
jest.mock('@react-aria/utils/src/scrollIntoView');
14-
import {act, fireEvent, pointerMap, render, within} from '@react-spectrum/test-utils-internal';
14+
import {act, fireEvent, pointerMap, render, setupIntersectionObserverMock, within} from '@react-spectrum/test-utils-internal';
1515
import {Card, CardView, GalleryLayout, GridLayout, WaterfallLayout} from '../';
1616
import {composeStories} from '@storybook/react';
1717
import {Content} from '@react-spectrum/view';
@@ -1186,6 +1186,10 @@ describe('CardView', function () {
11861186
${'Grid layout'} | ${GridLayout}
11871187
${'Gallery layout'} | ${GalleryLayout}
11881188
`('$Name CardView should call loadMore when scrolling to the bottom', async function ({layout}) {
1189+
let observe = jest.fn();
1190+
let observer = setupIntersectionObserverMock({
1191+
observe
1192+
});
11891193
let scrollHeightMock = jest.spyOn(window.HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(() => 3000);
11901194
let onLoadMore = jest.fn();
11911195
let tree = render(<DynamicCardView layout={layout} onLoadMore={onLoadMore} />);
@@ -1196,10 +1200,13 @@ describe('CardView', function () {
11961200

11971201
let cards = tree.getAllByRole('gridcell');
11981202
expect(cards).toBeTruthy();
1203+
let sentinel = tree.getByTestId('loadMoreSentinel');
1204+
expect(observe).toHaveBeenLastCalledWith(sentinel);
11991205

12001206
let grid = tree.getByRole('grid');
12011207
grid.scrollTop = 3000;
12021208
fireEvent.scroll(grid);
1209+
act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);});
12031210
expect(onLoadMore).toHaveBeenCalledTimes(1);
12041211
scrollHeightMock.mockReset();
12051212
});

packages/@react-spectrum/combobox/test/ComboBox.test.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
jest.mock('@react-aria/live-announcer');
14-
import {act, fireEvent, pointerMap, render, simulateDesktop, simulateMobile, waitFor, within} from '@react-spectrum/test-utils-internal';
14+
import {act, fireEvent, pointerMap, render, setupIntersectionObserverMock, simulateDesktop, simulateMobile, waitFor, within} from '@react-spectrum/test-utils-internal';
1515
import {announce} from '@react-aria/live-announcer';
1616
import {Button} from '@react-spectrum/button';
1717
import {chain} from '@react-aria/utils';
@@ -2074,6 +2074,7 @@ describe('ComboBox', function () {
20742074
jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 1000);
20752075
});
20762076
it('onLoadMore is called on initial open', async () => {
2077+
let observer = setupIntersectionObserverMock();
20772078
load = jest
20782079
.fn()
20792080
.mockImplementationOnce(() => {
@@ -2111,6 +2112,7 @@ describe('ComboBox', function () {
21112112
expect(listbox).toBeVisible();
21122113
// update size, virtualizer raf kicks in
21132114
act(() => {jest.advanceTimersToNextTimer();});
2115+
act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);});
21142116
// onLoadMore queued by previous timer, run it now
21152117
act(() => {jest.advanceTimersToNextTimer();});
21162118

@@ -2137,6 +2139,7 @@ describe('ComboBox', function () {
21372139
});
21382140

21392141
it('onLoadMore is not called on when previously opened', async () => {
2142+
let observer = setupIntersectionObserverMock();
21402143
load = jest
21412144
.fn()
21422145
.mockImplementationOnce(() => {
@@ -2173,6 +2176,7 @@ describe('ComboBox', function () {
21732176
expect(listbox).toBeVisible();
21742177
// update size, virtualizer raf kicks in
21752178
act(() => {jest.advanceTimersToNextTimer();});
2179+
act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);});
21762180
// onLoadMore queued by previous timer, run it now
21772181
act(() => {jest.advanceTimersToNextTimer();});
21782182

packages/@react-spectrum/listbox/test/ListBox.test.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {act, fireEvent, mockClickDefault, pointerMap, render, within} from '@react-spectrum/test-utils-internal';
13+
import {act, fireEvent, mockClickDefault, pointerMap, render, setupIntersectionObserverMock, within} from '@react-spectrum/test-utils-internal';
1414
import Bell from '@spectrum-icons/workflow/Bell';
1515
import {FocusExample} from '../stories/ListBox.stories';
1616
import {Item, ListBox, Section} from '../';
@@ -886,6 +886,10 @@ describe('ListBox', function () {
886886
});
887887

888888
it('should fire onLoadMore when scrolling near the bottom', function () {
889+
let observe = jest.fn();
890+
let observer = setupIntersectionObserverMock({
891+
observe
892+
});
889893
// Mock clientHeight to match maxHeight prop
890894
let maxHeight = 200;
891895
jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => maxHeight);
@@ -903,7 +907,7 @@ describe('ListBox', function () {
903907

904908
return 48;
905909
});
906-
let {getByRole} = render(
910+
let {getByRole, getByTestId} = render(
907911
<Provider theme={theme}>
908912
<ListBox aria-label="listbox" items={items} maxHeight={maxHeight} onLoadMore={onLoadMore}>
909913
{item => <Item key={item.name}>{item.name}</Item>}
@@ -916,6 +920,8 @@ describe('ListBox', function () {
916920
let listbox = getByRole('listbox');
917921
let options = within(listbox).getAllByRole('option');
918922
expect(options.length).toBe(6); // each row is 48px tall, listbox is 200px. 5 rows fit. + 1/3 overscan
923+
let sentinel = getByTestId('loadMoreSentinel');
924+
expect(observe).toHaveBeenLastCalledWith(sentinel);
919925

920926
listbox.scrollTop = 250;
921927
fireEvent.scroll(listbox);
@@ -929,13 +935,15 @@ describe('ListBox', function () {
929935
// there are no more items to load at this height, so loadMore is only called twice
930936
listbox.scrollTop = 5000;
931937
fireEvent.scroll(listbox);
938+
act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);});
932939
act(() => jest.runAllTimers());
933940

934941
expect(onLoadMore).toHaveBeenCalledTimes(1);
935942
scrollHeightMock.mockReset();
936943
});
937944

938945
it('should fire onLoadMore if there aren\'t enough items to fill the ListBox ', async function () {
946+
let observer = setupIntersectionObserverMock();
939947
// Mock clientHeight to match maxHeight prop
940948
let maxHeight = 300;
941949
jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => maxHeight);
@@ -1000,9 +1008,11 @@ describe('ListBox', function () {
10001008
let {getByRole} = render(
10011009
<AsyncListBox />
10021010
);
1011+
10031012
await act(async () => {
10041013
jest.runAllTimers();
10051014
});
1015+
await act(async () => {observer.instance.triggerCallback([{isIntersecting: true}]);});
10061016

10071017
let listbox = getByRole('listbox');
10081018
let options = within(listbox).getAllByRole('option');

packages/@react-spectrum/table/src/TableViewBase.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -584,8 +584,8 @@ function TableVirtualizer<T>(props: TableVirtualizerProps<T>) {
584584
columnWidths: columnResizeState.columnWidths
585585
}), [columnResizeState.columnWidths])
586586
});
587-
588-
useLoadMore({isLoading, onLoadMore, scrollOffset: 1}, bodyRef);
587+
let sentinelRef = useRef(null);
588+
useLoadMore({isLoading, onLoadMore, scrollOffset: 1, sentinelRef}, bodyRef);
589589
let onVisibleRectChange = useCallback((rect: Rect) => {
590590
state.setVisibleRect(rect);
591591
}, [state]);
@@ -694,6 +694,7 @@ function TableVirtualizer<T>(props: TableVirtualizerProps<T>) {
694694
}}
695695
innerStyle={{overflow: 'visible'}}
696696
ref={bodyRef}
697+
sentinelRef={sentinelRef}
697698
contentSize={state.contentSize}
698699
onVisibleRectChange={onVisibleRectChangeMemo}
699700
onScrollStart={state.startScrolling}

0 commit comments

Comments
 (0)