Skip to content

Commit 5f54d21

Browse files
authored
fix(useInfiniteScroll): use requestAnimationFrame for scroll position updates (#2881)
* fix(useInfiniteScroll): use requestAnimationFrame for scroll position updates * test(useInfiniteScroll): add test to ensure service is called only once during rapid scroll events * test(useInfiniteScroll): mock requestAnimationFrame for immediate callback execution in tests
1 parent efde6b2 commit 5f54d21

2 files changed

Lines changed: 81 additions & 10 deletions

File tree

packages/hooks/src/useInfiniteScroll/__tests__/index.spec.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,23 @@ const setup = <T extends Data>(service: Service<T>, options?: InfiniteScrollOpti
3232
renderHook(() => useInfiniteScroll(service, options));
3333

3434
describe('useInfiniteScroll', () => {
35+
let mockRaf: ReturnType<typeof vi.spyOn>;
36+
3537
beforeEach(() => {
3638
count = 0;
3739
});
3840

3941
beforeAll(() => {
4042
vi.useFakeTimers();
43+
// Mock requestAnimationFrame to execute callbacks immediately
44+
mockRaf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
45+
cb(0);
46+
return 0;
47+
}) as ReturnType<typeof vi.spyOn>;
4148
});
4249

4350
afterAll(() => {
51+
mockRaf.mockRestore();
4452
vi.useRealTimers();
4553
});
4654

@@ -175,6 +183,7 @@ describe('useInfiniteScroll', () => {
175183
await act(async () => {
176184
vi.advanceTimersByTime(1000);
177185
});
186+
178187
expect(result.current.loadingMore).toBe(false);
179188
//reverse order
180189
expect(result.current.data?.list).toMatchObject([4, 5, 6, 1, 2, 3]);
@@ -462,4 +471,63 @@ describe('useInfiniteScroll', () => {
462471
expect(result.current.data?.list.length).toBe(2);
463472
expect(result.current.data?.list).toEqual([1, 2]);
464473
});
474+
475+
test('service should be called only once when scrolling to bottom multiple times quickly', async () => {
476+
const mockService = vi.fn(async () => {
477+
await sleep(1000);
478+
return { list: [1, 2, 3], nextId: 1 };
479+
});
480+
481+
const events: Record<string, any> = {};
482+
const mockAddEventListener = vi
483+
.spyOn(targetEl, 'addEventListener')
484+
.mockImplementation((eventName: string, callback: any) => {
485+
events[eventName] = callback;
486+
});
487+
488+
const scrollHeightSpy = vi.spyOn(targetEl, 'scrollHeight', 'get').mockImplementation(() => 150);
489+
const clientHeightSpy = vi.spyOn(targetEl, 'clientHeight', 'get').mockImplementation(() => 100);
490+
491+
const { result } = setup(mockService, {
492+
target: targetEl,
493+
isNoMore: (d) => d?.nextId === undefined,
494+
});
495+
496+
// Wait for initial load to complete
497+
await act(async () => {
498+
vi.advanceTimersByTime(1000);
499+
});
500+
expect(result.current.loading).toBe(false);
501+
expect(mockService).toHaveBeenCalledTimes(1);
502+
503+
// Set scroll position to bottom (scrollHeight - scrollTop <= clientHeight + threshold)
504+
// 150 - 50 = 100 <= 100 + 100 = 200, so it should trigger loadMore
505+
setTargetInfo('scrollTop', 50);
506+
507+
// Trigger scroll event multiple times quickly (before first request completes)
508+
act(() => {
509+
events['scroll']();
510+
});
511+
512+
// Service should be called once more (total 2 times: initial + loadMore)
513+
expect(mockService).toHaveBeenCalledTimes(2);
514+
515+
// Trigger more scroll events while loading
516+
act(() => {
517+
events['scroll']();
518+
});
519+
act(() => {
520+
events['scroll']();
521+
});
522+
act(() => {
523+
events['scroll']();
524+
});
525+
526+
// Service should still only be called twice (no additional calls during loading)
527+
expect(mockService).toHaveBeenCalledTimes(2);
528+
529+
mockAddEventListener.mockRestore();
530+
scrollHeightSpy.mockRestore();
531+
clientHeightSpy.mockRestore();
532+
});
465533
});

packages/hooks/src/useInfiniteScroll/index.tsx

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,17 +67,20 @@ const useInfiniteScroll = <TData extends Data>(
6767
}
6868

6969
setTimeout(() => {
70-
if (isScrollToTop) {
71-
let el = getTargetElement(target);
72-
el = el === document ? document.documentElement : el;
73-
if (el) {
74-
const scrollHeight = getScrollHeight(el);
75-
(el as Element).scrollTo(0, scrollHeight - scrollBottom.current);
70+
// use requestAnimationFrame to ensure the scroll position is updated (To ensure compatibility react 19)
71+
requestAnimationFrame(() => {
72+
if (isScrollToTop) {
73+
let el = getTargetElement(target);
74+
el = el === document ? document.documentElement : el;
75+
if (el) {
76+
const scrollHeight = getScrollHeight(el);
77+
(el as Element).scrollTo(0, scrollHeight - scrollBottom.current);
78+
}
79+
} else {
80+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
81+
scrollMethod();
7682
}
77-
} else {
78-
// eslint-disable-next-line @typescript-eslint/no-use-before-define
79-
scrollMethod();
80-
}
83+
});
8184
});
8285

8386
onSuccess?.(d.currentData);

0 commit comments

Comments
 (0)