[Fix]: OOM Risk with Data Table (#1766)

### 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.

<!-- This is an auto-generated description by cubic. -->
---
## 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).

<sup>Written for commit 970abc0f03.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1766?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: aman <aman@stack-auth.com>
This commit is contained in:
Aman Ganapathy 2026-07-14 11:06:41 -07:00 committed by GitHub
parent ce956a0fd2
commit 32daff06f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 475 additions and 42 deletions

View File

@ -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' ? (
<UserCell userId={row.customerId} />
<Suspense fallback={<AvatarCellSkeleton />}>
<UserCell userId={row.customerId} />
</Suspense>
) : row.customerType === 'team' ? (
<TeamCell teamId={row.customerId} />
<Suspense fallback={<AvatarCellSkeleton />}>
<TeamCell teamId={row.customerId} />
</Suspense>
) : (
<div className="flex items-center gap-2">
<AvatarCell fallback="?" />

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, 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 <UserAvatarCell userId={summary.customerId} />;
return (
<Suspense fallback={<AvatarCellSkeleton className="max-w-40" />}>
<UserAvatarCell userId={summary.customerId} />
</Suspense>
);
}
if (summary?.customerType === 'team' && summary.customerId) {
return <TeamAvatarCell teamId={summary.customerId} />;
return (
<Suspense fallback={<AvatarCellSkeleton className="max-w-40" />}>
<TeamAvatarCell teamId={summary.customerId} />
</Suspense>
);
}
return (
<span>

View File

@ -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 (
<div className={cn("flex items-center gap-2", props.className)}>
<Skeleton className="h-6 w-6 shrink-0 rounded-full" />
<Skeleton className="h-4 w-24" />
</div>
);
}
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' });

View File

@ -81,7 +81,7 @@ const urlPrefetchers: Record<string, ((match: RegExpMatchArray, query: URLSearch
],
"/projects/*/teams": [
([_, projectId]) => {
useAdminApp(projectId).useTeams();
useAdminApp(projectId).useTeams({ limit: 1 });
},
],
"/projects/*/teams/*": [
@ -213,16 +213,6 @@ const urlPrefetchers: Record<string, ((match: RegExpMatchArray, query: URLSearch
() => {
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]) => {

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,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<Row>[] = [
{
id: "name",
header: "Name",
accessor: (row) => row.name,
width: 160,
},
];
function DataSourceHarness({
dataSource,
paginationMode = "infinite",
}: {
dataSource: DataGridDataSource<Row>,
paginationMode?: DataGridDataPaginationMode,
}) {
const gridData = useDataSource({
dataSource,
columns,
getRowId: (row) => row.id,
sorting: [],
quickSearch: "",
pagination: { pageIndex: 0, pageSize: 25 },
paginationMode,
});
return (
<>
<button onClick={gridData.loadMore}>Load more</button>
<span data-testid="row-count">{gridData.rows.length}</span>
<span data-testid="row-name">{gridData.rows[0]?.name ?? ""}</span>
<span data-testid="error">{gridData.error?.message ?? ""}</span>
</>
);
}
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<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" }));
// 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<Row> = 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<Row> = async function* () {
await new Promise<void>((resolve) => {
resolveB = resolve;
});
};
const { getByRole, getByTestId, rerender } = render(
<DataSourceHarness dataSource={dataSourceA} />,
);
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(<DataSourceHarness dataSource={dataSourceB} />);
rerender(<DataSourceHarness dataSource={dataSourceA} />);
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<Row> = async function* () {
yield {
rows: [{ id: "row-a", name: "A" }],
nextCursor: null,
hasMore: false,
};
};
let bCalls = 0;
let resolveFirstB: (value: void) => void = () => {};
const dataSourceB: DataGridDataSource<Row> = 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<void>((resolve) => {
resolveFirstB = resolve;
});
return;
}
yield {
rows: [{ id: "row-b", name: "B" }],
nextCursor: null,
hasMore: false,
};
};
const { getByTestId, rerender } = render(
<DataSourceHarness dataSource={dataSourceA} />,
);
await waitFor(() => expect(getByTestId("row-name").textContent).toBe("A"));
rerender(<DataSourceHarness dataSource={dataSourceB} />);
rerender(<DataSourceHarness dataSource={dataSourceA} />);
await act(async () => {
resolveFirstB();
});
rerender(<DataSourceHarness dataSource={dataSourceB} />);
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<void>((_resolve, reject) => {
rejectInitial = reject;
});
const dataSource: DataGridDataSource<Row> = async function* (params) {
calls.push({ cursor: params.cursor });
await initialFetch;
yield { rows: [], nextCursor: null, hasMore: false };
};
const { getByRole, getByTestId } = render(<DataSourceHarness dataSource={dataSource} />);
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<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, rerender } = render(
<DataSourceHarness dataSource={dataSource} paginationMode="infinite" />,
);
await waitFor(() => expect(calls).toHaveLength(1));
fireEvent.click(getByRole("button", { name: "Load more" }));
expect(calls).toHaveLength(1);
rerender(<DataSourceHarness dataSource={dataSource} paginationMode="server" />);
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<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

@ -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<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);
// 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<TRow>,
sortingKey: string,
quickSearchKey: string,
pageSize: number,
} | null>(null);
const latestArgsRef = useRef({
dataSource,
@ -141,6 +160,10 @@ function useAsyncDataSource<TRow>(opts: {
const fetchPage = useCallback(
async (append: boolean) => {
if (append && inFlightFetchRef.current != null) {
return;
}
const {
dataSource: currentDataSource,
getRowId: currentGetRowId,
@ -149,9 +172,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);
@ -162,21 +191,34 @@ function useAsyncDataSource<TRow>(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<TRow>(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<TRow>(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<TRow>(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<TRow>(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<TRow>(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 {

View File

@ -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<HasTokenStore extends boolean, Pro
], TeamsCrud['Server']['List']>(async ([userId, orderBy, desc, cursor, limit, query]) => {
return await this._interface.listServerTeamsPaginated({ userId, orderBy, desc, cursor, limit, query });
});
private readonly _serverTeamCache = createCache<string[], TeamsCrud['Server']['Read'] | null>(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<HasTokenStore extends boolean, Pro
},
// END_PLATFORM
selectedTeam: crud.selected_team ? app._serverTeamFromCrud(crud.selected_team) : null,
// Unlike the app-level getTeam/useTeam (which fetch any team by id),
// the user-scoped variants search the user's own team list on purpose:
// they must return null for teams the user is not a member of, and some
// callers rely on that as a membership check.
async getTeam(teamId: string) {
const teams = await this.listTeams();
return teams.find((t) => t.id === teamId) ?? null;
@ -1038,6 +1057,7 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
async update(update: Partial<ServerTeamUpdateOptions>) {
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<HasTokenStore extends boolean, Pro
async delete() {
await app._interface.deleteServerTeam(crud.id);
await Promise.all([
app._serverTeamCache.refresh([crud.id]),
app._serverTeamsCache.refreshWhere(() => true),
app._serverUsersCache.refreshWhere(() => true),
]);
@ -1543,6 +1564,7 @@ export class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pro
async createTeam(data: ServerTeamCreateOptions): Promise<ServerTeam> {
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<HasTokenStore extends boolean, Pro
return await this._getTeamByApiKey(options.apiKey);
} else {
const teamId = options;
const teams = await this.listTeams();
return teams.find((t) => 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<HasTokenStore extends boolean, Pro
return this._useTeamByApiKey(options.apiKey);
} else {
const teamId = options;
const teams = this.useTeams();
// "" is never a valid UUID, so the cache resolves a nullish id to null while keeping the hook call order stable.
const team = useAsyncCache(this._serverTeamCache, [teamId ?? ""], "serverApp.useTeam()");
return useMemo(() => {
return teams.find((t) => t.id === teamId) ?? null;
}, [teams, teamId]);
return team == null ? null : this._serverTeamFromCrud(team);
}, [team]);
}
}
// END_PLATFORM

View File

@ -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.