diff --git a/packages/ui-components/CHANGELOG.md b/packages/ui-components/CHANGELOG.md index e6ccbf2c..49645ceb 100644 --- a/packages/ui-components/CHANGELOG.md +++ b/packages/ui-components/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [4.3.1] - 2025-07-02 + +- Updated infinite scorlling to take `delay` as input param (default 100ms) +- Fixed duplicate data issue in Strict Mode + ## [4.3.0] - 2025-06-25 Updated infinite scrolling diff --git a/packages/ui-components/package.json b/packages/ui-components/package.json index a3777cbc..a141157a 100644 --- a/packages/ui-components/package.json +++ b/packages/ui-components/package.json @@ -1,6 +1,6 @@ { "name": "@macrostrat/ui-components", - "version": "4.3.0", + "version": "4.3.1", "description": "UI components for React and Blueprint.js", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", diff --git a/packages/ui-components/src/infinite-scroll.ts b/packages/ui-components/src/infinite-scroll.ts index 4fbf5042..89c94aa5 100644 --- a/packages/ui-components/src/infinite-scroll.ts +++ b/packages/ui-components/src/infinite-scroll.ts @@ -1,7 +1,7 @@ // @ts-nocheck import h from "@macrostrat/hyper"; import update, { Spec } from "immutability-helper"; -import React, { useReducer, useEffect, useRef, useCallback, memo } from "react"; +import React, { useReducer, useEffect, useRef, useCallback } from "react"; import { Spinner, NonIdealState } from "@blueprintjs/core"; import { APIParams, QueryParams } from "./util/query-string"; import { useInView } from "react-intersection-observer"; @@ -20,35 +20,7 @@ interface ScrollState { pageIndex: number; } -type ScrollResponseItems = Pick< - ScrollState, - "count" | "hasMore" | "items" ->; - -interface InfiniteScrollProps extends Omit, "params"> { - getCount(r: T): number; - getNextParams(r: T, params: QueryParams): QueryParams; - getItems(r: T): any; - hasMore(res: T): boolean; - totalCount?: number; - // Only allow more restrictive parameter types - params: APIParams; - className?: string; - itemComponent?: React.ComponentType<{ data: T; index: number }>; - loadingPlaceholder?: React.ComponentType; - emptyPlaceholder?: React.ComponentType; - finishedPlaceholder?: React.ComponentType; - resultsComponent?: React.ComponentType<{ data: T[] }>; - perPage?: number; - startPage?: number; - initialData?: T[]; // to allow for server-side rendering for initial state -} - type UpdateState = { type: "update-state"; spec: Spec> }; -type LoadNextPage = { - type: "load-next-page"; - page: number; -}; type LoadPage = { type: "load-page"; params: APIParams; @@ -56,8 +28,7 @@ type LoadPage = { callback(action: LoadPage): void; }; -type ScrollAction = UpdateState | LoadNextPage | LoadPage; - +type ScrollAction = UpdateState | LoadPage; type Reducer = ( state: ScrollState, action: ScrollAction, @@ -74,7 +45,6 @@ function infiniteScrollReducer( case "load-page": action.callback(action); return update(state, { - // @ts-ignore isLoadingPage: { $set: action.params.page ?? 0 }, }); } @@ -88,22 +58,25 @@ export function InfiniteScroll(props) { loadMore, offset = 0, isLoading, + delay = 100, } = props; const { ref, inView } = useInView({ rootMargin: `0px 0px ${offset}px 0px`, trackVisibility: true, - delay: 100, + delay: delay >= 100 ? delay : 100, }); - const shouldLoadMore = hasMore && inView; + // Only load more if not currently loading + const shouldLoadMore = hasMore && inView && !isLoading; useEffect(() => { - if (shouldLoadMore) loadMore(); - }, [shouldLoadMore, isLoading]); + if (shouldLoadMore) { + loadMore(); + } + }, [shouldLoadMore, loadMore]); return h("div.infinite-scroll-container", { className }, [ children, - //h.if(state.isLoadingPage != null)(placeholder), h("div.bottom-marker", { ref, style: { padding: "1px" } }), ]); } @@ -170,11 +143,6 @@ function FinishedPlaceholder({ totalCount, ...rest }: { totalCount?: number }) { } function InfiniteScrollView(props: InfiniteScrollProps) { - /* - A container for cursor-based pagination. This is built for - the GeoDeepDive API right now, but it can likely be generalized - for other uses. - */ const { route, params, @@ -189,6 +157,7 @@ function InfiniteScrollView(props: InfiniteScrollProps) { perPage = 10, startPage = 0, initialItems = [], + delay, } = props; const { get } = useAPIActions(); const { getCount, getNextParams, getItems, hasMore } = props; @@ -203,88 +172,96 @@ function InfiniteScrollView(props: InfiniteScrollProps) { pageIndex: startPage, }; - const pageOffset = 0; - const [state, dispatch] = useReducer>( infiniteScrollReducer, initialState, ); + const loadingRef = useRef(false); + + const mountedRef = useRef(true); + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + const loadPage = useCallback( async (action: LoadPage) => { - const res = await get(route, action.params, opts); - const itemVals = getItems(res); - const ival = { $push: itemVals }; - const nextLength = state.items.length + itemVals.length; - const count = getCount(res); - // if (state.isLoadingPage == null) { - // // We have externally cancelled this request (by e.g. moving to a new results set) - // console.log("Loading cancelled") - // return - // } - - let p1: QueryParams = getNextParams(res, params); - let hasNextParams = p1 != null; - - action.dispatch({ - type: "update-state", - spec: { - items: ival, - // @ts-ignore - scrollParams: { $set: p1 }, - pageIndex: { $set: state.pageIndex + 1 }, - count: { $set: count }, - hasMore: { - $set: hasMore(res) && itemVals.length > 0 && hasNextParams, + if (loadingRef.current) return; // Prevent concurrent loads + loadingRef.current = true; + + dispatch(action); + + try { + const res = await get(route, action.params, opts); + if (!mountedRef.current) return; + + const itemVals = getItems(res); + const nextParams = getNextParams(res, action.params); + const count = getCount(res); + const more = hasMore(res) && itemVals.length > 0 && nextParams != null; + + action.dispatch({ + type: "update-state", + spec: { + items: { $push: itemVals }, + scrollParams: { $set: nextParams }, + pageIndex: { $set: state.pageIndex + 1 }, + count: { $set: count }, + hasMore: { $set: more }, + isLoadingPage: { $set: null }, + error: { $set: null }, }, - isLoadingPage: { $set: null }, - }, - }); + }); + } catch (error) { + if (!mountedRef.current) return; + action.dispatch({ + type: "update-state", + spec: { error: { $set: error }, isLoadingPage: { $set: null } }, + }); + } finally { + loadingRef.current = false; + } }, - [state.items, route, params, opts], + [ + get, + route, + opts, + getItems, + getNextParams, + getCount, + hasMore, + state.pageIndex, + ], ); const loadMore = useCallback(() => { + if (state.isLoadingPage !== null || !state.hasMore) return; dispatch({ type: "load-page", params: state.scrollParams, dispatch, - // @ts-ignore callback: loadPage, }); - }, [state.scrollParams, loadPage, route, params, opts]); + }, [state.isLoadingPage, state.hasMore, state.scrollParams, loadPage]); const isInitialRender = useRef(true); - const loadInitialData = useCallback( - function () { - // Don't run on initial render - if (isInitialRender.current) { - isInitialRender.current = false; - return; - } - /* - Get the initial dataset - */ - // const success = await get(route, params, opts); - // parseResponse(success, true) - //if (state.items.length == 0 && state.isLoadingPage == null) return - dispatch({ type: "update-state", spec: { $set: initialState } }); - //await loadNext(0) - }, - [isInitialRender, route, params, opts], - ); - useEffect(loadInitialData, [props.route, props.params]); + useEffect(() => { + if (isInitialRender.current) { + isInitialRender.current = false; + if (state.items.length === 0) { + loadMore(); + } + } + }, [loadMore, state.items.length]); if (state == null) return null; - //useAsyncEffect(getInitialData, [route, params]); - - //const showLoader = state.isLoadingPage != null && state.items.length > 0 - const data = state.items; const isLoading = state.isLoadingPage != null; - const isEmpty = data.length == 0 && !isLoading; + const isEmpty = data.length === 0 && !isLoading; const isFinished = !state.hasMore && !isLoading; const totalCount = props.totalCount ?? state.count; @@ -297,6 +274,8 @@ function InfiniteScrollView(props: InfiniteScrollProps) { loader: placeholder, useWindow: true, className, + delay, + isLoading, }, [ h.if(isEmpty)(emptyPlaceholder), @@ -304,11 +283,8 @@ function InfiniteScrollView(props: InfiniteScrollProps) { h( resultsComponent, { data }, - data.map((d, i) => { - return h(itemComponent, { key: i, data: d, index: i }); - }), + data.map((d, i) => h(itemComponent, { key: i, data: d, index: i })), ), - // @ts-ignore h.if(isLoading)(loadingPlaceholder, { totalCount, scrollParams: state.scrollParams, @@ -316,7 +292,6 @@ function InfiniteScrollView(props: InfiniteScrollProps) { loadedCount: data.length, perPage, }), - // @ts-ignore h.if(isFinished)(finishedPlaceholder, { totalCount }), ]), ], @@ -324,13 +299,13 @@ function InfiniteScrollView(props: InfiniteScrollProps) { } InfiniteScrollView.defaultProps = { - hasMore(res) { + hasMore() { return true; }, getItems(d) { return d; }, - getCount(d) { + getCount() { return null; }, getNextParams(response, params) { diff --git a/packages/ui-components/stories/infinite-scroll.stories.ts b/packages/ui-components/stories/infinite-scroll.stories.ts index 7b72c577..85f14453 100644 --- a/packages/ui-components/stories/infinite-scroll.stories.ts +++ b/packages/ui-components/stories/infinite-scroll.stories.ts @@ -16,130 +16,33 @@ const Template: ComponentStory = (args) => export const Primary = Template.bind({}); // More on args: https://storybook.js.org/docs/react/writing-stories/args +const initialItems = [ + { + concept_id: 1, + id: null, + name: "Aaron", + rank: null, + strat_names: "Aaron", + strat_ids: "60001", + all_names: "Aaron,Aaron", + combined_id: 1, + strat_ranks: "Fm", + }, +]; + Primary.args = { params: { - combined_id: "gt.10", - limit: 10, + combined_id: "gt." + initialItems[0].combined_id, + limit: 1, order: "combined_id.asc", }, route: "https://dev.macrostrat.org/api/pg/strat_combined", getNextParams, - initialItems: [ - { - concept_id: 1, - id: null, - name: "Aaron", - rank: null, - strat_names: "Aaron", - strat_ids: "60001", - all_names: "Aaron,Aaron", - combined_id: 1, - strat_ranks: "Fm", - }, - { - concept_id: 2, - id: null, - name: "Abbott", - rank: null, - strat_names: null, - strat_ids: null, - all_names: "Abbott,", - combined_id: 2, - strat_ranks: null, - }, - { - concept_id: 3, - id: null, - name: "Abel Gap", - rank: null, - strat_names: "Abel Gap", - strat_ids: "7637", - all_names: "Abel Gap,Abel Gap", - combined_id: 3, - strat_ranks: "Fm", - }, - { - concept_id: 4, - id: null, - name: "Aberdeen", - rank: null, - strat_names: "Aberdeen,Aberdeen Sandstone", - strat_ids: "60004,60005", - all_names: "Aberdeen,Aberdeen,Aberdeen Sandstone", - combined_id: 4, - strat_ranks: "Mbr,Mbr", - }, - { - concept_id: 5, - id: null, - name: "Abingdon", - rank: null, - strat_names: "Abingdon Coal", - strat_ids: "60006", - all_names: "Abingdon,Abingdon Coal", - combined_id: 5, - strat_ranks: "Mbr", - }, - { - concept_id: 6, - id: null, - name: "Able", - rank: null, - strat_names: "Able", - strat_ids: "6899", - all_names: "Able,Able", - combined_id: 6, - strat_ranks: "Mbr", - }, - { - concept_id: 7, - id: null, - name: "Abrahams Creek", - rank: null, - strat_names: "Abrahams Creek", - strat_ids: "60008", - all_names: "Abrahams Creek,Abrahams Creek", - combined_id: 7, - strat_ranks: "Mbr", - }, - { - concept_id: 8, - id: null, - name: "Absalona", - rank: null, - strat_names: "Absalona,Absalona", - strat_ids: "60009,10498", - all_names: "Absalona,Absalona,Absalona", - combined_id: 8, - strat_ranks: "Fm,Fm", - }, - { - concept_id: 9, - id: null, - name: "Accomac Canyon", - rank: null, - strat_names: "Accomac Canyon Alloformation", - strat_ids: "60011", - all_names: "Accomac Canyon,Accomac Canyon Alloformation", - combined_id: 9, - strat_ranks: "Fm", - }, - { - concept_id: 10, - id: null, - name: "Accomack", - rank: null, - strat_names: "Accomack", - strat_ids: "60012", - all_names: "Accomack,Accomack", - combined_id: 10, - strat_ranks: "Mbr", - }, - ], + delay: 200, + initialItems, }; function getNextParams(response, params) { - console.log("getNextParams", response, params); return { ...params, combined_id: "gt." + response[response.length - 1].combined_id,