From 433f7a692d7ae0125fb8cee72db228b1ce8f0de8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:53:33 +0000 Subject: [PATCH] Fix data-grid refetch loop and per-row suspension causing renderer crash Co-Authored-By: aman --- .../products/[productId]/page-client.tsx | 17 ++- .../data-table/transaction-table.tsx | 25 +++- .../src/components/data-grid/data-grid.tsx | 4 +- .../data-grid/use-data-source.test.tsx | 109 ++++++++++++++++++ .../components/data-grid/use-data-source.ts | 54 ++++++++- 5 files changed, 196 insertions(+), 13 deletions(-) create mode 100644 packages/dashboard-ui-components/src/components/data-grid/use-data-source.test.tsx diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx index 3070618c9..81cfcdc21 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx @@ -1552,9 +1552,13 @@ function ProductCustomersSection({ productId, product }: ProductCustomersSection width: 200, renderCell: ({ row }) => ( row.customerType === 'user' ? ( - + }> + + ) : row.customerType === 'team' ? ( - + }> + + ) : (
@@ -1639,6 +1643,15 @@ function ProductCustomersSection({ productId, product }: ProductCustomersSection ); } +function CustomerAvatarSkeleton() { + return ( +
+ + +
+ ); +} + function CustomerRowActions({ customerType, customerId }: { customerType: string, customerId: string }) { const adminApp = useAdminApp(); return ( diff --git a/apps/dashboard/src/components/data-table/transaction-table.tsx b/apps/dashboard/src/components/data-table/transaction-table.tsx index 839b79729..5c1dd5d3c 100644 --- a/apps/dashboard/src/components/data-table/transaction-table.tsx +++ b/apps/dashboard/src/components/data-table/transaction-table.tsx @@ -5,7 +5,7 @@ 'use client'; import { useAdminApp } from '@/app/(main)/(protected)/projects/[projectId]/use-admin-app'; -import { ActionCell, ActionDialog, Alert, AlertDescription, AvatarCell, Badge, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SimpleTooltip } from '@/components/ui'; +import { ActionCell, ActionDialog, Alert, AlertDescription, AvatarCell, Badge, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SimpleTooltip, Skeleton } from '@/components/ui'; import type { Icon as PhosphorIcon } from '@phosphor-icons/react'; import { ArrowClockwiseIcon, ArrowCounterClockwiseIcon, GearIcon, ProhibitIcon, QuestionIcon, ReceiptXIcon, ShoppingCartIcon, ShuffleIcon } from '@phosphor-icons/react'; import { DataGrid, DataGridToolbar, useDataGridUrlState, useDataSource, type DataGridColumnDef, type DataGridDataSource, type DataGridExportField, type DataGridExportScope } from '@hexclave/dashboard-ui-components'; @@ -15,7 +15,7 @@ import { moneyAmountSchema } from '@hexclave/shared/dist/schema-fields'; import { moneyAmountToStripeUnits } from '@hexclave/shared/dist/utils/currencies'; import type { MoneyAmount } from '@hexclave/shared/dist/utils/currency-constants'; import { SUPPORTED_CURRENCIES } from '@hexclave/shared/dist/utils/currency-constants'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { Suspense, useCallback, useMemo, useRef, useState } from 'react'; import { Link } from '../link'; type SourceType = 'subscription' | 'one_time' | 'item_quantity_change' | 'other'; @@ -153,6 +153,15 @@ function TeamAvatarCell({ teamId }: { teamId: string }) { ); } +function CustomerAvatarSkeleton() { + return ( +
+ + +
+ ); +} + function pickChargedAmountDisplay(entry: MoneyTransferEntry | undefined): string { if (!entry) { return '—'; @@ -511,10 +520,18 @@ function TransactionTableBody(props: { renderCell: ({ row }) => { const summary = summaryByIdRef.current.get(row.id); if (summary?.customerType === 'user' && summary.customerId) { - return ; + return ( + }> + + + ); } if (summary?.customerType === 'team' && summary.customerId) { - return ; + return ( + }> + + + ); } return ( 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 263a5452d..b930b2b57 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 @@ -475,7 +475,9 @@ function InfiniteScrollSentinel({ const el = ref.current; if (!el) return; const observer = new IntersectionObserver( - (entries) => { if (entries[0]?.isIntersecting) onIntersectRef.current(); }, + (entries) => { + if (entries[0]?.isIntersecting) onIntersectRef.current(); + }, { root: rootRef?.current ?? null, rootMargin: "200px" }, ); observer.observe(el); diff --git a/packages/dashboard-ui-components/src/components/data-grid/use-data-source.test.tsx b/packages/dashboard-ui-components/src/components/data-grid/use-data-source.test.tsx new file mode 100644 index 000000000..1748a5a14 --- /dev/null +++ b/packages/dashboard-ui-components/src/components/data-grid/use-data-source.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment jsdom + +import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { useDataSource } from "./use-data-source"; +import type { DataGridColumnDef, DataGridDataSource } from "./types"; + +type Row = { + id: string, + name: string, +}; + +const columns: DataGridColumnDef[] = [ + { + id: "name", + header: "Name", + accessor: (row) => row.name, + width: 160, + }, +]; + +function DataSourceHarness({ dataSource }: { dataSource: DataGridDataSource }) { + const gridData = useDataSource({ + dataSource, + columns, + getRowId: (row) => row.id, + sorting: [], + quickSearch: "", + pagination: { pageIndex: 0, pageSize: 25 }, + paginationMode: "infinite", + }); + + return ( + <> + + {gridData.rows.length} + + ); +} + +afterEach(() => { + cleanup(); +}); + +describe("useDataSource infinite pagination", () => { + it("does not start loadMore while the initial fetch is in flight", async () => { + const calls: Array<{ cursor: unknown }> = []; + let resolveInitial: (value: void) => void = () => {}; + const initialFetch = new Promise((resolve) => { + resolveInitial = resolve; + }); + const dataSource: DataGridDataSource = async function* (params) { + calls.push({ cursor: params.cursor }); + if (calls.length === 1) { + await initialFetch; + yield { + rows: [{ id: "row-1", name: "Row 1" }], + nextCursor: "cursor-1", + hasMore: true, + }; + return; + } + yield { + rows: [{ id: "row-2", name: "Row 2" }], + nextCursor: null, + hasMore: false, + }; + }; + + const { getByRole } = render(); + await waitFor(() => expect(calls).toHaveLength(1)); + + fireEvent.click(getByRole("button", { name: "Load more" })); + expect(calls).toHaveLength(1); + + await act(async () => { + resolveInitial(); + }); + await waitFor(() => expect(getByRole("button", { name: "Load more" })).toBeTruthy()); + }); + + it("uses the completed page cursor for the next loadMore fetch", async () => { + const calls: Array<{ cursor: unknown }> = []; + const dataSource: DataGridDataSource = async function* (params) { + calls.push({ cursor: params.cursor }); + if (calls.length === 1) { + yield { + rows: [{ id: "row-1", name: "Row 1" }], + nextCursor: "cursor-1", + hasMore: true, + }; + return; + } + yield { + rows: [{ id: "row-2", name: "Row 2" }], + nextCursor: null, + hasMore: false, + }; + }; + + const { getByRole, getByTestId } = render(); + await waitFor(() => expect(getByTestId("row-count").textContent).toBe("1")); + + fireEvent.click(getByRole("button", { name: "Load more" })); + + await waitFor(() => expect(getByTestId("row-count").textContent).toBe("2")); + expect(calls).toEqual([{ cursor: undefined }, { cursor: "cursor-1" }]); + }); +}); diff --git a/packages/dashboard-ui-components/src/components/data-grid/use-data-source.ts b/packages/dashboard-ui-components/src/components/data-grid/use-data-source.ts index 123e77ada..dec100a3c 100644 --- a/packages/dashboard-ui-components/src/components/data-grid/use-data-source.ts +++ b/packages/dashboard-ui-components/src/components/data-grid/use-data-source.ts @@ -122,10 +122,19 @@ function useAsyncDataSource(opts: { const [error, setError] = useState(null); const cursorRef = useRef(undefined); - const abortRef = useRef(null); + const inFlightFetchRef = useRef(null); const pageIndexRef = useRef(0); const hasDataRef = useRef(false); const hasMountedServerPaginationRef = useRef(false); + // Effects can be replayed when a descendant suspends. Only a completed + // fetch is safe to reuse; an aborted fetch may have reset the cursor without + // ever replacing the visible rows. + const lastCompletedNonAppendFetchKeyRef = useRef<{ + dataSource: DataGridDataSource, + sortingKey: string, + quickSearchKey: string, + pageSize: number, + } | null>(null); const latestArgsRef = useRef({ dataSource, @@ -141,6 +150,10 @@ function useAsyncDataSource(opts: { const fetchPage = useCallback( async (append: boolean) => { + if (append && inFlightFetchRef.current != null) { + return; + } + const { dataSource: currentDataSource, getRowId: currentGetRowId, @@ -149,9 +162,15 @@ function useAsyncDataSource(opts: { pagination: currentPagination, } = latestArgsRef.current; - abortRef.current?.abort(); + inFlightFetchRef.current?.abort(); const controller = new AbortController(); - abortRef.current = controller; + inFlightFetchRef.current = controller; + const fetchKey = { + dataSource: currentDataSource, + sortingKey: JSON.stringify(currentSorting), + quickSearchKey: currentQuickSearch, + pageSize: currentPagination.pageSize, + }; if (append) { setIsLoadingMore(true); @@ -207,6 +226,9 @@ function useAsyncDataSource(opts: { hasDataRef.current = true; pageIndexRef.current++; } + if (!append) { + lastCompletedNonAppendFetchKeyRef.current = fetchKey; + } } catch (err) { if (controller.signal.aborted) return; // Surface the error on the result so consumers can render retry UI. @@ -216,7 +238,12 @@ function useAsyncDataSource(opts: { console.error("[DataGrid] Data source error:", err); setError(err instanceof Error ? err : new Error(String(err))); } finally { - if (!controller.signal.aborted) { + // Only the owning fetch resets the loading flags; if this fetch was + // replaced by a newer one, that fetch manages them instead. This also + // covers cleanup-initiated aborts, which would otherwise leave + // `isLoadingMore` stuck and block all future loadMore calls. + if (inFlightFetchRef.current === controller) { + inFlightFetchRef.current = null; setIsLoading(false); setIsRefetching(false); setIsLoadingMore(false); @@ -227,8 +254,18 @@ function useAsyncDataSource(opts: { ); useEffect(() => { - fetchPage(false).catch(() => {}); - return () => abortRef.current?.abort(); + const lastCompletedKey = lastCompletedNonAppendFetchKeyRef.current; + const isSameCompletedFetch = hasDataRef.current + && lastCompletedKey?.dataSource === dataSource + && lastCompletedKey.sortingKey === sortingKey + && lastCompletedKey.quickSearchKey === quickSearchKey + && lastCompletedKey.pageSize === pagination.pageSize; + if (!isSameCompletedFetch) { + fetchPage(false).catch(() => {}); + } + // Always return the abort cleanup (even when the fetch is skipped) so an + // in-flight append is cancelled on unmount. + return () => inFlightFetchRef.current?.abort(); // Also refetches when `dataSource` identity changes — consumers encode // external filter state into the generator's closure, so a new // generator reference is the signal that the query changed. @@ -247,6 +284,11 @@ function useAsyncDataSource(opts: { }, [fetchPage, paginationMode, pagination.pageIndex]); const loadMore = useCallback(() => { + // Keep pagination single-flight. In particular, do not let the sentinel + // start an append against a cursor that a concurrent reset is replacing. + if (inFlightFetchRef.current != null) { + return; + } if (!isLoadingMore && hasMore && paginationMode === "infinite") { fetchPage(true).catch(() => {}); }