Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) => (
<InfiniteScrollPaginator
data-testid='paginator-root'
listenToScroll={listenToScroll}
loadNextDebounceMs={INTERVAL_MS}
loadNextOnScrollToBottom={loadNextOnScrollToBottom}
loadNextOnScrollToTop={loader}
threshold={THRESHOLD}
threshold={threshold}
>
<div>content</div>
</InfiniteScrollPaginator>
Expand All @@ -64,6 +65,8 @@ describe('InfiniteScrollPaginator', () => {
loadNextOnScrollToBottom,
loadNextOnScrollToTop,
replaceLoader: (loader: () => void) => rerender(renderWithLoader(loader)),
replaceThreshold: (threshold: number) =>
rerender(renderWithLoader(loadNextOnScrollToTop, threshold)),
root: setUpLayout(),
};
};
Expand Down Expand Up @@ -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) => {
Expand Down
Loading