mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
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:
parent
f33cb4cbc5
commit
433f7a692d
@ -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 (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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" }]);
|
||||
});
|
||||
});
|
||||
@ -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(() => {});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user