Merge remote-tracking branch 'origin/dev' into ai-analytics

This commit is contained in:
Aadesh Kheria 2026-07-15 16:08:32 -07:00
commit 862d2ed661
11 changed files with 526 additions and 43 deletions

View File

@ -2,6 +2,30 @@
---
## 7/10/26
- New CLI Auth dashboard app and a browser `<script>` setup path that works without a bundler.
- The project overview globe now centers on the viewer, and the onboarding auth preview is interactive.
- Payments customers now support manual item quantity adjustments directly from the dashboard.
- Fixes for user-page syncing, session replay playback freezes, and runaway infinite scrolling.
- Improved docs discoverability and dashboard resilience when internal billing data is temporarily unavailable.
## 7/3/26
- New navigation funnel graph, recent sign-up rule activity, and project IDs in platform analytics.
- Payments checkout now surfaces errors clearly, with a more polished onboarding flow.
- Expanded email delivery to arbitrary recipients on managed domains, plus filtered user exports and safer user dialogs.
- Leaner dashboard downloads and local-dashboard support for `hexclave exec`, with one unified development environment workflow.
- Large session replays are compressed to avoid dropped recordings, alongside faster analytics and Payments data processing.
## 6/26/26
- Unified payments customers table with "Create checkout" available everywhere and idempotent webhook handling.
- Faster project onboarding with a preview project pool for instant creation and prefetched email themes.
- New Usage settings page with performance improvements and a "View usage" shortcut from limit banners.
- TypeScript 6.0 upgrade, STACK_* → HEXCLAVE_* env-var rename, and devtool indicator auto-visibility.
- Stability fixes for session recording flush limits, duplicate-email sign-up errors, and recurring production errors.
## 6/19/26
- New clickmap heatmaps for route analytics — visualize where users click on any page.

View File

@ -533,6 +533,8 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
selectedCountryRef.current = selectedCountry;
const pendingCountryClearTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingHoverHitTestRafRef = useRef<number | null>(null);
const latestPointerPositionRef = useRef<{ clientX: number, clientY: number } | null>(null);
const clearPendingCountryClear = () => {
if (pendingCountryClearTimeoutRef.current != null) {
clearTimeout(pendingCountryClearTimeoutRef.current);
@ -589,6 +591,14 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
updateSelectedCountry(findCountryAt(globeCoords.lat, globeCoords.lng, countries.features));
};
const cancelPendingHoverHitTest = () => {
if (pendingHoverHitTestRafRef.current != null) {
cancelAnimationFrame(pendingHoverHitTestRafRef.current);
pendingHoverHitTestRafRef.current = null;
}
latestPointerPositionRef.current = null;
};
useEffect(() => {
if (selectedCountry) {
setPreviousSelectedCountry(selectedCountry);
@ -600,6 +610,10 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
if (pendingCountryClearTimeoutRef.current != null) {
clearTimeout(pendingCountryClearTimeoutRef.current);
}
if (pendingHoverHitTestRafRef.current != null) {
cancelAnimationFrame(pendingHoverHitTestRafRef.current);
}
latestPointerPositionRef.current = null;
};
}, []);
@ -1107,13 +1121,25 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
}}
onMouseMoveCapture={(e) => {
resumeRender();
updateSelectedCountryFromPointerPosition(e.clientX, e.clientY);
latestPointerPositionRef.current = { clientX: e.clientX, clientY: e.clientY };
// Mousemove can fire many times between paints; coalesce the expensive
// globe raycast and country scan to one hit-test per animation frame.
if (pendingHoverHitTestRafRef.current == null) {
pendingHoverHitTestRafRef.current = requestAnimationFrame(() => {
pendingHoverHitTestRafRef.current = null;
const pointerPosition = latestPointerPositionRef.current;
if (pointerPosition != null) {
updateSelectedCountryFromPointerPosition(pointerPosition.clientX, pointerPosition.clientY);
}
});
}
if (tooltipRef.current) {
tooltipRef.current.style.transform = `translate(${e.clientX}px, ${e.clientY}px)`;
}
}}
onMouseLeave={() => {
// Only clear when leaving the entire globe area
cancelPendingHoverHitTest();
updateSelectedCountry(null, { immediateClear: true });
if (globeRef.current) {
//globeRef.current.controls().autoRotate = true;

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.