diff --git a/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx b/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx index 317f4ba1c..1aabd9aa3 100644 --- a/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx +++ b/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx @@ -179,12 +179,25 @@ export const InfiniteScrollPaginator = forwardRef(function InfiniteScrollPaginat useEffect(() => { const root = rootRef.current; - if (!root || typeof ResizeObserver === 'undefined' || !scrollListener) return; - const observer = new ResizeObserver(scrollListener); + if (!root || typeof ResizeObserver === 'undefined') return; + // The check runs in the next animation frame, not in the observer's callback. It can load a + // page, and the store update renders synchronously before the callback returns: a root sized by + // its content would resize while the browser is still delivering resize notifications, which it + // reports as "ResizeObserver loop completed with undelivered notifications". From a frame, the + // page renders before layout, and the observer reports that resize in its next delivery. + let frameId: number | undefined; + const observer = new ResizeObserver(() => { + if (typeof frameId !== 'undefined') return; + frameId = window.requestAnimationFrame(() => { + frameId = undefined; + scrollListener(); + }); + }); observer.observe(root as Element); return () => { observer.disconnect(); + if (typeof frameId !== 'undefined') window.cancelAnimationFrame(frameId); }; }, [scrollListener]); diff --git a/src/components/InfiniteScrollPaginator/__tests__/InfiniteScrollPaginator.test.tsx b/src/components/InfiniteScrollPaginator/__tests__/InfiniteScrollPaginator.test.tsx index 0cfa0b005..38c631702 100644 --- a/src/components/InfiniteScrollPaginator/__tests__/InfiniteScrollPaginator.test.tsx +++ b/src/components/InfiniteScrollPaginator/__tests__/InfiniteScrollPaginator.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; import { InfiniteScrollPaginator } from '../InfiniteScrollPaginator'; +import { ResizeObserverMock } from '../../../mock-builders/browser'; const THRESHOLD = 100; const INTERVAL_MS = 500; @@ -46,14 +47,14 @@ describe('InfiniteScrollPaginator', () => { const listenToScroll = vi.fn(); const loadNextOnScrollToBottom = vi.fn(); const loadNextOnScrollToTop = vi.fn(); - const renderWithLoader = (loader: () => void) => ( + const renderWithLoader = (loader: () => void, threshold = THRESHOLD) => (
content
@@ -64,6 +65,8 @@ describe('InfiniteScrollPaginator', () => { loadNextOnScrollToBottom, loadNextOnScrollToTop, replaceLoader: (loader: () => void) => rerender(renderWithLoader(loader)), + replaceThreshold: (threshold: number) => + rerender(renderWithLoader(loadNextOnScrollToTop, threshold)), root: setUpLayout(), }; }; @@ -156,6 +159,70 @@ describe('InfiniteScrollPaginator', () => { expect(replacedLoader).toHaveBeenCalledTimes(1); }); + describe('on resize', () => { + beforeEach(() => { + ResizeObserverMock.observers = []; + vi.stubGlobal('ResizeObserver', ResizeObserverMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const resize = () => { + const observer = ResizeObserverMock.observers.at(-1); + expect(observer).toBeDefined(); + observer?.cb([], observer as unknown as ResizeObserver); + }; + + // A page loaded inside the observer's callback renders before its delivery ends, and a root + // sized by its content then resizes within it: "ResizeObserver loop completed with + // undelivered notifications". + it('checks the distance to the edges in the next frame, not in the observer callback', () => { + const { loadNextOnScrollToTop, root } = renderPaginator(); + root.scrollTop = 0; + + resize(); + expect(loadNextOnScrollToTop).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersToNextFrame(); + }); + expect(loadNextOnScrollToTop).toHaveBeenCalledTimes(1); + }); + + // Two checks in one frame would leave the second for the end of the throttle interval. + it('checks once for resizes reported within the same frame', () => { + const { listenToScroll, root } = renderPaginator(); + root.scrollTop = 0; + + resize(); + resize(); + act(() => { + vi.advanceTimersToNextFrame(); + }); + act(() => { + vi.advanceTimersByTime(INTERVAL_MS); + }); + + expect(listenToScroll).toHaveBeenCalledTimes(1); + }); + + it('drops a pending check when the observer is replaced', () => { + const { listenToScroll, replaceThreshold, root } = renderPaginator(); + root.scrollTop = 0; + + resize(); + replaceThreshold(THRESHOLD * 2); + act(() => { + vi.advanceTimersToNextFrame(); + }); + + expect(ResizeObserverMock.observers).toHaveLength(2); + expect(listenToScroll).not.toHaveBeenCalled(); + }); + }); + it.each([true, false])( 'stops preventing wheel events after unmount with useCapture as %s', (useCapture) => {