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 <aman@stack-auth.com>
This commit is contained in:
Devin AI 2026-07-06 03:43:32 +00:00
parent 56dd0f357b
commit eb216dfe8a
2 changed files with 144 additions and 4 deletions

View File

@ -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<DataGridDataSource<Row>>(
() => {
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<Row>({
dataSource,
columns,
getRowId: (row) => row.id,
sorting: state.sorting,
quickSearch: state.quickSearch,
pagination: state.pagination,
paginationMode: "infinite",
});
return (
<div style={{ height: 400 }}>
<DataGrid<Row>
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}
/>
</div>
);
}
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(<InfiniteScrollLoadMoreHarness onFetch={onFetch} />);
// 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);

View File

@ -455,16 +455,27 @@ function InfiniteScrollSentinel({
strings: DataGridStrings;
}) {
const ref = useRef<HTMLDivElement>(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 (
<div ref={ref} className="flex items-center justify-center py-4">
{isLoading && (