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(() => {});
}