From 7a9a52ca481ec66444b8b11485465653c271edc4 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 29 Oct 2026 10:34:30 +0100 Subject: [PATCH 1/2] fix(InfiniteScrollPaginator): check the edges on resize in the next frame Since the edge check became a leading throttle (#3342), the ResizeObserver's first callback loads the next page synchronously. The store update renders before the callback's delivery ends, so a root sized by its content resizes within that delivery and the browser reports "ResizeObserver loop completed with undelivered notifications" (a full-screen overlay under CRA's dev server). The observer now schedules the check for the next animation frame, once per frame, and cancels it on unmount. Scroll-triggered checks stay synchronous. --- .../InfiniteScrollPaginator.tsx | 14 ++++- .../InfiniteScrollPaginator.test.tsx | 59 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx b/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx index 317f4ba1c..7c80ee08f 100644 --- a/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx +++ b/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx @@ -180,11 +180,23 @@ export const InfiniteScrollPaginator = forwardRef(function InfiniteScrollPaginat useEffect(() => { const root = rootRef.current; if (!root || typeof ResizeObserver === 'undefined' || !scrollListener) return; - const observer = new ResizeObserver(scrollListener); + // Checked in the next frame, not in the observer's callback: the check can load a page, and the + // store update renders synchronously before the callback's delivery ends. A root sized by its + // content then resizes within that delivery, which the browser reports as "ResizeObserver loop + // completed with undelivered notifications". Run from a frame, the resize is observed in the next. + 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..7a60c9bd4 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; @@ -156,6 +157,64 @@ 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) as ResizeObserverMock; + 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); + }); + + it('checks once for resizes reported within the same frame', () => { + const { listenToScroll, root } = renderPaginator(); + root.scrollTop = 0; + + resize(); + resize(); + act(() => { + vi.advanceTimersToNextFrame(); + }); + + expect(listenToScroll).toHaveBeenCalledTimes(1); + }); + + it('does not check after unmount', () => { + const { listenToScroll, root } = renderPaginator(); + root.scrollTop = 0; + + resize(); + cleanup(); + act(() => { + vi.advanceTimersToNextFrame(); + }); + + expect(listenToScroll).not.toHaveBeenCalled(); + }); + }); + it.each([true, false])( 'stops preventing wheel events after unmount with useCapture as %s', (useCapture) => { From da56c9b1a70a438dce09ccf32138d5b21618c7d9 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Thu, 29 Oct 2026 11:48:52 +0100 Subject: [PATCH 2/2] test(InfiniteScrollPaginator): cover the per-frame guard and the cancel on observer replacement --- .../InfiniteScrollPaginator.tsx | 11 +++++----- .../InfiniteScrollPaginator.test.tsx | 22 +++++++++++++------ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx b/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx index 7c80ee08f..1aabd9aa3 100644 --- a/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx +++ b/src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx @@ -179,11 +179,12 @@ export const InfiniteScrollPaginator = forwardRef(function InfiniteScrollPaginat useEffect(() => { const root = rootRef.current; - if (!root || typeof ResizeObserver === 'undefined' || !scrollListener) return; - // Checked in the next frame, not in the observer's callback: the check can load a page, and the - // store update renders synchronously before the callback's delivery ends. A root sized by its - // content then resizes within that delivery, which the browser reports as "ResizeObserver loop - // completed with undelivered notifications". Run from a frame, the resize is observed in the next. + 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; diff --git a/src/components/InfiniteScrollPaginator/__tests__/InfiniteScrollPaginator.test.tsx b/src/components/InfiniteScrollPaginator/__tests__/InfiniteScrollPaginator.test.tsx index 7a60c9bd4..38c631702 100644 --- a/src/components/InfiniteScrollPaginator/__tests__/InfiniteScrollPaginator.test.tsx +++ b/src/components/InfiniteScrollPaginator/__tests__/InfiniteScrollPaginator.test.tsx @@ -47,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
@@ -65,6 +65,8 @@ describe('InfiniteScrollPaginator', () => { loadNextOnScrollToBottom, loadNextOnScrollToTop, replaceLoader: (loader: () => void) => rerender(renderWithLoader(loader)), + replaceThreshold: (threshold: number) => + rerender(renderWithLoader(loadNextOnScrollToTop, threshold)), root: setUpLayout(), }; }; @@ -168,8 +170,9 @@ describe('InfiniteScrollPaginator', () => { }); const resize = () => { - const observer = ResizeObserverMock.observers.at(-1) as ResizeObserverMock; - observer.cb([], observer as unknown as ResizeObserver); + 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 @@ -188,6 +191,7 @@ describe('InfiniteScrollPaginator', () => { 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; @@ -197,20 +201,24 @@ describe('InfiniteScrollPaginator', () => { act(() => { vi.advanceTimersToNextFrame(); }); + act(() => { + vi.advanceTimersByTime(INTERVAL_MS); + }); expect(listenToScroll).toHaveBeenCalledTimes(1); }); - it('does not check after unmount', () => { - const { listenToScroll, root } = renderPaginator(); + it('drops a pending check when the observer is replaced', () => { + const { listenToScroll, replaceThreshold, root } = renderPaginator(); root.scrollTop = 0; resize(); - cleanup(); + replaceThreshold(THRESHOLD * 2); act(() => { vi.advanceTimersToNextFrame(); }); + expect(ResizeObserverMock.observers).toHaveLength(2); expect(listenToScroll).not.toHaveBeenCalled(); }); });