From eb7407187be6b6ff12b90a5d945b4af0cf710386 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Tue, 30 Jun 2026 15:44:59 -0600 Subject: [PATCH 01/34] Update BaseDataService to accommodate mutations, not just queries 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. --- packages/base-data-service/CHANGELOG.md | 5 + packages/base-data-service/jest.config.js | 5 +- .../src/BaseDataService.test.ts | 344 ++++++++++++++++-- .../base-data-service/src/BaseDataService.ts | 187 +++++++++- packages/base-data-service/src/loggers.ts | 5 + packages/base-data-service/src/utils.ts | 29 +- .../ExampleDataService-method-action-types.ts | 8 +- .../tests/ExampleDataService.ts | 110 +++++- packages/base-data-service/tests/mocks.ts | 66 +++- .../base-data-service/tsconfig.build.json | 8 +- packages/base-data-service/tsconfig.json | 9 +- packages/react-data-query/CHANGELOG.md | 5 + packages/react-data-query/package.json | 1 + .../src/createUIQueryClient.test.ts | 281 ++++++++++++-- .../src/createUIQueryClient.ts | 135 ++++++- packages/react-data-query/src/loggers.ts | 5 + yarn.lock | 1 + 17 files changed, 1109 insertions(+), 95 deletions(-) create mode 100644 packages/base-data-service/src/loggers.ts create mode 100644 packages/react-data-query/src/loggers.ts diff --git a/packages/base-data-service/CHANGELOG.md b/packages/base-data-service/CHANGELOG.md index 0cfa9bf8d00..7fb63bc7771 100644 --- a/packages/base-data-service/CHANGELOG.md +++ b/packages/base-data-service/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `executeMutation` to `BaseDataService` to allow for making server-state-mutating requests ([#9324](https://github.com/MetaMask/core/pull/9324)) + - These kinds of requests are never retried, unlike queries. + ### Changed - Bump `@metamask/utils` from `^11.11.0` to `^11.12.0` ([#10076](https://github.com/MetaMask/core/pull/10076)) diff --git a/packages/base-data-service/jest.config.js b/packages/base-data-service/jest.config.js index e45df2b6e59..15b4a822e8d 100644 --- a/packages/base-data-service/jest.config.js +++ b/packages/base-data-service/jest.config.js @@ -14,10 +14,13 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, + // v8 tends to work better for this package. + coverageProvider: 'v8', + // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 96.49, + branches: 100, functions: 100, lines: 100, statements: 100, diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index c647ac6e7f7..976412cec54 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -19,7 +19,9 @@ import { serviceName, } from '../tests/ExampleDataService.js'; import { + mockAddFollowerRequest, mockAssets, + mockCreateDataDeletionTaskRequest, mockTransactionsPage1, mockTransactionsPage2, mockTransactionsPage3, @@ -140,7 +142,136 @@ describe('BaseDataService', () => { expect(page2.data).not.toStrictEqual(page3.data); }); - it('emits `:cacheUpdated` events when cache is updated', async () => { + it('handles mutations that validate responses', async () => { + mockAddFollowerRequest(); + const messenger = createServiceMessenger(); + const service = new ExampleDataService(messenger); + + expect(await service.addFollower('1')).toStrictEqual({ + followed: [ + { + profileId: '550e8400-e29b-41d4-a716-446655440000', + address: '0x1234567890abcdef1234567890abcdef12345678', + name: 'TraderAlice', + imageUrl: 'https://example.com/avatar.png', + }, + ], + }); + }); + + it('handles mutations that do not validate responses', async () => { + mockCreateDataDeletionTaskRequest(); + const messenger = createServiceMessenger(); + const service = new ExampleDataService(messenger); + + expect(await service.createDataDeletionTask('1', '2')).toStrictEqual({ + status: 'ok', + regulateId: '99999', + }); + }); + + it('never retries mutations', async () => { + mockAddFollowerRequest({ + mockReply: { status: 504 }, + }); + mockAddFollowerRequest(); + const messenger = createServiceMessenger(); + const service = new ExampleDataService(messenger); + + await expect(service.addFollower('1')).rejects.toThrow('Mutation failed'); + expect(await service.addFollower('1')).toStrictEqual({ + followed: [ + { + profileId: '550e8400-e29b-41d4-a716-446655440000', + address: '0x1234567890abcdef1234567890abcdef12345678', + name: 'TraderAlice', + imageUrl: 'https://example.com/avatar.png', + }, + ], + }); + }); + + it('emits `:cacheUpdated` events when query cache entry is added', async () => { + const messenger = createServiceMessenger(); + const publishSpy = jest.spyOn(messenger, 'publish'); + const service = new ExampleDataService(messenger); + + await service.getAssets(MOCK_ASSETS); + + const queryKey = ['ExampleDataService:getAssets', MOCK_ASSETS]; + const hash = hashKey(queryKey); + const expectedState = { + mutations: [], + queries: [ + expect.objectContaining({ + state: expect.objectContaining({ + status: 'pending', + }), + }), + ], + }; + + expect(publishSpy).toHaveBeenNthCalledWith( + 1, + `ExampleDataService:cacheUpdated`, + { + type: 'added', + hash, + state: expectedState, + }, + ); + + expect(publishSpy).toHaveBeenNthCalledWith( + 2, + `ExampleDataService:cacheUpdated:${hash}`, + { + type: 'added', + state: expectedState, + }, + ); + }); + + it('emits `:cacheUpdated` events when mutation cache entry is added', async () => { + mockAddFollowerRequest(); + const messenger = createServiceMessenger(); + const service = new ExampleDataService(messenger); + const publishSpy = jest.spyOn(messenger, 'publish'); + + await service.addFollower('1'); + + const hash = hashKey(['ExampleDataService:addFollower', '1']); + const expectedState = { + queries: [], + mutations: [ + expect.objectContaining({ + state: expect.objectContaining({ + status: 'idle', + }), + }), + ], + }; + + expect(publishSpy).toHaveBeenNthCalledWith( + 1, + `ExampleDataService:cacheUpdated`, + { + type: 'added', + hash, + state: expectedState, + }, + ); + + expect(publishSpy).toHaveBeenNthCalledWith( + 2, + `ExampleDataService:cacheUpdated:${hash}`, + { + type: 'added', + state: expectedState, + }, + ); + }); + + it('emits `:cacheUpdated` events when query cache is updated', async () => { const messenger = createServiceMessenger(); const service = new ExampleDataService(messenger); @@ -151,48 +282,109 @@ describe('BaseDataService', () => { const queryKey = ['ExampleDataService:getAssets', MOCK_ASSETS]; const hash = hashKey(queryKey); + const expectedState = { + mutations: [], + queries: [ + expect.objectContaining({ + state: expect.objectContaining({ + status: 'success', + data: [ + { + assetId: + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + decimals: 18, + name: 'Dai Stablecoin', + symbol: 'DAI', + }, + { + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + decimals: 8, + name: 'Bitcoin', + symbol: 'BTC', + }, + { + assetId: 'eip155:1/slip44:60', + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + ], + }), + }), + ], + }; + + expect(publishSpy).toHaveBeenNthCalledWith( + 5, + `ExampleDataService:cacheUpdated`, + { + type: 'updated', + hash, + state: expectedState, + }, + ); expect(publishSpy).toHaveBeenNthCalledWith( 6, `ExampleDataService:cacheUpdated:${hash}`, { type: 'updated', - state: { - mutations: [], - queries: [ - expect.objectContaining({ - state: expect.objectContaining({ - status: 'success', - data: [ - { - assetId: - 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', - decimals: 18, - name: 'Dai Stablecoin', - symbol: 'DAI', - }, - { - assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', - decimals: 8, - name: 'Bitcoin', - symbol: 'BTC', - }, - { - assetId: 'eip155:1/slip44:60', - decimals: 18, - name: 'Ethereum', - symbol: 'ETH', - }, - ], - }), - }), - ], - }, + state: expectedState, + }, + ); + }); + + it('emits `:cacheUpdated` events when mutation cache is updated', async () => { + mockAddFollowerRequest(); + const messenger = createServiceMessenger(); + const service = new ExampleDataService(messenger); + const publishSpy = jest.spyOn(messenger, 'publish'); + + await service.addFollower('1'); + + const hash = hashKey(['ExampleDataService:addFollower', '1']); + const expectedState = { + queries: [], + mutations: [ + expect.objectContaining({ + state: expect.objectContaining({ + status: 'success', + data: { + followed: [ + { + profileId: '550e8400-e29b-41d4-a716-446655440000', + address: '0x1234567890abcdef1234567890abcdef12345678', + name: 'TraderAlice', + imageUrl: 'https://example.com/avatar.png', + }, + ], + }, + }), + }), + ], + }; + + expect(publishSpy).toHaveBeenNthCalledWith( + 5, + `ExampleDataService:cacheUpdated`, + { + type: 'updated', + hash, + state: expectedState, + }, + ); + + expect(publishSpy).toHaveBeenNthCalledWith( + 6, + `ExampleDataService:cacheUpdated:${hash}`, + { + type: 'updated', + state: expectedState, }, ); }); - it('emits `:cacheUpdated` events when cache entry is removed', async () => { + it('emits `:cacheUpdated` events when query cache entry is removed', async () => { const messenger = createServiceMessenger(); const service = new ExampleDataService(messenger); @@ -207,6 +399,58 @@ describe('BaseDataService', () => { const hash = hashKey(queryKey); + expect(publishSpy).toHaveBeenNthCalledWith( + 7, + `ExampleDataService:cacheUpdated`, + { + type: 'removed', + hash, + state: null, + }, + ); + + expect(publishSpy).toHaveBeenNthCalledWith( + 8, + `ExampleDataService:cacheUpdated:${hash}`, + { + type: 'removed', + state: null, + }, + ); + }); + + it('emits `:cacheUpdated` events when mutation cache entry is removed', async () => { + mockAddFollowerRequest(); + const messenger = createServiceMessenger(); + const service = new ExampleDataService(messenger); + const publishSpy = jest.spyOn(messenger, 'publish'); + + await service.addFollower('1'); + // Wait for GC + jest.runAllTimers(); + + const hash = hashKey(['ExampleDataService:addFollower', '1']); + expect(publishSpy).toHaveBeenCalledTimes(8); + expect(publishSpy).toHaveBeenNthCalledWith( + 7, + `ExampleDataService:cacheUpdated`, + { + type: 'removed', + hash, + state: null, + }, + ); + + expect(publishSpy).toHaveBeenNthCalledWith( + 7, + `ExampleDataService:cacheUpdated`, + { + type: 'removed', + hash, + state: null, + }, + ); + expect(publishSpy).toHaveBeenNthCalledWith( 8, `ExampleDataService:cacheUpdated:${hash}`, @@ -218,6 +462,7 @@ describe('BaseDataService', () => { }); it('does not emit events after being destroyed', async () => { + mockAddFollowerRequest(); const messenger = createServiceMessenger(); const service = new ExampleDataService(messenger); const publishSpy = jest.spyOn(messenger, 'publish'); @@ -225,10 +470,23 @@ describe('BaseDataService', () => { service.destroy(); await service.getAssets(MOCK_ASSETS); + await service.addFollower('1'); expect(publishSpy).toHaveBeenCalledTimes(0); }); + it('clears pending mutation garbage-collection timers when destroyed', async () => { + mockAddFollowerRequest(); + const messenger = createServiceMessenger(); + const service = new ExampleDataService(messenger); + const timerCountBeforeMutation = jest.getTimerCount(); + await service.addFollower('1'); + + service.destroy(); + + expect(jest.getTimerCount()).toBe(timerCountBeforeMutation); + }); + it('invalidates queries when requested', async () => { const messenger = createServiceMessenger(); const service = new ExampleDataService(messenger); @@ -258,7 +516,7 @@ describe('BaseDataService', () => { cleanAll(); }); - it('throws when fetchQuery response fails struct validation', async () => { + it('throws when query response fails struct validation', async () => { const messenger = createServiceMessenger(); const service = new ExampleDataService(messenger); @@ -270,6 +528,24 @@ describe('BaseDataService', () => { service.destroy(); }); + + it('throws when mutation response fails struct validation', async () => { + const messenger = createServiceMessenger(); + const service = new ExampleDataService(messenger); + + mockAddFollowerRequest({ + mockReply: { + status: 200, + body: { foo: 'bar' }, + }, + }); + + await expect(service.addFollower('1')).rejects.toThrow( + 'Mutation function for "ExampleDataService:addFollower" returned an unexpected response: At path: followed -- Expected an array value, but received: undefined.', + ); + + service.destroy(); + }); }); describe('service policy', () => { diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index eed44817ce0..186b6c09994 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -22,6 +22,7 @@ import { InfiniteQueryPageParamsOptions, InvalidateOptions, InvalidateQueryFilters, + MutationOptions, OmitKeyof, QueryClient, QueryClientConfig, @@ -29,6 +30,8 @@ import { WithRequired, dehydrate, hydrate, + hashKey, + MutationFunction, } from '@tanstack/query-core'; import deepEqual from 'fast-deep-equal'; import { debounce, DebouncedFunc } from 'lodash'; @@ -38,10 +41,28 @@ import { CreateServicePolicyOptions, ServicePolicy, } from './createServicePolicy.js'; -import { processQueryResponse } from './utils.js'; +import { createModuleLogger, projectLogger } from './loggers.js'; +import { processMutationResponse, processQueryResponse } from './utils.js'; -// Data service queries use the following format: ['ServiceActionName', ...params] -export type QueryKey = [string, ...Json[]] | readonly [string, ...Json[]]; +const log = createModuleLogger(projectLogger, 'BaseDataService'); + +/** + * Data service mutations and queries use the following format: + * `['${ServiceName}:${ActionName}', ...params]` + */ +type Key = [string, ...Json[]] | readonly [string, ...Json[]]; + +/* + * Data service queries use the following format: + * `['${ServiceName}:${ActionName}', ...params]` + */ +export type QueryKey = Key; + +/* + * Data service mutations use the following format: + * `['${ServiceName}:${ActionName}', ...params]` + */ +export type MutationKey = Key; /** * The supertype of all messengers, scoped to a namespace. @@ -103,12 +124,17 @@ export type DataServiceEvents = | DataServiceCacheUpdatedEvent | DataServiceGranularCacheUpdatedEvent; -// Defaults to apply to all data service queries if no default option specified +/* + * Defaults to apply to all data service queries if no default option specified. + */ const QUERY_CLIENT_DEFAULTS: DefaultOptions = { queries: { retry: false, staleTime: inMilliseconds(1, Duration.Minute), }, + mutations: { + retry: false, + }, }; export const STORAGE_SERVICE_KEY = 'cache'; @@ -163,6 +189,8 @@ export class BaseDataService< readonly #queryCacheUnsubscribe: () => void; + readonly #mutationCacheUnsubscribe: () => void; + readonly #debouncedPersist?: DebouncedFunc<() => void>; readonly #persistenceConfig?: PersistenceConfiguration; @@ -208,9 +236,16 @@ export class BaseDataService< defaultOptions: { queries: { ...QUERY_CLIENT_DEFAULTS.queries, + // We always provide defaultOptions in our tests. + /* c8 ignore next */ ...queryClientConfig.defaultOptions?.queries, }, - mutations: queryClientConfig.defaultOptions?.mutations, + mutations: { + ...QUERY_CLIENT_DEFAULTS.mutations, + // We always provide defaultOptions in our tests. + /* c8 ignore next */ + ...queryClientConfig.defaultOptions?.mutations, + }, }, }); @@ -223,7 +258,8 @@ export class BaseDataService< debounce( () => { this.#persistCache().catch( - /* istanbul ignore next */ + // We always provide this in our tests. + /* c8 ignore next */ (error) => this.#messenger.captureException?.(error), ); }, @@ -239,10 +275,33 @@ export class BaseDataService< this.#queryCacheUnsubscribe = this.#queryClient .getQueryCache() .subscribe((event) => { + log('Query cache event emitted', event); if (['added', 'updated', 'removed'].includes(event.type)) { this.#publishCacheUpdate( + 'query', + event.type as CacheUpdatedType, event.query.queryHash, + ); + + this.#debouncedPersist?.(); + } + }); + + log('Subscribing to mutation cache'); + this.#mutationCacheUnsubscribe = this.#queryClient + .getMutationCache() + .subscribe((event) => { + log('Mutation cache event emitted', event); + if ( + event.mutation && + ['added', 'updated', 'removed'].includes(event.type) && + event.mutation.options.mutationKey !== undefined + ) { + const mutationHash = hashKey(event.mutation.options.mutationKey); + this.#publishCacheUpdate( + 'mutation', event.type as CacheUpdatedType, + mutationHash, ); this.#debouncedPersist?.(); @@ -260,6 +319,7 @@ export class BaseDataService< * * @param options - The options defining the query. Keep in mind that `queryKey` and `queryFn` are required when using data services. * Additionally `retry` and `retryDelay` are not available, retries can be customized using the `servicePolicyOptions`. + * @param options.queryFn - The query function. * @param options.responseStruct - An optional struct for validating the response of the query function. * @returns The query results. */ @@ -272,6 +332,7 @@ export class BaseDataService< : TQueryFnData, TQueryKey extends QueryKey = QueryKey, >({ + queryFn, responseStruct, ...options }: WithRequired< @@ -287,9 +348,7 @@ export class BaseDataService< return this.#queryClient.fetchQuery({ ...options, queryFn: async (context) => { - const response = await this.#policy.execute(() => - options.queryFn(context), - ); + const response = await this.#policy.execute(() => queryFn(context)); return processQueryResponse(options.queryKey, response, responseStruct); }, }); @@ -390,6 +449,79 @@ export class BaseDataService< return result.pages[pageIndex]; } + /** + * 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. + */ + protected async executeMutation< + TMutationFnData extends Json, + // We have to use `Struct` here, as using `Struct` + // (or even `Struct`) 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 + TStruct extends Struct | undefined = undefined, + TData = TStruct extends Struct + ? StructType + : TMutationFnData, + TError = unknown, + TContext = unknown, + TMutationKey extends MutationKey = MutationKey, + >({ + mutationFn, + responseStruct, + ...options + }: OmitKeyof< + MutationOptions, TContext>, + 'retry' | 'retryDelay' | 'mutationKey' | 'mutationFn' + > & { + mutationKey: TMutationKey; + mutationFn: MutationFunction>; + responseStruct?: TStruct; + }): Promise { + const mutationCache = this.#queryClient.getMutationCache(); + const mutation = mutationCache.build< + TData, + TError, + Record, + TContext + >(this.#queryClient, { + ...options, + mutationFn: async (...args) => { + // 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. + const response = await this.#policy.circuitBreakerPolicy.execute(() => + mutationFn(...args), + ); + const data = responseStruct + ? processMutationResponse( + options.mutationKey, + response, + responseStruct, + ) + : response; + // Type assertion: `TData` is a conditional default that resolves to the + // struct's decoded type when a struct is provided and `TMutationFnData` + // otherwise, which mirrors the two branches above. TypeScript cannot + // relate a value to an unresolved conditional type parameter, so we + // assert the correspondence that this method's own generics guarantee. + return data as unknown as TData; + }, + }); + // We purposely pass an empty set of variables because this method is + // intended to be used internally by the data service, not end users, and + // the data service has full control of `mutationFn` anyway. + return await mutation.execute({}); + } + /** * Invalidate queries serviced by this data service. * @@ -409,7 +541,8 @@ export class BaseDataService< */ init(): void { this.#loadCache().catch( - /* istanbul ignore next */ + // We always provide captureException in our tests. + /* c8 ignore next */ (error) => this.#messenger.captureException?.(error), ); } @@ -421,29 +554,47 @@ export class BaseDataService< destroy(): void { this.#debouncedPersist?.cancel(); this.#queryCacheUnsubscribe(); + this.#mutationCacheUnsubscribe(); + // `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(); + } this.#queryClient.clear(); this.messenger.clearSubscriptions(); this.messenger.clearActions(); } /** - * Publish `cacheUpdated` events when a given query changes. + * Publish `cacheUpdated` events when the query or mutation cache is updated. * - * @param hash The hash of the query. - * @param type The type of cache update. + * @param objectType - The type of object updated ("query" or "mutation"). + * @param eventType - What happened to the query or mutation ("added" or "updated"). + * @param hash - The hash of the query or mutation. */ - #publishCacheUpdate(hash: string, type: CacheUpdatedType): void { + #publishCacheUpdate( + objectType: 'query' | 'mutation', + eventType: CacheUpdatedType, + hash: string, + ): void { const state = - type === 'added' || type === 'updated' + eventType === 'added' || eventType === 'updated' ? dehydrate(this.#queryClient, { - shouldDehydrateQuery: (query) => query.queryHash === hash, + shouldDehydrateQuery: (query) => + objectType === 'query' && query.queryHash === hash, + shouldDehydrateMutation: (mutation) => + objectType === 'mutation' && + mutation.options.mutationKey !== undefined && + hashKey(mutation.options.mutationKey) === hash, }) : null; this.#messenger.publish( `${this.name}:cacheUpdated` as const, { - type, + type: eventType, hash, state, } as DataServiceCacheUpdatedPayload, @@ -452,7 +603,7 @@ export class BaseDataService< this.#messenger.publish( `${this.name}:cacheUpdated:${hash}` as const, { - type, + type: eventType, state, } as DataServiceGranularCacheUpdatedPayload, ); diff --git a/packages/base-data-service/src/loggers.ts b/packages/base-data-service/src/loggers.ts new file mode 100644 index 00000000000..17fbe5289ac --- /dev/null +++ b/packages/base-data-service/src/loggers.ts @@ -0,0 +1,5 @@ +import { createProjectLogger, createModuleLogger } from '@metamask/utils'; + +export const projectLogger = createProjectLogger('base-data-service'); + +export { createModuleLogger }; diff --git a/packages/base-data-service/src/utils.ts b/packages/base-data-service/src/utils.ts index 5a9329f5a10..7da18054d4f 100644 --- a/packages/base-data-service/src/utils.ts +++ b/packages/base-data-service/src/utils.ts @@ -1,6 +1,6 @@ import { Struct, validate } from '@metamask/superstruct'; -import type { QueryKey } from './BaseDataService.js'; +import type { MutationKey, QueryKey } from './BaseDataService.js'; /** * Process query responses, validating them using Superstruct if a struct is defined. @@ -30,3 +30,30 @@ export function processQueryResponse( return result; } + +/** + * Process mutation responses, validating them using Superstruct if a struct is defined. + * + * @param mutationKey - The mutation key. + * @param response - The mutation response + * @param struct - The struct defining the schema for the mutation response. + * @returns The mutation response, coerced by Superstruct if needed. + * @throws If the mutation response does not match the struct. + * @template InputResponse - The type of the response data being validated, e.g. `Json`. + * @template OutputResponse - The type of the response data after validation, e.g. `FetchOrdersResponse`. + */ +export function processMutationResponse( + mutationKey: MutationKey, + response: InputResponse, + struct: Struct, +): OutputResponse { + const [error, result] = validate(response, struct); + + if (error) { + throw new Error( + `Mutation function for "${mutationKey[0]}" returned an unexpected response: ${error.message}.`, + ); + } + + return result; +} diff --git a/packages/base-data-service/tests/ExampleDataService-method-action-types.ts b/packages/base-data-service/tests/ExampleDataService-method-action-types.ts index b15943c584d..0a66bb9d735 100644 --- a/packages/base-data-service/tests/ExampleDataService-method-action-types.ts +++ b/packages/base-data-service/tests/ExampleDataService-method-action-types.ts @@ -15,9 +15,15 @@ export type ExampleDataServiceGetActivityAction = { handler: ExampleDataService['getActivity']; }; +export type ExampleDataServiceAddFollowerAction = { + type: `ExampleDataService:addFollower`; + handler: ExampleDataService['addFollower']; +}; + /** * Union of all ExampleDataService action types. */ export type ExampleDataServiceMethodActions = | ExampleDataServiceGetAssetsAction - | ExampleDataServiceGetActivityAction; + | ExampleDataServiceGetActivityAction + | ExampleDataServiceAddFollowerAction; diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts index 18b4d86da50..946aafbdae7 100644 --- a/packages/base-data-service/tests/ExampleDataService.ts +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -4,9 +4,16 @@ import { StorageServiceRemoveItemAction, StorageServiceSetItemAction, } from '@metamask/storage-service'; -import { object, number, string, array } from '@metamask/superstruct'; import { - CaipAssetType, + object, + number, + string, + array, + Infer, + nullable, + optional, +} from '@metamask/superstruct'; +import { CaipAssetTypeStruct, Duration, inMilliseconds, @@ -42,13 +49,6 @@ export type ExampleMessenger = Messenger< ExampleDataServiceEvents >; -export type GetAssetsResponse = { - assetId: CaipAssetType; - decimals: number; - name: string; - symbol: string; -}[]; - const GetAssetsResponseStruct = array( object({ assetId: CaipAssetTypeStruct, @@ -58,6 +58,8 @@ const GetAssetsResponseStruct = array( }), ); +export type GetAssetsResponse = Infer; + export type GetActivityResponse = { data: Json[]; pageInfo: { @@ -69,6 +71,19 @@ export type GetActivityResponse = { }; }; +export const AddFollowerResponseStruct = object({ + followed: array( + object({ + profileId: string(), + address: string(), + name: string(), + imageUrl: optional(nullable(string())), + }), + ), +}); + +export type AddFollowerResponse = Infer; + export type PageParam = | { before: string; @@ -76,7 +91,11 @@ export type PageParam = | { after: string } | null; -const MESSENGER_EXPOSED_METHODS = ['getAssets', 'getActivity'] as const; +const MESSENGER_EXPOSED_METHODS = [ + 'getAssets', + 'getActivity', + 'addFollower', +] as const; export class ExampleDataService extends BaseDataService< typeof serviceName, @@ -86,6 +105,10 @@ export class ExampleDataService extends BaseDataService< readonly #tokensBaseUrl = 'https://tokens.api.cx.metamask.io'; + readonly #socialBaseUrl = 'https://social.api.cx.metamask.io'; + + readonly #segmentRegulationsUrl = 'https://proxy.example.com/v1beta'; + constructor( messenger: ExampleMessenger, { persistenceConfig }: { persistenceConfig?: PersistenceConfiguration } = { @@ -173,6 +196,73 @@ export class ExampleDataService extends BaseDataService< ); } + async addFollower(followerId: string): Promise { + return this.executeMutation({ + mutationKey: [`${this.name}:addFollower`, followerId], + mutationFn: async () => { + const url = new URL(`${this.#socialBaseUrl}/api/v1/users/me/follows`); + + const response = await fetch(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ followerId }), + }); + + if (!response.ok) { + // NOTE: Can't use HttpError from controller-utils due to lint:tsc not + // being fully rolled out across the monorepo. + throw new Error( + `Mutation failed with status code: ${response.status}.`, + ); + } + + return response.json() as Promise; + }, + gcTime: inMilliseconds(1, Duration.Day), + responseStruct: AddFollowerResponseStruct, + }); + } + + async createDataDeletionTask( + analyticsId: string, + segmentSourceId: string, + ): Promise<{ + status: 'ok' | 'error'; + regulateId: string; + }> { + return this.executeMutation({ + mutationKey: [ + `${this.name}:createDataDeletionTask`, + analyticsId, + segmentSourceId, + ], + mutationFn: async () => { + const url = `${this.#segmentRegulationsUrl}/regulations/sources/${segmentSourceId}`; + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + regulationType: 'DELETE_ONLY', + subjectType: 'USER_ID', + subjectIds: [analyticsId], + }), + }); + + if (!response.ok) { + // NOTE: Can't use HttpError from controller-utils due to lint:tsc not + // being fully rolled out across the monorepo. + throw new Error( + `Creating data deletion task failed with status '${response.status}'`, + ); + } + + return response.json(); + }, + gcTime: inMilliseconds(1, Duration.Day), + }); + } + destroy(): void { super.destroy(); } diff --git a/packages/base-data-service/tests/mocks.ts b/packages/base-data-service/tests/mocks.ts index 82341e06721..72d735d229b 100644 --- a/packages/base-data-service/tests/mocks.ts +++ b/packages/base-data-service/tests/mocks.ts @@ -1,10 +1,44 @@ -import nock from 'nock'; +import nock, { ReplyFnResult } from 'nock'; type MockReply = { status: nock.StatusCode; body?: nock.Body; }; +export const DEFAULT_ADD_FOLLOWER_REPLY = { + status: 200, + body: { + followed: [ + { + profileId: '550e8400-e29b-41d4-a716-446655440000', + address: '0x1234567890abcdef1234567890abcdef12345678', + name: 'TraderAlice', + imageUrl: 'https://example.com/avatar.png', + }, + ], + }, +}; + +export function mockAddFollowerRequest({ + mockReply, + replyFn, +}: { + mockReply?: MockReply; + replyFn?: () => ReplyFnResult | Promise; +} = {}): nock.Scope { + const interceptor = nock('https://social.api.cx.metamask.io:443').put( + '/api/v1/users/me/follows', + { followerId: '1' }, + ); + + if (replyFn) { + return interceptor.reply(replyFn); + } + + const reply = mockReply ?? DEFAULT_ADD_FOLLOWER_REPLY; + return interceptor.reply(reply.status, reply.body); +} + export function mockAssets(mockReply?: MockReply): nock.Scope { const reply = mockReply ?? { status: 200, @@ -41,6 +75,36 @@ export function mockAssets(mockReply?: MockReply): nock.Scope { .reply(reply.status, reply.body); } +export function mockCreateDataDeletionTaskRequest({ + mockReply, + replyFn, +}: { + mockReply?: MockReply; + replyFn?: () => ReplyFnResult | Promise; +} = {}): nock.Scope { + const interceptor = nock('https://proxy.example.com/v1beta').post( + '/regulations/sources/2', + { + regulationType: 'DELETE_ONLY', + subjectType: 'USER_ID', + subjectIds: ['1'], + }, + ); + + if (replyFn) { + return interceptor.reply(replyFn); + } + + const reply = mockReply ?? { + status: 200, + body: { + status: 'ok', + regulateId: '99999', + }, + }; + return interceptor.reply(reply.status, reply.body); +} + export const TRANSACTIONS_PAGE_2_CURSOR = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTg0NjQ5fQ.btHnBzYlpbZtAA0kgdyZ5rZ-BC91PZyZQPUuXj1jj6M'; diff --git a/packages/base-data-service/tsconfig.build.json b/packages/base-data-service/tsconfig.build.json index b8f6416befa..2832068bbdb 100644 --- a/packages/base-data-service/tsconfig.build.json +++ b/packages/base-data-service/tsconfig.build.json @@ -6,8 +6,12 @@ "rootDir": "./src" }, "references": [ - { "path": "../messenger/tsconfig.build.json" }, - { "path": "../storage-service/tsconfig.build.json" } + { + "path": "../messenger/tsconfig.build.json" + }, + { + "path": "../storage-service/tsconfig.build.json" + } ], "include": ["../../types", "./src"] } diff --git a/packages/base-data-service/tsconfig.json b/packages/base-data-service/tsconfig.json index e63c2bfd348..8263374ab40 100644 --- a/packages/base-data-service/tsconfig.json +++ b/packages/base-data-service/tsconfig.json @@ -3,6 +3,13 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [{ "path": "../messenger" }, { "path": "../storage-service" }], + "references": [ + { + "path": "../messenger" + }, + { + "path": "../storage-service" + } + ], "include": ["../../types", "./src", "./tests"] } diff --git a/packages/react-data-query/CHANGELOG.md b/packages/react-data-query/CHANGELOG.md index 336906ecf80..e86b03fd173 100644 --- a/packages/react-data-query/CHANGELOG.md +++ b/packages/react-data-query/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. + ### Changed - Bump `@metamask/utils` from `^11.11.0` to `^11.12.0` ([#10076](https://github.com/MetaMask/core/pull/10076)) diff --git a/packages/react-data-query/package.json b/packages/react-data-query/package.json index 865158bc0bc..e10ab7bb4b3 100644 --- a/packages/react-data-query/package.json +++ b/packages/react-data-query/package.json @@ -65,6 +65,7 @@ "@types/jest": "^30.0.0", "deepmerge": "^4.2.2", "jest": "^30.4.2", + "nock": "^13.3.1", "ts-jest": "^29.4.11", "tsx": "^4.20.5", "typedoc": "^0.25.13", diff --git a/packages/react-data-query/src/createUIQueryClient.test.ts b/packages/react-data-query/src/createUIQueryClient.test.ts index 8bae8da68c2..110a1e29c6e 100644 --- a/packages/react-data-query/src/createUIQueryClient.test.ts +++ b/packages/react-data-query/src/createUIQueryClient.test.ts @@ -8,16 +8,24 @@ import { MessengerActions, MockAnyNamespace, } from '@metamask/messenger'; -import { Duration, inMilliseconds } from '@metamask/utils'; +import { + Duration, + createDeferredPromise, + inMilliseconds, +} from '@metamask/utils'; import { InfiniteData, InfiniteQueryObserver, + // MutationObserver is part of the Web API and is therefore a global + MutationObserver as TanStackQueryMutationObserver, QueryClient, QueryClientConfig, QueryObserver, } from '@tanstack/query-core'; +import { ReplyBody } from 'nock'; import { + AddFollowerResponse, ExampleDataService, ExampleMessenger, GetActivityResponse, @@ -26,8 +34,10 @@ import { } from '../../base-data-service/tests/ExampleDataService.js'; import { mockAssets, + mockAddFollowerRequest, mockTransactionsPage1, mockTransactionsPage2, + DEFAULT_ADD_FOLLOWER_REPLY, } from '../../base-data-service/tests/mocks.js'; import { StorageServiceGetItemAction, @@ -164,8 +174,14 @@ const getActivityQueryKey = [ '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520', ]; +const addFollowerMutationKey = ['ExampleDataService:addFollower', '1']; + describe('createUIQueryClient', () => { beforeEach(() => { + // This is necessary to avoid a "Jest did not exit within 1 second" error + // even for "simple" tests like fetching queries or executing mutations + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + mockAssets(); mockTransactionsPage1(); mockTransactionsPage2(); @@ -175,7 +191,7 @@ describe('createUIQueryClient', () => { jest.useRealTimers(); }); - it('proxies requests to the underlying service', async () => { + it('proxies queries to the underlying service', async () => { const { clientA: client, service } = createClients(); const result = await client.fetchQuery({ @@ -206,7 +222,32 @@ describe('createUIQueryClient', () => { service.destroy(); }); - it('fetches using observers', async () => { + it('proxies mutations to the underlying service', async () => { + const { clientA: client, service } = createClients(); + + mockAddFollowerRequest(); + + const mutationCache = client.getMutationCache(); + const mutation = mutationCache.build(client, { + mutationKey: addFollowerMutationKey, + }); + const result = await mutation.execute({}); + + expect(result).toStrictEqual({ + followed: [ + { + profileId: '550e8400-e29b-41d4-a716-446655440000', + address: '0x1234567890abcdef1234567890abcdef12345678', + name: 'TraderAlice', + imageUrl: 'https://example.com/avatar.png', + }, + ], + }); + + service.destroy(); + }); + + it('fetches queries using observers', async () => { const { clientA, clientB, service } = createClients(); const observerA = new QueryObserver(clientA, { @@ -245,7 +286,37 @@ describe('createUIQueryClient', () => { service.destroy(); }); - it('fetches using observers in the same client', async () => { + it('executes mutations using observers', async () => { + const { clientA, clientB, service } = createClients(); + + mockAddFollowerRequest(); + mockAddFollowerRequest(); + + const observerA = new TanStackQueryMutationObserver( + clientA, + { + mutationKey: addFollowerMutationKey, + }, + ); + const observerB = new TanStackQueryMutationObserver( + clientB, + { + mutationKey: addFollowerMutationKey, + }, + ); + + const resultA = await observerA.mutate(); + const resultB = await observerB.mutate(); + + expect(resultA.followed).toHaveLength(1); + expect(resultA).toStrictEqual(resultB); + + observerA.reset(); + observerB.reset(); + service.destroy(); + }); + + it('fetches queries using observers in the same client', async () => { const { clientA, service } = createClients(); const observerA = new QueryObserver(clientA, { @@ -284,6 +355,36 @@ describe('createUIQueryClient', () => { service.destroy(); }); + it('executes mutations using observers in the same client', async () => { + const { clientA, service } = createClients(); + + mockAddFollowerRequest(); + mockAddFollowerRequest(); + + const observerA = new TanStackQueryMutationObserver( + clientA, + { + mutationKey: addFollowerMutationKey, + }, + ); + const observerB = new TanStackQueryMutationObserver( + clientA, + { + mutationKey: addFollowerMutationKey, + }, + ); + + const resultA = await observerA.mutate(); + const resultB = await observerB.mutate(); + + expect(resultA.followed).toHaveLength(1); + expect(resultA).toStrictEqual(resultB); + + observerA.reset(); + observerB.reset(); + service.destroy(); + }); + it('synchronizes caches after invalidation', async () => { const { clientA, clientB, service } = createClients(); @@ -297,7 +398,7 @@ describe('createUIQueryClient', () => { const promiseA = new Promise((resolve) => { observerA.subscribe((event) => { - if (event.status === 'success') { + if (event.status === 'success' && !event.isFetching) { resolve(event.data); } }); @@ -305,7 +406,7 @@ describe('createUIQueryClient', () => { const promiseB = new Promise((resolve) => { observerB.subscribe((event) => { - if (event.status === 'success') { + if (event.status === 'success' && !event.isFetching) { resolve(event.data); } }); @@ -313,6 +414,9 @@ describe('createUIQueryClient', () => { await Promise.all([promiseA, promiseB]); + // Advance the full gcTime of ExampleDataService + jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); + // Replace the mock response and invalidate mockAssets({ status: 200, @@ -321,17 +425,18 @@ describe('createUIQueryClient', () => { await clientA.invalidateQueries(); - const queryData = clientA.getQueryData(getAssetsQueryKey); + const queryDataA = clientA.getQueryData(getAssetsQueryKey); + const queryDataB = clientB.getQueryData(getAssetsQueryKey); - expect(queryData).toStrictEqual([]); - expect(queryData).toStrictEqual(clientB.getQueryData(getAssetsQueryKey)); + expect(queryDataA).toStrictEqual([]); + expect(queryDataB).toStrictEqual([]); observerA.destroy(); observerB.destroy(); service.destroy(); }); - it('supports customizing invalidation', async () => { + it('supports customizing query invalidation', async () => { const { clientA, messenger, service } = createClients(); const spy = jest.spyOn(messenger, 'call'); @@ -371,9 +476,7 @@ describe('createUIQueryClient', () => { service.destroy(); }); - it('does not remove from the cache if observers still are subscribed', async () => { - jest.useFakeTimers(); - + it('does not remove entries from the query cache if query observers still are subscribed', async () => { const { clientA, clientB, service } = createClients(); const observerA = new QueryObserver(clientA, { @@ -408,7 +511,6 @@ describe('createUIQueryClient', () => { jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); const queryData = clientA.getQueryData(getAssetsQueryKey); - expect(queryData).toBeDefined(); expect(queryData).toStrictEqual(clientB.getQueryData(getAssetsQueryKey)); @@ -417,9 +519,114 @@ describe('createUIQueryClient', () => { service.destroy(); }); - it('cleans up removed cache entries once all observers are removed', async () => { - jest.useFakeTimers(); + it('does not remove entries from the mutation cache if mutation observers still are subscribed', async () => { + const { clientA, clientB, service } = createClients(); + const { promise: promiseToResolveMutation, resolve: resolveMutation } = + createDeferredPromise(); + const replyFn = async (): Promise<[number, ReplyBody]> => { + await promiseToResolveMutation; + return [ + DEFAULT_ADD_FOLLOWER_REPLY.status, + DEFAULT_ADD_FOLLOWER_REPLY.body, + ] as const; + }; + mockAddFollowerRequest({ replyFn }); + mockAddFollowerRequest({ replyFn }); + + const observerA = new TanStackQueryMutationObserver(clientA, { + mutationKey: addFollowerMutationKey, + }); + const observerB = new TanStackQueryMutationObserver(clientB, { + mutationKey: addFollowerMutationKey, + }); + + const promiseA = observerA.mutate(); + const promiseB = observerB.mutate(); + + jest.advanceTimersByTime(0); + + const mutationBeforeRemovalA = clientA + .getMutationCache() + .find({ mutationKey: addFollowerMutationKey }); + const mutationBeforeRemovalB = clientB + .getMutationCache() + .find({ mutationKey: addFollowerMutationKey }); + expect(mutationBeforeRemovalA).toBeDefined(); + expect(mutationBeforeRemovalB).toBeDefined(); + + resolveMutation(); + + await Promise.all([promiseA, promiseB]); + + // Advance the full gcTime of ExampleDataService + jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); + + const mutationDataAfterRemovalA = clientA + .getMutationCache() + .find({ mutationKey: addFollowerMutationKey }); + const mutationDataAfterRemovalB = clientB + .getMutationCache() + .find({ mutationKey: addFollowerMutationKey }); + expect(mutationDataAfterRemovalA).toBeDefined(); + expect(mutationDataAfterRemovalB).toBeDefined(); + + observerA.reset(); + observerB.reset(); + service.destroy(); + }); + + it('cleans up removed query cache entries once all query observers are removed', async () => { + const defaultOptions = { + queries: { gcTime: inMilliseconds(5, Duration.Minute) }, + }; + + const { clientA, clientB, service } = createClients({ defaultOptions }); + + const observerA = new QueryObserver(clientA, { + queryKey: getAssetsQueryKey, + }); + + const observerB = new QueryObserver(clientB, { + queryKey: getAssetsQueryKey, + }); + + const promiseA = new Promise((resolve) => { + observerA.subscribe((event) => { + if (event.status === 'success' && !event.isFetching) { + resolve(event.data); + } + }); + }); + + const promiseB = new Promise((resolve) => { + observerB.subscribe((event) => { + if (event.status === 'success' && !event.isFetching) { + resolve(event.data); + } + }); + }); + + jest.advanceTimersByTime(0); + + await Promise.all([promiseA, promiseB]); + + jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); + const queryData = clientA.getQueryData(getAssetsQueryKey); + + expect(queryData).toBeDefined(); + expect(queryData).toStrictEqual(clientB.getQueryData(getAssetsQueryKey)); + + observerA.destroy(); + observerB.destroy(); + + jest.advanceTimersByTime(inMilliseconds(5, Duration.Minute)); + + expect(clientA.getQueryData(getAssetsQueryKey)).toBeUndefined(); + service.destroy(); + }); + + it('cleans up removed mutation cache entries once all mutation observers are removed', async () => { const defaultOptions = { queries: { gcTime: inMilliseconds(5, Duration.Minute) }, }; @@ -470,7 +677,7 @@ describe('createUIQueryClient', () => { service.destroy(); }); - it('fetches using paginated observers', async () => { + it('fetches using paginated query observers', async () => { const { clientA, clientB, service } = createClients(); const getPreviousPageParam = ({ @@ -523,6 +730,9 @@ describe('createUIQueryClient', () => { const resultB = await promiseB; expect(resultA).toStrictEqual(resultB); + // Advance the full gcTime of ExampleDataService + jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); + const nextPageResult = await observerA.fetchNextPage(); expect(nextPageResult.data?.pages).toHaveLength(2); @@ -535,7 +745,7 @@ describe('createUIQueryClient', () => { service.destroy(); }); - it('errors if observer attempts to use default query function without a data service', async () => { + it('errors if query observer attempts to use default query function without a data service', async () => { const { clientA } = createClients(); const observer = new QueryObserver(clientA, { @@ -552,11 +762,22 @@ describe('createUIQueryClient', () => { }); await expect(promise).rejects.toThrow( - "Queries must call actions on the messenger provided to createUIQueryClient, e.g. `queryKey: ['ExampleDataService:getAssets', ...]`.", + "You must pass a `queryKey` that calls an action on the messenger provided to `createUIQueryClient`, e.g. `queryKey: ['ExampleDataService:getAssets', ...]`.", + ); + }); + + it('errors if mutation observer attempts to use default mutation function without a data service', async () => { + const { clientA } = createClients(); + const observer = new TanStackQueryMutationObserver(clientA, { + mutationKey: ['mutation'], + }); + + await expect(observer.mutate()).rejects.toThrow( + "You must pass a `mutationKey` that calls an action on the messenger provided to `createUIQueryClient`, e.g. `mutationKey: ['ExampleDataService:createOrder', ...]`.", ); }); - it('ignores attempts to invalidate non data service queries', async () => { + it('ignores attempts to invalidate non-data service queries', async () => { const { clientA, messenger } = createClients(); const spy = jest.spyOn(messenger, 'call'); @@ -579,7 +800,7 @@ describe('createUIQueryClient', () => { expect(spy).not.toHaveBeenCalled(); }); - it('ignores non data service queries', async () => { + it('ignores non-data service queries', async () => { const { clientA, messenger } = createClients(); const callSpy = jest.spyOn(messenger, 'call'); @@ -602,4 +823,22 @@ describe('createUIQueryClient', () => { expect(callSpy).not.toHaveBeenCalled(); expect(subscribeSpy).not.toHaveBeenCalled(); }); + + it('ignores non-data service mutations', async () => { + const { clientA, messenger } = createClients(); + + const callSpy = jest.spyOn(messenger, 'call'); + const subscribeSpy = jest.spyOn(messenger, 'subscribe'); + + const observer = new TanStackQueryMutationObserver(clientA, { + mutationKey: [1, 2, 3], + mutationFn: async (): Promise => 'foo', + retry: false, + }); + + await observer.mutate(); + + expect(callSpy).not.toHaveBeenCalled(); + expect(subscribeSpy).not.toHaveBeenCalled(); + }); }); diff --git a/packages/react-data-query/src/createUIQueryClient.ts b/packages/react-data-query/src/createUIQueryClient.ts index 7cccee746a0..55686a24aea 100644 --- a/packages/react-data-query/src/createUIQueryClient.ts +++ b/packages/react-data-query/src/createUIQueryClient.ts @@ -2,16 +2,22 @@ import type { DataServiceGranularCacheUpdatedEvent, DataServiceGranularCacheUpdatedPayload, } from '@metamask/base-data-service'; -import { assert } from '@metamask/utils'; +import { Json, assert } from '@metamask/utils'; import { + hashKey, hydrate, QueryClient, InvalidateQueryFilters, InvalidateOptions, QueryKey, QueryClientConfig, + MutationOptions, } from '@tanstack/query-core'; +import { createModuleLogger, projectLogger } from './loggers.js'; + +const log = createModuleLogger(projectLogger, 'createUIQueryClient'); + /** * Handles granular cache update events emitted by data services. */ @@ -83,6 +89,12 @@ export function createUIQueryClient( DataServiceGranularCacheUpdatedHandler >(); + // Tracks how many mutation observers are currently relying on each mutation + // key's cache subscription. Unlike queries, a `Mutation` does not expose its + // observer count publicly, so we count observers ourselves and only tear down + // the messenger subscription once the last observer for a key is removed. + const mutationObserverCounts = new Map(); + /** * Check whether a name is one of the provided data service names. * @@ -141,7 +153,7 @@ export function createUIQueryClient( assert( typeof action === 'string' && isRecognizedDataServiceAction(action), - "Queries must call actions on the messenger provided to createUIQueryClient, e.g. `queryKey: ['ExampleDataService:getAssets', ...]`.", + "You must pass a `queryKey` that calls an action on the messenger provided to `createUIQueryClient`, e.g. `queryKey: ['ExampleDataService:getAssets', ...]`.", ); const params = options.queryKey.slice(1); @@ -156,9 +168,8 @@ export function createUIQueryClient( }, }); - const cache = client.getQueryCache(); - - cache.subscribe((event) => { + const queryCache = client.getQueryCache(); + queryCache.subscribe((event) => { const { query } = event; const hash = query.queryHash; @@ -183,6 +194,7 @@ export function createUIQueryClient( return; } + log('Hydrating with', payload.state); hydrate(client, payload.state); }; @@ -208,6 +220,84 @@ export function createUIQueryClient( } }); + const mutationCache = client.getMutationCache(); + mutationCache.subscribe((event) => { + const { mutation } = event; + + if (!mutation?.options.mutationKey) { + return; + } + + const hash = hashKey(mutation.options.mutationKey); + const hasSubscription = subscriptions.has(hash); + + const service = parseQueryKey(mutation.options.mutationKey); + + if (!service) { + return; + } + + log( + `[mutationCache subscription] Received event "${event.type}". Details:`, + event.mutation, + ); + + if (event.type === 'observerAdded') { + mutationObserverCounts.set( + hash, + (mutationObserverCounts.get(hash) ?? 0) + 1, + ); + + log('[mutationCache subscription] hasSubscription =', hasSubscription); + + if (!hasSubscription) { + const cacheListener = ( + payload: DataServiceGranularCacheUpdatedPayload, + ): void => { + log( + `[mutationCache subscription] cacheUpdated:${hash} emitted`, + payload, + ); + + if (payload.type === 'removed') { + return; + } + + hydrate(client, payload.state); + }; + + subscriptions.set(hash, cacheListener); + messenger.subscribe(`${service}:cacheUpdated:${hash}`, cacheListener); + } + } else if (event.type === 'observerRemoved' && hasSubscription) { + // We can assume that if an observed is removed, it must have first been + // added; and that when it was added, the observer count was initialized. + // (There's no real way to test the alternative, anyway.) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const remainingObservers = mutationObserverCounts.get(hash)! - 1; + + if (remainingObservers > 0) { + mutationObserverCounts.set(hash, remainingObservers); + return; + } + + mutationObserverCounts.delete(hash); + + const subscriptionListener = subscriptions.get(hash); + + // A subscription always has a listener, since both are set together when + // the first observer is added. + // istanbul ignore next + if (subscriptionListener) { + messenger.unsubscribe( + `${service}:cacheUpdated:${hash}`, + subscriptionListener, + ); + } + subscriptions.delete(hash); + } + }); + // Override invalidateQueries to ensure the data service is invalidated as well. const originalInvalidate = client.invalidateQueries.bind(client); @@ -234,5 +324,40 @@ export function createUIQueryClient( return originalInvalidate(filters, options); }; + // Override defaultMutationOptions to check for mutationKey if mutationFn is + // not provided. + const originalDefaultMutationOptions = + client.defaultMutationOptions.bind(client); + + client.defaultMutationOptions = < + // We are overriding a type in @tanstack/query-core. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Options extends MutationOptions, + >( + options?: Options, + ): Options => { + const defaultedOptions = originalDefaultMutationOptions(options); + defaultedOptions.mutationFn ??= async (): Promise => { + const { mutationKey } = defaultedOptions; + + assert( + mutationKey !== undefined, + "You must pass a `mutationKey` that calls an action on the messenger provided to `createUIQueryClient`, e.g. `mutationKey: ['ExampleDataService:createOrder', ...]`.", + ); + + const [action, ...params] = mutationKey; + + assert( + typeof action === 'string' && isRecognizedDataServiceAction(action), + "You must pass a `mutationKey` that calls an action on the messenger provided to `createUIQueryClient`, e.g. `mutationKey: ['ExampleDataService:createOrder', ...]`.", + ); + + log(`Detected mutation request, calling action: "${action}"`); + + return await messenger.call(action, ...(params as Json[])); + }; + return defaultedOptions; + }; + return client; } diff --git a/packages/react-data-query/src/loggers.ts b/packages/react-data-query/src/loggers.ts new file mode 100644 index 00000000000..17fbe5289ac --- /dev/null +++ b/packages/react-data-query/src/loggers.ts @@ -0,0 +1,5 @@ +import { createProjectLogger, createModuleLogger } from '@metamask/utils'; + +export const projectLogger = createProjectLogger('base-data-service'); + +export { createModuleLogger }; diff --git a/yarn.lock b/yarn.lock index 1e88d4058a7..a7e8222593b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8581,6 +8581,7 @@ __metadata: "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" jest: "npm:^30.4.2" + nock: "npm:^13.3.1" ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" From ea44597f46f7a8eba8188d5e7a206c2a7242dbc6 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 2 Sep 2026 13:43:15 -0600 Subject: [PATCH 02/34] Add missing comment --- packages/react-data-query/src/createUIQueryClient.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-data-query/src/createUIQueryClient.test.ts b/packages/react-data-query/src/createUIQueryClient.test.ts index 110a1e29c6e..fd175200920 100644 --- a/packages/react-data-query/src/createUIQueryClient.test.ts +++ b/packages/react-data-query/src/createUIQueryClient.test.ts @@ -610,6 +610,7 @@ describe('createUIQueryClient', () => { await Promise.all([promiseA, promiseB]); + // Advance the full gcTime of ExampleDataService jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); const queryData = clientA.getQueryData(getAssetsQueryKey); From 00baf1c462132c2cb2dff36ea151388d2a1a4c86 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 2 Sep 2026 13:46:04 -0600 Subject: [PATCH 03/34] Remove 100% test coverage --- packages/base-data-service/jest.config.js | 11 ++++------- .../base-data-service/src/BaseDataService.ts | 16 ++++------------ 2 files changed, 8 insertions(+), 19 deletions(-) diff --git a/packages/base-data-service/jest.config.js b/packages/base-data-service/jest.config.js index 15b4a822e8d..7d949727aeb 100644 --- a/packages/base-data-service/jest.config.js +++ b/packages/base-data-service/jest.config.js @@ -14,16 +14,13 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, - // v8 tends to work better for this package. - coverageProvider: 'v8', - // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 100, - functions: 100, - lines: 100, - statements: 100, + branches: 96.1, + functions: 97.36, + lines: 99.41, + statements: 99.42, }, }, }); diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 186b6c09994..cbab25481b0 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -236,14 +236,10 @@ export class BaseDataService< defaultOptions: { queries: { ...QUERY_CLIENT_DEFAULTS.queries, - // We always provide defaultOptions in our tests. - /* c8 ignore next */ ...queryClientConfig.defaultOptions?.queries, }, mutations: { ...QUERY_CLIENT_DEFAULTS.mutations, - // We always provide defaultOptions in our tests. - /* c8 ignore next */ ...queryClientConfig.defaultOptions?.mutations, }, }, @@ -257,10 +253,8 @@ export class BaseDataService< this.#persistenceConfig && debounce( () => { - this.#persistCache().catch( - // We always provide this in our tests. - /* c8 ignore next */ - (error) => this.#messenger.captureException?.(error), + this.#persistCache().catch((error) => + this.#messenger.captureException?.(error), ); }, this.#persistenceConfig.writeDelay ?? @@ -540,10 +534,8 @@ export class BaseDataService< * Initialize the service, rehydrating the cache with persisted data if possible. */ init(): void { - this.#loadCache().catch( - // We always provide captureException in our tests. - /* c8 ignore next */ - (error) => this.#messenger.captureException?.(error), + this.#loadCache().catch((error) => + this.#messenger.captureException?.(error), ); } From 237d9f19b622e6045a4ae666ec92e5f0ca6c4f9a Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 2 Sep 2026 13:59:58 -0600 Subject: [PATCH 04/34] Use correct log namespace --- packages/react-data-query/src/loggers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-data-query/src/loggers.ts b/packages/react-data-query/src/loggers.ts index 17fbe5289ac..af61a2c008d 100644 --- a/packages/react-data-query/src/loggers.ts +++ b/packages/react-data-query/src/loggers.ts @@ -1,5 +1,5 @@ import { createProjectLogger, createModuleLogger } from '@metamask/utils'; -export const projectLogger = createProjectLogger('base-data-service'); +export const projectLogger = createProjectLogger('react-data-query'); export { createModuleLogger }; From 965c76721d8ae02626196b2c41f459ffc7016b0b Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 2 Sep 2026 14:04:10 -0600 Subject: [PATCH 05/34] Trying to fix this test --- .../src/createUIQueryClient.test.ts | 56 ++++++++++--------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/packages/react-data-query/src/createUIQueryClient.test.ts b/packages/react-data-query/src/createUIQueryClient.test.ts index fd175200920..b40db3ac402 100644 --- a/packages/react-data-query/src/createUIQueryClient.test.ts +++ b/packages/react-data-query/src/createUIQueryClient.test.ts @@ -627,54 +627,56 @@ describe('createUIQueryClient', () => { service.destroy(); }); - it('cleans up removed mutation cache entries once all mutation observers are removed', async () => { + it.only('cleans up removed mutation cache entries once all mutation observers are removed', async () => { const defaultOptions = { queries: { gcTime: inMilliseconds(5, Duration.Minute) }, }; const { clientA, clientB, service } = createClients({ defaultOptions }); + mockAddFollowerRequest(); + mockAddFollowerRequest(); - const observerA = new QueryObserver(clientA, { - queryKey: getAssetsQueryKey, - }); - - const observerB = new QueryObserver(clientB, { - queryKey: getAssetsQueryKey, + const observerA = new TanStackQueryMutationObserver(clientA, { + mutationKey: addFollowerMutationKey, }); - const promiseA = new Promise((resolve) => { - observerA.subscribe((event) => { - if (event.status === 'success' && !event.isFetching) { - resolve(event.data); - } - }); + const observerB = new TanStackQueryMutationObserver(clientB, { + mutationKey: addFollowerMutationKey, }); - const promiseB = new Promise((resolve) => { - observerB.subscribe((event) => { - if (event.status === 'success' && !event.isFetching) { - resolve(event.data); - } - }); - }); + const promiseA = observerA.mutate(); + const promiseB = observerB.mutate(); jest.advanceTimersByTime(0); await Promise.all([promiseA, promiseB]); + // Advance the full gcTime of ExampleDataService jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); - const queryData = clientA.getQueryData(getAssetsQueryKey); - - expect(queryData).toBeDefined(); - expect(queryData).toStrictEqual(clientB.getQueryData(getAssetsQueryKey)); + const mutationBeforeRemovalA = clientA + .getMutationCache() + .find({ mutationKey: addFollowerMutationKey }); + const mutationBeforeRemovalB = clientB + .getMutationCache() + .find({ mutationKey: addFollowerMutationKey }); + expect(mutationBeforeRemovalA).toBeDefined(); + expect(mutationBeforeRemovalB).toBeDefined(); - observerA.destroy(); - observerB.destroy(); + observerA.reset(); + observerB.reset(); jest.advanceTimersByTime(inMilliseconds(5, Duration.Minute)); - expect(clientA.getQueryData(getAssetsQueryKey)).toBeUndefined(); + const mutationDataAfterRemovalA = clientA + .getMutationCache() + .find({ mutationKey: addFollowerMutationKey }); + const mutationDataAfterRemovalB = clientB + .getMutationCache() + .find({ mutationKey: addFollowerMutationKey }); + expect(mutationDataAfterRemovalA).toBeUndefined(); + expect(mutationDataAfterRemovalB).toBeUndefined(); + service.destroy(); }); From 0676e8f85b46cb561f9f3304b75cfe8c1cafe2d3 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 2 Sep 2026 14:23:15 -0600 Subject: [PATCH 06/34] Explicitly migrate mutations --- .../src/createUIQueryClient.test.ts | 42 +++++++++++- .../src/createUIQueryClient.ts | 64 ++++++++++++++++++- 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/packages/react-data-query/src/createUIQueryClient.test.ts b/packages/react-data-query/src/createUIQueryClient.test.ts index b40db3ac402..48727365f7f 100644 --- a/packages/react-data-query/src/createUIQueryClient.test.ts +++ b/packages/react-data-query/src/createUIQueryClient.test.ts @@ -316,6 +316,44 @@ describe('createUIQueryClient', () => { service.destroy(); }); + it('does not accumulate duplicate mutations in the cache when the service emits cache updates', async () => { + const { clientA, clientB, service } = createClients(); + + mockAddFollowerRequest(); + mockAddFollowerRequest(); + + const observerA = new TanStackQueryMutationObserver( + clientA, + { + mutationKey: addFollowerMutationKey, + }, + ); + const observerB = new TanStackQueryMutationObserver( + clientB, + { + mutationKey: addFollowerMutationKey, + }, + ); + + await observerA.mutate(); + await observerB.mutate(); + + expect( + clientA + .getMutationCache() + .findAll({ mutationKey: addFollowerMutationKey }), + ).toHaveLength(1); + expect( + clientB + .getMutationCache() + .findAll({ mutationKey: addFollowerMutationKey }), + ).toHaveLength(1); + + observerA.reset(); + observerB.reset(); + service.destroy(); + }); + it('fetches queries using observers in the same client', async () => { const { clientA, service } = createClients(); @@ -627,9 +665,9 @@ describe('createUIQueryClient', () => { service.destroy(); }); - it.only('cleans up removed mutation cache entries once all mutation observers are removed', async () => { + it('cleans up removed mutation cache entries once all mutation observers are removed', async () => { const defaultOptions = { - queries: { gcTime: inMilliseconds(5, Duration.Minute) }, + mutations: { gcTime: inMilliseconds(5, Duration.Minute) }, }; const { clientA, clientB, service } = createClients({ defaultOptions }); diff --git a/packages/react-data-query/src/createUIQueryClient.ts b/packages/react-data-query/src/createUIQueryClient.ts index 55686a24aea..fabac2b1fa2 100644 --- a/packages/react-data-query/src/createUIQueryClient.ts +++ b/packages/react-data-query/src/createUIQueryClient.ts @@ -12,6 +12,7 @@ import { QueryKey, QueryClientConfig, MutationOptions, + DehydratedState, } from '@tanstack/query-core'; import { createModuleLogger, projectLogger } from './loggers.js'; @@ -62,6 +63,67 @@ type MessengerAdapter = { ): void; }; +/** + * Load a dehydrated mutation cache into a query client. + * + * TanStack Query's own `hydrate` matches dehydrated queries against the cache + * by hash and updates them in place, but it always inserts a brand-new mutation + * for every dehydrated mutation. Because data services emit a cache update on + * every `added`/`updated` mutation event, calling `hydrate` directly would + * append a fresh mutation to each subscribed query client on every event, so + * the cache would grow without bound, and a found migration could be stale. + * + * This behavior for `hydrate` makes sense because TanStack treats queries and + * mutations differently. Queries are deduplicated: two attempts for the same + * query using the same query key show up once in the query cache. But mutations + * are discrete events/attempts, and `mutationKey` is used by observers to find + * mutations, not enforce uniqueness. + * + * However, our situation is a bit unusual: we're using a mutation key as a + * stable shared identity between the service-side and UI-side caches (more + * query-like), which is exactly the case `hydrate` doesn't support. + * + * Instead of appending new mutations to the query client, this function reuses + * the mutation that already exists for a given key, updating its state to match + * the service. + * + * @param client - The UI query client whose mutation cache should be synced. + * @param dehydratedState - The dehydrated state emitted by the data service. + */ +function migrateMutations( + 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: { type: 'success', data: state.data }, + }); + } + } +} + /** * Create a QueryClient that queries and subscribes to data services using a * messenger adapter. This is a messenger-like object that carries some @@ -263,7 +325,7 @@ export function createUIQueryClient( return; } - hydrate(client, payload.state); + migrateMutations(client, payload.state); }; subscriptions.set(hash, cacheListener); From 7bd8a6a1e2735a56c95214f981cec4b8114a345d Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 2 Sep 2026 14:38:10 -0600 Subject: [PATCH 07/34] Fix JSDoc, function name --- packages/react-data-query/src/createUIQueryClient.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react-data-query/src/createUIQueryClient.ts b/packages/react-data-query/src/createUIQueryClient.ts index fabac2b1fa2..75b030d2f60 100644 --- a/packages/react-data-query/src/createUIQueryClient.ts +++ b/packages/react-data-query/src/createUIQueryClient.ts @@ -71,7 +71,7 @@ type MessengerAdapter = { * for every dehydrated mutation. Because data services emit a cache update on * every `added`/`updated` mutation event, calling `hydrate` directly would * append a fresh mutation to each subscribed query client on every event, so - * the cache would grow without bound, and a found migration could be stale. + * the cache would grow without bound, and a found mutation could be stale. * * This behavior for `hydrate` makes sense because TanStack treats queries and * mutations differently. Queries are deduplicated: two attempts for the same @@ -87,10 +87,10 @@ type MessengerAdapter = { * the mutation that already exists for a given key, updating its state to match * the service. * - * @param client - The UI query client whose mutation cache should be synced. + * @param client - The UI query client whose mutation cache should be hydrated. * @param dehydratedState - The dehydrated state emitted by the data service. */ -function migrateMutations( +function hydrateMutations( client: QueryClient, dehydratedState: DehydratedState, ): void { @@ -325,7 +325,7 @@ export function createUIQueryClient( return; } - migrateMutations(client, payload.state); + hydrateMutations(client, payload.state); }; subscriptions.set(hash, cacheListener); From 2a20d8beb361ce3f6f6c571258c83b06b84ffcb3 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Wed, 2 Sep 2026 14:58:17 -0600 Subject: [PATCH 08/34] Ensure hydrateMutations notifies the mutation cache with the right mutation status --- .../src/createUIQueryClient.test.ts | 85 +++++++++++++++++++ .../src/createUIQueryClient.ts | 36 +++++++- 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/packages/react-data-query/src/createUIQueryClient.test.ts b/packages/react-data-query/src/createUIQueryClient.test.ts index 48727365f7f..e9e7aee4f40 100644 --- a/packages/react-data-query/src/createUIQueryClient.test.ts +++ b/packages/react-data-query/src/createUIQueryClient.test.ts @@ -354,6 +354,91 @@ describe('createUIQueryClient', () => { service.destroy(); }); + it('reports a synced mutation as a success when it succeeded on the service', async () => { + const { clientA, clientB, service } = createClients(); + + mockAddFollowerRequest(); + mockAddFollowerRequest(); + + const observerA = new TanStackQueryMutationObserver( + clientA, + { + mutationKey: addFollowerMutationKey, + }, + ); + const observerB = new TanStackQueryMutationObserver( + clientB, + { + mutationKey: addFollowerMutationKey, + }, + ); + + const syncedActions: { type: string }[] = []; + clientB.getMutationCache().subscribe((event) => { + if (event.type === 'updated') { + syncedActions.push(event.action); + } + }); + + await observerA.mutate(); + await observerB.mutate(); + + expect(syncedActions).toContainEqual({ + type: 'success', + data: DEFAULT_ADD_FOLLOWER_REPLY.body, + }); + + observerA.reset(); + observerB.reset(); + service.destroy(); + }); + + it('reports a synced mutation as an error when it failed on the service', async () => { + const { clientA, clientB, service } = createClients(); + + mockAddFollowerRequest({ mockReply: { status: 500 } }); + mockAddFollowerRequest({ mockReply: { status: 500 } }); + + const observerA = new TanStackQueryMutationObserver( + clientA, + { + mutationKey: addFollowerMutationKey, + }, + ); + const observerB = new TanStackQueryMutationObserver( + clientB, + { + mutationKey: addFollowerMutationKey, + }, + ); + + const syncedActions: { type: string }[] = []; + clientB.getMutationCache().subscribe((event) => { + if (event.type === 'updated') { + syncedActions.push(event.action); + } + }); + + await expect(observerA.mutate()).rejects.toThrow( + 'Mutation failed with status code: 500.', + ); + await expect(observerB.mutate()).rejects.toThrow( + 'Mutation failed with status code: 500.', + ); + + expect(syncedActions).toContainEqual({ + type: 'error', + error: new Error('Mutation failed with status code: 500.'), + }); + expect(syncedActions).not.toContainEqual( + expect.objectContaining({ type: 'success' }), + ); + + observerA.reset(); + observerB.reset(); + service.destroy(); + }); + it('fetches queries using observers in the same client', async () => { const { clientA, service } = createClients(); diff --git a/packages/react-data-query/src/createUIQueryClient.ts b/packages/react-data-query/src/createUIQueryClient.ts index 75b030d2f60..50c33925e0a 100644 --- a/packages/react-data-query/src/createUIQueryClient.ts +++ b/packages/react-data-query/src/createUIQueryClient.ts @@ -13,6 +13,7 @@ import { QueryClientConfig, MutationOptions, DehydratedState, + MutationState, } from '@tanstack/query-core'; import { createModuleLogger, projectLogger } from './loggers.js'; @@ -118,12 +119,45 @@ function hydrateMutations( mutationCache.notify({ type: 'updated', mutation: existingMutation, - action: { type: 'success', data: state.data }, + action: deriveMutationAction(state), }); } } } +/** + * Build the `notify` action that describes a mutation's current state. + * + * @param state - The synced mutation state. + * @returns The action describing the state. + */ +function deriveMutationAction( + state: MutationState, +): + | { type: 'success'; data: unknown } + | { type: 'error'; error: unknown } + | { type: 'pending'; variables: unknown; context: unknown; isPaused: boolean } + | { type: 'continue' } { + switch (state.status) { + case 'success': + return { type: 'success', data: state.data }; + case 'error': + // A mutation in the `error` state always carries a non-null `error`. + return { type: 'error', error: state.error }; + case 'pending': + return { + type: 'pending', + variables: state.variables, + context: state.context, + isPaused: state.isPaused, + }; + // The `idle` status carries no data, error, or variables, so a neutral + // `continue` action refreshes subscribers without implying a result. + default: + return { type: 'continue' }; + } +} + /** * Create a QueryClient that queries and subscribes to data services using a * messenger adapter. This is a messenger-like object that carries some From cbc279534c24051ce16b968e739b9f8e0ca5c90f Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 3 Sep 2026 13:06:46 -0600 Subject: [PATCH 09/34] Restore 'istanbul ignore' lines --- packages/base-data-service/src/BaseDataService.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index cbab25481b0..68face1ecc8 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -254,6 +254,7 @@ export class BaseDataService< debounce( () => { this.#persistCache().catch((error) => + /* istanbul ignore next */ this.#messenger.captureException?.(error), ); }, @@ -535,6 +536,7 @@ export class BaseDataService< */ init(): void { this.#loadCache().catch((error) => + /* istanbul ignore next */ this.#messenger.captureException?.(error), ); } From 1366d7f585169a512445d1b476f2cfe405340f20 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 3 Sep 2026 13:14:01 -0600 Subject: [PATCH 10/34] Add createDataDeletionTask to ExampleDataService messenger --- .../tests/ExampleDataService-method-action-types.ts | 8 +++++++- packages/base-data-service/tests/ExampleDataService.ts | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/base-data-service/tests/ExampleDataService-method-action-types.ts b/packages/base-data-service/tests/ExampleDataService-method-action-types.ts index 0a66bb9d735..7edd2fa0825 100644 --- a/packages/base-data-service/tests/ExampleDataService-method-action-types.ts +++ b/packages/base-data-service/tests/ExampleDataService-method-action-types.ts @@ -20,10 +20,16 @@ export type ExampleDataServiceAddFollowerAction = { handler: ExampleDataService['addFollower']; }; +export type ExampleDataServiceCreateDataDeletionTaskAction = { + type: `ExampleDataService:createDataDeletionTask`; + handler: ExampleDataService['createDataDeletionTask']; +}; + /** * Union of all ExampleDataService action types. */ export type ExampleDataServiceMethodActions = | ExampleDataServiceGetAssetsAction | ExampleDataServiceGetActivityAction - | ExampleDataServiceAddFollowerAction; + | ExampleDataServiceAddFollowerAction + | ExampleDataServiceCreateDataDeletionTaskAction; diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts index 946aafbdae7..cc6a8f170a0 100644 --- a/packages/base-data-service/tests/ExampleDataService.ts +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -95,6 +95,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getAssets', 'getActivity', 'addFollower', + 'createDataDeletionTask', ] as const; export class ExampleDataService extends BaseDataService< From 49f169f9e3655fd3e73b80b93ec68fef3d8e3617 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 3 Sep 2026 13:17:29 -0600 Subject: [PATCH 11/34] Use TDataStruct, not TStruct, for consistency with fetch(Infinite)Query --- packages/base-data-service/src/BaseDataService.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 68face1ecc8..0670a357b2e 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -462,8 +462,8 @@ export class BaseDataService< // 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 | undefined = undefined, - TData = TStruct extends Struct + TDataStruct extends Struct | undefined = undefined, + TData = TDataStruct extends Struct ? StructType : TMutationFnData, TError = unknown, @@ -479,7 +479,7 @@ export class BaseDataService< > & { mutationKey: TMutationKey; mutationFn: MutationFunction>; - responseStruct?: TStruct; + responseStruct?: TDataStruct; }): Promise { const mutationCache = this.#queryClient.getMutationCache(); const mutation = mutationCache.build< From 4ef544e9d0b4464df998b8d031566dd50b4f8e64 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 3 Sep 2026 13:28:15 -0600 Subject: [PATCH 12/34] Adjust type parameters to more closely match TanStack Query --- packages/base-data-service/src/BaseDataService.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 0670a357b2e..bf7cc94cb91 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -466,15 +466,15 @@ export class BaseDataService< TData = TDataStruct extends Struct ? StructType : TMutationFnData, - TError = unknown, - TContext = unknown, + TError = DefaultError, + TOnMutateResult = unknown, TMutationKey extends MutationKey = MutationKey, >({ mutationFn, responseStruct, ...options }: OmitKeyof< - MutationOptions, TContext>, + MutationOptions, TOnMutateResult>, 'retry' | 'retryDelay' | 'mutationKey' | 'mutationFn' > & { mutationKey: TMutationKey; @@ -486,7 +486,7 @@ export class BaseDataService< TData, TError, Record, - TContext + TOnMutateResult >(this.#queryClient, { ...options, mutationFn: async (...args) => { From ba70f309ed6e457c9adeaa2db872cddfab1ab0d0 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 3 Sep 2026 14:01:52 -0600 Subject: [PATCH 13/34] Add useMutation wrapper --- packages/base-data-service/CHANGELOG.md | 1 + packages/base-data-service/src/index.ts | 1 + packages/react-data-query/CHANGELOG.md | 5 +- packages/react-data-query/src/hooks.test.ts | 29 ++++++++--- packages/react-data-query/src/hooks.ts | 55 +++++++++++++++++---- 5 files changed, 72 insertions(+), 19 deletions(-) diff --git a/packages/base-data-service/CHANGELOG.md b/packages/base-data-service/CHANGELOG.md index 7fb63bc7771..0cf5e3652a5 100644 --- a/packages/base-data-service/CHANGELOG.md +++ b/packages/base-data-service/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `executeMutation` to `BaseDataService` to allow for making server-state-mutating requests ([#9324](https://github.com/MetaMask/core/pull/9324)) - These kinds of requests are never retried, unlike queries. + - Also export `MutationKey` type. ### Changed diff --git a/packages/base-data-service/src/index.ts b/packages/base-data-service/src/index.ts index 58090738556..14cdc5f1307 100644 --- a/packages/base-data-service/src/index.ts +++ b/packages/base-data-service/src/index.ts @@ -22,6 +22,7 @@ export type { DataServiceCacheUpdatedEvent, DataServiceGranularCacheUpdatedEvent, QueryKey, + MutationKey, PersistenceConfiguration, } from './BaseDataService.js'; export { BaseDataService } from './BaseDataService.js'; diff --git a/packages/react-data-query/CHANGELOG.md b/packages/react-data-query/CHANGELOG.md index e86b03fd173..4e8dfe59947 100644 --- a/packages/react-data-query/CHANGELOG.md +++ b/packages/react-data-query/CHANGELOG.md @@ -9,8 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. +- Add support for executing mutations by updating `createUIQueryClient` and adding `useMutation` wrapper ([#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 via the UI query client and pass a reference to the action as the mutation key. + - Like `fetchQuery` and `fetchInfiniteQuery`, the `useMutation` wrapper disables retries by default and enforces that `mutationKey` matches the same thing that `executeMutation` takes. ### Changed diff --git a/packages/react-data-query/src/hooks.test.ts b/packages/react-data-query/src/hooks.test.ts index b874ce37f98..0510cd97cd5 100644 --- a/packages/react-data-query/src/hooks.test.ts +++ b/packages/react-data-query/src/hooks.test.ts @@ -1,22 +1,24 @@ import { - useQuery as useQueryTanStack, - useInfiniteQuery as useInfiniteQueryTanStack, + useQuery as useQueryFromTanStack, + useInfiniteQuery as useInfiniteQueryFromTanStack, + useMutation as useMutationFromTanStack, } from '@tanstack/react-query'; -import { useInfiniteQuery, useQuery } from './hooks.js'; +import { useInfiniteQuery, useMutation, useQuery } from './hooks.js'; jest.mock('@tanstack/react-query', () => ({ useQuery: jest.fn(), useInfiniteQuery: jest.fn(), + useMutation: jest.fn(), })); describe('useQuery', () => { - it('calls the underlying TanStack query function', () => { + it('calls useQuery from TanStack Query, enforcing that queries are always fresh and disabling retries by default', () => { const options = { queryKey: ['foo'] as const, }; expect(() => useQuery(options)).not.toThrow(); - expect(useQueryTanStack).toHaveBeenCalledWith({ + expect(useQueryFromTanStack).toHaveBeenCalledWith({ staleTime: 0, retry: false, ...options, @@ -25,17 +27,30 @@ describe('useQuery', () => { }); describe('useInfiniteQuery', () => { - it('calls the underlying TanStack query function', () => { + it('calls useInfiniteQuery from TanStack Query, enforcing that queries are always fresh and disabling retries by default', () => { const options = { queryKey: ['foo'] as const, initialPageParam: undefined, getNextPageParam: (): undefined => undefined, }; expect(() => useInfiniteQuery(options)).not.toThrow(); - expect(useInfiniteQueryTanStack).toHaveBeenCalledWith({ + expect(useInfiniteQueryFromTanStack).toHaveBeenCalledWith({ staleTime: 0, retry: false, ...options, }); }); }); + +describe('useMutation', () => { + it('calls useMutation from TanStack Query, disabling retries by default', () => { + const options = { + mutationKey: ['foo'] as const, + }; + expect(() => useMutation(options)).not.toThrow(); + expect(useMutationFromTanStack).toHaveBeenCalledWith({ + retry: false, + ...options, + }); + }); +}); diff --git a/packages/react-data-query/src/hooks.ts b/packages/react-data-query/src/hooks.ts index feefe7c9d6e..3e481b8a31e 100644 --- a/packages/react-data-query/src/hooks.ts +++ b/packages/react-data-query/src/hooks.ts @@ -1,7 +1,14 @@ -import { QueryKey } from '@metamask/base-data-service'; +/** + * @file + * We provide re-exports of the underlying TanStack Query hooks with narrower types, + * removing `staleTime` and `queryFn` which aren't useful when using data services. + */ + +import { QueryKey, MutationKey } from '@metamask/base-data-service'; import { - useQuery as useQueryTanStack, - useInfiniteQuery as useInfiniteQueryTanStack, + useQuery as useQueryFromTanStack, + useInfiniteQuery as useInfiniteQueryFromTanStack, + useMutation as useMutationFromTanStack, OmitKeyof, UseQueryOptions, InitialDataFunction, @@ -11,18 +18,19 @@ import { UseInfiniteQueryResult, DefaultError, InfiniteData, + UseMutationOptions, + UseMutationResult, } from '@tanstack/react-query'; -/** - * We provide re-exports of the underlying TanStack Query hooks with narrower types, - * removing `staleTime` and `queryFn` which aren't useful when using data services. - */ - const DATA_SERVICE_QUERY_DEFAULTS = { staleTime: 0, retry: false, }; +const DATA_SERVICE_MUTATION_DEFAULTS = { + retry: false, +}; + /** * Consume a query from a data service. * @@ -46,7 +54,7 @@ export function useQuery< | NonUndefinedGuard; }, ): UseQueryResult { - return useQueryTanStack({ ...DATA_SERVICE_QUERY_DEFAULTS, ...options }); + return useQueryFromTanStack({ ...DATA_SERVICE_QUERY_DEFAULTS, ...options }); } /** @@ -68,8 +76,35 @@ export function useInfiniteQuery< 'staleTime' | 'queryFn' >, ): UseInfiniteQueryResult { - return useInfiniteQueryTanStack({ + return useInfiniteQueryFromTanStack({ ...DATA_SERVICE_QUERY_DEFAULTS, ...options, }); } + +/** + * Execute a mutation through a data service. + * + * @param options - The mutation options. Keep in mind that `mutationFn` is not supported + * when executing mutations through data services. + * @returns The result of the mutation. + */ +export function useMutation< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, + TMutationKey extends MutationKey = MutationKey, +>( + options: OmitKeyof< + UseMutationOptions, + 'mutationKey' | 'mutationFn' + > & { + mutationKey: TMutationKey; + }, +): UseMutationResult { + return useMutationFromTanStack({ + ...DATA_SERVICE_MUTATION_DEFAULTS, + ...options, + }); +} From 35836ff7c18f1c6d711772a78b145815307522ff Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 3 Sep 2026 14:29:31 -0600 Subject: [PATCH 14/34] Clarify comment explaining use of circuit breaker policy alone --- packages/base-data-service/src/BaseDataService.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index bf7cc94cb91..44468df9ba9 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -490,9 +490,10 @@ export class BaseDataService< >(this.#queryClient, { ...options, mutationFn: async (...args) => { - // 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. + // To guard against mutations being retried, we deliberately execute the + // mutation function through the circuit breaker policy alone, rather + // than through `this.#policy` (which is a combination of both a + // circuit breaker policy and retry policy). const response = await this.#policy.circuitBreakerPolicy.execute(() => mutationFn(...args), ); From 24a2038849d998a9d29e954250ff3b2df0cb8317 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 3 Sep 2026 15:05:37 -0600 Subject: [PATCH 15/34] Add 'objectType' to :cacheUpdated event payloads --- packages/base-data-service/CHANGELOG.md | 1 + .../base-data-service/src/BaseDataService.test.ts | 13 +++++++++++++ packages/base-data-service/src/BaseDataService.ts | 10 +++++++--- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/base-data-service/CHANGELOG.md b/packages/base-data-service/CHANGELOG.md index 0cf5e3652a5..8a4d66d2a6a 100644 --- a/packages/base-data-service/CHANGELOG.md +++ b/packages/base-data-service/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `executeMutation` to `BaseDataService` to allow for making server-state-mutating requests ([#9324](https://github.com/MetaMask/core/pull/9324)) - These kinds of requests are never retried, unlike queries. - Also export `MutationKey` type. +- The payload for `:cacheUpdated` and `:cacheUpdated:${hash}` events now includes an `objectType` property, which is either "query" or "mutation" ([#9324](https://github.com/MetaMask/core/pull/9324)) ### Changed diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 976412cec54..243184aa266 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -215,6 +215,7 @@ describe('BaseDataService', () => { 1, `ExampleDataService:cacheUpdated`, { + objectType: 'query', type: 'added', hash, state: expectedState, @@ -225,6 +226,7 @@ describe('BaseDataService', () => { 2, `ExampleDataService:cacheUpdated:${hash}`, { + objectType: 'query', type: 'added', state: expectedState, }, @@ -255,6 +257,7 @@ describe('BaseDataService', () => { 1, `ExampleDataService:cacheUpdated`, { + objectType: 'mutation', type: 'added', hash, state: expectedState, @@ -265,6 +268,7 @@ describe('BaseDataService', () => { 2, `ExampleDataService:cacheUpdated:${hash}`, { + objectType: 'mutation', type: 'added', state: expectedState, }, @@ -318,6 +322,7 @@ describe('BaseDataService', () => { 5, `ExampleDataService:cacheUpdated`, { + objectType: 'query', type: 'updated', hash, state: expectedState, @@ -328,6 +333,7 @@ describe('BaseDataService', () => { 6, `ExampleDataService:cacheUpdated:${hash}`, { + objectType: 'query', type: 'updated', state: expectedState, }, @@ -368,6 +374,7 @@ describe('BaseDataService', () => { 5, `ExampleDataService:cacheUpdated`, { + objectType: 'mutation', type: 'updated', hash, state: expectedState, @@ -378,6 +385,7 @@ describe('BaseDataService', () => { 6, `ExampleDataService:cacheUpdated:${hash}`, { + objectType: 'mutation', type: 'updated', state: expectedState, }, @@ -403,6 +411,7 @@ describe('BaseDataService', () => { 7, `ExampleDataService:cacheUpdated`, { + objectType: 'query', type: 'removed', hash, state: null, @@ -413,6 +422,7 @@ describe('BaseDataService', () => { 8, `ExampleDataService:cacheUpdated:${hash}`, { + objectType: 'query', type: 'removed', state: null, }, @@ -435,6 +445,7 @@ describe('BaseDataService', () => { 7, `ExampleDataService:cacheUpdated`, { + objectType: 'mutation', type: 'removed', hash, state: null, @@ -445,6 +456,7 @@ describe('BaseDataService', () => { 7, `ExampleDataService:cacheUpdated`, { + objectType: 'mutation', type: 'removed', hash, state: null, @@ -455,6 +467,7 @@ describe('BaseDataService', () => { 8, `ExampleDataService:cacheUpdated:${hash}`, { + objectType: 'mutation', type: 'removed', state: null, }, diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 44468df9ba9..b95d53ea98c 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -80,12 +80,15 @@ export type BaseMessenger = Messenger< any >; -export type DataServiceGranularCacheUpdatedPayload = +export type DataServiceGranularCacheUpdatedPayload = { + objectType: 'query' | 'mutation'; +} & ( | { type: 'added' | 'updated'; state: DehydratedState } | { type: 'removed'; state: null; - }; + } +); export type DataServiceCacheUpdatedPayload = DataServiceGranularCacheUpdatedPayload & { @@ -590,15 +593,16 @@ export class BaseDataService< `${this.name}:cacheUpdated` as const, { type: eventType, + objectType, hash, state, } as DataServiceCacheUpdatedPayload, ); - this.#messenger.publish( `${this.name}:cacheUpdated:${hash}` as const, { type: eventType, + objectType, state, } as DataServiceGranularCacheUpdatedPayload, ); From 5ddfe6e3bef86358d415b34e9f9e1218b27a31e2 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 3 Sep 2026 15:16:57 -0600 Subject: [PATCH 16/34] Fix comment with missing word --- packages/react-data-query/src/createUIQueryClient.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-data-query/src/createUIQueryClient.ts b/packages/react-data-query/src/createUIQueryClient.ts index 50c33925e0a..ab48f8575f7 100644 --- a/packages/react-data-query/src/createUIQueryClient.ts +++ b/packages/react-data-query/src/createUIQueryClient.ts @@ -366,9 +366,9 @@ export function createUIQueryClient( messenger.subscribe(`${service}:cacheUpdated:${hash}`, cacheListener); } } else if (event.type === 'observerRemoved' && hasSubscription) { - // We can assume that if an observed is removed, it must have first been - // added; and that when it was added, the observer count was initialized. - // (There's no real way to test the alternative, anyway.) + // We can assume that if an observed mutation is removed, it must have + // first been added; and that when it was added, the observer count was + // initialized. (There's no real way to test the alternative, anyway.) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const remainingObservers = mutationObserverCounts.get(hash)! - 1; From f07ef66f2e1fa3a23a33d2ae6ec4fc0f99f1c73a Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 3 Sep 2026 16:21:51 -0600 Subject: [PATCH 17/34] Export useMutation --- packages/react-data-query/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-data-query/src/index.ts b/packages/react-data-query/src/index.ts index 00a1a60ab4e..ac6798a6e96 100644 --- a/packages/react-data-query/src/index.ts +++ b/packages/react-data-query/src/index.ts @@ -1,2 +1,2 @@ export { createUIQueryClient } from './createUIQueryClient.js'; -export { useQuery, useInfiniteQuery } from './hooks.js'; +export { useQuery, useInfiniteQuery, useMutation } from './hooks.js'; From 5bae87922b69a5f2ad4090e436b1b378b38e8e2e Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Fri, 4 Sep 2026 11:12:32 -0600 Subject: [PATCH 18/34] Fully revert 'istanbul ignore' lines --- packages/base-data-service/src/BaseDataService.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index b95d53ea98c..7eb10357e6e 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -256,9 +256,9 @@ export class BaseDataService< this.#persistenceConfig && debounce( () => { - this.#persistCache().catch((error) => + this.#persistCache().catch( /* istanbul ignore next */ - this.#messenger.captureException?.(error), + (error) => this.#messenger.captureException?.(error), ); }, this.#persistenceConfig.writeDelay ?? @@ -539,9 +539,9 @@ export class BaseDataService< * Initialize the service, rehydrating the cache with persisted data if possible. */ init(): void { - this.#loadCache().catch((error) => + this.#loadCache().catch( /* istanbul ignore next */ - this.#messenger.captureException?.(error), + (error) => this.#messenger.captureException?.(error), ); } From e2b72303f197f73d3103bd7c7e703fa5acb4c750 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Fri, 4 Sep 2026 11:15:40 -0600 Subject: [PATCH 19/34] Wrap comment --- packages/base-data-service/src/BaseDataService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 7eb10357e6e..04317aeb4df 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -553,8 +553,8 @@ export class BaseDataService< this.#debouncedPersist?.cancel(); this.#queryCacheUnsubscribe(); this.#mutationCacheUnsubscribe(); - // `QueryClient.clear()` clears both caches, but `MutationCache.clear()` only - // drops its references to mutations without clearing their pending + // `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()) { From 10b592817438fa620ba5bc25ba839c07b1305753 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Fri, 4 Sep 2026 11:17:59 -0600 Subject: [PATCH 20/34] Simplify check in mutation cache subscription handler --- packages/base-data-service/src/BaseDataService.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 04317aeb4df..5708af9bb52 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -291,9 +291,8 @@ export class BaseDataService< .subscribe((event) => { log('Mutation cache event emitted', event); if ( - event.mutation && ['added', 'updated', 'removed'].includes(event.type) && - event.mutation.options.mutationKey !== undefined + event.mutation?.options.mutationKey !== undefined ) { const mutationHash = hashKey(event.mutation.options.mutationKey); this.#publishCacheUpdate( From 9539ca9ddf041d6c3c16f67381e7001ef40e7428 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Fri, 4 Sep 2026 11:25:31 -0600 Subject: [PATCH 21/34] Remove extraneous comments --- packages/base-data-service/tests/ExampleDataService.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts index cc6a8f170a0..f55d0ff2f91 100644 --- a/packages/base-data-service/tests/ExampleDataService.ts +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -210,8 +210,6 @@ export class ExampleDataService extends BaseDataService< }); if (!response.ok) { - // NOTE: Can't use HttpError from controller-utils due to lint:tsc not - // being fully rolled out across the monorepo. throw new Error( `Mutation failed with status code: ${response.status}.`, ); @@ -251,8 +249,6 @@ export class ExampleDataService extends BaseDataService< }); if (!response.ok) { - // NOTE: Can't use HttpError from controller-utils due to lint:tsc not - // being fully rolled out across the monorepo. throw new Error( `Creating data deletion task failed with status '${response.status}'`, ); From 2473a50fbb0c1e5f8b016b87b2460b427d561d90 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Fri, 4 Sep 2026 12:54:10 -0600 Subject: [PATCH 22/34] Refactor the conditional into processMutationResponse --- .../base-data-service/src/BaseDataService.ts | 28 +++++------- packages/base-data-service/src/utils.ts | 43 ++++++++++++++++--- 2 files changed, 48 insertions(+), 23 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 5708af9bb52..306e46c44f6 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -458,11 +458,11 @@ export class BaseDataService< */ protected async executeMutation< TMutationFnData extends Json, - // We have to use `Struct` here, as using `Struct` - // (or even `Struct`) 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`. + // We have to use `Struct` here, as using `Struct` (or + // even `Struct`) would reject a more concrete, "real world" struct. + // The reason is that `Struct` is an object type with methods whose signatures + // feature the struct's content type, making `Struct` contravariant in its + // content type. The only way to get around this is to use `any`. // eslint-disable-next-line @typescript-eslint/no-explicit-any TDataStruct extends Struct | undefined = undefined, TData = TDataStruct extends Struct @@ -499,19 +499,11 @@ export class BaseDataService< const response = await this.#policy.circuitBreakerPolicy.execute(() => mutationFn(...args), ); - const data = responseStruct - ? processMutationResponse( - options.mutationKey, - response, - responseStruct, - ) - : response; - // Type assertion: `TData` is a conditional default that resolves to the - // struct's decoded type when a struct is provided and `TMutationFnData` - // otherwise, which mirrors the two branches above. TypeScript cannot - // relate a value to an unresolved conditional type parameter, so we - // assert the correspondence that this method's own generics guarantee. - return data as unknown as TData; + return processMutationResponse( + options.mutationKey, + response, + responseStruct, + ); }, }); // We purposely pass an empty set of variables because this method is diff --git a/packages/base-data-service/src/utils.ts b/packages/base-data-service/src/utils.ts index 7da18054d4f..e7d22accc8e 100644 --- a/packages/base-data-service/src/utils.ts +++ b/packages/base-data-service/src/utils.ts @@ -40,13 +40,45 @@ export function processQueryResponse( * @returns The mutation response, coerced by Superstruct if needed. * @throws If the mutation response does not match the struct. * @template InputResponse - The type of the response data being validated, e.g. `Json`. - * @template OutputResponse - The type of the response data after validation, e.g. `FetchOrdersResponse`. + * @template ResponseStruct - The struct used to validate and decode the response, + * e.g. `Struct`, or `undefined` when no validation is needed. */ -export function processMutationResponse( +export function processMutationResponse< + InputResponse, + // We have to use `Struct` here, as using `Struct` (or + // even `Struct`) would reject a more concrete, "real world" struct. + // The reason is that `Struct` is an object type with methods whose signatures + // feature the struct's content type, making `Struct` contravariant in its + // content type. The only way to get around this is to use `any`. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ResponseStruct extends Struct | undefined = undefined, +>( mutationKey: MutationKey, response: InputResponse, - struct: Struct, -): OutputResponse { + struct?: ResponseStruct, + // We don't want this to be a type parameter as it should not be customizable + // (it is only derived). +): ResponseStruct extends Struct + ? StructType + : InputResponse { + // Because we aren't defining the return type as a type parameter, + // we need to restate it so we can reuse it below. + type Response = + ResponseStruct extends Struct + ? StructType + : InputResponse; + + if (!struct) { + // Type assertion: TypeScript cannot "see" that when `struct` is + // `undefined`, `response` satisfies the `InputResponse` branch of + // `Response` (even though we stated this fact in the conditional type + // above). This is because at this point in time, `ResponseStruct` — being a + // generic — is unresolved, which makes `Response` unresolved too. This is a + // limitation of the way that generics work. Therefore, we need to help + // TypeScript out. + return response as unknown as Response; + } + const [error, result] = validate(response, struct); if (error) { @@ -55,5 +87,6 @@ export function processMutationResponse( ); } - return result; + // Type assertion: See above. + return result as unknown as Response; } From be2b8bfe42cadf2ed84e62c28f27726a49020f81 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Fri, 4 Sep 2026 14:18:23 -0600 Subject: [PATCH 23/34] Correlate UI and service mutations with a globalId The UI query client deduplicated mutations by mutation key alone, which could misroute service :cacheUpdated state onto the wrong UI mutation when multiple mutations shared a key, orphan concurrent mutations, and clobber mutations that used a custom mutationFn. Introduce a UI-generated globalId that uniquely correlates one UI mutation with the service-side mutation it triggers. The UI stores the globalId on the mutation's meta, passes it as the trailing action argument, and matches incoming cache updates strictly by it. executeMutation accepts an optional globalId and echoes it back on the service-side mutation's meta so it rides along in dehydrated payloads. --- packages/base-data-service/CHANGELOG.md | 1 + .../src/BaseDataService.test.ts | 42 ++++++ .../base-data-service/src/BaseDataService.ts | 13 ++ .../tests/ExampleDataService.ts | 8 +- packages/react-data-query/CHANGELOG.md | 2 + packages/react-data-query/package.json | 3 +- .../src/createUIQueryClient.test.ts | 124 ++++++++++++++---- .../src/createUIQueryClient.ts | 94 +++++++++---- yarn.lock | 1 + 9 files changed, 238 insertions(+), 50 deletions(-) diff --git a/packages/base-data-service/CHANGELOG.md b/packages/base-data-service/CHANGELOG.md index 8a4d66d2a6a..77cb24a64df 100644 --- a/packages/base-data-service/CHANGELOG.md +++ b/packages/base-data-service/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `executeMutation` to `BaseDataService` to allow for making server-state-mutating requests ([#9324](https://github.com/MetaMask/core/pull/9324)) - These kinds of requests are never retried, unlike queries. + - `executeMutation` accepts an optional `globalId` which, when provided, is stored on the mutation's `meta` and echoed back in `:cacheUpdated` payloads so a UI query client can correlate the service-side mutation with the specific UI mutation that triggered it. - Also export `MutationKey` type. - The payload for `:cacheUpdated` and `:cacheUpdated:${hash}` events now includes an `objectType` property, which is either "query" or "mutation" ([#9324](https://github.com/MetaMask/core/pull/9324)) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 243184aa266..28330e3fa7b 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -275,6 +275,48 @@ describe('BaseDataService', () => { ); }); + it('includes the `globalId` in the mutation meta of `:cacheUpdated` payloads when one is passed', async () => { + mockAddFollowerRequest(); + const messenger = createServiceMessenger(); + const service = new ExampleDataService(messenger); + const publishSpy = jest.spyOn(messenger, 'publish'); + + await service.addFollower('1', 'global-id-1'); + + const hash = hashKey(['ExampleDataService:addFollower', '1']); + expect(publishSpy).toHaveBeenCalledWith( + `ExampleDataService:cacheUpdated:${hash}`, + expect.objectContaining({ + state: expect.objectContaining({ + mutations: [ + expect.objectContaining({ + meta: { globalId: 'global-id-1' }, + }), + ], + }), + }), + ); + }); + + it('omits `globalId` from the mutation meta of `:cacheUpdated` payloads when none is passed', async () => { + mockAddFollowerRequest(); + const messenger = createServiceMessenger(); + const service = new ExampleDataService(messenger); + const publishSpy = jest.spyOn(messenger, 'publish'); + + await service.addFollower('1'); + + const hash = hashKey(['ExampleDataService:addFollower', '1']); + expect(publishSpy).toHaveBeenCalledWith( + `ExampleDataService:cacheUpdated:${hash}`, + expect.objectContaining({ + state: expect.objectContaining({ + mutations: [expect.not.objectContaining({ meta: expect.anything() })], + }), + }), + ); + }); + it('emits `:cacheUpdated` events when query cache is updated', async () => { const messenger = createServiceMessenger(); const service = new ExampleDataService(messenger); diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 306e46c44f6..e908e7495a3 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -454,6 +454,10 @@ export class BaseDataService< * 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. + * @param options.globalId - An optional identifier, generated by the UI query + * client, that correlates this service-side mutation with the specific UI + * mutation that triggered it. When provided, it is stored on the mutation's + * `meta` so it is included in dehydrated `:cacheUpdated` payloads. * @returns The mutation results. */ protected async executeMutation< @@ -474,6 +478,7 @@ export class BaseDataService< >({ mutationFn, responseStruct, + globalId, ...options }: OmitKeyof< MutationOptions, TOnMutateResult>, @@ -482,6 +487,7 @@ export class BaseDataService< mutationKey: TMutationKey; mutationFn: MutationFunction>; responseStruct?: TDataStruct; + globalId?: string; }): Promise { const mutationCache = this.#queryClient.getMutationCache(); const mutation = mutationCache.build< @@ -491,6 +497,13 @@ export class BaseDataService< TOnMutateResult >(this.#queryClient, { ...options, + // A `globalId` correlates this service-side mutation with the specific UI + // mutation that triggered it. We store it on `meta` so it rides along in + // dehydrated `:cacheUpdated` payloads, where the UI side uses it to find + // and update the exact mutation it created. + ...(globalId !== undefined && { + meta: { ...options.meta, globalId }, + }), mutationFn: async (...args) => { // To guard against mutations being retried, we deliberately execute the // mutation function through the circuit breaker policy alone, rather diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts index f55d0ff2f91..e0e9732634b 100644 --- a/packages/base-data-service/tests/ExampleDataService.ts +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -197,9 +197,13 @@ export class ExampleDataService extends BaseDataService< ); } - async addFollower(followerId: string): Promise { + async addFollower( + followerId: string, + globalId?: string, + ): Promise { return this.executeMutation({ mutationKey: [`${this.name}:addFollower`, followerId], + globalId, mutationFn: async () => { const url = new URL(`${this.#socialBaseUrl}/api/v1/users/me/follows`); @@ -225,6 +229,7 @@ export class ExampleDataService extends BaseDataService< async createDataDeletionTask( analyticsId: string, segmentSourceId: string, + globalId?: string, ): Promise<{ status: 'ok' | 'error'; regulateId: string; @@ -235,6 +240,7 @@ export class ExampleDataService extends BaseDataService< analyticsId, segmentSourceId, ], + globalId, mutationFn: async () => { const url = `${this.#segmentRegulationsUrl}/regulations/sources/${segmentSourceId}`; diff --git a/packages/react-data-query/CHANGELOG.md b/packages/react-data-query/CHANGELOG.md index 4e8dfe59947..fba006f07f3 100644 --- a/packages/react-data-query/CHANGELOG.md +++ b/packages/react-data-query/CHANGELOG.md @@ -12,9 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add support for executing mutations by updating `createUIQueryClient` and adding `useMutation` wrapper ([#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 via the UI query client and pass a reference to the action as the mutation key. - Like `fetchQuery` and `fetchInfiniteQuery`, the `useMutation` wrapper disables retries by default and enforces that `mutationKey` matches the same thing that `executeMutation` takes. + - `createUIQueryClient` now tags each mutation it creates with a unique `globalId` (stored on the mutation's `meta`) and passes it to the data service action as the trailing argument. It uses this `globalId` to update the exact UI mutation that a `:cacheUpdated` event corresponds to, so mutations sharing a `mutationKey` no longer clobber one another, and mutations that use a custom `mutationFn` are left untouched. ### Changed +- Add `uuid` `^8.3.2` as a dependency ([#9324](https://github.com/MetaMask/core/pull/9324)) - Bump `@metamask/utils` from `^11.11.0` to `^11.12.0` ([#10076](https://github.com/MetaMask/core/pull/10076)) ## [1.0.0] diff --git a/packages/react-data-query/package.json b/packages/react-data-query/package.json index e10ab7bb4b3..e3f12f5c0e1 100644 --- a/packages/react-data-query/package.json +++ b/packages/react-data-query/package.json @@ -56,7 +56,8 @@ "@metamask/base-data-service": "^1.0.0", "@metamask/utils": "^11.12.0", "@tanstack/query-core": "^5.62.16", - "@tanstack/react-query": "^5.62.16" + "@tanstack/react-query": "^5.62.16", + "uuid": "^8.3.2" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", diff --git a/packages/react-data-query/src/createUIQueryClient.test.ts b/packages/react-data-query/src/createUIQueryClient.test.ts index e9e7aee4f40..8b7884b9c57 100644 --- a/packages/react-data-query/src/createUIQueryClient.test.ts +++ b/packages/react-data-query/src/createUIQueryClient.test.ts @@ -14,6 +14,7 @@ import { inMilliseconds, } from '@metamask/utils'; import { + hashKey, InfiniteData, InfiniteQueryObserver, // MutationObserver is part of the Web API and is therefore a global @@ -22,6 +23,7 @@ import { QueryClientConfig, QueryObserver, } from '@tanstack/query-core'; +import assert from 'assert'; import { ReplyBody } from 'nock'; import { @@ -316,44 +318,119 @@ describe('createUIQueryClient', () => { service.destroy(); }); - it('does not accumulate duplicate mutations in the cache when the service emits cache updates', async () => { - const { clientA, clientB, service } = createClients(); + it('assigns each mutation a distinct `globalId` so concurrent mutations sharing a key stay independent', async () => { + const { clientA: client, service } = createClients(); mockAddFollowerRequest(); mockAddFollowerRequest(); const observerA = new TanStackQueryMutationObserver( - clientA, - { - mutationKey: addFollowerMutationKey, - }, + client, + { mutationKey: addFollowerMutationKey }, ); const observerB = new TanStackQueryMutationObserver( - clientB, - { - mutationKey: addFollowerMutationKey, - }, + client, + { mutationKey: addFollowerMutationKey }, ); - await observerA.mutate(); - await observerB.mutate(); + await Promise.all([observerA.mutate(), observerB.mutate()]); + + const globalMutationIds = client + .getMutationCache() + .findAll({ mutationKey: addFollowerMutationKey }) + .map((mutation) => mutation.meta?.globalId); - expect( - clientA - .getMutationCache() - .findAll({ mutationKey: addFollowerMutationKey }), - ).toHaveLength(1); - expect( - clientB - .getMutationCache() - .findAll({ mutationKey: addFollowerMutationKey }), - ).toHaveLength(1); + expect(globalMutationIds).toHaveLength(2); + expect(globalMutationIds[0]).toBeDefined(); + expect(globalMutationIds[1]).toBeDefined(); + expect(globalMutationIds[0]).not.toBe(globalMutationIds[1]); observerA.reset(); observerB.reset(); service.destroy(); }); + it('ignores :cacheUpdated events whose referenced mutation carries no `globalId`', async () => { + const { clientA: client, messenger, service } = createClients(); + + mockAddFollowerRequest(); + + const observer = new TanStackQueryMutationObserver( + client, + { mutationKey: addFollowerMutationKey }, + ); + + await observer.mutate(); + + const mutationFromUi = client + .getMutationCache() + .find({ mutationKey: addFollowerMutationKey }); + assert(mutationFromUi); + const stateBeforeCacheUpdated = mutationFromUi.state; + const dehydratedMutationFromDataService = { + mutationKey: addFollowerMutationKey, + state: { + context: undefined, + data: { followed: [] }, + error: null, + failureCount: 0, + failureReason: null, + isPaused: false, + status: 'success' as const, + submittedAt: 0, + variables: undefined, + }, + }; + + const hash = hashKey(addFollowerMutationKey); + messenger.publish(`ExampleDataService:cacheUpdated:${hash}`, { + objectType: 'mutation', + type: 'updated', + state: { + queries: [], + mutations: [dehydratedMutationFromDataService], + }, + }); + + const mutationFromUi2 = client + .getMutationCache() + .find({ mutationKey: addFollowerMutationKey }); + assert(mutationFromUi2); + expect(mutationFromUi2.state).toBe(stateBeforeCacheUpdated); + + observer.reset(); + service.destroy(); + }); + + it('preserves mutations that share a mutation key with an action on the data service but were not actually routed through the data service', async () => { + const { clientA: client, service } = createClients(); + + mockAddFollowerRequest(); + + const serviceObserver = + new TanStackQueryMutationObserver(client, { + mutationKey: addFollowerMutationKey, + }); + await serviceObserver.mutate(); + + const customObserver = new TanStackQueryMutationObserver(client, { + mutationKey: addFollowerMutationKey, + mutationFn: async (): Promise => 'custom-result', + }); + await customObserver.mutate(); + + const mutations = client + .getMutationCache() + .findAll({ mutationKey: addFollowerMutationKey }); + + expect(mutations).toHaveLength(2); + expect(mutations[1].state.data).toBe('custom-result'); + + customObserver.reset(); + serviceObserver.reset(); + service.destroy(); + }); + it('reports a synced mutation as a success when it succeeded on the service', async () => { const { clientA, clientB, service } = createClients(); @@ -439,6 +516,9 @@ describe('createUIQueryClient', () => { service.destroy(); }); + // TODO: Add test for pending + // TODO: Add test for continue + it('fetches queries using observers in the same client', async () => { const { clientA, service } = createClients(); diff --git a/packages/react-data-query/src/createUIQueryClient.ts b/packages/react-data-query/src/createUIQueryClient.ts index ab48f8575f7..5cb9d2e08b3 100644 --- a/packages/react-data-query/src/createUIQueryClient.ts +++ b/packages/react-data-query/src/createUIQueryClient.ts @@ -15,6 +15,7 @@ import { DehydratedState, MutationState, } from '@tanstack/query-core'; +import { v4 as uuidV4 } from 'uuid'; import { createModuleLogger, projectLogger } from './loggers.js'; @@ -64,6 +65,24 @@ type MessengerAdapter = { ): void; }; +/** + * Read the `globalId` correlation token from a mutation's `meta`. + * + * The UI query client generates a `globalId` for each mutation it creates and + * threads it through the data service, which stores it on its own mutation's + * `meta`. Because TanStack's `MutationMeta` is an open record, the value reads + * as `unknown`, so we narrow it to a string here. + * + * @param meta - The mutation `meta`, if any. + * @returns The `globalId` if present and a string, otherwise undefined. + */ +function readGlobalId( + meta: Record | undefined, +): string | undefined { + const globalId = meta?.globalId; + return typeof globalId === 'string' ? globalId : undefined; +} + /** * Load a dehydrated mutation cache into a query client. * @@ -80,13 +99,14 @@ type MessengerAdapter = { * are discrete events/attempts, and `mutationKey` is used by observers to find * mutations, not enforce uniqueness. * - * However, our situation is a bit unusual: we're using a mutation key as a - * stable shared identity between the service-side and UI-side caches (more - * query-like), which is exactly the case `hydrate` doesn't support. - * - * Instead of appending new mutations to the query client, this function reuses - * the mutation that already exists for a given key, updating its state to match - * the service. + * Because a mutation key is not unique, it cannot on its own tell us which UI + * mutation a service cache update belongs to: multiple mutations may share a + * key, and a mutation created with a custom `mutationFn` may reuse a key + * without ever going through a data service. To correlate the two caches, the + * UI query client tags each mutation it creates with a unique `globalId` and + * threads it through the data service, which echoes it back on the mutation's + * `meta`. This function updates the exact UI mutation carrying that `globalId`, + * and ignores mutations that carry none. * * @param client - The UI query client whose mutation cache should be hydrated. * @param dehydratedState - The dehydrated state emitted by the data service. @@ -98,16 +118,22 @@ function hydrateMutations( const mutationCache = client.getMutationCache(); for (const dehydratedMutation of dehydratedState.mutations) { - const { mutationKey, state } = dehydratedMutation; + const { mutationKey, state, meta } = dehydratedMutation; + + const globalId = readGlobalId(meta); // 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) { + // `mutationKey`, and only mutations that originated in the UI query client + // carry a `globalId`. Without both, we cannot correlate the update with a + // UI mutation, so we skip it. + if (!mutationKey || !globalId) { continue; } - const existingMutation = mutationCache.find({ mutationKey }); + const existingMutation = mutationCache.find({ + mutationKey, + predicate: (mutation) => readGlobalId(mutation.meta) === globalId, + }); // 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 @@ -433,25 +459,41 @@ export function createUIQueryClient( options?: Options, ): Options => { const defaultedOptions = originalDefaultMutationOptions(options); - defaultedOptions.mutationFn ??= async (): Promise => { - const { mutationKey } = defaultedOptions; - assert( - mutationKey !== undefined, - "You must pass a `mutationKey` that calls an action on the messenger provided to `createUIQueryClient`, e.g. `mutationKey: ['ExampleDataService:createOrder', ...]`.", - ); + // Only mutations that fall back to the data-service default `mutationFn` + // need a `globalId` to correlate the UI and service caches. A mutation with + // a custom `mutationFn` never reaches a data service, so we leave it alone. + if (defaultedOptions.mutationFn === undefined) { + // Generate the `globalId` once and memoize it on `meta` so it stays + // stable across the mutation's lifetime and can be matched against + // incoming cache updates. + const globalId = readGlobalId(defaultedOptions.meta) ?? uuidV4(); + defaultedOptions.meta = { ...defaultedOptions.meta, globalId }; + + defaultedOptions.mutationFn = async (): Promise => { + const { mutationKey } = defaultedOptions; + + assert( + mutationKey !== undefined, + "You must pass a `mutationKey` that calls an action on the messenger provided to `createUIQueryClient`, e.g. `mutationKey: ['ExampleDataService:createOrder', ...]`.", + ); - const [action, ...params] = mutationKey; + const [action, ...params] = mutationKey; - assert( - typeof action === 'string' && isRecognizedDataServiceAction(action), - "You must pass a `mutationKey` that calls an action on the messenger provided to `createUIQueryClient`, e.g. `mutationKey: ['ExampleDataService:createOrder', ...]`.", - ); + assert( + typeof action === 'string' && isRecognizedDataServiceAction(action), + "You must pass a `mutationKey` that calls an action on the messenger provided to `createUIQueryClient`, e.g. `mutationKey: ['ExampleDataService:createOrder', ...]`.", + ); - log(`Detected mutation request, calling action: "${action}"`); + log(`Detected mutation request, calling action: "${action}"`); + + // The `globalId` is passed as the trailing action argument. Each data + // service mutation method forwards it into `executeMutation`, which + // echoes it back on the service-side mutation's `meta`. + return await messenger.call(action, ...(params as Json[]), globalId); + }; + } - return await messenger.call(action, ...(params as Json[])); - }; return defaultedOptions; }; diff --git a/yarn.lock b/yarn.lock index a7e8222593b..acd58d2316f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8587,6 +8587,7 @@ __metadata: typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" + uuid: "npm:^8.3.2" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 From bc73f7234bef33646e834c2e05bbdf892332bce8 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Fri, 4 Sep 2026 15:12:21 -0600 Subject: [PATCH 24/34] Extract hydrateMutations into its own file and test it directly hydrateMutations, deriveMutationAction, and readGlobalId lived inside createUIQueryClient and were only exercised indirectly through integration tests. Extract them into hydrateMutations.ts and add a dedicated unit test that covers every deriveMutationAction branch, including the previously-untested pending and idle (continue) cases. Remove the integration tests from createUIQueryClient.test.ts that only existed to reach the derive-action logic, keeping one that verifies the full round-trip synchronization between clients. --- .../src/createUIQueryClient.test.ts | 88 ------- .../src/createUIQueryClient.ts | 122 +-------- .../src/hydrateMutations.test.ts | 235 ++++++++++++++++++ .../react-data-query/src/hydrateMutations.ts | 124 +++++++++ 4 files changed, 360 insertions(+), 209 deletions(-) create mode 100644 packages/react-data-query/src/hydrateMutations.test.ts create mode 100644 packages/react-data-query/src/hydrateMutations.ts diff --git a/packages/react-data-query/src/createUIQueryClient.test.ts b/packages/react-data-query/src/createUIQueryClient.test.ts index 8b7884b9c57..9b381bcc08f 100644 --- a/packages/react-data-query/src/createUIQueryClient.test.ts +++ b/packages/react-data-query/src/createUIQueryClient.test.ts @@ -431,94 +431,6 @@ describe('createUIQueryClient', () => { service.destroy(); }); - it('reports a synced mutation as a success when it succeeded on the service', async () => { - const { clientA, clientB, service } = createClients(); - - mockAddFollowerRequest(); - mockAddFollowerRequest(); - - const observerA = new TanStackQueryMutationObserver( - clientA, - { - mutationKey: addFollowerMutationKey, - }, - ); - const observerB = new TanStackQueryMutationObserver( - clientB, - { - mutationKey: addFollowerMutationKey, - }, - ); - - const syncedActions: { type: string }[] = []; - clientB.getMutationCache().subscribe((event) => { - if (event.type === 'updated') { - syncedActions.push(event.action); - } - }); - - await observerA.mutate(); - await observerB.mutate(); - - expect(syncedActions).toContainEqual({ - type: 'success', - data: DEFAULT_ADD_FOLLOWER_REPLY.body, - }); - - observerA.reset(); - observerB.reset(); - service.destroy(); - }); - - it('reports a synced mutation as an error when it failed on the service', async () => { - const { clientA, clientB, service } = createClients(); - - mockAddFollowerRequest({ mockReply: { status: 500 } }); - mockAddFollowerRequest({ mockReply: { status: 500 } }); - - const observerA = new TanStackQueryMutationObserver( - clientA, - { - mutationKey: addFollowerMutationKey, - }, - ); - const observerB = new TanStackQueryMutationObserver( - clientB, - { - mutationKey: addFollowerMutationKey, - }, - ); - - const syncedActions: { type: string }[] = []; - clientB.getMutationCache().subscribe((event) => { - if (event.type === 'updated') { - syncedActions.push(event.action); - } - }); - - await expect(observerA.mutate()).rejects.toThrow( - 'Mutation failed with status code: 500.', - ); - await expect(observerB.mutate()).rejects.toThrow( - 'Mutation failed with status code: 500.', - ); - - expect(syncedActions).toContainEqual({ - type: 'error', - error: new Error('Mutation failed with status code: 500.'), - }); - expect(syncedActions).not.toContainEqual( - expect.objectContaining({ type: 'success' }), - ); - - observerA.reset(); - observerB.reset(); - service.destroy(); - }); - - // TODO: Add test for pending - // TODO: Add test for continue - it('fetches queries using observers in the same client', async () => { const { clientA, service } = createClients(); diff --git a/packages/react-data-query/src/createUIQueryClient.ts b/packages/react-data-query/src/createUIQueryClient.ts index 5cb9d2e08b3..0a8d823bfef 100644 --- a/packages/react-data-query/src/createUIQueryClient.ts +++ b/packages/react-data-query/src/createUIQueryClient.ts @@ -12,11 +12,10 @@ import { QueryKey, QueryClientConfig, MutationOptions, - DehydratedState, - MutationState, } from '@tanstack/query-core'; import { v4 as uuidV4 } from 'uuid'; +import { hydrateMutations, readGlobalId } from './hydrateMutations.js'; import { createModuleLogger, projectLogger } from './loggers.js'; const log = createModuleLogger(projectLogger, 'createUIQueryClient'); @@ -65,125 +64,6 @@ type MessengerAdapter = { ): void; }; -/** - * Read the `globalId` correlation token from a mutation's `meta`. - * - * The UI query client generates a `globalId` for each mutation it creates and - * threads it through the data service, which stores it on its own mutation's - * `meta`. Because TanStack's `MutationMeta` is an open record, the value reads - * as `unknown`, so we narrow it to a string here. - * - * @param meta - The mutation `meta`, if any. - * @returns The `globalId` if present and a string, otherwise undefined. - */ -function readGlobalId( - meta: Record | undefined, -): string | undefined { - const globalId = meta?.globalId; - return typeof globalId === 'string' ? globalId : undefined; -} - -/** - * Load a dehydrated mutation cache into a query client. - * - * TanStack Query's own `hydrate` matches dehydrated queries against the cache - * by hash and updates them in place, but it always inserts a brand-new mutation - * for every dehydrated mutation. Because data services emit a cache update on - * every `added`/`updated` mutation event, calling `hydrate` directly would - * append a fresh mutation to each subscribed query client on every event, so - * the cache would grow without bound, and a found mutation could be stale. - * - * This behavior for `hydrate` makes sense because TanStack treats queries and - * mutations differently. Queries are deduplicated: two attempts for the same - * query using the same query key show up once in the query cache. But mutations - * are discrete events/attempts, and `mutationKey` is used by observers to find - * mutations, not enforce uniqueness. - * - * Because a mutation key is not unique, it cannot on its own tell us which UI - * mutation a service cache update belongs to: multiple mutations may share a - * key, and a mutation created with a custom `mutationFn` may reuse a key - * without ever going through a data service. To correlate the two caches, the - * UI query client tags each mutation it creates with a unique `globalId` and - * threads it through the data service, which echoes it back on the mutation's - * `meta`. This function updates the exact UI mutation carrying that `globalId`, - * and ignores mutations that carry none. - * - * @param client - The UI query client whose mutation cache should be hydrated. - * @param dehydratedState - The dehydrated state emitted by the data service. - */ -function hydrateMutations( - client: QueryClient, - dehydratedState: DehydratedState, -): void { - const mutationCache = client.getMutationCache(); - - for (const dehydratedMutation of dehydratedState.mutations) { - const { mutationKey, state, meta } = dehydratedMutation; - - const globalId = readGlobalId(meta); - - // A data service only publishes cache updates for mutations that have a - // `mutationKey`, and only mutations that originated in the UI query client - // carry a `globalId`. Without both, we cannot correlate the update with a - // UI mutation, so we skip it. - if (!mutationKey || !globalId) { - continue; - } - - const existingMutation = mutationCache.find({ - mutationKey, - predicate: (mutation) => readGlobalId(mutation.meta) === globalId, - }); - - // 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. - * @returns The action describing the state. - */ -function deriveMutationAction( - state: MutationState, -): - | { type: 'success'; data: unknown } - | { type: 'error'; error: unknown } - | { type: 'pending'; variables: unknown; context: unknown; isPaused: boolean } - | { type: 'continue' } { - switch (state.status) { - case 'success': - return { type: 'success', data: state.data }; - case 'error': - // A mutation in the `error` state always carries a non-null `error`. - return { type: 'error', error: state.error }; - case 'pending': - return { - type: 'pending', - variables: state.variables, - context: state.context, - isPaused: state.isPaused, - }; - // The `idle` status carries no data, error, or variables, so a neutral - // `continue` action refreshes subscribers without implying a result. - default: - return { type: 'continue' }; - } -} - /** * Create a QueryClient that queries and subscribes to data services using a * messenger adapter. This is a messenger-like object that carries some diff --git a/packages/react-data-query/src/hydrateMutations.test.ts b/packages/react-data-query/src/hydrateMutations.test.ts new file mode 100644 index 00000000000..2092bbfac83 --- /dev/null +++ b/packages/react-data-query/src/hydrateMutations.test.ts @@ -0,0 +1,235 @@ +import { + DehydratedState, + Mutation, + MutationState, + QueryClient, +} from '@tanstack/query-core'; + +import { hydrateMutations } from './hydrateMutations.js'; + +type MutationCacheAction = { + type: string; + [key: string]: unknown; +}; + +const MUTATION_KEY = ['ExampleDataService:addFollower', '1']; + +describe('hydrateMutations', () => { + it('updates the mutation whose `globalId` matches the dehydrated mutation', () => { + const client = new QueryClient(); + buildUiMutation(client, { globalId: 'global-id-1' }); + + hydrateMutations( + client, + createDehydratedState({ + globalId: 'global-id-1', + state: createMutationState({ status: 'success', data: 'result' }), + }), + ); + + const mutation = client.getMutationCache().find({ + mutationKey: MUTATION_KEY, + }); + expect(mutation?.state.status).toBe('success'); + expect(mutation?.state.data).toBe('result'); + }); + + it('leaves untouched a mutation whose `globalId` does not match', () => { + const client = new QueryClient(); + const mutation = buildUiMutation(client, { globalId: 'global-id-1' }); + const stateBefore = mutation.state; + + hydrateMutations( + client, + createDehydratedState({ + globalId: 'a-different-global-id', + state: createMutationState({ status: 'success', data: 'result' }), + }), + ); + + expect(mutation.state).toBe(stateBefore); + }); + + it('ignores dehydrated mutations that carry no `globalId`', () => { + const client = new QueryClient(); + const mutation = buildUiMutation(client, { globalId: 'global-id-1' }); + const stateBefore = mutation.state; + + hydrateMutations(client, { + queries: [], + mutations: [ + { + mutationKey: MUTATION_KEY, + state: createMutationState({ status: 'success', data: 'result' }), + }, + ], + }); + + expect(mutation.state).toBe(stateBefore); + }); + + it('ignores dehydrated mutations that carry a non-string `globalId`', () => { + const client = new QueryClient(); + const mutation = buildUiMutation(client, { globalId: 'global-id-1' }); + const stateBefore = mutation.state; + + hydrateMutations(client, { + queries: [], + mutations: [ + { + mutationKey: MUTATION_KEY, + meta: { globalId: 42 }, + state: createMutationState({ status: 'success', data: 'result' }), + }, + ], + }); + + expect(mutation.state).toBe(stateBefore); + }); + + it('notifies subscribers with a `success` action when the mutation succeeded', () => { + const actions = captureNotifyActions('global-id-1', { + status: 'success', + data: 'result', + }); + + expect(actions).toContainEqual({ type: 'success', data: 'result' }); + }); + + it('notifies subscribers with an `error` action when the mutation failed', () => { + const error = new Error('boom'); + + const actions = captureNotifyActions('global-id-1', { + status: 'error', + error, + }); + + expect(actions).toContainEqual({ type: 'error', error }); + }); + + it('notifies subscribers with a `pending` action while the mutation is pending', () => { + const actions = captureNotifyActions('global-id-1', { + status: 'pending', + variables: { followerId: '1' }, + context: { previous: null }, + isPaused: true, + }); + + expect(actions).toContainEqual({ + type: 'pending', + variables: { followerId: '1' }, + context: { previous: null }, + isPaused: true, + }); + }); + + it('notifies subscribers with a `continue` action when the mutation is idle', () => { + const actions = captureNotifyActions('global-id-1', { + status: 'idle', + }); + + expect(actions).toContainEqual({ type: 'continue' }); + }); +}); + +/** + * Build a mutation in a client's mutation cache, tagged with a `globalId`, to + * stand in for a mutation the UI query client created. + * + * @param client - The client whose cache the mutation is built in. + * @param options - The options. + * @param options.globalId - The `globalId` to tag the mutation with. + * @returns The built mutation. + */ +function buildUiMutation( + client: QueryClient, + { globalId }: { globalId: string }, +): Mutation { + return client.getMutationCache().build(client, { + mutationKey: MUTATION_KEY, + meta: { globalId }, + }); +} + +/** + * Build a dehydrated mutation cache carrying a single mutation for the shared + * mutation key, tagged with a `globalId`. + * + * @param options - The options. + * @param options.globalId - The `globalId` to tag the dehydrated mutation with. + * @param options.state - The state of the dehydrated mutation. + * @returns The dehydrated state. + */ +function createDehydratedState({ + globalId, + state, +}: { + globalId: string; + state: MutationState; +}): DehydratedState { + return { + queries: [], + mutations: [ + { + mutationKey: MUTATION_KEY, + meta: { globalId }, + state, + }, + ], + }; +} + +/** + * Build a mutation state, filling in the fields that are irrelevant to the test + * with neutral defaults. + * + * @param overrides - The state fields the test cares about. + * @returns The mutation state. + */ +function createMutationState(overrides: Partial): MutationState { + return { + context: undefined, + data: undefined, + error: null, + failureCount: 0, + failureReason: null, + isPaused: false, + status: 'idle', + submittedAt: 0, + variables: undefined, + ...overrides, + }; +} + +/** + * Hydrate a UI mutation with a service mutation of a given state and collect the + * actions that the mutation cache notifies its subscribers with. + * + * @param globalId - The `globalId` shared by the UI and service mutations. + * @param stateOverrides - The state fields to give the service mutation. + * @returns The captured notify actions. + */ +function captureNotifyActions( + globalId: string, + stateOverrides: Partial, +): MutationCacheAction[] { + const client = new QueryClient(); + buildUiMutation(client, { globalId }); + + const actions: MutationCacheAction[] = []; + client.getMutationCache().subscribe((event) => { + if (event.type === 'updated') { + actions.push(event.action); + } + }); + + hydrateMutations( + client, + createDehydratedState({ + globalId, + state: createMutationState(stateOverrides), + }), + ); + + return actions; +} diff --git a/packages/react-data-query/src/hydrateMutations.ts b/packages/react-data-query/src/hydrateMutations.ts new file mode 100644 index 00000000000..14a6b2b97fd --- /dev/null +++ b/packages/react-data-query/src/hydrateMutations.ts @@ -0,0 +1,124 @@ +import { + QueryClient, + DehydratedState, + MutationState, +} from '@tanstack/query-core'; + +/** + * Read the `globalId` correlation token from a mutation's `meta`. + * + * The UI query client generates a `globalId` for each mutation it creates and + * threads it through the data service, which stores it on its own mutation's + * `meta`. Because TanStack's `MutationMeta` is an open record, the value reads + * as `unknown`, so we narrow it to a string here. + * + * @param meta - The mutation `meta`, if any. + * @returns The `globalId` if present and a string, otherwise undefined. + */ +export function readGlobalId( + meta: Record | undefined, +): string | undefined { + const globalId = meta?.globalId; + return typeof globalId === 'string' ? globalId : undefined; +} + +/** + * Load a dehydrated mutation cache into a query client. + * + * TanStack Query's own `hydrate` matches dehydrated queries against the cache + * by hash and updates them in place, but it always inserts a brand-new mutation + * for every dehydrated mutation. Because data services emit a cache update on + * every `added`/`updated` mutation event, calling `hydrate` directly would + * append a fresh mutation to each subscribed query client on every event, so + * the cache would grow without bound, and a found mutation could be stale. + * + * This behavior for `hydrate` makes sense because TanStack treats queries and + * mutations differently. Queries are deduplicated: two attempts for the same + * query using the same query key show up once in the query cache. But mutations + * are discrete events/attempts, and `mutationKey` is used by observers to find + * mutations, not enforce uniqueness. + * + * Because a mutation key is not unique, it cannot on its own tell us which UI + * mutation a service cache update belongs to: multiple mutations may share a + * key, and a mutation created with a custom `mutationFn` may reuse a key + * without ever going through a data service. To correlate the two caches, the + * UI query client tags each mutation it creates with a unique `globalId` and + * threads it through the data service, which echoes it back on the mutation's + * `meta`. This function updates the exact UI mutation carrying that `globalId`, + * and ignores mutations that carry none. + * + * @param client - The UI query client whose mutation cache should be hydrated. + * @param dehydratedState - The dehydrated state emitted by the data service. + */ +export function hydrateMutations( + client: QueryClient, + dehydratedState: DehydratedState, +): void { + const mutationCache = client.getMutationCache(); + + for (const dehydratedMutation of dehydratedState.mutations) { + const { mutationKey, state, meta } = dehydratedMutation; + + const globalId = readGlobalId(meta); + + // A data service only publishes cache updates for mutations that have a + // `mutationKey`, and only mutations that originated in the UI query client + // carry a `globalId`. Without both, we cannot correlate the update with a + // UI mutation, so we skip it. + if (!mutationKey || !globalId) { + continue; + } + + const existingMutation = mutationCache.find({ + mutationKey, + predicate: (mutation) => readGlobalId(mutation.meta) === globalId, + }); + + // 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. + * @returns The action describing the state. + */ +function deriveMutationAction( + state: MutationState, +): + | { type: 'success'; data: unknown } + | { type: 'error'; error: unknown } + | { type: 'pending'; variables: unknown; context: unknown; isPaused: boolean } + | { type: 'continue' } { + switch (state.status) { + case 'success': + return { type: 'success', data: state.data }; + case 'error': + // A mutation in the `error` state always carries a non-null `error`. + return { type: 'error', error: state.error }; + case 'pending': + return { + type: 'pending', + variables: state.variables, + context: state.context, + isPaused: state.isPaused, + }; + // The `idle` status carries no data, error, or variables, so a neutral + // `continue` action refreshes subscribers without implying a result. + default: + return { type: 'continue' }; + } +} From b7639a8dc53cf118a24f1a28d730984c3a6f76a8 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Fri, 4 Sep 2026 16:26:34 -0600 Subject: [PATCH 25/34] Clean up --- .../src/hydrateMutations.test.ts | 259 +++++++++++------- .../react-data-query/src/hydrateMutations.ts | 61 ++--- 2 files changed, 191 insertions(+), 129 deletions(-) diff --git a/packages/react-data-query/src/hydrateMutations.test.ts b/packages/react-data-query/src/hydrateMutations.test.ts index 2092bbfac83..e0400aa190e 100644 --- a/packages/react-data-query/src/hydrateMutations.test.ts +++ b/packages/react-data-query/src/hydrateMutations.test.ts @@ -3,45 +3,60 @@ import { Mutation, MutationState, QueryClient, + MutationCacheNotifyEvent, } from '@tanstack/query-core'; +import assert from 'assert'; import { hydrateMutations } from './hydrateMutations.js'; -type MutationCacheAction = { - type: string; - [key: string]: unknown; -}; +const EXAMPLE_MUTATION_KEY = ['ExampleDataService:addFollower', '1']; +const EXAMPLE_GLOBAL_ID = 'global-id'; -const MUTATION_KEY = ['ExampleDataService:addFollower', '1']; +type NotifyEventMutationUpdated = Extract< + MutationCacheNotifyEvent, + { type: 'updated' } +>; describe('hydrateMutations', () => { it('updates the mutation whose `globalId` matches the dehydrated mutation', () => { - const client = new QueryClient(); - buildUiMutation(client, { globalId: 'global-id-1' }); + const globalId = EXAMPLE_GLOBAL_ID; + const { queryClient } = createQueryClientWithMutation({ + mutationKey: EXAMPLE_MUTATION_KEY, + globalId, + }); + const dehydratedMutationState = { + status: 'success' as const, + data: 'result', + }; hydrateMutations( - client, - createDehydratedState({ - globalId: 'global-id-1', - state: createMutationState({ status: 'success', data: 'result' }), + queryClient, + createDehydratedStateWithMutation({ + mutationKey: EXAMPLE_MUTATION_KEY, + globalId, + state: createMutationState(dehydratedMutationState), }), ); - const mutation = client.getMutationCache().find({ - mutationKey: MUTATION_KEY, + const mutation = queryClient.getMutationCache().find({ + mutationKey: EXAMPLE_MUTATION_KEY, }); - expect(mutation?.state.status).toBe('success'); - expect(mutation?.state.data).toBe('result'); + assert(mutation); + expect(mutation.state.status).toBe(dehydratedMutationState.status); + expect(mutation.state.data).toBe(dehydratedMutationState.data); }); it('leaves untouched a mutation whose `globalId` does not match', () => { - const client = new QueryClient(); - const mutation = buildUiMutation(client, { globalId: 'global-id-1' }); + const { queryClient, mutation } = createQueryClientWithMutation({ + mutationKey: EXAMPLE_MUTATION_KEY, + globalId: 'some-global-id', + }); const stateBefore = mutation.state; hydrateMutations( - client, - createDehydratedState({ + queryClient, + createDehydratedStateWithMutation({ + mutationKey: EXAMPLE_MUTATION_KEY, globalId: 'a-different-global-id', state: createMutationState({ status: 'success', data: 'result' }), }), @@ -50,47 +65,59 @@ describe('hydrateMutations', () => { expect(mutation.state).toBe(stateBefore); }); - it('ignores dehydrated mutations that carry no `globalId`', () => { - const client = new QueryClient(); - const mutation = buildUiMutation(client, { globalId: 'global-id-1' }); + it('ignores dehydrated mutations that have no `globalId`', () => { + const { queryClient, mutation } = createQueryClientWithMutation({ + mutationKey: EXAMPLE_MUTATION_KEY, + globalId: EXAMPLE_GLOBAL_ID, + }); const stateBefore = mutation.state; - hydrateMutations(client, { - queries: [], - mutations: [ - { - mutationKey: MUTATION_KEY, - state: createMutationState({ status: 'success', data: 'result' }), - }, - ], - }); + hydrateMutations( + queryClient, + createDehydratedStateWithMutation({ + mutationKey: EXAMPLE_MUTATION_KEY, + globalId: undefined, + state: createMutationState({ status: 'success', data: 'result' }), + }), + ); expect(mutation.state).toBe(stateBefore); }); - it('ignores dehydrated mutations that carry a non-string `globalId`', () => { - const client = new QueryClient(); - const mutation = buildUiMutation(client, { globalId: 'global-id-1' }); + it('ignores dehydrated mutations that have a non-string `globalId`', () => { + const { queryClient, mutation } = createQueryClientWithMutation({ + mutationKey: EXAMPLE_MUTATION_KEY, + globalId: EXAMPLE_GLOBAL_ID, + }); const stateBefore = mutation.state; - hydrateMutations(client, { - queries: [], - mutations: [ - { - mutationKey: MUTATION_KEY, - meta: { globalId: 42 }, - state: createMutationState({ status: 'success', data: 'result' }), - }, - ], - }); + hydrateMutations( + queryClient, + createDehydratedStateWithMutation({ + mutationKey: EXAMPLE_MUTATION_KEY, + globalId: 42, + state: createMutationState({ status: 'success', data: 'result' }), + }), + ); expect(mutation.state).toBe(stateBefore); }); it('notifies subscribers with a `success` action when the mutation succeeded', () => { - const actions = captureNotifyActions('global-id-1', { - status: 'success', - data: 'result', + const { queryClient } = createQueryClientWithMutation({ + mutationKey: EXAMPLE_MUTATION_KEY, + globalId: EXAMPLE_GLOBAL_ID, + }); + + const actions = capturingMutationCacheActions(queryClient, () => { + hydrateMutations( + queryClient, + createDehydratedStateWithMutation({ + mutationKey: EXAMPLE_MUTATION_KEY, + globalId: EXAMPLE_GLOBAL_ID, + state: createMutationState({ status: 'success', data: 'result' }), + }), + ); }); expect(actions).toContainEqual({ type: 'success', data: 'result' }); @@ -99,20 +126,45 @@ describe('hydrateMutations', () => { it('notifies subscribers with an `error` action when the mutation failed', () => { const error = new Error('boom'); - const actions = captureNotifyActions('global-id-1', { - status: 'error', - error, + const { queryClient } = createQueryClientWithMutation({ + mutationKey: EXAMPLE_MUTATION_KEY, + globalId: EXAMPLE_GLOBAL_ID, + }); + + const actions = capturingMutationCacheActions(queryClient, () => { + hydrateMutations( + queryClient, + createDehydratedStateWithMutation({ + mutationKey: EXAMPLE_MUTATION_KEY, + globalId: EXAMPLE_GLOBAL_ID, + state: createMutationState({ status: 'error', error }), + }), + ); }); expect(actions).toContainEqual({ type: 'error', error }); }); it('notifies subscribers with a `pending` action while the mutation is pending', () => { - const actions = captureNotifyActions('global-id-1', { - status: 'pending', - variables: { followerId: '1' }, - context: { previous: null }, - isPaused: true, + const { queryClient } = createQueryClientWithMutation({ + mutationKey: EXAMPLE_MUTATION_KEY, + globalId: EXAMPLE_GLOBAL_ID, + }); + + const actions = capturingMutationCacheActions(queryClient, () => { + hydrateMutations( + queryClient, + createDehydratedStateWithMutation({ + mutationKey: EXAMPLE_MUTATION_KEY, + globalId: EXAMPLE_GLOBAL_ID, + state: createMutationState({ + status: 'pending', + variables: { followerId: '1' }, + context: { previous: null }, + isPaused: true, + }), + }), + ); }); expect(actions).toContainEqual({ @@ -124,8 +176,22 @@ describe('hydrateMutations', () => { }); it('notifies subscribers with a `continue` action when the mutation is idle', () => { - const actions = captureNotifyActions('global-id-1', { - status: 'idle', + const { queryClient } = createQueryClientWithMutation({ + mutationKey: EXAMPLE_MUTATION_KEY, + globalId: EXAMPLE_GLOBAL_ID, + }); + + const actions = capturingMutationCacheActions(queryClient, () => { + hydrateMutations( + queryClient, + createDehydratedStateWithMutation({ + mutationKey: EXAMPLE_MUTATION_KEY, + globalId: EXAMPLE_GLOBAL_ID, + state: createMutationState({ + status: 'idle', + }), + }), + ); }); expect(actions).toContainEqual({ type: 'continue' }); @@ -133,45 +199,57 @@ describe('hydrateMutations', () => { }); /** - * Build a mutation in a client's mutation cache, tagged with a `globalId`, to - * stand in for a mutation the UI query client created. + * Create a query queryClient that is prepopulated with a mutation. The mutation is + * tagged with a `globalId` to stand in for a mutation the UI query queryClient + * created. * - * @param client - The client whose cache the mutation is built in. - * @param options - The options. - * @param options.globalId - The `globalId` to tag the mutation with. - * @returns The built mutation. + * @param args - The arguments. + * @param args.mutationKey - The key to assign to the mutation. + * @param args.globalId - The `globalId` to tag the mutation with. + * @returns The query queryClient. */ -function buildUiMutation( - client: QueryClient, - { globalId }: { globalId: string }, -): Mutation { - return client.getMutationCache().build(client, { - mutationKey: MUTATION_KEY, +function createQueryClientWithMutation({ + mutationKey, + globalId, +}: { + mutationKey: string[]; + globalId: string; +}): { + queryClient: QueryClient; + mutation: Mutation; +} { + const queryClient = new QueryClient(); + const mutation = queryClient.getMutationCache().build(queryClient, { + mutationKey, meta: { globalId }, }); + return { queryClient, mutation }; } /** - * Build a dehydrated mutation cache carrying a single mutation for the shared - * mutation key, tagged with a `globalId`. + * Construct a dehydrated mutation cache containing a single mutation for the + * shared mutation key, tagged with a `globalId`. * * @param options - The options. + * @param options.mutationKey - The key to assign to the dehydrated mutation. * @param options.globalId - The `globalId` to tag the dehydrated mutation with. * @param options.state - The state of the dehydrated mutation. * @returns The dehydrated state. */ -function createDehydratedState({ +function createDehydratedStateWithMutation({ + mutationKey, globalId, state, }: { - globalId: string; + mutationKey: string[]; + globalId?: unknown; state: MutationState; }): DehydratedState { return { queries: [], mutations: [ { - mutationKey: MUTATION_KEY, + mutationKey, meta: { globalId }, state, }, @@ -202,34 +280,25 @@ function createMutationState(overrides: Partial): MutationState { } /** - * Hydrate a UI mutation with a service mutation of a given state and collect the - * actions that the mutation cache notifies its subscribers with. + * Executes a function that presumably operates on a query client, collecting + * the actions that its mutation cache produces when there are updates. * - * @param globalId - The `globalId` shared by the UI and service mutations. - * @param stateOverrides - The state fields to give the service mutation. - * @returns The captured notify actions. + * @param queryClient - The query client. + * @param fn - The function to call. + * @returns The captured mutation cache actions. */ -function captureNotifyActions( - globalId: string, - stateOverrides: Partial, -): MutationCacheAction[] { - const client = new QueryClient(); - buildUiMutation(client, { globalId }); - - const actions: MutationCacheAction[] = []; - client.getMutationCache().subscribe((event) => { +function capturingMutationCacheActions( + queryClient: QueryClient, + fn: () => void, +): NotifyEventMutationUpdated['action'][] { + const actions: NotifyEventMutationUpdated['action'][] = []; + queryClient.getMutationCache().subscribe((event) => { if (event.type === 'updated') { actions.push(event.action); } }); - hydrateMutations( - client, - createDehydratedState({ - globalId, - state: createMutationState(stateOverrides), - }), - ); + fn(); return actions; } diff --git a/packages/react-data-query/src/hydrateMutations.ts b/packages/react-data-query/src/hydrateMutations.ts index 14a6b2b97fd..911f50e214e 100644 --- a/packages/react-data-query/src/hydrateMutations.ts +++ b/packages/react-data-query/src/hydrateMutations.ts @@ -5,12 +5,12 @@ import { } from '@tanstack/query-core'; /** - * Read the `globalId` correlation token from a mutation's `meta`. + * Get the `globalId` of a mutation by reading its `meta` data. * - * The UI query client generates a `globalId` for each mutation it creates and - * threads it through the data service, which stores it on its own mutation's - * `meta`. Because TanStack's `MutationMeta` is an open record, the value reads - * as `unknown`, so we narrow it to a string here. + * Each mutation that is routed from the UI query client to a data service is + * assigned a `globalId` through the mutation's `meta` property. However, the + * `meta` property is optional and untyped, so reading this property back + * requires some validation. * * @param meta - The mutation `meta`, if any. * @returns The `globalId` if present and a string, otherwise undefined. @@ -23,32 +23,24 @@ export function readGlobalId( } /** - * Load a dehydrated mutation cache into a query client. + * Load dehydrated mutations into the given query client. * - * TanStack Query's own `hydrate` matches dehydrated queries against the cache - * by hash and updates them in place, but it always inserts a brand-new mutation - * for every dehydrated mutation. Because data services emit a cache update on - * every `added`/`updated` mutation event, calling `hydrate` directly would - * append a fresh mutation to each subscribed query client on every event, so - * the cache would grow without bound, and a found mutation could be stale. + * TanStack Query's `hydrate` function works well for queries: it ensures that + * incoming queries remain deduplicated as it hydrates them (using the query key + * hash as a filter). But mutations don't need to be deduplicated, and so + * `hydrate` follows a different process, opting to load incoming mutations as + * new entries each time it is called. * - * This behavior for `hydrate` makes sense because TanStack treats queries and - * mutations differently. Queries are deduplicated: two attempts for the same - * query using the same query key show up once in the query cache. But mutations - * are discrete events/attempts, and `mutationKey` is used by observers to find - * mutations, not enforce uniqueness. - * - * Because a mutation key is not unique, it cannot on its own tell us which UI - * mutation a service cache update belongs to: multiple mutations may share a - * key, and a mutation created with a custom `mutationFn` may reuse a key - * without ever going through a data service. To correlate the two caches, the - * UI query client tags each mutation it creates with a unique `globalId` and - * threads it through the data service, which echoes it back on the mutation's - * `meta`. This function updates the exact UI mutation carrying that `globalId`, - * and ignores mutations that carry none. + * This does not well for what we want to achieve, which is to be able to + * synchronize queries and mutations between a data service query client service + * and a UI query client. To accomplish this, we assume that mutations which + * originated on the UI side have been tagged with a custom UUID (stored as + * `globalId` in its `meta`). This allows us to keep mutations with the same + * UUID on both sides and thus sychronize them effectively. * * @param client - The UI query client whose mutation cache should be hydrated. - * @param dehydratedState - The dehydrated state emitted by the data service. + * @param dehydratedState - The dehydrated state emitted by a data service's + * `:cacheUpdated` event. */ export function hydrateMutations( client: QueryClient, @@ -61,10 +53,9 @@ export function hydrateMutations( const globalId = readGlobalId(meta); - // A data service only publishes cache updates for mutations that have a - // `mutationKey`, and only mutations that originated in the UI query client - // carry a `globalId`. Without both, we cannot correlate the update with a - // UI mutation, so we skip it. + // Although TanStack Query does not require mutations to have mutation keys, + // all mutations created through data services or the UI query client *must* + // have one. They must also have a global ID. if (!mutationKey || !globalId) { continue; } @@ -91,10 +82,12 @@ export function hydrateMutations( } /** - * Build the `notify` action that describes a mutation's current state. + * When publishing an `update` event through the mutation cache we must supply + * an action. This function derives an appropriate action from a dehydrated + * mutation's state. * - * @param state - The synced mutation state. - * @returns The action describing the state. + * @param state - The state of a dehydrated mutation. + * @returns The mutation cache action describing the state. */ function deriveMutationAction( state: MutationState, From 80dc6284ee48fe2f0d5995c9d8298319ee4405fa Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Fri, 4 Sep 2026 16:31:42 -0600 Subject: [PATCH 26/34] Clean up 2 --- .../src/BaseDataService.test.ts | 65 +++++++------------ 1 file changed, 23 insertions(+), 42 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 28330e3fa7b..69c15781ae9 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -275,48 +275,6 @@ describe('BaseDataService', () => { ); }); - it('includes the `globalId` in the mutation meta of `:cacheUpdated` payloads when one is passed', async () => { - mockAddFollowerRequest(); - const messenger = createServiceMessenger(); - const service = new ExampleDataService(messenger); - const publishSpy = jest.spyOn(messenger, 'publish'); - - await service.addFollower('1', 'global-id-1'); - - const hash = hashKey(['ExampleDataService:addFollower', '1']); - expect(publishSpy).toHaveBeenCalledWith( - `ExampleDataService:cacheUpdated:${hash}`, - expect.objectContaining({ - state: expect.objectContaining({ - mutations: [ - expect.objectContaining({ - meta: { globalId: 'global-id-1' }, - }), - ], - }), - }), - ); - }); - - it('omits `globalId` from the mutation meta of `:cacheUpdated` payloads when none is passed', async () => { - mockAddFollowerRequest(); - const messenger = createServiceMessenger(); - const service = new ExampleDataService(messenger); - const publishSpy = jest.spyOn(messenger, 'publish'); - - await service.addFollower('1'); - - const hash = hashKey(['ExampleDataService:addFollower', '1']); - expect(publishSpy).toHaveBeenCalledWith( - `ExampleDataService:cacheUpdated:${hash}`, - expect.objectContaining({ - state: expect.objectContaining({ - mutations: [expect.not.objectContaining({ meta: expect.anything() })], - }), - }), - ); - }); - it('emits `:cacheUpdated` events when query cache is updated', async () => { const messenger = createServiceMessenger(); const service = new ExampleDataService(messenger); @@ -516,6 +474,29 @@ describe('BaseDataService', () => { ); }); + it('includes the provided `globalId` for a mutation in `:cacheUpdated` payloads', async () => { + mockAddFollowerRequest(); + const messenger = createServiceMessenger(); + const service = new ExampleDataService(messenger); + const publishSpy = jest.spyOn(messenger, 'publish'); + + await service.addFollower('1', 'global-id'); + + const hash = hashKey(['ExampleDataService:addFollower', '1']); + expect(publishSpy).toHaveBeenCalledWith( + `ExampleDataService:cacheUpdated:${hash}`, + expect.objectContaining({ + state: expect.objectContaining({ + mutations: [ + expect.objectContaining({ + meta: { globalId: 'global-id' }, + }), + ], + }), + }), + ); + }); + it('does not emit events after being destroyed', async () => { mockAddFollowerRequest(); const messenger = createServiceMessenger(); From 6381ab911c198f1573603079235619f8a03cf608 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Fri, 4 Sep 2026 16:41:01 -0600 Subject: [PATCH 27/34] Clean up 3 --- packages/base-data-service/CHANGELOG.md | 6 +++--- packages/base-data-service/src/BaseDataService.ts | 14 ++++++-------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/base-data-service/CHANGELOG.md b/packages/base-data-service/CHANGELOG.md index 77cb24a64df..68c2808a502 100644 --- a/packages/base-data-service/CHANGELOG.md +++ b/packages/base-data-service/CHANGELOG.md @@ -9,10 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `executeMutation` to `BaseDataService` to allow for making server-state-mutating requests ([#9324](https://github.com/MetaMask/core/pull/9324)) +- Add `executeMutation` protected method to `BaseDataService` to allow for making server-state-mutating requests ([#9324](https://github.com/MetaMask/core/pull/9324)) - These kinds of requests are never retried, unlike queries. - - `executeMutation` accepts an optional `globalId` which, when provided, is stored on the mutation's `meta` and echoed back in `:cacheUpdated` payloads so a UI query client can correlate the service-side mutation with the specific UI mutation that triggered it. - - Also export `MutationKey` type. + - To use this, create a method in your data service class which takes whatever arguments you need, plus a optional final argument called `globalId`; then call `executeMutation` with a `mutationKey`, `globalId`, and `mutationFn`. See `ExampleDataService` in this package for an example. + - A `MutationKey` type is also available. - The payload for `:cacheUpdated` and `:cacheUpdated:${hash}` events now includes an `objectType` property, which is either "query" or "mutation" ([#9324](https://github.com/MetaMask/core/pull/9324)) ### Changed diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index e908e7495a3..36fdeb74e87 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -454,10 +454,12 @@ export class BaseDataService< * 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. - * @param options.globalId - An optional identifier, generated by the UI query - * client, that correlates this service-side mutation with the specific UI - * mutation that triggered it. When provided, it is stored on the mutation's - * `meta` so it is included in dehydrated `:cacheUpdated` payloads. + * @param options.globalId - A string which uniquely identifies this mutation + * across different query clients — specifically, the one in this data service + * and the one that `createUIQueryClient` in `@metamask/react-data-query` + * holds (in fact usually you don't need to pass this — `createUIQueryClient` + * will do it automatically). This global ID will be stored in the new + * mutation `meta` data and included in `:cacheUpdated` payloads. * @returns The mutation results. */ protected async executeMutation< @@ -497,10 +499,6 @@ export class BaseDataService< TOnMutateResult >(this.#queryClient, { ...options, - // A `globalId` correlates this service-side mutation with the specific UI - // mutation that triggered it. We store it on `meta` so it rides along in - // dehydrated `:cacheUpdated` payloads, where the UI side uses it to find - // and update the exact mutation it created. ...(globalId !== undefined && { meta: { ...options.meta, globalId }, }), From b0c3b8de7471928d62451fa0d1167a230204b4c5 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 10 Sep 2026 13:10:14 -0600 Subject: [PATCH 28/34] Use Struct instead of Struct --- packages/base-data-service/src/BaseDataService.ts | 13 +++++-------- .../base-data-service/tests/ExampleDataService.ts | 2 +- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 36fdeb74e87..dda03ec86a6 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -464,13 +464,7 @@ export class BaseDataService< */ protected async executeMutation< TMutationFnData extends Json, - // We have to use `Struct` here, as using `Struct` (or - // even `Struct`) would reject a more concrete, "real world" struct. - // The reason is that `Struct` is an object type with methods whose signatures - // feature the struct's content type, making `Struct` contravariant in its - // content type. The only way to get around this is to use `any`. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - TDataStruct extends Struct | undefined = undefined, + TDataStruct extends Struct | undefined = undefined, TData = TDataStruct extends Struct ? StructType : TMutationFnData, @@ -510,11 +504,14 @@ export class BaseDataService< const response = await this.#policy.circuitBreakerPolicy.execute(() => mutationFn(...args), ); + // Type assertion: TypeScript is not able to unify the type that this + // function returns with `TData` at compile-time. We can still typecheck + // the arguments, though. return processMutationResponse( options.mutationKey, response, responseStruct, - ); + ) as TData; }, }); // We purposely pass an empty set of variables because this method is diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts index e0e9732634b..7cfb53702ba 100644 --- a/packages/base-data-service/tests/ExampleDataService.ts +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -219,7 +219,7 @@ export class ExampleDataService extends BaseDataService< ); } - return response.json() as Promise; + return response.json(); }, gcTime: inMilliseconds(1, Duration.Day), responseStruct: AddFollowerResponseStruct, From 4e37555be31700b7b2ad38f4d2039aefd35df86f Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 10 Sep 2026 14:14:54 -0600 Subject: [PATCH 29/34] Add a caveat about setQueryData --- .../react-data-query/src/createUIQueryClient.ts | 15 ++++----------- packages/react-data-query/src/hooks.ts | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/react-data-query/src/createUIQueryClient.ts b/packages/react-data-query/src/createUIQueryClient.ts index 0a8d823bfef..10befa20e87 100644 --- a/packages/react-data-query/src/createUIQueryClient.ts +++ b/packages/react-data-query/src/createUIQueryClient.ts @@ -65,21 +65,14 @@ type MessengerAdapter = { }; /** - * Create a QueryClient that queries and subscribes to data services using a - * messenger adapter. This is a messenger-like object that carries some - * constraints: + * Create a QueryClient that enables data services to power queries and mutations via a messenger adapter. * - * 1. The messenger must support the `call`, `subscribe` and - * `unsubscribe` methods. - * 2. All action handler arguments and event payloads must be JSON-compatible. - * 3. The messenger must minimally support actions that are scoped to the - * designated data services and must minimally support the - * `:cacheUpdated:${hash}` event scoped to the designated data services. + * This returns a wrapped version of TanStack Query's QueryClient interface. Note that while some methods such as `invalidateQueries` have support for data services, some such as `setQueryData` do not. * * @param dataServices - A list of data services. - * @param messenger - A messenger adapter. + * @param messenger - A messenger-like object with the following constraints: 1) the messenger must support the `call`, `subscribe` and `unsubscribe` methods; 2) all action handler arguments and event payloads must be JSON-compatible; 3) the messenger must minimally support actions that are scoped to the designated data services and must minimally support the `:cacheUpdated:${hash}` event scoped to the designated data services. * @param config - Optional query client configuration options. - * @returns The QueryClient. + * @returns The created QueryClient. */ export function createUIQueryClient( dataServices: DataServiceNames, diff --git a/packages/react-data-query/src/hooks.ts b/packages/react-data-query/src/hooks.ts index 3e481b8a31e..2235e5e2ef4 100644 --- a/packages/react-data-query/src/hooks.ts +++ b/packages/react-data-query/src/hooks.ts @@ -22,6 +22,10 @@ import { UseMutationResult, } from '@tanstack/react-query'; +// This is referenced in JSDoc below. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { createUIQueryClient } from './createUIQueryClient.js'; + const DATA_SERVICE_QUERY_DEFAULTS = { staleTime: 0, retry: false, @@ -85,8 +89,16 @@ export function useInfiniteQuery< /** * Execute a mutation through a data service. * - * @param options - The mutation options. Keep in mind that `mutationFn` is not supported - * when executing mutations through data services. + * This is a version of `useMutation` from TanStack Query which is intended to be used with a {@link createUIQueryClient|UI query client}. Note the following constraints: + * + * - The `mutationKey` must refer to a method within a data service by matching the following format: ``[`${ServiceName}:${actionName}`, ...arguments]``. + * - Providing a custom `mutationFn` is not supported. + * - Updating a connected query's cache data [before][1] or [after][2] a mutation via `setQueryData` is not supported. + * + * [1]: https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates#via-the-cache + * [2]: https://tanstack.com/query/latest/docs/framework/react/guides/updates-from-mutation-responses + * + * @param options - The mutation options. * @returns The result of the mutation. */ export function useMutation< From cd6be1f8df1d557ba62896e1b95b2f968bd63dfb Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 10 Sep 2026 15:20:55 -0600 Subject: [PATCH 30/34] Mint mutation globalId once per built mutation The globalId used to correlate UI and service mutations was minted inside the defaultMutationOptions override, which runs whenever a mutation's options are defaulted rather than once per mutation. This caused two bugs: - Calling mutate() more than once on the same observer reused the memoized globalId (since mutationFn was already set), so a :cacheUpdated payload could be applied to the wrong cache entry. - Re-defaulting an observer's options (as useMutation does on every render) minted a fresh globalId and, for a pending mutation, replaced the live mutation's meta, detaching it from its in-flight service-side events. Move globalId minting into a mutationCache.build override, which runs exactly once per Mutation instance, and read the id from context.meta in the installed mutationFn instead of a captured variable. Guard the built mutation's setOptions so later option swaps preserve its globalId. --- packages/react-data-query/CHANGELOG.md | 1 + .../src/createUIQueryClient.test.ts | 106 ++++++++++++++++++ .../src/createUIQueryClient.ts | 106 +++++++++++++++--- 3 files changed, 198 insertions(+), 15 deletions(-) diff --git a/packages/react-data-query/CHANGELOG.md b/packages/react-data-query/CHANGELOG.md index fba006f07f3..2c850e40a6c 100644 --- a/packages/react-data-query/CHANGELOG.md +++ b/packages/react-data-query/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 via the UI query client and pass a reference to the action as the mutation key. - Like `fetchQuery` and `fetchInfiniteQuery`, the `useMutation` wrapper disables retries by default and enforces that `mutationKey` matches the same thing that `executeMutation` takes. - `createUIQueryClient` now tags each mutation it creates with a unique `globalId` (stored on the mutation's `meta`) and passes it to the data service action as the trailing argument. It uses this `globalId` to update the exact UI mutation that a `:cacheUpdated` event corresponds to, so mutations sharing a `mutationKey` no longer clobber one another, and mutations that use a custom `mutationFn` are left untouched. + - The `globalId` is minted once per mutation as it is built, so that each `mutate` call from the same observer gets its own id, and re-rendering (which re-defaults a mutation's options) can no longer change the id of an in-flight mutation and detach it from its `:cacheUpdated` events. ### Changed diff --git a/packages/react-data-query/src/createUIQueryClient.test.ts b/packages/react-data-query/src/createUIQueryClient.test.ts index 9b381bcc08f..3f3efb34d6c 100644 --- a/packages/react-data-query/src/createUIQueryClient.test.ts +++ b/packages/react-data-query/src/createUIQueryClient.test.ts @@ -350,6 +350,109 @@ describe('createUIQueryClient', () => { service.destroy(); }); + it('assigns a distinct `globalId` to each mutation built from a single observer that mutates more than once', async () => { + const { clientA: client, service } = createClients(); + + mockAddFollowerRequest(); + mockAddFollowerRequest(); + + const observer = new TanStackQueryMutationObserver( + client, + { mutationKey: addFollowerMutationKey }, + ); + + await observer.mutate(); + await observer.mutate(); + + const globalMutationIds = client + .getMutationCache() + .findAll({ mutationKey: addFollowerMutationKey }) + .map((mutation) => mutation.meta?.globalId); + + expect(globalMutationIds).toHaveLength(2); + expect(globalMutationIds[0]).toBeDefined(); + expect(globalMutationIds[1]).toBeDefined(); + expect(globalMutationIds[0]).not.toBe(globalMutationIds[1]); + + observer.reset(); + service.destroy(); + }); + + it('preserves a pending mutation`s `globalId` when the observer`s options are re-defaulted', async () => { + const { clientA: client, messenger, service } = createClients(); + + const { promise: promiseToResolveMutation, resolve: resolveMutation } = + createDeferredPromise(); + const replyFn = async (): Promise<[number, ReplyBody]> => { + await promiseToResolveMutation; + return [ + DEFAULT_ADD_FOLLOWER_REPLY.status, + DEFAULT_ADD_FOLLOWER_REPLY.body, + ] as const; + }; + mockAddFollowerRequest({ replyFn }); + + const observer = new TanStackQueryMutationObserver( + client, + { mutationKey: addFollowerMutationKey }, + ); + + const promiseForMutation = observer.mutate(); + jest.advanceTimersByTime(0); + + const mutationFromUi = client + .getMutationCache() + .find({ mutationKey: addFollowerMutationKey }); + assert(mutationFromUi); + const globalIdBeforeReDefaulting = mutationFromUi.meta?.globalId; + + // Simulate a re-render passing a fresh options object (as `useMutation` + // does) while the mutation is still in flight. This must not mint a new + // `globalId` for the in-flight mutation. + observer.setOptions({ mutationKey: addFollowerMutationKey }); + + const globalIdAfterReDefaulting = mutationFromUi.meta?.globalId; + expect(globalIdAfterReDefaulting).toBe(globalIdBeforeReDefaulting); + + resolveMutation(); + await promiseForMutation; + + const { globalId } = mutationFromUi.meta ?? {}; + + const hash = hashKey(addFollowerMutationKey); + messenger.publish(`ExampleDataService:cacheUpdated:${hash}`, { + objectType: 'mutation', + type: 'updated', + state: { + queries: [], + mutations: [ + { + mutationKey: addFollowerMutationKey, + meta: { globalId }, + state: { + context: undefined, + data: { followed: [{ profileId: 'from-service' }] }, + error: null, + failureCount: 0, + failureReason: null, + isPaused: false, + status: 'success' as const, + submittedAt: 0, + variables: undefined, + }, + }, + ], + }, + }); + + expect(mutationFromUi.state.data).toStrictEqual({ + followed: [{ profileId: 'from-service' }], + }); + + observer.reset(); + service.destroy(); + }); + it('ignores :cacheUpdated events whose referenced mutation carries no `globalId`', async () => { const { clientA: client, messenger, service } = createClients(); @@ -425,6 +528,9 @@ describe('createUIQueryClient', () => { expect(mutations).toHaveLength(2); expect(mutations[1].state.data).toBe('custom-result'); + // The mutation with a custom `mutationFn` is never routed to the data + // service, so it must not be tagged with a `globalId`. + expect(mutations[1].meta?.globalId).toBeUndefined(); customObserver.reset(); serviceObserver.reset(); diff --git a/packages/react-data-query/src/createUIQueryClient.ts b/packages/react-data-query/src/createUIQueryClient.ts index 10befa20e87..a8680ae0e18 100644 --- a/packages/react-data-query/src/createUIQueryClient.ts +++ b/packages/react-data-query/src/createUIQueryClient.ts @@ -11,7 +11,11 @@ import { InvalidateOptions, QueryKey, QueryClientConfig, + Mutation, MutationOptions, + MutationState, + MutationFunction, + MutationFunctionContext, } from '@tanstack/query-core'; import { v4 as uuidV4 } from 'uuid'; @@ -294,6 +298,7 @@ export function createUIQueryClient( }); // Override invalidateQueries to ensure the data service is invalidated as well. + const originalInvalidate = client.invalidateQueries.bind(client); client.invalidateQueries = async ( @@ -319,8 +324,19 @@ export function createUIQueryClient( return originalInvalidate(filters, options); }; - // Override defaultMutationOptions to check for mutationKey if mutationFn is - // not provided. + // Tracks the `mutationFn`s that we install for data-service mutations, so the + // `build` override below can tell them apart from user-provided ones and only + // assign a `globalId` to mutations that are actually routed to a data + // service. + const dataServiceMutationFns = new WeakSet< + // We are interoperating with generic `mutationFn`s from @tanstack/query-core. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + MutationFunction + >(); + + // Override `defaultMutationOptions` so that `mutationFn` uses the + // `mutationKey` to call an action on a data service through the messenger. + const originalDefaultMutationOptions = client.defaultMutationOptions.bind(client); @@ -333,17 +349,11 @@ export function createUIQueryClient( ): Options => { const defaultedOptions = originalDefaultMutationOptions(options); - // Only mutations that fall back to the data-service default `mutationFn` - // need a `globalId` to correlate the UI and service caches. A mutation with - // a custom `mutationFn` never reaches a data service, so we leave it alone. if (defaultedOptions.mutationFn === undefined) { - // Generate the `globalId` once and memoize it on `meta` so it stays - // stable across the mutation's lifetime and can be matched against - // incoming cache updates. - const globalId = readGlobalId(defaultedOptions.meta) ?? uuidV4(); - defaultedOptions.meta = { ...defaultedOptions.meta, globalId }; - - defaultedOptions.mutationFn = async (): Promise => { + const dataServiceMutationFn = async ( + _variables: unknown, + context: MutationFunctionContext, + ): Promise => { const { mutationKey } = defaultedOptions; assert( @@ -360,15 +370,81 @@ export function createUIQueryClient( log(`Detected mutation request, calling action: "${action}"`); - // The `globalId` is passed as the trailing action argument. Each data - // service mutation method forwards it into `executeMutation`, which - // echoes it back on the service-side mutation's `meta`. + // Thanks to our `MutationCache.build` override below, by the time this + // `mutationFn` runs, our mutation *should* already have a `globalId` + // (which is available via `context.meta`). + const globalId = readGlobalId(context.meta); + // We can't realistically test that it doesn't, since + // `MutationCache.build` always runs first. + // istanbul ignore next + if (globalId === undefined) { + assert('Expected mutation to have a `globalId`.'); + } + return await messenger.call(action, ...(params as Json[]), globalId); }; + + dataServiceMutationFns.add(dataServiceMutationFn); + defaultedOptions.mutationFn = dataServiceMutationFn; } return defaultedOptions; }; + // Override `build` to ensure that any data-service mutation created via + // `executeMutation` or manually has a `globalId`. + + const originalBuildMutation = mutationCache.build.bind(mutationCache); + + mutationCache.build = function ( + buildClient: QueryClient, + options: MutationOptions, + state?: MutationState, + ): Mutation { + const mutation = originalBuildMutation(buildClient, options, state); + const { mutationFn } = mutation.options; + + // Only mutations routed to a data service need a `globalId`, and only if + // they don't already have one (e.g., a mutation rebuilt from dehydrated + // service state may already have one). + // We recognize data-service mutation functions by consulting a WeakSet + // (and we use a WeakSet to distinguish mutation functions that *we* + // installed via the `defaultMutationOptions` override above, vs. ones that + // the engineer has added). + if ( + mutationFn === undefined || + !dataServiceMutationFns.has(mutationFn) || + readGlobalId(mutation.options.meta) !== undefined + ) { + return mutation; + } + + const globalId = uuidV4(); + + // Give the mutation its own `options`/`meta` objects rather than mutating + // in place. When options are already defaulted, TanStack returns the shared + // observer options as-is, so mutating them would leak this mutation's + // `globalId` back onto the observer and cause the next mutation built from + // that observer to reuse the same id. + const originalSetMutationOptions = mutation.setOptions.bind(mutation); + originalSetMutationOptions({ + ...mutation.options, + meta: { ...mutation.options.meta, globalId }, + }); + + // `MutationObserver.setOptions` (triggered on every re-render) replaces a + // pending mutation's options wholesale, which would otherwise strip the + // `globalId` and stop the mutation from matching its service-side cache + // updates. Preserve the established `globalId` across such updates. + mutation.setOptions = (nextOptions): void => { + originalSetMutationOptions({ + ...nextOptions, + meta: { ...nextOptions.meta, globalId }, + }); + }; + + return mutation; + }; + return client; } From 6018bb56342e1a1ff696aa3784ee27938996a064 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Fri, 11 Sep 2026 08:44:33 -0600 Subject: [PATCH 31/34] Only sync 'updated' cache events to UI mutations The mutation cache listener hydrated every non-'removed' :cacheUpdated event, including 'added'. A data service emits 'added' when it first builds a mutation, while it is still 'idle' with no result, so hydrating it overwrote (and wiped the result of) a UI mutation that had already settled. Sync only 'updated' events, which are the ones that carry a meaningful mutation state. Also restore the two-argument form of the 'globalId' assert in the data-service mutationFn. It had regressed to assert('...message...'), which passes the message as the (always-truthy) condition, so it never threw and never narrowed 'globalId' to a string. --- packages/react-data-query/CHANGELOG.md | 1 + .../src/createUIQueryClient.test.ts | 56 +++++++++++++++++++ .../src/createUIQueryClient.ts | 7 ++- 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/packages/react-data-query/CHANGELOG.md b/packages/react-data-query/CHANGELOG.md index 7584d96039f..073113ea201 100644 --- a/packages/react-data-query/CHANGELOG.md +++ b/packages/react-data-query/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Like `fetchQuery` and `fetchInfiniteQuery`, the `useMutation` wrapper disables retries by default and enforces that `mutationKey` matches the same thing that `executeMutation` takes. - `createUIQueryClient` now tags each mutation it creates with a unique `globalId` (stored on the mutation's `meta`) and passes it to the data service action as the trailing argument. It uses this `globalId` to update the exact UI mutation that a `:cacheUpdated` event corresponds to, so mutations sharing a `mutationKey` no longer clobber one another, and mutations that use a custom `mutationFn` are left untouched. - The `globalId` is minted once per mutation as it is built, so that each `mutate` call from the same observer gets its own id, and re-rendering (which re-defaults a mutation's options) can no longer change the id of an in-flight mutation and detach it from its `:cacheUpdated` events. + - Only `updated` `:cacheUpdated` events are synced to UI mutations. An `added` event carries the service mutation in its initial `idle` state, so hydrating it would overwrite (and wipe the result of) a UI mutation that had already settled. ### Changed diff --git a/packages/react-data-query/src/createUIQueryClient.test.ts b/packages/react-data-query/src/createUIQueryClient.test.ts index 3f3efb34d6c..b7d9f20c41e 100644 --- a/packages/react-data-query/src/createUIQueryClient.test.ts +++ b/packages/react-data-query/src/createUIQueryClient.test.ts @@ -505,6 +505,62 @@ describe('createUIQueryClient', () => { service.destroy(); }); + it('ignores `added` :cacheUpdated events so an idle service mutation cannot wipe a settled UI result', async () => { + const { clientA: client, messenger, service } = createClients(); + + mockAddFollowerRequest(); + + const observer = new TanStackQueryMutationObserver( + client, + { mutationKey: addFollowerMutationKey }, + ); + + await observer.mutate(); + + const mutationFromUi = client + .getMutationCache() + .find({ mutationKey: addFollowerMutationKey }); + assert(mutationFromUi); + expect(mutationFromUi.state.status).toBe('success'); + const settledState = mutationFromUi.state; + const { globalId } = mutationFromUi.meta ?? {}; + + // Replay the kind of event a data service emits when it builds a fresh + // mutation for the same key: the mutation is included in its initial + // `idle` state under an `added` event. + const hash = hashKey(addFollowerMutationKey); + messenger.publish(`ExampleDataService:cacheUpdated:${hash}`, { + objectType: 'mutation', + type: 'added', + state: { + queries: [], + mutations: [ + { + mutationKey: addFollowerMutationKey, + meta: { globalId }, + state: { + context: undefined, + data: undefined, + error: null, + failureCount: 0, + failureReason: null, + isPaused: false, + status: 'idle' as const, + submittedAt: 0, + variables: undefined, + }, + }, + ], + }, + }); + + expect(mutationFromUi.state).toBe(settledState); + expect(mutationFromUi.state.status).toBe('success'); + + observer.reset(); + service.destroy(); + }); + it('preserves mutations that share a mutation key with an action on the data service but were not actually routed through the data service', async () => { const { clientA: client, service } = createClients(); diff --git a/packages/react-data-query/src/createUIQueryClient.ts b/packages/react-data-query/src/createUIQueryClient.ts index a8680ae0e18..c683159fa9c 100644 --- a/packages/react-data-query/src/createUIQueryClient.ts +++ b/packages/react-data-query/src/createUIQueryClient.ts @@ -258,7 +258,12 @@ export function createUIQueryClient( payload, ); - if (payload.type === 'removed') { + // Only `updated` events carry a meaningful mutation state to sync. An + // `added` event fires when the service first builds a mutation, while + // it is still `idle` with no result; hydrating that would clobber a + // UI mutation that has already moved to `pending`, `success`, or + // `error`. A `removed` event carries no state at all. + if (payload.type !== 'updated') { return; } From 17dcc254ec947d76bf60edda501164f0eac75d7b Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Fri, 11 Sep 2026 08:47:10 -0600 Subject: [PATCH 32/34] Fix assert --- packages/react-data-query/src/createUIQueryClient.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/react-data-query/src/createUIQueryClient.ts b/packages/react-data-query/src/createUIQueryClient.ts index c683159fa9c..dee76d0f81d 100644 --- a/packages/react-data-query/src/createUIQueryClient.ts +++ b/packages/react-data-query/src/createUIQueryClient.ts @@ -382,9 +382,10 @@ export function createUIQueryClient( // We can't realistically test that it doesn't, since // `MutationCache.build` always runs first. // istanbul ignore next - if (globalId === undefined) { - assert('Expected mutation to have a `globalId`.'); - } + assert( + globalId !== undefined, + 'Expected mutation to have a `globalId` in its `meta` by the time its `mutationFn` runs, but none was found. This is a bug in `createUIQueryClient`.', + ); return await messenger.call(action, ...(params as Json[]), globalId); }; From 09b7d0f6ebae1463e4eae9293ee43b7cd238a2e1 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Fri, 11 Sep 2026 09:08:18 -0600 Subject: [PATCH 33/34] Update changelog --- packages/react-data-query/CHANGELOG.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/react-data-query/CHANGELOG.md b/packages/react-data-query/CHANGELOG.md index c39d5df3388..5095997a111 100644 --- a/packages/react-data-query/CHANGELOG.md +++ b/packages/react-data-query/CHANGELOG.md @@ -9,12 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add support for executing mutations by updating `createUIQueryClient` and adding `useMutation` wrapper ([#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 via the UI query client and pass a reference to the action as the mutation key. - - Like `fetchQuery` and `fetchInfiniteQuery`, the `useMutation` wrapper disables retries by default and enforces that `mutationKey` matches the same thing that `executeMutation` takes. - - `createUIQueryClient` now tags each mutation it creates with a unique `globalId` (stored on the mutation's `meta`) and passes it to the data service action as the trailing argument. It uses this `globalId` to update the exact UI mutation that a `:cacheUpdated` event corresponds to, so mutations sharing a `mutationKey` no longer clobber one another, and mutations that use a custom `mutationFn` are left untouched. - - The `globalId` is minted once per mutation as it is built, so that each `mutate` call from the same observer gets its own id, and re-rendering (which re-defaults a mutation's options) can no longer change the id of an in-flight mutation and detach it from its `:cacheUpdated` events. - - Only `updated` `:cacheUpdated` events are synced to UI mutations. An `added` event carries the service mutation in its initial `idle` state, so hydrating it would overwrite (and wipe the result of) a UI mutation that had already settled. +- Add support for mutations ([#9324](https://github.com/MetaMask/core/pull/9324)) + - You can now use `useMutation` in your UI files via the UI query client, passing a reference to the action and its called arguments through the `mutationKey` option (e.g. `['SocialService:follow', '0xaaaa', '0xbbbb']`). + - This assumes that the data service method you want to call uses `BaseDataService.executeMutation` to make the request instead of `fetchQuery`, but should also take a trailing `globalId` argument. + - Retries are disabled by default. + - Also, the query client returned by `createUIQueryClient` is now aware of mutations and will ensure that they are copied from data service query clients properly. ### Changed From ac8209b753470f3571f1b2717cecdaff17bab3ab Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Fri, 11 Sep 2026 09:09:47 -0600 Subject: [PATCH 34/34] Bump uuid to 9.0.1 --- packages/react-data-query/CHANGELOG.md | 2 +- packages/react-data-query/package.json | 2 +- yarn.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-data-query/CHANGELOG.md b/packages/react-data-query/CHANGELOG.md index 5095997a111..fea118f0da2 100644 --- a/packages/react-data-query/CHANGELOG.md +++ b/packages/react-data-query/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Add `uuid` `^8.3.2` as a dependency ([#9324](https://github.com/MetaMask/core/pull/9324)) +- Add `uuid` `^9.0.1` as a dependency ([#9324](https://github.com/MetaMask/core/pull/9324)) - Bump `@metamask/utils` from `^11.12.0` to `^12.0.0` ([#10192](https://github.com/MetaMask/core/pull/10192)) ## [2.0.0] diff --git a/packages/react-data-query/package.json b/packages/react-data-query/package.json index b00b943556c..91d452b32e3 100644 --- a/packages/react-data-query/package.json +++ b/packages/react-data-query/package.json @@ -52,7 +52,7 @@ "@metamask/utils": "^12.0.0", "@tanstack/query-core": "^5.62.16", "@tanstack/react-query": "^5.62.16", - "uuid": "^8.3.2" + "uuid": "^9.0.1" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", diff --git a/yarn.lock b/yarn.lock index 209ecbca258..82411b3b3dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8760,7 +8760,7 @@ __metadata: typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:@typescript/typescript6@^6.0.2" - uuid: "npm:^8.3.2" + uuid: "npm:^9.0.1" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0