From 47ed002ea5e0aac8713be00d3178dd86d9554bea Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Thu, 15 Oct 2026 15:52:16 -0700 Subject: [PATCH] ref(api): Remove fetchMutation No callsite uses fetchMutation any more, so delete it and its ApiMutationVariables type, and point the form docs, the data-fetching and form agent skills, and the Client.request deprecation notes at apiFetch. The doc examples now read the body from response.json. --- .../skills/frontend-data-fetching/SKILL.md | 10 ++-- .../skills/generate-frontend-forms/SKILL.md | 54 +++++++++---------- .../skills/migrate-frontend-forms/SKILL.md | 47 ++++++++-------- static/app/api.tsx | 4 +- .../app/components/core/form/autoSaveForm.mdx | 20 +++---- static/app/components/core/form/form.mdx | 6 +-- static/app/utils/queryClient.tsx | 33 +----------- 7 files changed, 68 insertions(+), 106 deletions(-) diff --git a/.agents/skills/frontend-data-fetching/SKILL.md b/.agents/skills/frontend-data-fetching/SKILL.md index 42bb60164d16..6773ef94a434 100644 --- a/.agents/skills/frontend-data-fetching/SKILL.md +++ b/.agents/skills/frontend-data-fetching/SKILL.md @@ -1,6 +1,6 @@ --- name: frontend-data-fetching -description: Fetch data in Sentry's frontend with TanStack Query and apiOptions. Use when adding or editing React code in static/ that calls the API — useQuery/useMutation/useInfiniteQuery, apiOptions, queryOptions/mutationOptions, fetchMutation, reading response headers/pagination, or conditional fetching. Trigger on "fetch data", "add an API call", "useQuery", "useMutation", "apiOptions", "queryFn", "pagination headers", "X-Hits", or "why is my query type wrong". +description: Fetch data in Sentry's frontend with TanStack Query and apiOptions. Use when adding or editing React code in static/ that calls the API — useQuery/useMutation/useInfiniteQuery, apiOptions, queryOptions/mutationOptions, apiFetch in a mutationFn, reading response headers/pagination, or conditional fetching. Trigger on "fetch data", "add an API call", "useQuery", "useMutation", "apiOptions", "queryFn", "pagination headers", "X-Hits", or "why is my query type wrong". --- # Frontend Data Fetching (TanStack Query + apiOptions) @@ -48,14 +48,14 @@ useQuery({...}) // ✅ Let types be inferred — annotate the mutationFn/queryFn instead useMutation({ mutationFn: (variables: MyVariables) => - fetchMutation({...}), + apiFetch({queryKey: [url, {...}]}), }) ``` Specific rules: 1. **Type the `mutationFn` parameters**, not the hook/function generics. The variables type flows from the `mutationFn` signature. -2. **Use `fetchMutation`** to type the return value — the generic on `fetchMutation` is correct because it types the API response. +2. **Use `apiFetch`** to type the return value — the generic on `apiFetch` is correct because it types the API response. A `mutationFn` returning `apiFetch(...)` resolves to `ApiResponse` (`{json, headers, status}`), so `onSuccess`/`mutateAsync` consumers read `.json`. 3. **Never type the error generic as `RequestError`** — that's a type assertion in disguise. The error is `Error` by default. Use runtime narrowing (`if (error instanceof RequestError)`) when you need `RequestError`-specific properties. 4. **Never explicitly type the context** — it is inferred from what `onMutate` returns. Creating a separate `type FooContext = {...}` and passing it as a generic is unnecessary. 5. **Same rule applies to queries** — `useQuery`, `queryOptions`, `useInfiniteQuery`, etc. Types flow from `queryFn` and `select`. @@ -65,7 +65,7 @@ Specific rules: type MyContext = {previousData: Item[]}; mutationOptions({ - mutationFn: variables => fetchMutation({...}), + mutationFn: variables => apiFetch({queryKey: [url, {...}]}), onMutate: async () => { const previousData = queryClient.getQueryData(itemQueryOptions); return {previousData}; @@ -78,7 +78,7 @@ mutationOptions({ // ✅ Everything is inferred mutationOptions({ mutationFn: (variables: UpdateItemVars) => - fetchMutation({...}), + apiFetch({queryKey: [url, {...}]}), onMutate: async () => { const previousData = queryClient.getQueryData(itemQueryOptions); return {previousData}; diff --git a/.agents/skills/generate-frontend-forms/SKILL.md b/.agents/skills/generate-frontend-forms/SKILL.md index f32bcc14a58c..f5d24c0c4bb0 100644 --- a/.agents/skills/generate-frontend-forms/SKILL.md +++ b/.agents/skills/generate-frontend-forms/SKILL.md @@ -545,17 +545,15 @@ import {useMutation} from '@tanstack/react-query'; import {setFieldErrors} from '@sentry/scraps/form'; -import {fetchMutation} from 'sentry/utils/queryClient'; +import {apiFetch} from 'sentry/utils/api/apiFetch'; import {RequestError} from 'sentry/utils/requestError/requestError'; import {requestErrorToFieldErrors} from 'sentry/utils/requestError/requestErrorToFieldErrors'; function MyForm() { const mutation = useMutation({ mutationFn: (data: {email: string; username: string}) => { - return fetchMutation({ - url: '/users/', - method: 'POST', - data, + return apiFetch({ + queryKey: ['/users/', {method: 'POST', data}], }); }, }); @@ -626,7 +624,7 @@ import {z} from 'zod'; import {AutoSaveForm} from '@sentry/scraps/form'; -import {fetchMutation} from 'sentry/utils/queryClient'; +import {apiFetch} from 'sentry/utils/api/apiFetch'; const schema = z.object({ displayName: z.string().min(1, 'Display name is required'), @@ -640,15 +638,13 @@ function SettingsForm() { initialValue={user.displayName} mutationOptions={{ mutationFn: data => { - return fetchMutation({ - url: '/user/', - method: 'PUT', - data, + return apiFetch({ + queryKey: ['/user/', {method: 'PUT', data}], }); }, - onSuccess: data => { + onSuccess: response => { // Update React Query cache - queryClient.setQueryData(['user'], old => ({...old, ...data})); + queryClient.setQueryData(['user'], old => ({...old, ...response.json})); }, }} > @@ -768,15 +764,13 @@ confirm={value => value === 'delete' ? 'This will permanently delete all data!' ```tsx import {useMutation} from '@tanstack/react-query'; -import {fetchMutation} from 'sentry/utils/queryClient'; +import {apiFetch} from 'sentry/utils/api/apiFetch'; function MyForm() { const mutation = useMutation({ mutationFn: (data: FormData) => { - return fetchMutation({ - url: '/endpoint/', - method: 'POST', - data, + return apiFetch({ + queryKey: ['/endpoint/', {method: 'POST', data}], }); }, onSuccess: () => { @@ -898,9 +892,9 @@ onSubmit: ({value}) => { return mutation.mutateAsync(value); }; -// ✅ Use mutations with fetchMutation and .catch(() => {}) +// ✅ Use mutations with apiFetch and .catch(() => {}) const mutation = useMutation({ - mutationFn: data => fetchMutation({url: '/users/', method: 'POST', data}), + mutationFn: data => apiFetch({queryKey: ['/users/', {method: 'POST', data}]}), }); onSubmit: ({value}) => { @@ -940,7 +934,7 @@ z.string().min(1, 'Email address is required'); ```tsx // ❌ Don't use toasts for auto-save status mutationOptions={{ - mutationFn: (data) => fetchMutation({url: '/user/', method: 'PUT', data}), + mutationFn: (data) => apiFetch({queryKey: ['/user/', {method: 'PUT', data}]}), onSuccess: () => { addSuccessMessage('Saved!'); // ❌ noisy and disruptive }, @@ -948,9 +942,9 @@ mutationOptions={{ // ✅ Rely on built-in inline indicators (spinner, checkmark, warning icon) mutationOptions={{ - mutationFn: (data) => fetchMutation({url: '/user/', method: 'PUT', data}), - onSuccess: (data) => { - queryClient.setQueryData(['user'], old => ({...old, ...data})); + mutationFn: (data) => apiFetch({queryKey: ['/user/', {method: 'PUT', data}]}), + onSuccess: (response) => { + queryClient.setQueryData(['user'], old => ({...old, ...response.json})); // No toast needed - AutoSaveForm shows a checkmark automatically }, }} @@ -963,14 +957,14 @@ Always update the data store or cache in `onSuccess`. Without this, toggling a f ```tsx // ❌ Don't forget to update the cache after auto-save mutationOptions={{ - mutationFn: (data) => fetchMutation({url: '/user/', method: 'PUT', data}), + mutationFn: (data) => apiFetch({queryKey: ['/user/', {method: 'PUT', data}]}), }} // ✅ Update React Query cache on success mutationOptions={{ - mutationFn: (data) => fetchMutation({url: '/user/', method: 'PUT', data}), - onSuccess: (data) => { - queryClient.setQueryData(['user'], old => ({...old, ...data})); + mutationFn: (data) => apiFetch({queryKey: ['/user/', {method: 'PUT', data}]}), + onSuccess: (response) => { + queryClient.setQueryData(['user'], old => ({...old, ...response.json})); }, }} ``` @@ -988,12 +982,12 @@ useMutation({...}) // ❌ Don't use generic types - breaks field type narrowing const opts = mutationOptions({ - mutationFn: (data: Record) => fetchMutation({...}), + mutationFn: (data: Record) => apiFetch({queryKey: [url, {...}]}), }); // ❌ Don't tie mutation type to the zod schema const opts = mutationOptions({ - mutationFn: (data: Partial>) => fetchMutation({...}), + mutationFn: (data: Partial>) => apiFetch({queryKey: [url, {...}]}), }); // ❌ Don't explicitly type context — it's inferred from onMutate return @@ -1004,7 +998,7 @@ type MyContext = {previousData: UserDetails}; // ✅ Use the API's data type on mutationFn, let everything else be inferred const opts = mutationOptions({ mutationFn: (data: Partial) => - fetchMutation({...}), + apiFetch({queryKey: [url, {...}]}), }); ``` diff --git a/.agents/skills/migrate-frontend-forms/SKILL.md b/.agents/skills/migrate-frontend-forms/SKILL.md index 9bf02490de3e..17d6a1822880 100644 --- a/.agents/skills/migrate-frontend-forms/SKILL.md +++ b/.agents/skills/migrate-frontend-forms/SKILL.md @@ -157,10 +157,11 @@ The `getData` function transformed field data before sending to the API. In the mutationFn: data => { // Transform data before API call (equivalent to getData) const transformed = {options: data}; - return fetchMutation({ - url: `/projects/${organization.slug}/${project.slug}/`, - method: 'PUT', - data: transformed, + return apiFetch({ + queryKey: [ + `/projects/${organization.slug}/${project.slug}/`, + {method: 'PUT', data: transformed}, + ], }); }, }} @@ -178,10 +179,8 @@ The `getData` function transformed field data before sending to the API. In the ```tsx mutationOptions={{ mutationFn: fieldData => { - return fetchMutation({ - url: `/projects/${org}/${project}/`, - method: 'PUT', - data: {options: fieldData}, // getData equivalent + return apiFetch({ + queryKey: [`/projects/${org}/${project}/`, {method: 'PUT', data: {options: fieldData}}], // getData equivalent }); }, }} @@ -195,21 +194,21 @@ The `mutationFn` should be typed with the API's data type (e.g., `Partial) => { - return fetchMutation({url: '/user/', method: 'PUT', data: {options: data}}); + return apiFetch({queryKey: ['/user/', {method: 'PUT', data: {options: data}}]}); }, }} // ❌ Don't tie mutation type to the zod schema mutationOptions={{ mutationFn: (data: Partial>) => { - return fetchMutation({url: '/user/', method: 'PUT', data: {options: data}}); + return apiFetch({queryKey: ['/user/', {method: 'PUT', data: {options: data}}]}); }, }} // ✅ Use the API's data type mutationOptions={{ mutationFn: (data: Partial) => { - return fetchMutation({url: '/user/', method: 'PUT', data: {options: data}}); + return apiFetch({queryKey: ['/user/', {method: 'PUT', data: {options: data}}]}); }, }} ``` @@ -221,7 +220,7 @@ Make sure the zod schema's types are compatible with (i.e., assignable to) the A ```tsx // ❌ Generics on useMutation — NEVER do this const mutation = useMutation({ - mutationFn: ([payload]) => fetchMutation({url, method: 'POST', data: payload}), + mutationFn: ([payload]) => apiFetch({queryKey: [url, {method: 'POST', data: payload}]}), }); // ❌ Generics on mutationOptions — NEVER do this either @@ -233,19 +232,17 @@ type MyContext = {changeId: string}; // ❌ RequestError as error generic — it's a type assertion in disguise // Other things can go wrong that would NOT yield a RequestError -// ✅ Type the mutationFn payload; fetchMutation carries the return type +// ✅ Type the mutationFn payload; apiFetch carries the return type const mutation = useMutation({ mutationFn: (payload: {codeMappingId: string; raw: string}) => - fetchMutation({ - url: `/projects/${org}/${project}/codeowners/`, - method: 'POST', - data: payload, + apiFetch({ + queryKey: [`/projects/${org}/${project}/codeowners/`, {method: 'POST', data: payload}], }), }); // ✅ Context is inferred from onMutate, error is Error by default mutationOptions({ - mutationFn: (variables: MyVars) => fetchMutation({...}), + mutationFn: (variables: MyVars) => apiFetch({queryKey: [url, {...}]}), onMutate: async () => { return {changeId: uniqueId()}; // context type inferred from this }, @@ -392,7 +389,7 @@ import {addSuccessMessage} from 'sentry/actionCreators/indicator'; schema={schema} initialValue={project.fingerprintingRules} mutationOptions={{ - mutationFn: data => fetchMutation({...}), + mutationFn: data => apiFetch({queryKey: [url, {...}]}), onSuccess: () => { // Custom success message (equivalent to saveMessage) addSuccessMessage(t('Changing fingerprint rules will apply to future events only.')); @@ -419,7 +416,7 @@ The `formatMessageValue` controlled how the changed value appeared in success to ```tsx mutationOptions={{ - mutationFn: data => fetchMutation({...}), + mutationFn: data => apiFetch({queryKey: [url, {...}]}), onSuccess: () => { // Just show the message, no value (equivalent to formatMessageValue: false) addSuccessMessage(t('Rules updated')); @@ -427,8 +424,8 @@ mutationOptions={{ }} // Or if you want to show a formatted value: -onSuccess: (data) => { - addSuccessMessage(t('Slug changed to %s', data.slug)); +onSuccess: (response) => { + addSuccessMessage(t('Slug changed to %s', response.json.slug)); }, ``` @@ -473,7 +470,7 @@ const form = useScrapsForm({ schema={schema} initialValue={settings.enabled} mutationOptions={{ - mutationFn: data => fetchMutation({...}), + mutationFn: data => apiFetch({queryKey: [url, {...}]}), onError: () => { // The field automatically shows error state via TanStack Query // If you need to reset the value, you can pass a reset callback @@ -530,7 +527,7 @@ const slugSchema = z.object({ function SlugForm({project}: {project: Project}) { const mutation = useMutation({ mutationFn: (data: {slug: string}) => - fetchMutation({url: `/projects/${org}/${project.slug}/`, method: 'PUT', data}), + apiFetch({queryKey: [`/projects/${org}/${project.slug}/`, {method: 'PUT', data}]}), }); const form = useScrapsForm({ @@ -726,7 +723,7 @@ For a nullable number field, use `z.number().nullable()` in the form schema and ## Migration Checklist - [ ] Replace JsonForm/FormModel with useScrapsForm or AutoSaveForm -- [ ] No generics on `useMutation` — type the `mutationFn` payload and use `fetchMutation` for the return type +- [ ] No generics on `useMutation` — type the `mutationFn` payload and use `apiFetch` for the return type - [ ] When using `useScrapsForm` with a Save button: mutation runs in `onSubmit`, triggered by `` (no form that's never submitted) - [ ] Convert field config objects to JSX AppField components - [ ] Validate conditionally required fields on the rendered `AppField`, with a permissive form schema diff --git a/static/app/api.tsx b/static/app/api.tsx index 5584aec964f0..50f9ef179c9a 100644 --- a/static/app/api.tsx +++ b/static/app/api.tsx @@ -411,7 +411,7 @@ export class Client { /** * Initiate a request to the backend API. * - * @deprecated Use `useApiQuery` or `useMutation` with `fetchDataQuery` and `fetchMutation` instead. + * @deprecated Use `useQuery` with `apiOptions`, or `useMutation` with `apiFetch` instead. * See https://develop.sentry.dev/frontend/network-requests/ for more. */ request(path: string, options: Readonly = {}): Request { @@ -665,7 +665,7 @@ export class Client { /** * Initiate a request to the backend API. * - * @deprecated Use `useApiQuery` or `useMutation` with `fetchDataQuery` and `fetchMutation` instead. + * @deprecated Use `useQuery` with `apiOptions`, or `useMutation` with `apiFetch` instead. * See https://develop.sentry.dev/frontend/network-requests/ for more. */ requestPromise( diff --git a/static/app/components/core/form/autoSaveForm.mdx b/static/app/components/core/form/autoSaveForm.mdx index bc743135fb16..07ba74b2a055 100644 --- a/static/app/components/core/form/autoSaveForm.mdx +++ b/static/app/components/core/form/autoSaveForm.mdx @@ -30,7 +30,7 @@ import {z} from 'zod'; import {AutoSaveForm} from '@sentry/scraps/form'; -import {fetchMutation} from 'sentry/utils/queryClient'; +import {apiFetch} from 'sentry/utils/api/apiFetch'; const schema = z.object({ displayName: z.string().min(1, 'Display name is required'), @@ -41,9 +41,9 @@ const schema = z.object({ schema={schema} initialValue={user.displayName} mutationOptions={{ - mutationFn: (data: Partial) => fetchMutation({url: '/user/', method: 'PUT', data}), - onSuccess: data => { - queryClient.setQueryData(['user'], old => ({...old, ...data})); + mutationFn: (data: Partial) => apiFetch({queryKey: ['/user/', {method: 'PUT', data}]}), + onSuccess: response => { + queryClient.setQueryData(['user'], old => ({...old, ...response.json})); }, }} > @@ -106,7 +106,7 @@ const schema = z.object({ initialValue="{}" mutationOptions={{ mutationFn: (data: {highlightContext: Record}) => - fetchMutation({url: '/project/', method: 'PUT', data}), + apiFetch({queryKey: ['/project/', {method: 'PUT', data}]}), }} > {field => ( @@ -155,11 +155,11 @@ Type the `mutationFn` with the API's data type, **not** the zod schema type. The ```jsx // ❌ Don't tie mutation type to the zod schema mutationFn: (data: Partial>) => - fetchMutation({url: '/user/', method: 'PUT', data}) + apiFetch({queryKey: ['/user/', {method: 'PUT', data}]}) // ✅ Use the API's data type mutationFn: (data: Partial) => - fetchMutation({url: '/user/', method: 'PUT', data}) + apiFetch({queryKey: ['/user/', {method: 'PUT', data}]}) ``` Make sure the zod schema's types are compatible with the API type. For example, if the API expects a string union like `'off' | 'low' | 'high'`, use `z.enum(['off', 'low', 'high'])` instead of `z.string()`. @@ -171,9 +171,9 @@ Always update the data store or React Query cache in `onSuccess` so the UI stays ```jsx mutationOptions={{ mutationFn: data => - fetchMutation({url: '/user/', method: 'PUT', data}), - onSuccess: data => { - queryClient.setQueryData(['user'], old => ({...old, ...data})); + apiFetch({queryKey: ['/user/', {method: 'PUT', data}]}), + onSuccess: response => { + queryClient.setQueryData(['user'], old => ({...old, ...response.json})); }, }} ``` diff --git a/static/app/components/core/form/form.mdx b/static/app/components/core/form/form.mdx index be618c886964..ff2588cf2354 100644 --- a/static/app/components/core/form/form.mdx +++ b/static/app/components/core/form/form.mdx @@ -324,12 +324,12 @@ Always use TanStack Query mutations (`useMutation`) for form submissions. Return ```jsx import {useMutation} from '@tanstack/react-query'; -import {fetchMutation} from 'sentry/utils/queryClient'; +import {apiFetch} from 'sentry/utils/api/apiFetch'; function MyForm() { const mutation = useMutation({ mutationFn: (data: FormData) => - fetchMutation({url: '/endpoint/', method: 'POST', data}), + apiFetch({queryKey: ['/endpoint/', {method: 'POST', data}]}), }); const form = useScrapsForm({ @@ -398,7 +398,7 @@ const defaultValues: z.input = { function MyForm() { const mutation = useMutation({ mutationFn: (data: z.output) => - fetchMutation({url: '/endpoint/', method: 'POST', data}), + apiFetch({queryKey: ['/endpoint/', {method: 'POST', data}]}), }); const form = useScrapsForm({ diff --git a/static/app/utils/queryClient.tsx b/static/app/utils/queryClient.tsx index 6687ee6245c2..8e06fe81e8d0 100644 --- a/static/app/utils/queryClient.tsx +++ b/static/app/utils/queryClient.tsx @@ -7,11 +7,10 @@ import type { import {useQuery} from '@tanstack/react-query'; import type {ApiResponse} from 'sentry/utils/api/apiFetch'; -import {apiFetch, QUERY_API_CLIENT} from 'sentry/utils/api/apiFetch'; +import {apiFetch} from 'sentry/utils/api/apiFetch'; import {selectJson} from 'sentry/utils/api/apiOptions'; import {normalizeQueryKey} from 'sentry/utils/api/apiQueryKey'; -import type {ApiQueryKey, QueryKeyEndpointOptions} from 'sentry/utils/api/apiQueryKey'; -import type {getApiUrl} from 'sentry/utils/api/getApiUrl'; +import type {ApiQueryKey} from 'sentry/utils/api/apiQueryKey'; import {RequestError} from 'sentry/utils/requestError/requestError'; export interface UseApiQueryOptions extends Omit< @@ -118,31 +117,3 @@ export function setApiQueryData( return updateResult?.json; } - -type ApiMutationVariables = { - method: 'PUT' | 'POST' | 'PATCH' | 'DELETE'; - url: ReturnType; - data?: Record; - options?: Pick< - QueryKeyEndpointOptions, - 'includeAllArgs' | 'query' | 'headers' | 'host' - >; -}; - -/** - * This method can be used as a default `mutationFn` with `useMutation` hook. - */ -export function fetchMutation( - variables: ApiMutationVariables -): Promise { - const {method, url, options, data} = variables; - - return QUERY_API_CLIENT.requestPromise(url, { - includeAllArgs: options?.includeAllArgs, - method, - query: options?.query, - headers: options?.headers, - host: options?.host, - data, - }); -}