({
+ dataSource,
+ columns,
+ getRowId: (row) => row.id,
+ sorting: state.sorting,
+ quickSearch: state.quickSearch,
+ pagination: state.pagination,
+ paginationMode: "infinite",
+ });
+
+ return (
+
+
+ columns={columns}
+ rows={gridData.rows}
+ getRowId={(row) => row.id}
+ state={state}
+ onChange={setState}
+ paginationMode="infinite"
+ hasMore={gridData.hasMore}
+ isLoading={gridData.isLoading}
+ isLoadingMore={gridData.isLoadingMore}
+ onLoadMore={gridData.loadMore}
+ />
+
+ );
+}
+
+describe("DataGrid infinite scroll observer stability", () => {
+ beforeEach(() => {
+ intersectionObserverRecords = [];
+ intersectionObserverInstances = [];
+
+ vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
+ vi.stubGlobal("ResizeObserver", MockResizeObserver);
+ vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
+ function getBoundingClientRect() {
+ return {
+ x: 0,
+ y: 0,
+ width: 320,
+ height: 44,
+ top: 0,
+ left: 0,
+ right: 320,
+ bottom: 44,
+ toJSON() {
+ return this;
+ },
+ } as DOMRect;
+ },
+ );
+ Object.defineProperty(HTMLElement.prototype, "clientHeight", {
+ configurable: true,
+ get() {
+ return 400;
+ },
+ });
+ Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
+ configurable: true,
+ get() {
+ return 400;
+ },
+ });
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ // Regression: the sentinel used to re-create its IntersectionObserver every
+ // time the `onLoadMore` callback changed identity (which happens on every
+ // `isLoadingMore` / `hasMore` toggle). A freshly-created observer re-reports
+ // the sentinel's current intersection state, so a sentinel that stays in
+ // view fires `onLoadMore` again after every page — auto-loading the entire
+ // history back-to-back and OOM-crashing the tab ("Aw snap") on large
+ // datasets. The observer must stay stable across load-more cycles.
+ it("does not re-create the observer on load-more cycles", async () => {
+ const onFetch = vi.fn();
+ render();
+
+ // Initial page load, after which the sentinel (and its observer) mounts.
+ await waitFor(() => expect(onFetch).toHaveBeenCalledTimes(1));
+ await waitFor(() => expect(intersectionObserverInstances.length).toBeGreaterThan(0));
+
+ const observersBeforeLoadMore = intersectionObserverInstances.length;
+
+ // Simulate the sentinel scrolling into view exactly once.
+ await act(async () => {
+ intersectionObserverInstances.at(-1)?.trigger();
+ await Promise.resolve();
+ });
+
+ await waitFor(() => expect(onFetch).toHaveBeenCalledTimes(2));
+
+ // A single scroll-in must trigger a single fetch, without spawning new
+ // observers — otherwise each new observer would re-fire and runaway.
+ expect(intersectionObserverInstances.length).toBe(observersBeforeLoadMore);
+ });
+});
+
describe("DataGrid controlled callbacks", () => {
beforeEach(() => {
vi.stubGlobal("ResizeObserver", MockResizeObserver);
diff --git a/packages/dashboard-ui-components/src/components/data-grid/data-grid.tsx b/packages/dashboard-ui-components/src/components/data-grid/data-grid.tsx
index 110e3d95b..7c4522e08 100644
--- a/packages/dashboard-ui-components/src/components/data-grid/data-grid.tsx
+++ b/packages/dashboard-ui-components/src/components/data-grid/data-grid.tsx
@@ -455,16 +455,27 @@ function InfiniteScrollSentinel({
strings: DataGridStrings;
}) {
const ref = useRef(null);
+ // Read `onIntersect` through a ref so the observer never has to be
+ // re-created when the callback identity changes. `onLoadMore` (and thus
+ // `onIntersect`) changes on every `isLoadingMore`/`hasMore` toggle; if the
+ // observer were re-created each time, a freshly-created observer re-reports
+ // the sentinel's *current* intersection state, so a sentinel that stays in
+ // view keeps firing `onIntersect` after every page. That auto-loads the
+ // entire dataset back-to-back (defeating "load on scroll") and OOM-crashes
+ // the tab on long transaction/customer histories. A single stable observer
+ // only fires on genuine intersection *changes* (real user scrolls).
+ const onIntersectRef = useRef(onIntersect);
+ onIntersectRef.current = onIntersect;
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
- (entries) => { if (entries[0]?.isIntersecting) onIntersect(); },
+ (entries) => { if (entries[0]?.isIntersecting) onIntersectRef.current(); },
{ root: rootRef?.current ?? null, rootMargin: "200px" },
);
observer.observe(el);
return () => observer.disconnect();
- }, [onIntersect, rootRef]);
+ }, [rootRef]);
return (
{isLoading && (