From 0303748a2cec258e0444b4136f9d82ef0162359e Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 10 Feb 2026 15:27:47 +0100 Subject: [PATCH 01/54] Add beginning of BaseDataService --- packages/base-data-service/package.json | 5 + .../src/BaseDataService.test.ts | 59 +++++++ .../base-data-service/src/BaseDataService.ts | 164 ++++++++++++++++++ packages/base-data-service/src/index.test.ts | 9 - packages/base-data-service/src/index.ts | 10 +- yarn.lock | 12 +- 6 files changed, 240 insertions(+), 19 deletions(-) create mode 100644 packages/base-data-service/src/BaseDataService.test.ts create mode 100644 packages/base-data-service/src/BaseDataService.ts delete mode 100644 packages/base-data-service/src/index.test.ts diff --git a/packages/base-data-service/package.json b/packages/base-data-service/package.json index 7e49071c90e..4b6442bebcb 100644 --- a/packages/base-data-service/package.json +++ b/packages/base-data-service/package.json @@ -64,5 +64,10 @@ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" + }, + "dependencies": { + "@metamask/messenger": "workspace:^", + "@metamask/utils": "^11.9.0", + "@tanstack/query-core": "^4.43.0" } } diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts new file mode 100644 index 00000000000..1e0695e178c --- /dev/null +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -0,0 +1,59 @@ +import { Messenger } from '@metamask/messenger'; +import { BaseDataService } from './BaseDataService'; + +const serviceName = 'ExampleDataService'; + +type ExampleMessenger = Messenger; + +class ExampleDataService extends BaseDataService< + typeof serviceName, + ExampleMessenger +> { + #baseUrl = 'https://accounts.api.cx.metamask.io'; + + constructor(messenger: ExampleMessenger) { + super({ + name: serviceName, + messenger, + }); + + messenger.registerActionHandler( + `${this.name}:getActivity`, + // @ts-expect-error TODO. + this.getActivity.bind(this), + ); + } + + async getActivity(address: string) { + return this.fetchInfiniteQuery({ + queryKey: [`${this.name}:getActivity`, address], + queryFn: async ({ pageParam }) => { + const caipAddress = `eip155:0:${address.toLowerCase()}`; + const url = new URL( + `${this.#baseUrl}/v4/multiaccount/transactions?limit=10&accountAddresses=${caipAddress}`, + ); + + if (pageParam) { + url.searchParams.set('cursor', pageParam); + } + + const response = await fetch(url); + + return response.json(); + }, + getNextPageParam: ({ pageInfo }: { pageInfo: any }) => + pageInfo.hasNextPage ? pageInfo.endCursor : undefined, + }); + } +} + +describe('BaseDataService', () => { + it('works', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + expect( + await service.getActivity('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'), + ).toBe({}); + }); +}); diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts new file mode 100644 index 00000000000..2ad95946e63 --- /dev/null +++ b/packages/base-data-service/src/BaseDataService.ts @@ -0,0 +1,164 @@ +import { + DehydratedState, + FetchInfiniteQueryOptions, + FetchQueryOptions, + InfiniteData, + QueryClient, + QueryKey, + WithRequired, + dehydrate, + hashQueryKey, +} from '@tanstack/query-core'; +import { + Messenger, + ActionConstraint, + EventConstraint, +} from '@metamask/messenger'; +import { Json } from '@metamask/utils'; + +type SubscriptionCallback = (payload: Json) => void; + +export class BaseDataService< + ServiceName extends string, + ServiceMessenger extends Messenger< + ServiceName, + ActionConstraint, + EventConstraint, + // Use `any` to allow any parent to be set. `any` is harmless in a type constraint anyway, + // it's the one totally safe place to use it. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any + >, +> { + name: string; + + #messenger: ServiceMessenger; + + #client = new QueryClient(); + + #subscriptions: Map> = new Map(); + + constructor({ + name, + messenger, + }: { + name: ServiceName; + messenger: ServiceMessenger; + }) { + this.name = name; + this.#messenger = messenger; + + this.#registerMessageHandlers(); + this.#setupCacheListener(); + } + + protected async fetchQuery< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + >( + options: WithRequired< + FetchQueryOptions, + 'queryKey' + >, + ): Promise { + return this.#client.ensureQueryData(options); + } + + protected async fetchInfiniteQuery< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, + >( + options: FetchInfiniteQueryOptions, + ): Promise> { + // @ts-expect-error TODO. + return this.#client.ensureQueryData(options); + } + + #registerMessageHandlers() { + this.#messenger.registerActionHandler( + // @ts-expect-error TODO. + `${this.name}:subscribe`, + (queryKey: QueryKey, callback: SubscriptionCallback) => { + return this.#handleSubscribe(queryKey, callback); + }, + ); + + this.#messenger.registerActionHandler( + // @ts-expect-error TODO. + `${this.name}:unsubscribe`, + (queryKey: QueryKey, callback: SubscriptionCallback) => { + return this.#handleUnsubscribe(queryKey, callback); + }, + ); + } + + #setupCacheListener() { + this.#client.getQueryCache().subscribe((event) => { + if (!event.query) { + return; + } + + const queryKeyHash = event.query.queryHash; + + if (this.#subscriptions.has(queryKeyHash)) { + this.#broadcastQueryState(event.query.queryKey); + } + }); + } + + #handleSubscribe( + queryKey: QueryKey, + subscription: SubscriptionCallback, + ): DehydratedState { + const hash = hashQueryKey(queryKey); + + if (!this.#subscriptions.has(hash)) { + this.#subscriptions.set(hash, new Set()); + } + + this.#subscriptions.get(hash)!.add(subscription); + + return this.#getDehydratedStateForQuery(queryKey); + } + + #handleUnsubscribe( + queryKey: QueryKey, + subscription: SubscriptionCallback, + ): void { + const hash = hashQueryKey(queryKey); + const subscribers = this.#subscriptions.get(hash); + + if (!subscribers) { + return; + } + + subscribers.delete(subscription); + if (subscribers.size === 0) { + this.#subscriptions.delete(hash); + } + } + + #getDehydratedStateForQuery(queryKey: QueryKey): DehydratedState { + const hash = hashQueryKey(queryKey); + return dehydrate(this.#client, { + shouldDehydrateQuery: (query) => query.queryHash === hash, + }); + } + + #broadcastQueryState(queryKey: QueryKey) { + const hash = hashQueryKey(queryKey); + const state = this.#getDehydratedStateForQuery(queryKey); + + const subscribers = this.#subscriptions.get(hash)!; + subscribers.forEach((subscriber) => + subscriber({ + queryKeyHash: hash, + state, + } as unknown as Json), + ); + } +} diff --git a/packages/base-data-service/src/index.test.ts b/packages/base-data-service/src/index.test.ts deleted file mode 100644 index bc062d3694a..00000000000 --- a/packages/base-data-service/src/index.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import greeter from '.'; - -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greeter(name); - expect(result).toBe('Hello, Huey!'); - }); -}); diff --git a/packages/base-data-service/src/index.ts b/packages/base-data-service/src/index.ts index 6972c117292..8b032fc1277 100644 --- a/packages/base-data-service/src/index.ts +++ b/packages/base-data-service/src/index.ts @@ -1,9 +1 @@ -/** - * Example function that returns a greeting for the given name. - * - * @param name - The name to greet. - * @returns The greeting. - */ -export default function greeter(name: string): string { - return `Hello, ${name}!`; -} +export * from './BaseDataService'; diff --git a/yarn.lock b/yarn.lock index ab107535d6c..3af02b123c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2986,6 +2986,9 @@ __metadata: resolution: "@metamask/base-data-service@workspace:packages/base-data-service" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/messenger": "workspace:^" + "@metamask/utils": "npm:^11.9.0" + "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" @@ -4323,7 +4326,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/messenger@npm:^0.3.0, @metamask/messenger@workspace:packages/messenger": +"@metamask/messenger@npm:^0.3.0, @metamask/messenger@workspace:^, @metamask/messenger@workspace:packages/messenger": version: 0.0.0-use.local resolution: "@metamask/messenger@workspace:packages/messenger" dependencies: @@ -6205,6 +6208,13 @@ __metadata: languageName: node linkType: hard +"@tanstack/query-core@npm:^4.43.0": + version: 4.43.0 + resolution: "@tanstack/query-core@npm:4.43.0" + checksum: 10/c2a5a151c7adaea8311e01a643255f31946ae3164a71567ba80048242821ae14043f13f5516b695baebe5ea7e4b2cf717fd60908a929d18a5c5125fee925ff67 + languageName: node + linkType: hard + "@tanstack/query-core@npm:^5.62.16": version: 5.90.20 resolution: "@tanstack/query-core@npm:5.90.20" From 79d2f3a50e5e78b8fa836d49b4732bb8a5faf75d Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 11 Feb 2026 14:41:39 +0100 Subject: [PATCH 02/54] Support pagination --- .../base-data-service/src/BaseDataService.ts | 52 ++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 2ad95946e63..a0ac4466bea 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -3,18 +3,22 @@ import { FetchInfiniteQueryOptions, FetchQueryOptions, InfiniteData, + InvalidateOptions, + InvalidateQueryFilters, QueryClient, + QueryFunctionContext, QueryKey, WithRequired, dehydrate, hashQueryKey, + infiniteQueryBehavior, } from '@tanstack/query-core'; import { Messenger, ActionConstraint, EventConstraint, } from '@metamask/messenger'; -import { Json } from '@metamask/utils'; +import { assert, Json } from '@metamask/utils'; type SubscriptionCallback = (payload: Json) => void; @@ -60,10 +64,10 @@ export class BaseDataService< >( options: WithRequired< FetchQueryOptions, - 'queryKey' + 'queryKey' | 'queryFn' >, ): Promise { - return this.#client.ensureQueryData(options); + return this.#client.fetchQuery(options); } protected async fetchInfiniteQuery< @@ -72,10 +76,46 @@ export class BaseDataService< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >( - options: FetchInfiniteQueryOptions, + options: WithRequired< + FetchInfiniteQueryOptions, + 'queryKey' | 'queryFn' + >, + context: QueryFunctionContext, ): Promise> { - // @ts-expect-error TODO. - return this.#client.ensureQueryData(options); + assert(context, 'Context must be passed when using fetchInfiniteQuery.'); + + const queryData = await this.#client.ensureQueryData(options); + + if (context.pageParam) { + const query = this.#client + .getQueryCache() + .find({ queryKey: options.queryKey })!; + + return query.fetch({ + ...options, + behavior: { + onFetch: (fetchContext) => { + // Combine fetchContext with passed context, that may come from UI. + fetchContext.fetchFn = () => + fetchContext.options.queryFn({ + queryKey: fetchContext.queryKey, + signal: fetchContext.signal, + meta: context.meta, + pageParam: context.pageParam, + }); + }, + }, + }); + } + + return queryData; + } + + protected async invalidateQueries( + filters?: InvalidateQueryFilters, + options?: InvalidateOptions, + ): Promise { + return this.#client.invalidateQueries(filters, options); } #registerMessageHandlers() { From 4406b48570f4725ffe2da225c651b7e7ad35b033 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 11 Feb 2026 15:14:42 +0100 Subject: [PATCH 03/54] Improve pagination --- .../base-data-service/src/BaseDataService.ts | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index a0ac4466bea..daeb3ee6400 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -81,34 +81,32 @@ export class BaseDataService< 'queryKey' | 'queryFn' >, context: QueryFunctionContext, - ): Promise> { + ): Promise { assert(context, 'Context must be passed when using fetchInfiniteQuery.'); - const queryData = await this.#client.ensureQueryData(options); - - if (context.pageParam) { - const query = this.#client - .getQueryCache() - .find({ queryKey: options.queryKey })!; - - return query.fetch({ - ...options, - behavior: { - onFetch: (fetchContext) => { - // Combine fetchContext with passed context, that may come from UI. - fetchContext.fetchFn = () => - fetchContext.options.queryFn({ - queryKey: fetchContext.queryKey, - signal: fetchContext.signal, - meta: context.meta, - pageParam: context.pageParam, - }); + const query = this.#client + .getQueryCache() + .find({ queryKey: options.queryKey }); + + if (query && context.pageParam) { + const result = (await query.fetch(undefined, { + meta: { + // TODO: Determine if this breaks when fetching backwards. + fetchMore: { + direction: 'forward', + pageParam: context.pageParam, }, }, - }); + })) as InfiniteData; + + const pageIndex = result.pageParams.indexOf(context.pageParam); + + return result.pages[pageIndex]; } - return queryData; + const result = await this.#client.fetchInfiniteQuery(options); + + return result.pages[0]; } protected async invalidateQueries( From 5f45528588eae9a56cd075ac76fb26f1c599c08d Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 11 Feb 2026 15:52:47 +0100 Subject: [PATCH 04/54] Add invalidateQueries action --- packages/base-data-service/src/BaseDataService.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index daeb3ee6400..043f34feca0 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -11,7 +11,6 @@ import { WithRequired, dehydrate, hashQueryKey, - infiniteQueryBehavior, } from '@tanstack/query-core'; import { Messenger, @@ -132,6 +131,12 @@ export class BaseDataService< return this.#handleUnsubscribe(queryKey, callback); }, ); + + this.#messenger.registerActionHandler( + // @ts-expect-error TODO. + `${this.name}:invalidateQueries`, + this.invalidateQueries.bind(this), + ); } #setupCacheListener() { From e24309efeef8af48f58928b7eb3fcf72c93ead3f Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Fri, 13 Feb 2026 15:12:56 +0100 Subject: [PATCH 05/54] Account for fetch direction --- .../base-data-service/src/BaseDataService.ts | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 043f34feca0..a63d18ccee4 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -6,7 +6,6 @@ import { InvalidateOptions, InvalidateQueryFilters, QueryClient, - QueryFunctionContext, QueryKey, WithRequired, dehydrate, @@ -17,7 +16,7 @@ import { ActionConstraint, EventConstraint, } from '@metamask/messenger'; -import { assert, Json } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; type SubscriptionCallback = (payload: Json) => void; @@ -74,31 +73,36 @@ export class BaseDataService< TError = unknown, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, + TPageParam = unknown, >( options: WithRequired< FetchInfiniteQueryOptions, 'queryKey' | 'queryFn' >, - context: QueryFunctionContext, + pageParam?: TPageParam, ): Promise { - assert(context, 'Context must be passed when using fetchInfiniteQuery.'); - const query = this.#client .getQueryCache() .find({ queryKey: options.queryKey }); - if (query && context.pageParam) { + if (query && pageParam) { + const pages = + (query.state.data as InfiniteData | undefined)?.pages ?? + []; + const previous = options.getPreviousPageParam?.(pages[0], pages); + + const direction = pageParam === previous ? 'backward' : 'forward'; + const result = (await query.fetch(undefined, { meta: { - // TODO: Determine if this breaks when fetching backwards. fetchMore: { - direction: 'forward', - pageParam: context.pageParam, + direction, + pageParam, }, }, })) as InfiniteData; - const pageIndex = result.pageParams.indexOf(context.pageParam); + const pageIndex = result.pageParams.indexOf(pageParam); return result.pages[pageIndex]; } From ea3f10cc82a930d04ba740cf28758aa1d0bce33c Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 25 Feb 2026 14:29:34 +0100 Subject: [PATCH 06/54] Follow conventions for referencing local packages --- packages/base-data-service/package.json | 2 +- packages/base-data-service/tsconfig.build.json | 4 +++- packages/base-data-service/tsconfig.json | 4 +++- yarn.lock | 4 ++-- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/base-data-service/package.json b/packages/base-data-service/package.json index 4b6442bebcb..d5c3892474b 100644 --- a/packages/base-data-service/package.json +++ b/packages/base-data-service/package.json @@ -66,7 +66,7 @@ "registry": "https://registry.npmjs.org/" }, "dependencies": { - "@metamask/messenger": "workspace:^", + "@metamask/messenger": "^0.3.0", "@metamask/utils": "^11.9.0", "@tanstack/query-core": "^4.43.0" } diff --git a/packages/base-data-service/tsconfig.build.json b/packages/base-data-service/tsconfig.build.json index 02a0eea03fe..fa04f31e471 100644 --- a/packages/base-data-service/tsconfig.build.json +++ b/packages/base-data-service/tsconfig.build.json @@ -5,6 +5,8 @@ "outDir": "./dist", "rootDir": "./src" }, - "references": [], + "references": [ + { "path": "../messenger/tsconfig.build.json" } + ], "include": ["../../types", "./src"] } diff --git a/packages/base-data-service/tsconfig.json b/packages/base-data-service/tsconfig.json index 025ba2ef7f4..b5fdcac3bf3 100644 --- a/packages/base-data-service/tsconfig.json +++ b/packages/base-data-service/tsconfig.json @@ -3,6 +3,8 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [], + "references": [ + { "path": "../messenger/tsconfig.build.json" } + ], "include": ["../../types", "./src"] } diff --git a/yarn.lock b/yarn.lock index 3af02b123c2..602973cd5f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2986,7 +2986,7 @@ __metadata: resolution: "@metamask/base-data-service@workspace:packages/base-data-service" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/messenger": "workspace:^" + "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" @@ -4326,7 +4326,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/messenger@npm:^0.3.0, @metamask/messenger@workspace:^, @metamask/messenger@workspace:packages/messenger": +"@metamask/messenger@npm:^0.3.0, @metamask/messenger@workspace:packages/messenger": version: 0.0.0-use.local resolution: "@metamask/messenger@workspace:packages/messenger" dependencies: From 55c8c598e5463e323fd7b04ecd4ba09006e78d63 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 25 Feb 2026 15:52:13 +0100 Subject: [PATCH 07/54] Improve typing --- packages/base-data-service/package.json | 10 +- .../base-data-service/src/BaseDataService.ts | 125 +++++++++++------- .../base-data-service/tsconfig.build.json | 4 +- packages/base-data-service/tsconfig.json | 4 +- 4 files changed, 85 insertions(+), 58 deletions(-) diff --git a/packages/base-data-service/package.json b/packages/base-data-service/package.json index d5c3892474b..60309f2f01d 100644 --- a/packages/base-data-service/package.json +++ b/packages/base-data-service/package.json @@ -47,6 +47,11 @@ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, + "dependencies": { + "@metamask/messenger": "^0.3.0", + "@metamask/utils": "^11.9.0", + "@tanstack/query-core": "^4.43.0" + }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", @@ -64,10 +69,5 @@ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" - }, - "dependencies": { - "@metamask/messenger": "^0.3.0", - "@metamask/utils": "^11.9.0", - "@tanstack/query-core": "^4.43.0" } } diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index a63d18ccee4..1624161946d 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -18,7 +18,31 @@ import { } from '@metamask/messenger'; import type { Json } from '@metamask/utils'; -type SubscriptionCallback = (payload: Json) => void; +export type SubscriptionPayload = { hash: string, state: DehydratedState }; +export type SubscriptionCallback = (payload: SubscriptionPayload) => void; + +export type DataServiceSubscribeAction = { + type: `${ServiceName}:subscribe`; + handler: (queryKey: QueryKey, callback: SubscriptionCallback) => DehydratedState; +}; + +export type DataServiceUnsubscribeAction = { + type: `${ServiceName}:unsubscribe`; + handler: (queryKey: QueryKey, callback: SubscriptionCallback) => void; +}; + +export type DataServiceInvalidateQueriesAction = { + type: `${ServiceName}:invalidateQueries`; + handler: ( + filters?: InvalidateQueryFilters, + options?: InvalidateOptions, + ) => Promise +}; + +export type DataServiceActions = + DataServiceSubscribeAction | + DataServiceUnsubscribeAction | + DataServiceInvalidateQueriesAction export class BaseDataService< ServiceName extends string, @@ -32,9 +56,13 @@ export class BaseDataService< any >, > { - name: string; + public readonly name: ServiceName; - #messenger: ServiceMessenger; + #messenger: Messenger< + ServiceName, + DataServiceActions, + never + >; #client = new QueryClient(); @@ -48,14 +76,54 @@ export class BaseDataService< messenger: ServiceMessenger; }) { this.name = name; - this.#messenger = messenger; + + this.#messenger = messenger as unknown as Messenger< + ServiceName, + DataServiceActions, + never + >; this.#registerMessageHandlers(); this.#setupCacheListener(); } + #registerMessageHandlers() { + this.#messenger.registerActionHandler( + `${this.name}:subscribe`, + // @ts-expect-error TODO. + (queryKey: QueryKey, callback: SubscriptionCallback) => this.#handleSubscribe(queryKey, callback), + ); + + this.#messenger.registerActionHandler( + `${this.name}:unsubscribe`, + // @ts-expect-error TODO. + (queryKey: QueryKey, callback: SubscriptionCallback) => this.#handleUnsubscribe(queryKey, callback), + ); + + this.#messenger.registerActionHandler( + `${this.name}:invalidateQueries`, + // @ts-expect-error TODO. + (filters?: InvalidateQueryFilters, + options?: InvalidateOptions) => this.invalidateQueries(filters, options), + ); + } + + #setupCacheListener() { + this.#client.getQueryCache().subscribe((event) => { + if (!event.query) { + return; + } + + const queryKeyHash = event.query.queryHash; + + if (this.#subscriptions.has(queryKeyHash)) { + this.#broadcastQueryState(event.query.queryKey); + } + }); + } + protected async fetchQuery< - TQueryFnData = unknown, + TQueryFnData extends Json, TError = unknown, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, @@ -69,7 +137,7 @@ export class BaseDataService< } protected async fetchInfiniteQuery< - TQueryFnData = unknown, + TQueryFnData extends Json, TError = unknown, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, @@ -112,51 +180,14 @@ export class BaseDataService< return result.pages[0]; } - protected async invalidateQueries( + protected async invalidateQueries( filters?: InvalidateQueryFilters, options?: InvalidateOptions, ): Promise { return this.#client.invalidateQueries(filters, options); } - #registerMessageHandlers() { - this.#messenger.registerActionHandler( - // @ts-expect-error TODO. - `${this.name}:subscribe`, - (queryKey: QueryKey, callback: SubscriptionCallback) => { - return this.#handleSubscribe(queryKey, callback); - }, - ); - - this.#messenger.registerActionHandler( - // @ts-expect-error TODO. - `${this.name}:unsubscribe`, - (queryKey: QueryKey, callback: SubscriptionCallback) => { - return this.#handleUnsubscribe(queryKey, callback); - }, - ); - - this.#messenger.registerActionHandler( - // @ts-expect-error TODO. - `${this.name}:invalidateQueries`, - this.invalidateQueries.bind(this), - ); - } - - #setupCacheListener() { - this.#client.getQueryCache().subscribe((event) => { - if (!event.query) { - return; - } - - const queryKeyHash = event.query.queryHash; - - if (this.#subscriptions.has(queryKeyHash)) { - this.#broadcastQueryState(event.query.queryKey); - } - }); - } - + // TODO: Determine if this has a better fit with `messenger.publish`. #handleSubscribe( queryKey: QueryKey, subscription: SubscriptionCallback, @@ -203,9 +234,9 @@ export class BaseDataService< const subscribers = this.#subscriptions.get(hash)!; subscribers.forEach((subscriber) => subscriber({ - queryKeyHash: hash, + hash, state, - } as unknown as Json), + }), ); } } diff --git a/packages/base-data-service/tsconfig.build.json b/packages/base-data-service/tsconfig.build.json index fa04f31e471..57f3ffc0f9b 100644 --- a/packages/base-data-service/tsconfig.build.json +++ b/packages/base-data-service/tsconfig.build.json @@ -5,8 +5,6 @@ "outDir": "./dist", "rootDir": "./src" }, - "references": [ - { "path": "../messenger/tsconfig.build.json" } - ], + "references": [{ "path": "../messenger/tsconfig.build.json" }], "include": ["../../types", "./src"] } diff --git a/packages/base-data-service/tsconfig.json b/packages/base-data-service/tsconfig.json index b5fdcac3bf3..f8d51c32616 100644 --- a/packages/base-data-service/tsconfig.json +++ b/packages/base-data-service/tsconfig.json @@ -3,8 +3,6 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [ - { "path": "../messenger/tsconfig.build.json" } - ], + "references": [{ "path": "../messenger/tsconfig.build.json" }], "include": ["../../types", "./src"] } From 643a866de289d04c2c1162943c88a398f4edc62b Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 25 Feb 2026 15:56:42 +0100 Subject: [PATCH 08/54] Bring test over from other branch --- .../src/BaseDataService.test.ts | 84 ++++++++++++++++--- 1 file changed, 74 insertions(+), 10 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 1e0695e178c..3b1b861aea7 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -1,5 +1,6 @@ import { Messenger } from '@metamask/messenger'; import { BaseDataService } from './BaseDataService'; +import { Json } from '@metamask/utils'; const serviceName = 'ExampleDataService'; @@ -9,7 +10,8 @@ class ExampleDataService extends BaseDataService< typeof serviceName, ExampleMessenger > { - #baseUrl = 'https://accounts.api.cx.metamask.io'; + #accountsBaseUrl = 'https://accounts.api.cx.metamask.io'; + #tokensBaseUrl = 'https://tokens.api.cx.metamask.io'; constructor(messenger: ExampleMessenger) { super({ @@ -17,6 +19,12 @@ class ExampleDataService extends BaseDataService< messenger, }); + messenger.registerActionHandler( + `${this.name}:getAssets`, + // @ts-expect-error TODO. + this.getAssets.bind(this), + ); + messenger.registerActionHandler( `${this.name}:getActivity`, // @ts-expect-error TODO. @@ -24,13 +32,28 @@ class ExampleDataService extends BaseDataService< ); } - async getActivity(address: string) { - return this.fetchInfiniteQuery({ + async getAssets(assets: string[]) { + return this.fetchQuery({ + queryKey: [`${this.name}:getAssets`, ...assets], + queryFn: async () => { + const url = new URL( + `${this.#tokensBaseUrl}/v3/assets?assetIds=${assets.join(',')}`, + ); + + const response = await fetch(url); + + return response.json(); + }, + }); + } + + async getActivity(address: string, pageParam?: string) { + return this.fetchInfiniteQuery<{ data: Json; pageInfo: { hasNextPage: boolean; endCursor: string } }>({ queryKey: [`${this.name}:getActivity`, address], queryFn: async ({ pageParam }) => { const caipAddress = `eip155:0:${address.toLowerCase()}`; const url = new URL( - `${this.#baseUrl}/v4/multiaccount/transactions?limit=10&accountAddresses=${caipAddress}`, + `${this.#accountsBaseUrl}/v4/multiaccount/transactions?limit=10&accountAddresses=${caipAddress}`, ); if (pageParam) { @@ -41,19 +64,60 @@ class ExampleDataService extends BaseDataService< return response.json(); }, - getNextPageParam: ({ pageInfo }: { pageInfo: any }) => + getNextPageParam: ({ pageInfo }) => pageInfo.hasNextPage ? pageInfo.endCursor : undefined, - }); + }, pageParam); } } describe('BaseDataService', () => { - it('works', async () => { + it('handles basic queries', async () => { const messenger = new Messenger({ namespace: serviceName }); const service = new ExampleDataService(messenger); expect( - await service.getActivity('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'), - ).toBe({}); + await service.getAssets([ + 'eip155:1/slip44:60', + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + ]), + ).toStrictEqual([ + { + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + decimals: 8, + name: 'Bitcoin', + symbol: 'BTC', + }, + { + assetId: 'eip155:1/slip44:60', + decimals: 18, + name: 'Ethereum', + symbol: 'ETH', + }, + { + assetId: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + decimals: 18, + name: 'Dai Stablecoin', + symbol: 'DAI', + }, + ]); + }); + + it('handles paginated queries', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + const page1 = await service.getActivity( + '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + ); + + // expect(page1.data).toStrictEqual([]); + + const page2 = await service.getActivity( + '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + page1.pageInfo.endCursor, + ); + + expect(page2.data).not.toStrictEqual(page1.data); }); -}); +}); \ No newline at end of file From 6c745c3ae54eae853a8508f09748984ae2d69d2a Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 25 Feb 2026 16:58:24 +0100 Subject: [PATCH 09/54] Add example types --- .../src/BaseDataService.test.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 3b1b861aea7..0ddc1076662 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -4,7 +4,19 @@ import { Json } from '@metamask/utils'; const serviceName = 'ExampleDataService'; -type ExampleMessenger = Messenger; +type ExampleDataServiceGetAssetsAction = { + type: `${typeof serviceName}:getAssets`; + handler: ExampleDataService['getAssets']; +}; + +type ExampleDataServiceGetActivityAction = { + type: `${typeof serviceName}:getActivity`; + handler: ExampleDataService['getActivity'] +}; + +export type ExampleDataServiceActions = ExampleDataServiceGetAssetsAction | ExampleDataServiceGetActivityAction; + +type ExampleMessenger = Messenger; class ExampleDataService extends BaseDataService< typeof serviceName, @@ -21,13 +33,11 @@ class ExampleDataService extends BaseDataService< messenger.registerActionHandler( `${this.name}:getAssets`, - // @ts-expect-error TODO. this.getAssets.bind(this), ); messenger.registerActionHandler( `${this.name}:getActivity`, - // @ts-expect-error TODO. this.getActivity.bind(this), ); } From e8ceb4cb13b199bcdb4828da4b1588935490536e Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 26 Feb 2026 11:11:40 +0100 Subject: [PATCH 10/54] Add createUIQueryClient + lint --- .../src/BaseDataService.test.ts | 66 +++++---- .../base-data-service/src/BaseDataService.ts | 51 ++++--- .../src/createUIQueryClient.ts | 132 ++++++++++++++++++ 3 files changed, 201 insertions(+), 48 deletions(-) create mode 100644 packages/base-data-service/src/createUIQueryClient.ts diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 0ddc1076662..07127c8ae88 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -1,7 +1,8 @@ import { Messenger } from '@metamask/messenger'; -import { BaseDataService } from './BaseDataService'; import { Json } from '@metamask/utils'; +import { BaseDataService } from './BaseDataService'; + const serviceName = 'ExampleDataService'; type ExampleDataServiceGetAssetsAction = { @@ -11,19 +12,26 @@ type ExampleDataServiceGetAssetsAction = { type ExampleDataServiceGetActivityAction = { type: `${typeof serviceName}:getActivity`; - handler: ExampleDataService['getActivity'] + handler: ExampleDataService['getActivity']; }; -export type ExampleDataServiceActions = ExampleDataServiceGetAssetsAction | ExampleDataServiceGetActivityAction; +type ExampleDataServiceActions = + | ExampleDataServiceGetAssetsAction + | ExampleDataServiceGetActivityAction; -type ExampleMessenger = Messenger; +type ExampleMessenger = Messenger< + typeof serviceName, + ExampleDataServiceActions, + never +>; class ExampleDataService extends BaseDataService< typeof serviceName, ExampleMessenger > { - #accountsBaseUrl = 'https://accounts.api.cx.metamask.io'; - #tokensBaseUrl = 'https://tokens.api.cx.metamask.io'; + readonly #accountsBaseUrl = 'https://accounts.api.cx.metamask.io'; + + readonly #tokensBaseUrl = 'https://tokens.api.cx.metamask.io'; constructor(messenger: ExampleMessenger) { super({ @@ -57,26 +65,32 @@ class ExampleDataService extends BaseDataService< }); } - async getActivity(address: string, pageParam?: string) { - return this.fetchInfiniteQuery<{ data: Json; pageInfo: { hasNextPage: boolean; endCursor: string } }>({ - queryKey: [`${this.name}:getActivity`, address], - queryFn: async ({ pageParam }) => { - const caipAddress = `eip155:0:${address.toLowerCase()}`; - const url = new URL( - `${this.#accountsBaseUrl}/v4/multiaccount/transactions?limit=10&accountAddresses=${caipAddress}`, - ); - - if (pageParam) { - url.searchParams.set('cursor', pageParam); - } - - const response = await fetch(url); - - return response.json(); + async getActivity(address: string, page?: string) { + return this.fetchInfiniteQuery<{ + data: Json; + pageInfo: { hasNextPage: boolean; endCursor: string }; + }>( + { + queryKey: [`${this.name}:getActivity`, address], + queryFn: async ({ pageParam }) => { + const caipAddress = `eip155:0:${address.toLowerCase()}`; + const url = new URL( + `${this.#accountsBaseUrl}/v4/multiaccount/transactions?limit=10&accountAddresses=${caipAddress}`, + ); + + if (pageParam) { + url.searchParams.set('cursor', pageParam); + } + + const response = await fetch(url); + + return response.json(); + }, + getNextPageParam: ({ pageInfo }) => + pageInfo.hasNextPage ? pageInfo.endCursor : undefined, }, - getNextPageParam: ({ pageInfo }) => - pageInfo.hasNextPage ? pageInfo.endCursor : undefined, - }, pageParam); + page, + ); } } @@ -130,4 +144,4 @@ describe('BaseDataService', () => { expect(page2.data).not.toStrictEqual(page1.data); }); -}); \ No newline at end of file +}); diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 1624161946d..9ee678c7860 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -1,3 +1,9 @@ +import { + Messenger, + ActionConstraint, + EventConstraint, +} from '@metamask/messenger'; +import type { Json } from '@metamask/utils'; import { DehydratedState, FetchInfiniteQueryOptions, @@ -11,19 +17,16 @@ import { dehydrate, hashQueryKey, } from '@tanstack/query-core'; -import { - Messenger, - ActionConstraint, - EventConstraint, -} from '@metamask/messenger'; -import type { Json } from '@metamask/utils'; -export type SubscriptionPayload = { hash: string, state: DehydratedState }; +export type SubscriptionPayload = { hash: string; state: DehydratedState }; export type SubscriptionCallback = (payload: SubscriptionPayload) => void; export type DataServiceSubscribeAction = { type: `${ServiceName}:subscribe`; - handler: (queryKey: QueryKey, callback: SubscriptionCallback) => DehydratedState; + handler: ( + queryKey: QueryKey, + callback: SubscriptionCallback, + ) => DehydratedState; }; export type DataServiceUnsubscribeAction = { @@ -36,13 +39,13 @@ export type DataServiceInvalidateQueriesAction = { handler: ( filters?: InvalidateQueryFilters, options?: InvalidateOptions, - ) => Promise + ) => Promise; }; export type DataServiceActions = - DataServiceSubscribeAction | - DataServiceUnsubscribeAction | - DataServiceInvalidateQueriesAction + | DataServiceSubscribeAction + | DataServiceUnsubscribeAction + | DataServiceInvalidateQueriesAction; export class BaseDataService< ServiceName extends string, @@ -58,15 +61,15 @@ export class BaseDataService< > { public readonly name: ServiceName; - #messenger: Messenger< + readonly #messenger: Messenger< ServiceName, DataServiceActions, never >; - #client = new QueryClient(); + readonly #client = new QueryClient(); - #subscriptions: Map> = new Map(); + readonly #subscriptions: Map> = new Map(); constructor({ name, @@ -87,28 +90,30 @@ export class BaseDataService< this.#setupCacheListener(); } - #registerMessageHandlers() { + #registerMessageHandlers(): void { this.#messenger.registerActionHandler( `${this.name}:subscribe`, // @ts-expect-error TODO. - (queryKey: QueryKey, callback: SubscriptionCallback) => this.#handleSubscribe(queryKey, callback), + (queryKey: QueryKey, callback: SubscriptionCallback) => + this.#handleSubscribe(queryKey, callback), ); this.#messenger.registerActionHandler( `${this.name}:unsubscribe`, // @ts-expect-error TODO. - (queryKey: QueryKey, callback: SubscriptionCallback) => this.#handleUnsubscribe(queryKey, callback), + (queryKey: QueryKey, callback: SubscriptionCallback) => + this.#handleUnsubscribe(queryKey, callback), ); this.#messenger.registerActionHandler( `${this.name}:invalidateQueries`, // @ts-expect-error TODO. - (filters?: InvalidateQueryFilters, - options?: InvalidateOptions) => this.invalidateQueries(filters, options), + (filters?: InvalidateQueryFilters, options?: InvalidateOptions) => + this.invalidateQueries(filters, options), ); } - #setupCacheListener() { + #setupCacheListener(): void { this.#client.getQueryCache().subscribe((event) => { if (!event.query) { return; @@ -198,6 +203,7 @@ export class BaseDataService< this.#subscriptions.set(hash, new Set()); } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.#subscriptions.get(hash)!.add(subscription); return this.#getDehydratedStateForQuery(queryKey); @@ -227,10 +233,11 @@ export class BaseDataService< }); } - #broadcastQueryState(queryKey: QueryKey) { + #broadcastQueryState(queryKey: QueryKey): void { const hash = hashQueryKey(queryKey); const state = this.#getDehydratedStateForQuery(queryKey); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const subscribers = this.#subscriptions.get(hash)!; subscribers.forEach((subscriber) => subscriber({ diff --git a/packages/base-data-service/src/createUIQueryClient.ts b/packages/base-data-service/src/createUIQueryClient.ts new file mode 100644 index 00000000000..64b7d7b7996 --- /dev/null +++ b/packages/base-data-service/src/createUIQueryClient.ts @@ -0,0 +1,132 @@ +import { assert, Json } from '@metamask/utils'; +import { + hydrate, + QueryClient, + InvalidateQueryFilters, + InvalidateOptions, +} from '@tanstack/query-core'; + +type QueryKey = readonly [string, ...Json[]]; + +function getServiceFromQueryKey(queryKey: QueryKey): string { + return queryKey[0].split(':')[0]; +} + +type MessengerAdapter = { + call: (method: string, ...params: Json[]) => Promise; + subscribe: (method: string, callback: (data: Json) => void) => void; +}; + +/** + * Create a QueryClient queries and subscribes to data services using the messenger. + * + * @param dataServices - A list of data services. + * @param messenger - A messenger adapter. + * @returns The QueryClient. + */ +export function createUIQueryClient( + dataServices: string[], + messenger: MessengerAdapter, +): QueryClient { + const subscriptions = new Set(); + + const client: QueryClient = new QueryClient({ + defaultOptions: { + queries: { + queryFn: async (options): Promise => { + const { queryKey } = options; + + const potentialAction = queryKey?.[0]; + + assert( + typeof potentialAction === 'string', + 'The first element of a query key must be a string.', + ); + assert( + dataServices.includes(potentialAction?.split(':')?.[0]), + 'Queries must use data service actions.', + ); + + return await messenger.call( + potentialAction, + options as unknown as Json, + ); + }, + // TODO: Decide on values for these. + staleTime: Infinity, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + }, + }, + }); + + client.getQueryCache().subscribe((event) => { + const { query } = event; + if (!query) { + return; + } + + const hash = query.queryHash; + const hasSubscription = subscriptions.has(hash); + const observerCount = query.getObserversCount(); + + const service = getServiceFromQueryKey(query.queryKey); + + if ( + !hasSubscription && + event.type === 'observerAdded' && + observerCount === 1 + ) { + subscriptions.add(hash); + + // Lazily subscribe to the cache updates broadcast by the data service + messenger.subscribe(`${service}:cacheUpdate`, (data) => { + const castData = data as { hash: string; state: Json }; + if (subscriptions.has(castData.hash)) { + hydrate(client, castData.state); + } + }); + + messenger + .call(`${service}:subscribe`, query.queryKey) + .then((state) => hydrate(client, state)) + .catch(console.error); + } else if ( + event.type === 'observerRemoved' && + observerCount === 0 && + hasSubscription + ) { + subscriptions.delete(hash); + messenger + .call(`${service}:unsubscribe`, query.queryKey) + .catch(console.error); + } + }); + + // Override invalidateQueries to ensure the data service is invalidated as well. + const originalInvalidate = client.invalidateQueries.bind(client); + + // @ts-expect-error TODO. + client.invalidateQueries = async ( + filters?: InvalidateQueryFilters, + options?: InvalidateOptions, + ): Promise => { + const queries = client.getQueryCache().findAll(filters); + await Promise.all( + queries.map((query) => { + const service = getServiceFromQueryKey(query.queryKey as QueryKey); + + return messenger.call( + `${service}:invalidateQueries`, + filters as Json, + options as Json, + ); + }), + ); + + return originalInvalidate(filters, options); + }; + + return client; +} From 46fb4d98b3a5a8d430b0b5c9d12b95a1149956bd Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 26 Feb 2026 13:05:53 +0100 Subject: [PATCH 11/54] Improve tests --- packages/base-data-service/package.json | 1 + .../src/BaseDataService.test.ts | 112 +----- .../src/createUIQueryClient.test.ts | 194 +++++++++ .../src/createUIQueryClient.ts | 18 +- .../tests/ExampleDataService.ts | 97 +++++ packages/base-data-service/tests/mocks.ts | 369 ++++++++++++++++++ packages/base-data-service/tsconfig.json | 2 +- yarn.lock | 1 + 8 files changed, 685 insertions(+), 109 deletions(-) create mode 100644 packages/base-data-service/src/createUIQueryClient.test.ts create mode 100644 packages/base-data-service/tests/ExampleDataService.ts create mode 100644 packages/base-data-service/tests/mocks.ts diff --git a/packages/base-data-service/package.json b/packages/base-data-service/package.json index 60309f2f01d..711be7cefa9 100644 --- a/packages/base-data-service/package.json +++ b/packages/base-data-service/package.json @@ -58,6 +58,7 @@ "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", "jest": "^29.7.0", + "nock": "^13.3.1", "ts-jest": "^29.2.5", "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 07127c8ae88..b0c9da88b1e 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -1,100 +1,14 @@ import { Messenger } from '@metamask/messenger'; -import { Json } from '@metamask/utils'; -import { BaseDataService } from './BaseDataService'; - -const serviceName = 'ExampleDataService'; - -type ExampleDataServiceGetAssetsAction = { - type: `${typeof serviceName}:getAssets`; - handler: ExampleDataService['getAssets']; -}; - -type ExampleDataServiceGetActivityAction = { - type: `${typeof serviceName}:getActivity`; - handler: ExampleDataService['getActivity']; -}; - -type ExampleDataServiceActions = - | ExampleDataServiceGetAssetsAction - | ExampleDataServiceGetActivityAction; - -type ExampleMessenger = Messenger< - typeof serviceName, - ExampleDataServiceActions, - never ->; - -class ExampleDataService extends BaseDataService< - typeof serviceName, - ExampleMessenger -> { - readonly #accountsBaseUrl = 'https://accounts.api.cx.metamask.io'; - - readonly #tokensBaseUrl = 'https://tokens.api.cx.metamask.io'; - - constructor(messenger: ExampleMessenger) { - super({ - name: serviceName, - messenger, - }); - - messenger.registerActionHandler( - `${this.name}:getAssets`, - this.getAssets.bind(this), - ); - - messenger.registerActionHandler( - `${this.name}:getActivity`, - this.getActivity.bind(this), - ); - } - - async getAssets(assets: string[]) { - return this.fetchQuery({ - queryKey: [`${this.name}:getAssets`, ...assets], - queryFn: async () => { - const url = new URL( - `${this.#tokensBaseUrl}/v3/assets?assetIds=${assets.join(',')}`, - ); - - const response = await fetch(url); - - return response.json(); - }, - }); - } - - async getActivity(address: string, page?: string) { - return this.fetchInfiniteQuery<{ - data: Json; - pageInfo: { hasNextPage: boolean; endCursor: string }; - }>( - { - queryKey: [`${this.name}:getActivity`, address], - queryFn: async ({ pageParam }) => { - const caipAddress = `eip155:0:${address.toLowerCase()}`; - const url = new URL( - `${this.#accountsBaseUrl}/v4/multiaccount/transactions?limit=10&accountAddresses=${caipAddress}`, - ); - - if (pageParam) { - url.searchParams.set('cursor', pageParam); - } - - const response = await fetch(url); - - return response.json(); - }, - getNextPageParam: ({ pageInfo }) => - pageInfo.hasNextPage ? pageInfo.endCursor : undefined, - }, - page, - ); - } -} +import { ExampleDataService, serviceName } from '../tests/ExampleDataService'; +import { mockAssets, mockTransactions } from '../tests/mocks'; describe('BaseDataService', () => { + beforeEach(() => { + mockAssets(); + mockTransactions(); + }); + it('handles basic queries', async () => { const messenger = new Messenger({ namespace: serviceName }); const service = new ExampleDataService(messenger); @@ -106,6 +20,12 @@ describe('BaseDataService', () => { 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', ]), ).toStrictEqual([ + { + assetId: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + decimals: 18, + name: 'Dai Stablecoin', + symbol: 'DAI', + }, { assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', decimals: 8, @@ -118,12 +38,6 @@ describe('BaseDataService', () => { name: 'Ethereum', symbol: 'ETH', }, - { - assetId: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', - decimals: 18, - name: 'Dai Stablecoin', - symbol: 'DAI', - }, ]); }); diff --git a/packages/base-data-service/src/createUIQueryClient.test.ts b/packages/base-data-service/src/createUIQueryClient.test.ts new file mode 100644 index 00000000000..beef61b4a03 --- /dev/null +++ b/packages/base-data-service/src/createUIQueryClient.test.ts @@ -0,0 +1,194 @@ +import { Messenger } from '@metamask/messenger'; +import { Json } from '@metamask/utils'; +import { QueryClient, QueryKey, QueryObserver } from '@tanstack/query-core'; + +import { SubscriptionCallback, SubscriptionPayload } from './BaseDataService'; +import { createUIQueryClient } from './createUIQueryClient'; +import { + ExampleDataService, + ExampleDataServiceActions, + ExampleMessenger, +} from '../tests/ExampleDataService'; +import { mockAssets } from '../tests/mocks'; + +const DATA_SERVICES = ['ExampleDataService']; + +function createClient(serviceMessenger: ExampleMessenger): QueryClient { + const subscriptions = new Set(); + + const subscription = (payload: SubscriptionPayload): void => { + subscriptions.forEach((callback) => callback(payload)); + }; + + const messengerAdapter = { + call: async ( + method: ExampleDataServiceActions['type'], + ...params: Json[] + ) => { + if (method === 'ExampleDataService:subscribe') { + return serviceMessenger.call( + method, + params[0] as QueryKey, + subscription, + ); + } else if (method === 'ExampleDataService:unsubscribe') { + return serviceMessenger.call( + method, + params[0] as QueryKey, + subscription, + ); + } + return serviceMessenger.call(method, ...params); + }, + subscribe: async (_method: string, callback: SubscriptionCallback): Promise => { + subscriptions.add(callback); + }, + }; + + return createUIQueryClient(DATA_SERVICES, messengerAdapter); +} + +function createClients(): { + service: ExampleDataService; + clientA: QueryClient; + clientB: QueryClient; +} { + const serviceMessenger = new Messenger< + 'ExampleDataService', + ExampleDataServiceActions + >({ namespace: 'ExampleDataService' }); + const service = new ExampleDataService(serviceMessenger); + + const clientA = createClient(serviceMessenger); + const clientB = createClient(serviceMessenger); + + return { service, clientA, clientB }; +} + +const getAssetsQueryKey = [ + 'ExampleDataService:getAssets', + [ + 'eip155:1/slip44:60', + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + ], +]; + +describe('createUIQueryClient', () => { + beforeEach(() => { + mockAssets(); + }); + + it('proxies requests to the underlying service', async () => { + const { clientA: client } = createClients(); + + const result = await client.fetchQuery({ + queryKey: getAssetsQueryKey, + }); + + expect(result).toStrictEqual([ + { + 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', + }, + ]); + }); + + it('fetches using observers', async () => { + const { clientA, clientB } = createClients(); + + 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') { + resolve(event.data); + } + }); + }); + + const resultA = await promiseA; + + expect(resultA).toHaveLength(3); + + const promiseB = new Promise((resolve) => { + observerB.subscribe((event) => { + if (event.status === 'success') { + resolve(event.data); + } + }); + }); + + const resultB = await promiseB; + expect(resultA).toStrictEqual(resultB); + + observerA.destroy(); + observerB.destroy(); + }); + + it('synchronizes caches after invalidation', async () => { + const { clientA, clientB } = createClients(); + + 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') { + resolve(event.data); + } + }); + }); + + const promiseB = new Promise((resolve) => { + observerB.subscribe((event) => { + if (event.status === 'success') { + resolve(event.data); + } + }); + }); + + await Promise.all([promiseA, promiseB]); + + // Replace the mock response and invalidate + mockAssets({ + status: 200, + body: [], + }) + + await clientA.invalidateQueries(); + + const queryData = clientA.getQueryData(getAssetsQueryKey); + + expect(queryData).toStrictEqual([]) + expect(queryData).toStrictEqual(clientB.getQueryData(getAssetsQueryKey)); + + observerA.destroy(); + observerB.destroy(); + }); +}); diff --git a/packages/base-data-service/src/createUIQueryClient.ts b/packages/base-data-service/src/createUIQueryClient.ts index 64b7d7b7996..5ae34febe10 100644 --- a/packages/base-data-service/src/createUIQueryClient.ts +++ b/packages/base-data-service/src/createUIQueryClient.ts @@ -36,20 +36,21 @@ export function createUIQueryClient( queryFn: async (options): Promise => { const { queryKey } = options; - const potentialAction = queryKey?.[0]; + const action = queryKey?.[0]; assert( - typeof potentialAction === 'string', + typeof action === 'string', 'The first element of a query key must be a string.', ); assert( - dataServices.includes(potentialAction?.split(':')?.[0]), + dataServices.includes(action?.split(':')?.[0]), 'Queries must use data service actions.', ); return await messenger.call( - potentialAction, - options as unknown as Json, + action, + ...(options.queryKey.slice(1) as Json[]), + options.pageParam, ); }, // TODO: Decide on values for these. @@ -63,9 +64,6 @@ export function createUIQueryClient( client.getQueryCache().subscribe((event) => { const { query } = event; - if (!query) { - return; - } const hash = query.queryHash; const hasSubscription = subscriptions.has(hash); @@ -80,7 +78,9 @@ export function createUIQueryClient( ) { subscriptions.add(hash); - // Lazily subscribe to the cache updates broadcast by the data service + // This is a bit of a mess because we can't pass functions across the process boundary, so we call subscribe + // but also register listeners for :cacheUpdate which will be sent to subscribed processes + // TODO: Unsubscribe messenger.subscribe(`${service}:cacheUpdate`, (data) => { const castData = data as { hash: string; state: Json }; if (subscriptions.has(castData.hash)) { diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts new file mode 100644 index 00000000000..18dcabbe67a --- /dev/null +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -0,0 +1,97 @@ +import { Messenger } from '@metamask/messenger'; +import { Duration, inMilliseconds, Json } from '@metamask/utils'; + +import { BaseDataService, DataServiceActions } from '../src/BaseDataService'; + +export const serviceName = 'ExampleDataService'; + +export type ExampleDataServiceGetAssetsAction = { + type: `${typeof serviceName}:getAssets`; + handler: ExampleDataService['getAssets']; +}; + +export type ExampleDataServiceGetActivityAction = { + type: `${typeof serviceName}:getActivity`; + handler: ExampleDataService['getActivity']; +}; + +export type ExampleDataServiceActions = + | ExampleDataServiceGetAssetsAction + | ExampleDataServiceGetActivityAction + | DataServiceActions; + +export type ExampleMessenger = Messenger< + typeof serviceName, + ExampleDataServiceActions, + never +>; + +export class ExampleDataService extends BaseDataService< + typeof serviceName, + ExampleMessenger +> { + readonly #accountsBaseUrl = 'https://accounts.api.cx.metamask.io'; + + readonly #tokensBaseUrl = 'https://tokens.api.cx.metamask.io'; + + constructor(messenger: ExampleMessenger) { + super({ + name: serviceName, + messenger, + }); + + messenger.registerActionHandler( + `${this.name}:getAssets`, + this.getAssets.bind(this), + ); + + messenger.registerActionHandler( + `${this.name}:getActivity`, + this.getActivity.bind(this), + ); + } + + async getAssets(assets: string[]) { + return this.fetchQuery({ + queryKey: [`${this.name}:getAssets`, assets], + queryFn: async () => { + const url = new URL( + `${this.#tokensBaseUrl}/v3/assets?assetIds=${assets.join(',')}`, + ); + + const response = await fetch(url); + + return response.json(); + }, + staleTime: inMilliseconds(1, Duration.Day), + }); + } + + async getActivity(address: string, page?: string) { + return this.fetchInfiniteQuery<{ + data: Json; + pageInfo: { hasNextPage: boolean; endCursor: string }; + }>( + { + queryKey: [`${this.name}:getActivity`, address], + queryFn: async ({ pageParam }) => { + const caipAddress = `eip155:0:${address.toLowerCase()}`; + const url = new URL( + `${this.#accountsBaseUrl}/v4/multiaccount/transactions?limit=10&accountAddresses=${caipAddress}`, + ); + + if (pageParam) { + url.searchParams.set('cursor', pageParam); + } + + const response = await fetch(url); + + return response.json(); + }, + getNextPageParam: ({ pageInfo }) => + pageInfo.hasNextPage ? pageInfo.endCursor : undefined, + }, + page, + ); + } +} diff --git a/packages/base-data-service/tests/mocks.ts b/packages/base-data-service/tests/mocks.ts new file mode 100644 index 00000000000..e3521fa2c6f --- /dev/null +++ b/packages/base-data-service/tests/mocks.ts @@ -0,0 +1,369 @@ +import nock from 'nock'; + +type MockReply = { + status: nock.StatusCode; + body?: nock.Body; +}; + +export function mockAssets(mockReply?: MockReply): nock.Scope { + const reply = mockReply ?? { + status: 200, + body: [ + { + 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', + }, + ], + }; + + return nock('https://tokens.api.cx.metamask.io:443', { + encodedQueryParams: true, + }) + .get('/v3/assets') + .query({ + assetIds: + 'eip155%3A1%2Fslip44%3A60%2Cbip122%3A000000000019d6689c085ae165831e93%2Fslip44%3A0%2Ceip155%3A1%2Ferc20%3A0x6b175474e89094c44da98b954eedeac495271d0f', + }) + .reply(reply.status, reply.body); +} + +export function mockTransactions(mockReply?: MockReply): nock.Scope { + const reply = mockReply ?? { + status: 200, + body: { + data: [ + { + hash: '0x3fd0f0989c8307347492afd11e8f14929fe726e23939b2aec7c806658d7b96c8', + timestamp: '2026-02-26T10:20:49.000Z', + chainId: 8453, + accountId: 'eip155:8453:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + blockNumber: 42655951, + blockHash: + '0x0d950aa2dd400111cd70def5beeeb4e005a6c06b294a1b84e1ae2a2d082e2c4c', + gas: 63681, + gasUsed: 21062, + gasPrice: '18814867', + effectiveGasPrice: '18814867', + nonce: 9070, + cumulativeGasUsed: 32355593, + methodId: null, + value: '30000000000000', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + from: '0xfa783aa578a0f2d21756c5c6c5403494302a1eb1', + isError: false, + valueTransfers: [ + { + from: '0xfa783aa578a0f2d21756c5c6c5403494302a1eb1', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + amount: '30000000000000', + decimal: 18, + transferType: 'normal', + }, + ], + }, + { + hash: '0xa3e4916122815850f8aa6c0bdb8f9b075be3d2caa103003f955dfdf2816acf47', + timestamp: '2026-02-26T08:27:23.000Z', + chainId: 1, + accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + blockNumber: 24539890, + blockHash: + '0xb69dd1d970207a8da49cb52fc1a0351cc39b61142646d8381c04656936fb07d2', + gas: 25473, + gasUsed: 21062, + gasPrice: '62203661', + effectiveGasPrice: '62203661', + nonce: 13, + cumulativeGasUsed: 23622367, + methodId: null, + value: '1000000000000000', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + isError: false, + valueTransfers: [ + { + from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + amount: '1000000000000000', + decimal: 18, + transferType: 'normal', + }, + ], + }, + { + hash: '0x95060cbd9d9d049c73da8e81b8f1349e561a1edd209d12693f9e771cba4bed04', + timestamp: '2026-02-26T08:26:59.000Z', + chainId: 1, + accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + blockNumber: 24539888, + blockHash: + '0x8ca8ee27bbe0fe86d522a7639fed9ed39ed32aec0e234c4d3d84f91170119e09', + gas: 25473, + gasUsed: 21062, + gasPrice: '59049965', + effectiveGasPrice: '59049965', + nonce: 11, + cumulativeGasUsed: 32992820, + methodId: null, + value: '1000000000000000', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + isError: false, + valueTransfers: [ + { + from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + amount: '1000000000000000', + decimal: 18, + transferType: 'normal', + }, + ], + }, + { + hash: '0xd62d9036c6774e60955860ebdd8263bb2e04ea1d9f8a091203b8e450edd972a9', + timestamp: '2026-02-26T08:26:35.000Z', + chainId: 1, + accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + blockNumber: 24539886, + blockHash: + '0xde57db8d6318c1720abfeacbf07752f40ca70249578c9e28ca128436c25aded8', + gas: 25473, + gasUsed: 21062, + gasPrice: '59204928', + effectiveGasPrice: '59204928', + nonce: 10, + cumulativeGasUsed: 33005174, + methodId: null, + value: '100000000000000', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + isError: false, + valueTransfers: [ + { + from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + amount: '100000000000000', + decimal: 18, + transferType: 'normal', + }, + ], + }, + { + hash: '0xa4f6ba45916ce0398da55fb2be4a603c19d0a7bc692edac71970a76a854a769a', + timestamp: '2026-02-26T08:17:11.000Z', + chainId: 1, + accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + blockNumber: 24539839, + blockHash: + '0x4a92b6ae4d949a5f732ef0ff481dc61e00d8c129ec2eec9d26614e57b8f2c9d7', + gas: 25473, + gasUsed: 21062, + gasPrice: '41188673', + effectiveGasPrice: '41188673', + nonce: 8, + cumulativeGasUsed: 51579752, + methodId: null, + value: '1000000000000000', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + isError: false, + valueTransfers: [ + { + from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + amount: '1000000000000000', + decimal: 18, + transferType: 'normal', + }, + ], + }, + { + hash: '0x8ef436c3847ca66207fa7f1d903e0366f907701f63219042fca1540bf9af8fbb', + timestamp: '2026-02-26T08:16:11.000Z', + chainId: 1, + accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + blockNumber: 24539834, + blockHash: + '0x18275f949d3eb177977250c34b87cbd5ed2ebc28d067f7cdb618a5620ed79928', + gas: 25473, + gasUsed: 21062, + gasPrice: '42372579', + effectiveGasPrice: '42372579', + nonce: 6, + cumulativeGasUsed: 53840387, + methodId: null, + value: '1000000000000000', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + isError: false, + valueTransfers: [ + { + from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + amount: '1000000000000000', + decimal: 18, + transferType: 'normal', + }, + ], + }, + { + hash: '0x4e11fd71425b8aef394a427a60908394391ed01391223eaab5bcb47527b9ed95', + timestamp: '2026-02-26T06:19:41.000Z', + chainId: 8453, + accountId: 'eip155:8453:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + blockNumber: 42648717, + blockHash: + '0x49bf09a1b790b59c07210518d86f1572d174bf01c573dae5defd314471513faa', + gas: 100000, + gasUsed: 21062, + gasPrice: '6599218', + effectiveGasPrice: '6599218', + nonce: 488, + cumulativeGasUsed: 16806862, + methodId: null, + value: '99880000', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + from: '0xf0e9286cfcb75c94ac19e99bcd93d814da55e304', + isError: false, + valueTransfers: [ + { + from: '0xf0e9286cfcb75c94ac19e99bcd93d814da55e304', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + amount: '99880000', + decimal: 18, + transferType: 'normal', + }, + ], + }, + { + hash: '0xa0993bcb4b1fe0877c1eb2b3414291fb4a94560fa191ddab5d4946f9ca6a173a', + timestamp: '2026-02-26T04:51:47.000Z', + chainId: 1, + accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + blockNumber: 24538816, + blockHash: + '0x7a628393adbe09117e48d298b676a73108d40ab96e54345c87b027508a956851', + gas: 31841, + gasUsed: 21062, + gasPrice: '158983234', + effectiveGasPrice: '158983234', + nonce: 10, + cumulativeGasUsed: 18661650, + methodId: null, + value: '100000000000', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + from: '0xffe90d7897d56ef6c2a5953da34558015cccc85a', + isError: false, + valueTransfers: [ + { + from: '0xffe90d7897d56ef6c2a5953da34558015cccc85a', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + amount: '100000000000', + decimal: 18, + transferType: 'normal', + }, + ], + }, + { + hash: '0xea79462c31d6bf0a96409f5b49fa6a02464b45a48f5b3192329c2ea1887de57a', + timestamp: '2026-02-26T04:19:13.000Z', + chainId: 8453, + accountId: 'eip155:8453:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + blockNumber: 42645103, + blockHash: + '0x62ddcb78dc7abd9e6bca07c9a105afffb3d708b553ea74bcaac7c9b02b745fa9', + gas: 359969, + gasUsed: 237236, + gasPrice: '6004066', + effectiveGasPrice: '6004066', + nonce: 752, + cumulativeGasUsed: 31492867, + methodId: '0x01020400', + value: '0', + to: '0x0000000000006ac72ed1d192fa28f0058d3f8806', + from: '0xc723f2c210c4d29cfe35209340a6fb766d956982', + isError: false, + valueTransfers: [ + { + from: '0xe6ede73fa975b5a2f8daf2a51945addee6413df5', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + amount: '150000', + decimal: 6, + contractAddress: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + symbol: 'USDC', + name: 'USD Coin', + transferType: 'erc20', + }, + ], + }, + { + hash: '0xd98b2afab4bb65ef6d8b5f0c726192f180a906f27c960c9dad11c60041474738', + timestamp: '2026-02-26T02:38:01.000Z', + chainId: 8453, + accountId: 'eip155:8453:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + blockNumber: 42642067, + blockHash: + '0xf612c40cd76c5e8b4140b5235130c8ceb23f10464b20e7c5c314037aa9e82f82', + gas: 341543, + gasUsed: 225000, + gasPrice: '6493425', + effectiveGasPrice: '6493425', + nonce: 11197, + cumulativeGasUsed: 25787493, + methodId: '0x01020400', + value: '0', + to: '0x0000000000006ac72ed1d192fa28f0058d3f8806', + from: '0x52fba7915b2b37f85100b543af54fba499228846', + isError: false, + valueTransfers: [ + { + from: '0x74753ad4f1e5b1f0c25725f50796bb530636a912', + to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + amount: '150000', + decimal: 6, + contractAddress: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + symbol: 'USDC', + name: 'USD Coin', + transferType: 'erc20', + }, + ], + }, + ], + unprocessedNetworks: [ + 'eip155:137:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + ], + pageInfo: { + count: 10, + hasNextPage: true, + hasPreviousPage: false, + startCursor: null, + endCursor: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweGQ4ZGE2YmYyNjk2NGFmOWQ3ZWVkOWUwM2U1MzQxNWQzN2FhOTYwNDUiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDItMjZUMDI6Mzg6MDEuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4ZDhkYTZiZjI2OTY0YWY5ZDdlZWQ5ZTAzZTUzNDE1ZDM3YWE5NjA0NSI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMi0yNlQwMjozODowMS4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTMyOToweGQ4ZGE2YmYyNjk2NGFmOWQ3ZWVkOWUwM2U1MzQxNWQzN2FhOTYwNDUiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDItMjZUMDI6Mzg6MDEuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjE0MzoweGQ4ZGE2YmYyNjk2NGFmOWQ3ZWVkOWUwM2U1MzQxNWQzN2FhOTYwNDUiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDItMjZUMDI6Mzg6MDEuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjQyMTYxOjB4ZDhkYTZiZjI2OTY0YWY5ZDdlZWQ5ZTAzZTUzNDE1ZDM3YWE5NjA0NSI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMi0yNlQwMjozODowMS4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NTM0MzUyOjB4ZDhkYTZiZjI2OTY0YWY5ZDdlZWQ5ZTAzZTUzNDE1ZDM3YWE5NjA0NSI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMi0yNlQwMjozODowMS4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NTY6MHhkOGRhNmJmMjY5NjRhZjlkN2VlZDllMDNlNTM0MTVkMzdhYTk2MDQ1Ijp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAyLTI2VDAyOjM4OjAxLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1OTE0NDoweGQ4ZGE2YmYyNjk2NGFmOWQ3ZWVkOWUwM2U1MzQxNWQzN2FhOTYwNDUiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDItMjZUMDI6Mzg6MDEuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1Ojg0NTM6MHhkOGRhNmJmMjY5NjRhZjlkN2VlZDllMDNlNTM0MTVkMzdhYTk2MDQ1Ijp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAyLTI2VDAyOjM4OjAxLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo5OTk6MHhkOGRhNmJmMjY5NjRhZjlkN2VlZDllMDNlNTM0MTVkMzdhYTk2MDQ1Ijp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAyLTI2VDAyOjM4OjAxLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImlhdCI6MTc3MjEwMjAyNX0.NYLYAQ-7pTPd01t5Nz1VxP5tMBZvHOPf2PXZw7VInpM', + }, + }, + }; + return nock('https://accounts.api.cx.metamask.io:443', { + encodedQueryParams: true, + }) + .get('/v4/multiaccount/transactions') + .query({ + limit: '10', + accountAddresses: + 'eip155%3A0%3A0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + }) + .reply(reply.status, reply.body); +} diff --git a/packages/base-data-service/tsconfig.json b/packages/base-data-service/tsconfig.json index f8d51c32616..6e77825aa53 100644 --- a/packages/base-data-service/tsconfig.json +++ b/packages/base-data-service/tsconfig.json @@ -4,5 +4,5 @@ "baseUrl": "./" }, "references": [{ "path": "../messenger/tsconfig.build.json" }], - "include": ["../../types", "./src"] + "include": ["../../types", "./src", "./tests"] } diff --git a/yarn.lock b/yarn.lock index 602973cd5f6..ac0fbe69a54 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2993,6 +2993,7 @@ __metadata: "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" jest: "npm:^29.7.0" + nock: "npm:^13.3.1" ts-jest: "npm:^29.2.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" From 4346286a00e4b65b71d5757a17efaf9f15e506d7 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 26 Feb 2026 13:08:08 +0100 Subject: [PATCH 12/54] Add export --- packages/base-data-service/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/base-data-service/src/index.ts b/packages/base-data-service/src/index.ts index 8b032fc1277..cf5db9eb1db 100644 --- a/packages/base-data-service/src/index.ts +++ b/packages/base-data-service/src/index.ts @@ -1 +1,2 @@ export * from './BaseDataService'; +export * from './createUIQueryClient'; From 65315d5d6fe48a63f756a4059bdb5c0326f8a8b9 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 26 Feb 2026 13:45:06 +0100 Subject: [PATCH 13/54] Fix pagination test --- .../src/BaseDataService.test.ts | 19 +- .../base-data-service/src/BaseDataService.ts | 16 +- .../src/createUIQueryClient.test.ts | 13 +- .../tests/ExampleDataService.ts | 11 +- packages/base-data-service/tests/mocks.ts | 416 +++++++----------- 5 files changed, 200 insertions(+), 275 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index b0c9da88b1e..032a4a7afbb 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -1,12 +1,19 @@ import { Messenger } from '@metamask/messenger'; import { ExampleDataService, serviceName } from '../tests/ExampleDataService'; -import { mockAssets, mockTransactions } from '../tests/mocks'; +import { + mockAssets, + mockTransactionsPage1, + mockTransactionsPage2, +} from '../tests/mocks'; + +const TEST_ADDRESS = '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520'; describe('BaseDataService', () => { beforeEach(() => { mockAssets(); - mockTransactions(); + mockTransactionsPage1(); + mockTransactionsPage2(); }); it('handles basic queries', async () => { @@ -41,18 +48,16 @@ describe('BaseDataService', () => { ]); }); - it('handles paginated queries', async () => { + it.only('handles paginated queries', async () => { const messenger = new Messenger({ namespace: serviceName }); const service = new ExampleDataService(messenger); - const page1 = await service.getActivity( - '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', - ); + const page1 = await service.getActivity(TEST_ADDRESS); // expect(page1.data).toStrictEqual([]); const page2 = await service.getActivity( - '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + TEST_ADDRESS, page1.pageInfo.endCursor, ); diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 9ee678c7860..2eb4a202bfa 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -115,13 +115,7 @@ export class BaseDataService< #setupCacheListener(): void { this.#client.getQueryCache().subscribe((event) => { - if (!event.query) { - return; - } - - const queryKeyHash = event.query.queryHash; - - if (this.#subscriptions.has(queryKeyHash)) { + if (this.#subscriptions.has(event.query.queryHash)) { this.#broadcastQueryState(event.query.queryKey); } }); @@ -216,12 +210,8 @@ export class BaseDataService< const hash = hashQueryKey(queryKey); const subscribers = this.#subscriptions.get(hash); - if (!subscribers) { - return; - } - - subscribers.delete(subscription); - if (subscribers.size === 0) { + subscribers?.delete(subscription); + if (subscribers?.size === 0) { this.#subscriptions.delete(hash); } } diff --git a/packages/base-data-service/src/createUIQueryClient.test.ts b/packages/base-data-service/src/createUIQueryClient.test.ts index beef61b4a03..cf151ae287a 100644 --- a/packages/base-data-service/src/createUIQueryClient.test.ts +++ b/packages/base-data-service/src/createUIQueryClient.test.ts @@ -40,7 +40,10 @@ function createClient(serviceMessenger: ExampleMessenger): QueryClient { } return serviceMessenger.call(method, ...params); }, - subscribe: async (_method: string, callback: SubscriptionCallback): Promise => { + subscribe: async ( + _method: string, + callback: SubscriptionCallback, + ): Promise => { subscriptions.add(callback); }, }; @@ -177,15 +180,15 @@ describe('createUIQueryClient', () => { // Replace the mock response and invalidate mockAssets({ - status: 200, - body: [], - }) + status: 200, + body: [], + }); await clientA.invalidateQueries(); const queryData = clientA.getQueryData(getAssetsQueryKey); - expect(queryData).toStrictEqual([]) + expect(queryData).toStrictEqual([]); expect(queryData).toStrictEqual(clientB.getQueryData(getAssetsQueryKey)); observerA.destroy(); diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts index 18dcabbe67a..0af6ea3728c 100644 --- a/packages/base-data-service/tests/ExampleDataService.ts +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -70,14 +70,19 @@ export class ExampleDataService extends BaseDataService< async getActivity(address: string, page?: string) { return this.fetchInfiniteQuery<{ data: Json; - pageInfo: { hasNextPage: boolean; endCursor: string }; + pageInfo: { + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string; + endCursor: string; + }; }>( { queryKey: [`${this.name}:getActivity`, address], queryFn: async ({ pageParam }) => { const caipAddress = `eip155:0:${address.toLowerCase()}`; const url = new URL( - `${this.#accountsBaseUrl}/v4/multiaccount/transactions?limit=10&accountAddresses=${caipAddress}`, + `${this.#accountsBaseUrl}/v4/multiaccount/transactions?limit=3&accountAddresses=${caipAddress}`, ); if (pageParam) { @@ -88,6 +93,8 @@ export class ExampleDataService extends BaseDataService< return response.json(); }, + getPreviousPageParam: ({ pageInfo }) => + pageInfo.hasPreviousPage ? pageInfo.startCursor : undefined, getNextPageParam: ({ pageInfo }) => pageInfo.hasNextPage ? pageInfo.endCursor : undefined, }, diff --git a/packages/base-data-service/tests/mocks.ts b/packages/base-data-service/tests/mocks.ts index e3521fa2c6f..a99d8d0e877 100644 --- a/packages/base-data-service/tests/mocks.ts +++ b/packages/base-data-service/tests/mocks.ts @@ -41,318 +41,236 @@ export function mockAssets(mockReply?: MockReply): nock.Scope { .reply(reply.status, reply.body); } -export function mockTransactions(mockReply?: MockReply): nock.Scope { +export function mockTransactionsPage1(mockReply?: MockReply): nock.Scope { const reply = mockReply ?? { status: 200, body: { data: [ { - hash: '0x3fd0f0989c8307347492afd11e8f14929fe726e23939b2aec7c806658d7b96c8', - timestamp: '2026-02-26T10:20:49.000Z', + hash: '0xb398bcc8a9287ca18b5a7c4d6f52eaf4ae599d5ac85b860143f5293ed57724fb', + timestamp: '2026-02-07T22:44:17.000Z', chainId: 8453, - accountId: 'eip155:8453:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - blockNumber: 42655951, + accountId: 'eip155:8453:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 41857455, blockHash: - '0x0d950aa2dd400111cd70def5beeeb4e005a6c06b294a1b84e1ae2a2d082e2c4c', - gas: 63681, - gasUsed: 21062, - gasPrice: '18814867', - effectiveGasPrice: '18814867', - nonce: 9070, - cumulativeGasUsed: 32355593, - methodId: null, - value: '30000000000000', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - from: '0xfa783aa578a0f2d21756c5c6c5403494302a1eb1', - isError: false, - valueTransfers: [ - { - from: '0xfa783aa578a0f2d21756c5c6c5403494302a1eb1', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - amount: '30000000000000', - decimal: 18, - transferType: 'normal', - }, - ], - }, - { - hash: '0xa3e4916122815850f8aa6c0bdb8f9b075be3d2caa103003f955dfdf2816acf47', - timestamp: '2026-02-26T08:27:23.000Z', - chainId: 1, - accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - blockNumber: 24539890, - blockHash: - '0xb69dd1d970207a8da49cb52fc1a0351cc39b61142646d8381c04656936fb07d2', - gas: 25473, - gasUsed: 21062, - gasPrice: '62203661', - effectiveGasPrice: '62203661', - nonce: 13, - cumulativeGasUsed: 23622367, - methodId: null, - value: '1000000000000000', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', - isError: false, - valueTransfers: [ - { - from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - amount: '1000000000000000', - decimal: 18, - transferType: 'normal', - }, - ], - }, - { - hash: '0x95060cbd9d9d049c73da8e81b8f1349e561a1edd209d12693f9e771cba4bed04', - timestamp: '2026-02-26T08:26:59.000Z', - chainId: 1, - accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - blockNumber: 24539888, - blockHash: - '0x8ca8ee27bbe0fe86d522a7639fed9ed39ed32aec0e234c4d3d84f91170119e09', - gas: 25473, - gasUsed: 21062, - gasPrice: '59049965', - effectiveGasPrice: '59049965', - nonce: 11, - cumulativeGasUsed: 32992820, - methodId: null, - value: '1000000000000000', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', - isError: false, - valueTransfers: [ - { - from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - amount: '1000000000000000', - decimal: 18, - transferType: 'normal', - }, - ], - }, - { - hash: '0xd62d9036c6774e60955860ebdd8263bb2e04ea1d9f8a091203b8e450edd972a9', - timestamp: '2026-02-26T08:26:35.000Z', - chainId: 1, - accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - blockNumber: 24539886, - blockHash: - '0xde57db8d6318c1720abfeacbf07752f40ca70249578c9e28ca128436c25aded8', - gas: 25473, - gasUsed: 21062, - gasPrice: '59204928', - effectiveGasPrice: '59204928', - nonce: 10, - cumulativeGasUsed: 33005174, - methodId: null, - value: '100000000000000', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', - isError: false, - valueTransfers: [ - { - from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - amount: '100000000000000', - decimal: 18, - transferType: 'normal', - }, - ], - }, - { - hash: '0xa4f6ba45916ce0398da55fb2be4a603c19d0a7bc692edac71970a76a854a769a', - timestamp: '2026-02-26T08:17:11.000Z', - chainId: 1, - accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - blockNumber: 24539839, - blockHash: - '0x4a92b6ae4d949a5f732ef0ff481dc61e00d8c129ec2eec9d26614e57b8f2c9d7', - gas: 25473, - gasUsed: 21062, - gasPrice: '41188673', - effectiveGasPrice: '41188673', - nonce: 8, - cumulativeGasUsed: 51579752, - methodId: null, - value: '1000000000000000', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + '0x6700e8704b880e83081f3dadcf745eb5bb95ffd1c6557ecdd5dc78d0eb310e52', + gas: 20037644, + gasUsed: 19878709, + gasPrice: '3289893', + effectiveGasPrice: '3289893', + nonce: 800, + cumulativeGasUsed: 55796136, + methodId: '0x9ec68f0f', + value: '0', + to: '0x671fdde61d38f00dffb4f8ce8701d0aabb4b405d', + from: '0x6d052d8e0c666ed8011b966d94f240713cf08ea1', isError: false, valueTransfers: [ { - from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - amount: '1000000000000000', + from: '0x671fdde61d38f00dffb4f8ce8701d0aabb4b405d', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: '100000000000000000000', decimal: 18, - transferType: 'normal', + contractAddress: '0x491b67a94ec0a59b81b784f4719d0387c4510c36', + symbol: 'PF', + name: 'Purple Frog', + transferType: 'erc20', }, ], }, { - hash: '0x8ef436c3847ca66207fa7f1d903e0366f907701f63219042fca1540bf9af8fbb', - timestamp: '2026-02-26T08:16:11.000Z', + hash: '0x8e773bc374095ef6410b40b3c95e898077a30c70a9b74297738c60deb888dc34', + timestamp: '2026-02-02T02:25:59.000Z', chainId: 1, - accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - blockNumber: 24539834, + accountId: 'eip155:1:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 24366180, blockHash: - '0x18275f949d3eb177977250c34b87cbd5ed2ebc28d067f7cdb618a5620ed79928', - gas: 25473, - gasUsed: 21062, - gasPrice: '42372579', - effectiveGasPrice: '42372579', - nonce: 6, - cumulativeGasUsed: 53840387, - methodId: null, - value: '1000000000000000', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', + '0x3e057041ce87230e33a95d9dc7b9018bd86d2982c00a9a4d43d2f8ae6e9c5bac', + gas: 16000000, + gasUsed: 13402794, + gasPrice: '93000000', + effectiveGasPrice: '93000000', + nonce: 94, + cumulativeGasUsed: 42756417, + methodId: '0x60806040', + value: '0', + to: '0x0000000000000000000000000000000000000000', + from: '0x07838cbd1a74c6ad20cab35cb464bb36c1c761e3', isError: false, valueTransfers: [ { - from: '0x5931f36512899f6519aecd95f7189b817ab63ad9', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - amount: '1000000000000000', + from: '0x340eb3a94d7e6802742d0a82c1afe852629f7b08', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: '10000000000000000', decimal: 18, - transferType: 'normal', + contractAddress: '0x94f31ac896c9823d81cf9c2c93feceed4923218f', + symbol: 'YFTE', + name: 'YfTether.io', + transferType: 'erc20', }, ], }, { - hash: '0x4e11fd71425b8aef394a427a60908394391ed01391223eaab5bcb47527b9ed95', - timestamp: '2026-02-26T06:19:41.000Z', - chainId: 8453, - accountId: 'eip155:8453:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - blockNumber: 42648717, + hash: '0x3147f8bf154e854b27b24caf51ecb8e87ba625bb9c6b0bab60ac8f44057defc4', + timestamp: '2026-01-16T20:16:16.000Z', + chainId: 137, + accountId: 'eip155:137:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 81737302, blockHash: - '0x49bf09a1b790b59c07210518d86f1572d174bf01c573dae5defd314471513faa', - gas: 100000, - gasUsed: 21062, - gasPrice: '6599218', - effectiveGasPrice: '6599218', - nonce: 488, - cumulativeGasUsed: 16806862, - methodId: null, - value: '99880000', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - from: '0xf0e9286cfcb75c94ac19e99bcd93d814da55e304', + '0x397ad0a9bde0c50ade4ed009178a6d658abd7ee3fa32e34410e40be970ba0f13', + gas: 119472, + gasUsed: 98586, + gasPrice: '295049518159', + effectiveGasPrice: '295049518159', + nonce: 999, + cumulativeGasUsed: 874735, + methodId: '0xd47e107e', + value: '0', + to: '0xe581b0a826de8c199be934604c1962ee306ba292', + from: '0xca6e515cc0f52a255cb430c3c2e291e0b7c4476a', isError: false, valueTransfers: [ { - from: '0xf0e9286cfcb75c94ac19e99bcd93d814da55e304', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - amount: '99880000', - decimal: 18, - transferType: 'normal', + from: '0x0000000000000000000000000000000000000000', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + tokenId: '1106', + contractAddress: '0xe581b0a826de8c199be934604c1962ee306ba292', + transferType: 'erc721', }, ], }, + ], + unprocessedNetworks: [], + pageInfo: { + count: 3, + hasNextPage: true, + hasPreviousPage: false, + startCursor: null, + endCursor: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTA4NzM4fQ.rlFQKUlm5rJjHynbXffMKzWw36qFva91GBcjOwjwPOw', + }, + }, + }; + return nock('https://accounts.api.cx.metamask.io:443', { + encodedQueryParams: true, + }) + .get('/v4/multiaccount/transactions') + .query({ + limit: '3', + accountAddresses: + 'eip155%3A0%3A0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + }) + .reply(reply.status, reply.body); +} + +export function mockTransactionsPage2(mockReply?: MockReply): nock.Scope { + const reply = mockReply ?? { + status: 200, + body: { + data: [ { - hash: '0xa0993bcb4b1fe0877c1eb2b3414291fb4a94560fa191ddab5d4946f9ca6a173a', - timestamp: '2026-02-26T04:51:47.000Z', - chainId: 1, - accountId: 'eip155:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - blockNumber: 24538816, + hash: '0xcecd28aa5bd781ffd2a6d960578ffc6c89ac390e8d02baebc977a827956394e9', + timestamp: '2025-12-29T11:51:08.000Z', + chainId: 56, + accountId: 'eip155:56:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 73342543, blockHash: - '0x7a628393adbe09117e48d298b676a73108d40ab96e54345c87b027508a956851', - gas: 31841, - gasUsed: 21062, - gasPrice: '158983234', - effectiveGasPrice: '158983234', - nonce: 10, - cumulativeGasUsed: 18661650, - methodId: null, - value: '100000000000', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - from: '0xffe90d7897d56ef6c2a5953da34558015cccc85a', + '0xf229f9ef08e817dbcbb53595cb1e3a502107314b0b8b73a5f055770b457cd3f3', + gas: 5825657, + gasUsed: 5778628, + gasPrice: '78650000', + effectiveGasPrice: '78650000', + nonce: 1746, + cumulativeGasUsed: 8070157, + methodId: '0x1239ec8c', + value: '0', + to: '0x72fe31aae72fea4e1f9048a8a3ca580eeba3cd58', + from: '0x053577f23edd3d6bf15fc53db9ca8042d4796fa7', isError: false, valueTransfers: [ { - from: '0xffe90d7897d56ef6c2a5953da34558015cccc85a', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - amount: '100000000000', + from: '0x053577f23edd3d6bf15fc53db9ca8042d4796fa7', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: '29006498000000000', decimal: 18, - transferType: 'normal', + contractAddress: '0x18d0e455b3491e09210292d3953157a4bf104444', + symbol: '比特币', + name: '比特币', + transferType: 'erc20', }, ], }, { - hash: '0xea79462c31d6bf0a96409f5b49fa6a02464b45a48f5b3192329c2ea1887de57a', - timestamp: '2026-02-26T04:19:13.000Z', - chainId: 8453, - accountId: 'eip155:8453:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - blockNumber: 42645103, + hash: '0xdb40973b60f774a14616e6e2be7af6e426b559d29e25e9b2938b3a733f361b78', + timestamp: '2025-12-22T09:18:48.000Z', + chainId: 56, + accountId: 'eip155:56:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 72524170, blockHash: - '0x62ddcb78dc7abd9e6bca07c9a105afffb3d708b553ea74bcaac7c9b02b745fa9', - gas: 359969, - gasUsed: 237236, - gasPrice: '6004066', - effectiveGasPrice: '6004066', - nonce: 752, - cumulativeGasUsed: 31492867, - methodId: '0x01020400', + '0xd43d7bb4c06ccfc0ecd172ed08fccacb774ed29e1c58b727687c5b075bc3343d', + gas: 85408, + gasUsed: 56133, + gasPrice: '52330000', + effectiveGasPrice: '52330000', + nonce: 104, + cumulativeGasUsed: 24011496, + methodId: '0xa9059cbb', value: '0', - to: '0x0000000000006ac72ed1d192fa28f0058d3f8806', - from: '0xc723f2c210c4d29cfe35209340a6fb766d956982', + to: '0xcba411922349ecd7eec13aac1825b1ddca223fc8', + from: '0x0325f3aa3ef51e24b3f31a0c390e0bc984b5490f', isError: false, valueTransfers: [ { - from: '0xe6ede73fa975b5a2f8daf2a51945addee6413df5', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - amount: '150000', - decimal: 6, - contractAddress: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', - symbol: 'USDC', - name: 'USD Coin', + from: '0x0325f3aa3ef51e24b3f31a0c390e0bc984b5490f', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: '100000000000000000000', + decimal: 18, + contractAddress: '0xcba411922349ecd7eec13aac1825b1ddca223fc8', + symbol: 'MOB', + name: 'MOB', transferType: 'erc20', }, ], }, { - hash: '0xd98b2afab4bb65ef6d8b5f0c726192f180a906f27c960c9dad11c60041474738', - timestamp: '2026-02-26T02:38:01.000Z', - chainId: 8453, - accountId: 'eip155:8453:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - blockNumber: 42642067, + hash: '0x07bb21d1937b66aab9dfe1632e4eee9b96e82f54f41f17b3cc4378ec0188af61', + timestamp: '2025-12-14T12:55:16.000Z', + chainId: 56, + accountId: 'eip155:56:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 71620155, blockHash: - '0xf612c40cd76c5e8b4140b5235130c8ceb23f10464b20e7c5c314037aa9e82f82', - gas: 341543, - gasUsed: 225000, - gasPrice: '6493425', - effectiveGasPrice: '6493425', - nonce: 11197, - cumulativeGasUsed: 25787493, - methodId: '0x01020400', + '0xe0e71f46bba84eb4060565b376bc3ede99a45e84fad2e6588bbd003e5e623313', + gas: 30424536, + gasUsed: 3138845, + gasPrice: '50500000', + effectiveGasPrice: '50500000', + nonce: 968, + cumulativeGasUsed: 18618033, + methodId: '0x729ad39e', value: '0', - to: '0x0000000000006ac72ed1d192fa28f0058d3f8806', - from: '0x52fba7915b2b37f85100b543af54fba499228846', + to: '0xdd7eb7809d283ae3ffa880183f20e7016ebe8374', + from: '0x6c604c63fb280ca69559f42f6c5a4a4bfcf661d5', isError: false, valueTransfers: [ { - from: '0x74753ad4f1e5b1f0c25725f50796bb530636a912', - to: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - amount: '150000', - decimal: 6, - contractAddress: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', - symbol: 'USDC', - name: 'USD Coin', - transferType: 'erc20', + from: '0x6c604c63fb280ca69559f42f6c5a4a4bfcf661d5', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: 1, + tokenId: '0', + contractAddress: '0xdd7eb7809d283ae3ffa880183f20e7016ebe8374', + transferType: 'erc1155', }, ], }, ], - unprocessedNetworks: [ - 'eip155:137:0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - ], + unprocessedNetworks: [], pageInfo: { - count: 10, + count: 3, hasNextPage: true, hasPreviousPage: false, startCursor: null, endCursor: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweGQ4ZGE2YmYyNjk2NGFmOWQ3ZWVkOWUwM2U1MzQxNWQzN2FhOTYwNDUiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDItMjZUMDI6Mzg6MDEuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4ZDhkYTZiZjI2OTY0YWY5ZDdlZWQ5ZTAzZTUzNDE1ZDM3YWE5NjA0NSI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMi0yNlQwMjozODowMS4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTMyOToweGQ4ZGE2YmYyNjk2NGFmOWQ3ZWVkOWUwM2U1MzQxNWQzN2FhOTYwNDUiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDItMjZUMDI6Mzg6MDEuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjE0MzoweGQ4ZGE2YmYyNjk2NGFmOWQ3ZWVkOWUwM2U1MzQxNWQzN2FhOTYwNDUiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDItMjZUMDI6Mzg6MDEuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjQyMTYxOjB4ZDhkYTZiZjI2OTY0YWY5ZDdlZWQ5ZTAzZTUzNDE1ZDM3YWE5NjA0NSI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMi0yNlQwMjozODowMS4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NTM0MzUyOjB4ZDhkYTZiZjI2OTY0YWY5ZDdlZWQ5ZTAzZTUzNDE1ZDM3YWE5NjA0NSI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMi0yNlQwMjozODowMS4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NTY6MHhkOGRhNmJmMjY5NjRhZjlkN2VlZDllMDNlNTM0MTVkMzdhYTk2MDQ1Ijp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAyLTI2VDAyOjM4OjAxLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1OTE0NDoweGQ4ZGE2YmYyNjk2NGFmOWQ3ZWVkOWUwM2U1MzQxNWQzN2FhOTYwNDUiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDItMjZUMDI6Mzg6MDEuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1Ojg0NTM6MHhkOGRhNmJmMjY5NjRhZjlkN2VlZDllMDNlNTM0MTVkMzdhYTk2MDQ1Ijp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAyLTI2VDAyOjM4OjAxLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo5OTk6MHhkOGRhNmJmMjY5NjRhZjlkN2VlZDllMDNlNTM0MTVkMzdhYTk2MDQ1Ijp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAyLTI2VDAyOjM4OjAxLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImlhdCI6MTc3MjEwMjAyNX0.NYLYAQ-7pTPd01t5Nz1VxP5tMBZvHOPf2PXZw7VInpM', + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjU1OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjU1OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTA5NDE2fQ.FD0bOPSGwFLPJytoo9KCRxTcUuyDXDKfzAeIGRfJPQI', }, }, }; @@ -361,9 +279,11 @@ export function mockTransactions(mockReply?: MockReply): nock.Scope { }) .get('/v4/multiaccount/transactions') .query({ - limit: '10', + limit: '3', accountAddresses: - 'eip155%3A0%3A0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + 'eip155%3A0%3A0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + cursor: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTA4NzM4fQ.rlFQKUlm5rJjHynbXffMKzWw36qFva91GBcjOwjwPOw', }) .reply(reply.status, reply.body); } From 40692661bb77985ceba2680b7289b22fb02597f6 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 26 Feb 2026 13:47:50 +0100 Subject: [PATCH 14/54] Fix missing assertion --- packages/base-data-service/src/BaseDataService.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 032a4a7afbb..486923e8ae3 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -48,19 +48,25 @@ describe('BaseDataService', () => { ]); }); - it.only('handles paginated queries', async () => { + it('handles paginated queries', async () => { const messenger = new Messenger({ namespace: serviceName }); const service = new ExampleDataService(messenger); const page1 = await service.getActivity(TEST_ADDRESS); - // expect(page1.data).toStrictEqual([]); + expect(page1.data).toHaveLength(3); const page2 = await service.getActivity( TEST_ADDRESS, page1.pageInfo.endCursor, ); + expect(page2.data).toHaveLength(3); + + expect(page2.data).not.toStrictEqual(page1.data); + }); + + expect(page2.data).not.toStrictEqual(page1.data); }); }); From 346738dc57138ea4eb3e75c5ce9e5ed32b75518c Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 26 Feb 2026 13:52:16 +0100 Subject: [PATCH 15/54] Revert accidental change --- packages/base-data-service/src/BaseDataService.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 486923e8ae3..b2a676e8cb4 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -63,10 +63,6 @@ describe('BaseDataService', () => { expect(page2.data).toHaveLength(3); - expect(page2.data).not.toStrictEqual(page1.data); - }); - - expect(page2.data).not.toStrictEqual(page1.data); }); }); From c0dc77ea6313fe73966dcabf45b96c4b57789c10 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 26 Feb 2026 14:21:05 +0100 Subject: [PATCH 16/54] Add test for paginated observers --- .../src/createUIQueryClient.test.ts | 84 ++++++++++++++++++- .../tests/ExampleDataService.ts | 38 ++++++--- 2 files changed, 108 insertions(+), 14 deletions(-) diff --git a/packages/base-data-service/src/createUIQueryClient.test.ts b/packages/base-data-service/src/createUIQueryClient.test.ts index cf151ae287a..4ec78abf4af 100644 --- a/packages/base-data-service/src/createUIQueryClient.test.ts +++ b/packages/base-data-service/src/createUIQueryClient.test.ts @@ -1,6 +1,12 @@ import { Messenger } from '@metamask/messenger'; import { Json } from '@metamask/utils'; -import { QueryClient, QueryKey, QueryObserver } from '@tanstack/query-core'; +import { + InfiniteData, + InfiniteQueryObserver, + QueryClient, + QueryKey, + QueryObserver, +} from '@tanstack/query-core'; import { SubscriptionCallback, SubscriptionPayload } from './BaseDataService'; import { createUIQueryClient } from './createUIQueryClient'; @@ -8,8 +14,13 @@ import { ExampleDataService, ExampleDataServiceActions, ExampleMessenger, + GetActivityResponse, } from '../tests/ExampleDataService'; -import { mockAssets } from '../tests/mocks'; +import { + mockAssets, + mockTransactionsPage1, + mockTransactionsPage2, +} from '../tests/mocks'; const DATA_SERVICES = ['ExampleDataService']; @@ -77,9 +88,16 @@ const getAssetsQueryKey = [ ], ]; +const getActivityQueryKey = [ + 'ExampleDataService:getActivity', + '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520', +]; + describe('createUIQueryClient', () => { beforeEach(() => { mockAssets(); + mockTransactionsPage1(); + mockTransactionsPage2(); }); it('proxies requests to the underlying service', async () => { @@ -194,4 +212,66 @@ describe('createUIQueryClient', () => { observerA.destroy(); observerB.destroy(); }); + + it('fetches using paginated observers', async () => { + const { clientA, clientB } = createClients(); + + const getPreviousPageParam = ({ + pageInfo, + }: GetActivityResponse): string | undefined => + pageInfo.hasPreviousPage ? pageInfo.startCursor : undefined; + const getNextPageParam = ({ + pageInfo, + }: GetActivityResponse): string | undefined => + pageInfo.hasNextPage ? pageInfo.endCursor : undefined; + + const observerA = new InfiniteQueryObserver(clientA, { + queryKey: getActivityQueryKey, + getNextPageParam, + getPreviousPageParam, + }); + + const observerB = new InfiniteQueryObserver(clientB, { + queryKey: getActivityQueryKey, + getNextPageParam, + getPreviousPageParam, + }); + + const promiseA = new Promise>( + (resolve) => { + observerA.subscribe((event) => { + if (event.status === 'success') { + resolve(event.data); + } + }); + }, + ); + + const resultA = await promiseA; + + expect(resultA.pages[0].data).toHaveLength(3); + + const promiseB = new Promise>( + (resolve) => { + observerB.subscribe((event) => { + if (event.status === 'success') { + resolve(event.data); + } + }); + }, + ); + + const resultB = await promiseB; + expect(resultA).toStrictEqual(resultB); + + const nextPageResult = await observerA.fetchNextPage(); + expect(nextPageResult.data?.pages).toHaveLength(2); + + expect(clientA.getQueryData(getActivityQueryKey)).toStrictEqual( + clientB.getQueryData(getActivityQueryKey), + ); + + observerA.destroy(); + observerB.destroy(); + }); }); diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts index 0af6ea3728c..90312c46f30 100644 --- a/packages/base-data-service/tests/ExampleDataService.ts +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -1,5 +1,5 @@ import { Messenger } from '@metamask/messenger'; -import { Duration, inMilliseconds, Json } from '@metamask/utils'; +import { CaipAssetId, Duration, inMilliseconds, Json } from '@metamask/utils'; import { BaseDataService, DataServiceActions } from '../src/BaseDataService'; @@ -26,6 +26,24 @@ export type ExampleMessenger = Messenger< never >; +export type GetAssetsResponse = { + assetId: CaipAssetId; + decimals: number; + name: string; + symbol: string; +}; + +export type GetActivityResponse = { + data: Json[]; + pageInfo: { + count: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: string; + endCursor: string; + }; +}; + export class ExampleDataService extends BaseDataService< typeof serviceName, ExampleMessenger @@ -51,7 +69,7 @@ export class ExampleDataService extends BaseDataService< ); } - async getAssets(assets: string[]) { + async getAssets(assets: string[]): Promise { return this.fetchQuery({ queryKey: [`${this.name}:getAssets`, assets], queryFn: async () => { @@ -67,16 +85,11 @@ export class ExampleDataService extends BaseDataService< }); } - async getActivity(address: string, page?: string) { - return this.fetchInfiniteQuery<{ - data: Json; - pageInfo: { - hasNextPage: boolean; - hasPreviousPage: boolean; - startCursor: string; - endCursor: string; - }; - }>( + async getActivity( + address: string, + page?: string, + ): Promise { + return this.fetchInfiniteQuery( { queryKey: [`${this.name}:getActivity`, address], queryFn: async ({ pageParam }) => { @@ -97,6 +110,7 @@ export class ExampleDataService extends BaseDataService< pageInfo.hasPreviousPage ? pageInfo.startCursor : undefined, getNextPageParam: ({ pageInfo }) => pageInfo.hasNextPage ? pageInfo.endCursor : undefined, + staleTime: inMilliseconds(5, Duration.Minute), }, page, ); From eb7b92ca352e24b26571af9c095b076217461e4b Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 26 Feb 2026 15:23:23 +0100 Subject: [PATCH 17/54] Fix lint --- .../src/createUIQueryClient.test.ts | 14 +++++++++++--- .../base-data-service/src/createUIQueryClient.ts | 7 ++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/base-data-service/src/createUIQueryClient.test.ts b/packages/base-data-service/src/createUIQueryClient.test.ts index 4ec78abf4af..c6a5ae74bb1 100644 --- a/packages/base-data-service/src/createUIQueryClient.test.ts +++ b/packages/base-data-service/src/createUIQueryClient.test.ts @@ -1,6 +1,7 @@ import { Messenger } from '@metamask/messenger'; import { Json } from '@metamask/utils'; import { + DehydratedState, InfiniteData, InfiniteQueryObserver, QueryClient, @@ -15,6 +16,7 @@ import { ExampleDataServiceActions, ExampleMessenger, GetActivityResponse, + GetAssetsResponse, } from '../tests/ExampleDataService'; import { mockAssets, @@ -33,9 +35,11 @@ function createClient(serviceMessenger: ExampleMessenger): QueryClient { const messengerAdapter = { call: async ( - method: ExampleDataServiceActions['type'], + method: string, ...params: Json[] - ) => { + ): Promise< + void | DehydratedState | GetActivityResponse | GetAssetsResponse + > => { if (method === 'ExampleDataService:subscribe') { return serviceMessenger.call( method, @@ -49,7 +53,11 @@ function createClient(serviceMessenger: ExampleMessenger): QueryClient { subscription, ); } - return serviceMessenger.call(method, ...params); + return serviceMessenger.call( + method as ExampleDataServiceActions['type'], + // @ts-expect-error TODO. + ...params, + ); }, subscribe: async ( _method: string, diff --git a/packages/base-data-service/src/createUIQueryClient.ts b/packages/base-data-service/src/createUIQueryClient.ts index 5ae34febe10..16f6e191cc8 100644 --- a/packages/base-data-service/src/createUIQueryClient.ts +++ b/packages/base-data-service/src/createUIQueryClient.ts @@ -12,8 +12,9 @@ function getServiceFromQueryKey(queryKey: QueryKey): string { return queryKey[0].split(':')[0]; } +// When UI messengers are available this should simply be a proper messenger that allows access to DataServiceActions type MessengerAdapter = { - call: (method: string, ...params: Json[]) => Promise; + call: (method: string, ...params: Json[]) => Promise; subscribe: (method: string, callback: (data: Json) => void) => void; }; @@ -47,11 +48,11 @@ export function createUIQueryClient( 'Queries must use data service actions.', ); - return await messenger.call( + return (await messenger.call( action, ...(options.queryKey.slice(1) as Json[]), options.pageParam, - ); + )) as Json; }, // TODO: Decide on values for these. staleTime: Infinity, From e6353057f3b1fc0a935a1ef8f61a6d2a1a6a84d7 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Fri, 27 Feb 2026 11:34:05 +0100 Subject: [PATCH 18/54] Fix issues with pagination when query was not already called once --- .../src/BaseDataService.test.ts | 24 ++++ .../base-data-service/src/BaseDataService.ts | 48 ++++--- packages/base-data-service/tests/mocks.ts | 135 +++++++++++++++++- 3 files changed, 181 insertions(+), 26 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index b2a676e8cb4..6ac1178e79a 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -5,6 +5,8 @@ import { mockAssets, mockTransactionsPage1, mockTransactionsPage2, + mockTransactionsPage3, + TRANSACTIONS_PAGE_2_CURSOR, } from '../tests/mocks'; const TEST_ADDRESS = '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520'; @@ -14,6 +16,7 @@ describe('BaseDataService', () => { mockAssets(); mockTransactionsPage1(); mockTransactionsPage2(); + mockTransactionsPage3(); }); it('handles basic queries', async () => { @@ -65,4 +68,25 @@ describe('BaseDataService', () => { expect(page2.data).not.toStrictEqual(page1.data); }); + + it('handles paginated queries starting at a specific page', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + const page2 = await service.getActivity( + TEST_ADDRESS, + TRANSACTIONS_PAGE_2_CURSOR, + ); + + expect(page2.data).toHaveLength(3); + + const page3 = await service.getActivity( + TEST_ADDRESS, + page2.pageInfo.endCursor, + ); + + expect(page3.data).toHaveLength(3); + + expect(page3.data).not.toStrictEqual(page2.data); + }); }); diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 2eb4a202bfa..fac7b4224a8 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -152,31 +152,37 @@ export class BaseDataService< .getQueryCache() .find({ queryKey: options.queryKey }); - if (query && pageParam) { - const pages = - (query.state.data as InfiniteData | undefined)?.pages ?? - []; - const previous = options.getPreviousPageParam?.(pages[0], pages); - - const direction = pageParam === previous ? 'backward' : 'forward'; - - const result = (await query.fetch(undefined, { - meta: { - fetchMore: { - direction, - pageParam, - }, - }, - })) as InfiniteData; + if (!query || !pageParam) { + const result = await this.#client.fetchInfiniteQuery({ + ...options, + queryFn: (context) => + options.queryFn({ + ...context, + pageParam: context.pageParam ?? pageParam, + }), + }); + + return result.pages[0]; + } - const pageIndex = result.pageParams.indexOf(pageParam); + const pages = + (query.state.data as InfiniteData | undefined)?.pages ?? []; + const previous = options.getPreviousPageParam?.(pages[0], pages); - return result.pages[pageIndex]; - } + const direction = pageParam === previous ? 'backward' : 'forward'; + + const result = (await query.fetch(undefined, { + meta: { + fetchMore: { + direction, + pageParam, + }, + }, + })) as InfiniteData; - const result = await this.#client.fetchInfiniteQuery(options); + const pageIndex = result.pageParams.indexOf(pageParam); - return result.pages[0]; + return result.pages[pageIndex]; } protected async invalidateQueries( diff --git a/packages/base-data-service/tests/mocks.ts b/packages/base-data-service/tests/mocks.ts index a99d8d0e877..7094de4b75f 100644 --- a/packages/base-data-service/tests/mocks.ts +++ b/packages/base-data-service/tests/mocks.ts @@ -41,6 +41,12 @@ export function mockAssets(mockReply?: MockReply): nock.Scope { .reply(reply.status, reply.body); } +export const TRANSACTIONS_PAGE_2_CURSOR = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTg0NjQ5fQ.btHnBzYlpbZtAA0kgdyZ5rZ-BC91PZyZQPUuXj1jj6M'; + +export const TRANSACTIONS_PAGE_3_CURSOR = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjU1OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjU1OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTg0NzE4fQ.3bzO_0SLGmIbhN8HoN_JTqaiOOcVqF25U8ftRuth2ow'; + export function mockTransactionsPage1(mockReply?: MockReply): nock.Scope { const reply = mockReply ?? { status: 200, @@ -146,8 +152,7 @@ export function mockTransactionsPage1(mockReply?: MockReply): nock.Scope { hasNextPage: true, hasPreviousPage: false, startCursor: null, - endCursor: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTA4NzM4fQ.rlFQKUlm5rJjHynbXffMKzWw36qFva91GBcjOwjwPOw', + endCursor: TRANSACTIONS_PAGE_2_CURSOR, }, }, }; @@ -269,8 +274,129 @@ export function mockTransactionsPage2(mockReply?: MockReply): nock.Scope { hasNextPage: true, hasPreviousPage: false, startCursor: null, + endCursor: TRANSACTIONS_PAGE_3_CURSOR, + }, + }, + }; + return nock('https://accounts.api.cx.metamask.io:443', { + encodedQueryParams: true, + }) + .get('/v4/multiaccount/transactions') + .query({ + limit: '3', + accountAddresses: + 'eip155%3A0%3A0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + cursor: TRANSACTIONS_PAGE_2_CURSOR, + }) + .reply(reply.status, reply.body); +} + +export function mockTransactionsPage3(mockReply?: MockReply): nock.Scope { + const reply = mockReply ?? { + status: 200, + body: { + data: [ + { + hash: '0xb7cec2f0aab8013c0f69a6e8841a565d925e9d9dff39d6f55236ef62df11f2ae', + timestamp: '2025-12-14T12:06:02.000Z', + chainId: 534352, + accountId: 'eip155:534352:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 26534356, + blockHash: + '0xca1eadb6d82aa3ae9ab3dfb4cde81c69537152b54242b5dc53a8f7167beaf68e', + gas: 20000000, + gasUsed: 13860597, + gasPrice: '120118', + effectiveGasPrice: '120118', + nonce: 270515, + cumulativeGasUsed: 13860597, + methodId: '0xc204642c', + value: '0', + to: '0x20cc3197f82c389978d70ec3169eecccf0d63cef', + from: '0x8245637968c2e16e9c28d45067bf6dd4334e6db0', + isError: false, + valueTransfers: [ + { + from: '0xaf061718473fbcfc4315e33cd29ccba0bb3f8ac8', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: 1, + tokenId: '1', + contractAddress: '0x20cc3197f82c389978d70ec3169eecccf0d63cef', + transferType: 'erc1155', + }, + ], + }, + { + hash: '0x0fd46d8c05d0817fbfff845d32a39f1eadb0ced2a10136f9cca3603ab21f577d', + timestamp: '2025-12-14T11:25:35.000Z', + chainId: 1, + accountId: 'eip155:1:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 24010531, + blockHash: + '0x24ffc87ef6dee436018f114a9e1756ea874e3a10c79744465e5f297e03f3b914', + gas: 21000, + gasUsed: 21000, + gasPrice: '20000000000', + effectiveGasPrice: '20000000000', + nonce: 2, + cumulativeGasUsed: 14457098, + methodId: null, + value: '5000000000000000', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + from: '0xc50103d72598734f6d6007cedc5d1d22d227710d', + isError: false, + valueTransfers: [ + { + from: '0xc50103d72598734f6d6007cedc5d1d22d227710d', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: '5000000000000000', + decimal: 18, + transferType: 'normal', + }, + ], + }, + { + hash: '0x136142885cf873cb681cfe2967bc96b28d696b7a5d8b23d00dacd4e395a001b0', + timestamp: '2025-12-13T04:59:23.000Z', + chainId: 1, + accountId: 'eip155:1:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + blockNumber: 24001456, + blockHash: + '0x50f4c60b4f7aa5944f0bff7f51e2417afa8ae3ce1a010ed4af5046c85bf01809', + gas: 16000000, + gasUsed: 12517751, + gasPrice: '50000000', + effectiveGasPrice: '50000000', + nonce: 242, + cumulativeGasUsed: 35408463, + methodId: '0x60806040', + value: '0', + to: '0x0000000000000000000000000000000000000000', + from: '0x8c984ec1dea4ecb9ae790ccca1e7ebb92b9631b0', + isError: false, + valueTransfers: [ + { + from: '0xadae2631d69c848698ac4a73a9b1fc38f478fb8a', + to: '0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', + amount: '3682800000000000000', + decimal: 18, + contractAddress: '0xcb696c86917175dfb4f0037ddc4f2e877a9f081a', + symbol: 'MD+', + name: 'MoonDayPlus.com', + transferType: 'erc20', + }, + ], + }, + ], + unprocessedNetworks: [], + pageInfo: { + count: 3, + hasNextPage: true, + hasPreviousPage: true, + startCursor: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6MDY6MDIuMDAwWiIsImhhc1ByZXZpb3VzUGFnZSI6dHJ1ZX0sImVpcDE1NToxMDoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6MDY6MDIuMDAwWiIsImhhc1ByZXZpb3VzUGFnZSI6dHJ1ZX0sImVpcDE1NToxMzc6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjA2OjAyLjAwMFoiLCJoYXNQcmV2aW91c1BhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjA2OjAyLjAwMFoiLCJoYXNQcmV2aW91c1BhZ2UiOnRydWV9LCJlaXAxNTU6NTM0MzUyOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjowNjowMi4wMDBaIiwiaGFzUHJldmlvdXNQYWdlIjp0cnVlfSwiZWlwMTU1OjU2OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjowNjowMi4wMDBaIiwiaGFzUHJldmlvdXNQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjowNjowMi4wMDBaIiwiaGFzUHJldmlvdXNQYWdlIjp0cnVlfSwiZWlwMTU1Ojg0NTM6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjA2OjAyLjAwMFoiLCJoYXNQcmV2aW91c1BhZ2UiOnRydWV9LCJpYXQiOjE3NzIxODQ4MjJ9.mQOxvn8fFy8yLtntxJspuvL0i4A7QoyjGoJOn-XcnJI', endCursor: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjU1OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjU1OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTA5NDE2fQ.FD0bOPSGwFLPJytoo9KCRxTcUuyDXDKfzAeIGRfJPQI', + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTNUMDQ6NTk6MjMuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xM1QwNDo1OToyMy4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xM1QwNDo1OToyMy4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTEzVDA0OjU5OjIzLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTEzVDA0OjU5OjIzLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTNUMDQ6NTk6MjMuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xM1QwNDo1OToyMy4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTNUMDQ6NTk6MjMuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTg0ODIyfQ.-JOxS3Ly3j0XLp9P-PfRHJuzVsHQh6uRzvYJvcW_PGs', }, }, }; @@ -282,8 +408,7 @@ export function mockTransactionsPage2(mockReply?: MockReply): nock.Scope { limit: '3', accountAddresses: 'eip155%3A0%3A0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', - cursor: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTA4NzM4fQ.rlFQKUlm5rJjHynbXffMKzWw36qFva91GBcjOwjwPOw', + cursor: TRANSACTIONS_PAGE_3_CURSOR, }) .reply(reply.status, reply.body); } From 864ad523b495f123b19f31b3111fcbb12a41c84b Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Fri, 27 Feb 2026 12:26:57 +0100 Subject: [PATCH 19/54] Improve example --- .../src/BaseDataService.test.ts | 21 ++++++++----------- .../src/createUIQueryClient.test.ts | 9 ++++---- .../tests/ExampleDataService.ts | 20 +++++++++++++----- packages/base-data-service/tests/mocks.ts | 4 ++-- 4 files changed, 31 insertions(+), 23 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 6ac1178e79a..36bd0074ddb 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -59,10 +59,9 @@ describe('BaseDataService', () => { expect(page1.data).toHaveLength(3); - const page2 = await service.getActivity( - TEST_ADDRESS, - page1.pageInfo.endCursor, - ); + const page2 = await service.getActivity(TEST_ADDRESS, { + after: page1.pageInfo.endCursor, + }); expect(page2.data).toHaveLength(3); @@ -73,17 +72,15 @@ describe('BaseDataService', () => { const messenger = new Messenger({ namespace: serviceName }); const service = new ExampleDataService(messenger); - const page2 = await service.getActivity( - TEST_ADDRESS, - TRANSACTIONS_PAGE_2_CURSOR, - ); + const page2 = await service.getActivity(TEST_ADDRESS, { + after: TRANSACTIONS_PAGE_2_CURSOR, + }); expect(page2.data).toHaveLength(3); - const page3 = await service.getActivity( - TEST_ADDRESS, - page2.pageInfo.endCursor, - ); + const page3 = await service.getActivity(TEST_ADDRESS, { + after: page2.pageInfo.endCursor, + }); expect(page3.data).toHaveLength(3); diff --git a/packages/base-data-service/src/createUIQueryClient.test.ts b/packages/base-data-service/src/createUIQueryClient.test.ts index c6a5ae74bb1..07bb3af8173 100644 --- a/packages/base-data-service/src/createUIQueryClient.test.ts +++ b/packages/base-data-service/src/createUIQueryClient.test.ts @@ -17,6 +17,7 @@ import { ExampleMessenger, GetActivityResponse, GetAssetsResponse, + PageParam, } from '../tests/ExampleDataService'; import { mockAssets, @@ -226,12 +227,12 @@ describe('createUIQueryClient', () => { const getPreviousPageParam = ({ pageInfo, - }: GetActivityResponse): string | undefined => - pageInfo.hasPreviousPage ? pageInfo.startCursor : undefined; + }: GetActivityResponse): PageParam | undefined => + pageInfo.hasPreviousPage ? { before: pageInfo.startCursor } : undefined; const getNextPageParam = ({ pageInfo, - }: GetActivityResponse): string | undefined => - pageInfo.hasNextPage ? pageInfo.endCursor : undefined; + }: GetActivityResponse): PageParam | undefined => + pageInfo.hasNextPage ? { after: pageInfo.endCursor } : undefined; const observerA = new InfiniteQueryObserver(clientA, { queryKey: getActivityQueryKey, diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts index 90312c46f30..6ed9cfc3e5f 100644 --- a/packages/base-data-service/tests/ExampleDataService.ts +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -44,6 +44,12 @@ export type GetActivityResponse = { }; }; +export type PageParam = + | { + before: string; + } + | { after: string }; + export class ExampleDataService extends BaseDataService< typeof serviceName, ExampleMessenger @@ -87,7 +93,7 @@ export class ExampleDataService extends BaseDataService< async getActivity( address: string, - page?: string, + page?: PageParam, ): Promise { return this.fetchInfiniteQuery( { @@ -98,8 +104,10 @@ export class ExampleDataService extends BaseDataService< `${this.#accountsBaseUrl}/v4/multiaccount/transactions?limit=3&accountAddresses=${caipAddress}`, ); - if (pageParam) { - url.searchParams.set('cursor', pageParam); + if (pageParam?.after) { + url.searchParams.set('after', pageParam.after); + } else if (pageParam?.before) { + url.searchParams.set('before', pageParam.before); } const response = await fetch(url); @@ -107,9 +115,11 @@ export class ExampleDataService extends BaseDataService< return response.json(); }, getPreviousPageParam: ({ pageInfo }) => - pageInfo.hasPreviousPage ? pageInfo.startCursor : undefined, + pageInfo.hasPreviousPage + ? { before: pageInfo.startCursor } + : undefined, getNextPageParam: ({ pageInfo }) => - pageInfo.hasNextPage ? pageInfo.endCursor : undefined, + pageInfo.hasNextPage ? { after: pageInfo.endCursor } : undefined, staleTime: inMilliseconds(5, Duration.Minute), }, page, diff --git a/packages/base-data-service/tests/mocks.ts b/packages/base-data-service/tests/mocks.ts index 7094de4b75f..7cda01a1315 100644 --- a/packages/base-data-service/tests/mocks.ts +++ b/packages/base-data-service/tests/mocks.ts @@ -286,7 +286,7 @@ export function mockTransactionsPage2(mockReply?: MockReply): nock.Scope { limit: '3', accountAddresses: 'eip155%3A0%3A0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', - cursor: TRANSACTIONS_PAGE_2_CURSOR, + after: TRANSACTIONS_PAGE_2_CURSOR, }) .reply(reply.status, reply.body); } @@ -408,7 +408,7 @@ export function mockTransactionsPage3(mockReply?: MockReply): nock.Scope { limit: '3', accountAddresses: 'eip155%3A0%3A0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', - cursor: TRANSACTIONS_PAGE_3_CURSOR, + after: TRANSACTIONS_PAGE_3_CURSOR, }) .reply(reply.status, reply.body); } From 60fec5a92e94689cd9cb517113af3b8ad4b505a9 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Fri, 27 Feb 2026 12:50:32 +0100 Subject: [PATCH 20/54] Add working test for backwards pagination --- packages/base-data-service/package.json | 3 ++- .../src/BaseDataService.test.ts | 19 ++++++++++++++++++ .../base-data-service/src/BaseDataService.ts | 5 +++-- packages/base-data-service/tests/mocks.ts | 20 +++++++++++-------- yarn.lock | 1 + 5 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/base-data-service/package.json b/packages/base-data-service/package.json index 711be7cefa9..f6449084f25 100644 --- a/packages/base-data-service/package.json +++ b/packages/base-data-service/package.json @@ -50,7 +50,8 @@ "dependencies": { "@metamask/messenger": "^0.3.0", "@metamask/utils": "^11.9.0", - "@tanstack/query-core": "^4.43.0" + "@tanstack/query-core": "^4.43.0", + "fast-deep-equal": "^3.1.3" }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 36bd0074ddb..78fa43abba5 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -7,6 +7,7 @@ import { mockTransactionsPage2, mockTransactionsPage3, TRANSACTIONS_PAGE_2_CURSOR, + TRANSACTIONS_PAGE_3_CURSOR, } from '../tests/mocks'; const TEST_ADDRESS = '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520'; @@ -86,4 +87,22 @@ describe('BaseDataService', () => { expect(page3.data).not.toStrictEqual(page2.data); }); + + it('handles backwards queries starting at a specific page', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + const page3 = await service.getActivity(TEST_ADDRESS, { + after: TRANSACTIONS_PAGE_3_CURSOR, + }); + + expect(page3.data).toHaveLength(3); + + const page2 = await service.getActivity(TEST_ADDRESS, { + before: page3.pageInfo.startCursor, + }); + + expect(page2.data).toHaveLength(3); + expect(page2.data).not.toStrictEqual(page3.data); + }); }); diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index fac7b4224a8..25aeeb815f7 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -17,6 +17,7 @@ import { dehydrate, hashQueryKey, } from '@tanstack/query-core'; +import deepEqual from 'fast-deep-equal'; export type SubscriptionPayload = { hash: string; state: DehydratedState }; export type SubscriptionCallback = (payload: SubscriptionPayload) => void; @@ -166,10 +167,10 @@ export class BaseDataService< } const pages = - (query.state.data as InfiniteData | undefined)?.pages ?? []; + (query.state.data as InfiniteData).pages; const previous = options.getPreviousPageParam?.(pages[0], pages); - const direction = pageParam === previous ? 'backward' : 'forward'; + const direction = deepEqual(pageParam, previous) ? 'backward' : 'forward'; const result = (await query.fetch(undefined, { meta: { diff --git a/packages/base-data-service/tests/mocks.ts b/packages/base-data-service/tests/mocks.ts index 7cda01a1315..82341e06721 100644 --- a/packages/base-data-service/tests/mocks.ts +++ b/packages/base-data-service/tests/mocks.ts @@ -44,6 +44,9 @@ export function mockAssets(mockReply?: MockReply): nock.Scope { export const TRANSACTIONS_PAGE_2_CURSOR = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI2LTAxLTE2VDIwOjE2OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNi0wMS0xNlQyMDoxNjoxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjYtMDEtMTZUMjA6MTY6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTg0NjQ5fQ.btHnBzYlpbZtAA0kgdyZ5rZ-BC91PZyZQPUuXj1jj6M'; +export const TRANSACTIONS_PAGE_3_START_CURSOR = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6MDY6MDIuMDAwWiIsImhhc1ByZXZpb3VzUGFnZSI6dHJ1ZX0sImVpcDE1NToxMDoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6MDY6MDIuMDAwWiIsImhhc1ByZXZpb3VzUGFnZSI6dHJ1ZX0sImVpcDE1NToxMzc6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjA2OjAyLjAwMFoiLCJoYXNQcmV2aW91c1BhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjA2OjAyLjAwMFoiLCJoYXNQcmV2aW91c1BhZ2UiOnRydWV9LCJlaXAxNTU6NTM0MzUyOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjowNjowMi4wMDBaIiwiaGFzUHJldmlvdXNQYWdlIjp0cnVlfSwiZWlwMTU1OjU2OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjowNjowMi4wMDBaIiwiaGFzUHJldmlvdXNQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjowNjowMi4wMDBaIiwiaGFzUHJldmlvdXNQYWdlIjp0cnVlfSwiZWlwMTU1Ojg0NTM6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjA2OjAyLjAwMFoiLCJoYXNQcmV2aW91c1BhZ2UiOnRydWV9LCJpYXQiOjE3NzIxODQ4MjJ9.mQOxvn8fFy8yLtntxJspuvL0i4A7QoyjGoJOn-XcnJI'; + export const TRANSACTIONS_PAGE_3_CURSOR = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjU1OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjU1OjE2LjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjo1NToxNi4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6NTU6MTYuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTg0NzE4fQ.3bzO_0SLGmIbhN8HoN_JTqaiOOcVqF25U8ftRuth2ow'; @@ -282,12 +285,14 @@ export function mockTransactionsPage2(mockReply?: MockReply): nock.Scope { encodedQueryParams: true, }) .get('/v4/multiaccount/transactions') - .query({ - limit: '3', - accountAddresses: - 'eip155%3A0%3A0x4bbeeb066ed09b7aed07bf39eee0460dfa261520', - after: TRANSACTIONS_PAGE_2_CURSOR, - }) + .query( + (args) => + args.limit === '3' && + args.accountAddresses === + 'eip155:0:0x4bbeeb066ed09b7aed07bf39eee0460dfa261520' && + (args.before === TRANSACTIONS_PAGE_3_START_CURSOR || + args.after === TRANSACTIONS_PAGE_2_CURSOR), + ) .reply(reply.status, reply.body); } @@ -393,8 +398,7 @@ export function mockTransactionsPage3(mockReply?: MockReply): nock.Scope { count: 3, hasNextPage: true, hasPreviousPage: true, - startCursor: - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6MDY6MDIuMDAwWiIsImhhc1ByZXZpb3VzUGFnZSI6dHJ1ZX0sImVpcDE1NToxMDoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTRUMTI6MDY6MDIuMDAwWiIsImhhc1ByZXZpb3VzUGFnZSI6dHJ1ZX0sImVpcDE1NToxMzc6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjA2OjAyLjAwMFoiLCJoYXNQcmV2aW91c1BhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjA2OjAyLjAwMFoiLCJoYXNQcmV2aW91c1BhZ2UiOnRydWV9LCJlaXAxNTU6NTM0MzUyOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjowNjowMi4wMDBaIiwiaGFzUHJldmlvdXNQYWdlIjp0cnVlfSwiZWlwMTU1OjU2OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjowNjowMi4wMDBaIiwiaGFzUHJldmlvdXNQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xNFQxMjowNjowMi4wMDBaIiwiaGFzUHJldmlvdXNQYWdlIjp0cnVlfSwiZWlwMTU1Ojg0NTM6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTE0VDEyOjA2OjAyLjAwMFoiLCJoYXNQcmV2aW91c1BhZ2UiOnRydWV9LCJpYXQiOjE3NzIxODQ4MjJ9.mQOxvn8fFy8yLtntxJspuvL0i4A7QoyjGoJOn-XcnJI', + startCursor: TRANSACTIONS_PAGE_3_START_CURSOR, endCursor: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlaXAxNTU6MToweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTNUMDQ6NTk6MjMuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjEwOjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xM1QwNDo1OToyMy4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6MTM3OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xM1QwNDo1OToyMy4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6NDIxNjE6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTEzVDA0OjU5OjIzLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1MzQzNTI6MHg0YmJlZWIwNjZlZDA5YjdhZWQwN2JmMzllZWUwNDYwZGZhMjYxNTIwIjp7Imxhc3RUaW1lc3RhbXAiOiIyMDI1LTEyLTEzVDA0OjU5OjIzLjAwMFoiLCJoYXNOZXh0UGFnZSI6dHJ1ZX0sImVpcDE1NTo1NjoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTNUMDQ6NTk6MjMuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiZWlwMTU1OjU5MTQ0OjB4NGJiZWViMDY2ZWQwOWI3YWVkMDdiZjM5ZWVlMDQ2MGRmYTI2MTUyMCI6eyJsYXN0VGltZXN0YW1wIjoiMjAyNS0xMi0xM1QwNDo1OToyMy4wMDBaIiwiaGFzTmV4dFBhZ2UiOnRydWV9LCJlaXAxNTU6ODQ1MzoweDRiYmVlYjA2NmVkMDliN2FlZDA3YmYzOWVlZTA0NjBkZmEyNjE1MjAiOnsibGFzdFRpbWVzdGFtcCI6IjIwMjUtMTItMTNUMDQ6NTk6MjMuMDAwWiIsImhhc05leHRQYWdlIjp0cnVlfSwiaWF0IjoxNzcyMTg0ODIyfQ.-JOxS3Ly3j0XLp9P-PfRHJuzVsHQh6uRzvYJvcW_PGs', }, diff --git a/yarn.lock b/yarn.lock index ac0fbe69a54..25db2015745 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2992,6 +2992,7 @@ __metadata: "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" + fast-deep-equal: "npm:^3.1.3" jest: "npm:^29.7.0" nock: "npm:^13.3.1" ts-jest: "npm:^29.2.5" From fba5acc9736add6d54ac6f55e38297c4982ff8ee Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Fri, 27 Feb 2026 13:23:39 +0100 Subject: [PATCH 21/54] Fix lint --- packages/base-data-service/src/BaseDataService.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 25aeeb815f7..9b0d25cf277 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -141,7 +141,7 @@ export class BaseDataService< TError = unknown, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, - TPageParam = unknown, + TPageParam extends Json = Json, >( options: WithRequired< FetchInfiniteQueryOptions, @@ -166,8 +166,7 @@ export class BaseDataService< return result.pages[0]; } - const pages = - (query.state.data as InfiniteData).pages; + const { pages } = query.state.data as InfiniteData; const previous = options.getPreviousPageParam?.(pages[0], pages); const direction = deepEqual(pageParam, previous) ? 'backward' : 'forward'; From b7c90495f7c3a0742c39a88ce4a451d0799d1c0a Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 3 Mar 2026 12:43:29 +0100 Subject: [PATCH 22/54] Unsubscribe cache listeners --- .../src/createUIQueryClient.test.ts | 6 ++++++ .../src/createUIQueryClient.ts | 20 ++++++++++++------- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/base-data-service/src/createUIQueryClient.test.ts b/packages/base-data-service/src/createUIQueryClient.test.ts index 07bb3af8173..b7ddd781b82 100644 --- a/packages/base-data-service/src/createUIQueryClient.test.ts +++ b/packages/base-data-service/src/createUIQueryClient.test.ts @@ -66,6 +66,12 @@ function createClient(serviceMessenger: ExampleMessenger): QueryClient { ): Promise => { subscriptions.add(callback); }, + unsubscribe: async ( + _method: string, + callback: SubscriptionCallback, + ): Promise => { + subscriptions.delete(callback); + }, }; return createUIQueryClient(DATA_SERVICES, messengerAdapter); diff --git a/packages/base-data-service/src/createUIQueryClient.ts b/packages/base-data-service/src/createUIQueryClient.ts index 16f6e191cc8..8de6268714e 100644 --- a/packages/base-data-service/src/createUIQueryClient.ts +++ b/packages/base-data-service/src/createUIQueryClient.ts @@ -16,6 +16,7 @@ function getServiceFromQueryKey(queryKey: QueryKey): string { type MessengerAdapter = { call: (method: string, ...params: Json[]) => Promise; subscribe: (method: string, callback: (data: Json) => void) => void; + unsubscribe: (method: string, callback: (data: Json) => void) => void; }; /** @@ -31,6 +32,14 @@ export function createUIQueryClient( ): QueryClient { const subscriptions = new Set(); + const cacheListener = (data: Json): void => { + const castData = data as { hash: string; state: Json }; + if (subscriptions.has(castData.hash)) { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + hydrate(client, castData.state); + } + }; + const client: QueryClient = new QueryClient({ defaultOptions: { queries: { @@ -81,13 +90,7 @@ export function createUIQueryClient( // This is a bit of a mess because we can't pass functions across the process boundary, so we call subscribe // but also register listeners for :cacheUpdate which will be sent to subscribed processes - // TODO: Unsubscribe - messenger.subscribe(`${service}:cacheUpdate`, (data) => { - const castData = data as { hash: string; state: Json }; - if (subscriptions.has(castData.hash)) { - hydrate(client, castData.state); - } - }); + messenger.subscribe(`${service}:cacheUpdate`, cacheListener); messenger .call(`${service}:subscribe`, query.queryKey) @@ -99,6 +102,9 @@ export function createUIQueryClient( hasSubscription ) { subscriptions.delete(hash); + + messenger.unsubscribe(`${service}:cacheUpdate`, cacheListener); + messenger .call(`${service}:unsubscribe`, query.queryKey) .catch(console.error); From ed244536bb1058a959e2d308f1c32e8c4a08b40d Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 3 Mar 2026 12:56:06 +0100 Subject: [PATCH 23/54] Add :cacheUpdate messenger event --- .../base-data-service/src/BaseDataService.ts | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 9b0d25cf277..92e1b328109 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -48,6 +48,14 @@ export type DataServiceActions = | DataServiceUnsubscribeAction | DataServiceInvalidateQueriesAction; +export type DataServiceCacheUpdateEvent = { + type: `${ServiceName}:cacheUpdate`; + payload: [SubscriptionPayload]; +}; + +export type DataServiceEvents = + DataServiceCacheUpdateEvent; + export class BaseDataService< ServiceName extends string, ServiceMessenger extends Messenger< @@ -65,7 +73,7 @@ export class BaseDataService< readonly #messenger: Messenger< ServiceName, DataServiceActions, - never + DataServiceEvents >; readonly #client = new QueryClient(); @@ -84,7 +92,7 @@ export class BaseDataService< this.#messenger = messenger as unknown as Messenger< ServiceName, DataServiceActions, - never + DataServiceEvents >; this.#registerMessageHandlers(); @@ -116,8 +124,8 @@ export class BaseDataService< #setupCacheListener(): void { this.#client.getQueryCache().subscribe((event) => { - if (this.#subscriptions.has(event.query.queryHash)) { - this.#broadcastQueryState(event.query.queryKey); + if (['added', 'updated', 'removed'].includes(event.type)) { + this.#broadcastCacheUpdate(event.query.queryKey); } }); } @@ -192,7 +200,6 @@ export class BaseDataService< return this.#client.invalidateQueries(filters, options); } - // TODO: Determine if this has a better fit with `messenger.publish`. #handleSubscribe( queryKey: QueryKey, subscription: SubscriptionCallback, @@ -229,17 +236,19 @@ export class BaseDataService< }); } - #broadcastQueryState(queryKey: QueryKey): void { + #broadcastCacheUpdate(queryKey: QueryKey): void { const hash = hashQueryKey(queryKey); const state = this.#getDehydratedStateForQuery(queryKey); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const subscribers = this.#subscriptions.get(hash)!; - subscribers.forEach((subscriber) => - subscriber({ - hash, - state, - }), - ); + const payload = { + hash, + state, + }; + + this.#messenger.publish(`${this.name}:cacheUpdate` as const, payload); + + // TODO: Determine if we can leverage `messenger.publish` entirely in order to not keep track of subscriptions manually. + const subscribers = this.#subscriptions.get(hash); + subscribers?.forEach((subscriber) => subscriber(payload)); } } From 0aa17b2d6add94ffe19adf008c70033f129d3967 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 3 Mar 2026 13:24:55 +0100 Subject: [PATCH 24/54] Improve typing --- .../base-data-service/src/BaseDataService.ts | 42 ++++++++++--------- .../src/createUIQueryClient.ts | 12 ++++-- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 92e1b328109..4e31ec1ab7f 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -2,6 +2,7 @@ import { Messenger, ActionConstraint, EventConstraint, + ActionHandler, } from '@metamask/messenger'; import type { Json } from '@metamask/utils'; import { @@ -100,26 +101,27 @@ export class BaseDataService< } #registerMessageHandlers(): void { - this.#messenger.registerActionHandler( - `${this.name}:subscribe`, - // @ts-expect-error TODO. - (queryKey: QueryKey, callback: SubscriptionCallback) => - this.#handleSubscribe(queryKey, callback), - ); - - this.#messenger.registerActionHandler( - `${this.name}:unsubscribe`, - // @ts-expect-error TODO. - (queryKey: QueryKey, callback: SubscriptionCallback) => - this.#handleUnsubscribe(queryKey, callback), - ); - - this.#messenger.registerActionHandler( - `${this.name}:invalidateQueries`, - // @ts-expect-error TODO. - (filters?: InvalidateQueryFilters, options?: InvalidateOptions) => - this.invalidateQueries(filters, options), - ); + // Casts are required since `registerActionHandler` isn't able to extract the method parameters correctly. + this.#messenger.registerActionHandler(`${this.name}:subscribe`, (( + queryKey: QueryKey, + callback: SubscriptionCallback, + ) => this.#handleSubscribe(queryKey, callback)) as ActionHandler< + DataServiceActions + >); + + this.#messenger.registerActionHandler(`${this.name}:unsubscribe`, (( + queryKey: QueryKey, + callback: SubscriptionCallback, + ) => this.#handleUnsubscribe(queryKey, callback)) as ActionHandler< + DataServiceActions + >); + + this.#messenger.registerActionHandler(`${this.name}:invalidateQueries`, (( + filters?: InvalidateQueryFilters, + options?: InvalidateOptions, + ) => this.invalidateQueries(filters, options)) as ActionHandler< + DataServiceActions + >); } #setupCacheListener(): void { diff --git a/packages/base-data-service/src/createUIQueryClient.ts b/packages/base-data-service/src/createUIQueryClient.ts index 8de6268714e..606e7ed672f 100644 --- a/packages/base-data-service/src/createUIQueryClient.ts +++ b/packages/base-data-service/src/createUIQueryClient.ts @@ -4,6 +4,9 @@ import { QueryClient, InvalidateQueryFilters, InvalidateOptions, + OmitKeyof, + parseFilterArgs, + QueryKey as TanStackQueryKey } from '@tanstack/query-core'; type QueryKey = readonly [string, ...Json[]]; @@ -114,11 +117,14 @@ export function createUIQueryClient( // Override invalidateQueries to ensure the data service is invalidated as well. const originalInvalidate = client.invalidateQueries.bind(client); - // @ts-expect-error TODO. + // This function is defined in this way to have full support for all function overloads. client.invalidateQueries = async ( - filters?: InvalidateQueryFilters, - options?: InvalidateOptions, + arg1?: TanStackQueryKey | InvalidateQueryFilters, + arg2?: OmitKeyof | InvalidateOptions, + arg3?: InvalidateOptions, ): Promise => { + const [filters, options] = parseFilterArgs(arg1, arg2, arg3) + const queries = client.getQueryCache().findAll(filters); await Promise.all( queries.map((query) => { From 3367cb185901b1560b278b8d329a619e4e0d9092 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 3 Mar 2026 14:00:58 +0100 Subject: [PATCH 25/54] Improve handling of non data service queries --- .../src/createUIQueryClient.test.ts | 47 ++++++++++++++++++- .../src/createUIQueryClient.ts | 41 +++++++++++----- 2 files changed, 75 insertions(+), 13 deletions(-) diff --git a/packages/base-data-service/src/createUIQueryClient.test.ts b/packages/base-data-service/src/createUIQueryClient.test.ts index b7ddd781b82..b5b21faac55 100644 --- a/packages/base-data-service/src/createUIQueryClient.test.ts +++ b/packages/base-data-service/src/createUIQueryClient.test.ts @@ -81,6 +81,7 @@ function createClients(): { service: ExampleDataService; clientA: QueryClient; clientB: QueryClient; + messenger: Messenger<'ExampleDataService', ExampleDataServiceActions>; } { const serviceMessenger = new Messenger< 'ExampleDataService', @@ -91,7 +92,7 @@ function createClients(): { const clientA = createClient(serviceMessenger); const clientB = createClient(serviceMessenger); - return { service, clientA, clientB }; + return { service, clientA, clientB, messenger: serviceMessenger }; } const getAssetsQueryKey = [ @@ -289,4 +290,48 @@ describe('createUIQueryClient', () => { observerA.destroy(); observerB.destroy(); }); + + it('errors if observer attempts to use default query function without a data service', async () => { + const { clientA } = createClients(); + + const observer = new QueryObserver(clientA, { + queryKey: ['query'], + retry: false, + }); + + const promise = new Promise((_resolve, reject) => { + observer.subscribe((event) => { + if (event.status === 'error') { + reject(event.error as Error); + } + }); + }); + + await expect(promise).rejects.toThrow( + 'Queries must use data service actions.', + ); + }); + + it('ignores attempts to invalidate non data service queries', async () => { + const { clientA, messenger } = createClients(); + + const spy = jest.spyOn(messenger, 'call'); + + const observer = new QueryObserver(clientA, { + queryKey: ['query'], + retry: false, + }); + + const promise = new Promise((resolve) => { + observer.subscribe(() => { + resolve(); + }); + }); + + await promise; + + await clientA.invalidateQueries({ queryKey: ['query'] }); + + expect(spy).not.toHaveBeenCalled(); + }); }); diff --git a/packages/base-data-service/src/createUIQueryClient.ts b/packages/base-data-service/src/createUIQueryClient.ts index 606e7ed672f..2eefb006dd7 100644 --- a/packages/base-data-service/src/createUIQueryClient.ts +++ b/packages/base-data-service/src/createUIQueryClient.ts @@ -6,15 +6,9 @@ import { InvalidateOptions, OmitKeyof, parseFilterArgs, - QueryKey as TanStackQueryKey + QueryKey, } from '@tanstack/query-core'; -type QueryKey = readonly [string, ...Json[]]; - -function getServiceFromQueryKey(queryKey: QueryKey): string { - return queryKey[0].split(':')[0]; -} - // When UI messengers are available this should simply be a proper messenger that allows access to DataServiceActions type MessengerAdapter = { call: (method: string, ...params: Json[]) => Promise; @@ -43,6 +37,20 @@ export function createUIQueryClient( } }; + const getServiceFromQueryKey = (queryKey: QueryKey): string | null => { + try { + const action = queryKey[0]; + assert(typeof action === 'string'); + + const service = action.split(':')[0]; + assert(dataServices.includes(service)); + + return service; + } catch { + return null; + } + }; + const client: QueryClient = new QueryClient({ defaultOptions: { queries: { @@ -55,8 +63,9 @@ export function createUIQueryClient( typeof action === 'string', 'The first element of a query key must be a string.', ); + assert( - dataServices.includes(action?.split(':')?.[0]), + dataServices.includes(action.split(':')?.[0]), 'Queries must use data service actions.', ); @@ -84,6 +93,10 @@ export function createUIQueryClient( const service = getServiceFromQueryKey(query.queryKey); + if (!service) { + return; + } + if ( !hasSubscription && event.type === 'observerAdded' && @@ -119,16 +132,20 @@ export function createUIQueryClient( // This function is defined in this way to have full support for all function overloads. client.invalidateQueries = async ( - arg1?: TanStackQueryKey | InvalidateQueryFilters, + arg1?: QueryKey | InvalidateQueryFilters, arg2?: OmitKeyof | InvalidateOptions, arg3?: InvalidateOptions, ): Promise => { - const [filters, options] = parseFilterArgs(arg1, arg2, arg3) + const [filters, options] = parseFilterArgs(arg1, arg2, arg3); const queries = client.getQueryCache().findAll(filters); await Promise.all( - queries.map((query) => { - const service = getServiceFromQueryKey(query.queryKey as QueryKey); + queries.map(async (query) => { + const service = getServiceFromQueryKey(query.queryKey); + + if (!service) { + return null; + } return messenger.call( `${service}:invalidateQueries`, From c85ab2ea35fa6592a422eab57571dcec86943b89 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 3 Mar 2026 14:11:44 +0100 Subject: [PATCH 26/54] Simplify --- 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 4e31ec1ab7f..34915984e11 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -195,7 +195,7 @@ export class BaseDataService< return result.pages[pageIndex]; } - protected async invalidateQueries( + async invalidateQueries( filters?: InvalidateQueryFilters, options?: InvalidateOptions, ): Promise { @@ -215,7 +215,7 @@ export class BaseDataService< // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.#subscriptions.get(hash)!.add(subscription); - return this.#getDehydratedStateForQuery(queryKey); + return this.#getDehydratedState(queryKey); } #handleUnsubscribe( @@ -231,7 +231,7 @@ export class BaseDataService< } } - #getDehydratedStateForQuery(queryKey: QueryKey): DehydratedState { + #getDehydratedState(queryKey: QueryKey): DehydratedState { const hash = hashQueryKey(queryKey); return dehydrate(this.#client, { shouldDehydrateQuery: (query) => query.queryHash === hash, @@ -240,7 +240,7 @@ export class BaseDataService< #broadcastCacheUpdate(queryKey: QueryKey): void { const hash = hashQueryKey(queryKey); - const state = this.#getDehydratedStateForQuery(queryKey); + const state = this.#getDehydratedState(queryKey); const payload = { hash, From e80a4581e171d342e6cbc0c705dec9680d7558c9 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 3 Mar 2026 15:49:34 +0100 Subject: [PATCH 27/54] Fix type issue in test --- .../base-data-service/src/createUIQueryClient.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/base-data-service/src/createUIQueryClient.test.ts b/packages/base-data-service/src/createUIQueryClient.test.ts index b5b21faac55..fdacdc98811 100644 --- a/packages/base-data-service/src/createUIQueryClient.test.ts +++ b/packages/base-data-service/src/createUIQueryClient.test.ts @@ -1,4 +1,5 @@ import { Messenger } from '@metamask/messenger'; +import { ExtractActionParameters } from '@metamask/messenger'; import { Json } from '@metamask/utils'; import { DehydratedState, @@ -30,7 +31,7 @@ const DATA_SERVICES = ['ExampleDataService']; function createClient(serviceMessenger: ExampleMessenger): QueryClient { const subscriptions = new Set(); - const subscription = (payload: SubscriptionPayload): void => { + const listener = (payload: SubscriptionPayload): void => { subscriptions.forEach((callback) => callback(payload)); }; @@ -45,19 +46,18 @@ function createClient(serviceMessenger: ExampleMessenger): QueryClient { return serviceMessenger.call( method, params[0] as QueryKey, - subscription, + listener, ); } else if (method === 'ExampleDataService:unsubscribe') { return serviceMessenger.call( method, params[0] as QueryKey, - subscription, + listener, ); } return serviceMessenger.call( method as ExampleDataServiceActions['type'], - // @ts-expect-error TODO. - ...params, + ...(params as ExtractActionParameters), ); }, subscribe: async ( From bfc45aaf49bfb91def37f17dfd4e332cd566a777 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 5 Mar 2026 12:53:47 +0100 Subject: [PATCH 28/54] Use messenger API for subscriptions --- .../base-data-service/src/BaseDataService.ts | 96 ++++--------------- .../src/createUIQueryClient.test.ts | 70 ++------------ .../src/createUIQueryClient.ts | 54 +++++------ .../tests/ExampleDataService.ts | 10 +- 4 files changed, 63 insertions(+), 167 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 34915984e11..282fa455e47 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -2,7 +2,6 @@ import { Messenger, ActionConstraint, EventConstraint, - ActionHandler, } from '@metamask/messenger'; import type { Json } from '@metamask/utils'; import { @@ -20,21 +19,7 @@ import { } from '@tanstack/query-core'; import deepEqual from 'fast-deep-equal'; -export type SubscriptionPayload = { hash: string; state: DehydratedState }; -export type SubscriptionCallback = (payload: SubscriptionPayload) => void; - -export type DataServiceSubscribeAction = { - type: `${ServiceName}:subscribe`; - handler: ( - queryKey: QueryKey, - callback: SubscriptionCallback, - ) => DehydratedState; -}; - -export type DataServiceUnsubscribeAction = { - type: `${ServiceName}:unsubscribe`; - handler: (queryKey: QueryKey, callback: SubscriptionCallback) => void; -}; +export type CacheUpdatePayload = { hash: string; state: DehydratedState }; export type DataServiceInvalidateQueriesAction = { type: `${ServiceName}:invalidateQueries`; @@ -45,17 +30,21 @@ export type DataServiceInvalidateQueriesAction = { }; export type DataServiceActions = - | DataServiceSubscribeAction - | DataServiceUnsubscribeAction - | DataServiceInvalidateQueriesAction; + DataServiceInvalidateQueriesAction; export type DataServiceCacheUpdateEvent = { type: `${ServiceName}:cacheUpdate`; - payload: [SubscriptionPayload]; + payload: [CacheUpdatePayload]; +}; + +export type DataServiceGranularCacheUpdateEvent = { + type: `${ServiceName}:cacheUpdate:${string}`; + payload: [CacheUpdatePayload]; }; export type DataServiceEvents = - DataServiceCacheUpdateEvent; + | DataServiceCacheUpdateEvent + | DataServiceGranularCacheUpdateEvent; export class BaseDataService< ServiceName extends string, @@ -79,8 +68,6 @@ export class BaseDataService< readonly #client = new QueryClient(); - readonly #subscriptions: Map> = new Map(); - constructor({ name, messenger, @@ -101,27 +88,11 @@ export class BaseDataService< } #registerMessageHandlers(): void { - // Casts are required since `registerActionHandler` isn't able to extract the method parameters correctly. - this.#messenger.registerActionHandler(`${this.name}:subscribe`, (( - queryKey: QueryKey, - callback: SubscriptionCallback, - ) => this.#handleSubscribe(queryKey, callback)) as ActionHandler< - DataServiceActions - >); - - this.#messenger.registerActionHandler(`${this.name}:unsubscribe`, (( - queryKey: QueryKey, - callback: SubscriptionCallback, - ) => this.#handleUnsubscribe(queryKey, callback)) as ActionHandler< - DataServiceActions - >); - - this.#messenger.registerActionHandler(`${this.name}:invalidateQueries`, (( - filters?: InvalidateQueryFilters, - options?: InvalidateOptions, - ) => this.invalidateQueries(filters, options)) as ActionHandler< - DataServiceActions - >); + this.#messenger.registerActionHandler( + `${this.name}:invalidateQueries`, + (filters?: InvalidateQueryFilters, options?: InvalidateOptions) => + this.invalidateQueries(filters, options), + ); } #setupCacheListener(): void { @@ -202,35 +173,6 @@ export class BaseDataService< return this.#client.invalidateQueries(filters, options); } - #handleSubscribe( - queryKey: QueryKey, - subscription: SubscriptionCallback, - ): DehydratedState { - const hash = hashQueryKey(queryKey); - - if (!this.#subscriptions.has(hash)) { - this.#subscriptions.set(hash, new Set()); - } - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.#subscriptions.get(hash)!.add(subscription); - - return this.#getDehydratedState(queryKey); - } - - #handleUnsubscribe( - queryKey: QueryKey, - subscription: SubscriptionCallback, - ): void { - const hash = hashQueryKey(queryKey); - const subscribers = this.#subscriptions.get(hash); - - subscribers?.delete(subscription); - if (subscribers?.size === 0) { - this.#subscriptions.delete(hash); - } - } - #getDehydratedState(queryKey: QueryKey): DehydratedState { const hash = hashQueryKey(queryKey); return dehydrate(this.#client, { @@ -248,9 +190,9 @@ export class BaseDataService< }; this.#messenger.publish(`${this.name}:cacheUpdate` as const, payload); - - // TODO: Determine if we can leverage `messenger.publish` entirely in order to not keep track of subscriptions manually. - const subscribers = this.#subscriptions.get(hash); - subscribers?.forEach((subscriber) => subscriber(payload)); + this.#messenger.publish( + `${this.name}:cacheUpdate:${hash}` as const, + payload, + ); } } diff --git a/packages/base-data-service/src/createUIQueryClient.test.ts b/packages/base-data-service/src/createUIQueryClient.test.ts index fdacdc98811..6fa55d03e1b 100644 --- a/packages/base-data-service/src/createUIQueryClient.test.ts +++ b/packages/base-data-service/src/createUIQueryClient.test.ts @@ -1,23 +1,17 @@ import { Messenger } from '@metamask/messenger'; -import { ExtractActionParameters } from '@metamask/messenger'; -import { Json } from '@metamask/utils'; import { - DehydratedState, InfiniteData, InfiniteQueryObserver, QueryClient, - QueryKey, QueryObserver, } from '@tanstack/query-core'; -import { SubscriptionCallback, SubscriptionPayload } from './BaseDataService'; import { createUIQueryClient } from './createUIQueryClient'; import { ExampleDataService, ExampleDataServiceActions, - ExampleMessenger, + ExampleDataServiceEvents, GetActivityResponse, - GetAssetsResponse, PageParam, } from '../tests/ExampleDataService'; import { @@ -28,69 +22,25 @@ import { const DATA_SERVICES = ['ExampleDataService']; -function createClient(serviceMessenger: ExampleMessenger): QueryClient { - const subscriptions = new Set(); - - const listener = (payload: SubscriptionPayload): void => { - subscriptions.forEach((callback) => callback(payload)); - }; - - const messengerAdapter = { - call: async ( - method: string, - ...params: Json[] - ): Promise< - void | DehydratedState | GetActivityResponse | GetAssetsResponse - > => { - if (method === 'ExampleDataService:subscribe') { - return serviceMessenger.call( - method, - params[0] as QueryKey, - listener, - ); - } else if (method === 'ExampleDataService:unsubscribe') { - return serviceMessenger.call( - method, - params[0] as QueryKey, - listener, - ); - } - return serviceMessenger.call( - method as ExampleDataServiceActions['type'], - ...(params as ExtractActionParameters), - ); - }, - subscribe: async ( - _method: string, - callback: SubscriptionCallback, - ): Promise => { - subscriptions.add(callback); - }, - unsubscribe: async ( - _method: string, - callback: SubscriptionCallback, - ): Promise => { - subscriptions.delete(callback); - }, - }; - - return createUIQueryClient(DATA_SERVICES, messengerAdapter); -} - function createClients(): { service: ExampleDataService; clientA: QueryClient; clientB: QueryClient; - messenger: Messenger<'ExampleDataService', ExampleDataServiceActions>; + messenger: Messenger< + 'ExampleDataService', + ExampleDataServiceActions, + ExampleDataServiceEvents + >; } { const serviceMessenger = new Messenger< 'ExampleDataService', - ExampleDataServiceActions + ExampleDataServiceActions, + ExampleDataServiceEvents >({ namespace: 'ExampleDataService' }); const service = new ExampleDataService(serviceMessenger); - const clientA = createClient(serviceMessenger); - const clientB = createClient(serviceMessenger); + const clientA = createUIQueryClient(DATA_SERVICES, serviceMessenger); + const clientB = createUIQueryClient(DATA_SERVICES, serviceMessenger); return { service, clientA, clientB, messenger: serviceMessenger }; } diff --git a/packages/base-data-service/src/createUIQueryClient.ts b/packages/base-data-service/src/createUIQueryClient.ts index 2eefb006dd7..9d581c71a63 100644 --- a/packages/base-data-service/src/createUIQueryClient.ts +++ b/packages/base-data-service/src/createUIQueryClient.ts @@ -9,11 +9,16 @@ import { QueryKey, } from '@tanstack/query-core'; -// When UI messengers are available this should simply be a proper messenger that allows access to DataServiceActions +import { CacheUpdatePayload } from './BaseDataService'; + +type SubscriptionCallback = (payload: CacheUpdatePayload) => void; +type JsonSubscriptionCallback = (data: Json) => void; + +// TODO: Figure out if we can replace with a better Messenger type type MessengerAdapter = { call: (method: string, ...params: Json[]) => Promise; - subscribe: (method: string, callback: (data: Json) => void) => void; - unsubscribe: (method: string, callback: (data: Json) => void) => void; + subscribe: (method: string, callback: JsonSubscriptionCallback) => void; + unsubscribe: (method: string, callback: JsonSubscriptionCallback) => void; }; /** @@ -27,15 +32,7 @@ export function createUIQueryClient( dataServices: string[], messenger: MessengerAdapter, ): QueryClient { - const subscriptions = new Set(); - - const cacheListener = (data: Json): void => { - const castData = data as { hash: string; state: Json }; - if (subscriptions.has(castData.hash)) { - // eslint-disable-next-line @typescript-eslint/no-use-before-define - hydrate(client, castData.state); - } - }; + const subscriptions = new Map(); const getServiceFromQueryKey = (queryKey: QueryKey): string | null => { try { @@ -102,28 +99,29 @@ export function createUIQueryClient( event.type === 'observerAdded' && observerCount === 1 ) { - subscriptions.add(hash); - - // This is a bit of a mess because we can't pass functions across the process boundary, so we call subscribe - // but also register listeners for :cacheUpdate which will be sent to subscribed processes - messenger.subscribe(`${service}:cacheUpdate`, cacheListener); - - messenger - .call(`${service}:subscribe`, query.queryKey) - .then((state) => hydrate(client, state)) - .catch(console.error); + const cacheListener = (payload: CacheUpdatePayload): void => { + hydrate(client, payload.state); + }; + + subscriptions.set(hash, cacheListener); + messenger.subscribe( + `${service}:cacheUpdate:${hash}`, + cacheListener as unknown as JsonSubscriptionCallback, + ); } else if ( event.type === 'observerRemoved' && observerCount === 0 && hasSubscription ) { + const subscriptionListener = subscriptions.get( + hash, + ) as unknown as JsonSubscriptionCallback; + + messenger.unsubscribe( + `${service}:cacheUpdate:${hash}`, + subscriptionListener, + ); subscriptions.delete(hash); - - messenger.unsubscribe(`${service}:cacheUpdate`, cacheListener); - - messenger - .call(`${service}:unsubscribe`, query.queryKey) - .catch(console.error); } }); diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts index 6ed9cfc3e5f..0580f0ec755 100644 --- a/packages/base-data-service/tests/ExampleDataService.ts +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -1,7 +1,11 @@ import { Messenger } from '@metamask/messenger'; import { CaipAssetId, Duration, inMilliseconds, Json } from '@metamask/utils'; -import { BaseDataService, DataServiceActions } from '../src/BaseDataService'; +import { + BaseDataService, + DataServiceActions, + DataServiceEvents, +} from '../src/BaseDataService'; export const serviceName = 'ExampleDataService'; @@ -20,10 +24,12 @@ export type ExampleDataServiceActions = | ExampleDataServiceGetActivityAction | DataServiceActions; +export type ExampleDataServiceEvents = DataServiceEvents; + export type ExampleMessenger = Messenger< typeof serviceName, ExampleDataServiceActions, - never + ExampleDataServiceEvents >; export type GetAssetsResponse = { From 5f48224b653899651b2515aa56ba9e83ce0d8664 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 10 Mar 2026 13:00:28 +0100 Subject: [PATCH 29/54] Adjust staleTime and add utility hooks --- packages/base-data-service/package.json | 1 + .../src/createUIQueryClient.ts | 6 +-- packages/base-data-service/src/hooks.ts | 49 +++++++++++++++++++ packages/base-data-service/src/index.ts | 1 + yarn.lock | 31 +++++++++++- 5 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 packages/base-data-service/src/hooks.ts diff --git a/packages/base-data-service/package.json b/packages/base-data-service/package.json index f6449084f25..b4165b9d7f4 100644 --- a/packages/base-data-service/package.json +++ b/packages/base-data-service/package.json @@ -51,6 +51,7 @@ "@metamask/messenger": "^0.3.0", "@metamask/utils": "^11.9.0", "@tanstack/query-core": "^4.43.0", + "@tanstack/react-query": "^4.43.0", "fast-deep-equal": "^3.1.3" }, "devDependencies": { diff --git a/packages/base-data-service/src/createUIQueryClient.ts b/packages/base-data-service/src/createUIQueryClient.ts index 9d581c71a63..bd0520fd85d 100644 --- a/packages/base-data-service/src/createUIQueryClient.ts +++ b/packages/base-data-service/src/createUIQueryClient.ts @@ -72,11 +72,7 @@ export function createUIQueryClient( options.pageParam, )) as Json; }, - // TODO: Decide on values for these. - staleTime: Infinity, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - refetchOnMount: false, + staleTime: 0, }, }, }); diff --git a/packages/base-data-service/src/hooks.ts b/packages/base-data-service/src/hooks.ts new file mode 100644 index 00000000000..c7c11c41e40 --- /dev/null +++ b/packages/base-data-service/src/hooks.ts @@ -0,0 +1,49 @@ +import { + useQuery as useQueryTanStack, + useInfiniteQuery as useInfiniteQueryTanStack, + OmitKeyof, + UseQueryOptions, + QueryKey, + InitialDataFunction, + NonUndefinedGuard, + UseInfiniteQueryOptions, +} from '@tanstack/react-query'; + +export function useQuery< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: OmitKeyof< + UseQueryOptions, + 'initialData' | 'staleTime' | 'queryFn' + > & { + initialData?: + | undefined + | InitialDataFunction> + | NonUndefinedGuard; + }, +): ReturnType { + return useQueryTanStack(options); +} + +export function useInfiniteQuery< + TQueryFnData = unknown, + TError = unknown, + TData = TQueryFnData, + TQueryKey extends QueryKey = QueryKey, +>( + options: OmitKeyof< + UseInfiniteQueryOptions< + TQueryFnData, + TError, + TData, + TQueryFnData, + TQueryKey + >, + 'staleTime' | 'queryFn' + >, +): ReturnType { + return useInfiniteQueryTanStack(options); +} diff --git a/packages/base-data-service/src/index.ts b/packages/base-data-service/src/index.ts index cf5db9eb1db..70976d446c0 100644 --- a/packages/base-data-service/src/index.ts +++ b/packages/base-data-service/src/index.ts @@ -1,2 +1,3 @@ export * from './BaseDataService'; export * from './createUIQueryClient'; +export * from './hooks'; diff --git a/yarn.lock b/yarn.lock index 25db2015745..8309ff43809 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2989,6 +2989,7 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" "@tanstack/query-core": "npm:^4.43.0" + "@tanstack/react-query": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" @@ -6210,7 +6211,7 @@ __metadata: languageName: node linkType: hard -"@tanstack/query-core@npm:^4.43.0": +"@tanstack/query-core@npm:4.43.0, @tanstack/query-core@npm:^4.43.0": version: 4.43.0 resolution: "@tanstack/query-core@npm:4.43.0" checksum: 10/c2a5a151c7adaea8311e01a643255f31946ae3164a71567ba80048242821ae14043f13f5516b695baebe5ea7e4b2cf717fd60908a929d18a5c5125fee925ff67 @@ -6224,6 +6225,25 @@ __metadata: languageName: node linkType: hard +"@tanstack/react-query@npm:^4.43.0": + version: 4.43.0 + resolution: "@tanstack/react-query@npm:4.43.0" + dependencies: + "@tanstack/query-core": "npm:4.43.0" + use-sync-external-store: "npm:^1.6.0" + 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 + react-native: "*" + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + checksum: 10/23f9d18d130fa2a1238d8fba8bc914c67e33753b7fc3a3c7856354a9873c4cbc5d18ce24dbf6364ecf86b8ea787575e1e60998ea75baa2b9e9647ad4b9127e10 + languageName: node + linkType: hard + "@tootallnate/once@npm:2": version: 2.0.0 resolution: "@tootallnate/once@npm:2.0.0" @@ -14428,6 +14448,15 @@ __metadata: languageName: node linkType: hard +"use-sync-external-store@npm:^1.6.0": + version: 1.6.0 + resolution: "use-sync-external-store@npm:1.6.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10/b40ad2847ba220695bff2d4ba4f4d60391c0fb4fb012faa7a4c18eb38b69181936f5edc55a522c4d20a788d1a879b73c3810952c9d0fd128d01cb3f22042c09e + languageName: node + linkType: hard + "utf8@npm:^3.0.0": version: 3.0.0 resolution: "utf8@npm:3.0.0" From a274f474576156e70dfed9ada682a96587c1210b Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 10 Mar 2026 13:09:17 +0100 Subject: [PATCH 30/54] Add basic test for hooks --- packages/base-data-service/src/hooks.test.ts | 27 ++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 packages/base-data-service/src/hooks.test.ts diff --git a/packages/base-data-service/src/hooks.test.ts b/packages/base-data-service/src/hooks.test.ts new file mode 100644 index 00000000000..e639e82e1e3 --- /dev/null +++ b/packages/base-data-service/src/hooks.test.ts @@ -0,0 +1,27 @@ +import { + useQuery as useQueryTanStack, + useInfiniteQuery as useInfiniteQueryTanStack, +} from '@tanstack/react-query'; + +import { useInfiniteQuery, useQuery } from './hooks'; + +jest.mock('@tanstack/react-query', () => ({ + useQuery: jest.fn(), + useInfiniteQuery: jest.fn(), +})); + +describe('useQuery', () => { + it('calls the underlying TanStack query function', () => { + const options = { queryKey: ['foo'] }; + expect(() => useQuery(options)).not.toThrow(); + expect(useQueryTanStack).toHaveBeenCalledWith(options); + }); +}); + +describe('useInfiniteQuery', () => { + it('calls the underlying TanStack query function', () => { + const options = { queryKey: ['foo'] }; + expect(() => useInfiniteQuery(options)).not.toThrow(); + expect(useInfiniteQueryTanStack).toHaveBeenCalledWith(options); + }); +}); From 4323908a0dfbd402bf1492d6572e62e482cab9c1 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 10 Mar 2026 13:37:27 +0100 Subject: [PATCH 31/54] Allow configuring query client --- .../base-data-service/src/BaseDataService.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 282fa455e47..47350fa386a 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -3,8 +3,10 @@ import { ActionConstraint, EventConstraint, } from '@metamask/messenger'; +import { Duration, inMilliseconds } from '@metamask/utils'; import type { Json } from '@metamask/utils'; import { + DefaultOptions, DehydratedState, FetchInfiniteQueryOptions, FetchQueryOptions, @@ -12,6 +14,7 @@ import { InvalidateOptions, InvalidateQueryFilters, QueryClient, + QueryClientConfig, QueryKey, WithRequired, dehydrate, @@ -46,6 +49,13 @@ export type DataServiceEvents = | DataServiceCacheUpdateEvent | DataServiceGranularCacheUpdateEvent; +// Defaults to apply to all data service queries if no default option specified +const queryClientDefaults: DefaultOptions = { + queries: { + staleTime: inMilliseconds(1, Duration.Minute), + }, +}; + export class BaseDataService< ServiceName extends string, ServiceMessenger extends Messenger< @@ -66,14 +76,18 @@ export class BaseDataService< DataServiceEvents >; - readonly #client = new QueryClient(); + protected messenger: ServiceMessenger; + + readonly #client: QueryClient; constructor({ name, messenger, + clientConfig = {}, }: { name: ServiceName; messenger: ServiceMessenger; + clientConfig?: QueryClientConfig; }) { this.name = name; @@ -82,6 +96,15 @@ export class BaseDataService< DataServiceActions, DataServiceEvents >; + this.messenger = messenger; + + this.#client = new QueryClient({ + ...clientConfig, + defaultOptions: { + ...queryClientDefaults, + ...clientConfig.defaultOptions, + }, + }); this.#registerMessageHandlers(); this.#setupCacheListener(); From a4331f5c6b850bbee176509a427298f2e5d3015b Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 10 Mar 2026 15:25:36 +0100 Subject: [PATCH 32/54] Add comment --- packages/base-data-service/src/hooks.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/base-data-service/src/hooks.ts b/packages/base-data-service/src/hooks.ts index c7c11c41e40..5fcf8986f42 100644 --- a/packages/base-data-service/src/hooks.ts +++ b/packages/base-data-service/src/hooks.ts @@ -9,6 +9,9 @@ import { UseInfiniteQueryOptions, } 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. + export function useQuery< TQueryFnData = unknown, TError = unknown, From 5411578df73945830edb27e766684b9fe390c750 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 10 Mar 2026 15:26:48 +0100 Subject: [PATCH 33/54] Add CHANGELOG entry --- packages/base-data-service/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/base-data-service/CHANGELOG.md b/packages/base-data-service/CHANGELOG.md index b518709c7b8..3cc5491ed05 100644 --- a/packages/base-data-service/CHANGELOG.md +++ b/packages/base-data-service/CHANGELOG.md @@ -7,4 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Initial release ([#8039](https://github.com/MetaMask/core/pull/8039)) + [Unreleased]: https://github.com/MetaMask/core/ From 2c8324642292fdf92839ecb19ee2a9a88493aa24 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 10 Mar 2026 15:31:56 +0100 Subject: [PATCH 34/54] Allow inconsistent tanstack dependency for now --- yarn.config.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn.config.cjs b/yarn.config.cjs index db5c54c7fe3..117ac1db0b5 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -24,7 +24,7 @@ const { inspect } = require('util'); * This should trend towards empty. */ const ALLOWED_INCONSISTENT_DEPENDENCIES = { - // '@metamask/json-rpc-engine': ['^9.0.3'], + '@tanstack/query-core': ['^4.43.0'], }; /** From d0e5a82ffe6e65aa4c625f06c25b1acbac9107b6 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 10 Mar 2026 16:49:16 +0100 Subject: [PATCH 35/54] Fix bugbot flagged issues --- packages/base-data-service/src/BaseDataService.ts | 4 +++- packages/base-data-service/src/createUIQueryClient.ts | 11 ++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 47350fa386a..c70744fc289 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -184,7 +184,9 @@ export class BaseDataService< }, })) as InfiniteData; - const pageIndex = result.pageParams.indexOf(pageParam); + const pageIndex = result.pageParams.findIndex((param) => + deepEqual(param, pageParam), + ); return result.pages[pageIndex]; } diff --git a/packages/base-data-service/src/createUIQueryClient.ts b/packages/base-data-service/src/createUIQueryClient.ts index bd0520fd85d..1cbd1e07d87 100644 --- a/packages/base-data-service/src/createUIQueryClient.ts +++ b/packages/base-data-service/src/createUIQueryClient.ts @@ -133,10 +133,15 @@ export function createUIQueryClient( const [filters, options] = parseFilterArgs(arg1, arg2, arg3); const queries = client.getQueryCache().findAll(filters); - await Promise.all( - queries.map(async (query) => { - const service = getServiceFromQueryKey(query.queryKey); + const services = [ + ...new Set( + queries.map((query) => getServiceFromQueryKey(query.queryKey)), + ), + ]; + + await Promise.all( + services.map(async (service) => { if (!service) { return null; } From 2195da1940063e35077a8145bf0f4c323b12a6f6 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 12 Mar 2026 13:28:22 +0100 Subject: [PATCH 36/54] Address PR comments --- .../base-data-service/src/BaseDataService.ts | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index c70744fc289..28b3721db61 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -78,16 +78,16 @@ export class BaseDataService< protected messenger: ServiceMessenger; - readonly #client: QueryClient; + readonly #queryClient: QueryClient; constructor({ name, messenger, - clientConfig = {}, + queryClientConfig = {}, }: { name: ServiceName; messenger: ServiceMessenger; - clientConfig?: QueryClientConfig; + queryClientConfig?: QueryClientConfig; }) { this.name = name; @@ -98,11 +98,11 @@ export class BaseDataService< >; this.messenger = messenger; - this.#client = new QueryClient({ - ...clientConfig, + this.#queryClient = new QueryClient({ + ...queryClientConfig, defaultOptions: { ...queryClientDefaults, - ...clientConfig.defaultOptions, + ...queryClientConfig.defaultOptions, }, }); @@ -113,13 +113,12 @@ export class BaseDataService< #registerMessageHandlers(): void { this.#messenger.registerActionHandler( `${this.name}:invalidateQueries`, - (filters?: InvalidateQueryFilters, options?: InvalidateOptions) => - this.invalidateQueries(filters, options), + this.invalidateQueries.bind(this), ); } #setupCacheListener(): void { - this.#client.getQueryCache().subscribe((event) => { + this.#queryClient.getQueryCache().subscribe((event) => { if (['added', 'updated', 'removed'].includes(event.type)) { this.#broadcastCacheUpdate(event.query.queryKey); } @@ -137,13 +136,13 @@ export class BaseDataService< 'queryKey' | 'queryFn' >, ): Promise { - return this.#client.fetchQuery(options); + return this.#queryClient.fetchQuery(options); } protected async fetchInfiniteQuery< TQueryFnData extends Json, TError = unknown, - TData = TQueryFnData, + TData extends TQueryFnData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, TPageParam extends Json = Json, >( @@ -153,12 +152,12 @@ export class BaseDataService< >, pageParam?: TPageParam, ): Promise { - const query = this.#client + const query = this.#queryClient .getQueryCache() - .find({ queryKey: options.queryKey }); + .find>({ queryKey: options.queryKey }); - if (!query || !pageParam) { - const result = await this.#client.fetchInfiniteQuery({ + if (!query?.state.data || !pageParam) { + const result = await this.#queryClient.fetchInfiniteQuery({ ...options, queryFn: (context) => options.queryFn({ @@ -170,7 +169,7 @@ export class BaseDataService< return result.pages[0]; } - const { pages } = query.state.data as InfiniteData; + const { pages } = query.state.data; const previous = options.getPreviousPageParam?.(pages[0], pages); const direction = deepEqual(pageParam, previous) ? 'backward' : 'forward'; @@ -195,12 +194,12 @@ export class BaseDataService< filters?: InvalidateQueryFilters, options?: InvalidateOptions, ): Promise { - return this.#client.invalidateQueries(filters, options); + return this.#queryClient.invalidateQueries(filters, options); } #getDehydratedState(queryKey: QueryKey): DehydratedState { const hash = hashQueryKey(queryKey); - return dehydrate(this.#client, { + return dehydrate(this.#queryClient, { shouldDehydrateQuery: (query) => query.queryHash === hash, }); } From bdc4c21915518d9b1db33640db783ca83a3a40a4 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 12 Mar 2026 13:44:21 +0100 Subject: [PATCH 37/54] Address more PR comments --- .../base-data-service/src/BaseDataService.ts | 41 +++++++++++-------- .../src/createUIQueryClient.ts | 12 +++--- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 28b3721db61..58ae3c85f22 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -22,7 +22,7 @@ import { } from '@tanstack/query-core'; import deepEqual from 'fast-deep-equal'; -export type CacheUpdatePayload = { hash: string; state: DehydratedState }; +export type CacheUpdatedPayload = { hash: string; state: DehydratedState }; export type DataServiceInvalidateQueriesAction = { type: `${ServiceName}:invalidateQueries`; @@ -35,19 +35,19 @@ export type DataServiceInvalidateQueriesAction = { export type DataServiceActions = DataServiceInvalidateQueriesAction; -export type DataServiceCacheUpdateEvent = { - type: `${ServiceName}:cacheUpdate`; - payload: [CacheUpdatePayload]; +export type DataServiceCacheUpdatedEvent = { + type: `${ServiceName}:cacheUpdated`; + payload: [CacheUpdatedPayload]; }; -export type DataServiceGranularCacheUpdateEvent = { - type: `${ServiceName}:cacheUpdate:${string}`; - payload: [CacheUpdatePayload]; +export type DataServiceGranularCacheUpdatedEvent = { + type: `${ServiceName}:cacheUpdated:${string}`; + payload: [CacheUpdatedPayload['state']]; }; export type DataServiceEvents = - | DataServiceCacheUpdateEvent - | DataServiceGranularCacheUpdateEvent; + | DataServiceCacheUpdatedEvent + | DataServiceGranularCacheUpdatedEvent; // Defaults to apply to all data service queries if no default option specified const queryClientDefaults: DefaultOptions = { @@ -101,8 +101,11 @@ export class BaseDataService< this.#queryClient = new QueryClient({ ...queryClientConfig, defaultOptions: { - ...queryClientDefaults, - ...queryClientConfig.defaultOptions, + queries: { + ...queryClientDefaults.queries, + ...queryClientConfig.defaultOptions?.queries, + }, + mutations: queryClientConfig.defaultOptions?.mutations, }, }); @@ -154,7 +157,11 @@ export class BaseDataService< ): Promise { const query = this.#queryClient .getQueryCache() - .find>({ queryKey: options.queryKey }); + .find< + TQueryFnData, + TError, + InfiniteData + >({ queryKey: options.queryKey }); if (!query?.state.data || !pageParam) { const result = await this.#queryClient.fetchInfiniteQuery({ @@ -174,14 +181,14 @@ export class BaseDataService< const direction = deepEqual(pageParam, previous) ? 'backward' : 'forward'; - const result = (await query.fetch(undefined, { + const result = await query.fetch(undefined, { meta: { fetchMore: { direction, pageParam, }, }, - })) as InfiniteData; + }); const pageIndex = result.pageParams.findIndex((param) => deepEqual(param, pageParam), @@ -213,10 +220,10 @@ export class BaseDataService< state, }; - this.#messenger.publish(`${this.name}:cacheUpdate` as const, payload); + this.#messenger.publish(`${this.name}:cacheUpdated` as const, payload); this.#messenger.publish( - `${this.name}:cacheUpdate:${hash}` as const, - payload, + `${this.name}:cacheUpdated:${hash}` as const, + state, ); } } diff --git a/packages/base-data-service/src/createUIQueryClient.ts b/packages/base-data-service/src/createUIQueryClient.ts index 1cbd1e07d87..960c13fe56a 100644 --- a/packages/base-data-service/src/createUIQueryClient.ts +++ b/packages/base-data-service/src/createUIQueryClient.ts @@ -9,9 +9,9 @@ import { QueryKey, } from '@tanstack/query-core'; -import { CacheUpdatePayload } from './BaseDataService'; +import { CacheUpdatedPayload } from './BaseDataService'; -type SubscriptionCallback = (payload: CacheUpdatePayload) => void; +type SubscriptionCallback = (state: CacheUpdatedPayload['state']) => void; type JsonSubscriptionCallback = (data: Json) => void; // TODO: Figure out if we can replace with a better Messenger type @@ -95,13 +95,13 @@ export function createUIQueryClient( event.type === 'observerAdded' && observerCount === 1 ) { - const cacheListener = (payload: CacheUpdatePayload): void => { - hydrate(client, payload.state); + const cacheListener = (state: CacheUpdatedPayload['state']): void => { + hydrate(client, state); }; subscriptions.set(hash, cacheListener); messenger.subscribe( - `${service}:cacheUpdate:${hash}`, + `${service}:cacheUpdated:${hash}`, cacheListener as unknown as JsonSubscriptionCallback, ); } else if ( @@ -114,7 +114,7 @@ export function createUIQueryClient( ) as unknown as JsonSubscriptionCallback; messenger.unsubscribe( - `${service}:cacheUpdate:${hash}`, + `${service}:cacheUpdated:${hash}`, subscriptionListener, ); subscriptions.delete(hash); From 4ca85e5a4e8767a1401b7368492b282a27d7db78 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 12 Mar 2026 14:27:17 +0100 Subject: [PATCH 38/54] Add cacheUpdated event test --- .../src/BaseDataService.test.ts | 56 +++++++++++++++++++ .../base-data-service/src/BaseDataService.ts | 6 +- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 78fa43abba5..4d3bdae4b1a 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -1,4 +1,5 @@ import { Messenger } from '@metamask/messenger'; +import { hashQueryKey } from '@tanstack/query-core'; import { ExampleDataService, serviceName } from '../tests/ExampleDataService'; import { @@ -105,4 +106,59 @@ describe('BaseDataService', () => { expect(page2.data).toHaveLength(3); expect(page2.data).not.toStrictEqual(page3.data); }); + + it('emits `:cacheUpdated` events', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + const publishSpy = jest.spyOn(messenger, 'publish'); + + const assets = [ + 'eip155:1/slip44:60', + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + ]; + + await service.getAssets(assets); + + const queryKey = ['ExampleDataService:getAssets', assets]; + + const hash = hashQueryKey(queryKey); + + expect(publishSpy).toHaveBeenNthCalledWith( + 6, + `ExampleDataService:cacheUpdated:${hash}`, + { + 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', + }, + ], + }), + }), + ], + }, + ); + }); }); diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 58ae3c85f22..1d7120ce329 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -215,12 +215,10 @@ export class BaseDataService< const hash = hashQueryKey(queryKey); const state = this.#getDehydratedState(queryKey); - const payload = { + this.#messenger.publish(`${this.name}:cacheUpdated` as const, { hash, state, - }; - - this.#messenger.publish(`${this.name}:cacheUpdated` as const, payload); + }); this.#messenger.publish( `${this.name}:cacheUpdated:${hash}` as const, state, From 496403af014d61aaff8c25e7aef3d19b93c25d44 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 12 Mar 2026 15:51:29 +0100 Subject: [PATCH 39/54] Fix hook return type --- packages/base-data-service/src/hooks.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/base-data-service/src/hooks.ts b/packages/base-data-service/src/hooks.ts index 5fcf8986f42..0f720ced91b 100644 --- a/packages/base-data-service/src/hooks.ts +++ b/packages/base-data-service/src/hooks.ts @@ -7,6 +7,8 @@ import { InitialDataFunction, NonUndefinedGuard, UseInfiniteQueryOptions, + UseQueryResult, + UseInfiniteQueryResult, } from '@tanstack/react-query'; // We provide re-exports of the underlying TanStack Query hooks with narrower types, @@ -27,7 +29,7 @@ export function useQuery< | InitialDataFunction> | NonUndefinedGuard; }, -): ReturnType { +): UseQueryResult { return useQueryTanStack(options); } @@ -47,6 +49,6 @@ export function useInfiniteQuery< >, 'staleTime' | 'queryFn' >, -): ReturnType { +): UseInfiniteQueryResult { return useInfiniteQueryTanStack(options); } From b2dfa26c29d9cfbbcc57c458ca4246b916adc313 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 17 Mar 2026 14:28:48 +0100 Subject: [PATCH 40/54] Add logic for destroying the service --- .../src/BaseDataService.test.ts | 18 +++++++++++++++++ .../base-data-service/src/BaseDataService.ts | 20 ++++++++++++------- .../tests/ExampleDataService.ts | 4 ++++ 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 4d3bdae4b1a..d98aefea749 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -161,4 +161,22 @@ describe('BaseDataService', () => { }, ); }); + + it('does not emit events after being destroyed', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + const publishSpy = jest.spyOn(messenger, 'publish'); + + service.destroy(); + + const assets = [ + 'eip155:1/slip44:60', + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + ]; + + await service.getAssets(assets); + + expect(publishSpy).toHaveBeenCalledTimes(0); + }); }); diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 1d7120ce329..4dac6e4eced 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -80,6 +80,8 @@ export class BaseDataService< readonly #queryClient: QueryClient; + readonly #queryCacheUnsubscribe: () => void; + constructor({ name, messenger, @@ -109,8 +111,15 @@ export class BaseDataService< }, }); + this.#queryCacheUnsubscribe = this.#queryClient + .getQueryCache() + .subscribe((event) => { + if (['added', 'updated', 'removed'].includes(event.type)) { + this.#broadcastCacheUpdate(event.query.queryKey); + } + }); + this.#registerMessageHandlers(); - this.#setupCacheListener(); } #registerMessageHandlers(): void { @@ -120,12 +129,9 @@ export class BaseDataService< ); } - #setupCacheListener(): void { - this.#queryClient.getQueryCache().subscribe((event) => { - if (['added', 'updated', 'removed'].includes(event.type)) { - this.#broadcastCacheUpdate(event.query.queryKey); - } - }); + protected destroy(): void { + this.messenger.clearSubscriptions(); + this.#queryCacheUnsubscribe(); } protected async fetchQuery< diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts index 0580f0ec755..2c5ec99b2a7 100644 --- a/packages/base-data-service/tests/ExampleDataService.ts +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -131,4 +131,8 @@ export class ExampleDataService extends BaseDataService< page, ); } + + destroy(): void { + super.destroy(); + } } From fbf0898f686f3f5933cad807e0150a024b178b81 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Tue, 17 Mar 2026 15:25:56 +0100 Subject: [PATCH 41/54] Emit additional type information for cache updates to facilitate proper cache removal --- .../src/BaseDataService.test.ts | 110 +++++++++++------- .../base-data-service/src/BaseDataService.ts | 56 ++++++--- .../src/createUIQueryClient.test.ts | 46 ++++++++ .../src/createUIQueryClient.ts | 16 ++- .../tests/ExampleDataService.ts | 1 + 5 files changed, 161 insertions(+), 68 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index d98aefea749..4c19edbb456 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -13,6 +13,12 @@ import { const TEST_ADDRESS = '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520'; +const MOCK_ASSETS = [ + 'eip155:1/slip44:60', + 'bip122:000000000019d6689c085ae165831e93/slip44:0', + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', +]; + describe('BaseDataService', () => { beforeEach(() => { mockAssets(); @@ -25,13 +31,7 @@ describe('BaseDataService', () => { const messenger = new Messenger({ namespace: serviceName }); const service = new ExampleDataService(messenger); - expect( - await service.getAssets([ - 'eip155:1/slip44:60', - 'bip122:000000000019d6689c085ae165831e93/slip44:0', - 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', - ]), - ).toStrictEqual([ + expect(await service.getAssets(MOCK_ASSETS)).toStrictEqual([ { assetId: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', decimals: 18, @@ -107,21 +107,15 @@ describe('BaseDataService', () => { expect(page2.data).not.toStrictEqual(page3.data); }); - it('emits `:cacheUpdated` events', async () => { + it('emits `:cacheUpdated` events when cache is updated', async () => { const messenger = new Messenger({ namespace: serviceName }); const service = new ExampleDataService(messenger); const publishSpy = jest.spyOn(messenger, 'publish'); - const assets = [ - 'eip155:1/slip44:60', - 'bip122:000000000019d6689c085ae165831e93/slip44:0', - 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', - ]; + await service.getAssets(MOCK_ASSETS); - await service.getAssets(assets); - - const queryKey = ['ExampleDataService:getAssets', assets]; + const queryKey = ['ExampleDataService:getAssets', MOCK_ASSETS]; const hash = hashQueryKey(queryKey); @@ -129,35 +123,63 @@ describe('BaseDataService', () => { 6, `ExampleDataService:cacheUpdated:${hash}`, { - 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', - }, - ], + 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', + }, + ], + }), }), - }), - ], + ], + }, + }, + ); + }); + + it('emits `:cacheUpdated` events when cache entry is removed', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + const publishSpy = jest.spyOn(messenger, 'publish'); + + await service.getAssets(MOCK_ASSETS); + + // Wait for GC + await new Promise((resolve) => setTimeout(resolve, 0)); + + const queryKey = ['ExampleDataService:getAssets', MOCK_ASSETS]; + + const hash = hashQueryKey(queryKey); + + expect(publishSpy).toHaveBeenNthCalledWith( + 8, + `ExampleDataService:cacheUpdated:${hash}`, + { + type: 'removed', + state: null, }, ); }); diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 4dac6e4eced..d06aa47215d 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -18,11 +18,21 @@ import { QueryKey, WithRequired, dehydrate, - hashQueryKey, } from '@tanstack/query-core'; import deepEqual from 'fast-deep-equal'; -export type CacheUpdatedPayload = { hash: string; state: DehydratedState }; +export type GranularCacheUpdatedPayload = + | { type: 'added' | 'updated'; state: DehydratedState } + | { + type: 'removed'; + state: null; + }; + +export type CacheUpdatedPayload = { + hash: string; +} & GranularCacheUpdatedPayload; + +type CacheUpdatedType = CacheUpdatedPayload['type']; export type DataServiceInvalidateQueriesAction = { type: `${ServiceName}:invalidateQueries`; @@ -42,7 +52,7 @@ export type DataServiceCacheUpdatedEvent = { export type DataServiceGranularCacheUpdatedEvent = { type: `${ServiceName}:cacheUpdated:${string}`; - payload: [CacheUpdatedPayload['state']]; + payload: [GranularCacheUpdatedPayload]; }; export type DataServiceEvents = @@ -115,7 +125,10 @@ export class BaseDataService< .getQueryCache() .subscribe((event) => { if (['added', 'updated', 'removed'].includes(event.type)) { - this.#broadcastCacheUpdate(event.query.queryKey); + this.#broadcastCacheUpdate( + event.query.queryHash, + event.type as CacheUpdatedType, + ); } }); @@ -130,8 +143,8 @@ export class BaseDataService< } protected destroy(): void { - this.messenger.clearSubscriptions(); this.#queryCacheUnsubscribe(); + this.messenger.clearSubscriptions(); } protected async fetchQuery< @@ -210,24 +223,29 @@ export class BaseDataService< return this.#queryClient.invalidateQueries(filters, options); } - #getDehydratedState(queryKey: QueryKey): DehydratedState { - const hash = hashQueryKey(queryKey); - return dehydrate(this.#queryClient, { - shouldDehydrateQuery: (query) => query.queryHash === hash, - }); - } + #broadcastCacheUpdate(hash: string, type: CacheUpdatedType): void { + const state = + type === 'added' || type === 'updated' + ? dehydrate(this.#queryClient, { + shouldDehydrateQuery: (query) => query.queryHash === hash, + }) + : null; - #broadcastCacheUpdate(queryKey: QueryKey): void { - const hash = hashQueryKey(queryKey); - const state = this.#getDehydratedState(queryKey); + this.#messenger.publish( + `${this.name}:cacheUpdated` as const, + { + type, + hash, + state, + } as CacheUpdatedPayload, + ); - this.#messenger.publish(`${this.name}:cacheUpdated` as const, { - hash, - state, - }); this.#messenger.publish( `${this.name}:cacheUpdated:${hash}` as const, - state, + { + type, + state, + } as GranularCacheUpdatedPayload, ); } } diff --git a/packages/base-data-service/src/createUIQueryClient.test.ts b/packages/base-data-service/src/createUIQueryClient.test.ts index 6fa55d03e1b..af2b4f2336d 100644 --- a/packages/base-data-service/src/createUIQueryClient.test.ts +++ b/packages/base-data-service/src/createUIQueryClient.test.ts @@ -1,5 +1,6 @@ import { Messenger } from '@metamask/messenger'; import { + hashQueryKey, InfiniteData, InfiniteQueryObserver, QueryClient, @@ -179,6 +180,51 @@ describe('createUIQueryClient', () => { observerB.destroy(); }); + it('synchronizes cache removal after remove event', async () => { + const { messenger, clientA, clientB } = createClients(); + + 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') { + resolve(event.data); + } + }); + }); + + const promiseB = new Promise((resolve) => { + observerB.subscribe((event) => { + if (event.status === 'success') { + resolve(event.data); + } + }); + }); + + await Promise.all([promiseA, promiseB]); + + const hash = hashQueryKey(getAssetsQueryKey); + + messenger.publish(`ExampleDataService:cacheUpdated:${hash}`, { + type: 'removed', + state: null, + }); + + const queryData = clientA.getQueryData(getAssetsQueryKey); + + expect(queryData).toBeUndefined(); + expect(queryData).toStrictEqual(clientB.getQueryData(getAssetsQueryKey)); + + observerA.destroy(); + observerB.destroy(); + }); + it('fetches using paginated observers', async () => { const { clientA, clientB } = createClients(); diff --git a/packages/base-data-service/src/createUIQueryClient.ts b/packages/base-data-service/src/createUIQueryClient.ts index 960c13fe56a..36509c6be2f 100644 --- a/packages/base-data-service/src/createUIQueryClient.ts +++ b/packages/base-data-service/src/createUIQueryClient.ts @@ -9,9 +9,9 @@ import { QueryKey, } from '@tanstack/query-core'; -import { CacheUpdatedPayload } from './BaseDataService'; +import { GranularCacheUpdatedPayload } from './BaseDataService'; -type SubscriptionCallback = (state: CacheUpdatedPayload['state']) => void; +type SubscriptionCallback = (payload: GranularCacheUpdatedPayload) => void; type JsonSubscriptionCallback = (data: Json) => void; // TODO: Figure out if we can replace with a better Messenger type @@ -77,7 +77,9 @@ export function createUIQueryClient( }, }); - client.getQueryCache().subscribe((event) => { + const cache = client.getQueryCache(); + + cache.subscribe((event) => { const { query } = event; const hash = query.queryHash; @@ -95,8 +97,12 @@ export function createUIQueryClient( event.type === 'observerAdded' && observerCount === 1 ) { - const cacheListener = (state: CacheUpdatedPayload['state']): void => { - hydrate(client, state); + const cacheListener = (payload: GranularCacheUpdatedPayload): void => { + if (payload.type === 'removed') { + cache.remove(query); + } else { + hydrate(client, payload.state); + } }; subscriptions.set(hash, cacheListener); diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts index 2c5ec99b2a7..04f38a26d24 100644 --- a/packages/base-data-service/tests/ExampleDataService.ts +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -94,6 +94,7 @@ export class ExampleDataService extends BaseDataService< return response.json(); }, staleTime: inMilliseconds(1, Duration.Day), + cacheTime: 0, // Not recommended in production, just for testing purposes. }); } From 1d47520063e27a8068d12450b568d5f43f227ce2 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 18 Mar 2026 10:32:21 +0100 Subject: [PATCH 42/54] Use named exports --- packages/base-data-service/src/index.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/base-data-service/src/index.ts b/packages/base-data-service/src/index.ts index 70976d446c0..21dc49eba0b 100644 --- a/packages/base-data-service/src/index.ts +++ b/packages/base-data-service/src/index.ts @@ -1,3 +1,12 @@ -export * from './BaseDataService'; -export * from './createUIQueryClient'; -export * from './hooks'; +export type { + CacheUpdatedPayload, + GranularCacheUpdatedPayload, + DataServiceInvalidateQueriesAction, + DataServiceActions, + DataServiceEvents, + DataServiceCacheUpdatedEvent, + DataServiceGranularCacheUpdatedEvent, +} from './BaseDataService'; +export { BaseDataService } from './BaseDataService'; +export { createUIQueryClient } from './createUIQueryClient'; +export { useQuery, useInfiniteQuery } from './hooks'; From 806c757660a7ec5271d7bc1798614b4be8c18039 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 18 Mar 2026 11:36:01 +0100 Subject: [PATCH 43/54] Simplify --- packages/base-data-service/src/BaseDataService.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index d06aa47215d..f2e52b7feab 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -145,6 +145,7 @@ export class BaseDataService< protected destroy(): void { this.#queryCacheUnsubscribe(); this.messenger.clearSubscriptions(); + this.messenger.clearActions(); } protected async fetchQuery< @@ -174,15 +175,13 @@ export class BaseDataService< >, pageParam?: TPageParam, ): Promise { - const query = this.#queryClient - .getQueryCache() - .find< - TQueryFnData, - TError, - InfiniteData - >({ queryKey: options.queryKey }); + const cache = this.#queryClient.getQueryCache(); + + const query = cache.find>({ + queryKey: options.queryKey, + }); - if (!query?.state.data || !pageParam) { + if (!query?.state.data || pageParam === undefined) { const result = await this.#queryClient.fetchInfiniteQuery({ ...options, queryFn: (context) => From 229f8bc45998b96be2db9a885e9fbe786ca1dcd9 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 18 Mar 2026 13:08:28 +0100 Subject: [PATCH 44/54] Add service policy support --- packages/base-data-service/package.json | 1 + .../src/BaseDataService.test.ts | 70 +++++++++++++++++++ .../base-data-service/src/BaseDataService.ts | 27 +++++-- .../tests/ExampleDataService.ts | 6 ++ .../base-data-service/tsconfig.build.json | 2 +- packages/base-data-service/tsconfig.json | 2 +- yarn.lock | 1 + 7 files changed, 102 insertions(+), 7 deletions(-) diff --git a/packages/base-data-service/package.json b/packages/base-data-service/package.json index b4165b9d7f4..db75e38e3f5 100644 --- a/packages/base-data-service/package.json +++ b/packages/base-data-service/package.json @@ -48,6 +48,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/controller-utils": "^11.19.0", "@metamask/messenger": "^0.3.0", "@metamask/utils": "^11.9.0", "@tanstack/query-core": "^4.43.0", diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 4c19edbb456..805289a21d5 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -1,5 +1,7 @@ +import { BrokenCircuitError } from '@metamask/controller-utils'; import { Messenger } from '@metamask/messenger'; import { hashQueryKey } from '@tanstack/query-core'; +import { cleanAll } from 'nock'; import { ExampleDataService, serviceName } from '../tests/ExampleDataService'; import { @@ -201,4 +203,72 @@ describe('BaseDataService', () => { expect(publishSpy).toHaveBeenCalledTimes(0); }); + + describe('service policy', () => { + beforeEach(() => { + cleanAll(); + }); + + it('retries failed queries using the service policy', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + mockAssets({ status: 500 }); + mockAssets({ status: 500 }); + mockAssets(); + + const result = await service.getAssets(MOCK_ASSETS); + + expect(result).toStrictEqual([ + { + 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', + }, + ]); + }); + + it('throws after exhausting service policy retries', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + mockAssets({ status: 500 }); + mockAssets({ status: 500 }); + mockAssets({ status: 500 }); + + await expect(service.getAssets(MOCK_ASSETS)).rejects.toThrow( + 'invalid json response body', + ); + }); + + it('breaks the circuit after consecutive failures', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + mockAssets({ status: 500 }); + mockAssets({ status: 500 }); + mockAssets({ status: 500 }); + + await expect(service.getAssets(MOCK_ASSETS)).rejects.toThrow( + 'invalid json response body', + ); + + await expect(service.getAssets(MOCK_ASSETS)).rejects.toThrow( + BrokenCircuitError, + ); + }); + }); }); diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index f2e52b7feab..cd910ac07d4 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -1,3 +1,8 @@ +import { + createServicePolicy, + CreateServicePolicyOptions, + ServicePolicy, +} from '@metamask/controller-utils'; import { Messenger, ActionConstraint, @@ -88,6 +93,8 @@ export class BaseDataService< protected messenger: ServiceMessenger; + readonly #policy: ServicePolicy; + readonly #queryClient: QueryClient; readonly #queryCacheUnsubscribe: () => void; @@ -96,10 +103,12 @@ export class BaseDataService< name, messenger, queryClientConfig = {}, + servicePolicyOptions, }: { name: ServiceName; messenger: ServiceMessenger; queryClientConfig?: QueryClientConfig; + servicePolicyOptions?: CreateServicePolicyOptions; }) { this.name = name; @@ -121,6 +130,8 @@ export class BaseDataService< }, }); + this.#policy = createServicePolicy(servicePolicyOptions); + this.#queryCacheUnsubscribe = this.#queryClient .getQueryCache() .subscribe((event) => { @@ -159,7 +170,11 @@ export class BaseDataService< 'queryKey' | 'queryFn' >, ): Promise { - return this.#queryClient.fetchQuery(options); + return this.#queryClient.fetchQuery({ + ...options, + queryFn: (context) => + this.#policy.execute(() => options.queryFn(context)), + }); } protected async fetchInfiniteQuery< @@ -185,10 +200,12 @@ export class BaseDataService< const result = await this.#queryClient.fetchInfiniteQuery({ ...options, queryFn: (context) => - options.queryFn({ - ...context, - pageParam: context.pageParam ?? pageParam, - }), + this.#policy.execute(() => + options.queryFn({ + ...context, + pageParam: context.pageParam ?? pageParam, + }), + ), }); return result.pages[0]; diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts index 04f38a26d24..e46178a2fd6 100644 --- a/packages/base-data-service/tests/ExampleDataService.ts +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -1,3 +1,4 @@ +import { ConstantBackoff } from '@metamask/controller-utils'; import { Messenger } from '@metamask/messenger'; import { CaipAssetId, Duration, inMilliseconds, Json } from '@metamask/utils'; @@ -68,6 +69,11 @@ export class ExampleDataService extends BaseDataService< super({ name: serviceName, messenger, + servicePolicyOptions: { + maxRetries: 2, + maxConsecutiveFailures: 3, + backoff: new ConstantBackoff(0), + }, }); messenger.registerActionHandler( diff --git a/packages/base-data-service/tsconfig.build.json b/packages/base-data-service/tsconfig.build.json index 57f3ffc0f9b..c63a3e499cc 100644 --- a/packages/base-data-service/tsconfig.build.json +++ b/packages/base-data-service/tsconfig.build.json @@ -5,6 +5,6 @@ "outDir": "./dist", "rootDir": "./src" }, - "references": [{ "path": "../messenger/tsconfig.build.json" }], + "references": [{ "path": "../messenger/tsconfig.build.json" }, { "path": "../controller-utils/tsconfig.build.json" }], "include": ["../../types", "./src"] } diff --git a/packages/base-data-service/tsconfig.json b/packages/base-data-service/tsconfig.json index 6e77825aa53..3ac9b5d7da7 100644 --- a/packages/base-data-service/tsconfig.json +++ b/packages/base-data-service/tsconfig.json @@ -3,6 +3,6 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [{ "path": "../messenger/tsconfig.build.json" }], + "references": [{ "path": "../messenger/tsconfig.build.json" }, { "path": "../controller-utils/tsconfig.build.json" }], "include": ["../../types", "./src", "./tests"] } diff --git a/yarn.lock b/yarn.lock index 8309ff43809..7b6b3baea29 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2986,6 +2986,7 @@ __metadata: resolution: "@metamask/base-data-service@workspace:packages/base-data-service" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/controller-utils": "npm:^11.19.0" "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" "@tanstack/query-core": "npm:^4.43.0" From 2134475cdeeb07ed7e683accb0a819288b16d564 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 18 Mar 2026 13:15:12 +0100 Subject: [PATCH 45/54] Run Prettier on tsconfig --- packages/base-data-service/tsconfig.build.json | 5 ++++- packages/base-data-service/tsconfig.json | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/base-data-service/tsconfig.build.json b/packages/base-data-service/tsconfig.build.json index c63a3e499cc..f83c71b4af8 100644 --- a/packages/base-data-service/tsconfig.build.json +++ b/packages/base-data-service/tsconfig.build.json @@ -5,6 +5,9 @@ "outDir": "./dist", "rootDir": "./src" }, - "references": [{ "path": "../messenger/tsconfig.build.json" }, { "path": "../controller-utils/tsconfig.build.json" }], + "references": [ + { "path": "../messenger/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" } + ], "include": ["../../types", "./src"] } diff --git a/packages/base-data-service/tsconfig.json b/packages/base-data-service/tsconfig.json index 3ac9b5d7da7..7f64efe2fd5 100644 --- a/packages/base-data-service/tsconfig.json +++ b/packages/base-data-service/tsconfig.json @@ -3,6 +3,9 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [{ "path": "../messenger/tsconfig.build.json" }, { "path": "../controller-utils/tsconfig.build.json" }], + "references": [ + { "path": "../messenger/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" } + ], "include": ["../../types", "./src", "./tests"] } From 090e534f373a7d3b0b5eaa7fddac982391ce389c Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 18 Mar 2026 13:59:44 +0100 Subject: [PATCH 46/54] Address more PR comments --- .../base-data-service/src/BaseDataService.ts | 3 +++ packages/base-data-service/src/hooks.ts | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index cd910ac07d4..6e265547ccd 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -67,6 +67,7 @@ export type DataServiceEvents = // Defaults to apply to all data service queries if no default option specified const queryClientDefaults: DefaultOptions = { queries: { + retry: false, staleTime: inMilliseconds(1, Duration.Minute), }, }; @@ -112,6 +113,8 @@ export class BaseDataService< }) { this.name = name; + // We are storing a separately typed messenger for known actions and events provided by data services + // and a generic public one that is typed using the generic parameters and accessible to implementations. this.#messenger = messenger as unknown as Messenger< ServiceName, DataServiceActions, diff --git a/packages/base-data-service/src/hooks.ts b/packages/base-data-service/src/hooks.ts index 0f720ced91b..62633c20bf6 100644 --- a/packages/base-data-service/src/hooks.ts +++ b/packages/base-data-service/src/hooks.ts @@ -1,9 +1,9 @@ +import { Json } from '@metamask/utils'; import { useQuery as useQueryTanStack, useInfiniteQuery as useInfiniteQueryTanStack, OmitKeyof, UseQueryOptions, - QueryKey, InitialDataFunction, NonUndefinedGuard, UseInfiniteQueryOptions, @@ -14,6 +14,16 @@ import { // 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. +// Data service queries use the following format: ['ServiceActionName', ...params] +export type QueryKey = [string, ...Json[]]; + +/** + * Consume a query from a data service. + * + * @param options - The query options. Keep in mind that `staleTime` and `queryFn` are not supported + * when querying data services. + * @returns The query results. + */ export function useQuery< TQueryFnData = unknown, TError = unknown, @@ -33,6 +43,13 @@ export function useQuery< return useQueryTanStack(options); } +/** + * Consume a paginated query from a data service. + * + * @param options - The query options. Keep in mind that `staleTime` and `queryFn` are not supported + * when querying data services. + * @returns The paginated query results. + */ export function useInfiniteQuery< TQueryFnData = unknown, TError = unknown, From 3adfec44130255f88934eebd652054432567df85 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 18 Mar 2026 14:05:55 +0100 Subject: [PATCH 47/54] Move QueryKey type --- packages/base-data-service/src/BaseDataService.ts | 4 +++- packages/base-data-service/src/hooks.ts | 6 ++---- packages/base-data-service/src/index.ts | 1 + 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 6e265547ccd..00e4414b959 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -20,12 +20,14 @@ import { InvalidateQueryFilters, QueryClient, QueryClientConfig, - QueryKey, WithRequired, dehydrate, } from '@tanstack/query-core'; import deepEqual from 'fast-deep-equal'; +// Data service queries use the following format: ['ServiceActionName', ...params] +export type QueryKey = [string, ...Json[]]; + export type GranularCacheUpdatedPayload = | { type: 'added' | 'updated'; state: DehydratedState } | { diff --git a/packages/base-data-service/src/hooks.ts b/packages/base-data-service/src/hooks.ts index 62633c20bf6..23b9217bf04 100644 --- a/packages/base-data-service/src/hooks.ts +++ b/packages/base-data-service/src/hooks.ts @@ -1,4 +1,3 @@ -import { Json } from '@metamask/utils'; import { useQuery as useQueryTanStack, useInfiniteQuery as useInfiniteQueryTanStack, @@ -11,12 +10,11 @@ import { UseInfiniteQueryResult, } from '@tanstack/react-query'; +import { QueryKey } from './BaseDataService'; + // 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. -// Data service queries use the following format: ['ServiceActionName', ...params] -export type QueryKey = [string, ...Json[]]; - /** * Consume a query from a data service. * diff --git a/packages/base-data-service/src/index.ts b/packages/base-data-service/src/index.ts index 21dc49eba0b..82d71394b0c 100644 --- a/packages/base-data-service/src/index.ts +++ b/packages/base-data-service/src/index.ts @@ -6,6 +6,7 @@ export type { DataServiceEvents, DataServiceCacheUpdatedEvent, DataServiceGranularCacheUpdatedEvent, + QueryKey, } from './BaseDataService'; export { BaseDataService } from './BaseDataService'; export { createUIQueryClient } from './createUIQueryClient'; From 0d3c01de10ce6579ed6b2a18ae041841611acf9f Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 19 Mar 2026 10:57:45 +0100 Subject: [PATCH 48/54] Move UI-specific code to react-data-query --- packages/base-data-service/package.json | 1 - packages/base-data-service/src/index.ts | 2 -- packages/base-data-service/tsconfig.json | 4 +-- packages/react-data-query/package.json | 10 +++++++ .../src/createUIQueryClient.test.ts | 4 +-- .../src/createUIQueryClient.ts | 2 +- .../src/hooks.test.ts | 0 .../src/hooks.ts | 2 +- packages/react-data-query/src/index.test.ts | 9 ------- packages/react-data-query/src/index.ts | 11 ++------ packages/react-data-query/tsconfig.build.json | 2 +- packages/react-data-query/tsconfig.json | 2 +- yarn.lock | 27 ++++++++++++++++++- 13 files changed, 46 insertions(+), 30 deletions(-) rename packages/{base-data-service => react-data-query}/src/createUIQueryClient.test.ts (98%) rename packages/{base-data-service => react-data-query}/src/createUIQueryClient.ts (98%) rename packages/{base-data-service => react-data-query}/src/hooks.test.ts (100%) rename packages/{base-data-service => react-data-query}/src/hooks.ts (96%) delete mode 100644 packages/react-data-query/src/index.test.ts diff --git a/packages/base-data-service/package.json b/packages/base-data-service/package.json index db75e38e3f5..edf84b2a1e1 100644 --- a/packages/base-data-service/package.json +++ b/packages/base-data-service/package.json @@ -52,7 +52,6 @@ "@metamask/messenger": "^0.3.0", "@metamask/utils": "^11.9.0", "@tanstack/query-core": "^4.43.0", - "@tanstack/react-query": "^4.43.0", "fast-deep-equal": "^3.1.3" }, "devDependencies": { diff --git a/packages/base-data-service/src/index.ts b/packages/base-data-service/src/index.ts index 82d71394b0c..15724e00df8 100644 --- a/packages/base-data-service/src/index.ts +++ b/packages/base-data-service/src/index.ts @@ -9,5 +9,3 @@ export type { QueryKey, } from './BaseDataService'; export { BaseDataService } from './BaseDataService'; -export { createUIQueryClient } from './createUIQueryClient'; -export { useQuery, useInfiniteQuery } from './hooks'; diff --git a/packages/base-data-service/tsconfig.json b/packages/base-data-service/tsconfig.json index 7f64efe2fd5..b8c1c7a66bc 100644 --- a/packages/base-data-service/tsconfig.json +++ b/packages/base-data-service/tsconfig.json @@ -4,8 +4,8 @@ "baseUrl": "./" }, "references": [ - { "path": "../messenger/tsconfig.build.json" }, - { "path": "../controller-utils/tsconfig.build.json" } + { "path": "../messenger" }, + { "path": "../controller-utils" } ], "include": ["../../types", "./src", "./tests"] } diff --git a/packages/react-data-query/package.json b/packages/react-data-query/package.json index a161bb23099..05d77736008 100644 --- a/packages/react-data-query/package.json +++ b/packages/react-data-query/package.json @@ -46,6 +46,11 @@ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, + "dependencies": { + "@metamask/utils": "^11.10.0", + "@tanstack/query-core": "^4.43.0", + "@tanstack/react-query": "^4.43.0" + }, "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", @@ -57,6 +62,11 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, + "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", + "react-native": "*" + }, "engines": { "node": "^18.18 || >=20" }, diff --git a/packages/base-data-service/src/createUIQueryClient.test.ts b/packages/react-data-query/src/createUIQueryClient.test.ts similarity index 98% rename from packages/base-data-service/src/createUIQueryClient.test.ts rename to packages/react-data-query/src/createUIQueryClient.test.ts index af2b4f2336d..18bf7817f80 100644 --- a/packages/base-data-service/src/createUIQueryClient.test.ts +++ b/packages/react-data-query/src/createUIQueryClient.test.ts @@ -14,12 +14,12 @@ import { ExampleDataServiceEvents, GetActivityResponse, PageParam, -} from '../tests/ExampleDataService'; +} from '../../base-data-service/tests/ExampleDataService'; import { mockAssets, mockTransactionsPage1, mockTransactionsPage2, -} from '../tests/mocks'; +} from '../../base-data-service/tests/mocks'; const DATA_SERVICES = ['ExampleDataService']; diff --git a/packages/base-data-service/src/createUIQueryClient.ts b/packages/react-data-query/src/createUIQueryClient.ts similarity index 98% rename from packages/base-data-service/src/createUIQueryClient.ts rename to packages/react-data-query/src/createUIQueryClient.ts index 36509c6be2f..01a8ebf3513 100644 --- a/packages/base-data-service/src/createUIQueryClient.ts +++ b/packages/react-data-query/src/createUIQueryClient.ts @@ -9,7 +9,7 @@ import { QueryKey, } from '@tanstack/query-core'; -import { GranularCacheUpdatedPayload } from './BaseDataService'; +import { GranularCacheUpdatedPayload } from '@metamask/base-data-service'; type SubscriptionCallback = (payload: GranularCacheUpdatedPayload) => void; type JsonSubscriptionCallback = (data: Json) => void; diff --git a/packages/base-data-service/src/hooks.test.ts b/packages/react-data-query/src/hooks.test.ts similarity index 100% rename from packages/base-data-service/src/hooks.test.ts rename to packages/react-data-query/src/hooks.test.ts diff --git a/packages/base-data-service/src/hooks.ts b/packages/react-data-query/src/hooks.ts similarity index 96% rename from packages/base-data-service/src/hooks.ts rename to packages/react-data-query/src/hooks.ts index 23b9217bf04..93b2049937b 100644 --- a/packages/base-data-service/src/hooks.ts +++ b/packages/react-data-query/src/hooks.ts @@ -10,7 +10,7 @@ import { UseInfiniteQueryResult, } from '@tanstack/react-query'; -import { QueryKey } from './BaseDataService'; +import { QueryKey } from '@metamask/base-data-service'; // 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. diff --git a/packages/react-data-query/src/index.test.ts b/packages/react-data-query/src/index.test.ts deleted file mode 100644 index bc062d3694a..00000000000 --- a/packages/react-data-query/src/index.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import greeter from '.'; - -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greeter(name); - expect(result).toBe('Hello, Huey!'); - }); -}); diff --git a/packages/react-data-query/src/index.ts b/packages/react-data-query/src/index.ts index 6972c117292..5151248dc9b 100644 --- a/packages/react-data-query/src/index.ts +++ b/packages/react-data-query/src/index.ts @@ -1,9 +1,2 @@ -/** - * Example function that returns a greeting for the given name. - * - * @param name - The name to greet. - * @returns The greeting. - */ -export default function greeter(name: string): string { - return `Hello, ${name}!`; -} +export { createUIQueryClient } from './createUIQueryClient'; +export { useQuery, useInfiniteQuery } from './hooks'; \ No newline at end of file diff --git a/packages/react-data-query/tsconfig.build.json b/packages/react-data-query/tsconfig.build.json index 02a0eea03fe..3c1d8d64e87 100644 --- a/packages/react-data-query/tsconfig.build.json +++ b/packages/react-data-query/tsconfig.build.json @@ -5,6 +5,6 @@ "outDir": "./dist", "rootDir": "./src" }, - "references": [], + "references": [{ "path": "../base-data-service/tsconfig.build.json" }], "include": ["../../types", "./src"] } diff --git a/packages/react-data-query/tsconfig.json b/packages/react-data-query/tsconfig.json index 025ba2ef7f4..181f238ab73 100644 --- a/packages/react-data-query/tsconfig.json +++ b/packages/react-data-query/tsconfig.json @@ -3,6 +3,6 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [], + "references": [{ "path": "../base-data-service" }], "include": ["../../types", "./src"] } diff --git a/yarn.lock b/yarn.lock index 7b6b3baea29..54c2ff53aa3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2990,7 +2990,6 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/utils": "npm:^11.9.0" "@tanstack/query-core": "npm:^4.43.0" - "@tanstack/react-query": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" @@ -4977,6 +4976,9 @@ __metadata: resolution: "@metamask/react-data-query@workspace:packages/react-data-query" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/utils": "npm:^11.10.0" + "@tanstack/query-core": "npm:^4.43.0" + "@tanstack/react-query": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" @@ -4985,6 +4987,10 @@ __metadata: typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" + 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 + react-native: "*" languageName: unknown linkType: soft @@ -5607,6 +5613,25 @@ __metadata: languageName: node linkType: hard +"@metamask/utils@npm:^11.10.0": + version: 11.10.0 + resolution: "@metamask/utils@npm:11.10.0" + dependencies: + "@ethereumjs/tx": "npm:^4.2.0" + "@metamask/superstruct": "npm:^3.1.0" + "@noble/hashes": "npm:^1.3.1" + "@scure/base": "npm:^1.1.3" + "@types/debug": "npm:^4.1.7" + "@types/lodash": "npm:^4.17.20" + debug: "npm:^4.3.4" + lodash: "npm:^4.17.21" + pony-cause: "npm:^2.1.10" + semver: "npm:^7.5.4" + uuid: "npm:^9.0.1" + checksum: 10/691a268af66593b60e9807a069127993cea3cdc941f99d5d7ca4664868754f08945821f1787b2f3e99e4497df63ceb0af37a2419ad494da29a1fddffe94f5797 + languageName: node + linkType: hard + "@metamask/utils@npm:^9.0.0": version: 9.3.0 resolution: "@metamask/utils@npm:9.3.0" From 76aad78ecb92594330b3490b7e4a3d96f5c5b8e2 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 19 Mar 2026 10:58:49 +0100 Subject: [PATCH 49/54] Update CHANGELOG --- packages/react-data-query/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/react-data-query/CHANGELOG.md b/packages/react-data-query/CHANGELOG.md index b518709c7b8..3cc5491ed05 100644 --- a/packages/react-data-query/CHANGELOG.md +++ b/packages/react-data-query/CHANGELOG.md @@ -7,4 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Initial release ([#8039](https://github.com/MetaMask/core/pull/8039)) + [Unreleased]: https://github.com/MetaMask/core/ From 8a83840fad6b43181babc4a8bdbc12d4fb59894f Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 19 Mar 2026 11:10:01 +0100 Subject: [PATCH 50/54] Fix lint --- packages/base-data-service/tsconfig.json | 5 +--- packages/react-data-query/package.json | 3 ++- .../src/createUIQueryClient.ts | 3 +-- packages/react-data-query/src/hooks.ts | 3 +-- packages/react-data-query/src/index.ts | 2 +- yarn.lock | 24 +++---------------- 6 files changed, 9 insertions(+), 31 deletions(-) diff --git a/packages/base-data-service/tsconfig.json b/packages/base-data-service/tsconfig.json index b8c1c7a66bc..3dbaffd259b 100644 --- a/packages/base-data-service/tsconfig.json +++ b/packages/base-data-service/tsconfig.json @@ -3,9 +3,6 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [ - { "path": "../messenger" }, - { "path": "../controller-utils" } - ], + "references": [{ "path": "../messenger" }, { "path": "../controller-utils" }], "include": ["../../types", "./src", "./tests"] } diff --git a/packages/react-data-query/package.json b/packages/react-data-query/package.json index 05d77736008..c9a33812315 100644 --- a/packages/react-data-query/package.json +++ b/packages/react-data-query/package.json @@ -47,7 +47,8 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/utils": "^11.10.0", + "@metamask/base-data-service": "^0.0.0", + "@metamask/utils": "^11.9.0", "@tanstack/query-core": "^4.43.0", "@tanstack/react-query": "^4.43.0" }, diff --git a/packages/react-data-query/src/createUIQueryClient.ts b/packages/react-data-query/src/createUIQueryClient.ts index 01a8ebf3513..2cf9849f839 100644 --- a/packages/react-data-query/src/createUIQueryClient.ts +++ b/packages/react-data-query/src/createUIQueryClient.ts @@ -1,3 +1,4 @@ +import { GranularCacheUpdatedPayload } from '@metamask/base-data-service'; import { assert, Json } from '@metamask/utils'; import { hydrate, @@ -9,8 +10,6 @@ import { QueryKey, } from '@tanstack/query-core'; -import { GranularCacheUpdatedPayload } from '@metamask/base-data-service'; - type SubscriptionCallback = (payload: GranularCacheUpdatedPayload) => void; type JsonSubscriptionCallback = (data: Json) => void; diff --git a/packages/react-data-query/src/hooks.ts b/packages/react-data-query/src/hooks.ts index 93b2049937b..67ea942c764 100644 --- a/packages/react-data-query/src/hooks.ts +++ b/packages/react-data-query/src/hooks.ts @@ -1,3 +1,4 @@ +import { QueryKey } from '@metamask/base-data-service'; import { useQuery as useQueryTanStack, useInfiniteQuery as useInfiniteQueryTanStack, @@ -10,8 +11,6 @@ import { UseInfiniteQueryResult, } from '@tanstack/react-query'; -import { QueryKey } from '@metamask/base-data-service'; - // 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. diff --git a/packages/react-data-query/src/index.ts b/packages/react-data-query/src/index.ts index 5151248dc9b..f121a7eed64 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'; -export { useQuery, useInfiniteQuery } from './hooks'; \ No newline at end of file +export { useQuery, useInfiniteQuery } from './hooks'; diff --git a/yarn.lock b/yarn.lock index 54c2ff53aa3..730062168a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2981,7 +2981,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/base-data-service@workspace:packages/base-data-service": +"@metamask/base-data-service@npm:^0.0.0, @metamask/base-data-service@workspace:packages/base-data-service": version: 0.0.0-use.local resolution: "@metamask/base-data-service@workspace:packages/base-data-service" dependencies: @@ -4976,7 +4976,8 @@ __metadata: resolution: "@metamask/react-data-query@workspace:packages/react-data-query" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/utils": "npm:^11.10.0" + "@metamask/base-data-service": "npm:^0.0.0" + "@metamask/utils": "npm:^11.9.0" "@tanstack/query-core": "npm:^4.43.0" "@tanstack/react-query": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" @@ -5613,25 +5614,6 @@ __metadata: languageName: node linkType: hard -"@metamask/utils@npm:^11.10.0": - version: 11.10.0 - resolution: "@metamask/utils@npm:11.10.0" - dependencies: - "@ethereumjs/tx": "npm:^4.2.0" - "@metamask/superstruct": "npm:^3.1.0" - "@noble/hashes": "npm:^1.3.1" - "@scure/base": "npm:^1.1.3" - "@types/debug": "npm:^4.1.7" - "@types/lodash": "npm:^4.17.20" - debug: "npm:^4.3.4" - lodash: "npm:^4.17.21" - pony-cause: "npm:^2.1.10" - semver: "npm:^7.5.4" - uuid: "npm:^9.0.1" - checksum: 10/691a268af66593b60e9807a069127993cea3cdc941f99d5d7ca4664868754f08945821f1787b2f3e99e4497df63ceb0af37a2419ad494da29a1fddffe94f5797 - languageName: node - linkType: hard - "@metamask/utils@npm:^9.0.0": version: 9.3.0 resolution: "@metamask/utils@npm:9.3.0" From c5acf3e39c4c6f8be285a4d491b6baab738ef24e Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 19 Mar 2026 11:10:19 +0100 Subject: [PATCH 51/54] Add missing test --- .../src/BaseDataService.test.ts | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 805289a21d5..59ba340a4de 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -193,16 +193,25 @@ describe('BaseDataService', () => { service.destroy(); - const assets = [ - 'eip155:1/slip44:60', - 'bip122:000000000019d6689c085ae165831e93/slip44:0', - 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', - ]; - - await service.getAssets(assets); + await service.getAssets(MOCK_ASSETS); expect(publishSpy).toHaveBeenCalledTimes(0); }); + + it('invalidates queries when requested', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + const publishSpy = jest.spyOn(messenger, 'publish'); + + await service.getAssets(MOCK_ASSETS); + + expect(publishSpy).toHaveBeenCalledTimes(6); + + const queryKey = ['ExampleDataService:getAssets', MOCK_ASSETS]; + await service.invalidateQueries({ queryKey }); + + expect(publishSpy).toHaveBeenCalledTimes(8); + }) describe('service policy', () => { beforeEach(() => { From d0d303cf8acd89277833498be5eee8325c5e18b8 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 19 Mar 2026 11:34:55 +0100 Subject: [PATCH 52/54] Fix formatting --- packages/base-data-service/src/BaseDataService.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 59ba340a4de..a98a135ed0c 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -197,7 +197,7 @@ describe('BaseDataService', () => { expect(publishSpy).toHaveBeenCalledTimes(0); }); - + it('invalidates queries when requested', async () => { const messenger = new Messenger({ namespace: serviceName }); const service = new ExampleDataService(messenger); @@ -211,7 +211,7 @@ describe('BaseDataService', () => { await service.invalidateQueries({ queryKey }); expect(publishSpy).toHaveBeenCalledTimes(8); - }) + }); describe('service policy', () => { beforeEach(() => { From 1fdda0516338da593f106a05d4e4f2322f01044e Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 19 Mar 2026 11:48:11 +0100 Subject: [PATCH 53/54] Ignore React peer deps --- yarn.config.cjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/yarn.config.cjs b/yarn.config.cjs index 117ac1db0b5..6321d9137cd 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -27,6 +27,12 @@ const ALLOWED_INCONSISTENT_DEPENDENCIES = { '@tanstack/query-core': ['^4.43.0'], }; +/** + * These packages are allowed as peer dependencies without requiring installation as + * devDependencies. + */ +const ALLOWED_PEER_DEPENDENCIES = ['react', 'react-dom', 'react-native']; + /** * Aliases for the Yarn type definitions, to make the code more readable. * @@ -747,6 +753,10 @@ function expectPeerDependenciesAlsoListedAsDevDependencies( continue; } + if (ALLOWED_PEER_DEPENDENCIES.includes(dependencyIdent)) { + continue; + } + const dependencyWorkspace = Yarn.workspace({ ident: dependencyIdent }); if (!dependencyWorkspace) { From e0a7d03f922cda68b6ba35fd0ea38773041bd10e Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Fri, 20 Mar 2026 14:13:52 +0100 Subject: [PATCH 54/54] More cleanup --- .../base-data-service/src/BaseDataService.ts | 71 ++++++++++++++----- packages/base-data-service/src/index.ts | 2 - 2 files changed, 53 insertions(+), 20 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 00e4414b959..a6408ed91cc 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -18,6 +18,7 @@ import { InfiniteData, InvalidateOptions, InvalidateQueryFilters, + OmitKeyof, QueryClient, QueryClientConfig, WithRequired, @@ -35,9 +36,9 @@ export type GranularCacheUpdatedPayload = state: null; }; -export type CacheUpdatedPayload = { +export type CacheUpdatedPayload = GranularCacheUpdatedPayload & { hash: string; -} & GranularCacheUpdatedPayload; +}; type CacheUpdatedType = CacheUpdatedPayload['type']; @@ -49,7 +50,7 @@ export type DataServiceInvalidateQueriesAction = { ) => Promise; }; -export type DataServiceActions = +type DataServiceActions = DataServiceInvalidateQueriesAction; export type DataServiceCacheUpdatedEvent = { @@ -62,7 +63,7 @@ export type DataServiceGranularCacheUpdatedEvent = { payload: [GranularCacheUpdatedPayload]; }; -export type DataServiceEvents = +type DataServiceEvents = | DataServiceCacheUpdatedEvent | DataServiceGranularCacheUpdatedEvent; @@ -141,29 +142,26 @@ export class BaseDataService< .getQueryCache() .subscribe((event) => { if (['added', 'updated', 'removed'].includes(event.type)) { - this.#broadcastCacheUpdate( + this.#publishCacheUpdate( event.query.queryHash, event.type as CacheUpdatedType, ); } }); - this.#registerMessageHandlers(); - } - - #registerMessageHandlers(): void { this.#messenger.registerActionHandler( `${this.name}:invalidateQueries`, this.invalidateQueries.bind(this), ); } - protected destroy(): void { - this.#queryCacheUnsubscribe(); - this.messenger.clearSubscriptions(); - this.messenger.clearActions(); - } - + /** + * Fetch a query. + * + * @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`. + * @returns The query results. + */ protected async fetchQuery< TQueryFnData extends Json, TError = unknown, @@ -171,7 +169,10 @@ export class BaseDataService< TQueryKey extends QueryKey = QueryKey, >( options: WithRequired< - FetchQueryOptions, + OmitKeyof< + FetchQueryOptions, + 'retry' | 'retryDelay' + >, 'queryKey' | 'queryFn' >, ): Promise { @@ -182,6 +183,14 @@ export class BaseDataService< }); } + /** + * Fetch a paginated query. + * + * @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 pageParam - An optional page parameter. + * @returns The query result, exclusively the requested page is returned. + */ protected async fetchInfiniteQuery< TQueryFnData extends Json, TError = unknown, @@ -190,7 +199,10 @@ export class BaseDataService< TPageParam extends Json = Json, >( options: WithRequired< - FetchInfiniteQueryOptions, + OmitKeyof< + FetchInfiniteQueryOptions, + 'retry' | 'retryDelay' + >, 'queryKey' | 'queryFn' >, pageParam?: TPageParam, @@ -237,6 +249,13 @@ export class BaseDataService< return result.pages[pageIndex]; } + /** + * Invalidate queries serviced by this data service. + * + * @param filters - Optional filter for selecting specific queries. + * @param options - Additional optional options for query invalidations. + * @returns Nothing. + */ async invalidateQueries( filters?: InvalidateQueryFilters, options?: InvalidateOptions, @@ -244,7 +263,23 @@ export class BaseDataService< return this.#queryClient.invalidateQueries(filters, options); } - #broadcastCacheUpdate(hash: string, type: CacheUpdatedType): void { + /** + * Prepares the service for garbage collection. This should be extended + * by any subclasses to clean up any additional connections or events. + */ + protected destroy(): void { + this.#queryCacheUnsubscribe(); + this.messenger.clearSubscriptions(); + this.messenger.clearActions(); + } + + /** + * Publish `cacheUpdated` events when a given query changes. + * + * @param hash The hash of the query. + * @param type The type of cache update. + */ + #publishCacheUpdate(hash: string, type: CacheUpdatedType): void { const state = type === 'added' || type === 'updated' ? dehydrate(this.#queryClient, { diff --git a/packages/base-data-service/src/index.ts b/packages/base-data-service/src/index.ts index 15724e00df8..9375eec75b3 100644 --- a/packages/base-data-service/src/index.ts +++ b/packages/base-data-service/src/index.ts @@ -2,8 +2,6 @@ export type { CacheUpdatedPayload, GranularCacheUpdatedPayload, DataServiceInvalidateQueriesAction, - DataServiceActions, - DataServiceEvents, DataServiceCacheUpdatedEvent, DataServiceGranularCacheUpdatedEvent, QueryKey,