diff --git a/.size-limit.json b/.size-limit.json index 6b464da2d..079a74868 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -3,7 +3,7 @@ "name": "Total", "path": "lib/index.mjs", "import": "*", - "limit": "5 kB" + "limit": "5.10 kB" }, { "name": "VList", diff --git a/README.md b/README.md index 99f00aa54..32e0e0d67 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ And see [examples](./stories) for more usages. | Reverse scroll | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | | Reverse scroll in iOS Safari | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | | Infinite scroll | ✅ | ✅ | 🟠 (needs [react-window-infinite-loader](https://github.com/bvaughn/react-window-infinite-loader)) | 🟠 (needs [InfiniteLoader](https://github.com/bvaughn/react-virtualized/blob/master/docs/InfiniteLoader.md)) | ✅ | ✅ | -| Bi-directional infinite scroll | ❌ | ✅ | ❌ | ❌ | ❌ | 🟠 (has startItem method but its scroll position can be inaccurate) | +| Reverse (bi-directional) infinite scroll | ✅ | ✅ | ❌ | ❌ | ❌ | 🟠 (has startItem method but its scroll position can be inaccurate) | | Scroll restoration | ✅ | ✅ (getState) | ❌ | ❌ | ❌ | ❌ | | Smooth scroll | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | | RTL direction | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | diff --git a/e2e/VList.spec.ts b/e2e/VList.spec.ts index 82b1755bd..0f0e1646a 100644 --- a/e2e/VList.spec.ts +++ b/e2e/VList.spec.ts @@ -583,3 +583,75 @@ test.describe("check if scrollBy works", () => { await expect(await scrollable.evaluate((e) => e.scrollTop)).toEqual(1000); }); }); + +test.describe("check if item shift compensation works", () => { + test.beforeEach(async ({ page }) => { + await page.goto(storyUrl("basics-vlist--increasing-items")); + }); + + test("end", async ({ page }) => { + const scrollable = await page.waitForSelector(scrollableSelector); + await scrollable.waitForElementState("stable"); + + const updateButton = page.getByRole("button", { name: "update" }); + + // fill list and move to mid + for (let i = 0; i < 20; i++) { + await updateButton.click(); + } + await scrollable.evaluate((e) => (e.scrollTop += 400)); + await page.waitForTimeout(500); + + const topItem = await getFirstItem(scrollable); + expect(topItem.text).not.toEqual("0"); + expect(topItem.text.length).toBeLessThanOrEqual(2); + + // add + await page.getByRole("radio", { name: "append" }).click(); + await page.getByRole("radio", { name: "increase" }).click(); + await updateButton.click(); + await page.waitForTimeout(100); + // check if visible item is keeped + expect(topItem).toEqual(await getFirstItem(scrollable)); + + // remove + await page.getByRole("radio", { name: "decrease" }).click(); + await updateButton.click(); + await page.waitForTimeout(100); + // check if visible item is keeped + expect(topItem).toEqual(await getFirstItem(scrollable)); + }); + + test("start", async ({ page }) => { + const scrollable = await page.waitForSelector(scrollableSelector); + await scrollable.waitForElementState("stable"); + + const updateButton = page.getByRole("button", { name: "update" }); + + // fill list and move to mid + for (let i = 0; i < 20; i++) { + await updateButton.click(); + } + await scrollable.evaluate((e) => (e.scrollTop += 800)); + await page.waitForTimeout(500); + + const topItem = await getFirstItem(scrollable); + expect(topItem.text).not.toEqual("0"); + expect(topItem.text.length).toBeLessThanOrEqual(2); + + // add + await page.getByRole("radio", { name: "prepend" }).click(); + await page.getByRole("radio", { name: "increase" }).click(); + await updateButton.click(); + await page.waitForTimeout(100); + // check if visible item is keeped + expect(topItem).toEqual(await getFirstItem(scrollable)); + + // remove + await page.getByRole("radio", { name: "decrease" }).click(); + await updateButton.click(); + await page.waitForTimeout(100); + // check if visible item is keeped + expect(topItem).toEqual(await getFirstItem(scrollable)); + }); +}); diff --git a/src/core/cache.spec.ts b/src/core/cache.spec.ts index 2f2c9de36..a1a0a5187 100644 --- a/src/core/cache.spec.ts +++ b/src/core/cache.spec.ts @@ -8,7 +8,7 @@ import { findIndex as findEndIndex, Cache, hasUnmeasuredItemsInRange, - updateCache, + updateCacheLength, initCache, } from "./cache"; import type { Writeable } from "./types"; @@ -755,10 +755,11 @@ describe(initCache.name, () => { }); }); -describe(updateCache.name, () => { +describe(updateCacheLength.name, () => { it("should increase cache length", () => { const cache = initCache(10, 40); - updateCache(cache as Writeable, 15); + const res = updateCacheLength(cache as Writeable, 15, undefined); + expect(res).toEqual([40 * 5, false]); expect(cache).toMatchInlineSnapshot(` { "_defaultItemSize": 40, @@ -804,7 +805,9 @@ describe(updateCache.name, () => { it("should decrease cache length", () => { const cache = initCache(10, 40); - updateCache(cache as Writeable, 5); + (cache as Writeable)._sizes[9] = 123; + const res = updateCacheLength(cache as Writeable, 5, undefined); + expect(res).toEqual([40 * 4 + 123, true]); expect(cache).toMatchInlineSnapshot(` { "_defaultItemSize": 40, @@ -831,8 +834,83 @@ describe(updateCache.name, () => { it("should recover cache length from 0", () => { const cache = initCache(10, 40); const initialCache = JSON.parse(JSON.stringify(cache)); - updateCache(cache as Writeable, 0); - updateCache(cache as Writeable, 10); + updateCacheLength(cache as Writeable, 0); + updateCacheLength(cache as Writeable, 10); expect(cache).toEqual(initialCache); }); + + it("should increase cache length with shifting", () => { + const cache = initCache(10, 40); + const res = updateCacheLength(cache as Writeable, 15, true); + expect(res).toEqual([40 * 5, false]); + expect(cache).toMatchInlineSnapshot(` + { + "_defaultItemSize": 40, + "_length": 15, + "_measuredOffsetIndex": 0, + "_offsets": [ + 0, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + ], + "_sizes": [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + ], + } + `); + }); + + it("should decrease cache length with shifting", () => { + const cache = initCache(10, 40); + (cache as Writeable)._sizes[0] = 123; + const res = updateCacheLength(cache as Writeable, 5, true); + expect(res).toEqual([40 * 4 + 123, true]); + expect(cache).toMatchInlineSnapshot(` + { + "_defaultItemSize": 40, + "_length": 5, + "_measuredOffsetIndex": 0, + "_offsets": [ + 0, + -1, + -1, + -1, + -1, + ], + "_sizes": [ + -1, + -1, + -1, + -1, + -1, + ], + } + `); + }); }); diff --git a/src/core/cache.ts b/src/core/cache.ts index 59d0a8945..2d789fb8f 100644 --- a/src/core/cache.ts +++ b/src/core/cache.ts @@ -121,9 +121,14 @@ export const estimateDefaultItemSize = (cache: Writeable) => { median(measuredSizes); }; -const appendCache = (cache: Writeable, length: number) => { +const appendCache = ( + cache: Writeable, + length: number, + prepend?: boolean +) => { + const key = prepend ? "unshift" : "push"; for (let i = cache._length; i < length; i++) { - cache._sizes.push(UNCACHED); + cache._sizes[key](UNCACHED); // first offset must be 0 cache._offsets.push(i === 0 ? 0 : UNCACHED); } @@ -142,23 +147,37 @@ export const initCache = (length: number, itemSize: number): Cache => { return cache; }; -export const updateCache = (cache: Writeable, length: number) => { +export const updateCacheLength = ( + cache: Writeable, + length: number, + isShift?: boolean +): [number, boolean] => { const diff = length - cache._length; - if (diff > 0) { - appendCache(cache as Writeable, length); - } else { - for (let i = diff; i < 0; i++) { - cache._sizes.pop(); - cache._offsets.pop(); - } - cache._length = length; - // measuredOffsetIndex shouldn't be less than 0 because it makes scrollSize NaN and cause infinite rerender. - // https://github.com/inokawa/virtua/pull/160 - cache._measuredOffsetIndex = clamp( - length - 1, - 0, - cache._measuredOffsetIndex + const isRemove = diff < 0; + let shift: number; + if (isRemove) { + // Removed + shift = ( + isShift ? cache._sizes.splice(0, -diff) : cache._sizes.splice(diff) + ).reduce( + (acc, removed) => + acc + (removed === UNCACHED ? cache._defaultItemSize : removed), + 0 ); + cache._offsets.splice(diff); + } else { + // Added + shift = cache._defaultItemSize * diff; + appendCache(cache, cache._length + diff, isShift); } + + cache._measuredOffsetIndex = isShift + ? // Discard cache for now + 0 + : // measuredOffsetIndex shouldn't be less than 0 because it makes scrollSize NaN and cause infinite rerender. + // https://github.com/inokawa/virtua/pull/160 + clamp(length - 1, 0, cache._measuredOffsetIndex); + cache._length = length; + return [shift, isRemove]; }; diff --git a/src/core/scroller.ts b/src/core/scroller.ts index e8418e2d1..aa7a26970 100644 --- a/src/core/scroller.ts +++ b/src/core/scroller.ts @@ -174,7 +174,7 @@ export const createScroller = ( scrollManually(() => offset); }, _scrollToIndex(index, align) { - index = clamp(index, 0, store._getItemLength() - 1); + index = clamp(index, 0, store._getItemsLength() - 1); scrollManually( align === "end" diff --git a/src/core/store.ts b/src/core/store.ts index d4cc8cbad..a0d06bc85 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -10,7 +10,7 @@ import { setItemSize, hasUnmeasuredItemsInRange, estimateDefaultItemSize, - updateCache, + updateCacheLength, } from "./cache"; import type { CacheSnapshot, Writeable } from "./types"; import { abs, clamp, max, min } from "./utils"; @@ -38,14 +38,19 @@ type ScrollDirection = export const ACTION_ITEM_RESIZE = 1; export const ACTION_VIEWPORT_RESIZE = 2; -export const ACTION_SCROLL = 3; -export const ACTION_BEFORE_MANUAL_SCROLL = 4; -export const ACTION_SCROLL_END = 5; -export const ACTION_MANUAL_SCROLL = 6; +export const ACTION_ITEMS_LENGTH_CHANGE = 3; +export const ACTION_SCROLL = 4; +export const ACTION_BEFORE_MANUAL_SCROLL = 5; +export const ACTION_SCROLL_END = 6; +export const ACTION_MANUAL_SCROLL = 7; type Actions = | [type: typeof ACTION_ITEM_RESIZE, entries: ItemResize[]] | [type: typeof ACTION_VIEWPORT_RESIZE, size: number] + | [ + type: typeof ACTION_ITEMS_LENGTH_CHANGE, + arg: [length: number, isShift?: boolean | undefined] + ] | [type: typeof ACTION_SCROLL, offset: number] | [type: typeof ACTION_BEFORE_MANUAL_SCROLL, offset: number] | [type: typeof ACTION_SCROLL_END, dummy?: void] @@ -66,7 +71,7 @@ export type VirtualStore = { _hasUnmeasuredItemsInTargetViewport(offset: number): boolean; _getItemOffset(index: number): number; _getItemSize(index: number): number; - _getItemLength(): number; + _getItemsLength(): number; _getScrollOffset(): number; _getScrollOffsetMax(): number; _getIsScrolling(): boolean; @@ -76,7 +81,6 @@ export type VirtualStore = { _flushJump(): ScrollJump; _subscribe(target: number, cb: Subscriber): () => void; _update(...action: Actions): void; - _updateCacheLength(length: number): void; }; export const createVirtualStore = ( @@ -92,6 +96,7 @@ export const createVirtualStore = ( let jumpCount = 0; let jump: ScrollJump = 0; let _scrollDirection: ScrollDirection = SCROLL_IDLE; + let _isShifting = false; let _isManualScrolling = false; let _resized = false; let _prevRange: ItemsRange = [0, initialItemCount]; @@ -101,6 +106,10 @@ export const createVirtualStore = ( computeTotalSize(cache as Writeable); const getScrollOffsetMax = () => getScrollSize() - viewportSize; + const clampScrollOffset = (value: number): number => { + // Scroll offset may exceed min or max especially in Safari's elastic scrolling. + return clamp(value, 0, getScrollOffsetMax()); + }; const updateScrollDirection = (dir: ScrollDirection): boolean => { const prev = _scrollDirection; _scrollDirection = dir; @@ -158,7 +167,7 @@ export const createVirtualStore = ( _getItemSize(index) { return getItemSize(cache, index); }, - _getItemLength() { + _getItemsLength() { return cache._length; }, _getScrollOffset() { @@ -203,11 +212,11 @@ export const createVirtualStore = ( break; } - let diff = 0; // Calculate jump - if (_scrollDirection === SCROLL_UP) { - diff = calculateJump(cache, updated); - } else if (_isManualScrolling) { + let diff = 0; + if (_isShifting || _isManualScrolling) { + // Should maintain visible position under specific situations + if (scrollOffset === 0) { // Do nothing to stick to the start } else if ( @@ -224,10 +233,12 @@ export const createVirtualStore = ( updated.filter(([index]) => index < startIndex) ); } + } else if (_scrollDirection === SCROLL_UP) { + // We can assume jumps occurred on the upper outside during reverse scrolling + diff = calculateJump(cache, updated); } else { // Do nothing } - if (diff) { jump = diff; jumpCount++; @@ -262,6 +273,28 @@ export const createVirtualStore = ( } break; } + case ACTION_ITEMS_LENGTH_CHANGE: { + if (payload[1]) { + // Calc distance before updating cache + const distanceToEnd = getScrollOffsetMax() - scrollOffset; + + const [shift, isRemove] = updateCacheLength( + cache as Writeable, + payload[0], + true + ); + const diff = isRemove ? -min(shift, distanceToEnd) : shift; + jump += diff; + scrollOffset = clampScrollOffset(scrollOffset + diff); + jumpCount++; + + mutated = UPDATE_SCROLL + UPDATE_JUMP; + _isShifting = true; + } else { + updateCacheLength(cache as Writeable, payload[0]); + } + break; + } case ACTION_SCROLL: case ACTION_BEFORE_MANUAL_SCROLL: { // Skip if offset is not changed @@ -270,10 +303,12 @@ export const createVirtualStore = ( } if (type === ACTION_SCROLL) { - // Skip scroll direction detection just after resizing because it may result in the opposite direction. - // Scroll events are dispatched enough so it's ok to skip some of them. + // Scrolling after resizing will be caused by jump compensation const isJustResized = _resized; _resized = false; + + // Skip scroll direction detection just after resizing because it may result in the opposite direction. + // Scroll events are dispatched enough so it's ok to skip some of them. if ( (_scrollDirection === SCROLL_IDLE || !isJustResized) && // Ignore until manual scrolling @@ -295,10 +330,13 @@ export const createVirtualStore = ( shouldSync = abs(scrollOffset - payload) > viewportSize; mutated += UPDATE_SCROLL_WITH_EVENT; + + if (!isJustResized) { + _isShifting = false; + } } - // Scroll offset may exceed min or max especially in Safari's elastic scrolling. - scrollOffset = clamp(payload, 0, getScrollOffsetMax()); + scrollOffset = clampScrollOffset(payload); mutated += UPDATE_SCROLL; break; } @@ -306,7 +344,7 @@ export const createVirtualStore = ( if (updateScrollDirection(SCROLL_IDLE)) { mutated = UPDATE_IS_SCROLLING; } - _isManualScrolling = false; + _isShifting = _isManualScrolling = false; break; } case ACTION_MANUAL_SCROLL: { @@ -325,10 +363,5 @@ export const createVirtualStore = ( }); } }, - _updateCacheLength(length) { - // It's ok to be updated in render because states should be calculated consistently regardless cache length - if (cache._length === length) return; - updateCache(cache as Writeable, length); - }, }; }; diff --git a/src/react/VGrid.tsx b/src/react/VGrid.tsx index 425bde797..e80c31f4c 100644 --- a/src/react/VGrid.tsx +++ b/src/react/VGrid.tsx @@ -8,7 +8,11 @@ import { ReactNode, useImperativeHandle, } from "react"; -import { VirtualStore, createVirtualStore } from "../core/store"; +import { + ACTION_ITEMS_LENGTH_CHANGE, + VirtualStore, + createVirtualStore, +} from "../core/store"; import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect"; import { SELECT_IS_SCROLLING, @@ -286,8 +290,12 @@ export const VGrid = forwardRef( } ); // The elements length and cached items length are different just after element is added/removed. - vStore._updateCacheLength(rowCount); - hStore._updateCacheLength(colCount); + if (rowCount !== vStore._getItemsLength()) { + vStore._update(ACTION_ITEMS_LENGTH_CHANGE, [rowCount]); + } + if (colCount !== hStore._getItemsLength()) { + hStore._update(ACTION_ITEMS_LENGTH_CHANGE, [colCount]); + } const [startRowIndex, endRowIndex] = useSelector( vStore, diff --git a/src/react/VList.tsx b/src/react/VList.tsx index b1e19f69d..e70649ab9 100644 --- a/src/react/VList.tsx +++ b/src/react/VList.tsx @@ -7,7 +7,11 @@ import { ReactNode, useEffect, } from "react"; -import { UPDATE_SCROLL_WITH_EVENT, createVirtualStore } from "../core/store"; +import { + UPDATE_SCROLL_WITH_EVENT, + ACTION_ITEMS_LENGTH_CHANGE, + createVirtualStore, +} from "../core/store"; import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect"; import { SELECT_IS_SCROLLING, @@ -100,6 +104,10 @@ export interface VListProps extends ViewportComponentAttributes { * If set, the specified amount of items will be mounted in the initial rendering regardless of the container size. This prop is mostly for SSR. */ initialItemCount?: number; + /** + * While true is set, scroll position will be maintained from the end not usual start when items are shifted/unshifted. It is useful for reverse infinite scrolling. + */ + shift?: boolean; /** * If true, rendered as a horizontally scrollable list. Otherwise rendered as a vertically scrollable list. */ @@ -164,6 +172,7 @@ export const VList = forwardRef( overscan = 4, initialItemSize, initialItemCount, + shift, horizontal: horizontalProp, mode, cache, @@ -207,8 +216,11 @@ export const VList = forwardRef( _isRtl, ]; }); + // The elements length and cached items length are different just after element is added/removed. - store._updateCacheLength(count); + if (count !== store._getItemsLength()) { + store._update(ACTION_ITEMS_LENGTH_CHANGE, [count, shift]); + } const [startIndex, endIndex] = useSelector( store, diff --git a/src/react/WVList.tsx b/src/react/WVList.tsx index a06c6fc80..99a72805e 100644 --- a/src/react/WVList.tsx +++ b/src/react/WVList.tsx @@ -7,7 +7,7 @@ import { forwardRef, useImperativeHandle, } from "react"; -import { createVirtualStore } from "../core/store"; +import { ACTION_ITEMS_LENGTH_CHANGE, createVirtualStore } from "../core/store"; import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect"; import { SELECT_IS_SCROLLING, @@ -161,7 +161,9 @@ export const WVList = forwardRef( ]; }); // The elements length and cached items length are different just after element is added/removed. - store._updateCacheLength(count); + if (count !== store._getItemsLength()) { + store._update(ACTION_ITEMS_LENGTH_CHANGE, [count]); + } const [startIndex, endIndex] = useSelector( store, diff --git a/stories/advanced/Feed.stories.tsx b/stories/advanced/Feed.stories.tsx new file mode 100644 index 000000000..48178278f --- /dev/null +++ b/stories/advanced/Feed.stories.tsx @@ -0,0 +1,151 @@ +import { Meta, StoryObj } from "@storybook/react"; +import { VList, VListHandle } from "../../src"; +import React, { + CSSProperties, + ReactNode, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Spinner } from "../basics/components"; +import { faker } from "@faker-js/faker"; + +export default { + component: VList, +} as Meta; + +const itemStyle: CSSProperties = { + borderTop: "solid 1px #ccc", + background: "#fff", + padding: 32, + paddingTop: 48, + paddingBottom: 48, + whiteSpace: "pre-wrap", +}; + +type TextData = { + type: "text"; + id: number; + value: string; +}; +type ImageData = { + type: "image"; + id: number; + src: string; + size: number; +}; + +type Data = TextData | ImageData; + +const Item = ({ content }: { content: ReactNode }) => { + return
{content}
; +}; + +const range = (n: number, cb: (i: number) => T) => + Array.from({ length: n }).map((_, i) => cb(i)); + +export const Default: StoryObj = { + name: "Feed", + render: () => { + const id = useRef(0); + const createItem = (): Data => { + const rand = Math.random(); + return rand > 0.2 + ? { + type: "text", + id: id.current++, + value: faker.lorem.paragraphs(Math.floor(Math.random() * 10) + 1), + } + : { + type: "image", + id: id.current++, + src: faker.image.url(), + size: 100 * (Math.floor(Math.random() * 4) + 1), + }; + }; + const createItems = (num: number) => range(num, createItem); + + const [shifting, setShifting] = useState(false); + const [startFetching, setStartFetching] = useState(false); + const [endFetching, setEndFetching] = useState(false); + const fetchItems = async (isStart?: boolean) => { + if (isStart) { + setShifting(true); + setStartFetching(true); + } else { + setShifting(false); + setEndFetching(true); + } + await new Promise((r) => setTimeout(r, 1000)); + if (isStart) { + setStartFetching(false); + } else { + setEndFetching(false); + } + }; + + const ref = useRef(null); + const ITEM_BATCH_COUNT = 30; + const [items, setItems] = useState(() => createItems(ITEM_BATCH_COUNT * 2)); + const elements = useMemo( + () => + items.map((d) => ( + : d.value + } + /> + )), + [items] + ); + const THRESHOLD = 10; + const count = items.length; + const startFetchedCountRef = useRef(-1); + const endFetchedCountRef = useRef(-1); + + const ready = useRef(false); + useEffect(() => { + ref.current?.scrollToIndex(items.length / 2 + 1); + ready.current = true; + }, []); + + return ( + { + if (!ready.current) return; + if (end + THRESHOLD > count && endFetchedCountRef.current < count) { + endFetchedCountRef.current = count; + await fetchItems(); + setItems((prev) => [...prev, ...createItems(ITEM_BATCH_COUNT)]); + } else if ( + start - THRESHOLD < 0 && + startFetchedCountRef.current < count + ) { + startFetchedCountRef.current = count; + await fetchItems(true); + setItems((prev) => [ + ...createItems(ITEM_BATCH_COUNT).reverse(), + ...prev, + ]); + } + }} + > + {/* // TODO support the case when spinner is at index 0 + */} + {elements} + + + ); + }, +}; diff --git a/stories/basics/VList.stories.tsx b/stories/basics/VList.stories.tsx index 2b1d9d42c..6b20aa5a4 100644 --- a/stories/basics/VList.stories.tsx +++ b/stories/basics/VList.stories.tsx @@ -379,6 +379,100 @@ export const InfiniteScrolling: StoryObj = { }, }; +export const BiDirectionalInfiniteScrolling: StoryObj = { + render: () => { + const id = useRef(0); + const createRows = (num: number) => { + const heights = [20, 40, 80, 77]; + return Array.from({ length: num }).map(() => { + const i = id.current++; + return ( +
+ {i} +
+ ); + }); + }; + + const [shifting, setShifting] = useState(false); + const [startFetching, setStartFetching] = useState(false); + const [endFetching, setEndFetching] = useState(false); + const fetchItems = async (isStart?: boolean) => { + if (isStart) { + setShifting(true); + setStartFetching(true); + } else { + setShifting(false); + setEndFetching(true); + } + await new Promise((r) => setTimeout(r, 1000)); + if (isStart) { + setStartFetching(false); + } else { + setEndFetching(false); + } + }; + + const ref = useRef(null); + const ITEM_BATCH_COUNT = 100; + const [items, setItems] = useState(() => createRows(ITEM_BATCH_COUNT * 2)); + const THRESHOLD = 50; + const count = items.length; + const startFetchedCountRef = useRef(-1); + const endFetchedCountRef = useRef(-1); + + const ready = useRef(false); + useEffect(() => { + ref.current?.scrollToIndex(items.length / 2 + 1); + ready.current = true; + }, []); + + return ( + { + if (!ready.current) return; + if (end + THRESHOLD > count && endFetchedCountRef.current < count) { + endFetchedCountRef.current = count; + await fetchItems(); + setItems((prev) => [...prev, ...createRows(ITEM_BATCH_COUNT)]); + } else if ( + start - THRESHOLD < 0 && + startFetchedCountRef.current < count + ) { + startFetchedCountRef.current = count; + await fetchItems(true); + setItems((prev) => [ + ...createRows(ITEM_BATCH_COUNT).reverse(), + ...prev, + ]); + } + }} + > + {/* // TODO support the case when spinner is at index 0 + */} + {items} + + + ); + }, +}; + export const Callbacks: StoryObj = { render: () => { const items = useState(() => createRows(1000))[0]; @@ -486,43 +580,44 @@ export const WithState: StoryObj = { export const IncreasingItems: StoryObj = { render: () => { - const BATCH_LENGTH = 4; + const id = useRef(0); const createRows = (num: number, offset: number) => { return Array.from({ length: num }).map((_, i) => { i += offset; - return i; + return { id: id.current++, index: i }; }); }; + const [auto, setAuto] = useState(false); + const [amount, setAmount] = useState(4); const [prepend, setPrepend] = useState(false); const [increase, setIncrease] = useState(true); - const [rows, setRows] = useState(() => createRows(BATCH_LENGTH, 0)); - useEffect(() => { - const timer = setInterval(() => { - if (increase) { - setRows((prev) => - prepend - ? [ - ...createRows(BATCH_LENGTH, (prev[0] ?? 0) - BATCH_LENGTH), - ...prev, - ] - : [ - ...prev, - ...createRows(BATCH_LENGTH, (prev[prev.length - 1] ?? 0) + 1), - ] - ); + const [rows, setRows] = useState(() => createRows(amount, 0)); + const update = () => { + if (increase) { + setRows((prev) => + prepend + ? [...createRows(amount, (prev[0]?.index ?? 0) - amount), ...prev] + : [ + ...prev, + ...createRows(amount, (prev[prev.length - 1]?.index ?? 0) + 1), + ] + ); + } else { + if (prepend) { + setRows((prev) => prev.slice(amount)); } else { - if (prepend) { - setRows((prev) => prev.slice(BATCH_LENGTH)); - } else { - setRows((prev) => prev.slice(0, -BATCH_LENGTH)); - } + setRows((prev) => prev.slice(0, -amount)); } - }, 500); + } + }; + useEffect(() => { + if (!auto) return; + const timer = setInterval(update, 500); return () => { clearInterval(timer); }; - }); + }, [update, auto]); const heights = [20, 40, 80, 77]; @@ -575,18 +670,49 @@ export const IncreasingItems: StoryObj = { /> decrease + { + setAmount(Number(e.target.value)); + }} + /> + +
+ +
- - {rows.map((d, i) => ( + + {rows.map((d) => (
- {d} + {d.index}
))}
diff --git a/stories/basics/components.tsx b/stories/basics/components.tsx index abdf86b7d..d7c11c70c 100644 --- a/stories/basics/components.tsx +++ b/stories/basics/components.tsx @@ -1,10 +1,11 @@ -import React from "react"; +import React, { CSSProperties } from "react"; -export const Spinner = () => { +export const Spinner = ({ style }: { style?: CSSProperties }) => { return ( <>