Skip to content

Commit 7c5f1e8

Browse files
committed
refactor to use collection instead of isLoading in useLoadMoreSentinel
this allows us to only have one prop in the sentinel to control visiblility of the loader while still calling loadMore when the collection changes. Also gives the added benefit of causing load more to happen if items are deleted from the collection
1 parent f87438b commit 7c5f1e8

File tree

19 files changed

+162
-111
lines changed

19 files changed

+162
-111
lines changed

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

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

13-
import type {AsyncLoadable} from '@react-types/shared';
13+
import type {AsyncLoadable, Collection, Node} from '@react-types/shared';
1414
import {getScrollParent} from './getScrollParent';
1515
import {RefObject, useRef} from 'react';
1616
import {useEffectEvent} from './useEffectEvent';
1717
import {useLayoutEffect} from './useLayoutEffect';
1818

19-
export interface LoadMoreSentinelProps extends AsyncLoadable {
19+
export interface LoadMoreSentinelProps extends Omit<AsyncLoadable, 'isLoading'> {
20+
collection: Collection<Node<unknown>>,
2021
/**
2122
* The amount of offset from the bottom of your scrollable region that should trigger load more.
2223
* Uses a percentage value relative to the scroll body's client height. Load more is then triggered
@@ -29,28 +30,27 @@ export interface LoadMoreSentinelProps extends AsyncLoadable {
2930
}
3031

3132
export function UNSTABLE_useLoadMoreSentinel(props: LoadMoreSentinelProps, ref: RefObject<HTMLElement | null>): void {
32-
let {isLoading, onLoadMore, scrollOffset = 1} = props;
33+
let {collection, onLoadMore, scrollOffset = 1} = props;
3334

3435
let sentinelObserver = useRef<IntersectionObserver>(null);
3536

3637
let triggerLoadMore = useEffectEvent((entries: IntersectionObserverEntry[]) => {
3738
// Use "isIntersecting" over an equality check of 0 since it seems like there is cases where
3839
// a intersection ratio of 0 can be reported when isIntersecting is actually true
3940
for (let entry of entries) {
40-
if (entry.isIntersecting && !isLoading && onLoadMore) {
41+
// Note that this will be called if the collection changes, even if onLoadMore was already called and is being processed.
42+
// Up to user discretion as to how to handle these multiple onLoadMore calls
43+
if (entry.isIntersecting && onLoadMore) {
4144
onLoadMore();
4245
}
4346
}
4447
});
4548

4649
useLayoutEffect(() => {
4750
if (ref.current) {
48-
// TODO: problem with S2's Table loading spinner. Now that we use the isLoading provided to the sentinel in the layout to adjust the height of the loader,
49-
// we are getting space reserved for the loadMore spinner when doing initial loading and rendering empty state at the same time. We can somewhat fix this by providing isLoading={loadingState === 'loadingMore'}
50-
// which will mean the layout won't reserve space for the loader for initial loads, but that breaks the load more behavior (specifically, auto load more to fill scrollOffset. Scroll to load more seems broken to after initial load).
51-
// We need to tear down and set up a new IntersectionObserver to force a check if the sentinel is "in view", see https://codesandbox.io/p/sandbox/magical-swanson-dhgp89?file=%2Fsrc%2FApp.js%3A21%2C21
52-
// I've actually fixed this via a ListLayout change (TableLayout extends this) where I check "collection?.size === 0 || (collection.size === 1 && collection.getItem(collection.getFirstKey()!)!.type === 'loader')"
53-
// as well as isLoading, but it feels pretty opinionated/implementation specifc
51+
// Tear down and set up a new IntersectionObserver when the collection changes so that we can properly trigger additional loadMores if there is room for more items
52+
// Need to do this tear down and set up since using a large rootMargin will mean the observer's callback isn't called even when scrolling the item into view beause its visibility hasn't actually changed
53+
// https://codesandbox.io/p/sandbox/magical-swanson-dhgp89?file=%2Fsrc%2FApp.js%3A21%2C21
5454
sentinelObserver.current = new IntersectionObserver(triggerLoadMore, {root: getScrollParent(ref?.current) as HTMLElement, rootMargin: `0px ${100 * scrollOffset}% ${100 * scrollOffset}% ${100 * scrollOffset}%`});
5555
sentinelObserver.current.observe(ref.current);
5656
}
@@ -60,5 +60,5 @@ export function UNSTABLE_useLoadMoreSentinel(props: LoadMoreSentinelProps, ref:
6060
sentinelObserver.current.disconnect();
6161
}
6262
};
63-
}, [isLoading, triggerLoadMore, ref, scrollOffset]);
63+
}, [collection, triggerLoadMore, ref, scrollOffset]);
6464
}

packages/@react-spectrum/s2/src/CardView.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,6 @@ export const CardView = /*#__PURE__*/ (forwardRef as forwardRefType)(function Ca
245245
let renderer;
246246
let cardLoadingSentinel = (
247247
<UNSTABLE_GridListLoadingSentinel
248-
isLoading={props.loadingState === 'loading' || props.loadingState === 'filtering' || props.loadingState === 'loadingMore'}
249248
onLoadMore={onLoadMore} />
250249
);
251250

packages/@react-spectrum/s2/src/ComboBox.tsx

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -558,21 +558,16 @@ const ComboboxInner = forwardRef(function ComboboxInner(props: ComboBoxProps<any
558558
let renderer;
559559
let listBoxLoadingCircle = (
560560
<UNSTABLE_ListBoxLoadingSentinel
561-
// TODO: we only want the listbox loading sentinel row to have a height when performing a loadingMore
562-
// Unlike table, this works because the listbox's initial load isn't triggerd by the sentintel and thus the first
563-
// observation occurs when we've already loaded our first set of items rather than starting from empty, therefor flipping
564-
// isLoading here from true to false and triggering the creation of a new InterserctionObserver
561+
// Only show the spinner in the list when loading more
565562
isLoading={loadingState === 'loadingMore'}
566563
onLoadMore={onLoadMore}
567564
className={loadingWrapperStyles}>
568-
{loadingState === 'loadingMore' && (
569-
<ProgressCircle
570-
isIndeterminate
571-
size="S"
572-
styles={progressCircleStyles({size})}
573-
// Same loading string as table
574-
aria-label={stringFormatter.format('table.loadingMore')} />
575-
)}
565+
<ProgressCircle
566+
isIndeterminate
567+
size="S"
568+
styles={progressCircleStyles({size})}
569+
// Same loading string as table
570+
aria-label={stringFormatter.format('table.loadingMore')} />
576571
</UNSTABLE_ListBoxLoadingSentinel>
577572
);
578573

packages/@react-spectrum/s2/src/TableView.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -383,14 +383,12 @@ export const TableBody = /*#__PURE__*/ (forwardRef as forwardRefType)(function T
383383
// This is because we don't distinguish between loadingMore and loading in the layout, resulting in a different rect being used to build the body. Perhaps can be considered as a user error
384384
// if they pass loadingMore without having any other items in the table. Alternatively, could update the layout so it knows the current loading state.
385385
let loadMoreSpinner = (
386-
<UNSTABLE_TableLoadingSentinel isLoading={isLoading} onLoadMore={onLoadMore} className={style({height: 'full', width: 'full'})}>
387-
{loadingState === 'loadingMore' && (
388-
<div className={centeredWrapper}>
389-
<ProgressCircle
390-
isIndeterminate
391-
aria-label={stringFormatter.format('table.loadingMore')} />
392-
</div>
393-
)}
386+
<UNSTABLE_TableLoadingSentinel isLoading={loadingState === 'loadingMore'} onLoadMore={onLoadMore} className={style({height: 'full', width: 'full'})}>
387+
<div className={centeredWrapper}>
388+
<ProgressCircle
389+
isIndeterminate
390+
aria-label={stringFormatter.format('table.loadingMore')} />
391+
</div>
394392
</UNSTABLE_TableLoadingSentinel>
395393
);
396394

packages/@react-spectrum/s2/stories/ComboBox.stories.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ const AsyncComboBox = (args: any) => {
223223
}
224224

225225
// Slow down load so progress circle can appear
226-
await new Promise(resolve => setTimeout(resolve, 2000));
226+
await new Promise(resolve => setTimeout(resolve, args.delay));
227227
let res = await fetch(cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`, {signal});
228228
let json = await res.json();
229229

@@ -251,7 +251,8 @@ export const AsyncComboBoxStory = {
251251
render: AsyncComboBox,
252252
args: {
253253
...Example.args,
254-
label: 'Star Wars Character Lookup'
254+
label: 'Star Wars Character Lookup',
255+
delay: 50
255256
},
256257
name: 'Async loading combobox',
257258
parameters: {

packages/@react-spectrum/s2/stories/Picker.stories.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ const AsyncPicker = (args: any) => {
240240
}
241241

242242
// Slow down load so progress circle can appear
243-
await new Promise(resolve => setTimeout(resolve, 2000));
243+
await new Promise(resolve => setTimeout(resolve, args.delay));
244244
let res = await fetch(cursor || 'https://swapi.py4e.com/api/people/?search=', {signal});
245245
let json = await res.json();
246246
return {
@@ -260,7 +260,8 @@ const AsyncPicker = (args: any) => {
260260
export const AsyncPickerStory = {
261261
render: AsyncPicker,
262262
args: {
263-
...Example.args
263+
...Example.args,
264+
delay: 50
264265
},
265266
name: 'Async loading picker',
266267
parameters: {

packages/@react-spectrum/s2/stories/TableView.stories.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ const OnLoadMoreTable = (args: any) => {
428428
}
429429

430430
// Slow down load so progress circle can appear
431-
await new Promise(resolve => setTimeout(resolve, 2000));
431+
await new Promise(resolve => setTimeout(resolve, args.delay));
432432
let res = await fetch(cursor || 'https://swapi.py4e.com/api/people/?search=', {signal});
433433
let json = await res.json();
434434
return {
@@ -464,7 +464,8 @@ const OnLoadMoreTable = (args: any) => {
464464
export const OnLoadMoreTableStory = {
465465
render: OnLoadMoreTable,
466466
args: {
467-
...Example.args
467+
...Example.args,
468+
delay: 50
468469
},
469470
name: 'async loading table'
470471
};

packages/@react-spectrum/s2/test/Combobox.test.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,7 @@ describe('Combobox', () => {
4949
expect(within(comboboxTester.listbox!).getByTestId('loadMoreSentinel')).toBeInTheDocument();
5050
});
5151

52-
// TODO: will need to update these tests when I replace isLoading with colection in useLoadMoreSentinel
53-
it('should only call loadMore if loading is false', async () => {
52+
it('should only call loadMore whenever intersection is detected', async () => {
5453
let onLoadMore = jest.fn();
5554
let observe = jest.fn();
5655
let observer = setupIntersectionObserverMock({
@@ -92,6 +91,8 @@ describe('Combobox', () => {
9291

9392
act(() => {observer.instance.triggerCallback([{isIntersecting: true}]);});
9493
act(() => {jest.runAllTimers();});
95-
expect(onLoadMore).toHaveBeenCalledTimes(1);
94+
// Note that if this was using useAsyncList, we'd be shielded from extranous onLoadMore calls but
95+
// we want to leave that to user discretion
96+
expect(onLoadMore).toHaveBeenCalledTimes(2);
9697
});
9798
});

packages/@react-stately/layout/src/ListLayout.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -337,15 +337,12 @@ export class ListLayout<T, O extends ListLayoutOptions = ListLayoutOptions> exte
337337
}
338338

339339
protected buildLoader(node: Node<T>, x: number, y: number): LayoutNode {
340-
let collection = this.virtualizer!.collection;
341-
let isEmptyOrLoading = collection?.size === 0 || (collection.size === 1 && collection.getItem(collection.getFirstKey()!)!.type === 'loader');
342340
let rect = new Rect(x, y, this.padding, 0);
343341
let layoutInfo = new LayoutInfo('loader', node.key, rect);
344342
rect.width = this.virtualizer!.contentSize.width - this.padding - x;
345-
// TODO: Kinda gross but we also have to differentiate between isLoading and isLoadingMore so that we dont'reserve room
346-
// for the loadMore loader row when we are performing initial load. Is this too opinionated? Note that users creating their own layouts
347-
// may need to perform similar logic
348-
rect.height = node.props.isLoading && !isEmptyOrLoading ? this.loaderHeight ?? this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT : 0;
343+
// Note that if the user provides isLoading to their sentinel during a case where they only want to render the emptyState, this will reserve
344+
// room for the loader alongside rendering the emptyState
345+
rect.height = node.props.isLoading ? this.loaderHeight ?? this.rowHeight ?? this.estimatedRowHeight ?? DEFAULT_HEIGHT : 0;
349346

350347
return {
351348
layoutInfo,

packages/react-aria-components/src/GridList.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -497,23 +497,29 @@ function RootDropIndicator() {
497497
);
498498
}
499499

500-
export interface GridListLoadingIndicatorProps extends LoadMoreSentinelProps, StyleProps {
501-
children?: ReactNode
500+
export interface GridListLoadingSentinelProps extends Omit<LoadMoreSentinelProps, 'collection'>, StyleProps {
501+
/**
502+
* The load more spinner to render when loading additional items.
503+
*/
504+
children?: ReactNode,
505+
/**
506+
* Whether or not the loading spinner should be rendered or not.
507+
*/
508+
isLoading?: boolean
502509
}
503510

504-
export const UNSTABLE_GridListLoadingSentinel = createLeafComponent('loader', function GridListLoadingIndicator<T extends object>(props: GridListLoadingIndicatorProps, ref: ForwardedRef<HTMLDivElement>, item: Node<T>) {
511+
export const UNSTABLE_GridListLoadingSentinel = createLeafComponent('loader', function GridListLoadingIndicator<T extends object>(props: GridListLoadingSentinelProps, ref: ForwardedRef<HTMLDivElement>, item: Node<T>) {
505512
let state = useContext(ListStateContext)!;
506513
let {isVirtualized} = useContext(CollectionRendererContext);
507514
let {isLoading, onLoadMore, scrollOffset, ...otherProps} = props;
508515

509516
let sentinelRef = useRef(null);
510517
let memoedLoadMoreProps = useMemo(() => ({
511-
isLoading,
512518
onLoadMore,
513519
collection: state?.collection,
514520
sentinelRef,
515521
scrollOffset
516-
}), [isLoading, onLoadMore, scrollOffset, state?.collection]);
522+
}), [onLoadMore, scrollOffset, state?.collection]);
517523
UNSTABLE_useLoadMoreSentinel(memoedLoadMoreProps, sentinelRef);
518524

519525
let renderProps = useRenderProps({
@@ -531,7 +537,7 @@ export const UNSTABLE_GridListLoadingSentinel = createLeafComponent('loader', fu
531537
<div style={{position: 'relative', width: 0, height: 0}} inert={inertValue(true)} >
532538
<div data-testid="loadMoreSentinel" ref={sentinelRef} style={{position: 'absolute', height: 1, width: 1}} />
533539
</div>
534-
{isLoading && state.collection.size > 1 && renderProps.children && (
540+
{isLoading && renderProps.children && (
535541
<div
536542
role="row"
537543
aria-rowindex={isVirtualized ? item.index + 1 : undefined}

0 commit comments

Comments
 (0)