diff --git a/front/apps/web/package.json b/front/apps/web/package.json index 380ccc91..199f1470 100644 --- a/front/apps/web/package.json +++ b/front/apps/web/package.json @@ -22,6 +22,7 @@ "@radix-ui/react-select": "^2.0.0", "@tanstack/react-query": "^5.12.1", "@tanstack/react-query-devtools": "^5.12.2", + "@tanstack/react-virtual": "^3.0.1", "array-shuffle": "^3.0.0", "expo-linear-gradient": "^12.5.0", "expo-modules-core": "^1.5.12", diff --git a/front/packages/ui/src/fetch-infinite.web.tsx b/front/packages/ui/src/fetch-infinite.web.tsx index a0cd929b..103a5ae0 100644 --- a/front/packages/ui/src/fetch-infinite.web.tsx +++ b/front/packages/ui/src/fetch-infinite.web.tsx @@ -33,22 +33,23 @@ import { import { Stylable, nativeStyleToCss, useYoshiki, ysMap } from "yoshiki"; import { EmptyView, ErrorView, Layout, WithLoading, addHeader } from "./fetch"; import type { ContentStyle } from "@shopify/flash-list"; +import { useVirtualizer } from "@tanstack/react-virtual"; -const InfiniteScroll = ({ - children, - loader, +const InfiniteScroll = ({ + data, + renderItem, layout, loadMore, - hasMore = true, + hasMore, isFetching, Header, headerProps, - fetchMore = true, contentContainerStyle, + getItemSize, ...props }: { - children?: ReactElement | (ReactElement | null)[] | null; - loader?: (ReactElement | null)[]; + data: T[]; + renderItem: (item: T, index: number) => ReactElement; layout: Layout; loadMore: () => void; hasMore: boolean; @@ -57,12 +58,21 @@ const InfiniteScroll = ({ headerProps?: Props; fetchMore?: boolean; contentContainerStyle?: ContentStyle; + getItemSize: (x: T, idx: number) => number; } & Stylable) => { const ref = useRef(null); + const containerRef = useRef(null); + const virtualizer = useVirtualizer({ + getScrollElement: () => ref.current, + horizontal: layout.layout === "horizontal", + count: data.length + +hasMore, + estimateSize: (i) => (i === data.length ? 0 : getItemSize(data[i], i)), + overscan: 5, + }); const { css } = useYoshiki(); const onScroll = useCallback(() => { - if (!ref.current || !hasMore || isFetching || !fetchMore) return; + if (!ref.current || !hasMore || isFetching) return; const scroll = layout.layout === "horizontal" ? ref.current.scrollWidth - ref.current.scrollLeft @@ -72,7 +82,7 @@ const InfiniteScroll = ({ // Load more if less than 3 element's worth of scroll is left if (scroll <= offset * 3) loadMore(); - }, [hasMore, isFetching, layout, loadMore, fetchMore]); + }, [hasMore, isFetching, layout, loadMore]); const scrollProps = { ref, onScroll }; // Automatically trigger a scroll check on start and after a fetch end in case the user is already @@ -87,6 +97,7 @@ const InfiniteScroll = ({ [ { display: "grid", + height: `${virtualizer.getTotalSize()}px`, gridAutoRows: "max-content", // the as any is due to differencies between css types of native and web (already accounted for in yoshiki) gridGap: layout.gap as any, @@ -118,8 +129,20 @@ const InfiniteScroll = ({ nativeStyleToCss(props), )} > - {children} - {isFetching && loader} + {virtualizer.getVirtualItems().map((x) => ( +
+ {renderItem(data[x.index], x.index)} +
+ ))} ); @@ -139,7 +162,12 @@ const InfiniteScroll = ({ ); }; -export const InfiniteFetchList = ({ +export const InfiniteFetchList = < + Data extends { id: string }, + _, + HeaderProps, + Kind extends number | string, +>({ query, incremental = false, placeholderCount = 2, @@ -151,6 +179,7 @@ export const InfiniteFetchList = { const oldItems = useRef(); - const { items, error, fetchNextPage, hasNextPage, isFetching } = query; + let { items, error, fetchNextPage, hasNextPage, isFetching } = query; if (incremental && items) oldItems.current = items; if (error) return addHeader(Header, , headerProps); @@ -182,29 +211,32 @@ export const InfiniteFetchList = , headerProps); } + if (incremental) items ??= oldItems.current; + const placeholders = [...Array(placeholderCount)].map( + (_, i) => ({ id: `gen${i}`, isLoading: true }) as unknown as Data, + ); return ( ( - - {Divider && i !== 0 && (Divider === true ?
: )} - {children({ isLoading: true } as any, i)} -
- ))} Header={Header} headerProps={headerProps} - {...props} - > - {(items ?? oldItems.current)?.map((item, i) => ( + data={isFetching ? [...(items || []), ...placeholders] : items ?? []} + renderItem={(item, i) => ( {Divider && i !== 0 && (Divider === true ?
: )} {children({ ...item, isLoading: false } as any, i)}
- ))} -
+ )} + getItemSize={(x, i) => + getItemSize && getItemType + ? getItemSize(getItemType({ isLoading: false, ...x }, i)) + : layout.size + } + {...props} + /> ); }; diff --git a/front/packages/ui/src/fetch.tsx b/front/packages/ui/src/fetch.tsx index 5e2fb7f7..a30fe726 100644 --- a/front/packages/ui/src/fetch.tsx +++ b/front/packages/ui/src/fetch.tsx @@ -27,7 +27,7 @@ import { useYoshiki } from "yoshiki/native"; export type Layout = { numColumns: Breakpoint; - size: Breakpoint; + size: number; gap: Breakpoint; layout: "grid" | "horizontal" | "vertical"; }; diff --git a/front/yarn.lock b/front/yarn.lock index e4cbd908..9e63df3d 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -4741,6 +4741,25 @@ __metadata: languageName: node linkType: hard +"@tanstack/react-virtual@npm:^3.0.1": + version: 3.0.1 + resolution: "@tanstack/react-virtual@npm:3.0.1" + dependencies: + "@tanstack/virtual-core": 3.0.0 + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 11534a23100de14a7e0a95da667381181e60a24e29a71246aeed174f8d5f6e176216de6639e6e1f403722ae30d8b92b21ed75ea131529b1417fb81f433468ef0 + languageName: node + linkType: hard + +"@tanstack/virtual-core@npm:3.0.0": + version: 3.0.0 + resolution: "@tanstack/virtual-core@npm:3.0.0" + checksum: 7283d50fc7b7a56608c37a8e94a93b85890ff7e39c6281633a19c4d6f6f4fbf25f8418f1eec302a008a8746a0d1d0cd00630137b55e6cf019818d68af8ed16b6 + languageName: node + linkType: hard + "@tootallnate/once@npm:2": version: 2.0.0 resolution: "@tootallnate/once@npm:2.0.0" @@ -15339,6 +15358,7 @@ __metadata: "@svgr/webpack": ^8.1.0 "@tanstack/react-query": ^5.12.1 "@tanstack/react-query-devtools": ^5.12.2 + "@tanstack/react-virtual": ^3.0.1 "@types/node": 20.10.1 "@types/react": 18.2.39 "@types/react-dom": 18.2.17