Provide a general summary of the issue here
When a collection with a *LoadMoreItem (GridListLoadMoreItem, ListBoxLoadMoreItem, TreeLoadMoreItem, …) scrolls with the page instead of inside its own scroll container, onLoadMore fires over and over right after mount, until every page is loaded. The user never scrolls.
The cause is in useLoadMoreSentinel. The observer root is getScrollParent(sentinel), and when no ancestor is scrollable that falls back to document.scrollingElement (the <html> element). That element is then passed as an explicit IntersectionObserver root. Per the Intersection Observer spec, an explicit element root with no content clip is measured by its bounding box. <html>'s overflow propagates to the viewport, so the element itself does not clip, and its box is the whole document. So the root rectangle covers the entire page (plus rootMargin), and the sentinel always counts as intersecting. useLoadMoreSentinel recreates the observer on every collection change, so each new page triggers the next load.
This also affects Virtualizer, which already virtualizes against the viewport (allowsWindowScrolling: true in the RAC CollectionRoot). A virtualized list that scrolls with the page therefore cannot use the built-in load-more item.
🤔 Expected Behavior?
With page scrolling, onLoadMore should fire only when the sentinel is within scrollOffset × viewport of the visible area, the same as inside a scroll container.
😯 Current Behavior
Every page loads immediately after mount. In the reproduction below (10 pages of 20 items, each 80px tall, with page scroll), onLoadMore is called 9 times without any scrolling in Chrome, Firefox and Safari (WebKit). The same list inside a height: 400px; overflow: auto container calls it 0 times until scrolled.
Logging the observer entries in Chrome shows rootBounds spanning 0 .. 2 × document height for the default scrollOffset of 1 (100% margin), instead of the viewport.
💁 Possible Solution
When the scroll parent is the document's scrolling element, observe through the Document instead of the <html> element. A Document root is measured by its viewport and still honours rootMargin, including inside iframes:
// useLoadMoreSentinel
let scrollParent = getScrollParent(ref.current);
let root = scrollParent === document.scrollingElement || scrollParent === document.documentElement
? scrollParent.ownerDocument
: scrollParent;
sentinelObserver.current = new IntersectionObserver(triggerLoadMore, {root, rootMargin});
We are running this as a local patch of react-aria@3.52.0. With it, the reproduction loads nothing on mount and exactly one page per scroll to the end, in Chrome, Firefox and WebKit. Collections inside element scroll containers (popovers, dialogs, panels) are unaffected because their root is still the element.
🔦 Context
We render a virtualized, infinitely paged card grid (Virtualizer + GridLayout + GridList + GridListLoadMoreItem) that should scroll with the window like any other page. The only way to page lazily was to put the grid in a fixed-height scroll container, which gives the page a second scrollbar. The same applies to any non-virtualized list with a load-more item that is not inside its own scroll container.
🖥️ Steps to Reproduce
Render this in a page with no scrollable ancestor (e.g. a fresh Vite + React app), open it, and do not scroll. The status reads "Loaded 10 of 10 pages" almost immediately, and window.loadMoreCalls is 9. Open it with ?contained and it stays on page 1 until the container is scrolled.
import { useState } from "react"
import { Collection, GridList, GridListItem, GridListLoadMoreItem } from "react-aria-components"
const PAGE_SIZE = 20
const TOTAL_PAGES = 10
const contained = new URLSearchParams(location.search).has("contained")
export function App() {
const [pages, setPages] = useState(1)
const items = Array.from({length: pages * PAGE_SIZE}, (_, index) => ({id: index, name: `Item ${index + 1}`}))
function loadMore() {
window.loadMoreCalls = (window.loadMoreCalls ?? 0) + 1
setPages((current) => Math.min(current + 1, TOTAL_PAGES))
}
return (
<main style={{padding: 16}}>
<p id="status">Loaded {pages} of {TOTAL_PAGES} pages</p>
<div style={contained ? {height: 400, overflow: 'auto'} : undefined}>
<GridList aria-label="Items">
<Collection items={items}>
{(item) => <GridListItem style={{height: 80}}>{item.name}</GridListItem>}
</Collection>
{pages < TOTAL_PAGES ? <GridListLoadMoreItem onLoadMore={loadMore}>Loading…</GridListLoadMoreItem> : null}
</GridList>
</div>
</main>
)
}
Version
react-aria-components 1.21.1 (react-aria 3.52.1); the same code is in the 2026-09-22 nightly
What browsers are you seeing the problem on?
Chrome, Firefox, Safari (reproduced with Playwright's Chromium, Firefox and WebKit builds)
What operating system are you using?
macOS
Provide a general summary of the issue here
When a collection with a
*LoadMoreItem(GridListLoadMoreItem,ListBoxLoadMoreItem,TreeLoadMoreItem, …) scrolls with the page instead of inside its own scroll container,onLoadMorefires over and over right after mount, until every page is loaded. The user never scrolls.The cause is in
useLoadMoreSentinel. The observer root isgetScrollParent(sentinel), and when no ancestor is scrollable that falls back todocument.scrollingElement(the<html>element). That element is then passed as an explicitIntersectionObserverroot. Per the Intersection Observer spec, an explicit element root with no content clip is measured by its bounding box.<html>'s overflow propagates to the viewport, so the element itself does not clip, and its box is the whole document. So the root rectangle covers the entire page (plusrootMargin), and the sentinel always counts as intersecting.useLoadMoreSentinelrecreates the observer on every collection change, so each new page triggers the next load.This also affects
Virtualizer, which already virtualizes against the viewport (allowsWindowScrolling: truein the RACCollectionRoot). A virtualized list that scrolls with the page therefore cannot use the built-in load-more item.🤔 Expected Behavior?
With page scrolling,
onLoadMoreshould fire only when the sentinel is withinscrollOffset× viewport of the visible area, the same as inside a scroll container.😯 Current Behavior
Every page loads immediately after mount. In the reproduction below (10 pages of 20 items, each 80px tall, with page scroll),
onLoadMoreis called 9 times without any scrolling in Chrome, Firefox and Safari (WebKit). The same list inside aheight: 400px; overflow: autocontainer calls it 0 times until scrolled.Logging the observer entries in Chrome shows
rootBoundsspanning0 .. 2 × document heightfor the defaultscrollOffsetof 1 (100% margin), instead of the viewport.💁 Possible Solution
When the scroll parent is the document's scrolling element, observe through the Document instead of the
<html>element. A Document root is measured by its viewport and still honoursrootMargin, including inside iframes:We are running this as a local patch of
react-aria@3.52.0. With it, the reproduction loads nothing on mount and exactly one page per scroll to the end, in Chrome, Firefox and WebKit. Collections inside element scroll containers (popovers, dialogs, panels) are unaffected because their root is still the element.🔦 Context
We render a virtualized, infinitely paged card grid (
Virtualizer+GridLayout+GridList+GridListLoadMoreItem) that should scroll with the window like any other page. The only way to page lazily was to put the grid in a fixed-height scroll container, which gives the page a second scrollbar. The same applies to any non-virtualized list with a load-more item that is not inside its own scroll container.🖥️ Steps to Reproduce
Render this in a page with no scrollable ancestor (e.g. a fresh Vite + React app), open it, and do not scroll. The status reads "Loaded 10 of 10 pages" almost immediately, and
window.loadMoreCallsis 9. Open it with?containedand it stays on page 1 until the container is scrolled.Version
react-aria-components 1.21.1 (react-aria 3.52.1); the same code is in the 2026-09-22 nightly
What browsers are you seeing the problem on?
Chrome, Firefox, Safari (reproduced with Playwright's Chromium, Firefox and WebKit builds)
What operating system are you using?
macOS