From 45d00f133a44b6951e5d48c97b9e31542b7da3f3 Mon Sep 17 00:00:00 2001 From: Aman Ganapathy <84686202+nams1570@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:29:59 -0700 Subject: [PATCH] [Fix](dashboard): prevent infinite-scroll observer from auto-loading entire dataset (#1734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Context 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. ### Summary of Changes Keep a single stable observer that reads the callback via a ref, so it only fires on genuine scroll changes. ### UI Changes Reproduced with a DataGrid in `paginationMode="infinite"` fed by a data source that always reports `hasMore: true`. The counter tracks how many load-more fetches fire **with no user scrolling**. **Before** — the observer is re-created every load cycle and re-fires on creation, auto-loading unboundedly (the counter races past 290 while rows stays at 3; on real data each fetch accumulates rows until the tab OOMs): ![Before: infinite-scroll runaway](https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJvcmdfaWQiOiJvcmdfaGwzT2d1STVWMXBYcTUwUCIsInVzZXJfaWQiOm51bGwsImJ1Y2tldF9uYW1lIjoiZGV2aW5hdHRhY2htZW50cyIsImJ1Y2tldF9rZXkiOiJhdHRhY2htZW50c19wcml2YXRlL29yZ19obDNPZ3VJNVYxcFhxNTBQLzM1YWJjODkyLWIzYzItNGVmZC04M2M3LWRlYTlmYjc4YjNjZiIsImlhdCI6MTc4MzMxMTgwMiwiZXhwIjoxNzgzOTE2NjAyLCJmaWxlbmFtZSI6ImJlZm9yZS1idWdneS53ZWJwIn0.rS-xeo6ZaWVSbdw7k_vepnpPG0segl8kgfLvwc41NJU) **After** — a single stable observer only fires on genuine intersection changes; fetches settle at 2 and stay put: ![After: infinite-scroll stable](https://app.devin.ai/api/presigned_proxy?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJvcmdfaWQiOiJvcmdfaGwzT2d1STVWMXBYcTUwUCIsInVzZXJfaWQiOm51bGwsImJ1Y2tldF9uYW1lIjoiZGV2aW5hdHRhY2htZW50cyIsImJ1Y2tldF9rZXkiOiJhdHRhY2htZW50c19wcml2YXRlL29yZ19obDNPZ3VJNVYxcFhxNTBQLzFlNDY5ODVjLTFhMDMtNDQ0MC04OGZiLWQyODdjZWViNmM5ZCIsImlhdCI6MTc4MzMxMTgwMiwiZXhwIjoxNzgzOTE2NjAyLCJmaWxlbmFtZSI6ImFmdGVyLWZpeGVkLndlYnAifQ.jpwVCwjnw5QNLc2xpC2xMytEnNeTFU7EAddwbuUNk1o) --- ## Summary by cubic Fixes DataGrid infinite-scroll auto-loading when the sentinel stays in view, preventing OOM crashes on long histories. Addresses Linear 1783309406. - Bug Fixes - Keep a single IntersectionObserver and read the `onLoadMore` callback via a ref, so it only fires on real intersection changes. - Add a regression test that simulates endless `hasMore: true` data and asserts no new observers are created across load-more cycles. Written for commit eb216dfe8a502c7aefc29f9ef2093ef3b16f849b. Summary will update on new commits. Review in cubic Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> 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 && (