feat(portal): adopt TanStack Query with a shared per-resource query layer (#7135)

## Why

The processor/portal loads slowly because every view fetches its data on
mount with no client-side cache — navigating away and back refetches
everything, and shared data (policies, sources, roster, fleet stats) is
fetched repeatedly. This adopts **TanStack Query** so the portal caches,
dedupes, and revalidates instead.

Follows the Users-page proof-of-concept (kept as the reference A/B
example behind a dev flag); DevTools before/after confirmed revisiting a
cached view now costs zero network calls.

## What

**Shared per-resource query layer** (`portal/queries/`) — the mechanism
for both in-view and cross-view sharing:
- `keys.ts` (flavor-agnostic queryKey factory), `adapters.ts`
(`toAsyncState` → the existing `AsyncState` shape, so view bodies barely
change)
- One **base hook per endpoint**; **derived hooks**
(`usePoliciesOverview`, `useProcessorFlow`, `useOnboardingProgress`)
compose them
- The bundle functions (`fetchPolicies`, `fetchProcessorFlow`,
`useOnboardingProgress`) are decomposed into base queries — otherwise
the caches wouldn't dedupe against each other

**Migrated:** Documents, Policies, Pipelines, Sources + all of Home's
fetching cards. Mutations use `invalidateQueries` (Policies' `version`
bump removed; Source/Pipeline builders invalidate-then-navigate;
ConnectionsTab + S3 picker share one cache).

**SaaS `/team/my` collapse:** `resolveTeam()` reads through the shared
cache (`ensureQueryData`), so roster + teams resolve it once (2→1), with
a direct-fetch fallback when no provider is mounted.

`QueryClientProvider` is mounted once at the portal root (`PortalApp`),
above the router, so the cache survives navigation.

## Impact on duplicate fetches

- **In-view:** Home `/policies` ×3, `/policies/runs` ×3, `/sources` ×2,
`/v1/editor/deployment` ×2 → **1× each** per mount
- **Cross-view:** Policies / Sources / Users / EditorAdmin /
Infrastructure reuse Home's warmed cache within `staleTime` (no refetch
on navigation)
- **SaaS Users:** `/team/my` 2× → **1×**

## Testing

- Portal typecheck + SaaS typecheck, ESLint (`--max-warnings=0`),
Prettier — all green
- **224 portal tests pass** (existing component tests wrapped in a
shared `QueryClient` test provider)
- New: `queries/sharing.test.tsx` (in-view: 3 consumers → 1 fetch each;
cross-view: remount → 0 refetch) and a `/team/my` collapse assertion in
`UsersReactQuery.test.tsx`

## Notes for reviewers

- Keys are intentionally flavor-agnostic (local vs SaaS routing lives
inside the api fns), so one key addresses whichever backend the flavor
build resolves.
- `staleTime` 30s / `gcTime` 5m defaults; tier-dependent resources key
on tier.
- Users view keeps its dev flag/legacy path deliberately as the
documented reference.
This commit is contained in:
ConnorYoh
2026-07-23 14:53:16 +00:00
committed by GitHub
parent a1b1f974a0
commit 54bf32485f
44 changed files with 908 additions and 180 deletions
+22 -15
View File
@@ -1,9 +1,11 @@
import { type ReactNode } from "react";
import { useState, type ReactNode } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { PortalAuthBoundary } from "@portal/auth/PortalAuthBoundary";
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
import { SuiProvider } from "@portal/theme/SuiProvider";
import { PortalProviders } from "@portal/PortalProviders";
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
import { createPortalQueryClient } from "@portal/queryClient";
// Reset + typography, scoped to .portal-scope below.
import "@portal/theme/base.css";
@@ -28,20 +30,25 @@ function ThemedSuiProvider({ children }: { children: ReactNode }) {
* self-hosted mounts the account-link layer, SaaS does not.
*/
export function PortalApp() {
// One client for the portal's lifetime. Sits above the router so its cache
// survives view navigation. Cheap and inert when no query hooks are mounted.
const [queryClient] = useState(createPortalQueryClient);
return (
<ThemeProvider>
<ThemedSuiProvider>
{/* Scopes base.css to the portal so it doesn't restyle the host editor. */}
<div className="portal-scope">
{/* Tool registry is read by portal views (e.g. the policy setup
wizard); mount it above the per-flavor provider split. */}
<ToolRegistryProvider>
<PortalAuthBoundary>
<PortalProviders />
</PortalAuthBoundary>
</ToolRegistryProvider>
</div>
</ThemedSuiProvider>
</ThemeProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<ThemedSuiProvider>
{/* Scopes base.css to the portal so it doesn't restyle the host editor. */}
<div className="portal-scope">
{/* Tool registry is read by portal views (e.g. the policy setup
wizard); mount it above the per-flavor provider split. */}
<ToolRegistryProvider>
<PortalAuthBoundary>
<PortalProviders />
</PortalAuthBoundary>
</ToolRegistryProvider>
</div>
</ThemedSuiProvider>
</ThemeProvider>
</QueryClientProvider>
);
}
+22 -10
View File
@@ -4,8 +4,8 @@
* The portal calls the real Stirling policy API (`/api/v1/policies`);
* Storybook and tests intercept the same calls with MSW handlers.
*
* `fetchPolicies()` assembles the decorated catalogue client-side from the
* backend's flat `WirePolicy[]` + `PolicyRunView[]`, mirroring the same
* The flat `WirePolicy[]` + `PolicyRunView[]` responses are assembled into the
* decorated catalogue client-side by `assemblePolicies()`, mirroring the same
* approach the editor uses for its own catalogue view.
*/
@@ -455,15 +455,27 @@ function decoratePolicy(
};
}
/** GET /api/v1/policies + GET /api/v1/policies/runs → assembled catalogue. */
export async function fetchPolicies(): Promise<PoliciesResponse> {
const [wirePolicies, runs] = await Promise.all([
apiClient.local.json<WirePolicy[]>("/api/v1/policies"),
apiClient.local
.json<PolicyRunView[]>("/api/v1/policies/runs")
.catch(() => [] as PolicyRunView[]),
]);
/** GET /api/v1/policies — the flat stored-policy records. */
export function fetchPoliciesList(): Promise<WirePolicy[]> {
return apiClient.local.json<WirePolicy[]>("/api/v1/policies");
}
/** GET /api/v1/policies/runs — best-effort (empty on a backend without runs). */
export function fetchPolicyRuns(): Promise<PolicyRunView[]> {
return apiClient.local
.json<PolicyRunView[]>("/api/v1/policies/runs")
.catch(() => [] as PolicyRunView[]);
}
/**
* Pure assembly of the decorated catalogue from the two raw responses. Split
* out so the React Query layer can fetch the list + runs as separate shared
* cache entries (deduped across Home + Policies) and assemble client-side.
*/
export function assemblePolicies(
wirePolicies: WirePolicy[],
runs: PolicyRunView[],
): PoliciesResponse {
const decodedByCategory = new Map<
string,
{ decoded: PolicyDecodedState; isDefault: boolean }
+11 -12
View File
@@ -1,8 +1,7 @@
/** Assembles the home visualiser's sources → policies → outcomes from the real
* sources/policies/runs APIs. Counts are real; the flow motion is illustrative. */
import { apiClient } from "@portal/api/http";
import { fetchSources } from "@portal/api/sources";
import type { SourcesResponse } from "@portal/api/sources";
import { POLICY_CATEGORIES } from "@portal/api/policies";
import { fromWirePolicy } from "@app/policies/codec";
import type { PolicyRunView, WirePolicy } from "@app/policies/types";
@@ -123,16 +122,16 @@ function buildOutcomes(runs: PolicyRunView[]): FlowOutcome[] {
];
}
/** Assemble the full flow model from the three live portal surfaces. */
export async function fetchProcessorFlow(): Promise<ProcessorFlow> {
const [sourcesResp, wirePolicies, runs] = await Promise.all([
fetchSources(),
apiClient.local.json<WirePolicy[]>("/api/v1/policies"),
apiClient.local
.json<PolicyRunView[]>("/api/v1/policies/runs")
.catch(() => [] as PolicyRunView[]),
]);
/**
* Pure assembly of the flow model from the three raw responses. Split out so
* the React Query layer composes it from the shared sources/policies/runs
* cache entries instead of re-fetching them (see useProcessorFlow).
*/
export function assembleProcessorFlow(
sourcesResp: SourcesResponse,
wirePolicies: WirePolicy[],
runs: PolicyRunView[],
): ProcessorFlow {
const sources: FlowSource[] = sourcesResp.sources.map((s) => ({
id: s.id,
name: s.name,
@@ -4,12 +4,8 @@ import { useTranslation } from "react-i18next";
import { Button, Skeleton } from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { useView } from "@portal/contexts/ViewContext";
import { useAsync } from "@portal/hooks/useAsync";
import {
fetchEditorDeployment,
type EditorDeploymentResponse,
type EditorInstance,
} from "@portal/api/editorDeploy";
import { useEditorDeployment } from "@portal/queries/infrastructure";
import { type EditorInstance } from "@portal/api/editorDeploy";
import {
DownloadIcon,
ExternalLinkIcon,
@@ -76,10 +72,7 @@ export function EditorStatusCard({ footer, hideChips }: EditorStatusCardProps) {
const { tier } = useTier();
const { setActiveView } = useView();
const [installOpen, setInstallOpen] = useState(false);
const { data, loading } = useAsync<EditorDeploymentResponse>(
() => fetchEditorDeployment(tier),
[tier],
);
const { data, loading } = useEditorDeployment(tier);
const view = useMemo(() => {
if (!data) return null;
@@ -7,11 +7,8 @@ import {
VIEW_PATHS,
toPortalPath,
} from "@portal/contexts/ViewContext";
import { useAsync } from "@portal/hooks/useAsync";
import {
fetchProcessorFlow,
type ProcessorFlow as ProcessorFlowModel,
} from "@portal/api/processorFlow";
import { useProcessorFlow } from "@portal/queries/processorFlow";
import { type ProcessorFlow as ProcessorFlowModel } from "@portal/api/processorFlow";
import {
DEV_KEEP_FLOWING,
DEV_SYNTH_RATE,
@@ -37,7 +34,7 @@ export function ProcessorFlow({ dataOverride }: ProcessorFlowProps = {}) {
const { t } = useTranslation();
const { setActiveView } = useView();
const navigate = useNavigate();
const fetched = useAsync<ProcessorFlowModel>(() => fetchProcessorFlow(), []);
const fetched = useProcessorFlow();
const data = dataOverride ?? fetched.data;
const loading = dataOverride ? false : fetched.loading;
@@ -3,8 +3,7 @@ import { useTranslation } from "react-i18next";
import { Button, Card, MetricCard, MetricStrip } from "@app/ui";
import GroupsIcon from "@mui/icons-material/GroupsRounded";
import PersonAddIcon from "@mui/icons-material/PersonAddAltRounded";
import { useAsync } from "@portal/hooks/useAsync";
import { fetchFleetStats } from "@portal/api/fleetStats";
import { useFleetStats } from "@portal/queries/infrastructure";
/**
* "Free PDF Editors" team-fleet card. Editors-deployed / active-this-month /
@@ -22,7 +21,7 @@ function fmtMetric(value: number | null | undefined, loading: boolean): string {
export function FreePdfEditorsCard() {
const navigate = useNavigate();
const { t } = useTranslation();
const { data, loading } = useAsync((signal) => fetchFleetStats(signal), []);
const { data, loading } = useFleetStats();
return (
<Card padding="loose">
<div className="portal-billing__fleet-row">
@@ -12,13 +12,12 @@ import {
type TableColumn,
} from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import { useSectionFlags } from "@portal/hooks/useAsync";
import { useAuditLog } from "@portal/queries/infrastructure";
import { HttpError } from "@portal/api/http";
import {
fetchAuditLog,
type AuditCategory,
type AuditEvent,
type AuditLogResponse,
} from "@portal/api/infrastructure";
import { AuditExportModal } from "@portal/components/infrastructure/AuditExportModal";
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
@@ -108,7 +107,7 @@ export function AuditTab() {
},
];
const state = useAsync<AuditLogResponse>(() => fetchAuditLog(tier), [tier]);
const state = useAuditLog(tier);
const { data, error } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
// Backend returns 403 for scoped-out callers; show an access message, not an empty state.
@@ -5,7 +5,7 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { PortalTestProviders } from "@portal/test/TestQueryProvider";
import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard";
import {
POLICY_CATEGORIES,
@@ -17,7 +17,7 @@ import {
} from "@portal/api/policies";
const render = (ui: Parameters<typeof baseRender>[0]) =>
baseRender(ui, { wrapper: MantineProvider });
baseRender(ui, { wrapper: PortalTestProviders });
// Deterministic i18n: return the fallback when given, else the key. initReactI18next is stubbed
// because the import graph pulls core/i18n.ts, which registers it as a plugin.
@@ -31,7 +31,7 @@ import {
type PolicyToolId,
type PolicyToolStep,
} from "@app/policies/operations";
import { fetchSources } from "@portal/api/sources";
import { useSources } from "@portal/queries/sources";
import { fetchIntegrations } from "@portal/api/integrations";
import { errorMessage } from "@portal/api/http";
import { useAsync } from "@portal/hooks/useAsync";
@@ -271,7 +271,7 @@ function PolicySetupWizardBody({
policy?.state.sources ?? ["editor"],
);
const sourcesAsync = useAsync(() => fetchSources(), []);
const sourcesAsync = useSources();
const availableSources = useMemo(() => {
const backendSources = (sourcesAsync.data?.sources ?? []).filter(
(s) => s.status !== "disabled",
@@ -15,7 +15,7 @@ import { seedPolicies, seedPolicyRuns } from "@portal/mocks/policies";
export { POLICY_CATEGORIES, POLICY_CONFIG };
/** A decorated, active policy for a category, mirroring fetchPolicies() assembly. */
/** A decorated, active policy for a category, mirroring assemblePolicies(). */
export function decorateForStory(categoryId: string): DecoratedPolicy {
const category = POLICY_CATEGORIES.find((c) => c.id === categoryId)!;
const config = POLICY_CONFIG[categoryId];
@@ -2,8 +2,9 @@
// setup: the card always shows but can't be enabled until the engine is on.
// `loading` lets callers hold the decision rather than flash a locked card.
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@portal/api/http";
import { useAsync } from "@portal/hooks/useAsync";
import { qk } from "@portal/queries/keys";
interface AppConfigShape {
aiEngineEnabled?: boolean;
@@ -15,12 +16,13 @@ export interface AiEngineState {
}
export function useAiEngineEnabled(): AiEngineState {
const state = useAsync<AppConfigShape>(
() => apiClient.local.json<AppConfigShape>("/api/v1/config/app-config"),
[],
);
const query = useQuery({
queryKey: qk.appConfig(),
queryFn: () =>
apiClient.local.json<AppConfigShape>("/api/v1/config/app-config"),
});
return {
enabled: Boolean(state.data?.aiEngineEnabled),
loading: state.loading && state.data === null,
enabled: Boolean(query.data?.aiEngineEnabled),
loading: query.isPending,
};
}
@@ -1,18 +1,17 @@
import { useTier } from "@portal/contexts/TierContext";
import { useAsync } from "@portal/hooks/useAsync";
import { useEditorInstalled } from "@portal/hooks/useEditorInstalled";
import { fetchEditorDeployment } from "@portal/api/editorDeploy";
import { fetchPolicies } from "@portal/api/policies";
import { fetchUsers } from "@portal/api/users";
import { useEditorDeployment } from "@portal/queries/infrastructure";
import { usePoliciesOverview } from "@portal/queries/policies";
import { useUsersRoster } from "@portal/queries/users";
/**
* Getting-started completion, derived live from the org's real state. Drives
* both the per-step checks on the home hero's setup steps and the collapse to
* the deployed-status header once every step is done.
* Getting-started completion, derived live from the org's real state, composed
* from the shared editor-deployment / policies / users queries.
*
* Each fetch is independently best-effort: an endpoint that isn't served yet
* (e.g. editor-deployment on a bare backend) simply leaves its step incomplete
* rather than breaking the card.
* Best-effort per source: a step reads `data ?? fallback` and query errors are
* never folded into `loading`, so an endpoint that isn't served yet (e.g.
* editor-deployment on a bare backend) leaves its step incomplete instead of
* breaking the card.
*/
export interface OnboardingProgress {
loading: boolean;
@@ -31,17 +30,13 @@ export interface OnboardingProgress {
export function useOnboardingProgress(): OnboardingProgress {
const { tier } = useTier();
const editorInstalled = useEditorInstalled();
const { data, loading } = useAsync(
() =>
Promise.all([
fetchEditorDeployment(tier).catch(() => null),
fetchPolicies().catch(() => null),
fetchUsers(tier).catch(() => null),
]),
[tier],
);
const deployQuery = useEditorDeployment(tier);
const policiesQuery = usePoliciesOverview();
const usersQuery = useUsersRoster(tier);
const [deploy, policies, users] = data ?? [null, null, null];
const deploy = deployQuery.data;
const policies = policiesQuery.data;
const users = usersQuery.data;
const deployed = (deploy?.instances.length ?? 0) > 0;
// Authoritative signal is a deployed instance; the user's own download/Done
@@ -56,7 +51,7 @@ export function useOnboardingProgress(): OnboardingProgress {
const inviteDone = (users?.summary.totalMembers ?? 0) > 1;
return {
loading,
loading: deployQuery.loading || policiesQuery.loading || usersQuery.loading,
deployed,
editorDone,
policiesDone,
@@ -14,7 +14,7 @@ import type { PolicyRunView, WirePolicy } from "@app/policies/types";
* - DELETE /api/v1/policies/:id → 204
*
* The decorated catalogue (summary, category grouping, stats) is assembled
* client-side in api/policies.ts#fetchPolicies(), mirroring the real backend.
* client-side in api/policies.ts#assemblePolicies(), mirroring the real backend.
*/
let store: WirePolicy[] = seedPolicies();
@@ -0,0 +1,16 @@
import type { UseQueryResult } from "@tanstack/react-query";
import type { AsyncState } from "@portal/hooks/useAsync";
/**
* Adapt a query result to the {@link AsyncState} shape the views already use
* (data/loading/error + useSectionFlags), so a hook swaps in with no render
* changes. `isPending` is false once data is cached, so a remount renders from
* cache instead of flashing a skeleton.
*/
export function toAsyncState<T>(query: UseQueryResult<T>): AsyncState<T> {
return {
data: query.data ?? null,
loading: query.isPending,
error: (query.error as Error | null) ?? null,
};
}
@@ -0,0 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { qk } from "@portal/queries/keys";
import { toAsyncState } from "@portal/queries/adapters";
import type { AsyncState } from "@portal/hooks/useAsync";
import { fetchDocuments, type DocumentsResponse } from "@portal/api/documents";
import type { Tier } from "@portal/contexts/TierContext";
/** Base query: the documents review queue (tier-scoped). */
export function useDocuments(tier: Tier): AsyncState<DocumentsResponse> {
return toAsyncState(
useQuery({
queryKey: qk.documents(tier),
queryFn: () => fetchDocuments(tier),
}),
);
}
@@ -0,0 +1,49 @@
import { useQuery } from "@tanstack/react-query";
import { qk } from "@portal/queries/keys";
import { toAsyncState } from "@portal/queries/adapters";
import type { AsyncState } from "@portal/hooks/useAsync";
import { fetchFleetStats, type FleetStats } from "@portal/api/fleetStats";
import {
fetchAuditLog,
type AuditLogResponse,
} from "@portal/api/infrastructure";
import {
fetchEditorDeployment,
type EditorDeploymentResponse,
} from "@portal/api/editorDeploy";
import type { Tier } from "@portal/contexts/TierContext";
/** Base query: fleet processing stats (GET /api/v1/usage/fleet-stats). */
export function useFleetStats(): AsyncState<FleetStats> {
return toAsyncState(
useQuery({
queryKey: qk.fleetStats(),
queryFn: ({ signal }) => fetchFleetStats(signal),
}),
);
}
/** Base query: recent audit-log activity (tier-scoped). */
export function useAuditLog(tier: Tier): AsyncState<AuditLogResponse> {
return toAsyncState(
useQuery({
queryKey: qk.auditLog(tier),
queryFn: () => fetchAuditLog(tier),
}),
);
}
/** Base query: editor deployment health (tier-scoped). Shared by Home's hero /
* status card, EditorAdmin, and onboarding. Best-effort — callers tolerate a
* 404 on a bare backend, so no retry. */
export function useEditorDeployment(
tier: Tier,
): AsyncState<EditorDeploymentResponse> {
return toAsyncState(
useQuery({
queryKey: qk.editorDeployment(tier),
queryFn: () => fetchEditorDeployment(tier),
retry: false,
}),
);
}
@@ -0,0 +1,33 @@
import type { Tier } from "@portal/contexts/TierContext";
/**
* The portal's TanStack Query keys, in one place. Convention:
* ["portal", <resource>, ...params].
*
* Keep keys flavor-agnostic — self-hosted-vs-SaaS routing lives in the api
* functions, not the key, so one key addresses whichever backend the build
* resolves. Include tier only for resources whose response varies by tier.
*/
export const qk = {
// Tier-independent
policiesList: () => ["portal", "policies", "list"] as const,
policyRuns: () => ["portal", "policies", "runs"] as const,
sources: () => ["portal", "sources"] as const,
pipelines: () => ["portal", "pipelines"] as const,
fleetStats: () => ["portal", "fleetStats"] as const,
appConfig: () => ["portal", "appConfig"] as const,
// Tier-dependent
documents: (tier: Tier) => ["portal", "documents", tier] as const,
auditLog: (tier: Tier) => ["portal", "auditLog", tier] as const,
editorDeployment: (tier: Tier) =>
["portal", "editorDeployment", tier] as const,
// Users cluster (consumed by usersData.ts + Home onboarding)
usersRoster: (tier: Tier) => ["portal", "users", "roster", tier] as const,
usersGrants: (tier: Tier) => ["portal", "users", "grants", tier] as const,
usersTeams: (tier: Tier) => ["portal", "users", "teams", tier] as const,
usersAuthConfig: () => ["portal", "users", "authConfig"] as const,
/** SaaS-only shared team directory (/api/v1/team/my) — see the /team/my collapse. */
teamMy: () => ["portal", "team", "my"] as const,
} as const;
@@ -0,0 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { qk } from "@portal/queries/keys";
import { toAsyncState } from "@portal/queries/adapters";
import type { AsyncState } from "@portal/hooks/useAsync";
import {
fetchPipelines,
type PipelinesOverviewResponse,
} from "@portal/api/pipelines";
/** Base query: the pipelines overview (GET /api/v1/policies/overview). */
export function usePipelines(): AsyncState<PipelinesOverviewResponse> {
return toAsyncState(
useQuery({ queryKey: qk.pipelines(), queryFn: fetchPipelines }),
);
}
@@ -0,0 +1,42 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { qk } from "@portal/queries/keys";
import { toAsyncState } from "@portal/queries/adapters";
import type { AsyncState } from "@portal/hooks/useAsync";
import {
assemblePolicies,
fetchPoliciesList,
fetchPolicyRuns,
type PoliciesResponse,
type PolicyRunView,
type WirePolicy,
} from "@portal/api/policies";
/** Base query: the flat stored-policy records (GET /api/v1/policies). */
export function usePoliciesList(): AsyncState<WirePolicy[]> {
return toAsyncState(
useQuery({ queryKey: qk.policiesList(), queryFn: fetchPoliciesList }),
);
}
/** Base query: policy run history (GET /api/v1/policies/runs). */
export function usePolicyRuns(): AsyncState<PolicyRunView[]> {
return toAsyncState(
useQuery({ queryKey: qk.policyRuns(), queryFn: fetchPolicyRuns }),
);
}
/**
* The decorated catalogue Policies and Home both render, composed from the two
* shared base queries so /policies and /policies/runs are fetched once across
* all consumers.
*/
export function usePoliciesOverview(): AsyncState<PoliciesResponse> {
const list = usePoliciesList();
const runs = usePolicyRuns();
const data = useMemo(
() => (list.data ? assemblePolicies(list.data, runs.data ?? []) : null),
[list.data, runs.data],
);
return { data, loading: list.loading, error: list.error };
}
@@ -0,0 +1,30 @@
import { useMemo } from "react";
import { usePoliciesList, usePolicyRuns } from "@portal/queries/policies";
import { useSources } from "@portal/queries/sources";
import type { AsyncState } from "@portal/hooks/useAsync";
import {
assembleProcessorFlow,
type ProcessorFlow,
} from "@portal/api/processorFlow";
/**
* Home's sources → policies → outcomes visualiser, composed from the shared
* base queries so it reuses Home's cache entries instead of refetching them.
*/
export function useProcessorFlow(): AsyncState<ProcessorFlow> {
const sources = useSources();
const list = usePoliciesList();
const runs = usePolicyRuns();
const data = useMemo(
() =>
sources.data && list.data
? assembleProcessorFlow(sources.data, list.data, runs.data ?? [])
: null,
[sources.data, list.data, runs.data],
);
return {
data,
loading: sources.loading || list.loading,
error: sources.error ?? list.error,
};
}
@@ -0,0 +1,109 @@
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { render, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
import { createPortalQueryClient } from "@portal/queryClient";
import { usePoliciesOverview } from "@portal/queries/policies";
import { useProcessorFlow } from "@portal/queries/processorFlow";
/**
* Proves the two sharing properties the migration is for, at the hook level:
* - in-view: several consumers on one screen (Home renders the policies
* overview AND the processor flow, which both need /policies + /runs)
* trigger ONE fetch of each endpoint, not one per consumer.
* - cross-view: navigating to another screen that needs the same data
* (unmount + remount within staleTime) serves it from cache — no refetch.
*/
// Keep apiClient.local's transport hermetic (no real token / Supabase at import).
vi.mock("@app/auth", () => ({
getStoredToken: () => null,
clearStoredToken: vi.fn(),
}));
vi.mock("@app/auth/supabase/supabaseClient", () => ({
getSupabaseClient: () => null,
configureSupabase: vi.fn(),
}));
const counts: Record<string, number> = {};
const server = setupServer(
http.get("*/api/v1/policies", () => {
counts["/policies"] = (counts["/policies"] ?? 0) + 1;
return HttpResponse.json([]);
}),
http.get("*/api/v1/policies/runs", () => {
counts["/policies/runs"] = (counts["/policies/runs"] ?? 0) + 1;
return HttpResponse.json([]);
}),
http.get("*/api/v1/sources", () => {
counts["/sources"] = (counts["/sources"] ?? 0) + 1;
return HttpResponse.json({ sources: [] });
}),
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
beforeEach(() => {
for (const k of Object.keys(counts)) delete counts[k];
});
// Two Home consumers of the same base queries, rendered together.
function HomeConsumers() {
usePoliciesOverview(); // e.g. onboarding progress
useProcessorFlow(); // the processor visualiser (also needs /sources)
return null;
}
// A Policies-view-style consumer of the same policies caches.
function PoliciesConsumer() {
usePoliciesOverview();
return null;
}
describe("portal query sharing", () => {
it("in-view: multiple consumers of the same endpoints fetch each once", async () => {
const client = createPortalQueryClient();
render(
<QueryClientProvider client={client}>
<HomeConsumers />
</QueryClientProvider>,
);
await waitFor(() => expect(counts["/policies"]).toBe(1));
// Two consumers both needed /policies + /runs; /sources came from the flow.
expect(counts["/policies"]).toBe(1);
expect(counts["/policies/runs"]).toBe(1);
expect(counts["/sources"]).toBe(1);
});
it("cross-view: a later screen reusing the data refetches nothing", async () => {
const client = createPortalQueryClient();
const home = render(
<QueryClientProvider client={client}>
<HomeConsumers />
</QueryClientProvider>,
);
await waitFor(() => expect(counts["/policies"]).toBe(1));
home.unmount();
// Navigate to "Policies" (same client, within staleTime) — cache hit.
render(
<QueryClientProvider client={client}>
<PoliciesConsumer />
</QueryClientProvider>,
);
// Give any (unwanted) refetch a chance to fire, then assert it didn't.
await new Promise((r) => setTimeout(r, 50));
expect(counts["/policies"]).toBe(1);
expect(counts["/policies/runs"]).toBe(1);
});
});
@@ -0,0 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { qk } from "@portal/queries/keys";
import { toAsyncState } from "@portal/queries/adapters";
import type { AsyncState } from "@portal/hooks/useAsync";
import { fetchSources, type SourcesResponse } from "@portal/api/sources";
/** Base query: configured sources (GET /api/v1/sources). Shared by Sources,
* Home's ProcessorFlow, the pipeline/policy builders' source pickers. */
export function useSources(): AsyncState<SourcesResponse> {
return toAsyncState(
useQuery({ queryKey: qk.sources(), queryFn: fetchSources }),
);
}
@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import { qk } from "@portal/queries/keys";
import { toAsyncState } from "@portal/queries/adapters";
import type { AsyncState } from "@portal/hooks/useAsync";
import { usersBackend } from "@app/portal/usersBackend";
import type { UsersResponse } from "@portal/api/users";
import type { Tier } from "@portal/contexts/TierContext";
/**
* Base query: the org roster (flavor-resolved via usersBackend). Keyed the same
* as the Users view's roster query (qk.usersRoster), so Home's onboarding read
* and the Users page share one cache entry — the roster is fetched once across
* both. Used by useOnboardingProgress.
*/
export function useUsersRoster(tier: Tier): AsyncState<UsersResponse> {
return toAsyncState(
useQuery({
queryKey: qk.usersRoster(tier),
queryFn: () => usersBackend.fetchUsers(tier),
}),
);
}
+35
View File
@@ -0,0 +1,35 @@
import { QueryClient } from "@tanstack/react-query";
/**
* The portal's TanStack Query client, mounted once at the portal root
* (PortalApp) so its cache lives above the router — data survives navigating
* away and back. staleTime 30s: a return visit within 30s serves cache with no
* network call, then revalidates in the background. Focus refetch is off — admin
* screens don't need polling.
*/
let current: QueryClient | null = null;
export function createPortalQueryClient(): QueryClient {
current = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 5 * 60_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
return current;
}
/**
* The client created by {@link createPortalQueryClient}, or null if none has
* been mounted yet. Lets a non-hook module (the SaaS usersBackend's resolveTeam)
* read/populate the shared cache via ensureQueryData when the portal is mounted,
* while still working — via a direct fetch — when it isn't (e.g. a unit test
* that exercises the adapter without the provider).
*/
export function tryGetPortalQueryClient(): QueryClient | null {
return current;
}
@@ -0,0 +1,29 @@
import { useState, type ReactNode } from "react";
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
/**
* Wraps a portal component under test in a fresh QueryClient (retries off for
* deterministic tests). Needed by any test that renders a component using the
* shared query hooks (portal/queries/*). Mirror of the QueryClientProvider the
* app mounts at PortalApp.
*/
export function TestQueryProvider({ children }: { children: ReactNode }) {
const [client] = useState(
() => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
/**
* Combined provider for portal component tests: QueryClient + Mantine. Drop-in
* replacement for a bare `MantineProvider` test wrapper once a component (or a
* child) uses the shared query hooks.
*/
export function PortalTestProviders({ children }: { children: ReactNode }) {
return (
<TestQueryProvider>
<MantineProvider>{children}</MantineProvider>
</TestQueryProvider>
);
}
@@ -2,11 +2,9 @@ import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync } from "@portal/hooks/useAsync";
import { useDocuments } from "@portal/queries/documents";
import {
fetchDocuments,
DOCUMENT_STATUS_LABEL,
type DocumentsResponse,
type ReviewDocument,
} from "@portal/api/documents";
import { ReviewQueue } from "@portal/components/documents/ReviewQueue";
@@ -66,7 +64,7 @@ function toCsv(docs: ReviewDocument[], t: TFunction): string {
export function Documents() {
const { t } = useTranslation();
const { tier } = useTier();
const state = useAsync<DocumentsResponse>(() => fetchDocuments(tier), [tier]);
const state = useDocuments(tier);
const documents = state.data?.documents ?? [];
function exportCsv() {
@@ -2,11 +2,8 @@ import { useTranslation } from "react-i18next";
import { Skeleton } from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { useView } from "@portal/contexts/ViewContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import {
fetchEditorDeployment,
type EditorDeploymentResponse,
} from "@portal/api/editorDeploy";
import { useSectionFlags } from "@portal/hooks/useAsync";
import { useEditorDeployment } from "@portal/queries/infrastructure";
import { DeploymentSummaryStrip } from "@portal/components/editor-admin/DeploymentSummaryStrip";
import { DeploymentTargets } from "@portal/components/editor-admin/DeploymentTargets";
import { PairingPanel } from "@portal/components/editor-admin/PairingPanel";
@@ -35,10 +32,7 @@ export function EditorAdmin() {
const { t } = useTranslation();
const { tier } = useTier();
const { setActiveView } = useView();
const state = useAsync<EditorDeploymentResponse>(
() => fetchEditorDeployment(tier),
[tier],
);
const state = useEditorDeployment(tier);
const { data } = state;
const { isLoading } = useSectionFlags(state);
@@ -5,7 +5,7 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { PortalTestProviders } from "@portal/test/TestQueryProvider";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import type { Policy, TriggerOutcome } from "@portal/api/pipelines";
import type { ToolRegistryCatalog } from "@app/contexts/ToolRegistryContext";
@@ -15,7 +15,7 @@ import { PipelineBuilder } from "@portal/views/PipelineBuilder";
const render = (
ui: Parameters<typeof baseRender>[0],
options?: Parameters<typeof baseRender>[1],
) => baseRender(ui, { wrapper: MantineProvider, ...options });
) => baseRender(ui, { wrapper: PortalTestProviders, ...options });
// Deterministic i18n: keys returned verbatim.
vi.mock("react-i18next", () => ({
@@ -51,9 +51,12 @@ import {
import { clearProcessedHistory } from "@portal/api/policies";
import { availableOutputModes } from "@portal/components/pipelines/outputModes";
import { S3ConnectionPicker } from "@portal/components/sources/S3ConnectionPicker";
import { fetchSources, type SourceView } from "@portal/api/sources";
import { type SourceView } from "@portal/api/sources";
import { useSources } from "@portal/queries/sources";
import { EDITOR_SOURCE_TYPE } from "@portal/components/sources/sourceTypes";
import { useAsync } from "@portal/hooks/useAsync";
import { useQueryClient } from "@tanstack/react-query";
import { qk } from "@portal/queries/keys";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
import { humanizeOperation } from "@portal/components/pipelines/pipelineOperations";
import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepSettings";
@@ -154,6 +157,16 @@ function parseOutput(output: OutputSpec | undefined): {
export function PipelineBuilder() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
// Pipelines are stored as policies, so a save/delete must invalidate both the
// pipelines overview and the policies caches (Policies page + Home) before
// navigating back to the list.
const invalidatePipelines = () =>
Promise.all([
queryClient.invalidateQueries({ queryKey: qk.pipelines() }),
queryClient.invalidateQueries({ queryKey: qk.policiesList() }),
queryClient.invalidateQueries({ queryKey: qk.policyRuns() }),
]);
const { id } = useParams();
const isEdit = Boolean(id);
const { allTools } = useToolRegistry();
@@ -166,20 +179,20 @@ export function PipelineBuilder() {
async () => (id ? await fetchPipeline(id) : null),
[id],
);
const sourcesState = useAsync<SourceView[]>(
// The editor is a built-in, client-driven source (it runs on editor upload, not as a pipeline
// input), so it's excluded from the sources a pipeline can pull from.
async () =>
(await fetchSources()).sources.filter(
(source) => source.type !== EDITOR_SOURCE_TYPE,
),
[],
);
const sourcesState = useSources();
const triggersState = useAsync<TriggerInfo[]>(
async () => await fetchTriggers(),
[],
);
const availableSources = sourcesState.data ?? [];
// The editor is a built-in, client-driven source (it runs on editor upload,
// not as a pipeline input), so it's excluded from a pipeline's inputs.
const availableSources = useMemo<SourceView[]>(
() =>
(sourcesState.data?.sources ?? []).filter(
(source) => source.type !== EDITOR_SOURCE_TYPE,
),
[sourcesState.data],
);
const triggers = useMemo(
() => triggersState.data ?? [],
[triggersState.data],
@@ -450,6 +463,7 @@ export function PipelineBuilder() {
};
try {
await savePipeline(policy);
await invalidatePipelines();
navigate(destination);
} catch (e) {
setError(errorMessage(e));
@@ -560,6 +574,7 @@ export function PipelineBuilder() {
setDeleting(true);
try {
await deletePipeline(id);
await invalidatePipelines();
close();
} catch (e) {
setError(errorMessage(e));
@@ -4,7 +4,7 @@ import {
render as baseRender,
screen,
} from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { PortalTestProviders } from "@portal/test/TestQueryProvider";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import type { PipelinesOverviewResponse } from "@portal/api/pipelines";
import { Pipelines } from "@portal/views/Pipelines";
@@ -12,7 +12,7 @@ import { Pipelines } from "@portal/views/Pipelines";
const render = (
ui: Parameters<typeof baseRender>[0],
options?: Parameters<typeof baseRender>[1],
) => baseRender(ui, { wrapper: MantineProvider, ...options });
) => baseRender(ui, { wrapper: PortalTestProviders, ...options });
// Deterministic i18n: keys returned verbatim.
vi.mock("react-i18next", () => ({
@@ -2,12 +2,9 @@ import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import AddRoundedIcon from "@mui/icons-material/AddRounded";
import { Button, EmptyState, Skeleton } from "@app/ui";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import {
fetchPipelines,
type PipelinesOverviewResponse,
type PipelineView,
} from "@portal/api/pipelines";
import { useSectionFlags } from "@portal/hooks/useAsync";
import { usePipelines } from "@portal/queries/pipelines";
import { type PipelineView } from "@portal/api/pipelines";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
import { PipelinesIcon } from "@portal/components/icons";
import { KpiStrip } from "@portal/components/pipelines/KpiStrip";
@@ -17,7 +14,7 @@ import "@portal/views/Pipelines.css";
export function Pipelines() {
const { t } = useTranslation();
const navigate = useNavigate();
const state = useAsync<PipelinesOverviewResponse>(() => fetchPipelines(), []);
const state = usePipelines();
const { data, loading } = state;
const { isLoading } = useSectionFlags(state);
+12 -6
View File
@@ -1,22 +1,23 @@
import { useCallback, useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Banner, Button, Skeleton } from "@app/ui";
import { errorMessage } from "@portal/api/http";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import { useSectionFlags } from "@portal/hooks/useAsync";
import {
buildWireFromSetup,
buildWireFromState,
clearProcessedHistory,
deletePolicy,
fetchPolicies,
savePolicy,
POLICY_CATEGORIES,
POLICY_CONFIG,
type CatalogueEntry,
type PoliciesResponse,
type PolicySetupResult,
} from "@portal/api/policies";
import { usePoliciesOverview } from "@portal/queries/policies";
import { qk } from "@portal/queries/keys";
import { CatalogueSummary } from "@portal/components/policies/CatalogueSummary";
import { PolicyCatalogueTable } from "@portal/components/policies/PolicyCatalogueTable";
import { PolicyDetailPanel } from "@portal/components/policies/PolicyDetailPanel";
@@ -26,8 +27,8 @@ import "@portal/views/Policies.css";
export function Policies() {
const { t } = useTranslation();
const [version, setVersion] = useState(0);
const state = useAsync<PoliciesResponse>(() => fetchPolicies(), [version]);
const queryClient = useQueryClient();
const state = usePoliciesOverview();
const { data, loading, error: fetchError } = state;
const { isLoading } = useSectionFlags(state);
@@ -63,7 +64,12 @@ export function Policies() {
);
const catalogue = data?.catalogue ?? [];
const refetch = useCallback(() => setVersion((v) => v + 1), []);
// Invalidate the shared policies caches; because ProcessorFlow and onboarding
// read the SAME entries, this also live-refreshes Home.
const refetch = useCallback(() => {
queryClient.invalidateQueries({ queryKey: qk.policiesList() });
queryClient.invalidateQueries({ queryKey: qk.policyRuns() });
}, [queryClient]);
// The catalogue cards are always shown (they're the "configure a policy" CTAs),
// but the summary strip is pure stat boxes: hide it until at least one policy
// is configured so a fresh workspace doesn't show a row of zeros.
@@ -5,17 +5,18 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { PortalTestProviders } from "@portal/test/TestQueryProvider";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import type { ReactNode } from "react";
import { SourceBuilder } from "@portal/views/SourceBuilder";
import { UIProvider } from "@portal/contexts/UIContext";
// SourceBuilder reads useUI() to open settings, so wrap in its provider.
// SourceBuilder reads useUI() (open settings) and useQueryClient (list
// invalidation), so provide the query client + Mantine + the UI context.
const Providers = ({ children }: { children: ReactNode }) => (
<MantineProvider>
<PortalTestProviders>
<UIProvider>{children}</UIProvider>
</MantineProvider>
</PortalTestProviders>
);
const render = (ui: Parameters<typeof baseRender>[0]) =>
@@ -23,6 +23,8 @@ import {
} from "@portal/api/sources";
import { useUI } from "@portal/contexts/UIContext";
import { useAsync } from "@portal/hooks/useAsync";
import { useQueryClient } from "@tanstack/react-query";
import { qk } from "@portal/queries/keys";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
import { creatableSourceTypes } from "@portal/components/sources/creatableSourceTypes";
import {
@@ -71,11 +73,17 @@ function optionsFor(
export function SourceBuilder() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { openSettings } = useUI();
const { id } = useParams();
const isEdit = Boolean(id);
const listPath = toPortalPath(VIEW_PATHS.sources);
// The list is a shared cache entry (Sources view + Home's ProcessorFlow), so
// a create/delete here must invalidate it before we navigate back to it.
const invalidateSources = () =>
queryClient.invalidateQueries({ queryKey: qk.sources() });
const sourceState = useAsync<Source | null>(
async () => (id ? await fetchSource(id) : null),
[id],
@@ -152,6 +160,7 @@ export function SourceBuilder() {
options,
enabled,
});
await invalidateSources();
if (!isEdit && type.type === WEBHOOK_SOURCE_TYPE) {
const webhookId = String(saved.options?.webhookId ?? "");
const secret = String(saved.options?.signingSecret ?? "");
@@ -177,6 +186,7 @@ export function SourceBuilder() {
setDeleting(true);
try {
await deleteSource(id);
await invalidateSources();
navigate(listPath);
} catch (e) {
setError(errorMessage(e));
@@ -4,13 +4,13 @@ import {
render as baseRender,
screen,
} from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { PortalTestProviders } from "@portal/test/TestQueryProvider";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import type { SourcesResponse } from "@portal/api/sources";
import { Sources } from "@portal/views/Sources";
const render = (ui: Parameters<typeof baseRender>[0]) =>
baseRender(ui, { wrapper: MantineProvider });
baseRender(ui, { wrapper: PortalTestProviders });
// Deterministic i18n: keys returned verbatim.
vi.mock("react-i18next", () => ({
+4 -7
View File
@@ -2,13 +2,10 @@ import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import AddRoundedIcon from "@mui/icons-material/AddRounded";
import { Button, EmptyState, Skeleton, Tabs } from "@app/ui";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import { useSectionFlags } from "@portal/hooks/useAsync";
import { useSources } from "@portal/queries/sources";
import { SourcesIcon } from "@portal/components/icons";
import {
fetchSources,
type SourcesResponse,
type SourceView,
} from "@portal/api/sources";
import { type SourceView } from "@portal/api/sources";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
import { KpiStrip } from "@portal/components/sources/KpiStrip";
import { SourcesTable } from "@portal/components/sources/SourcesTable";
@@ -24,7 +21,7 @@ export function Sources() {
const activeTab: SourcesTab =
searchParams.get("tab") === "connections" ? "connections" : "sources";
const state = useAsync<SourcesResponse>(() => fetchSources(), []);
const state = useSources();
const { data, loading } = state;
const { isLoading } = useSectionFlags(state);
@@ -0,0 +1,126 @@
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { render, screen, type RenderResult } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { setupServer } from "msw/node";
import {
teamSaasHandlers,
resetTeamSaasStore,
} from "@portal/mocks/handlers/teamSaas";
import { createPortalQueryClient } from "@portal/queryClient";
import { qk } from "@portal/queries/keys";
/**
* The migration's payoff, as assertions: revisiting the Users view serves the
* roster from cache with no refetch, and the SaaS roster + teams queries share
* one /team/my resolve. Same SaaS mocks as Users.saas.test.tsx.
*/
vi.mock("@app/auth", () => ({
getStoredToken: () => null,
clearStoredToken: vi.fn(),
}));
vi.mock("@app/auth/supabase/supabaseClient", () => ({
getSupabaseClient: () => null,
configureSupabase: vi.fn(),
}));
vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() }));
vi.mock("@app/portal/usersCapabilities", async () => ({
usersCapabilities: (await import("../../saas/portal/usersCapabilities"))
.usersCapabilities,
}));
vi.mock("@app/portal/usersBackend", async () => ({
usersBackend: (await import("../../saas/portal/usersBackend")).usersBackend,
}));
vi.mock("@portal/contexts/TierContext", () => ({
useTier: () => ({ tier: "pro" }),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, fallback?: string, opts?: Record<string, unknown>) => {
const base = fallback ?? key;
return opts
? base.replace(/\{\{(\w+)\}\}/g, (_, k) => String(opts[k] ?? ""))
: base;
},
i18n: { changeLanguage: vi.fn() },
}),
}));
import { Users } from "@portal/views/Users";
const server = setupServer(...teamSaasHandlers);
let rosterFetches = 0;
let teamMyFetches = 0;
server.events.on("request:start", ({ request }) => {
const { pathname } = new URL(request.url);
if (pathname.endsWith("/members")) rosterFetches += 1;
if (pathname.endsWith("/team/my")) teamMyFetches += 1;
});
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => {
server.events.removeAllListeners();
server.close();
});
beforeEach(() => {
resetTeamSaasStore();
rosterFetches = 0;
teamMyFetches = 0;
});
function renderUsers(client: QueryClient): RenderResult {
return render(
<MantineProvider>
<QueryClientProvider client={client}>
<MemoryRouter>
<Users />
</MemoryRouter>
</QueryClientProvider>
</MantineProvider>,
);
}
describe("Users view caching", () => {
it("serves the roster from cache on remount (no refetch)", async () => {
// One client across both mounts — the real app keeps it at the portal root,
// above the router, for exactly this reason.
const client = createPortalQueryClient();
const first = renderUsers(client);
expect(await screen.findByText("leader@acme.com")).toBeInTheDocument();
expect(rosterFetches).toBe(1);
first.unmount();
// Remount (navigate away + back) within staleTime → straight from cache.
renderUsers(client);
expect(await screen.findByText("leader@acme.com")).toBeInTheDocument();
expect(rosterFetches).toBe(1);
});
it("collapses the SaaS /team/my call to one per mount", async () => {
const client = createPortalQueryClient();
renderUsers(client);
await screen.findByText("leader@acme.com");
// Roster + teams both resolve the team but share the cached qk.teamMy()
// entry, so /team/my is hit once, not twice.
expect(teamMyFetches).toBe(1);
expect(client.getQueryData(qk.teamMy())).toBeDefined();
});
});
@@ -15,7 +15,7 @@ import {
waitFor,
within,
} from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { PortalTestProviders } from "@portal/test/TestQueryProvider";
import { MemoryRouter } from "react-router-dom";
import { setupServer } from "msw/node";
import {
@@ -78,11 +78,11 @@ beforeEach(() => resetTeamSaasStore());
function renderUsers() {
return render(
<MantineProvider>
<PortalTestProviders>
<MemoryRouter>
<Users />
</MemoryRouter>
</MantineProvider>,
</PortalTestProviders>,
);
}
+9 -28
View File
@@ -2,28 +2,23 @@ import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Button, EmptyState, Skeleton } from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync } from "@portal/hooks/useAsync";
import {
changeMemberRole,
disableMemberMfa,
setMemberSuspended,
unlockMember,
type AdminAuthConfig,
type Member,
type PendingInvitation,
type PortalAccessState,
type RoleId,
type UsersResponse,
} from "@portal/api/users";
import { usersBackend } from "@app/portal/usersBackend";
import {
createGrant,
fetchGrants,
revokeGrant,
type ResourceGrant,
} from "@portal/api/access";
import { deleteTeam as apiDeleteTeam, type Team } from "@portal/api/teams";
import { deleteTeam as apiDeleteTeam } from "@portal/api/teams";
import { errorMessage } from "@portal/api/http";
import { usersCapabilities as caps } from "@app/portal/usersCapabilities";
import { UsersDirectory } from "@portal/components/users/UsersDirectory";
@@ -35,6 +30,7 @@ import { MoveToTeamModal } from "@portal/components/users/MoveToTeamModal";
import { RenameTeamModal } from "@portal/components/users/RenameTeamModal";
import { ConfirmModal } from "@portal/components/users/ConfirmModal";
import type { TeamGroup } from "@portal/components/users/directory";
import { useUsersData } from "@portal/views/usersData";
interface Confirm {
title: string;
@@ -44,28 +40,14 @@ interface Confirm {
action: () => Promise<unknown>;
}
/**
* Users page: the org roster, teams, and portal-access management. Mutation
* handlers call `refresh` to invalidate the shared caches (see useUsersData).
*/
export function Users() {
const { t } = useTranslation();
const { tier } = useTier();
const [refreshKey, setRefreshKey] = useState(0);
const usersState = useAsync<UsersResponse>(
() => usersBackend.fetchUsers(tier),
[tier, refreshKey],
);
// Grants are ADMIN-only; skip the fetch entirely on flavors that can't manage them.
const grantsState = useAsync<ResourceGrant[]>(
() => (caps.manageGrants ? fetchGrants("PORTAL") : Promise.resolve([])),
[tier, refreshKey],
);
const teamsState = useAsync<Team[]>(
() => usersBackend.fetchTeams(),
[tier, refreshKey],
);
const authState = useAsync<AdminAuthConfig>(
() => usersBackend.fetchAuthConfig(),
[],
);
const { usersState, grantsState, teamsState, authState, refresh } =
useUsersData();
const [actionError, setActionError] = useState<string | null>(null);
const [inviteOpen, setInviteOpen] = useState(false);
@@ -151,9 +133,8 @@ export function Users() {
.catch((error) => setActionError(errorMessage(error)))
// Refetch on success AND failure: a multi-step mutation (e.g. changeMemberRole)
// has no rollback, so a mid-sequence failure must resync the roster to real state.
.finally(() => setRefreshKey((k) => k + 1));
.finally(() => refresh());
}
const refresh = () => setRefreshKey((k) => k + 1);
function changeRole(member: Member, role: RoleId) {
run(() => changeMemberRole(member, role));
@@ -0,0 +1,87 @@
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
import { createPortalQueryClient } from "@portal/queryClient";
import { qk } from "@portal/queries/keys";
import { usersBackend } from "@app/portal/usersBackend";
/**
* The SaaS team resolution (/team/my) is read through the shared query cache so
* fetchUsers + fetchTeams dedupe to one request per mount. Regression guard for
* the bug where a rename/remove "did nothing": the cache must also honour
* invalidation, so a mutation's refresh() forces a re-resolve instead of
* serving the stale team. (Uses fetchQuery, not ensureQueryData — the latter
* returns cached data even when invalidated.)
*/
// Keep apiClient.local's transport hermetic (no real token at import).
vi.mock("@app/auth", () => ({
getStoredToken: () => null,
clearStoredToken: vi.fn(),
}));
vi.mock("@app/auth/supabase/supabaseClient", () => ({
getSupabaseClient: () => null,
configureSupabase: vi.fn(),
}));
// The portal test project's @app points at proprietary; resolve the flavor seam
// to the real SaaS backend (same approach as Users.saas.test).
vi.mock("@app/portal/usersBackend", async () => ({
usersBackend: (await import("../../saas/portal/usersBackend")).usersBackend,
}));
let teamMyFetches = 0;
let teamName = "Old name";
const server = setupServer(
http.get("*/api/v1/team/my", () => {
teamMyFetches += 1;
return HttpResponse.json([
{
teamId: 1,
name: teamName,
teamType: "STANDARD",
isPersonal: false,
memberCount: 2,
seatCount: 5,
seatsUsed: 2,
maxSeats: 5,
isLeader: true,
},
]);
}),
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
beforeEach(() => {
teamMyFetches = 0;
teamName = "Old name";
});
describe("SaaS /team/my resolution cache", () => {
it("dedupes within staleTime but re-resolves after invalidation", async () => {
const client = createPortalQueryClient();
// Two resolves within staleTime → one network call (the collapse).
expect((await usersBackend.fetchTeams())[0]?.name).toBe("Old name");
expect((await usersBackend.fetchTeams())[0]?.name).toBe("Old name");
expect(teamMyFetches).toBe(1);
// Server-side rename + what a Users mutation's refresh() does.
teamName = "New name";
await client.invalidateQueries({ queryKey: qk.teamMy() });
// Must refetch, not serve the stale cached team (the reported bug).
expect((await usersBackend.fetchTeams())[0]?.name).toBe("New name");
expect(teamMyFetches).toBe(2);
});
});
@@ -0,0 +1,63 @@
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTier } from "@portal/contexts/TierContext";
import type { AsyncState } from "@portal/hooks/useAsync";
import { toAsyncState } from "@portal/queries/adapters";
import { qk } from "@portal/queries/keys";
import { usersBackend } from "@app/portal/usersBackend";
import { fetchGrants, type ResourceGrant } from "@portal/api/access";
import { usersCapabilities as caps } from "@app/portal/usersCapabilities";
import type { AdminAuthConfig, UsersResponse } from "@portal/api/users";
import type { Team } from "@portal/api/teams";
/** The four resources the Users page renders, plus a post-mutation refresh. */
export interface UsersData {
usersState: AsyncState<UsersResponse>;
grantsState: AsyncState<ResourceGrant[]>;
teamsState: AsyncState<Team[]>;
authState: AsyncState<AdminAuthConfig>;
refresh: () => void;
}
// Grants are ADMIN-only; resolve empty on flavors that can't manage them.
const fetchGrantsOrEmpty = (): Promise<ResourceGrant[]> =>
caps.manageGrants ? fetchGrants("PORTAL") : Promise.resolve([]);
export function useUsersData(): UsersData {
const { tier } = useTier();
const queryClient = useQueryClient();
const usersQuery = useQuery({
queryKey: qk.usersRoster(tier),
queryFn: () => usersBackend.fetchUsers(tier),
});
const grantsQuery = useQuery({
queryKey: qk.usersGrants(tier),
queryFn: fetchGrantsOrEmpty,
});
const teamsQuery = useQuery({
queryKey: qk.usersTeams(tier),
queryFn: () => usersBackend.fetchTeams(),
});
const authQuery = useQuery({
queryKey: qk.usersAuthConfig(),
queryFn: () => usersBackend.fetchAuthConfig(),
});
// Auth config is deliberately not invalidated (it never changes via these
// mutations); teamMy is the shared SaaS entry roster + teams both derive from.
const refresh = useCallback(() => {
queryClient.invalidateQueries({ queryKey: qk.usersRoster(tier) });
queryClient.invalidateQueries({ queryKey: qk.usersGrants(tier) });
queryClient.invalidateQueries({ queryKey: qk.usersTeams(tier) });
queryClient.invalidateQueries({ queryKey: qk.teamMy() });
}, [queryClient, tier]);
return {
usersState: toAsyncState(usersQuery),
grantsState: toAsyncState(grantsQuery),
teamsState: toAsyncState(teamsQuery),
authState: toAsyncState(authQuery),
refresh,
};
}
@@ -1,5 +1,7 @@
import type { UsersBackend } from "@portal/api/usersBackend";
import { apiClient } from "@portal/api/http";
import { tryGetPortalQueryClient } from "@portal/queryClient";
import { qk } from "@portal/queries/keys";
import {
ROLES,
type AdminAuthConfig,
@@ -76,7 +78,18 @@ function isExpired(iso: string | undefined): boolean {
* no teams at all.
*/
async function resolveTeam(): Promise<TeamDetailsDTO | null> {
const teams = await apiClient.local.json<TeamDetailsDTO[]>("/api/v1/team/my");
const fetchMy = () =>
apiClient.local.json<TeamDetailsDTO[]>("/api/v1/team/my");
// fetchUsers and fetchTeams both resolve the team; share one /team/my via the
// query cache. fetchQuery (not ensureQueryData) so the shared entry honours
// both staleTime — two callers in one mount dedupe to a single request — AND
// invalidation: refresh() invalidates qk.teamMy(), so the next resolve after a
// rename/remove refetches instead of returning the stale team. Falls back to a
// direct fetch when no portal client is mounted (e.g. a unit test).
const client = tryGetPortalQueryClient();
const teams = client
? await client.fetchQuery({ queryKey: qk.teamMy(), queryFn: fetchMy })
: await fetchMy();
if (!teams || teams.length === 0) return null;
return (
teams.find((t) => t.isLeader && !t.isPersonal) ??
+27
View File
@@ -50,6 +50,7 @@
"@stripe/stripe-js": "^7.9.0",
"@supabase/supabase-js": "^2.47.13",
"@tailwindcss/postcss": "^4.1.13",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-virtual": "^3.13.12",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "2.7.0",
@@ -4525,6 +4526,32 @@
"tailwindcss": "4.2.2"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.101.4",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz",
"integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.101.4",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz",
"integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.101.4"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.23",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.23.tgz",
+1
View File
@@ -47,6 +47,7 @@
"@stripe/stripe-js": "^7.9.0",
"@supabase/supabase-js": "^2.47.13",
"@tailwindcss/postcss": "^4.1.13",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-virtual": "^3.13.12",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "2.7.0",