From 340d755f0ef387f08780efa415b32ab637a624d0 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 12 Aug 2025 10:54:35 -0500 Subject: [PATCH] add errors-allowed version of apiq and use it everywhere --- app/api/client.ts | 20 +++++++++++++++++-- app/api/hooks.ts | 20 ++++--------------- app/hooks/use-current-user.ts | 6 ++++-- app/pages/project/snapshots/SnapshotsPage.tsx | 5 +++-- app/table/cells/IpPoolCell.tsx | 10 ++++++---- 5 files changed, 35 insertions(+), 26 deletions(-) diff --git a/app/api/client.ts b/app/api/client.ts index 5c1d2dcd15..b7cbb5b38b 100644 --- a/app/api/client.ts +++ b/app/api/client.ts @@ -16,10 +16,10 @@ import { type ApiError } from './errors' import { ensurePrefetched, getApiQueryOptions, + getApiQueryOptionsErrorsAllowed, getListQueryOptionsFn, getUseApiMutation, getUseApiQuery, - getUseApiQueryErrorsAllowed, getUsePrefetchedApiQuery, wrapQueryClient, } from './hooks' @@ -33,6 +33,23 @@ export type ApiMethods = typeof api.methods /** API-specific query options helper. */ export const apiq = getApiQueryOptions(api.methods) +/** + * Variant of `apiq` that allows error responses as a valid result, + * which importantly means they can be cached by RQ. This means we can prefetch + * an endpoint that might error (see `prefetchQueryErrorsAllowed`) and use this + * hook to retrieve the error result. + * + * Concretely, the difference from the usual query function is that we turn all + * errors into successes. Instead of throwing the error, we return it as a valid + * result. This means `data` has a type that includes the possibility of error, + * plus a discriminant to let us handle both sides properly in the calling code. + * + * We also use a special query key to distinguish these from normal API queries. + * If we hit a given endpoint twice on the same page, once the normal way and + * once with errors allowed, the responses have different shapes, so we do not + * want to share the cache and mix them up. + */ +export const apiqErrorsAllowed = getApiQueryOptionsErrorsAllowed(api.methods) /** * Query options helper that only supports list endpoints. Returns * a function `(limit, pageToken) => QueryOptions` for use with @@ -47,7 +64,6 @@ export const useApiQuery = getUseApiQuery(api.methods) * test loading the page to exercise the invariant in CI. */ export const usePrefetchedApiQuery = getUsePrefetchedApiQuery(api.methods) -export const useApiQueryErrorsAllowed = getUseApiQueryErrorsAllowed(api.methods) export const useApiMutation = getUseApiMutation(api.methods) export const usePrefetchedQuery = (options: UseQueryOptions) => diff --git a/app/api/hooks.ts b/app/api/hooks.ts index f6cfcbf846..0d218a5e6c 100644 --- a/app/api/hooks.ts +++ b/app/api/hooks.ts @@ -229,27 +229,16 @@ const ERRORS_ALLOWED = 'errors-allowed' /** Result that includes both success and error so it can be cached by RQ */ type ErrorsAllowed = { type: 'success'; data: T } | { type: 'error'; data: E } -/** - * Variant of `getUseApiQuery` that allows error responses as a valid result, - * which importantly means they can be cached by RQ. This means we can prefetch - * an endpoint that might error (see `prefetchQueryErrorsAllowed`) and use this - * hook to retrieve the error result. - * - * Concretely, the only difference from `getUseApiQuery`: we turn all errors - * into successes. Instead of throwing the error, we return it as a valid - * result. This means `data` has a type that includes the possibility of error, - * plus a discriminant to let us handle both sides properly in the calling code. - */ -export const getUseApiQueryErrorsAllowed = +export const getApiQueryOptionsErrorsAllowed = (api: A) => ( method: M, params: Params, options: UseQueryOtherOptions, ApiError>> = {} - ) => { - return useQuery({ + ) => + queryOptions({ // extra bit of key is important to distinguish from normal query. if we - // hit a a given endpoint twice on the same page, once the normal way and + // hit a given endpoint twice on the same page, once the normal way and // once with errors allowed the responses have different shapes, so we do // not want to share the cache and mix them up queryKey: [method, params, ERRORS_ALLOWED], @@ -260,7 +249,6 @@ export const getUseApiQueryErrorsAllowed = .catch((data) => ({ type: 'error' as const, data })), ...options, }) - } export const getUseApiMutation = (api: A) => diff --git a/app/hooks/use-current-user.ts b/app/hooks/use-current-user.ts index 39be910045..b6ddd36f0d 100644 --- a/app/hooks/use-current-user.ts +++ b/app/hooks/use-current-user.ts @@ -6,7 +6,9 @@ * Copyright Oxide Computer Company */ -import { useApiQueryErrorsAllowed, usePrefetchedApiQuery } from '~/api/client' +import { useQuery } from '@tanstack/react-query' + +import { apiqErrorsAllowed, usePrefetchedApiQuery } from '~/api/client' import { invariant } from '~/util/invariant' /** @@ -24,7 +26,7 @@ export function useCurrentUser() { // the fleet (system) policy, but if the user doesn't have fleet read, we'll // get a 403 from that endpoint. So we simply check whether that endpoint 200s // or not to determine whether the user is a fleet viewer. - const { data: systemPolicy } = useApiQueryErrorsAllowed('systemPolicyView', {}) + const { data: systemPolicy } = useQuery(apiqErrorsAllowed('systemPolicyView', {})) // don't use usePrefetchedApiQuery because it's not worth making an errors // allowed version of that invariant(systemPolicy, 'System policy must be prefetched') diff --git a/app/pages/project/snapshots/SnapshotsPage.tsx b/app/pages/project/snapshots/SnapshotsPage.tsx index 416152ecc0..e2a58d82c8 100644 --- a/app/pages/project/snapshots/SnapshotsPage.tsx +++ b/app/pages/project/snapshots/SnapshotsPage.tsx @@ -5,17 +5,18 @@ * * Copyright Oxide Computer Company */ +import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' import { useCallback } from 'react' import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' import { + apiqErrorsAllowed, apiQueryClient, getListQFn, queryClient, useApiMutation, useApiQueryClient, - useApiQueryErrorsAllowed, type Snapshot, } from '@oxide/api' import { Snapshots16Icon, Snapshots24Icon } from '@oxide/design-system/icons/react' @@ -38,7 +39,7 @@ import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' const DiskNameFromId = ({ value }: { value: string }) => { - const { data } = useApiQueryErrorsAllowed('diskView', { path: { disk: value } }) + const { data } = useQuery(apiqErrorsAllowed('diskView', { path: { disk: value } })) if (!data) return if (data.type === 'error') return Deleted diff --git a/app/table/cells/IpPoolCell.tsx b/app/table/cells/IpPoolCell.tsx index 03cbce1937..1b4114b32d 100644 --- a/app/table/cells/IpPoolCell.tsx +++ b/app/table/cells/IpPoolCell.tsx @@ -5,15 +5,17 @@ * * Copyright Oxide Computer Company */ -import { useApiQueryErrorsAllowed } from '~/api' +import { useQuery } from '@tanstack/react-query' + +import { apiqErrorsAllowed } from '~/api' import { Tooltip } from '~/ui/lib/Tooltip' import { EmptyCell, SkeletonCell } from './EmptyCell' export const IpPoolCell = ({ ipPoolId }: { ipPoolId: string }) => { - const { data: result } = useApiQueryErrorsAllowed('projectIpPoolView', { - path: { pool: ipPoolId }, - }) + const { data: result } = useQuery( + apiqErrorsAllowed('projectIpPoolView', { path: { pool: ipPoolId } }) + ) if (!result) return // this should essentially never happen, but it's probably better than blowing // up the whole page if the pool is not found