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

feat: support userInfiniteScroll use forcedLoadMore to keep requestin… #2640

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
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
100 changes: 100 additions & 0 deletions packages/hooks/src/useInfiniteScroll/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export async function mockRequest() {
}

const targetEl = document.createElement('div');
const scrollToBottomEl = document.createElement('div');

const setup = <T extends Data>(service: Service<T>, options?: InfiniteScrollOptions<T>) =>
renderHook(() => useInfiniteScroll(service, options));
Expand Down Expand Up @@ -125,6 +126,105 @@ describe('useInfiniteScroll', () => {
mockAddEventListener.mockRestore();
});

it('should not be trriggerd when not scroll to bottom and configured with "forcedLoadMore"', async () => {
const events = {};
const mockAddEventListener = jest
.spyOn(targetEl, 'addEventListener')
.mockImplementation((eventName, callback) => {
events[eventName] = callback;
});
const { result } = setup(mockRequest, {
target: targetEl,
isNoMore: (d) => d?.nextId === undefined,
forcedLoadMore: true,
});
// not work when loading
expect(result.current.loading).toBe(true);
events['scroll']();
await act(async () => {
jest.advanceTimersByTime(1000);
});
expect(result.current.loading).toBe(false);

// mock scroll
Object.defineProperties(targetEl, {
clientHeight: {
value: 150,
},
scrollHeight: {
value: 300,
},
scrollTop: {
value: 100,
},
});

act(() => {
events['scroll']();
});
// should not be trriggerd when not scroll to bottom
expect(result.current.loadingMore).toBe(false);
mockAddEventListener.mockRestore();
});

it('should always load when configured with "forcedLoadMore"', async () => {
const events = {};
const mockAddEventListener = jest
.spyOn(scrollToBottomEl, 'addEventListener')
.mockImplementation((eventName, callback) => {
events[eventName] = callback;
});
const { result } = setup(mockRequest, {
target: scrollToBottomEl,
isNoMore: (d) => d?.nextId === undefined,
forcedLoadMore: true,
});
// not work when loading
expect(result.current.loading).toBe(true);
events['scroll']();
await act(async () => {
jest.advanceTimersByTime(1000);
});
expect(result.current.loading).toBe(false);

// mock scroll
Object.defineProperties(scrollToBottomEl, {
clientHeight: {
value: 150,
},
scrollHeight: {
value: 300,
},
scrollTop: {
value: 150,
},
});

act(() => {
events['scroll']();
});
expect(result.current.loadingMore).toBe(true);
await act(async () => {
jest.advanceTimersByTime(1000);
});
expect(result.current.loadingMore).toBe(false);

// still work when no more
expect(result.current.noMore).toBe(false);
expect(result.current.trulyNoMore).toBe(true);
act(() => {
events['scroll']();
});

expect(result.current.loadingMore).toBe(true);
await act(async () => {
jest.advanceTimersByTime(1000);
});
expect(result.current.loadingMore).toBe(false);

mockAddEventListener.mockRestore();
});

it('reload should be work', async () => {
const fn = jest.fn(() => Promise.resolve({ list: [] }));
const { result } = setup(fn);
Expand Down
72 changes: 72 additions & 0 deletions packages/hooks/src/useInfiniteScroll/demo/scrollForcedLoadMore.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React, { useRef } from 'react';
import { useInfiniteScroll } from 'ahooks';

interface Result {
list: string[];
nextId: string | undefined;
lastId: string | undefined;
}

const resultData = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13'];

setInterval(() => {
resultData.push(resultData.length.toString());
}, 5000);

function getLoadMoreList(lastId: string | undefined, limit: number): Promise<Result> {
let start = -1;
if (lastId) {
start = resultData.findIndex((i) => i === lastId);
}
const end = start + limit;
const list = resultData.slice(start + 1, end);
const nId = resultData.length >= end ? resultData[end] : undefined;
return new Promise((resolve) => {
setTimeout(() => {
resolve({
list,
nextId: nId,
lastId: list[list.length - 1] || lastId,
});
}, 1000);
});
}

export default () => {
const ref = useRef<HTMLDivElement>(null);

const { data, loading, loadMore, loadingMore, noMore, trulyNoMore } = useInfiniteScroll(
(d) => getLoadMoreList(d?.lastId, 4),
{
target: ref,
isNoMore: (d) => d?.nextId === undefined,
forcedLoadMore: true,
},
);

return (
<div ref={ref} style={{ height: 150, overflow: 'auto', border: '1px solid', padding: 12 }}>
{loading ? (
<p>loading</p>
) : (
<div>
{data?.list?.map((item) => (
<div key={item} style={{ padding: 12, border: '1px solid #f5f5f5' }}>
item-{item}
</div>
))}
</div>
)}

<div style={{ marginTop: 8 }}>
{!noMore && (
<button type="button" onClick={loadMore} disabled={loadingMore}>
{loadingMore ? 'Loading more...' : 'Click to load more'}
</button>
)}

{trulyNoMore && <span>No more data</span>}
</div>
</div>
);
};
8 changes: 8 additions & 0 deletions packages/hooks/src/useInfiniteScroll/index.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ In the infinite scrolling scenario, the most common case is to automatically loa

<code src="./demo/scroll.tsx" />

## Scroll Forced Infinite Loading

When there is no more data, but the user still wants to try loading potentially updated data, you can configure `options.forcedLoadMore` for forced loading.

- `options.forcedLoadMore` When `options.isNoMore` is `true`, scrolling to the bottom will still trigger forced loading.
- `options.trulyNoMore` When `options.forcedLoadMore` is set to `true`, `options.isNoMore` will remain `true`, so you need to use `options.trulyNoMore` to determine if there is truly no more data.
<code src="./demo/scrollForcedLoadMore.tsx" />

## Data reset

The data can be reset by `reload`. The following example shows that after the `filter` changes, the data is reset to the first page.
Expand Down
46 changes: 36 additions & 10 deletions packages/hooks/src/useInfiniteScroll/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,28 @@ const useInfiniteScroll = <TData extends Data>(
onSuccess,
onError,
onFinally,
forcedLoadMore = false,
} = options;

const [finalData, setFinalData] = useState<TData>();
const [loadingMore, setLoadingMore] = useState(false);

const noMore = useMemo(() => {
if (!isNoMore) return false;
return isNoMore(finalData);
const { noMore, trulyNoMore } = useMemo(() => {
if (!isNoMore)
return {
noMore: false,
trulyNoMore: false,
};
const dataIsNoMore = isNoMore(finalData);

return {
noMore: !forcedLoadMore && dataIsNoMore,
trulyNoMore: dataIsNoMore,
};
}, [finalData]);

const { loading, error, run, runAsync, cancel } = useRequest(
async (lastData?: TData) => {
async (lastData?: TData, _isNeedForcedLoadMore?: boolean) => {
const currentData = await service(lastData);
if (!lastData) {
setFinalData({
Expand All @@ -54,8 +64,12 @@ const useInfiniteScroll = <TData extends Data>(
onFinally?.(d, e);
},
onBefore: () => onBefore?.(),
onSuccess: (d) => {
onSuccess: (d, isNeedForcedLoadMore) => {
setTimeout(() => {
// if request trrigered by forced load more, is no need to check again
if (isNeedForcedLoadMore) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-use-before-define
scrollMethod();
});
Expand All @@ -65,16 +79,22 @@ const useInfiniteScroll = <TData extends Data>(
},
);

const loadMore = useMemoizedFn(() => {
const loadMore = useMemoizedFn((isScrollToBottom = false) => {
if (noMore) return;
Copy link
Collaborator

@crazylxr crazylxr Sep 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

我理解这个好像没那么复杂,不需要写很多复杂的逻辑,其他的逻辑都删掉,直接:

if(!isNeedForcedLoadMore && noMore) {
 return;
}

是不是就好了

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

我理解这个好像没那么复杂,不需要写很多复杂的逻辑,其他的逻辑都删掉,直接:

if(!isNeedForcedLoadMore && noMore) {
 return;
}

是不是就好了

会有问题的,当滚动到下方时,如果不判断
else if (scrollHeight - scrollTop <= clientHeight) { loadMore(true); } 会导致它一直触发loadMore,需要加一层判断来确认是用户主动向下滚动,而不是考虑threshold的距离

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

你又改 api 了呀,如果实现 forceLoadMore,从 api 的设计上 我认为在loadMore 的时候增加一个参数,是否忽略 noMore,比增加一个配置更好,逻辑处理也比较简单

function loadMore(ignoreNoMore: bolean) {
}

你看看呢

// when set `forcedLoadMore`, only user scroll to bottom will trrigger load more
if (forcedLoadMore && !isScrollToBottom) return;

setLoadingMore(true);
run(finalData);
run(finalData, forcedLoadMore && isScrollToBottom);
});

const loadMoreAsync = useMemoizedFn(() => {
const loadMoreAsync = useMemoizedFn((isScrollToBottom = false) => {
if (noMore) return Promise.reject();
// when set `forcedLoadMore`, only user scroll to bottom will trrigger load more
if (forcedLoadMore && !isScrollToBottom) return Promise.reject();

setLoadingMore(true);
return runAsync(finalData);
return runAsync(finalData, forcedLoadMore && isScrollToBottom);
});

const reload = () => {
Expand All @@ -100,6 +120,11 @@ const useInfiniteScroll = <TData extends Data>(
const clientHeight = getClientHeight(el);

if (scrollHeight - scrollTop <= clientHeight + threshold) {
if (scrollHeight - scrollTop <= clientHeight) {
loadMore(true);
return;
}

loadMore();
}
};
Expand All @@ -125,7 +150,8 @@ const useInfiniteScroll = <TData extends Data>(
error,
loadingMore,
noMore,

/** when set `forcedLoadMore` true, `noMore` will be always true, use `trulyNoMore` to judge data whther is no more */
trulyNoMore,
loadMore,
loadMoreAsync,
reload: useMemoizedFn(reload),
Expand Down
9 changes: 9 additions & 0 deletions packages/hooks/src/useInfiniteScroll/index.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ useInfiniteScroll 的第一个参数 `service` 是一个异步函数,对这个

<code src="./demo/scroll.tsx" />

## 滚动强制无限加载

当没有更多数据时,用户依旧想尝试加载可能更新的数据,可以配置`options.forcedLoadMore`来进行强制加载。

- `options.forcedLoadMore` 当 `options.isNoMore`为`true`时滚动到底部依旧强制加载
- `options.trulyNoMore` 当配置`options.forcedLoadMore`为`true`时, `options.isNoMore`为`true`一直为`true`,需要使用`options.trulyNoMore`来判断是不是没有更多数据了

<code src="./demo/scrollForcedLoadMore.tsx" />

## 数据重置

通过 `reload` 即可实现数据重置,下面示例我们演示在 `filter` 变化后,重置数据到第一页。
Expand Down
4 changes: 3 additions & 1 deletion packages/hooks/src/useInfiniteScroll/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export interface InfiniteScrollResult<TData extends Data> {
loadingMore: boolean;
error?: Error;
noMore: boolean;

/** when set `forcedLoadMore` true, `noMore` will be always true, use `trulyNoMore` to judge data whther is no more */
trulyNoMore: boolean;
loadMore: () => void;
loadMoreAsync: () => Promise<TData>;
reload: () => void;
Expand All @@ -26,6 +27,7 @@ export interface InfiniteScrollOptions<TData extends Data> {
threshold?: number;

manual?: boolean;
forcedLoadMore?: boolean;
reloadDeps?: DependencyList;

onBefore?: () => void;
Expand Down
Loading