From c559d7e2c6de1c12a1c053d75272e62f1ece86d5 Mon Sep 17 00:00:00 2001 From: Bowen Date: Fri, 23 Jan 2026 16:25:27 +0800 Subject: [PATCH 1/5] fix closure issue --- packages/hooks/src/useInfiniteScroll/index.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/hooks/src/useInfiniteScroll/index.tsx b/packages/hooks/src/useInfiniteScroll/index.tsx index ba74035b7e..efd472bc48 100644 --- a/packages/hooks/src/useInfiniteScroll/index.tsx +++ b/packages/hooks/src/useInfiniteScroll/index.tsx @@ -76,9 +76,6 @@ const useInfiniteScroll = ( const scrollHeight = getScrollHeight(el); (el as Element).scrollTo(0, scrollHeight - scrollBottom.current); } - } else { - // eslint-disable-next-line @typescript-eslint/no-use-before-define - scrollMethod(); } }); }); @@ -145,6 +142,9 @@ const useInfiniteScroll = ( loadMore(); } }; + useUpdateEffect(() => { + scrollMethod(); + }, [finalData]); useEventListener( 'scroll', From c446c9ebc712811eb82060eecced0818a3247b59 Mon Sep 17 00:00:00 2001 From: Bowen Date: Tue, 31 Mar 2026 00:46:19 +0800 Subject: [PATCH 2/5] fix(useInfiniteScroll): narrow scrollMethod trigger scope to post-fetch only, add regression tests --- .../useInfiniteScroll/__tests__/index.spec.ts | 80 +++++++++++++++++++ .../hooks/src/useInfiniteScroll/index.tsx | 9 +++ 2 files changed, 89 insertions(+) diff --git a/packages/hooks/src/useInfiniteScroll/__tests__/index.spec.ts b/packages/hooks/src/useInfiniteScroll/__tests__/index.spec.ts index cbbb3903dc..f982d020c9 100644 --- a/packages/hooks/src/useInfiniteScroll/__tests__/index.spec.ts +++ b/packages/hooks/src/useInfiniteScroll/__tests__/index.spec.ts @@ -532,4 +532,84 @@ describe('useInfiniteScroll', () => { scrollHeightSpy.mockRestore(); clientHeightSpy.mockRestore(); }); + + test('should auto loadMore after initial load when container is not full, and second request receives the latest finalData', async () => { + setTargetInfo('scrollTop', 0); + const scrollHeightSpy = vi.spyOn(targetEl, 'scrollHeight', 'get').mockImplementation(() => 50); + const clientHeightSpy = vi.spyOn(targetEl, 'clientHeight', 'get').mockImplementation(() => 300); + + let capturedLastData: any; + const service = vi.fn(async (lastData?: any) => { + capturedLastData = lastData; + await sleep(1000); + if (!lastData) { + return { list: [1, 2, 3], nextId: 1 }; + } + return { list: [4, 5, 6] }; + }); + + const { result } = setup(service, { + target: targetEl, + isNoMore: (d) => d?.nextId === undefined, + }); + + // Wait for initial load to complete + await act(async () => { + vi.advanceTimersByTime(1000); + }); + + // Container is not full, so scrollMethod should have auto-triggered loadMore + expect(result.current.loadingMore).toBe(true); + // The second call must receive the up-to-date finalData (not the stale closure value) + expect(capturedLastData).toMatchObject({ list: [1, 2, 3], nextId: 1 }); + + await act(async () => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.data?.list).toMatchObject([1, 2, 3, 4, 5, 6]); + expect(result.current.noMore).toBe(true); + + scrollHeightSpy.mockRestore(); + clientHeightSpy.mockRestore(); + }); + + test('mutate should not trigger extra loadMore', async () => { + setTargetInfo('scrollTop', 0); + const scrollHeightSpy = vi.spyOn(targetEl, 'scrollHeight', 'get').mockImplementation(() => 50); + const clientHeightSpy = vi.spyOn(targetEl, 'clientHeight', 'get').mockImplementation(() => 300); + + const service = vi.fn(mockRequest); + const { result } = setup(service, { + target: targetEl, + isNoMore: (d) => d?.nextId === undefined, + }); + + // Wait for initial load to complete; this auto-triggers a second load + await act(async () => { + vi.advanceTimersByTime(1000); + }); + // Wait for the auto-triggered second load to complete + await act(async () => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.noMore).toBe(true); + + const callCountAfterLoad = service.mock.calls.length; + + // Mutate with data that makes noMore false, so loadMore *would* fire if scrollMethod ran + act(() => { + result.current.mutate({ list: [10, 20], nextId: 1 }); + }); + + // Wait for any effects to run + await act(async () => { + vi.advanceTimersByTime(100); + }); + + // mutate should NOT trigger additional requests via scrollMethod + expect(service).toBeCalledTimes(callCountAfterLoad); + + scrollHeightSpy.mockRestore(); + clientHeightSpy.mockRestore(); + }); }); diff --git a/packages/hooks/src/useInfiniteScroll/index.tsx b/packages/hooks/src/useInfiniteScroll/index.tsx index efd472bc48..bb4f72a8dc 100644 --- a/packages/hooks/src/useInfiniteScroll/index.tsx +++ b/packages/hooks/src/useInfiniteScroll/index.tsx @@ -31,6 +31,8 @@ const useInfiniteScroll = ( const lastScrollTop = useRef(undefined); // scrollBottom is used to record the distance from the bottom of the scroll bar const scrollBottom = useRef(0); + // flag: set in onSuccess (bottom direction only) to trigger scrollMethod via effect + const shouldScrollCheckRef = useRef(false); const noMore = useMemo(() => { if (!isNoMore) { @@ -80,6 +82,9 @@ const useInfiniteScroll = ( }); }); + if (!isScrollToTop) { + shouldScrollCheckRef.current = true; + } onSuccess?.(d.currentData); }, onError: (e) => onError?.(e), @@ -143,6 +148,10 @@ const useInfiniteScroll = ( } }; useUpdateEffect(() => { + if (!shouldScrollCheckRef.current) { + return; + } + shouldScrollCheckRef.current = false; scrollMethod(); }, [finalData]); From a14b9ca54148a681b0b3cb33c1baf77e1a2ec5fe Mon Sep 17 00:00:00 2001 From: Bowen Date: Tue, 31 Mar 2026 01:00:14 +0800 Subject: [PATCH 3/5] Commit suggestion Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/hooks/src/useInfiniteScroll/__tests__/index.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/hooks/src/useInfiniteScroll/__tests__/index.spec.ts b/packages/hooks/src/useInfiniteScroll/__tests__/index.spec.ts index f982d020c9..e6078ed160 100644 --- a/packages/hooks/src/useInfiniteScroll/__tests__/index.spec.ts +++ b/packages/hooks/src/useInfiniteScroll/__tests__/index.spec.ts @@ -566,7 +566,7 @@ describe('useInfiniteScroll', () => { await act(async () => { vi.advanceTimersByTime(1000); }); - expect(result.current.data?.list).toMatchObject([1, 2, 3, 4, 5, 6]); + expect(result.current.data?.list).toEqual([1, 2, 3, 4, 5, 6]); expect(result.current.noMore).toBe(true); scrollHeightSpy.mockRestore(); From 50ab387c6f309e3eecdbfba071a3de34b3aaa3a5 Mon Sep 17 00:00:00 2001 From: Bowen Date: Tue, 31 Mar 2026 01:12:11 +0800 Subject: [PATCH 4/5] perf(useInfiniteScroll): avoid unnecessary timer/rAF scheduling in bottom mode --- packages/hooks/src/useInfiniteScroll/index.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/hooks/src/useInfiniteScroll/index.tsx b/packages/hooks/src/useInfiniteScroll/index.tsx index bb4f72a8dc..d243f7263b 100644 --- a/packages/hooks/src/useInfiniteScroll/index.tsx +++ b/packages/hooks/src/useInfiniteScroll/index.tsx @@ -68,21 +68,19 @@ const useInfiniteScroll = ( }); } - setTimeout(() => { - // use requestAnimationFrame to ensure the scroll position is updated (To ensure compatibility react 19) - requestAnimationFrame(() => { - if (isScrollToTop) { + if (isScrollToTop) { + setTimeout(() => { + // use requestAnimationFrame to ensure the scroll position is updated (To ensure compatibility react 19) + requestAnimationFrame(() => { let el = getTargetElement(target); el = el === document ? document.documentElement : el; if (el) { const scrollHeight = getScrollHeight(el); (el as Element).scrollTo(0, scrollHeight - scrollBottom.current); } - } + }); }); - }); - - if (!isScrollToTop) { + } else { shouldScrollCheckRef.current = true; } onSuccess?.(d.currentData); From 2fc7b06625bd5b25217d3e81160851d8e844824f Mon Sep 17 00:00:00 2001 From: Bowen Date: Tue, 31 Mar 2026 01:15:26 +0800 Subject: [PATCH 5/5] style: rename variable --- packages/hooks/src/useInfiniteScroll/index.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/hooks/src/useInfiniteScroll/index.tsx b/packages/hooks/src/useInfiniteScroll/index.tsx index d243f7263b..7845713303 100644 --- a/packages/hooks/src/useInfiniteScroll/index.tsx +++ b/packages/hooks/src/useInfiniteScroll/index.tsx @@ -32,7 +32,7 @@ const useInfiniteScroll = ( // scrollBottom is used to record the distance from the bottom of the scroll bar const scrollBottom = useRef(0); // flag: set in onSuccess (bottom direction only) to trigger scrollMethod via effect - const shouldScrollCheckRef = useRef(false); + const pendingBottomScrollCheckRef = useRef(false); const noMore = useMemo(() => { if (!isNoMore) { @@ -81,7 +81,7 @@ const useInfiniteScroll = ( }); }); } else { - shouldScrollCheckRef.current = true; + pendingBottomScrollCheckRef.current = true; } onSuccess?.(d.currentData); }, @@ -146,10 +146,10 @@ const useInfiniteScroll = ( } }; useUpdateEffect(() => { - if (!shouldScrollCheckRef.current) { + if (!pendingBottomScrollCheckRef.current) { return; } - shouldScrollCheckRef.current = false; + pendingBottomScrollCheckRef.current = false; scrollMethod(); }, [finalData]);