Skip to content

Commit

Permalink
Support bi-directional infinite scrolling
Browse files Browse the repository at this point in the history
  • Loading branch information
inokawa committed Aug 23, 2023
1 parent 1926e7f commit d79107f
Show file tree
Hide file tree
Showing 13 changed files with 589 additions and 87 deletions.
2 changes: 1 addition & 1 deletion .size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "Total",
"path": "lib/index.mjs",
"import": "*",
"limit": "5 kB"
"limit": "5.10 kB"
},
{
"name": "VList",
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |||||||
Expand Down
72 changes: 72 additions & 0 deletions e2e/VList.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
});
90 changes: 84 additions & 6 deletions src/core/cache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
findIndex as findEndIndex,
Cache,
hasUnmeasuredItemsInRange,
updateCache,
updateCacheLength,
initCache,
} from "./cache";
import type { Writeable } from "./types";
Expand Down Expand Up @@ -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<Cache>, 15);
const res = updateCacheLength(cache as Writeable<Cache>, 15, undefined);
expect(res).toEqual([40 * 5, false]);
expect(cache).toMatchInlineSnapshot(`
{
"_defaultItemSize": 40,
Expand Down Expand Up @@ -804,7 +805,9 @@ describe(updateCache.name, () => {

it("should decrease cache length", () => {
const cache = initCache(10, 40);
updateCache(cache as Writeable<Cache>, 5);
(cache as Writeable<Cache>)._sizes[9] = 123;
const res = updateCacheLength(cache as Writeable<Cache>, 5, undefined);
expect(res).toEqual([40 * 4 + 123, true]);
expect(cache).toMatchInlineSnapshot(`
{
"_defaultItemSize": 40,
Expand All @@ -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<Cache>, 0);
updateCache(cache as Writeable<Cache>, 10);
updateCacheLength(cache as Writeable<Cache>, 0);
updateCacheLength(cache as Writeable<Cache>, 10);
expect(cache).toEqual(initialCache);
});

it("should increase cache length with shifting", () => {
const cache = initCache(10, 40);
const res = updateCacheLength(cache as Writeable<Cache>, 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<Cache>)._sizes[0] = 123;
const res = updateCacheLength(cache as Writeable<Cache>, 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,
],
}
`);
});
});
53 changes: 36 additions & 17 deletions src/core/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,14 @@ export const estimateDefaultItemSize = (cache: Writeable<Cache>) => {
median(measuredSizes);
};

const appendCache = (cache: Writeable<Cache>, length: number) => {
const appendCache = (
cache: Writeable<Cache>,
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);
}
Expand All @@ -142,23 +147,37 @@ export const initCache = (length: number, itemSize: number): Cache => {
return cache;
};

export const updateCache = (cache: Writeable<Cache>, length: number) => {
export const updateCacheLength = (
cache: Writeable<Cache>,
length: number,
isShift?: boolean
): [number, boolean] => {
const diff = length - cache._length;

if (diff > 0) {
appendCache(cache as Writeable<Cache>, 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];
};
2 changes: 1 addition & 1 deletion src/core/scroller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading

0 comments on commit d79107f

Please sign in to comment.