mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Merge remote-tracking branch 'origin/dev' into ai-analytics
This commit is contained in:
commit
862d2ed661
24
CHANGELOG.md
24
CHANGELOG.md
@ -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.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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="?" />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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]) => {
|
||||
|
||||
@ -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,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" }]);
|
||||
});
|
||||
});
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user