diff --git a/apps/web/package.json b/apps/web/package.json index 00bc7ce72..8b5bb85c7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -24,6 +24,7 @@ "@intx/types": "workspace:*", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slot": "^1.2.3", + "@tanstack/react-query": "catalog:", "arktype": "catalog:", "lucide-react": "^1.27.0", "react": "^19.2.0", diff --git a/apps/web/src/agents-api.ts b/apps/web/src/agents-api.ts index 2e1bda0ec..446e76c9b 100644 --- a/apps/web/src/agents-api.ts +++ b/apps/web/src/agents-api.ts @@ -15,9 +15,11 @@ import { } from "@intx/types"; import { type } from "arktype"; import type { ArkErrors } from "arktype"; -import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import type { APIQuery } from "./api"; +import { toAPIQuery } from "./api"; +import { UnauthenticatedError, tenantKeys } from "./query-client"; export type AgentDefinition = typeof WorkflowDefinitionResponse.infer; export type AgentInstance = typeof WorkflowRunResponse.infer; @@ -205,43 +207,33 @@ export async function loadAgentDirectory( } /** - * Loads a bench's full agent directory, re-fetching whenever `tenantId` - * changes or `reloadKey` is bumped — the same "no push, refetch on demand" - * convention `useAPIQuery` uses, so a freshly created definition shows up - * the moment the create dialog closes. + * Loads a bench's full agent directory. One query owns definitions + + * instances + models (models are best-effort inside `loadAgentDirectory`) so + * the page keeps a single loading/error envelope. Pass no reloadKey — + * invalidate `tenantKeys.agentDirectory(tenantId)` after create. */ export function useAgentDirectory( tenantId: string | undefined, - reloadKey: number, ): APIQuery { - const [state, setState] = useState>({ - kind: "loading", - }); - - useEffect(() => { - if (tenantId === undefined) return; - let cancelled = false; - setState({ kind: "loading" }); - loadAgentDirectory(tenantId) - .then((data) => { - if (cancelled) return; - setState({ kind: "ready", data }); - }) - .catch((cause: unknown) => { - if (cancelled) return; + const result = useQuery({ + queryKey: + tenantId === undefined + ? (["tenant", "none", "agents", "directory"] as const) + : tenantKeys.agentDirectory(tenantId), + enabled: tenantId !== undefined, + queryFn: async () => { + if (tenantId === undefined) { + throw new Error("tenantId required when agent directory is enabled"); + } + try { + return await loadAgentDirectory(tenantId); + } catch (cause) { if (cause instanceof AgentDirectoryError && cause.status === 401) { - setState({ kind: "unauthenticated" }); - return; + throw new UnauthenticatedError(); } - setState({ - kind: "error", - message: cause instanceof Error ? cause.message : String(cause), - }); - }); - return () => { - cancelled = true; - }; - }, [tenantId, reloadKey]); - - return state; + throw cause; + } + }, + }); + return toAPIQuery(result); } diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 446e21b4a..46f6dd2d5 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -10,9 +10,11 @@ import { WorkflowRunSummary, paginatedSchema, } from "@intx/types"; +import { useQuery } from "@tanstack/react-query"; import { type } from "arktype"; import type { ArkErrors } from "arktype"; -import { useEffect, useState } from "react"; + +import { UnauthenticatedError, pathToQueryKey } from "./query-client"; export const ProfileSchema = UserProfile; export const PrincipalsSchema = paginatedSchema(PrincipalSummary); @@ -73,63 +75,75 @@ export type APIQuery = /** An arktype schema, seen as the validating call every `Type` provides. */ type Validator = (data: unknown) => T | ArkErrors; +/** + * Map a TanStack Query result onto the APIQuery discriminant pages already + * render through QueryView. `isLoading` (pending + fetching) is the loading + * state — bare `isPending` would flash skeletons when cached data exists. + */ +export function toAPIQuery(result: { + readonly isLoading: boolean; + readonly isError: boolean; + readonly error: unknown; + readonly data: T | undefined; + readonly isPending: boolean; + readonly fetchStatus: "fetching" | "paused" | "idle"; +}): APIQuery { + if (result.isLoading) return { kind: "loading" }; + if (result.isError) { + if (result.error instanceof UnauthenticatedError) { + return { kind: "unauthenticated" }; + } + return { + kind: "error", + message: + result.error instanceof Error + ? result.error.message + : String(result.error), + }; + } + if (result.data !== undefined) return { kind: "ready", data: result.data }; + // Disabled queries (empty path, unresolved tenant) have no data and are not + // fetching — still report loading so callers that gate on "ready" stay quiet. + return { kind: "loading" }; +} + /** * Fetches one hub endpoint and reports exactly what happened: loading, no * session (401), a failure, or validated data. Pass a module-level schema so - * the effect does not re-run on every render. + * identity stays stable; the schema never enters the query key. + * + * Empty paths are disabled and never fetch — the boundary owns the gate so + * call sites that still pass `""` when a tenant is unresolved cannot hit + * the network with a broken URL. */ export function useAPIQuery( path: string, schema: Validator, - /** Bump this to force a re-fetch of an otherwise-unchanged path, e.g. - * after a mutation the hub doesn't push updates for. */ - reloadKey: number = 0, ): APIQuery { - const [state, setState] = useState>({ kind: "loading" }); - - useEffect(() => { - let cancelled = false; - const settle = (next: APIQuery) => { - if (!cancelled) setState(next); - }; - void (async () => { - try { - const response = await fetch(path, { - headers: { accept: "application/json" }, - }); - if (response.status === 401) { - settle({ kind: "unauthenticated" }); - return; - } - if (!response.ok) { - settle({ - kind: "error", - message: `The hub answered ${response.status} for ${path}.`, - }); - return; - } - const parsed = schema(await response.json()); - if (parsed instanceof type.errors) { - settle({ - kind: "error", - message: `Unexpected response shape from ${path}: ${parsed.summary}`, - }); - return; - } - settle({ kind: "ready", data: parsed }); - } catch (cause) { - settle({ - kind: "error", - message: cause instanceof Error ? cause.message : String(cause), - }); + const enabled = path !== ""; + const result = useQuery({ + queryKey: pathToQueryKey(path), + enabled, + queryFn: async () => { + const response = await fetch(path, { + headers: { accept: "application/json" }, + }); + if (response.status === 401) { + throw new UnauthenticatedError(); } - })(); - return () => { - cancelled = true; - }; - }, [path, schema, reloadKey]); - - return state; + if (!response.ok) { + throw new Error(`The hub answered ${response.status} for ${path}.`); + } + const parsed = schema(await response.json()); + if (parsed instanceof type.errors) { + throw new Error( + `Unexpected response shape from ${path}: ${parsed.summary}`, + ); + } + return parsed; + }, + }); + return toAPIQuery(result); } export class APIMutationError extends Error { diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 1fbb8ba2b..41bae8e8f 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -1,5 +1,12 @@ +// The whole interface as a pure function of the current path and session +// state. The entry point owns the browser history and the one session probe; +// screens that talk to the hub only mount once the session is confirmed, so +// a signed-out browser fires no authenticated request anywhere. + import { BootScreen, Button, CorbitsMark, EmptyState } from "@corbits/react-ui"; +import { QueryClientProvider } from "@tanstack/react-query"; import { CircleAlert } from "lucide-react"; +import { useMemo } from "react"; import { AuthScreen } from "./auth-screen"; import { BenchProvider } from "./bench-context"; @@ -8,6 +15,7 @@ import { NavigationProvider, type Navigate } from "./navigation"; import { NotFoundPage } from "./pages/not-found-page"; import { OnboardingPage } from "./pages/onboarding-page"; import { ProvisioningErrorPage } from "./pages/provisioning-error-page"; +import { createAppQueryClient } from "./query-client"; import { APP_ROUTES, matchesRoute, ONBOARDING_PATH } from "./routes"; import type { SessionState, SessionUser } from "./session"; import { AppShell } from "./shell/app-shell"; @@ -32,24 +40,29 @@ function Shell({ readonly user: SessionUser; readonly onSignOut: () => void; }) { + // One client per signed-in shell mount — above BenchProvider so principals + // and every tenant-scoped page share the same cache. + const queryClient = useMemo(() => createAppQueryClient(), []); const route = APP_ROUTES.find((candidate) => matchesRoute(candidate.path, path), ); return ( - - - - - {path === ONBOARDING_PATH ? ( - - ) : route === undefined ? ( - - ) : ( - route.render(path, navigate) - )} - - - + + + + + + {path === ONBOARDING_PATH ? ( + + ) : route === undefined ? ( + + ) : ( + route.render(path, navigate) + )} + + + + ); } diff --git a/apps/web/src/bench-context.tsx b/apps/web/src/bench-context.tsx index 5f9390b6d..a8be871bc 100644 --- a/apps/web/src/bench-context.tsx +++ b/apps/web/src/bench-context.tsx @@ -4,11 +4,13 @@ // bench (the chat page, the benches page, the header switcher) reads this // context instead of re-deriving "membership[0]" on its own. +import { useQueryClient } from "@tanstack/react-query"; import { createContext, useContext, useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; import { PrincipalsSchema, useAPIQuery } from "./api"; import type { APIQuery, Principal, PrincipalsPage } from "./api"; +import { meKeys, tenantKeys } from "./query-client"; const STORAGE_KEY = "workbench.selectedTenantId"; @@ -55,12 +57,8 @@ function resolveSelection( } export function BenchProvider({ children }: { readonly children: ReactNode }) { - const [reloadKey, setReloadKey] = useState(0); - const memberships = useAPIQuery( - "/api/me/principals", - PrincipalsSchema, - reloadKey, - ); + const queryClient = useQueryClient(); + const memberships = useAPIQuery("/api/me/principals", PrincipalsSchema); const [stored, setStored] = useState(() => readStoredTenantId(), ); @@ -83,16 +81,22 @@ export function BenchProvider({ children }: { readonly children: ReactNode }) { selectedTenantId: resolved?.tenantId ?? null, selectedPrincipalId: resolved?.principalId ?? null, selectTenant: (tenantId: string) => { + const previous = stored; + if (previous !== null && previous !== tenantId) { + // Drop the left-behind bench's cache entirely — do not invalidate + // (which would refetch for a bench the user is no longer on). + queryClient.removeQueries({ queryKey: tenantKeys.all(previous) }); + } writeStoredTenantId(tenantId); setStored(tenantId); }, onBenchCreated: (tenantId: string) => { writeStoredTenantId(tenantId); setStored(tenantId); - setReloadKey((value) => value + 1); + void queryClient.invalidateQueries({ queryKey: meKeys.principals }); }, }), - [memberships, resolved], + [memberships, resolved, stored, queryClient], ); return ( diff --git a/apps/web/src/pages/agents-page.tsx b/apps/web/src/pages/agents-page.tsx index 8e4907cbe..1788894f3 100644 --- a/apps/web/src/pages/agents-page.tsx +++ b/apps/web/src/pages/agents-page.tsx @@ -23,6 +23,7 @@ import type { BadgeTone, ViewMode } from "@corbits/react-ui"; import { Bot, Copy, Plus, Workflow } from "lucide-react"; import { useState } from "react"; import type { ReactNode } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import type { AgentDefinition, AgentInstance } from "../agents-api"; import type { AgentDirectoryData } from "../agents-api"; @@ -30,6 +31,7 @@ import type { APIQuery } from "../api"; import { useAgentDirectory } from "../agents-api"; import { useBench } from "../bench-context"; import { countProp } from "../optional-props"; +import { tenantKeys } from "../query-client"; import { QueryView } from "../query-view"; import { CreateAgentDialog } from "./create-agent-dialog"; import { @@ -467,8 +469,8 @@ export function AgentsRoute() { // BenchProvider is the only source of the active tenant — never re-fetch // /api/me/principals and take memberships[0], which ignores the switcher. const { memberships, selectedTenantId } = useBench(); - const [reloadKey, setReloadKey] = useState(0); - const directory = useAgentDirectory(selectedTenantId ?? undefined, reloadKey); + const queryClient = useQueryClient(); + const directory = useAgentDirectory(selectedTenantId ?? undefined); const resolvedDirectory: APIQuery = memberships.kind !== "ready" @@ -488,7 +490,12 @@ export function AgentsRoute() { return ( setReloadKey((key) => key + 1)} + onAgentCreated={() => { + if (selectedTenantId === null) return; + void queryClient.invalidateQueries({ + queryKey: tenantKeys.agentDirectory(selectedTenantId), + }); + }} /> ); } diff --git a/apps/web/src/pages/approvals-page.tsx b/apps/web/src/pages/approvals-page.tsx index d5e1ba0e9..30bca299b 100644 --- a/apps/web/src/pages/approvals-page.tsx +++ b/apps/web/src/pages/approvals-page.tsx @@ -28,6 +28,7 @@ import { import type { ApprovalRequest } from "@corbits/react-ui"; import { ShieldCheck } from "lucide-react"; import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { approveApproval, @@ -38,6 +39,7 @@ import { import { countProp } from "../optional-props"; import type { APIQuery, NeedsYouItem } from "../api"; import { useBench } from "../bench-context"; +import { tenantKeys } from "../query-client"; import { QueryView } from "../query-view"; export function ApprovalsPage({ @@ -194,7 +196,7 @@ function RejectDialog({ export function ApprovalsRoute() { const { selectedTenantId } = useBench(); - const [reloadKey, setReloadKey] = useState(0); + const queryClient = useQueryClient(); const [approvingId, setApprovingId] = useState(null); const [rejectingId, setRejectingId] = useState(null); const [actionError, setActionError] = useState(null); @@ -204,7 +206,6 @@ export function ApprovalsRoute() { ? "" : `/api/tenants/${selectedTenantId}/approvals/needs-you`, NeedsYouSchema, - reloadKey, ); const rows: APIQuery = selectedTenantId === null @@ -214,7 +215,10 @@ export function ApprovalsRoute() { : approvals; function reload() { - setReloadKey((value) => value + 1); + if (selectedTenantId === null) return; + void queryClient.invalidateQueries({ + queryKey: tenantKeys.needsYou(selectedTenantId), + }); } function handleApprove(approval: NeedsYouItem) { diff --git a/apps/web/src/pages/routines-page.tsx b/apps/web/src/pages/routines-page.tsx index 8933c5d32..3ae78a9bb 100644 --- a/apps/web/src/pages/routines-page.tsx +++ b/apps/web/src/pages/routines-page.tsx @@ -39,12 +39,14 @@ import { } from "@corbits/react-ui"; import type { BadgeTone } from "@corbits/react-ui"; import { Clock, Plus } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { RunsSchema, useAPIQuery } from "../api"; import type { APIQuery, WorkflowRun } from "../api"; import { useBench } from "../bench-context"; import { countProp } from "../optional-props"; +import { tenantKeys } from "../query-client"; import { QueryView } from "../query-view"; import { approximateNextRun, cadenceLabel } from "../routine-trigger"; @@ -650,23 +652,31 @@ export function RoutinesRoute({ // BenchProvider owns the active tenant. Never re-fetch principals and take // memberships[0] — that ignores the shell's bench switcher. const { selectedTenantId } = useBench(); + const queryClient = useQueryClient(); const allRuns = useAPIQuery("/api/me/workflows/runs", RunsSchema); const tenantId = selectedTenantId; - // Bumped after a mutation (create/toggle) so the affected queries - // re-run without a full page reload — `useTenantQuery`'s effect keys - // off its `key` array exactly like `useAPIQuery` keys off its path, so - // changing this value is what a cache invalidation would otherwise do. - const [refreshToken, setRefreshToken] = useState(0); - const refresh = () => setRefreshToken((token) => token + 1); + function invalidateRoutines() { + if (tenantId === null) return; + void queryClient.invalidateQueries({ + queryKey: tenantKeys.routines(tenantId), + }); + void queryClient.invalidateQueries({ + queryKey: tenantKeys.routineRunHistories(tenantId), + }); + } const routines = useTenantQuery( - ["routines", tenantId, refreshToken], + tenantId === null + ? (["tenant", "none", "routines"] as const) + : tenantKeys.routines(tenantId), tenantId !== null, () => listRoutines(tenantId ?? ""), ); const definitionsQuery = useTenantQuery( - ["routine-definitions", tenantId], + tenantId === null + ? (["tenant", "none", "definitions"] as const) + : tenantKeys.definitions(tenantId), tenantId !== null, () => listWorkflowDefinitions(tenantId ?? ""), ); @@ -678,7 +688,9 @@ export function RoutinesRoute({ const runHistoriesQuery = useTenantQuery< ReadonlyMap >( - ["routine-run-histories", tenantId, routineIds.join(","), refreshToken], + tenantId === null + ? (["tenant", "none", "routine-run-histories"] as const) + : [...tenantKeys.routineRunHistories(tenantId), routineIds.join(",")], tenantId !== null && routineIds.length > 0, async () => { const entries = await Promise.all( @@ -705,20 +717,25 @@ export function RoutinesRoute({ const openRoutineId = routineIdFromPath(path); - const detailRoutine = useTenantQuery( - ["routine-detail", tenantId, openRoutineId], - tenantId !== null && openRoutineId !== null, - async () => { - const found = - routines.kind === "ready" - ? routines.data.find((r) => r.id === openRoutineId) - : undefined; - if (found !== undefined) return found; - throw new Error("Routine not found"); - }, - ); + // Client-side selector over the already-loaded list — not a server fetch. + // A separate useQuery would race the list load and stick on "not found". + const detailRoutine: APIQuery = useMemo(() => { + if (openRoutineId === null || tenantId === null) { + return { kind: "loading" }; + } + if (routines.kind === "loading") return { kind: "loading" }; + if (routines.kind !== "ready") return routines; + const found = routines.data.find((r) => r.id === openRoutineId); + if (found === undefined) { + return { kind: "error", message: "Routine not found" }; + } + return { kind: "ready", data: found }; + }, [openRoutineId, tenantId, routines]); + const detailRuns = useTenantQuery( - ["routine-detail-runs", tenantId, openRoutineId], + tenantId === null || openRoutineId === null + ? (["tenant", "none", "routines", "none", "runs"] as const) + : tenantKeys.routineRuns(tenantId, openRoutineId), tenantId !== null && openRoutineId !== null, () => listRoutineRuns(tenantId ?? "", openRoutineId ?? ""), ); @@ -750,15 +767,23 @@ export function RoutinesRoute({ if (tenantId === null) throw new Error("No bench to create this in yet"); await createRoutine(tenantId, input); - refresh(); + invalidateRoutines(); }} onToggleEnabled={(routine, enabled) => { if (tenantId === null) return; - void updateRoutine(tenantId, routine.id, { enabled }).then(refresh); + void updateRoutine(tenantId, routine.id, { enabled }).then( + invalidateRoutines, + ); }} onRunNow={async (routine) => { if (tenantId === null) throw new Error("No bench to run this in yet"); await runRoutineNow(tenantId, routine.id); + void queryClient.invalidateQueries({ + queryKey: tenantKeys.routineRuns(tenantId, routine.id), + }); + void queryClient.invalidateQueries({ + queryKey: tenantKeys.routineRunHistories(tenantId), + }); }} /> ); diff --git a/apps/web/src/query-client.ts b/apps/web/src/query-client.ts new file mode 100644 index 000000000..0d3b1ce16 --- /dev/null +++ b/apps/web/src/query-client.ts @@ -0,0 +1,64 @@ +// One QueryClient for the signed-in shell: shared cache for /api/me and +// tenant-scoped reads, so navigating between pages reuses data and a bench +// switch can drop the previous bench's tenant keys in a single call. + +import { QueryClient } from "@tanstack/react-query"; + +/** Thrown from queryFns on HTTP 401 so the client can stop retrying and the + * APIQuery adapter can map to `kind: "unauthenticated"`. */ +export class UnauthenticatedError extends Error { + constructor(message = "unauthenticated") { + super(message); + this.name = "UnauthenticatedError"; + } +} + +export function createAppQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: (failureCount, error) => { + if (error instanceof UnauthenticatedError) return false; + return failureCount < 3; + }, + }, + }, + }); +} + +/** Stable identity-scoped keys — survive a bench switch. */ +export const meKeys = { + profile: ["me", "profile"] as const, + principals: ["me", "principals"] as const, + runs: ["me", "runs"] as const, +}; + +/** Tenant-scoped keys — removed wholesale when the user leaves a bench. */ +export const tenantKeys = { + all: (tenantId: string) => ["tenant", tenantId] as const, + needsYou: (tenantId: string) => + ["tenant", tenantId, "approvals", "needs-you"] as const, + routines: (tenantId: string) => ["tenant", tenantId, "routines"] as const, + routineRuns: (tenantId: string, routineId: string) => + ["tenant", tenantId, "routines", routineId, "runs"] as const, + routineRunHistories: (tenantId: string) => + ["tenant", tenantId, "routine-run-histories"] as const, + definitions: (tenantId: string) => + ["tenant", tenantId, "definitions"] as const, + agentDirectory: (tenantId: string) => + ["tenant", tenantId, "agents", "directory"] as const, +}; + +/** + * Map a hub GET path onto a stable query key. Unknown paths fall back to a + * path-keyed entry so callers cannot accidentally share cache entries. + */ +export function pathToQueryKey(path: string): readonly unknown[] { + if (path === "/api/me") return meKeys.profile; + if (path === "/api/me/principals") return meKeys.principals; + if (path === "/api/me/workflows/runs") return meKeys.runs; + const needsYou = /^\/api\/tenants\/([^/]+)\/approvals\/needs-you$/.exec(path); + if (needsYou?.[1] !== undefined) return tenantKeys.needsYou(needsYou[1]); + return ["path", path]; +} diff --git a/apps/web/src/routines-api.ts b/apps/web/src/routines-api.ts index c2099a359..d8bffdb49 100644 --- a/apps/web/src/routines-api.ts +++ b/apps/web/src/routines-api.ts @@ -11,8 +11,10 @@ import { type } from "arktype"; import type { ArkErrors } from "arktype"; -import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import type { APIQuery } from "./api"; +import { toAPIQuery } from "./api"; +import { UnauthenticatedError } from "./query-client"; export const RoutineTrigger = type({ kind: "'interval'", @@ -209,47 +211,30 @@ export function listWorkflowDefinitions( } /** - * The `useAPIQuery` state machine, for a fetch that needs a tenant id - * (and thus cannot be a static path) — the extra seam - * `@corbits/routines`' tenant-scoped routes need that `/api/me/...` - * queries do not. `enabled` mirrors chat-page.tsx's own gate on a - * resolved tenant: skip fetching until one exists. + * Tenant-scoped query via TanStack Query. Keys must be stable arrays that + * already include the tenant id under the `["tenant", tenantId, ...]` + * convention so a bench switch can `removeQueries` the whole prefix. + * When `enabled` is false the previous result is not kept on screen — TQ + * drops the active fetch and the adapter reports loading until re-enabled. */ export function useTenantQuery( key: readonly unknown[], enabled: boolean, fetcher: () => Promise, ): APIQuery { - const [state, setState] = useState>({ kind: "loading" }); - - useEffect(() => { - if (!enabled) return; - let cancelled = false; - setState({ kind: "loading" }); - void (async () => { + const result = useQuery({ + queryKey: key, + enabled, + queryFn: async () => { try { - const data = await fetcher(); - if (!cancelled) setState({ kind: "ready", data }); + return await fetcher(); } catch (cause) { - if (cancelled) return; if (cause instanceof RoutinesApiError && cause.status === 401) { - setState({ kind: "unauthenticated" }); - return; + throw new UnauthenticatedError(); } - setState({ - kind: "error", - message: cause instanceof Error ? cause.message : String(cause), - }); + throw cause; } - })(); - return () => { - cancelled = true; - }; - // `fetcher` is intentionally excluded: callers pass a fresh closure - // every render, and `key` is the caller's own declared cache - // identity for what that closure fetches — the same contract - // `useAPIQuery` documents for its `schema` argument. - }, [enabled, ...key]); - - return state; + }, + }); + return toAPIQuery(result); } diff --git a/apps/web/test/contextual-panel.test.tsx b/apps/web/test/contextual-panel.test.tsx index 29296e017..310d337a8 100644 --- a/apps/web/test/contextual-panel.test.tsx +++ b/apps/web/test/contextual-panel.test.tsx @@ -10,6 +10,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { BenchProvider } from "../src/bench-context"; import { ContextualPanel } from "../src/shell/contextual-panel"; +import { TestQueryProvider } from "./test-query-provider"; const noop = () => undefined; const realFetch = globalThis.fetch; @@ -20,9 +21,11 @@ afterEach(() => { function renderPanel(path: string): string { return renderToStaticMarkup( - - - , + + + + + , ); } @@ -46,11 +49,19 @@ describe("ContextualPanel", () => { const root = createRoot(container); await act(async () => { root.render( - - - , + + + + + , ); }); + for (let i = 0; i < 20; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + if (container.innerHTML.includes("No bench selected")) break; + } expect(container.innerHTML).toContain("No bench selected"); root.unmount(); container.remove(); @@ -94,15 +105,19 @@ describe("ContextualPanel", () => { const root = createRoot(container); await act(async () => { root.render( - - - , + + + + + , ); }); - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); + for (let i = 0; i < 20; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + if (container.innerHTML.includes("No notifications yet")) break; + } expect(container.innerHTML).toContain("No notifications yet"); expect(container.innerHTML).toContain( "mentions and mail-backed alerts will land here", diff --git a/apps/web/test/query-client.test.ts b/apps/web/test/query-client.test.ts new file mode 100644 index 000000000..56486e0de --- /dev/null +++ b/apps/web/test/query-client.test.ts @@ -0,0 +1,97 @@ +// Query-key helpers and the APIQuery adapter — pure unit coverage so the +// TanStack cutover does not depend only on page-level smoke. + +import { describe, expect, test } from "bun:test"; + +import { toAPIQuery } from "../src/api"; +import { + UnauthenticatedError, + meKeys, + pathToQueryKey, + tenantKeys, +} from "../src/query-client"; + +describe("pathToQueryKey", () => { + test("maps identity-scoped hub paths onto meKeys", () => { + expect(pathToQueryKey("/api/me")).toEqual(meKeys.profile); + expect(pathToQueryKey("/api/me/principals")).toEqual(meKeys.principals); + expect(pathToQueryKey("/api/me/workflows/runs")).toEqual(meKeys.runs); + }); + + test("maps needs-you onto a tenant-scoped key", () => { + expect(pathToQueryKey("/api/tenants/tnt_1/approvals/needs-you")).toEqual( + tenantKeys.needsYou("tnt_1"), + ); + }); + + test("falls back to a path key for unknown routes", () => { + expect(pathToQueryKey("/api/mystery")).toEqual(["path", "/api/mystery"]); + }); +}); + +describe("toAPIQuery", () => { + test("loading while fetch is in flight with no data", () => { + expect( + toAPIQuery({ + isLoading: true, + isError: false, + error: null, + data: undefined, + isPending: true, + fetchStatus: "fetching", + }), + ).toEqual({ kind: "loading" }); + }); + + test("maps UnauthenticatedError to unauthenticated", () => { + expect( + toAPIQuery({ + isLoading: false, + isError: true, + error: new UnauthenticatedError(), + data: undefined, + isPending: false, + fetchStatus: "idle", + }), + ).toEqual({ kind: "unauthenticated" }); + }); + + test("maps other errors to error messages", () => { + expect( + toAPIQuery({ + isLoading: false, + isError: true, + error: new Error("boom"), + data: undefined, + isPending: false, + fetchStatus: "idle", + }), + ).toEqual({ kind: "error", message: "boom" }); + }); + + test("ready when data is present", () => { + expect( + toAPIQuery({ + isLoading: false, + isError: false, + error: null, + data: { ok: true }, + isPending: false, + fetchStatus: "idle", + }), + ).toEqual({ kind: "ready", data: { ok: true } }); + }); + + test("disabled / idle with no data still reports loading", () => { + expect( + toAPIQuery({ + isLoading: false, + isError: false, + error: null, + data: undefined, + isPending: true, + fetchStatus: "idle", + }), + ).toEqual({ kind: "loading" }); + }); +}); diff --git a/apps/web/test/rail.test.tsx b/apps/web/test/rail.test.tsx index 86121ed06..6ca7a7d3c 100644 --- a/apps/web/test/rail.test.tsx +++ b/apps/web/test/rail.test.tsx @@ -9,6 +9,7 @@ import { BenchProvider } from "../src/bench-context"; import { NavigationProvider } from "../src/navigation"; import { NAV_ROUTES, SETTINGS_PATH } from "../src/routes"; import { Rail } from "../src/shell/rail"; +import { TestQueryProvider } from "./test-query-provider"; const noop = () => undefined; const user = { id: "user_1", name: "Ada Lovelace", email: "ada@example.com" }; @@ -21,11 +22,13 @@ globalThis.fetch = ((_input: RequestInfo | URL, _init?: RequestInit) => function renderRail(path: string): string { return renderToStaticMarkup( - - - - - , + + + + + + + , ); } diff --git a/apps/web/test/shell-contextual-panel.test.tsx b/apps/web/test/shell-contextual-panel.test.tsx index 05bdb0e4d..ce5f68586 100644 --- a/apps/web/test/shell-contextual-panel.test.tsx +++ b/apps/web/test/shell-contextual-panel.test.tsx @@ -12,6 +12,7 @@ import { createRoot, type Root } from "react-dom/client"; import { BenchProvider } from "../src/bench-context"; import { NavigationProvider } from "../src/navigation"; import { Rail } from "../src/shell/rail"; +import { TestQueryProvider } from "./test-query-provider"; const noop = () => undefined; const user = { id: "user_1", name: "Ada Lovelace", email: "ada@example.com" }; @@ -80,25 +81,27 @@ async function renderRail(): Promise { root = createRoot(container); await act(async () => { root?.render( - - - - - , + + + + + + + , ); - // Two effect-driven fetches run one after the other (membership resolves - // a tenant id, which is what makes the needs-you effect fire at all), so - // this waits on a macrotask between each of several microtask turns - // rather than guessing a fixed microtask count. - for (let i = 0; i < 5; i++) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } }); + // Principals then needs-you are sequential TQ queries. Drain each settle + // under act so React commits the badge before assertions. + for (let i = 0; i < 20; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } if (container === null) throw new Error("container not mounted"); return container; } diff --git a/apps/web/test/test-query-provider.tsx b/apps/web/test/test-query-provider.tsx new file mode 100644 index 000000000..bbf3d36a8 --- /dev/null +++ b/apps/web/test/test-query-provider.tsx @@ -0,0 +1,27 @@ +// Shared QueryClientProvider for component tests that touch useAPIQuery / +// BenchProvider. retry:false + gcTime:0 keep failures loud and cache-free. + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +export function createTestQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + staleTime: 0, + }, + }, + }); +} + +export function TestQueryProvider({ + children, + client = createTestQueryClient(), +}: { + readonly children: ReactNode; + readonly client?: QueryClient; +}) { + return {children}; +} diff --git a/bun.lock b/bun.lock index ea3993df2..527297e46 100644 --- a/bun.lock +++ b/bun.lock @@ -96,6 +96,7 @@ "@intx/types": "workspace:*", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slot": "^1.2.3", + "@tanstack/react-query": "catalog:", "arktype": "catalog:", "lucide-react": "^1.27.0", "react": "^19.2.0", @@ -745,6 +746,7 @@ "@corbits/react-ui", ], "catalog": { + "@tanstack/react-query": "^5.101.4", "@types/bun": "^1.3.9", "@types/semver": "^7.7.1", "arktype": "^2.2.0", @@ -1137,6 +1139,10 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tanstack/query-core": ["@tanstack/query-core@5.101.4", "", {}, "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.101.4", "", { "dependencies": { "@tanstack/query-core": "5.101.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], diff --git a/package.json b/package.json index 6b0ba9b20..7106faa03 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "typescript": "6.0.3", "@types/bun": "^1.3.9", "@types/semver": "^7.7.1", + "@tanstack/react-query": "^5.101.4", "better-auth": "^1.4.18", "drizzle-orm": "^0.45.1", "hono": "^4.11.9",