Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support bi-directional infinite scrolling #147

Merged
merged 1 commit into from
Aug 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading