Skip to content

Commit dd40f58

Browse files
authored
fix(InfiniteScrollPaginator): check the edges on resize in the next frame (#3357)
Ticket: https://linear.app/stream/issue/REACT-1469 ### Goal Every page load of a v15 app whose channel list grows with its content reports `ResizeObserver loop completed with undelivered notifications`. Under CRA / webpack-dev-server this becomes a full-screen "Uncaught runtime errors" overlay that blocks clicks. It was found while migrating the team-ts and social-messenger-ts demos in website-react-examples to 15.0.0-rc.3 (GetStream/website-react-examples#138), and does not happen on v14. ### Root cause `InfiniteScrollPaginator` observes its root with a ResizeObserver and checks the distance to the edges on every resize. #3342 changed that check from a trailing `debounce` (always async) to a leading `throttle`, so the observer's first callback now calls `loadNextOnScrollToBottom` synchronously. The page load updates a `StateStore`, and the `useSyncExternalStore` subscription renders synchronously in the callback's microtasks. When the root is sized by its content (team-ts's channel lists are), the root grows within the same ResizeObserver delivery. The browser cannot deliver that resize in the same frame and reports the loop error. ### How it was isolated I ran a copy of team-ts against the rc.3 build with `window.ResizeObserver` wrapped from a module imported first. The wrapper recorded every callback's targets per animation frame and which observed elements changed size during the callback and its microtasks. The error appeared on 2 of 2 loads. Only the paginator's callback ever resized an observed element: the channel list root went from 240x111 to 240x132 within its own callback, and the next frame delivered 240x132. The `MessageList` relayout observer, `useFloatingDateSeparatorMessageList` and `useScrollLocationLogic` never resized an observed element. ### Implementation details - The observer callback now schedules the check with `requestAnimationFrame`, at most once per frame, and the frame is cancelled on unmount. The resize the loaded page causes is then observed in the following frame instead of within the current delivery. - Scroll-triggered checks are unchanged and still run immediately. - New tests in `InfiniteScrollPaginator.test.tsx` cover: no check inside the observer callback (the check runs in the next frame), one check for several resizes in a frame, and a pending check dropped when the observer is replaced. Each fails when the code it covers is removed (the deferral, the per-frame guard, the cancel in the cleanup). Verified: with a local build of this branch in the same team-ts copy, there was no loop error on 5 of 5 loads, and no observer callback resized an observed element. `yarn lint`, `tsc -p tsconfig.lib.json --noEmit` and `yarn test` (3,300 tests) pass. Related: REACT-1439 (a content-height `ChannelList` loads every page on mount). This PR does not change that behavior; it only moves the load out of the observer callback. ### UI Changes None visible, apart from the dev-server error overlay no longer appearing. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **Bug Fixes** * Improved infinite-scroll behavior when content is resized: edge checks now run on the next frame, and repeated resize events within that frame trigger only one check. * Pending checks are discarded when the observer configuration changes or the component is cleaned up. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent cf7a9f1 commit dd40f58

2 files changed

Lines changed: 84 additions & 4 deletions

File tree

‎src/components/InfiniteScrollPaginator/InfiniteScrollPaginator.tsx‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,12 +179,25 @@ export const InfiniteScrollPaginator = forwardRef(function InfiniteScrollPaginat
179179

180180
useEffect(() => {
181181
const root = rootRef.current;
182-
if (!root || typeof ResizeObserver === 'undefined' || !scrollListener) return;
183-
const observer = new ResizeObserver(scrollListener);
182+
if (!root || typeof ResizeObserver === 'undefined') return;
183+
// The check runs in the next animation frame, not in the observer's callback. It can load a
184+
// page, and the store update renders synchronously before the callback returns: a root sized by
185+
// its content would resize while the browser is still delivering resize notifications, which it
186+
// reports as "ResizeObserver loop completed with undelivered notifications". From a frame, the
187+
// page renders before layout, and the observer reports that resize in its next delivery.
188+
let frameId: number | undefined;
189+
const observer = new ResizeObserver(() => {
190+
if (typeof frameId !== 'undefined') return;
191+
frameId = window.requestAnimationFrame(() => {
192+
frameId = undefined;
193+
scrollListener();
194+
});
195+
});
184196
observer.observe(root as Element);
185197

186198
return () => {
187199
observer.disconnect();
200+
if (typeof frameId !== 'undefined') window.cancelAnimationFrame(frameId);
188201
};
189202
}, [scrollListener]);
190203

‎src/components/InfiniteScrollPaginator/__tests__/InfiniteScrollPaginator.test.tsx‎

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
33

44
import { InfiniteScrollPaginator } from '../InfiniteScrollPaginator';
5+
import { ResizeObserverMock } from '../../../mock-builders/browser';
56

67
const THRESHOLD = 100;
78
const INTERVAL_MS = 500;
@@ -46,14 +47,14 @@ describe('InfiniteScrollPaginator', () => {
4647
const listenToScroll = vi.fn();
4748
const loadNextOnScrollToBottom = vi.fn();
4849
const loadNextOnScrollToTop = vi.fn();
49-
const renderWithLoader = (loader: () => void) => (
50+
const renderWithLoader = (loader: () => void, threshold = THRESHOLD) => (
5051
<InfiniteScrollPaginator
5152
data-testid='paginator-root'
5253
listenToScroll={listenToScroll}
5354
loadNextDebounceMs={INTERVAL_MS}
5455
loadNextOnScrollToBottom={loadNextOnScrollToBottom}
5556
loadNextOnScrollToTop={loader}
56-
threshold={THRESHOLD}
57+
threshold={threshold}
5758
>
5859
<div>content</div>
5960
</InfiniteScrollPaginator>
@@ -64,6 +65,8 @@ describe('InfiniteScrollPaginator', () => {
6465
loadNextOnScrollToBottom,
6566
loadNextOnScrollToTop,
6667
replaceLoader: (loader: () => void) => rerender(renderWithLoader(loader)),
68+
replaceThreshold: (threshold: number) =>
69+
rerender(renderWithLoader(loadNextOnScrollToTop, threshold)),
6770
root: setUpLayout(),
6871
};
6972
};
@@ -156,6 +159,70 @@ describe('InfiniteScrollPaginator', () => {
156159
expect(replacedLoader).toHaveBeenCalledTimes(1);
157160
});
158161

162+
describe('on resize', () => {
163+
beforeEach(() => {
164+
ResizeObserverMock.observers = [];
165+
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
166+
});
167+
168+
afterEach(() => {
169+
vi.unstubAllGlobals();
170+
});
171+
172+
const resize = () => {
173+
const observer = ResizeObserverMock.observers.at(-1);
174+
expect(observer).toBeDefined();
175+
observer?.cb([], observer as unknown as ResizeObserver);
176+
};
177+
178+
// A page loaded inside the observer's callback renders before its delivery ends, and a root
179+
// sized by its content then resizes within it: "ResizeObserver loop completed with
180+
// undelivered notifications".
181+
it('checks the distance to the edges in the next frame, not in the observer callback', () => {
182+
const { loadNextOnScrollToTop, root } = renderPaginator();
183+
root.scrollTop = 0;
184+
185+
resize();
186+
expect(loadNextOnScrollToTop).not.toHaveBeenCalled();
187+
188+
act(() => {
189+
vi.advanceTimersToNextFrame();
190+
});
191+
expect(loadNextOnScrollToTop).toHaveBeenCalledTimes(1);
192+
});
193+
194+
// Two checks in one frame would leave the second for the end of the throttle interval.
195+
it('checks once for resizes reported within the same frame', () => {
196+
const { listenToScroll, root } = renderPaginator();
197+
root.scrollTop = 0;
198+
199+
resize();
200+
resize();
201+
act(() => {
202+
vi.advanceTimersToNextFrame();
203+
});
204+
act(() => {
205+
vi.advanceTimersByTime(INTERVAL_MS);
206+
});
207+
208+
expect(listenToScroll).toHaveBeenCalledTimes(1);
209+
});
210+
211+
it('drops a pending check when the observer is replaced', () => {
212+
const { listenToScroll, replaceThreshold, root } = renderPaginator();
213+
root.scrollTop = 0;
214+
215+
resize();
216+
replaceThreshold(THRESHOLD * 2);
217+
act(() => {
218+
vi.advanceTimersToNextFrame();
219+
});
220+
221+
expect(ResizeObserverMock.observers).toHaveLength(2);
222+
expect(listenToScroll).not.toHaveBeenCalled();
223+
});
224+
});
225+
159226
it.each([true, false])(
160227
'stops preventing wheel events after unmount with useCapture as %s',
161228
(useCapture) => {

0 commit comments

Comments
 (0)