From eb216dfe8a502c7aefc29f9ef2093ef3b16f849b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 03:43:32 +0000 Subject: [PATCH] fix(dashboard): prevent infinite-scroll observer from auto-loading entire dataset The DataGrid infinite-scroll sentinel re-created its IntersectionObserver whenever the onLoadMore callback changed identity (every isLoadingMore/hasMore toggle). A freshly-created observer re-reports the sentinel's current intersection state, so a sentinel that stays in view fired onLoadMore after every page, auto-loading the whole dataset back-to-back and OOM-crashing the tab on long transaction/customer histories. Keep a single stable observer that reads the callback via a ref, so it only fires on genuine scroll changes. Co-Authored-By: aman --- .../components/data-grid/data-grid.test.tsx | 133 +++++++++++++++++- .../src/components/data-grid/data-grid.tsx | 15 +- 2 files changed, 144 insertions(+), 4 deletions(-) diff --git a/packages/dashboard-ui-components/src/components/data-grid/data-grid.test.tsx b/packages/dashboard-ui-components/src/components/data-grid/data-grid.test.tsx index d63a1ceea..08a76d51a 100644 --- a/packages/dashboard-ui-components/src/components/data-grid/data-grid.test.tsx +++ b/packages/dashboard-ui-components/src/components/data-grid/data-grid.test.tsx @@ -1,13 +1,15 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; -import { useState } from "react"; +import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { useMemo, useState } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createDefaultDataGridState, DataGrid, isDataGridInteractiveRowClickTarget, + useDataSource, type DataGridColumnDef, + type DataGridDataSource, type DataGridProps, } from "./index"; @@ -52,6 +54,7 @@ type ObserverRecord = { }; let intersectionObserverRecords: ObserverRecord[] = []; +let intersectionObserverInstances: MockIntersectionObserver[] = []; class MockIntersectionObserver implements IntersectionObserver { readonly root: Element | Document | null; @@ -74,6 +77,7 @@ class MockIntersectionObserver implements IntersectionObserver { : [options?.threshold ?? 0]; this.record = { options }; intersectionObserverRecords.push(this.record); + intersectionObserverInstances.push(this); } disconnect() {} @@ -266,6 +270,131 @@ describe("DataGrid infinite scroll observer", () => { }); }); +// Drives a real `useDataSource` infinite-scroll grid whose data source +// always reports `hasMore: true`, mirroring a project with a long +// transaction / customer history (e.g. the transactions table and customers +// tab). Used to prove the sentinel doesn't thrash its IntersectionObserver. +function InfiniteScrollLoadMoreHarness({ onFetch }: { onFetch: () => void }) { + const [state, setState] = useState(() => createDefaultDataGridState(columns)); + const dataSource = useMemo>( + () => { + let page = 0; + return async function* () { + onFetch(); + const current = page++; + yield { + rows: [{ id: `row-${current}`, name: `Row ${current}` }], + hasMore: true, + nextCursor: `cursor-${current}`, + }; + }; + }, + [onFetch], + ); + + const gridData = useDataSource({ + 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 && (