Skip to content

Commit 1bd1f85

Browse files
committed
prototype awaited mutation invalidations
1 parent 9e4d320 commit 1bd1f85

5 files changed

Lines changed: 114 additions & 28 deletions

File tree

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8+
import { QueryClientProvider, useQuery } from '@tanstack/react-query'
89
import { http, HttpResponse } from 'msw'
910
import { setupWorker } from 'msw/browser'
11+
import type { ReactNode } from 'react'
1012
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
13+
import { render } from 'vitest-browser-react'
1114

1215
import { project } from '@oxide/api-mocks'
1316

14-
import { api, q } from '..'
17+
import { api, type Project, q, queryClient, type ResultsPage, useApiMutation } from '..'
1518
import { resetDb } from '../../../mock-api/msw/db'
1619
import { handlers } from '../../../mock-api/msw/handlers'
1720
import { processServerError } from '../errors'
@@ -31,6 +34,7 @@ beforeAll(() => worker.start({ quiet: true, onUnhandledRequest: 'error' }))
3134
afterEach(() => {
3235
resetDb()
3336
worker.resetHandlers()
37+
queryClient.clear()
3438
})
3539
afterAll(() => worker.stop())
3640

@@ -54,13 +58,35 @@ function overrideOnce(
5458
)
5559
}
5660

57-
// useApiQuery and useApiMutation are almost entirely typed wrappers around React
58-
// Query's useQuery and useMutation, so they're exercised end-to-end by the
59-
// Playwright suite (every error toast goes through this path). The logic worth
60-
// unit-testing directly is response parsing in the generated client
61-
// (`handleResponse`) and the error transformation in `processServerError` (the
62-
// latter is covered exhaustively in errors.spec.ts). These tests call the API
63-
// methods directly — no React, no renderHook — since they return an ApiResult.
61+
// The API hooks are mostly typed wrappers around React Query and are exercised
62+
// end-to-end by the Playwright suite. Most tests here therefore call API methods
63+
// directly; the mutation invalidation test renders a component because the
64+
// mutation's loading state is the behavior under test.
65+
66+
const QueryClientWrapper = ({ children }: { children: ReactNode }) => (
67+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
68+
)
69+
70+
function MutationInvalidationTest({ onSuccess }: { onSuccess: () => void }) {
71+
const projects = useQuery(q(api.projectList, {}))
72+
const createProject = useApiMutation(api.projectCreate, {
73+
invalidateEndpoints: ['projectList'],
74+
onSuccess,
75+
})
76+
const count = projects.data?.items.length ?? 0
77+
78+
return (
79+
<button
80+
type="button"
81+
disabled={createProject.isPending}
82+
onClick={() =>
83+
createProject.mutate({ body: { name: 'new-project', description: '' } })
84+
}
85+
>
86+
{createProject.isPending ? 'Creating' : 'Create'} project ({count} projects)
87+
</button>
88+
)
89+
}
6490

6591
describe('API response parsing', () => {
6692
it('returns success data for a normal response', async () => {
@@ -126,3 +152,39 @@ it('apiq queryKey', () => {
126152
const queryOptions = q(api.siloView, params)
127153
expect(queryOptions.queryKey).toEqual(['siloView', params])
128154
})
155+
156+
it('stays pending until invalidated queries have refreshed', async () => {
157+
// capture the cached list length at onSuccess call time so we can assert
158+
// onSuccess ran after the invalidated query refetched
159+
let countAtSuccess: number | undefined
160+
const onSuccess = () => {
161+
const projects = queryClient.getQueryData<ResultsPage<Project>>(['projectList', {}])
162+
countAtSuccess = projects?.items.length
163+
}
164+
const screen = await render(<MutationInvalidationTest onSuccess={onSuccess} />, {
165+
wrapper: QueryClientWrapper,
166+
})
167+
const createButton = screen.getByRole('button')
168+
await expect.element(createButton).toHaveAccessibleName('Create project (3 projects)')
169+
170+
const { promise: refetch, resolve: releaseRefetch } = Promise.withResolvers<void>()
171+
worker.use(
172+
http.get(
173+
'http://testhost/v1/projects',
174+
async () => {
175+
await refetch
176+
return HttpResponse.json({ items: [project, project, project, project] })
177+
},
178+
{ once: true }
179+
)
180+
)
181+
182+
await createButton.click()
183+
await expect.element(createButton).toBeDisabled()
184+
await expect.element(createButton).toHaveAccessibleName('Creating project (3 projects)')
185+
186+
releaseRefetch()
187+
await expect.element(createButton).toBeEnabled()
188+
await expect.element(createButton).toHaveAccessibleName('Create project (4 projects)')
189+
expect(countAtSuccess).toEqual(4)
190+
})

app/api/__tests__/safety.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ it('mock-api is only referenced in test files', () => {
6767
expect(grepFiles('api-mocks')).toMatchInlineSnapshot(`
6868
[
6969
"AGENTS.md",
70-
"app/api/__tests__/client.browser.spec.ts",
70+
"app/api/__tests__/client.browser.spec.tsx",
7171
"mock-api/msw/db.ts",
7272
"test/e2e/fleet-access.e2e.ts",
7373
"test/e2e/instance-create.e2e.ts",
@@ -83,7 +83,7 @@ it('mock-api is only referenced in test files', () => {
8383
[
8484
"AGENTS.md",
8585
"README.md",
86-
"app/api/__tests__/client.browser.spec.ts",
86+
"app/api/__tests__/client.browser.spec.tsx",
8787
"app/main.tsx",
8888
"app/msw-mock-api.ts",
8989
"docs/mock-api-differences.md",

app/api/client.ts

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -377,18 +377,45 @@ export const qErrorsAllowed = <Params, Data>(
377377
// object. You can only initalize the meta at the site of the useMutation call,
378378
// which doesn't work for the image upload use case because the timeout signal
379379
// needs to be initialized separately for each call.
380+
type ApiMutationOptions<Params, Data> = Omit<
381+
UseMutationOptions<Data, ApiError, Params & { __signal?: AbortSignal }>,
382+
'mutationFn' | 'onSettled'
383+
> & {
384+
/**
385+
* Invalidate and refetch these endpoints on success. Refetches are awaited
386+
* before `onSuccess` runs and before the mutation settles, so `isPending`
387+
* (spinners, disabled buttons, open confirm modals) lasts until the UI is
388+
* consistent with the mutation — active queries on these endpoints become
389+
* part of the mutation's perceived duration.
390+
*
391+
* On every render, useMutation overwrites the options on any mutation
392+
* that is still running, and it doesn't look up onSuccess (which does the
393+
* invalidating) until the request resolves. So a mutation invalidates the
394+
* list from the most recent render, not the one from when `mutate` was
395+
* called. Adding items across renders is harmless (extra invalidations at
396+
* worst), but don't remove items and assume they'll still be queued up for
397+
* invalidation.
398+
*/
399+
invalidateEndpoints?: readonly (keyof typeof api)[]
400+
}
401+
380402
export const useApiMutation = <Params, Data>(
381403
f: (p: Params, fp: FetchParams) => Promise<ApiResult<Data>>,
382-
options?: Omit<
383-
// __signal bit makes it so you can pass a signal to mutate and mutateAsync.
384-
// the underscores make it virtually impossible for this to conflict with an
385-
// actual API field
386-
UseMutationOptions<Data, ApiError, Params & { __signal?: AbortSignal }>,
387-
'mutationFn' | 'onSettled'
388-
>
389-
) =>
390-
useMutation({
404+
options?: ApiMutationOptions<Params, Data>
405+
) => {
406+
const { invalidateEndpoints, onSuccess, ...mutationOptions } = options ?? {}
407+
const onSuccessWithInvalidation = invalidateEndpoints?.length
408+
? async (...args: Parameters<NonNullable<typeof onSuccess>>) => {
409+
await Promise.all(invalidateEndpoints.map((e) => queryClient.invalidateEndpoint(e)))
410+
await onSuccess?.(...args)
411+
}
412+
: onSuccess
413+
414+
return useMutation({
391415
mutationFn: ({ __signal, ...params }) =>
416+
// __signal bit makes it so you can pass a signal to mutate and mutateAsync.
417+
// the underscores make it virtually impossible for this to conflict with an
418+
// actual API field.
392419
// Pretty safe cast: signal is an optional addition at the call site, not
393420
// part of the original Params type. Removing it via destructuring gives
394421
// us back Params, but TS can't prove Omit<Params & {signal?}, 'signal'>
@@ -400,5 +427,7 @@ export const useApiMutation = <Params, Data>(
400427
throw result.data
401428
}),
402429
// no catch, let unexpected errors bubble up
403-
...options,
430+
...mutationOptions,
431+
onSuccess: onSuccessWithInvalidation,
404432
})
433+
}

app/forms/floating-ip-create.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,8 @@ export default function CreateFloatingIpSideModalForm() {
6060
const navigate = useNavigate()
6161

6262
const createFloatingIp = useApiMutation(api.floatingIpCreate, {
63+
invalidateEndpoints: ['floatingIpList', 'systemIpPoolUtilizationView'],
6364
onSuccess(floatingIp) {
64-
queryClient.invalidateEndpoint('floatingIpList')
65-
queryClient.invalidateEndpoint('systemIpPoolUtilizationView')
6665
// prettier-ignore
6766
addToast(<>Floating IP <HL>{floatingIp.name}</HL> created</>)
6867
navigate(pb.floatingIps(projectSelector))

app/pages/system/silos/SiloIpPoolsTab.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -162,15 +162,11 @@ export default function SiloIpPoolsTab() {
162162
)
163163

164164
const { mutateAsync: updatePoolLink } = useApiMutation(api.systemIpPoolSiloUpdate, {
165-
onSuccess() {
166-
queryClient.invalidateEndpoint('siloIpPoolList')
167-
queryClient.invalidateEndpoint('systemIpPoolSiloList')
168-
},
165+
invalidateEndpoints: ['siloIpPoolList', 'systemIpPoolSiloList'],
169166
})
170167
const { mutateAsync: unlinkPool } = useApiMutation(api.systemIpPoolSiloUnlink, {
168+
invalidateEndpoints: ['siloIpPoolList', 'systemIpPoolSiloList'],
171169
onSuccess() {
172-
queryClient.invalidateEndpoint('siloIpPoolList')
173-
queryClient.invalidateEndpoint('systemIpPoolSiloList')
174170
// We only have the ID, so will show a generic confirmation message
175171
addToast({ content: 'IP pool unlinked' })
176172
},

0 commit comments

Comments
 (0)