Skip to content

LoadMoreItem loads every page at once when the collection scrolls with the page (useLoadMoreSentinel uses <html> as an explicit IntersectionObserver root) #10627

Description

@gustavozirbes

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions