diff --git a/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx b/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx
index b27d57995e..a415abf107 100644
--- a/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx
+++ b/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx
@@ -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
(() => 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.
diff --git a/frontend/editor/src/portal/components/policies/PolicySetupWizard.test.tsx b/frontend/editor/src/portal/components/policies/PolicySetupWizard.test.tsx
index 503811eeda..da253f9fcc 100644
--- a/frontend/editor/src/portal/components/policies/PolicySetupWizard.test.tsx
+++ b/frontend/editor/src/portal/components/policies/PolicySetupWizard.test.tsx
@@ -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[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.
diff --git a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx
index e56065ce48..39ef8246f9 100644
--- a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx
+++ b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx
@@ -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",
diff --git a/frontend/editor/src/portal/components/policies/storyFixtures.ts b/frontend/editor/src/portal/components/policies/storyFixtures.ts
index 3cb061b9a7..0e92ade641 100644
--- a/frontend/editor/src/portal/components/policies/storyFixtures.ts
+++ b/frontend/editor/src/portal/components/policies/storyFixtures.ts
@@ -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];
diff --git a/frontend/editor/src/portal/hooks/useAiEngineEnabled.ts b/frontend/editor/src/portal/hooks/useAiEngineEnabled.ts
index 9fe76a19fa..ea6d14a0c1 100644
--- a/frontend/editor/src/portal/hooks/useAiEngineEnabled.ts
+++ b/frontend/editor/src/portal/hooks/useAiEngineEnabled.ts
@@ -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(
- () => apiClient.local.json("/api/v1/config/app-config"),
- [],
- );
+ const query = useQuery({
+ queryKey: qk.appConfig(),
+ queryFn: () =>
+ apiClient.local.json("/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,
};
}
diff --git a/frontend/editor/src/portal/hooks/useOnboardingProgress.ts b/frontend/editor/src/portal/hooks/useOnboardingProgress.ts
index c46fa18fa0..0aacd87be7 100644
--- a/frontend/editor/src/portal/hooks/useOnboardingProgress.ts
+++ b/frontend/editor/src/portal/hooks/useOnboardingProgress.ts
@@ -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,
diff --git a/frontend/editor/src/portal/mocks/handlers/policies.ts b/frontend/editor/src/portal/mocks/handlers/policies.ts
index 16bc0933e6..1bfc435d4c 100644
--- a/frontend/editor/src/portal/mocks/handlers/policies.ts
+++ b/frontend/editor/src/portal/mocks/handlers/policies.ts
@@ -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();
diff --git a/frontend/editor/src/portal/queries/adapters.ts b/frontend/editor/src/portal/queries/adapters.ts
new file mode 100644
index 0000000000..083e396bad
--- /dev/null
+++ b/frontend/editor/src/portal/queries/adapters.ts
@@ -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(query: UseQueryResult): AsyncState {
+ return {
+ data: query.data ?? null,
+ loading: query.isPending,
+ error: (query.error as Error | null) ?? null,
+ };
+}
diff --git a/frontend/editor/src/portal/queries/documents.ts b/frontend/editor/src/portal/queries/documents.ts
new file mode 100644
index 0000000000..ae1ec14cee
--- /dev/null
+++ b/frontend/editor/src/portal/queries/documents.ts
@@ -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 {
+ return toAsyncState(
+ useQuery({
+ queryKey: qk.documents(tier),
+ queryFn: () => fetchDocuments(tier),
+ }),
+ );
+}
diff --git a/frontend/editor/src/portal/queries/infrastructure.ts b/frontend/editor/src/portal/queries/infrastructure.ts
new file mode 100644
index 0000000000..40e0e829cd
--- /dev/null
+++ b/frontend/editor/src/portal/queries/infrastructure.ts
@@ -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 {
+ return toAsyncState(
+ useQuery({
+ queryKey: qk.fleetStats(),
+ queryFn: ({ signal }) => fetchFleetStats(signal),
+ }),
+ );
+}
+
+/** Base query: recent audit-log activity (tier-scoped). */
+export function useAuditLog(tier: Tier): AsyncState {
+ 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 {
+ return toAsyncState(
+ useQuery({
+ queryKey: qk.editorDeployment(tier),
+ queryFn: () => fetchEditorDeployment(tier),
+ retry: false,
+ }),
+ );
+}
diff --git a/frontend/editor/src/portal/queries/keys.ts b/frontend/editor/src/portal/queries/keys.ts
new file mode 100644
index 0000000000..d2c3fc601a
--- /dev/null
+++ b/frontend/editor/src/portal/queries/keys.ts
@@ -0,0 +1,33 @@
+import type { Tier } from "@portal/contexts/TierContext";
+
+/**
+ * The portal's TanStack Query keys, in one place. Convention:
+ * ["portal", , ...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;
diff --git a/frontend/editor/src/portal/queries/pipelines.ts b/frontend/editor/src/portal/queries/pipelines.ts
new file mode 100644
index 0000000000..58f67c0b4e
--- /dev/null
+++ b/frontend/editor/src/portal/queries/pipelines.ts
@@ -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 {
+ return toAsyncState(
+ useQuery({ queryKey: qk.pipelines(), queryFn: fetchPipelines }),
+ );
+}
diff --git a/frontend/editor/src/portal/queries/policies.ts b/frontend/editor/src/portal/queries/policies.ts
new file mode 100644
index 0000000000..51a8c0ff55
--- /dev/null
+++ b/frontend/editor/src/portal/queries/policies.ts
@@ -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 {
+ return toAsyncState(
+ useQuery({ queryKey: qk.policiesList(), queryFn: fetchPoliciesList }),
+ );
+}
+
+/** Base query: policy run history (GET /api/v1/policies/runs). */
+export function usePolicyRuns(): AsyncState {
+ 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 {
+ 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 };
+}
diff --git a/frontend/editor/src/portal/queries/processorFlow.ts b/frontend/editor/src/portal/queries/processorFlow.ts
new file mode 100644
index 0000000000..5ac441436f
--- /dev/null
+++ b/frontend/editor/src/portal/queries/processorFlow.ts
@@ -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 {
+ 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,
+ };
+}
diff --git a/frontend/editor/src/portal/queries/sharing.test.tsx b/frontend/editor/src/portal/queries/sharing.test.tsx
new file mode 100644
index 0000000000..5cfb9d1629
--- /dev/null
+++ b/frontend/editor/src/portal/queries/sharing.test.tsx
@@ -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 = {};
+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(
+
+
+ ,
+ );
+ 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(
+
+
+ ,
+ );
+ await waitFor(() => expect(counts["/policies"]).toBe(1));
+ home.unmount();
+
+ // Navigate to "Policies" (same client, within staleTime) — cache hit.
+ render(
+
+
+ ,
+ );
+ // 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);
+ });
+});
diff --git a/frontend/editor/src/portal/queries/sources.ts b/frontend/editor/src/portal/queries/sources.ts
new file mode 100644
index 0000000000..4d0c363fd9
--- /dev/null
+++ b/frontend/editor/src/portal/queries/sources.ts
@@ -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 {
+ return toAsyncState(
+ useQuery({ queryKey: qk.sources(), queryFn: fetchSources }),
+ );
+}
diff --git a/frontend/editor/src/portal/queries/users.ts b/frontend/editor/src/portal/queries/users.ts
new file mode 100644
index 0000000000..8f314d7fc0
--- /dev/null
+++ b/frontend/editor/src/portal/queries/users.ts
@@ -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 {
+ return toAsyncState(
+ useQuery({
+ queryKey: qk.usersRoster(tier),
+ queryFn: () => usersBackend.fetchUsers(tier),
+ }),
+ );
+}
diff --git a/frontend/editor/src/portal/queryClient.ts b/frontend/editor/src/portal/queryClient.ts
new file mode 100644
index 0000000000..85bd449b61
--- /dev/null
+++ b/frontend/editor/src/portal/queryClient.ts
@@ -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;
+}
diff --git a/frontend/editor/src/portal/test/TestQueryProvider.tsx b/frontend/editor/src/portal/test/TestQueryProvider.tsx
new file mode 100644
index 0000000000..d843d816d3
--- /dev/null
+++ b/frontend/editor/src/portal/test/TestQueryProvider.tsx
@@ -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 {children};
+}
+
+/**
+ * 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 (
+
+ {children}
+
+ );
+}
diff --git a/frontend/editor/src/portal/views/Documents.tsx b/frontend/editor/src/portal/views/Documents.tsx
index c083071bcb..8854d4db7f 100644
--- a/frontend/editor/src/portal/views/Documents.tsx
+++ b/frontend/editor/src/portal/views/Documents.tsx
@@ -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(() => fetchDocuments(tier), [tier]);
+ const state = useDocuments(tier);
const documents = state.data?.documents ?? [];
function exportCsv() {
diff --git a/frontend/editor/src/portal/views/EditorAdmin.tsx b/frontend/editor/src/portal/views/EditorAdmin.tsx
index e60efceea5..bd202a2bd3 100644
--- a/frontend/editor/src/portal/views/EditorAdmin.tsx
+++ b/frontend/editor/src/portal/views/EditorAdmin.tsx
@@ -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(
- () => fetchEditorDeployment(tier),
- [tier],
- );
+ const state = useEditorDeployment(tier);
const { data } = state;
const { isLoading } = useSectionFlags(state);
diff --git a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx
index 6f40b82764..2f7300422f 100644
--- a/frontend/editor/src/portal/views/PipelineBuilder.test.tsx
+++ b/frontend/editor/src/portal/views/PipelineBuilder.test.tsx
@@ -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[0],
options?: Parameters[1],
-) => baseRender(ui, { wrapper: MantineProvider, ...options });
+) => baseRender(ui, { wrapper: PortalTestProviders, ...options });
// Deterministic i18n: keys returned verbatim.
vi.mock("react-i18next", () => ({
diff --git a/frontend/editor/src/portal/views/PipelineBuilder.tsx b/frontend/editor/src/portal/views/PipelineBuilder.tsx
index 66c5748e85..fc23ac1516 100644
--- a/frontend/editor/src/portal/views/PipelineBuilder.tsx
+++ b/frontend/editor/src/portal/views/PipelineBuilder.tsx
@@ -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(
- // 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(
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(
+ () =>
+ (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));
diff --git a/frontend/editor/src/portal/views/Pipelines.test.tsx b/frontend/editor/src/portal/views/Pipelines.test.tsx
index ff54881c72..655abf62cb 100644
--- a/frontend/editor/src/portal/views/Pipelines.test.tsx
+++ b/frontend/editor/src/portal/views/Pipelines.test.tsx
@@ -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[0],
options?: Parameters[1],
-) => baseRender(ui, { wrapper: MantineProvider, ...options });
+) => baseRender(ui, { wrapper: PortalTestProviders, ...options });
// Deterministic i18n: keys returned verbatim.
vi.mock("react-i18next", () => ({
diff --git a/frontend/editor/src/portal/views/Pipelines.tsx b/frontend/editor/src/portal/views/Pipelines.tsx
index 4e9893dd03..b0321cfee1 100644
--- a/frontend/editor/src/portal/views/Pipelines.tsx
+++ b/frontend/editor/src/portal/views/Pipelines.tsx
@@ -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(() => fetchPipelines(), []);
+ const state = usePipelines();
const { data, loading } = state;
const { isLoading } = useSectionFlags(state);
diff --git a/frontend/editor/src/portal/views/Policies.tsx b/frontend/editor/src/portal/views/Policies.tsx
index 2de9122e0e..5bdcd19a8f 100644
--- a/frontend/editor/src/portal/views/Policies.tsx
+++ b/frontend/editor/src/portal/views/Policies.tsx
@@ -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(() => 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.
diff --git a/frontend/editor/src/portal/views/SourceBuilder.test.tsx b/frontend/editor/src/portal/views/SourceBuilder.test.tsx
index 77186de7d8..fab14ff1ae 100644
--- a/frontend/editor/src/portal/views/SourceBuilder.test.tsx
+++ b/frontend/editor/src/portal/views/SourceBuilder.test.tsx
@@ -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 }) => (
-
+
{children}
-
+
);
const render = (ui: Parameters[0]) =>
diff --git a/frontend/editor/src/portal/views/SourceBuilder.tsx b/frontend/editor/src/portal/views/SourceBuilder.tsx
index 4d6ff1af14..c38eb9f48d 100644
--- a/frontend/editor/src/portal/views/SourceBuilder.tsx
+++ b/frontend/editor/src/portal/views/SourceBuilder.tsx
@@ -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(
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));
diff --git a/frontend/editor/src/portal/views/Sources.test.tsx b/frontend/editor/src/portal/views/Sources.test.tsx
index e3b42356c0..f4a52b5665 100644
--- a/frontend/editor/src/portal/views/Sources.test.tsx
+++ b/frontend/editor/src/portal/views/Sources.test.tsx
@@ -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[0]) =>
- baseRender(ui, { wrapper: MantineProvider });
+ baseRender(ui, { wrapper: PortalTestProviders });
// Deterministic i18n: keys returned verbatim.
vi.mock("react-i18next", () => ({
diff --git a/frontend/editor/src/portal/views/Sources.tsx b/frontend/editor/src/portal/views/Sources.tsx
index 385a0ab121..6013e00d79 100644
--- a/frontend/editor/src/portal/views/Sources.tsx
+++ b/frontend/editor/src/portal/views/Sources.tsx
@@ -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(() => fetchSources(), []);
+ const state = useSources();
const { data, loading } = state;
const { isLoading } = useSectionFlags(state);
diff --git a/frontend/editor/src/portal/views/Users.caching.test.tsx b/frontend/editor/src/portal/views/Users.caching.test.tsx
new file mode 100644
index 0000000000..a43cd8a835
--- /dev/null
+++ b/frontend/editor/src/portal/views/Users.caching.test.tsx
@@ -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) => {
+ 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(
+
+
+
+
+
+
+ ,
+ );
+}
+
+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();
+ });
+});
diff --git a/frontend/editor/src/portal/views/Users.saas.test.tsx b/frontend/editor/src/portal/views/Users.saas.test.tsx
index 7cc49c91dd..3b63171c5d 100644
--- a/frontend/editor/src/portal/views/Users.saas.test.tsx
+++ b/frontend/editor/src/portal/views/Users.saas.test.tsx
@@ -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(
-
+
- ,
+ ,
);
}
diff --git a/frontend/editor/src/portal/views/Users.tsx b/frontend/editor/src/portal/views/Users.tsx
index e8a287e6f7..ad76894b06 100644
--- a/frontend/editor/src/portal/views/Users.tsx
+++ b/frontend/editor/src/portal/views/Users.tsx
@@ -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;
}
+/**
+ * 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(
- () => usersBackend.fetchUsers(tier),
- [tier, refreshKey],
- );
- // Grants are ADMIN-only; skip the fetch entirely on flavors that can't manage them.
- const grantsState = useAsync(
- () => (caps.manageGrants ? fetchGrants("PORTAL") : Promise.resolve([])),
- [tier, refreshKey],
- );
- const teamsState = useAsync(
- () => usersBackend.fetchTeams(),
- [tier, refreshKey],
- );
- const authState = useAsync(
- () => usersBackend.fetchAuthConfig(),
- [],
- );
+ const { usersState, grantsState, teamsState, authState, refresh } =
+ useUsersData();
const [actionError, setActionError] = useState(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));
diff --git a/frontend/editor/src/portal/views/teamMyCache.saas.test.ts b/frontend/editor/src/portal/views/teamMyCache.saas.test.ts
new file mode 100644
index 0000000000..f18c70dd0a
--- /dev/null
+++ b/frontend/editor/src/portal/views/teamMyCache.saas.test.ts
@@ -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);
+ });
+});
diff --git a/frontend/editor/src/portal/views/usersData.ts b/frontend/editor/src/portal/views/usersData.ts
new file mode 100644
index 0000000000..d6e965d714
--- /dev/null
+++ b/frontend/editor/src/portal/views/usersData.ts
@@ -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;
+ grantsState: AsyncState;
+ teamsState: AsyncState;
+ authState: AsyncState;
+ refresh: () => void;
+}
+
+// Grants are ADMIN-only; resolve empty on flavors that can't manage them.
+const fetchGrantsOrEmpty = (): Promise =>
+ 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,
+ };
+}
diff --git a/frontend/editor/src/saas/portal/usersBackend.ts b/frontend/editor/src/saas/portal/usersBackend.ts
index 9239810814..a4fbc03dfb 100644
--- a/frontend/editor/src/saas/portal/usersBackend.ts
+++ b/frontend/editor/src/saas/portal/usersBackend.ts
@@ -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 {
- const teams = await apiClient.local.json("/api/v1/team/my");
+ const fetchMy = () =>
+ apiClient.local.json("/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) ??
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 2ddece68a0..f467cd4e47 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -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",
diff --git a/frontend/package.json b/frontend/package.json
index e460f37e40..06059a3962 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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",