From 32daff06f55f208cbc3c1af03325b15ddaf570ec Mon Sep 17 00:00:00 2001
From: Aman Ganapathy <84686202+nams1570@users.noreply.github.com>
Date: Tue, 14 Jul 2026 11:06:41 -0700
Subject: [PATCH] [Fix]: OOM Risk with Data Table (#1766)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
### Context
We were seeing the txn table and the customers tab under product page
OOM.
It turns out this was because the team icon when loaded would fetch all
of the teams.
---
## Summary by cubic
Fixes OOM and renderer crashes in the transactions and product customers
tables by stopping per-row refetch storms and avoiding list‑all‑teams
calls. Infinite scroll is now robust, and shared avatar skeletons keep
table rendering smooth.
- **Bug Fixes**
- Data grid (`@hexclave/dashboard-ui-components` `use-data-source`):
queue `loadMore` while a fetch is in flight and replay only after a
successful settle; discard queued requests when pagination mode leaves
infinite; commit the cursor only after a result is delivered; skip
redundant refetches when inputs match a completed fetch; abort in‑flight
requests on unmount.
- Transactions table and product customers tab: wrap `User*`/`Team*`
avatar cells in `Suspense` with a shared `AvatarCellSkeleton` to prevent
per‑row fetch storms and suspend thrash.
- `serverApp.getTeam`/`useTeam` (in `@hexclave/shared` consumers): fetch
a single team by id via a cache; return null for invalid ids; keep
user‑scoped variants membership‑scoped; stabilize hook order. This
removes the “fetch all teams per row” behavior that triggered OOMs.
- Prefetching: cap `/projects/*/teams` to `useTeams({ limit: 1 })` and
remove the heavy owner‑team users prefetch.
- **Refactors**
- Add unit tests for infinite pagination in `use-data-source` (deferred
`loadMore`, aborted resets, error handling, and cursor continuity).
Written for commit 970abc0f036e0ef0488ff7848d196096be82c8aa.
Summary will update on new commits.
## Summary by CodeRabbit
* **Bug Fixes**
* Added loading placeholders for customer avatar/name rendering across
payment and transaction customer tables to prevent jarring updates while
data loads.
* Improved infinite data loading to correctly queue and replay “load
more” after in-flight requests, handle abort/reset races, and avoid
stale cursor behavior; deferred requests are discarded when leaving
infinite mode.
* Improved user-scoped team lookups by validating team identifiers and
reliably returning `null` for missing/invalid teams.
* **Documentation**
* Clarified that user-level team lookups only return teams the user is a
member of.
* **Tests**
* Added/expanded coverage for infinite pagination cursor correctness,
race conditions, error handling, and mode transitions.
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: aman
---
.../products/[productId]/page-client.tsx | 9 +-
.../data-table/transaction-table.tsx | 16 +-
.../src/components/ui/data-table/cells.tsx | 11 +
.../src/lib/prefetch/url-prefetcher.tsx | 12 +-
.../src/components/data-grid/data-grid.tsx | 4 +-
.../data-grid/use-data-source.test.tsx | 289 ++++++++++++++++++
.../components/data-grid/use-data-source.ts | 138 +++++++--
.../apps/implementations/server-app-impl.ts | 36 ++-
sdks/spec/src/types/users/server-user.spec.md | 2 +-
9 files changed, 475 insertions(+), 42 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..71efa7ddb 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
@@ -9,6 +9,7 @@ import {
ActionCell,
Alert,
AvatarCell,
+ AvatarCellSkeleton,
Badge,
Button,
Checkbox,
@@ -1552,9 +1553,13 @@ function ProductCustomersSection({ productId, product }: ProductCustomersSection
width: 200,
renderCell: ({ row }) => (
row.customerType === 'user' ? (
-
+ }>
+
+
) : row.customerType === 'team' ? (
-
+ }>
+
+
) : (
diff --git a/apps/dashboard/src/components/data-table/transaction-table.tsx b/apps/dashboard/src/components/data-table/transaction-table.tsx
index 839b79729..0936d54eb 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, AvatarCellSkeleton, Badge, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SimpleTooltip } 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';
@@ -511,10 +511,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/apps/dashboard/src/components/ui/data-table/cells.tsx b/apps/dashboard/src/components/ui/data-table/cells.tsx
index 1a77b99a1..ac71e596d 100644
--- a/apps/dashboard/src/components/ui/data-table/cells.tsx
+++ b/apps/dashboard/src/components/ui/data-table/cells.tsx
@@ -6,6 +6,7 @@ import { Badge } from "../badge";
import { Button } from "../button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "../dropdown-menu";
import { SimpleTooltip } from "../simple-tooltip";
+import { Skeleton } from "../skeleton";
import { cn } from "@/lib/utils";
export function TextCell(props: { children: React.ReactNode, size?: number, icon?: React.ReactNode }) {
@@ -55,6 +56,16 @@ export function AvatarCell(props: { src?: string, fallback?: string }) {
);
}
+/** Loading placeholder matching AvatarCell + adjacent label layout. */
+export function AvatarCellSkeleton(props: { className?: string }) {
+ return (
+
+
+
+
+ );
+}
+
export function DateCell(props: { date: Date, ignoreAfterYears?: number }) {
const ignore = !!props.ignoreAfterYears && new Date(new Date().setFullYear(new Date().getFullYear() + props.ignoreAfterYears)) < props.date;
const timeString = props.date.toLocaleTimeString([], { year: 'numeric', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' });
diff --git a/apps/dashboard/src/lib/prefetch/url-prefetcher.tsx b/apps/dashboard/src/lib/prefetch/url-prefetcher.tsx
index 3cb32a877..311ac690b 100644
--- a/apps/dashboard/src/lib/prefetch/url-prefetcher.tsx
+++ b/apps/dashboard/src/lib/prefetch/url-prefetcher.tsx
@@ -81,7 +81,7 @@ const urlPrefetchers: Record {
- useAdminApp(projectId).useTeams();
+ useAdminApp(projectId).useTeams({ limit: 1 });
},
],
"/projects/*/teams/*": [
@@ -213,16 +213,6 @@ const urlPrefetchers: Record {
useDashboardInternalUser();
},
- ([_, projectId]) => {
- const project = useAdminApp(projectId).useProject();
- const teams = useDashboardInternalUser().useTeams();
- const ownerTeam = teams.find((team) => team.id === project.ownerTeamId);
- if (ownerTeam) {
- return [() => {
- ownerTeam.useUsers();
- }];
- }
- },
],
"/projects/*/payments/**": [
([_, projectId]) => {
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..4567ec8f1
--- /dev/null
+++ b/packages/dashboard-ui-components/src/components/data-grid/use-data-source.test.tsx
@@ -0,0 +1,289 @@
+// @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, DataGridDataPaginationMode, 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,
+ paginationMode = "infinite",
+}: {
+ dataSource: DataGridDataSource,
+ paginationMode?: DataGridDataPaginationMode,
+}) {
+ const gridData = useDataSource({
+ dataSource,
+ columns,
+ getRowId: (row) => row.id,
+ sorting: [],
+ quickSearch: "",
+ pagination: { pageIndex: 0, pageSize: 25 },
+ paginationMode,
+ });
+
+ return (
+ <>
+
+ {gridData.rows.length}
+ {gridData.rows[0]?.name ?? ""}
+ {gridData.error?.message ?? ""}
+ >
+ );
+}
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("useDataSource infinite pagination", () => {
+ it("defers loadMore during the initial fetch and replays it once settled", 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" }));
+ // Not started concurrently — queued behind the in-flight initial fetch.
+ expect(calls).toHaveLength(1);
+
+ await act(async () => {
+ resolveInitial();
+ });
+ // The queued loadMore replays automatically with the completed cursor,
+ // so a sentinel that fired during the fetch is not silently dropped.
+ await waitFor(() => expect(calls).toHaveLength(2));
+ expect(calls[1]).toEqual({ cursor: "cursor-1" });
+ });
+
+ it("keeps the committed cursor when an aborted reset is followed by a skipped refetch", async () => {
+ const callsA: Array<{ cursor: unknown }> = [];
+ const dataSourceA: DataGridDataSource = async function* (params) {
+ callsA.push({ cursor: params.cursor });
+ if (callsA.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,
+ };
+ };
+ // Stays in flight until we resolve it, after the switch back to A has
+ // already aborted it.
+ let resolveB: (value: void) => void = () => {};
+ const dataSourceB: DataGridDataSource = async function* () {
+ await new Promise((resolve) => {
+ resolveB = resolve;
+ });
+ };
+
+ const { getByRole, getByTestId, rerender } = render(
+ ,
+ );
+ await waitFor(() => expect(getByTestId("row-count").textContent).toBe("1"));
+
+ // Unstable dataSource identity flipping away and back to a completed
+ // reference: the B reset starts (and used to wipe cursor/pageIndex
+ // immediately), then gets aborted; the A effect run matches the
+ // completed-fetch key and skips.
+ rerender();
+ rerender();
+ await act(async () => {
+ resolveB();
+ });
+
+ fireEvent.click(getByRole("button", { name: "Load more" }));
+ await waitFor(() => expect(getByTestId("row-count").textContent).toBe("2"));
+ // The append must continue from the committed cursor, not restart from
+ // the beginning with cursor === undefined.
+ expect(callsA).toEqual([{ cursor: undefined }, { cursor: "cursor-1" }]);
+ });
+
+ it("does not treat an aborted zero-yield reset as a completed fetch", async () => {
+ const dataSourceA: DataGridDataSource = async function* () {
+ yield {
+ rows: [{ id: "row-a", name: "A" }],
+ nextCursor: null,
+ hasMore: false,
+ };
+ };
+ let bCalls = 0;
+ let resolveFirstB: (value: void) => void = () => {};
+ const dataSourceB: DataGridDataSource = async function* () {
+ bCalls++;
+ if (bCalls === 1) {
+ // Aborted before any yield: completing this must not mark B as a
+ // successful skip key, or a later render with B would keep A's rows.
+ await new Promise((resolve) => {
+ resolveFirstB = resolve;
+ });
+ return;
+ }
+ yield {
+ rows: [{ id: "row-b", name: "B" }],
+ nextCursor: null,
+ hasMore: false,
+ };
+ };
+
+ const { getByTestId, rerender } = render(
+ ,
+ );
+ await waitFor(() => expect(getByTestId("row-name").textContent).toBe("A"));
+
+ rerender();
+ rerender();
+ await act(async () => {
+ resolveFirstB();
+ });
+
+ rerender();
+ await waitFor(() => expect(getByTestId("row-name").textContent).toBe("B"));
+ expect(bCalls).toBe(2);
+ });
+
+ it("does not replay a deferred loadMore after the in-flight fetch errors", async () => {
+ const calls: Array<{ cursor: unknown }> = [];
+ let rejectInitial: (err: Error) => void = () => {};
+ const initialFetch = new Promise((_resolve, reject) => {
+ rejectInitial = reject;
+ });
+ const dataSource: DataGridDataSource = async function* (params) {
+ calls.push({ cursor: params.cursor });
+ await initialFetch;
+ yield { rows: [], nextCursor: null, hasMore: false };
+ };
+
+ const { getByRole, getByTestId } = render();
+ await waitFor(() => expect(calls).toHaveLength(1));
+
+ // Deferred while the (soon-to-fail) fetch is in flight.
+ fireEvent.click(getByRole("button", { name: "Load more" }));
+
+ await act(async () => {
+ rejectInitial(new Error("fetch failed"));
+ });
+
+ // The failed fetch must surface its error and must NOT chain the deferred
+ // append (which would fetch against inconsistent state and clear the
+ // error again via setError(null)).
+ await waitFor(() => expect(getByTestId("error").textContent).toBe("fetch failed"));
+ expect(calls).toHaveLength(1);
+ });
+
+ it("discards a deferred loadMore when pagination mode leaves infinite", 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, rerender } = render(
+ ,
+ );
+ await waitFor(() => expect(calls).toHaveLength(1));
+
+ fireEvent.click(getByRole("button", { name: "Load more" }));
+ expect(calls).toHaveLength(1);
+
+ rerender();
+
+ await act(async () => {
+ resolveInitial();
+ });
+
+ // Mode left infinite while the deferred loadMore was queued — do not
+ // append against server pagination after the fetch settles.
+ await waitFor(() => expect(calls).toHaveLength(1));
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ });
+ expect(calls).toHaveLength(1);
+ });
+
+ 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..3939e239c 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
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
import type {
DataGridColumnDef,
DataGridDataSource,
@@ -122,10 +123,28 @@ function useAsyncDataSource(opts: {
const [error, setError] = useState(null);
const cursorRef = useRef(undefined);
- const abortRef = useRef(null);
+ const inFlightFetchRef = useRef(null);
+ // The infinite-scroll sentinel's IntersectionObserver only fires on
+ // intersection *changes* (by design, to avoid auto-loading the whole
+ // dataset). If `loadMore` is called while another fetch is in flight we
+ // can't just drop it: the sentinel won't fire again until the user scrolls
+ // away and back. Remember the request and replay it once the in-flight
+ // fetch settles.
+ const pendingLoadMoreRef = useRef(false);
+ const hasMoreRef = useRef(true);
+ const paginationModeRef = useRef(paginationMode);
+ paginationModeRef.current = paginationMode;
const pageIndexRef = useRef(0);
const hasDataRef = useRef(false);
const hasMountedServerPaginationRef = useRef(false);
+ // Effects can be replayed when a descendant suspends. Only a fetch that
+ // actually delivered results is safe to reuse as a skip key.
+ const lastCompletedNonAppendFetchKeyRef = useRef<{
+ dataSource: DataGridDataSource,
+ sortingKey: string,
+ quickSearchKey: string,
+ pageSize: number,
+ } | null>(null);
const latestArgsRef = useRef({
dataSource,
@@ -141,6 +160,10 @@ function useAsyncDataSource(opts: {
const fetchPage = useCallback(
async (append: boolean) => {
+ if (append && inFlightFetchRef.current != null) {
+ return;
+ }
+
const {
dataSource: currentDataSource,
getRowId: currentGetRowId,
@@ -149,9 +172,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);
@@ -162,21 +191,34 @@ function useAsyncDataSource(opts: {
} else {
setIsLoading(true);
}
- cursorRef.current = undefined;
- pageIndexRef.current = 0;
}
// Clear previous error at the start of a new attempt; we'll set it
// again if this attempt fails.
setError(null);
+ // Cursor/page-index state is tracked locally and only committed to the
+ // shared refs when this fetch actually delivers a result. An aborted
+ // reset must not clobber the committed pagination state: if it did, a
+ // later effect run that skips via `lastCompletedNonAppendFetchKeyRef`
+ // would keep the old rows but leave `cursorRef === undefined`, and the
+ // next loadMore would silently restart from page one.
+ let cursor = append ? cursorRef.current : undefined;
+ let pageIndex = append ? pageIndexRef.current : 0;
+ let failed = false;
+ // The abort guard lives inside the for-await body, so a generator that
+ // completes with zero yields never hits it and would otherwise fall
+ // through to the post-loop completion-key write. Only mark a reset
+ // complete after it has actually delivered (and committed) a result.
+ let committedResult = false;
+
try {
const params: DataGridFetchParams = {
sorting: currentSorting,
quickSearch: currentQuickSearch,
pagination: append
- ? { pageIndex: pageIndexRef.current, pageSize: currentPagination.pageSize }
+ ? { pageIndex, pageSize: currentPagination.pageSize }
: currentPagination,
- cursor: cursorRef.current,
+ cursor,
};
const gen = currentDataSource(params);
@@ -188,9 +230,11 @@ function useAsyncDataSource(opts: {
setTotalRowCount(result.totalRowCount);
}
if (result.nextCursor !== undefined) {
- cursorRef.current = result.nextCursor;
+ cursor = result.nextCursor;
}
- setHasMore(result.hasMore !== false);
+ cursorRef.current = cursor;
+ hasMoreRef.current = result.hasMore !== false;
+ setHasMore(hasMoreRef.current);
if (append) {
setRows((prev) => {
@@ -201,11 +245,20 @@ function useAsyncDataSource(opts: {
return [...prev, ...newRows];
});
} else {
+ // The visible rows now belong to this (possibly still incomplete)
+ // reset, so the previously completed fetch key no longer describes
+ // them and must not allow a skip.
+ lastCompletedNonAppendFetchKeyRef.current = null;
setRows(result.rows);
}
hasDataRef.current = true;
- pageIndexRef.current++;
+ committedResult = true;
+ pageIndex++;
+ pageIndexRef.current = pageIndex;
+ }
+ if (!append && committedResult && !controller.signal.aborted) {
+ lastCompletedNonAppendFetchKeyRef.current = fetchKey;
}
} catch (err) {
if (controller.signal.aborted) return;
@@ -214,12 +267,35 @@ function useAsyncDataSource(opts: {
// consumer to wire up error rendering.
// eslint-disable-next-line no-console
console.error("[DataGrid] Data source error:", err);
+ failed = true;
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);
+ // Replay a loadMore that was requested (and deferred) while this
+ // fetch was in flight — but only if this fetch succeeded while we
+ // are still in infinite mode. Skip on failure (the append would run
+ // against inconsistent state and its setError(null) would hide this
+ // fetch's error), when pagination mode changed away from infinite,
+ // and on unmount-cleanup aborts — `inFlightFetchRef.current ===
+ // controller` with an aborted signal only happens then.
+ const shouldChainLoadMore =
+ pendingLoadMoreRef.current
+ && !failed
+ && !controller.signal.aborted
+ && hasMoreRef.current
+ && paginationModeRef.current === "infinite";
+ pendingLoadMoreRef.current = false;
+ if (shouldChainLoadMore) {
+ runAsynchronously(fetchPage(true));
+ }
}
}
},
@@ -227,8 +303,26 @@ function useAsyncDataSource(opts: {
);
useEffect(() => {
- fetchPage(false).catch(() => {});
- return () => abortRef.current?.abort();
+ // Deferred loadMore is only meaningful for infinite scroll; drop it when
+ // the consumer leaves that mode so a later settle cannot append wrongly.
+ if (paginationMode !== "infinite") {
+ pendingLoadMoreRef.current = false;
+ }
+ }, [paginationMode]);
+
+ useEffect(() => {
+ const lastCompletedKey = lastCompletedNonAppendFetchKeyRef.current;
+ const isSameCompletedFetch = hasDataRef.current
+ && lastCompletedKey?.dataSource === dataSource
+ && lastCompletedKey.sortingKey === sortingKey
+ && lastCompletedKey.quickSearchKey === quickSearchKey
+ && lastCompletedKey.pageSize === pagination.pageSize;
+ if (!isSameCompletedFetch) {
+ runAsynchronously(fetchPage(false));
+ }
+ // 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.
@@ -243,17 +337,25 @@ function useAsyncDataSource(opts: {
hasMountedServerPaginationRef.current = true;
return;
}
- fetchPage(false).catch(() => {});
+ runAsynchronously(fetchPage(false));
}, [fetchPage, paginationMode, pagination.pageIndex]);
const loadMore = useCallback(() => {
- if (!isLoadingMore && hasMore && paginationMode === "infinite") {
- fetchPage(true).catch(() => {});
+ if (paginationMode !== "infinite" || !hasMore) {
+ return;
}
- }, [isLoadingMore, hasMore, paginationMode, fetchPage]);
+ // Keep pagination single-flight. In particular, do not let the sentinel
+ // start an append against a cursor that a concurrent reset is replacing.
+ // Queue the request instead of dropping it (see pendingLoadMoreRef).
+ if (inFlightFetchRef.current != null) {
+ pendingLoadMoreRef.current = true;
+ return;
+ }
+ runAsynchronously(fetchPage(true));
+ }, [hasMore, paginationMode, fetchPage]);
const reload = useCallback(() => {
- fetchPage(false).catch(() => {});
+ runAsynchronously(fetchPage(false));
}, [fetchPage]);
return {
diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts
index 1594e4dcc..b147f04a7 100644
--- a/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts
+++ b/packages/template/src/lib/hexclave-app/apps/implementations/server-app-impl.ts
@@ -19,6 +19,7 @@ import { ProviderType } from "@hexclave/shared/dist/utils/oauth";
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
import { suspend } from "@hexclave/shared/dist/utils/react";
import { Result } from "@hexclave/shared/dist/utils/results";
+import { isUuid } from "@hexclave/shared/dist/utils/uuids";
import { WebAuthnError, startRegistration } from "@simplewebauthn/browser";
import { useMemo } from "react"; // THIS_LINE_PLATFORM react-like
import * as yup from "yup";
@@ -86,6 +87,20 @@ export class _HexclaveServerAppImplIncomplete(async ([userId, orderBy, desc, cursor, limit, query]) => {
return await this._interface.listServerTeamsPaginated({ userId, orderBy, desc, cursor, limit, query });
});
+ private readonly _serverTeamCache = createCache(async ([teamId]) => {
+ // The previous list-and-find implementation treated unknown or malformed IDs as null; preserve that behavior without making an invalid request.
+ if (!isUuid(teamId)) {
+ return null;
+ }
+ try {
+ return await this._interface.getServerTeam(teamId);
+ } catch (error) {
+ if (KnownErrors.TeamNotFound.isInstance(error)) {
+ return null;
+ }
+ throw error;
+ }
+ });
protected async _refreshTeamMembership(teamId: string, userId: string) {
await Promise.all([
@@ -708,6 +723,10 @@ export class _HexclaveServerAppImplIncomplete t.id === teamId) ?? null;
@@ -1038,6 +1057,7 @@ export class _HexclaveServerAppImplIncomplete) {
await app._interface.updateServerTeam(crud.id, serverTeamUpdateOptionsToCrud(update));
await Promise.all([
+ app._serverTeamCache.refresh([crud.id]),
app._serverTeamsCache.refreshWhere(() => true),
app._serverUsersCache.refreshWhere(() => true),
]);
@@ -1045,6 +1065,7 @@ export class _HexclaveServerAppImplIncomplete true),
app._serverUsersCache.refreshWhere(() => true),
]);
@@ -1543,6 +1564,7 @@ export class _HexclaveServerAppImplIncomplete {
const team = await this._interface.createServerTeam(serverTeamCreateOptionsToCrud(data));
+ await this._serverTeamCache.refresh([team.id]);
await this._serverTeamsCache.refreshWhere(() => true);
return this._serverTeamFromCrud(team);
}
@@ -1586,8 +1608,11 @@ export class _HexclaveServerAppImplIncomplete t.id === teamId) ?? null;
+ if (teamId == null) {
+ return null;
+ }
+ const team = Result.orThrow(await this._serverTeamCache.getOrWait([teamId], "write-only"));
+ return team == null ? null : this._serverTeamFromCrud(team);
}
}
@@ -1599,10 +1624,11 @@ export class _HexclaveServerAppImplIncomplete {
- return teams.find((t) => t.id === teamId) ?? null;
- }, [teams, teamId]);
+ return team == null ? null : this._serverTeamFromCrud(team);
+ }, [team]);
}
}
// END_PLATFORM
diff --git a/sdks/spec/src/types/users/server-user.spec.md b/sdks/spec/src/types/users/server-user.spec.md
index 6af5029ef..6a5bc7374 100644
--- a/sdks/spec/src/types/users/server-user.spec.md
+++ b/sdks/spec/src/types/users/server-user.spec.md
@@ -113,7 +113,7 @@ teamId: string
Returns: ServerTeam | null
-Find in listTeams() by id.
+Find in listTeams() by id. This is deliberately scoped to the user's own team memberships (unlike the app-level getTeam), so it returns null for teams the user is not a member of.
Does not error.