Skip to content

Update BaseDataService to accommodate mutations, not just queries - #9324

Open
mcmire wants to merge 17 commits into
mainfrom
add-execute-mutation
Open

Update BaseDataService to accommodate mutations, not just queries#9324
mcmire wants to merge 17 commits into
mainfrom
add-execute-mutation

Conversation

@mcmire

@mcmire mcmire commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Explanation

Currently, BaseDataService has a fetchQuery method which is best used for making read-only requests ("queries" in TanStack Query parlance), but does not work as well for requests that change state on the server side ("mutations"). For instance, it makes sense for queries to be cached and retried, but not so much for mutations.

This commit adds a separate method, executeMutation, which accommodates mutations better. Besides the differences mentioned above, it also uses the mutation cache instead of the query cache.

References

https://consensyssoftware.atlassian.net/browse/WPC-1118

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

Note

Medium Risk
Introduces side-effecting mutation paths and changes cache-event payload shape; incorrect sync or double-submit behavior would affect server state, though retries are deliberately disabled for mutations.

Overview
Adds executeMutation on BaseDataService so subclasses can run server-mutating work through TanStack’s mutation cache instead of fetchQuery. Mutations use circuit breaker only (no retry policy), optional Superstruct validation via processMutationResponse, and export MutationKey.

:cacheUpdated / :cacheUpdated:${hash} payloads now include objectType: 'query' | 'mutation', with mutation cache subscribe/unsubscribe, targeted dehydration on publish, and destroy() tearing down mutation GC timers.

In @metamask/react-data-query, createUIQueryClient wires a default mutationFn from the messenger, syncs service mutation state with hydrateMutations (in-place updates to avoid unbounded duplicate mutations), and tracks mutation observer counts for subscriptions. useMutation is exported with retry: false by default, mirroring the query hooks pattern.

Reviewed by Cursor Bugbot for commit f07ef66. Bugbot is set up for automated code reviews on this repo. Configure here.

@mcmire
mcmire force-pushed the add-execute-mutation branch from 7e277f0 to 603d673 Compare June 30, 2026 22:07
'mutationKey' | 'mutationFn'
>,
): Promise<TData> {
const mutationCache = this.#queryClient.getMutationCache();

@mcmire mcmire Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QueryClient doesn't have a executeMutation method itself. I looked inside of the class, however, and saw that there was a getMutationCache method and followed where that led. This is not how useMutation works, which uses MutationObserver, but I don't know if that really matters. I figured it made more sense to mimic how fetchQuery works. But I'm not very familiar with TanStack Query, so if this is not the way we should be doing things, I'm happy to change this.

@mcmire mcmire Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked into this further and while this is not how useMutation works, it is how MutationObserver works. You can see that here:

https://github.com/TanStack/query/blob/4d8da1e29e97ad5b0c151822d4962849515b4478/packages/query-core/src/mutationObserver.ts#L136

So, we aren't really diverging from TanStack Query as much as I implied before.

);
}

async addFollower(followerId: string): Promise<AddFollowerResponse> {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adapted from SocialService:

async follow(options: FollowOptions): Promise<FollowResponse> {

@mcmire
mcmire marked this pull request as ready for review June 30, 2026 22:14
@mcmire
mcmire requested a review from a team as a code owner June 30, 2026 22:14
@mcmire
mcmire temporarily deployed to default-branch June 30, 2026 22:14 — with GitHub Actions Inactive
Comment thread packages/base-data-service/tests/mocks.ts Outdated
Comment thread packages/base-data-service/src/BaseDataService.ts Outdated
const mutation = mutationCache.build(this.#queryClient, {
...options,
mutationFn: (context) =>
this.#policy.execute(() => options.mutationFn(context)),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, are we safe to apply the policy as-is to mutations?

Just wondering if there could be a problem with accidentally doing double the mutation with retries 🤔

@mcmire mcmire Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, good point. We're still making an API request, so I feel like we would still want to have some kind of retry logic. But maybe it needs to be a little different than the logic used for GET requests.

I wonder if the default retry policy for createServicePolicy is too liberal. Right now, if the function you pass to the policy throws any error, then the function will get run again.

In RpcService we only retry connection errors, JSON parse errors, HTTP server errors (5xx), timeout errors, and "connection reset" errors. I wonder if BaseDataService should configure createServicePolicy such that it does the same thing?

Then I would be less worried here, because at that point, either we never make the request, or we do make the request but the server returns a 5xx. And in that case I feel like we ought to assume that the server is well-behaved, i.e. won't attempt to write to a database if it runs into an error. (If the server is not well-behaved, it shouldn't be our fault — the engineer writing the data service should know that and account for it.)

What do you think about that idea?

@FrederikBolding FrederikBolding Jul 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Improving the defaults to be more "targeted" to BaseDataService makes sense to me. Though I still would be a bit worried that developers configure the service policy mainly for GET requests and don't realize how it may impact a PUT 🤔

@mcmire mcmire Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't expect that most developers would configure the service policy, I would expect them to go with the defaults. But that's a good point. Should we have two kinds of service policies, one for queries and another for mutations? Then if a developer does configure a service policy, it should be more obvious what it's used for, and maybe it will cause them to think about it more critically. Or maybe this is solvable via documentation: we can add an "advanced" section to the tutorial/guide on data services that talks about the service policy, and we can remind the reader to consider non-GET requests when configuring it.

@FrederikBolding FrederikBolding Jul 6, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsure if a separate service policy or modifications to the default service policy would be preferred. We could consider a default isServiceFailure function that changes depending on the type of request for example? But maybe a separate policy for maximum flexibility is preferred 🤔

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about this some more, I've see realized that it makes more sense to have one policy instead of two. All requests to an API should share one circuit and one counter to keep track of whether the circuit should break. Queries and mutations shouldn't be treated specially. I'll see if I figure out that path tomorrow.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps it is enough to find a way to disable retrying for mutations?

@mcmire mcmire Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess that approach could work to get this PR merged, and then we could further refine it in another PR if we need to.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've disabled retrying for mutations by only using the circuit breaker policy to wrap the mutationFn instead of using the circuit breaker and retry policies.

* Additionally `retry` and `retryDelay` are not available, retries can be customized using the `servicePolicyOptions`.
* @returns The mutation results.
*/
protected async executeMutation<

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mutation cache is separate from the query cache, should we sync it with the UI as well?

E.g. https://github.com/MetaMask/core/blob/main/packages/base-data-service/src/BaseDataService.ts#L145-L152

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooh good point. Okay I'll make this change.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've made changes to createUIQueryClient to accommodate mutations.

@mcmire
mcmire marked this pull request as draft July 9, 2026 21:01
@mcmire

mcmire commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Moving this PR back to draft. There's still more work to do here in order to properly support mutations.

@mcmire
mcmire changed the base branch from main to fix-messenger-adapter-type July 27, 2026 18:16
Base automatically changed from fix-messenger-adapter-type to main July 30, 2026 22:21
pull Bot pushed a commit to Reality2byte/core that referenced this pull request Jul 31, 2026
## Explanation

<!--
Thanks for your contribution! Take a moment to answer these questions so
that reviewers have the information they need to properly understand
your changes:

* What is the current state of things and why does it need to change?
* What is the solution your changes offer and how does it work?
* Are there any changes whose purpose might not obvious to those
unfamiliar with the domain?
* If your primary goal was to update one package but you found you had
to update another one along the way, why did you do so?
* If you had to upgrade a dependency, why did you do so?
-->

`createUIQueryClient` takes a messenger that is too broadly typed: it
does not require that the actions and events that the messenger can
access are actually scoped to the given data services. This was actually
causing a type error in `createUIQueryClient.test.ts` — which replicates
realistic usage of `createUIQueryClient` — but neither ESLint nor Jest
caught it. This commit fixes the `MessengerAdapter` type so the type
error goes away and adds a special test file we can run with `tsc` to
ensure it doesn't pop up again.

## References

<!--
Are there any issues that this pull request is tied to?
Are there other links that reviewers should consult to understand these
changes better?
Are there client or consumer pull requests to adopt any breaking
changes?

For example:

* Fixes #12345
* Related to #67890
-->

This is blocking MetaMask#9324.

https://consensyssoftware.atlassian.net/browse/WPC-1171

## Checklist

- [x] I've updated the test suite for new or updated code as appropriate
- [x] I've updated documentation (JSDoc, Markdown, etc.) for new or
updated code as appropriate
- [x] I've communicated my changes to consumers by [updating changelogs
for packages I've
changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md)
- [x] I've introduced [breaking
changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md)
in this PR and have prepared draft pull requests for clients and
consumer packages to resolve them
- Extension PR:
MetaMask/metamask-extension#44498
  - Mobile PR: MetaMask/metamask-mobile#33450

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Breaking TypeScript contract for `createUIQueryClient` and exported
messenger adapter shapes may fail consumer builds until adapters are
retyped; runtime query/invalidation behavior is largely unchanged.
> 
> **Overview**
> **`createUIQueryClient`** now takes a **readonly** data-service name
tuple and a **`MessengerAdapter`** scoped to those names: `call` only
accepts `` `${Service}:${string}` `` actions (with `unknown[]` params
instead of `Json[]`), and `subscribe` / `unsubscribe` only accept
granular `` `:cacheUpdated:${hash}` `` events. Runtime checks use the
same name guards; query and invalidation paths no longer cast messenger
arguments through `Json`.
> 
> **`@metamask/base-data-service`** publicly exports
**`DataServiceActions`** and **`DataServiceEvents`** so consumers can
type messengers against data services.
> 
> **Testing / repo hygiene:** `@metamask/react-data-query` adds
**tstyche** (`createUIQueryClient.tst.ts`), Jest coverage for adapter
proxying and invalidation forwarding, and build excludes `*.tst.ts`.
**`yarn.config.cjs`** centralizes **`expectTestScripts`** so workspaces
with `test:types` use `test:unit` + tstyche (messenger scripts renamed
to match).
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
fa890ae. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
@mcmire
mcmire force-pushed the add-execute-mutation branch from 5ce00ba to ec84edc Compare August 4, 2026 04:23
@mcmire

mcmire commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

I just realized that it's probably best if I wait until we upgrade base-data-service to @tanstack/query-core v5. There are some slightly API differences in v5 and I don't want to have to mimic v4 only to then have to migrate to v5 anyway.

@mcmire
mcmire force-pushed the add-execute-mutation branch from ec84edc to e9cba53 Compare August 28, 2026 20:41
@mcmire
mcmire changed the base branch from main to enable-typechecking-for-react-data-query August 28, 2026 20:42
@mcmire

mcmire commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

Almost done. Working through the tests to make sure that the way we test mutations is very similar to the way we've tested queries.

Base automatically changed from enable-typechecking-for-react-data-query to main September 2, 2026 13:42
@mcmire
mcmire force-pushed the add-execute-mutation branch 6 times, most recently from 9162ced to 4c370ee Compare September 2, 2026 19:18
Currently, `BaseDataService` has a `fetchQuery` method which is best
used for making read-only requests ("queries" in TanStack Query
parlance), but does not work as well for requests that change state on
the server side ("mutations"). For instance, queries are cacheable, but
mutations are not.

This commit adds a separate method, `executeMutation`, which
accommodates mutations better. Its implementation is different from
`fetchQuery` as it uses the mutation cache instead of the query cache.
Also, retries are disabled.
@mcmire
mcmire force-pushed the add-execute-mutation branch from 4c370ee to eb74071 Compare September 2, 2026 19:30
Comment thread packages/react-data-query/src/loggers.ts Outdated
Comment thread packages/react-data-query/src/createUIQueryClient.ts Outdated
Comment thread packages/react-data-query/src/createUIQueryClient.test.ts
Comment thread packages/react-data-query/src/createUIQueryClient.ts
* @param client - The UI query client whose mutation cache should be hydrated.
* @param dehydratedState - The dehydrated state emitted by the data service.
*/
function hydrateMutations(

@mcmire mcmire Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kinda had AI write this function to see what it would come up with. It's interesting but not 100% sure it's the right direction.

@mcmire mcmire Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After thinking about this some more, this definitely doesn't seem right. We don't want migrations to be unique; they aren't unique in the parent mutation cache and this mutation cache ultimately needs to have the same contents. It is definitely tricky to make that happen here but I'll think of a different approach.

debounce(
() => {
this.#persistCache().catch(
/* istanbul ignore next */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems fine to keep ignoring IMO

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I accidentally deleted this. Restored in cbc2795.

Comment on lines +492 to +494
// Note that we purposely only use the circuit breaker policy
// and not the circuit breaker and retry policies, as we don't want
// to retry mutations.

@FrederikBolding FrederikBolding Sep 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment seems a bit confusing, maybe just a gap in my understanding: "we purposely only use the circuit breaker policy and not the circuit breaker"?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, this was confusing. Updated in 35836ff.

*/
init(): void {
this.#loadCache().catch(
/* istanbul ignore next */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I accidentally deleted this. Restored in cbc2795.

Comment on lines +550 to +556
// `QueryClient.clear()` clears both caches, but `MutationCache.clear()` only
// drops its references to mutations without clearing their pending
// garbage-collection timers. We destroy each mutation first so those timers
// are cleared and do not keep the process alive.
for (const mutation of this.#queryClient.getMutationCache().getAll()) {
mutation.destroy();
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really? QueryClient.clear seemed to clear up all timers for queries 🤔

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it seems that there is a difference in behavior between QueryClient.clear and MutationCache.clear:

});
}

async createDataDeletionTask(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be on the messenger?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I missed this. Added in 1366d7f.

if (
event.mutation &&
['added', 'updated', 'removed'].includes(event.type) &&
event.mutation.options.mutationKey !== undefined

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How/when can the key be undefined?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike queries, a mutation can be created without a mutationKey, i.e. mutationKey is optional in MutationOptions: https://github.com/TanStack/query/blob/4d8da1e29e97ad5b0c151822d4962849515b4478/packages/query-core/src/types.ts#L1112. I am not really sure why this is. I assume it is because fundamentally mutations are not deduplicated like queries are, so maybe there isn't strictly a need for a key to exist.

// content type as arguments (i.e. `Struct` is contravariant in its content type).
// The only way to get around that it to use `any`.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TStruct extends Struct<any> | undefined = undefined,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
TStruct extends Struct<any> | undefined = undefined,
TDataStruct extends Struct<any> | undefined = undefined,

IMO to indicate it is tied to TData

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, makes sense. Updated in 49f169f.

Comment on lines +458 to +463
// We have to use `Struct<any>` here, as using `Struct<TMutationFnData>`
// (or even `Struct<unknown>`) would reject a more concrete, "real world" struct.
// The reason is that `Struct` is an object type with methods that take its
// content type as arguments (i.e. `Struct` is contravariant in its content type).
// The only way to get around that it to use `any`.
// eslint-disable-next-line @typescript-eslint/no-explicit-any

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't follow this, why can't we do the same as fetchQuery?

@mcmire mcmire Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can, but I think using Struct<any> is more future-proof (and I think we will want to update fetchQuery to match).

The reason we can get away with using Struct<TQueryFnData> for fetchQuery right now is that in ExampleDataService, the return type of our queryFns is any (because response.json() is any), so we don't get any type errors when we call fetchQuery.

For the new methods that call executeMutation, however, I wanted to use a more realistic return type for mutationFn. My thought was that if in the future we introduce some kind of successfullyFetchJson helper to replace some of the boilerplate in queryFn, and we have this return a concrete type like Promise<Json>, then once engineers start to use it, it will break fetchQuery.

Therefore, in both addFollower and createDataDeletionTask, I typecast the result to Promise<Json>, e.g.:

return response.json() as Promise<Json>;

This forced me to use Struct<any> rather than Struct<TMutationFnData>.

What do you think?

TData = TStruct extends Struct<infer StructType>
? StructType
: TMutationFnData,
TError = unknown,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
TError = unknown,
TError = DefaultError,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Fixed in 4ef544e.

responseStruct?: TStruct;
}): Promise<TData> {
const mutationCache = this.#queryClient.getMutationCache();
const mutation = mutationCache.build<

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this overwrite existing mutations with the same key?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to add hooks too which enforce

const DATA_SERVICE_QUERY_DEFAULTS = {
staleTime: 0,
retry: false,
};
?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I forgot about this. Added in ba70f30.

@MajorLift MajorLift left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey just wanted to raise that the QueryClient API surface needs to be guarded/blocked for the ADR 0020 option A paradigm to not be bypassable. (see #8530 (block the QueryClient API surface in UI), #8531 (replacement implementations for the blocked APIs) on this). also different concern but #8552 (tracing observability in BaseDataService) is relevant as well.

Since this PR is extending QueryClient to cover mutations, I think some of the items below are worth settling here rather than as a follow up, since anything exposed here will be directly availble to feature teams. The nits are less critical. lmk your thoughts!

Comment on lines +435 to +436
const defaultedOptions = originalDefaultMutationOptions(options);
defaultedOptions.mutationFn ??= async (): Promise<unknown> => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

  • Nullish-assign lets a caller-supplied mutationFn win on a data-service mutationKey, so the write never reaches the service.
  • The query path has the same runtime shape, but useQuery strips queryFn via OmitKeyof. There is no mutation wrapper, so this path has neither guard.

#8530 (Block QueryClient API surface in UI) enumerates QueryClient instance methods only. This hole is in the options resolution path, so implementing that ticket as written would not close it.

Suggested guard, scoped so non-data-service mutations keep working:

Suggested change
const defaultedOptions = originalDefaultMutationOptions(options);
defaultedOptions.mutationFn ??= async (): Promise<unknown> => {
const defaultedOptions = originalDefaultMutationOptions(options);
const mutationAction = defaultedOptions.mutationKey?.[0];
const isDataServiceMutation =
typeof mutationAction === 'string' &&
isRecognizedDataServiceAction(mutationAction);
assert(
!(isDataServiceMutation && defaultedOptions.mutationFn),
'A mutation whose `mutationKey` names a data service action must not define its own `mutationFn`. The request is dispatched to the service via the messenger.',
);
defaultedOptions.mutationFn ??= async (): Promise<unknown> => {

@mcmire mcmire Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm. I wonder about whether the scope of the ticket you linked to is fully accurate. It's possible that we didn't think about all of the ways that TanStack Query can be used, but I can tell you this much: In the original design of this package, we implemented queries so that if you called useQuery with just a queryKey then it was expected that the query key matched a data service action (via a runtime check). However, you were allowed to override this behavior (to a certain degree) by passing a custom queryFn, and inside of this function you could do what you wanted. I tried to implement the same behavior with mutations.

If we implement the changes you suggest, then it sounds like even if mutationFn is specified then mutationKey will always need to refer to a data service action. Is this true or am I misunderstanding?

Comment thread packages/react-data-query/CHANGELOG.md Outdated
### Added

- Update `createUIQueryClient` to add support for executing mutations ([#9324](https://github.com/MetaMask/core/pull/9324))
- Provided an action in your data service uses `BaseDataService.executeMutation` to make the request instead of `fetchQuery`, you can now use `useMutation` in your UI files and pass a reference to the action as the mutation key.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

  • This line points consumers at useMutation, but the package exports only useQuery and useInfiniteQuery, so in practice that means the raw hook from @tanstack/react-query.
  • Either we ship a wrapper alongside useQuery, or we drop the sentence.

I would take the wrapper. #8530 (Block QueryClient API surface in UI) names the OmitKeyof wrappers as one of the two mechanisms keeping ownership in the background, and it describes itself as "currently dormant (DATA_SERVICES = [])" while that list on main holds MoneyAccountBalanceService and MoneyAccountApiDataService.

It tracks useQuery closely. TVariables is pinned to Record<never, never> to match executeMutation, which always calls mutation.execute({}):

import { MutationKey } from '@metamask/base-data-service';
import {
  useMutation as useMutationTanStack,
  OmitKeyof,
  UseMutationOptions,
  UseMutationResult,
  DefaultError,
} from '@tanstack/react-query';

export function useMutation<
  TData = unknown,
  TError = DefaultError,
  TOnMutateResult = unknown,
  TMutationKey extends MutationKey = MutationKey,
>(
  options: OmitKeyof<
    UseMutationOptions<TData, TError, Record<never, never>, TOnMutateResult>,
    'mutationFn' | 'retry' | 'retryDelay'
  > & { mutationKey: TMutationKey },
): UseMutationResult<TData, TError, Record<never, never>, TOnMutateResult> {
  return useMutationTanStack(options);
}

That first import needs MutationKey out of the barrel first. It is declared and exported from BaseDataService.ts but does not reach index.ts, so consumers cannot import it the way they import QueryKey. That file is not in this PR, so it needs a one-line addition alongside QueryKey.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was an oversight. I've added the wrapper in ba70f30.

@@ -452,7 +595,7 @@ export class BaseDataService<
this.#messenger.publish(
`${this.name}:cacheUpdated:${hash}` as const,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Queries and mutations share one hash namespace across the boundary. This event is ${name}:cacheUpdated:${hash} for both, with hash being queryHash for one and hashKey(mutationKey) for the other, QueryKey and MutationKey are aliases of the same Key, and on the UI side a single subscriptions map is read and written by both cache blocks.

If a service ever registers one action name as both a query key and a mutation key, the mutation block's if (!hasSubscription) skips subscribing and its observerRemoved branch deletes the query's listener. Nothing in the example service collides today, so this is latent. It also lands on #8530 (Block QueryClient API surface in UI), whose guards are specified as key-prefix checks and cannot tell the two apart either.

The rename is not consumer-visible. ui/contexts/query-client.ts in the extension and ReactQueryService.ts in mobile both pass an opaque subscribe(event, callback) adapter, so neither hardcodes the event string.

Prefixing the hash with the object type here and in the subscriptions map is cheapest while both sides are in one diff.

@mcmire mcmire Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. I can see how sharing a cache key would create problems. Maybe the way I've gone about this is wrong. We probably do at least want to prefix the type but I wonder if more changes are needed here given the fundamental differences between queries and mutations. I'll take a closer look.

@mcmire mcmire Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a service ever registers one action name as both a query key and a mutation key,

After thinking about this, I believe that this would in practice be impossible. The query or mutation key that a method passes to fetchQuery or executeMutation needs to match the name of the method itself — or really, needs to match the messenger action type that represents the method — otherwise it won't be callable via the query client. So there's no real way that two methods could share the same query or mutation key.

That said, we don't really enforce this, and it is definitely odd. I have added an objectType property to the :cacheUpdated event and then added a check for this on the query/mutation cache subscription side to ensure that changes to one kind of cache don't bleed over into the other kind. But perhaps more changes are needed here. I'll think more about this.

Comment on lines +447 to +455
* Execute a mutation (e.g. a request that is expected to change server-side data).
* Unlike `fetchQuery`, the request will not be cached or retried.
*
* @param options - The options defining the mutation. Keep in mind that `mutationKey` and `mutationFn` are required when using data services.
* Additionally, `retry` and `retryDelay` are not available.
* @param options.mutationFn - The mutation function.
* @param options.responseStruct - An optional struct for validating the response of the mutation function.
* @returns The mutation results.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ADR 0020 (Adopt TanStack Query for External API Data) records that background hydrate calls query.setState and overwrites optimistic values written with setQueryData, and that coordinating it to skip keys with pending mutations "is non-trivial and not built into the proxy model". This PR adds the mutation lifecycle and the query hydration path still calls plain hydrate, so the canonical onMutate to setQueryData to rollback pattern does not work here. #8530 (Block QueryClient API surface in UI) is slated to block setQueryData on data-service keys and #8531 (Expose replacement implementations of QueryClient APIs in UI) tracks the sanctioned alternative.

A line here saying optimistic updates on data-service keys are unsupported would stop teams adopting the pattern before that replacement lands.

@mcmire mcmire Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, interesting. I was not aware of setQueryData or onMutate. You're probably right that we don't fully support these use cases. I'm not sure how trivial it is to add that support (you may have already done some research). If we can't do it, then noting it in the JSDoc definitely makes sense.

Comment on lines +94 to +131
function hydrateMutations(
client: QueryClient,
dehydratedState: DehydratedState,
): void {
const mutationCache = client.getMutationCache();

for (const dehydratedMutation of dehydratedState.mutations) {
const { mutationKey, state } = dehydratedMutation;

// A data service only publishes cache updates for mutations that have a
// `mutationKey`, so we can disregard the case in which the key is not set.
// istanbul ignore next
if (!mutationKey) {
continue;
}

const existingMutation = mutationCache.find({ mutationKey });

// A UI query client only subscribes to a mutation key's cache updates after
// it has built a mutation for that key, so there is always a matching
// mutation to update in place, and we can disregard the case in which there
// is not.
// istanbul ignore else
if (existingMutation) {
existingMutation.state = state;
mutationCache.notify({
type: 'updated',
mutation: existingMutation,
action: deriveMutationAction(state),
});
}
}
}

/**
* Build the `notify` action that describes a mutation's current state.
*
* @param state - The synced mutation state.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

  • The state assignment lands, but mutationCache.notify does not reach MutationObserver, so useMutation will not re-render from a synced update.
  • observerAdded only fires from mutate(), so a client that has not mutated is never subscribed in the first place.
  • A test where only one client mutates would settle which behavior this ships.

existingMutation.state = state followed by mutationCache.notify(...) updates the cache object and reaches cache-level listeners. It does not reach MutationObserver, which has no subscription to the cache. Its only refresh path is onMutationUpdate, called from Mutation.#dispatch, which is private, and Mutation exposes no public setState. The query side works because Query.setState is public and dispatches to observers, which is what hydrate uses.

The second half sits upstream of this function. observerAdded fires from Mutation.addObserver, which MutationObserver calls only inside mutate(), so constructing an observer emits nothing. The subscription block keys on observerAdded, which leaves a client with useMutation mounted but not yet fired unsubscribed from cacheUpdated:<hash>.

Driving a bare @tanstack/query-core@5.90.20 client through both paths, output first and the script that produced it below it:

observerAdded after constructing an observer:     0
observerAdded after mutate():                     1
observer result after state assign + notify:      success / "local"        <- stale
mutation.state after the same:                    success / "FROM_SERVICE" <- cache did update
probe
import { QueryClient, MutationObserver } from '@tanstack/query-core';

const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
const cache = client.getMutationCache();

let observerAdded = 0;
cache.subscribe((e) => { if (e.type === 'observerAdded') observerAdded++; });

const obs = new MutationObserver(client, {
  mutationKey: ['Svc:doThing'],
  mutationFn: async () => 'local',
});
console.log('after constructing an observer:', observerAdded);

await obs.mutate();
console.log('after mutate():', observerAdded);

const m = cache.find({ mutationKey: ['Svc:doThing'] });
m.state = { ...m.state, status: 'success', data: 'FROM_SERVICE' };
cache.notify({ type: 'updated', mutation: m, action: { type: 'success', data: 'FROM_SERVICE' } });
await new Promise((r) => setTimeout(r, 20));

console.log('observer result:', obs.getCurrentResult().status, obs.getCurrentResult().data);
console.log('mutation.state: ', m.state.status, m.state.data);

The two tests that look like they cover this, success and error, subscribe to clientB.getMutationCache() and assert on the pushed action, which is the path that does work. Both clients mutate independently in each, so each one's own dispatch supplies the right answer regardless.

Let's add the case where only client A mutates:

it('reflects a mutation executed by another client in an observer that did not initiate it', async () => {
  const { clientA, clientB, service } = createClients();
  mockAddFollowerRequest();

  const observerA = new TanStackQueryMutationObserver<AddFollowerResponse>(
    clientA, { mutationKey: addFollowerMutationKey });
  const observerB = new TanStackQueryMutationObserver<AddFollowerResponse>(
    clientB, { mutationKey: addFollowerMutationKey });
  observerB.subscribe(() => undefined);

  await observerA.mutate();

  expect(observerB.getCurrentResult().status).toBe('success');
  expect(observerB.getCurrentResult().data)
    .toStrictEqual(DEFAULT_ADD_FOLLOWER_REPLY.body);

  observerA.reset(); observerB.reset(); service.destroy();
});

continue;
}

const existingMutation = mutationCache.find({ mutationKey });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment above this line holds: there is always a matching mutation. Which match drifts.

MutationCache.find is getAll().find(...) over an insertion-ordered Set, so it returns the oldest mutation carrying that key, while MutationObserver.mutate() builds a fresh Mutation on every call and points #currentMutation at the newest. Two mutate() calls on one key give two mutations, ids 1 and 2, with find() returning 1. does not accumulate duplicate mutations in the cache passes because each client mutates exactly once.

Extending it to mutate twice with different mock replies, asserting the synced state matches the second, would cover it.

Comment on lines +492 to +494
// Note that we purposely only use the circuit breaker policy
// and not the circuit breaker and retry policies, as we don't want
// to retry mutations.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this reads as excluding the circuit breaker from itself.

Suggested change
// Note that we purposely only use the circuit breaker policy
// and not the circuit breaker and retry policies, as we don't want
// to retry mutations.
// Note that we deliberately execute through the circuit breaker policy
// alone, rather than through `#policy.execute`, which composes the
// circuit breaker with the retry policy. Mutations are not retried.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, this was confusing. Updated in 35836ff.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 35836ff. Configure here.

Comment thread packages/react-data-query/src/hooks.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants