diff --git a/packages/base-data-service/CHANGELOG.md b/packages/base-data-service/CHANGELOG.md index 362cf9042f6..25dd4aa755c 100644 --- a/packages/base-data-service/CHANGELOG.md +++ b/packages/base-data-service/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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. + - 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 - Bump `@metamask/utils` from `^11.12.0` to `^12.0.0` ([#10192](https://github.com/MetaMask/core/pull/10192)) diff --git a/packages/base-data-service/jest.config.cjs b/packages/base-data-service/jest.config.cjs index abd4f404e7e..e6a6cd34642 100644 --- a/packages/base-data-service/jest.config.cjs +++ b/packages/base-data-service/jest.config.cjs @@ -17,10 +17,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 96.49, - 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.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index c647ac6e7f7..69c15781ae9 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,140 @@ 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`, + { + objectType: 'query', + type: 'added', + hash, + state: expectedState, + }, + ); + + expect(publishSpy).toHaveBeenNthCalledWith( + 2, + `ExampleDataService:cacheUpdated:${hash}`, + { + objectType: 'query', + 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`, + { + objectType: 'mutation', + type: 'added', + hash, + state: expectedState, + }, + ); + + expect(publishSpy).toHaveBeenNthCalledWith( + 2, + `ExampleDataService:cacheUpdated:${hash}`, + { + objectType: 'mutation', + type: 'added', + state: expectedState, + }, + ); + }); + + it('emits `:cacheUpdated` events when query cache is updated', async () => { const messenger = createServiceMessenger(); const service = new ExampleDataService(messenger); @@ -151,48 +286,113 @@ 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`, + { + objectType: 'query', + type: 'updated', + hash, + state: expectedState, + }, + ); expect(publishSpy).toHaveBeenNthCalledWith( 6, `ExampleDataService:cacheUpdated:${hash}`, { + objectType: 'query', 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 cache entry is removed', async () => { + 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`, + { + objectType: 'mutation', + type: 'updated', + hash, + state: expectedState, + }, + ); + + expect(publishSpy).toHaveBeenNthCalledWith( + 6, + `ExampleDataService:cacheUpdated:${hash}`, + { + objectType: 'mutation', + type: 'updated', + state: expectedState, + }, + ); + }); + + it('emits `:cacheUpdated` events when query cache entry is removed', async () => { const messenger = createServiceMessenger(); const service = new ExampleDataService(messenger); @@ -207,17 +407,98 @@ describe('BaseDataService', () => { const hash = hashKey(queryKey); + expect(publishSpy).toHaveBeenNthCalledWith( + 7, + `ExampleDataService:cacheUpdated`, + { + objectType: 'query', + type: 'removed', + hash, + state: null, + }, + ); + + expect(publishSpy).toHaveBeenNthCalledWith( + 8, + `ExampleDataService:cacheUpdated:${hash}`, + { + objectType: 'query', + 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`, + { + objectType: 'mutation', + type: 'removed', + hash, + state: null, + }, + ); + + expect(publishSpy).toHaveBeenNthCalledWith( + 7, + `ExampleDataService:cacheUpdated`, + { + objectType: 'mutation', + type: 'removed', + hash, + state: null, + }, + ); + expect(publishSpy).toHaveBeenNthCalledWith( 8, `ExampleDataService:cacheUpdated:${hash}`, { + objectType: 'mutation', type: 'removed', state: null, }, ); }); + 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(); const service = new ExampleDataService(messenger); const publishSpy = jest.spyOn(messenger, 'publish'); @@ -225,10 +506,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 +552,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 +564,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 77ffd8981c7..aae28c098a8 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-es'; @@ -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. @@ -59,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 & { @@ -103,12 +127,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 +192,8 @@ export class BaseDataService< readonly #queryCacheUnsubscribe: () => void; + readonly #mutationCacheUnsubscribe: () => void; + readonly #debouncedPersist?: DebouncedFunc<() => void>; readonly #persistenceConfig?: PersistenceConfiguration; @@ -210,7 +241,10 @@ export class BaseDataService< ...QUERY_CLIENT_DEFAULTS.queries, ...queryClientConfig.defaultOptions?.queries, }, - mutations: queryClientConfig.defaultOptions?.mutations, + mutations: { + ...QUERY_CLIENT_DEFAULTS.mutations, + ...queryClientConfig.defaultOptions?.mutations, + }, }, }); @@ -239,10 +273,32 @@ 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 ( + ['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 +316,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 +329,7 @@ export class BaseDataService< : TQueryFnData, TQueryKey extends QueryKey = QueryKey, >({ + queryFn, responseStruct, ...options }: WithRequired< @@ -287,9 +345,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 +446,80 @@ 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. + * @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< + TMutationFnData extends Json, + TDataStruct extends Struct | undefined = undefined, + TData = TDataStruct extends Struct + ? StructType + : TMutationFnData, + TError = DefaultError, + TOnMutateResult = unknown, + TMutationKey extends MutationKey = MutationKey, + >({ + mutationFn, + responseStruct, + globalId, + ...options + }: OmitKeyof< + MutationOptions, TOnMutateResult>, + 'retry' | 'retryDelay' | 'mutationKey' | 'mutationFn' + > & { + mutationKey: TMutationKey; + mutationFn: MutationFunction>; + responseStruct?: TDataStruct; + globalId?: string; + }): Promise { + const mutationCache = this.#queryClient.getMutationCache(); + const mutation = mutationCache.build< + TData, + TError, + Record, + TOnMutateResult + >(this.#queryClient, { + ...options, + ...(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 + // 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), + ); + // 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 + // 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. * @@ -421,38 +551,57 @@ 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, + objectType, hash, state, } as DataServiceCacheUpdatedPayload, ); - this.#messenger.publish( `${this.name}:cacheUpdated:${hash}` as const, { - type, + type: eventType, + objectType, state, } as DataServiceGranularCacheUpdatedPayload, ); 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/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..e7d22accc8e 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,63 @@ 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 ResponseStruct - The struct used to validate and decode the response, + * e.g. `Struct`, or `undefined` when no validation is needed. + */ +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?: 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) { + throw new Error( + `Mutation function for "${mutationKey[0]}" returned an unexpected response: ${error.message}.`, + ); + } + + // Type assertion: See above. + return result as unknown as Response; +} 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..7edd2fa0825 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,21 @@ export type ExampleDataServiceGetActivityAction = { handler: ExampleDataService['getActivity']; }; +export type ExampleDataServiceAddFollowerAction = { + type: `ExampleDataService:addFollower`; + handler: ExampleDataService['addFollower']; +}; + +export type ExampleDataServiceCreateDataDeletionTaskAction = { + type: `ExampleDataService:createDataDeletionTask`; + handler: ExampleDataService['createDataDeletionTask']; +}; + /** * Union of all ExampleDataService action types. */ export type ExampleDataServiceMethodActions = | ExampleDataServiceGetAssetsAction - | ExampleDataServiceGetActivityAction; + | ExampleDataServiceGetActivityAction + | ExampleDataServiceAddFollowerAction + | ExampleDataServiceCreateDataDeletionTaskAction; diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts index 8f77e10b751..10402aa659c 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,12 @@ export type PageParam = | { after: string } | null; -const MESSENGER_EXPOSED_METHODS = ['getAssets', 'getActivity'] as const; +const MESSENGER_EXPOSED_METHODS = [ + 'getAssets', + 'getActivity', + 'addFollower', + 'createDataDeletionTask', +] as const; export class ExampleDataService extends BaseDataService< typeof serviceName, @@ -86,6 +106,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 } = { @@ -171,6 +195,75 @@ export class ExampleDataService extends BaseDataService< ); } + 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`); + + const response = await fetch(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ followerId }), + }); + + if (!response.ok) { + throw new Error( + `Mutation failed with status code: ${response.status}.`, + ); + } + + return response.json(); + }, + gcTime: inMilliseconds(1, Duration.Day), + responseStruct: AddFollowerResponseStruct, + }); + } + + async createDataDeletionTask( + analyticsId: string, + segmentSourceId: string, + globalId?: string, + ): Promise<{ + status: 'ok' | 'error'; + regulateId: string; + }> { + return this.executeMutation({ + mutationKey: [ + `${this.name}:createDataDeletionTask`, + analyticsId, + segmentSourceId, + ], + globalId, + 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) { + 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/react-data-query/CHANGELOG.md b/packages/react-data-query/CHANGELOG.md index 073a85cff01..fea118f0da2 100644 --- a/packages/react-data-query/CHANGELOG.md +++ b/packages/react-data-query/CHANGELOG.md @@ -7,8 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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 +- 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 0a2237ba212..91d452b32e3 100644 --- a/packages/react-data-query/package.json +++ b/packages/react-data-query/package.json @@ -51,7 +51,8 @@ "@metamask/base-data-service": "^2.0.0", "@metamask/utils": "^12.0.0", "@tanstack/query-core": "^5.62.16", - "@tanstack/react-query": "^5.62.16" + "@tanstack/react-query": "^5.62.16", + "uuid": "^9.0.1" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", @@ -60,6 +61,7 @@ "@typescript/native": "npm:typescript@^7.0.2", "deepmerge": "^4.2.2", "jest": "^30.4.2", + "nock": "^13.3.1", "rimraf": "^5.0.5", "ts-jest": "^29.4.11", "tsx": "^4.20.5", diff --git a/packages/react-data-query/src/createUIQueryClient.test.ts b/packages/react-data-query/src/createUIQueryClient.test.ts index 8bae8da68c2..b7d9f20c41e 100644 --- a/packages/react-data-query/src/createUIQueryClient.test.ts +++ b/packages/react-data-query/src/createUIQueryClient.test.ts @@ -8,16 +8,26 @@ import { MessengerActions, MockAnyNamespace, } from '@metamask/messenger'; -import { Duration, inMilliseconds } from '@metamask/utils'; import { + Duration, + createDeferredPromise, + inMilliseconds, +} from '@metamask/utils'; +import { + hashKey, 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 assert from 'assert'; +import { ReplyBody } from 'nock'; import { + AddFollowerResponse, ExampleDataService, ExampleMessenger, GetActivityResponse, @@ -26,8 +36,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 +176,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 +193,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 +224,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 +288,312 @@ 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('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( + client, + { mutationKey: addFollowerMutationKey }, + ); + const observerB = new TanStackQueryMutationObserver( + client, + { mutationKey: addFollowerMutationKey }, + ); + + await Promise.all([observerA.mutate(), observerB.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]); + + observerA.reset(); + observerB.reset(); + 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(); + + 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('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(); + + 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'); + // 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(); + service.destroy(); + }); + + it('fetches queries using observers in the same client', async () => { const { clientA, service } = createClients(); const observerA = new QueryObserver(clientA, { @@ -284,6 +632,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 +675,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 +683,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 +691,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 +702,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 +753,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 +788,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 +796,63 @@ 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) }, }; @@ -454,6 +887,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); @@ -470,7 +904,60 @@ describe('createUIQueryClient', () => { service.destroy(); }); - it('fetches using paginated observers', async () => { + it('cleans up removed mutation cache entries once all mutation observers are removed', async () => { + const defaultOptions = { + mutations: { gcTime: inMilliseconds(5, Duration.Minute) }, + }; + + const { clientA, clientB, service } = createClients({ defaultOptions }); + mockAddFollowerRequest(); + mockAddFollowerRequest(); + + 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); + + await Promise.all([promiseA, promiseB]); + + // Advance the full gcTime of ExampleDataService + jest.advanceTimersByTime(inMilliseconds(1, Duration.Day)); + + const mutationBeforeRemovalA = clientA + .getMutationCache() + .find({ mutationKey: addFollowerMutationKey }); + const mutationBeforeRemovalB = clientB + .getMutationCache() + .find({ mutationKey: addFollowerMutationKey }); + expect(mutationBeforeRemovalA).toBeDefined(); + expect(mutationBeforeRemovalB).toBeDefined(); + + observerA.reset(); + observerB.reset(); + + jest.advanceTimersByTime(inMilliseconds(5, Duration.Minute)); + + const mutationDataAfterRemovalA = clientA + .getMutationCache() + .find({ mutationKey: addFollowerMutationKey }); + const mutationDataAfterRemovalB = clientB + .getMutationCache() + .find({ mutationKey: addFollowerMutationKey }); + expect(mutationDataAfterRemovalA).toBeUndefined(); + expect(mutationDataAfterRemovalB).toBeUndefined(); + + service.destroy(); + }); + + it('fetches using paginated query observers', async () => { const { clientA, clientB, service } = createClients(); const getPreviousPageParam = ({ @@ -523,6 +1010,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 +1025,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 +1042,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 +1080,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 +1103,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..dee76d0f81d 100644 --- a/packages/react-data-query/src/createUIQueryClient.ts +++ b/packages/react-data-query/src/createUIQueryClient.ts @@ -2,15 +2,27 @@ 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, + Mutation, + MutationOptions, + MutationState, + MutationFunction, + MutationFunctionContext, } 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'); /** * Handles granular cache update events emitted by data services. @@ -57,21 +69,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, @@ -83,6 +88,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 +152,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 +167,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 +193,7 @@ export function createUIQueryClient( return; } + log('Hydrating with', payload.state); hydrate(client, payload.state); }; @@ -208,7 +219,91 @@ 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, + ); + + // 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; + } + + hydrateMutations(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 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; + + 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); client.invalidateQueries = async ( @@ -234,5 +329,128 @@ export function createUIQueryClient( return originalInvalidate(filters, options); }; + // 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); + + 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); + + if (defaultedOptions.mutationFn === undefined) { + const dataServiceMutationFn = async ( + _variables: unknown, + context: MutationFunctionContext, + ): 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}"`); + + // 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 + 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); + }; + + 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; } 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..2235e5e2ef4 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,23 @@ 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. - */ +// 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, }; +const DATA_SERVICE_MUTATION_DEFAULTS = { + retry: false, +}; + /** * Consume a query from a data service. * @@ -46,7 +58,7 @@ export function useQuery< | NonUndefinedGuard; }, ): UseQueryResult { - return useQueryTanStack({ ...DATA_SERVICE_QUERY_DEFAULTS, ...options }); + return useQueryFromTanStack({ ...DATA_SERVICE_QUERY_DEFAULTS, ...options }); } /** @@ -68,8 +80,43 @@ export function useInfiniteQuery< 'staleTime' | 'queryFn' >, ): UseInfiniteQueryResult { - return useInfiniteQueryTanStack({ + return useInfiniteQueryFromTanStack({ ...DATA_SERVICE_QUERY_DEFAULTS, ...options, }); } + +/** + * Execute a mutation through a data service. + * + * 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< + 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, + }); +} 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..e0400aa190e --- /dev/null +++ b/packages/react-data-query/src/hydrateMutations.test.ts @@ -0,0 +1,304 @@ +import { + DehydratedState, + Mutation, + MutationState, + QueryClient, + MutationCacheNotifyEvent, +} from '@tanstack/query-core'; +import assert from 'assert'; + +import { hydrateMutations } from './hydrateMutations.js'; + +const EXAMPLE_MUTATION_KEY = ['ExampleDataService:addFollower', '1']; +const EXAMPLE_GLOBAL_ID = 'global-id'; + +type NotifyEventMutationUpdated = Extract< + MutationCacheNotifyEvent, + { type: 'updated' } +>; + +describe('hydrateMutations', () => { + it('updates the mutation whose `globalId` matches the dehydrated mutation', () => { + const globalId = EXAMPLE_GLOBAL_ID; + const { queryClient } = createQueryClientWithMutation({ + mutationKey: EXAMPLE_MUTATION_KEY, + globalId, + }); + const dehydratedMutationState = { + status: 'success' as const, + data: 'result', + }; + + hydrateMutations( + queryClient, + createDehydratedStateWithMutation({ + mutationKey: EXAMPLE_MUTATION_KEY, + globalId, + state: createMutationState(dehydratedMutationState), + }), + ); + + const mutation = queryClient.getMutationCache().find({ + mutationKey: EXAMPLE_MUTATION_KEY, + }); + 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 { queryClient, mutation } = createQueryClientWithMutation({ + mutationKey: EXAMPLE_MUTATION_KEY, + globalId: 'some-global-id', + }); + const stateBefore = mutation.state; + + hydrateMutations( + queryClient, + createDehydratedStateWithMutation({ + mutationKey: EXAMPLE_MUTATION_KEY, + globalId: 'a-different-global-id', + state: createMutationState({ status: 'success', data: 'result' }), + }), + ); + + expect(mutation.state).toBe(stateBefore); + }); + + 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( + queryClient, + createDehydratedStateWithMutation({ + mutationKey: EXAMPLE_MUTATION_KEY, + globalId: undefined, + state: createMutationState({ status: 'success', data: 'result' }), + }), + ); + + expect(mutation.state).toBe(stateBefore); + }); + + 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( + 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 { 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' }); + }); + + it('notifies subscribers with an `error` action when the mutation failed', () => { + const error = new Error('boom'); + + 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 { 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({ + type: 'pending', + variables: { followerId: '1' }, + context: { previous: null }, + isPaused: true, + }); + }); + + it('notifies subscribers with a `continue` action when the mutation is 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' }); + }); +}); + +/** + * 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 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 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 }; +} + +/** + * 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 createDehydratedStateWithMutation({ + mutationKey, + globalId, + state, +}: { + mutationKey: string[]; + globalId?: unknown; + state: MutationState; +}): DehydratedState { + return { + queries: [], + mutations: [ + { + mutationKey, + 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, + }; +} + +/** + * Executes a function that presumably operates on a query client, collecting + * the actions that its mutation cache produces when there are updates. + * + * @param queryClient - The query client. + * @param fn - The function to call. + * @returns The captured mutation cache actions. + */ +function capturingMutationCacheActions( + queryClient: QueryClient, + fn: () => void, +): NotifyEventMutationUpdated['action'][] { + const actions: NotifyEventMutationUpdated['action'][] = []; + queryClient.getMutationCache().subscribe((event) => { + if (event.type === 'updated') { + actions.push(event.action); + } + }); + + fn(); + + 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..911f50e214e --- /dev/null +++ b/packages/react-data-query/src/hydrateMutations.ts @@ -0,0 +1,117 @@ +import { + QueryClient, + DehydratedState, + MutationState, +} from '@tanstack/query-core'; + +/** + * Get the `globalId` of a mutation by reading its `meta` data. + * + * 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. + */ +export function readGlobalId( + meta: Record | undefined, +): string | undefined { + const globalId = meta?.globalId; + return typeof globalId === 'string' ? globalId : undefined; +} + +/** + * Load dehydrated mutations into the given query client. + * + * 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 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 a data service's + * `:cacheUpdated` event. + */ +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); + + // 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; + } + + 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), + }); + } + } +} + +/** + * 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 state of a dehydrated mutation. + * @returns The mutation cache 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' }; + } +} 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'; diff --git a/packages/react-data-query/src/loggers.ts b/packages/react-data-query/src/loggers.ts new file mode 100644 index 00000000000..af61a2c008d --- /dev/null +++ b/packages/react-data-query/src/loggers.ts @@ -0,0 +1,5 @@ +import { createProjectLogger, createModuleLogger } from '@metamask/utils'; + +export const projectLogger = createProjectLogger('react-data-query'); + +export { createModuleLogger }; diff --git a/yarn.lock b/yarn.lock index f7c6d47c78d..82411b3b3dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8753,12 +8753,14 @@ __metadata: "@typescript/native": "npm:typescript@^7.0.2" deepmerge: "npm:^4.2.2" jest: "npm:^30.4.2" + nock: "npm:^13.3.1" rimraf: "npm:^5.0.5" ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:@typescript/typescript6@^6.0.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