pl-fe: Replace virtuoso with tanstack virtual for scrollable list
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
@ -1,43 +1,14 @@
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Virtuoso, Components, VirtuosoProps, VirtuosoHandle, ListRange, IndexLocationWithAlign } from 'react-virtuoso';
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { useVirtualizer, useWindowVirtualizer, type Virtualizer } from '@tanstack/react-virtual';
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
|
||||
import { useSettings } from 'pl-fe/hooks';
|
||||
|
||||
import LoadMore from './load-more';
|
||||
import { Card, Spinner } from './ui';
|
||||
|
||||
/** Custom Viruoso component context. */
|
||||
type Context = {
|
||||
itemClassName?: string;
|
||||
listClassName?: string;
|
||||
}
|
||||
|
||||
/** Scroll position saved in sessionStorage. */
|
||||
type SavedScrollPosition = {
|
||||
index: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/** Custom Virtuoso Item component representing a single scrollable item. */
|
||||
// NOTE: It's crucial to space lists with **padding** instead of margin!
|
||||
// Pass an `itemClassName` like `pb-3`, NOT a `space-y-3` className
|
||||
// https://virtuoso.dev/troubleshooting#list-does-not-scroll-to-the-bottom--items-jump-around
|
||||
const Item: Components<JSX.Element, Context>['Item'] = ({ context, ...rest }) => (
|
||||
<div className={context?.itemClassName} {...rest} />
|
||||
);
|
||||
|
||||
/** Custom Virtuoso List component for the outer container. */
|
||||
// Ensure the className winds up here
|
||||
const List: Components<JSX.Element, Context>['List'] = React.forwardRef((props, ref) => {
|
||||
const { context, ...rest } = props;
|
||||
return <div ref={ref} className={context?.listClassName} {...rest} />;
|
||||
});
|
||||
|
||||
interface IScrollableList extends VirtuosoProps<any, any> {
|
||||
/** Unique key to preserve the scroll position when navigating back. */
|
||||
scrollKey?: string;
|
||||
interface IScrollableList {
|
||||
/** Pagination callback when the end of the list is reached. */
|
||||
onLoadMore?: () => void;
|
||||
/** Whether the data is currently being fetched. */
|
||||
@ -64,12 +35,7 @@ interface IScrollableList extends VirtuosoProps<any, any> {
|
||||
placeholderComponent?: React.ComponentType | React.NamedExoticComponent;
|
||||
/** Number of placeholders to render while loading. */
|
||||
placeholderCount?: number;
|
||||
/**
|
||||
* Pull to refresh callback.
|
||||
* @deprecated Put a PTR around the component instead.
|
||||
*/
|
||||
onRefresh?: () => Promise<any>;
|
||||
/** Extra class names on the Virtuoso element. */
|
||||
/** Extra class names on the parent element. */
|
||||
className?: string;
|
||||
/** Extra class names on the list element. */
|
||||
listClassName?: string;
|
||||
@ -77,17 +43,19 @@ interface IScrollableList extends VirtuosoProps<any, any> {
|
||||
itemClassName?: string;
|
||||
/** Extra class names on the LoadMore element */
|
||||
loadMoreClassName?: string;
|
||||
/** `id` attribute on the Virtuoso element. */
|
||||
/** `id` attribute on the parent element. */
|
||||
id?: string;
|
||||
/** CSS styles on the Virtuoso element. */
|
||||
/** CSS styles on the parent element. */
|
||||
style?: React.CSSProperties;
|
||||
/** Whether to use the window to scroll the content instead of Virtuoso's container. */
|
||||
/** Whether to use the window to scroll the content instead of the container. */
|
||||
useWindowScroll?: boolean;
|
||||
/** Initial item index to scroll to. */
|
||||
initialIndex?: number;
|
||||
/** Estimated size for items */
|
||||
estimatedSize?: number;
|
||||
}
|
||||
|
||||
/** Legacy ScrollableList with Virtuoso for backwards-compatibility. */
|
||||
const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
|
||||
scrollKey,
|
||||
const ScrollableList = React.forwardRef<Virtualizer<any, any>, IScrollableList>(({
|
||||
prepend = null,
|
||||
alwaysPrepend,
|
||||
children,
|
||||
@ -95,7 +63,6 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
|
||||
emptyMessage,
|
||||
emptyMessageCard = true,
|
||||
showLoading,
|
||||
onRefresh,
|
||||
onScroll,
|
||||
onScrollToTop,
|
||||
onLoadMore,
|
||||
@ -107,58 +74,70 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
|
||||
hasMore,
|
||||
placeholderComponent: Placeholder,
|
||||
placeholderCount = 0,
|
||||
initialTopMostItemIndex = 0,
|
||||
initialIndex = 0,
|
||||
style = {},
|
||||
useWindowScroll = true,
|
||||
estimatedSize = 300,
|
||||
}, ref) => {
|
||||
const history = useHistory();
|
||||
const { autoloadMore } = useSettings();
|
||||
|
||||
// Preserve scroll position
|
||||
const scrollDataKey = `plfe:scrollData:${scrollKey}`;
|
||||
const scrollData: SavedScrollPosition | null = useMemo(() => JSON.parse(sessionStorage.getItem(scrollDataKey)!), [scrollDataKey]);
|
||||
const topIndex = useRef<number>(scrollData ? scrollData.index : 0);
|
||||
const topOffset = useRef<number>(scrollData ? scrollData.offset : 0);
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
/** Normalized children. */
|
||||
const elements = Array.from(children || []);
|
||||
|
||||
const showPlaceholder = showLoading && Placeholder && placeholderCount > 0;
|
||||
|
||||
// NOTE: We are doing some trickery to load a feed of placeholders
|
||||
// Virtuoso's `EmptyPlaceholder` unfortunately doesn't work for our use-case
|
||||
const data = showPlaceholder ? Array(placeholderCount).fill('') : elements;
|
||||
|
||||
// Add a placeholder at the bottom for loading
|
||||
// (Don't use Virtuoso's `Footer` component because it doesn't preserve its height)
|
||||
if (hasMore && (autoloadMore || isLoading) && Placeholder) {
|
||||
data.push(<Placeholder />);
|
||||
} else if (hasMore && (autoloadMore || isLoading)) {
|
||||
data.push(<Spinner />);
|
||||
}
|
||||
|
||||
const handleScroll = useCallback(debounce(() => {
|
||||
// HACK: Virtuoso has no better way to get this...
|
||||
const node = document.querySelector(`[data-virtuoso-scroller] [data-item-index="${topIndex.current}"]`);
|
||||
if (node) {
|
||||
topOffset.current = node.getBoundingClientRect().top * -1;
|
||||
} else {
|
||||
topOffset.current = 0;
|
||||
}
|
||||
}, 150, { trailing: true }), []);
|
||||
const virtualizer = useWindowScroll ? useWindowVirtualizer({
|
||||
count: data.length + (hasMore ? 1 : 0),
|
||||
overscan: 3,
|
||||
// scrollMargin: parentRef.current?.offsetTop ?? 0,
|
||||
estimateSize: () => estimatedSize,
|
||||
}) : useVirtualizer({
|
||||
count: data.length + (hasMore ? 1 : 0),
|
||||
overscan: 3,
|
||||
// scrollMargin: parentRef.current?.offsetTop ?? 0,
|
||||
estimateSize: () => estimatedSize,
|
||||
getScrollElement: () => parentRef.current,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('scroll', handleScroll);
|
||||
sessionStorage.removeItem(scrollDataKey);
|
||||
if (typeof ref === 'function') ref(virtualizer); else if (ref !== null) ref.current = virtualizer;
|
||||
}, [virtualizer]);
|
||||
|
||||
return () => {
|
||||
if (scrollKey) {
|
||||
const data: SavedScrollPosition = { index: topIndex.current, offset: topOffset.current };
|
||||
sessionStorage.setItem(scrollDataKey, JSON.stringify(data));
|
||||
}
|
||||
document.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, []);
|
||||
const range = virtualizer.calculateRange();
|
||||
|
||||
useEffect(() => {
|
||||
if (showLoading) return;
|
||||
|
||||
if (typeof initialIndex === 'number') virtualizer.scrollToIndex(initialIndex);
|
||||
}, [showLoading, initialIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (range?.startIndex === 0) {
|
||||
onScrollToTop?.();
|
||||
} else onScroll?.();
|
||||
}, [range?.startIndex === 0]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onLoadMore && range?.endIndex === data.length && !showLoading && autoloadMore && hasMore) {
|
||||
onLoadMore();
|
||||
}
|
||||
}, [range?.endIndex]);
|
||||
|
||||
const loadMore = useMemo(() => {
|
||||
if (autoloadMore || !hasMore || !onLoadMore) {
|
||||
return null;
|
||||
} else {
|
||||
const button = <LoadMore className='mt-2' visible={!isLoading} onClick={onLoadMore} />;
|
||||
|
||||
if (loadMoreClassName) return <div className={loadMoreClassName}>{button}</div>;
|
||||
|
||||
return button;
|
||||
}
|
||||
}, [autoloadMore, hasMore, isLoading]);
|
||||
|
||||
/* Render an empty state instead of the scrollable list. */
|
||||
const renderEmpty = (): JSX.Element => (
|
||||
@ -179,94 +158,52 @@ const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
|
||||
</div>
|
||||
);
|
||||
|
||||
/** Render a single item. */
|
||||
const renderItem = (_i: number, element: JSX.Element): JSX.Element => {
|
||||
if (showPlaceholder) {
|
||||
return <Placeholder />;
|
||||
} else {
|
||||
return element;
|
||||
}
|
||||
const renderItem = (index: number): JSX.Element => {
|
||||
const PlaceholderComponent = Placeholder || Spinner;
|
||||
if (index === data.length) return (isLoading) ? <PlaceholderComponent /> : loadMore || <div className='h-4' />;
|
||||
if (showPlaceholder) return <PlaceholderComponent />;
|
||||
return data[index];
|
||||
};
|
||||
|
||||
const handleEndReached = () => {
|
||||
if (autoloadMore && hasMore && onLoadMore) {
|
||||
onLoadMore();
|
||||
}
|
||||
};
|
||||
|
||||
const loadMore = () => {
|
||||
if (autoloadMore || !hasMore || !onLoadMore) {
|
||||
return null;
|
||||
} else {
|
||||
const button = <LoadMore visible={!isLoading} onClick={onLoadMore} />;
|
||||
|
||||
if (loadMoreClassName) return <div className={loadMoreClassName}>{button}</div>;
|
||||
|
||||
return button;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRangeChange = (range: ListRange) => {
|
||||
// HACK: using the first index can be buggy.
|
||||
// Track the second item instead, unless the endIndex comes before it (eg one 1 item in view).
|
||||
topIndex.current = Math.min(range.startIndex + 1, range.endIndex);
|
||||
handleScroll();
|
||||
};
|
||||
|
||||
/** Figure out the initial index to scroll to. */
|
||||
const initialIndex = useMemo<number | IndexLocationWithAlign>(() => {
|
||||
if (showLoading) return 0;
|
||||
|
||||
if (initialTopMostItemIndex) {
|
||||
if (typeof initialTopMostItemIndex === 'number') {
|
||||
return {
|
||||
align: 'start',
|
||||
index: initialTopMostItemIndex,
|
||||
offset: 60,
|
||||
};
|
||||
}
|
||||
return initialTopMostItemIndex;
|
||||
}
|
||||
|
||||
if (scrollData && history.action === 'POP') {
|
||||
return {
|
||||
align: 'start',
|
||||
index: scrollData.index,
|
||||
offset: scrollData.offset,
|
||||
};
|
||||
}
|
||||
|
||||
return 0;
|
||||
}, [showLoading, initialTopMostItemIndex]);
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
ref={ref}
|
||||
<div
|
||||
ref={parentRef}
|
||||
id={id}
|
||||
useWindowScroll={useWindowScroll}
|
||||
data={data}
|
||||
totalCount={data.length}
|
||||
startReached={onScrollToTop}
|
||||
endReached={handleEndReached}
|
||||
isScrolling={isScrolling => isScrolling && onScroll && onScroll()}
|
||||
itemContent={renderItem}
|
||||
initialTopMostItemIndex={initialIndex}
|
||||
rangeChanged={handleRangeChange}
|
||||
className={className}
|
||||
className={clsx(className, 'w-full')}
|
||||
style={style}
|
||||
context={{
|
||||
listClassName,
|
||||
itemClassName,
|
||||
}}
|
||||
components={{
|
||||
Header: () => <>{prepend}</>,
|
||||
ScrollSeekPlaceholder: Placeholder as any,
|
||||
EmptyPlaceholder: () => renderEmpty(),
|
||||
List,
|
||||
Item,
|
||||
Footer: loadMore,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<div
|
||||
className={listClassName}
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{!showLoading && data.length ? (
|
||||
<>
|
||||
{prepend}
|
||||
{virtualItems.map((item) => (
|
||||
<div
|
||||
className={item.index === data.length ? '' : itemClassName}
|
||||
key={item.key as number}
|
||||
data-index={item.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
transform: `translateY(${item.start}px)`,
|
||||
}}
|
||||
>
|
||||
{renderItem(item.index)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : renderEmpty()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import clsx from 'clsx';
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import LoadGap from 'pl-fe/components/load-gap';
|
||||
@ -15,7 +15,6 @@ import { Stack, Text } from './ui';
|
||||
|
||||
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import type { IScrollableList } from 'pl-fe/components/scrollable-list';
|
||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||
|
||||
interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
|
||||
/** Unique key to preserve the scroll position when navigating back. */
|
||||
@ -62,7 +61,6 @@ const StatusList: React.FC<IStatusList> = ({
|
||||
...other
|
||||
}) => {
|
||||
const plFeConfig = usePlFeConfig();
|
||||
const node = useRef<VirtuosoHandle>(null);
|
||||
|
||||
const getFeaturedStatusCount = () => featuredStatusIds?.size || 0;
|
||||
|
||||
@ -96,14 +94,6 @@ const StatusList: React.FC<IStatusList> = ({
|
||||
const element = document.querySelector<HTMLDivElement>(selector);
|
||||
|
||||
if (element) element.focus();
|
||||
|
||||
node.current?.scrollIntoView({
|
||||
index,
|
||||
behavior: 'smooth',
|
||||
done: () => {
|
||||
if (!element) document.querySelector<HTMLDivElement>(selector)?.focus();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderLoadGap = (index: number) => {
|
||||
@ -179,7 +169,6 @@ const StatusList: React.FC<IStatusList> = ({
|
||||
return statusIds.toList().reduce((acc, statusId, index) => {
|
||||
if (statusId === null) {
|
||||
const gap = renderLoadGap(index);
|
||||
// one does not simply push a null item to Virtuoso: https://github.com/petyosi/react-virtuoso/issues/206#issuecomment-747363793
|
||||
if (gap) {
|
||||
acc.push(gap);
|
||||
}
|
||||
@ -234,10 +223,10 @@ const StatusList: React.FC<IStatusList> = ({
|
||||
onLoadMore={handleLoadOlder}
|
||||
placeholderComponent={() => <PlaceholderStatus variant={divideType === 'border' ? 'slim' : 'rounded'} />}
|
||||
placeholderCount={20}
|
||||
ref={node}
|
||||
className={className}
|
||||
listClassName={clsx('divide-y divide-solid divide-gray-200 dark:divide-gray-800', {
|
||||
'divide-none': divideType !== 'border',
|
||||
}, className)}
|
||||
})}
|
||||
itemClassName={clsx({
|
||||
'pb-3': divideType !== 'border',
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user