Fix data-grid refetch loop and per-row suspension causing renderer crash

Co-Authored-By: aman <aman@stack-auth.com>
This commit is contained in:
Devin AI 2026-07-13 19:53:33 +00:00
parent f33cb4cbc5
commit 433f7a692d
5 changed files with 196 additions and 13 deletions

View File

@ -1552,9 +1552,13 @@ function ProductCustomersSection({ productId, product }: ProductCustomersSection
width: 200,
renderCell: ({ row }) => (
row.customerType === 'user' ? (
<UserCell userId={row.customerId} />
<Suspense fallback={<CustomerAvatarSkeleton />}>
<UserCell userId={row.customerId} />
</Suspense>
) : row.customerType === 'team' ? (
<TeamCell teamId={row.customerId} />
<Suspense fallback={<CustomerAvatarSkeleton />}>
<TeamCell teamId={row.customerId} />
</Suspense>
) : (
<div className="flex items-center gap-2">
<AvatarCell fallback="?" />
@ -1639,6 +1643,15 @@ function ProductCustomersSection({ productId, product }: ProductCustomersSection
);
}
function CustomerAvatarSkeleton() {
return (
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-6 shrink-0 rounded-full" />
<Skeleton className="h-4 w-24" />
</div>
);
}
function CustomerRowActions({ customerType, customerId }: { customerType: string, customerId: string }) {
const adminApp = useAdminApp();
return (

View File

@ -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 (
<div className="flex items-center gap-2 max-w-40">
<Skeleton className="h-6 w-6 shrink-0 rounded-full" />
<Skeleton className="h-4 w-24" />
</div>
);
}
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 <UserAvatarCell userId={summary.customerId} />;
return (
<Suspense fallback={<CustomerAvatarSkeleton />}>
<UserAvatarCell userId={summary.customerId} />
</Suspense>
);
}
if (summary?.customerType === 'team' && summary.customerId) {
return <TeamAvatarCell teamId={summary.customerId} />;
return (
<Suspense fallback={<CustomerAvatarSkeleton />}>
<TeamAvatarCell teamId={summary.customerId} />
</Suspense>
);
}
return (
<span>

View File

@ -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);

View File

@ -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<Row>[] = [
{
id: "name",
header: "Name",
accessor: (row) => row.name,
width: 160,
},
];
function DataSourceHarness({ dataSource }: { dataSource: DataGridDataSource<Row> }) {
const gridData = useDataSource({
dataSource,
columns,
getRowId: (row) => row.id,
sorting: [],
quickSearch: "",
pagination: { pageIndex: 0, pageSize: 25 },
paginationMode: "infinite",
});
return (
<>
<button onClick={gridData.loadMore}>Load more</button>
<span data-testid="row-count">{gridData.rows.length}</span>
</>
);
}
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<void>((resolve) => {
resolveInitial = resolve;
});
const dataSource: DataGridDataSource<Row> = 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(<DataSourceHarness dataSource={dataSource} />);
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<Row> = 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(<DataSourceHarness dataSource={dataSource} />);
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" }]);
});
});

View File

@ -122,10 +122,19 @@ function useAsyncDataSource<TRow>(opts: {
const [error, setError] = useState<Error | null>(null);
const cursorRef = useRef<unknown>(undefined);
const abortRef = useRef<AbortController | null>(null);
const inFlightFetchRef = useRef<AbortController | null>(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<TRow>,
sortingKey: string,
quickSearchKey: string,
pageSize: number,
} | null>(null);
const latestArgsRef = useRef({
dataSource,
@ -141,6 +150,10 @@ function useAsyncDataSource<TRow>(opts: {
const fetchPage = useCallback(
async (append: boolean) => {
if (append && inFlightFetchRef.current != null) {
return;
}
const {
dataSource: currentDataSource,
getRowId: currentGetRowId,
@ -149,9 +162,15 @@ function useAsyncDataSource<TRow>(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<TRow>(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<TRow>(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<TRow>(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<TRow>(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(() => {});
}