diff --git a/packages/hooks/src/useInfiniteScroll/__tests__/index.spec.ts b/packages/hooks/src/useInfiniteScroll/__tests__/index.spec.ts index cbbb3903dc..e6078ed160 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).toEqual([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 ba74035b7e..7845713303 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 pendingBottomScrollCheckRef = useRef(false); const noMore = useMemo(() => { if (!isNoMore) { @@ -66,23 +68,21 @@ 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); } - } else { - // eslint-disable-next-line @typescript-eslint/no-use-before-define - scrollMethod(); - } + }); }); - }); - + } else { + pendingBottomScrollCheckRef.current = true; + } onSuccess?.(d.currentData); }, onError: (e) => onError?.(e), @@ -145,6 +145,13 @@ const useInfiniteScroll = ( loadMore(); } }; + useUpdateEffect(() => { + if (!pendingBottomScrollCheckRef.current) { + return; + } + pendingBottomScrollCheckRef.current = false; + scrollMethod(); + }, [finalData]); useEventListener( 'scroll',