diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index df6ed594d36..152eb6bd816 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -1,5 +1,5 @@ import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; -import { hashQueryKey } from '@tanstack/query-core'; +import { hashKey } from '@tanstack/query-core'; import { BrokenCircuitError } from 'cockatiel'; import { cleanAll } from 'nock'; @@ -131,7 +131,7 @@ describe('BaseDataService', () => { const queryKey = ['ExampleDataService:getAssets', MOCK_ASSETS]; - const hash = hashQueryKey(queryKey); + const hash = hashKey(queryKey); expect(publishSpy).toHaveBeenNthCalledWith( 6, @@ -186,7 +186,7 @@ describe('BaseDataService', () => { const queryKey = ['ExampleDataService:getAssets', MOCK_ASSETS]; - const hash = hashQueryKey(queryKey); + const hash = hashKey(queryKey); expect(publishSpy).toHaveBeenNthCalledWith( 8, @@ -333,6 +333,7 @@ describe('BaseDataService', () => { state: { queries: [ { + dehydratedAt: expect.any(Number), queryHash: '["ExampleDataService:getAssets",["eip155:1/slip44:60","bip122:000000000019d6689c085ae165831e93/slip44:0","eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f"]]', queryKey: [ diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 7ac764fcad5..06c369fc782 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -13,14 +13,16 @@ import type { Json } from '@metamask/utils'; import { DefaultOptions, DehydratedState, - FetchInfiniteQueryOptions, FetchQueryOptions, + GetNextPageParamFunction, + GetPreviousPageParamFunction, InfiniteData, InvalidateOptions, InvalidateQueryFilters, OmitKeyof, QueryClient, QueryClientConfig, + QueryFunction, WithRequired, dehydrate, hydrate, @@ -54,7 +56,7 @@ type CacheUpdatedType = DataServiceCacheUpdatedPayload['type']; export type DataServiceInvalidateQueriesAction = { type: `${ServiceName}:invalidateQueries`; handler: ( - filters?: InvalidateQueryFilters, + filters?: InvalidateQueryFilters, options?: InvalidateOptions, ) => Promise; }; @@ -251,10 +253,14 @@ export class BaseDataService< options: WithRequired< OmitKeyof< FetchQueryOptions, - 'retry' | 'retryDelay' + 'retry' | 'retryDelay' | 'queryFn' >, - 'queryKey' | 'queryFn' - >, + 'queryKey' + > & { + // Data services always provide a concrete query function; the `skipToken` + // sentinel added in query-core v5 is not supported here. + queryFn: QueryFunction; + }, ): Promise { return this.#queryClient.fetchQuery({ ...options, @@ -280,22 +286,50 @@ export class BaseDataService< >( options: WithRequired< OmitKeyof< - FetchInfiniteQueryOptions, - 'retry' | 'retryDelay' + FetchQueryOptions< + TQueryFnData, + TError, + InfiniteData, + TQueryKey, + TPageParam + >, + 'retry' | 'retryDelay' | 'queryFn' | 'initialPageParam' >, - 'queryKey' | 'queryFn' - >, + 'queryKey' + > & { + // Data services always provide a concrete query function; the `skipToken` + // sentinel added in query-core v5 is not supported here. + queryFn: QueryFunction; + // These are required by query-core v5 for infinite queries but remain + // optional here: consumers may drive pagination purely by passing an + // explicit `pageParam` (see below). + initialPageParam?: TPageParam; + getNextPageParam?: GetNextPageParamFunction; + getPreviousPageParam?: GetPreviousPageParamFunction; + }, pageParam?: TPageParam, ): Promise { const cache = this.#queryClient.getQueryCache(); - const query = cache.find>({ - queryKey: options.queryKey, - }); + const query = cache.find>( + { + queryKey: options.queryKey, + }, + ); if (!query?.state.data || pageParam === undefined) { - const result = await this.#queryClient.fetchInfiniteQuery({ + const result = await this.#queryClient.fetchInfiniteQuery< + TQueryFnData, + TError, + TData, + TQueryKey, + TPageParam + >({ ...options, + // query-core v5 requires an `initialPageParam`. When the caller drives + // pagination with an explicit `pageParam`, use it as the initial param + // so the first (and only) page fetched is the requested one. + initialPageParam: (options.initialPageParam ?? pageParam) as TPageParam, queryFn: (context) => this.#policy.execute(() => options.queryFn({ @@ -308,19 +342,34 @@ export class BaseDataService< return result.pages[0]; } - const { pages } = query.state.data; - const previous = options.getPreviousPageParam?.(pages[0], pages); + const { pages, pageParams } = query.state.data; + const previous = options.getPreviousPageParam?.( + pages[0], + pages, + pageParams[0], + pageParams, + ); const direction = deepEqual(pageParam, previous) ? 'backward' : 'forward'; - const result = await query.fetch(undefined, { - meta: { - fetchMore: { - direction, - pageParam, + // query-core v5 no longer accepts an explicit page param via the `fetchMore` + // meta; it derives the next/previous param from these callbacks instead. + // Override them to return exactly the requested page so pagination works + // even when the consumer did not provide page-param callbacks. + const result = await query.fetch( + { + ...query.options, + getNextPageParam: () => pageParam, + getPreviousPageParam: () => pageParam, + } as typeof query.options, + { + meta: { + fetchMore: { + direction, + }, }, }, - }); + ); const pageIndex = result.pageParams.findIndex((param) => deepEqual(param, pageParam), @@ -337,7 +386,7 @@ export class BaseDataService< * @returns Nothing. */ async invalidateQueries( - filters?: InvalidateQueryFilters, + filters?: InvalidateQueryFilters, options?: InvalidateOptions, ): Promise { return this.#queryClient.invalidateQueries(filters, options); diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts index d84e9edf5d1..3f0b2a16ff3 100644 --- a/packages/base-data-service/tests/ExampleDataService.ts +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -101,7 +101,7 @@ export class ExampleDataService extends BaseDataService< return response.json(); }, staleTime: inMilliseconds(1, Duration.Day), - cacheTime: inMilliseconds(1, Duration.Day), + gcTime: inMilliseconds(1, Duration.Day), }); } @@ -109,7 +109,13 @@ export class ExampleDataService extends BaseDataService< address: string, page?: PageParam, ): Promise { - return this.fetchInfiniteQuery( + return this.fetchInfiniteQuery< + GetActivityResponse, + unknown, + GetActivityResponse, + [string, string], + PageParam + >( { queryKey: [`${this.name}:getActivity`, address], queryFn: async ({ pageParam }) => { diff --git a/packages/chomp-api-service/src/chomp-api-service.ts b/packages/chomp-api-service/src/chomp-api-service.ts index a8d400c1bc5..d3348fd7a6a 100644 --- a/packages/chomp-api-service/src/chomp-api-service.ts +++ b/packages/chomp-api-service/src/chomp-api-service.ts @@ -404,7 +404,7 @@ export class ChompApiService extends BaseDataService< * The result is scoped to the authenticated profile and consumers use it * to decide whether an association already exists, so it is always fetched * fresh (`staleTime: 0`) and evicted as soon as the call settles - * (`cacheTime: 0`). The query key carries a SHA-256 digest of the bearer + * (`gcTime: 0`). The query key carries a SHA-256 digest of the bearer * token — the same token the request is made with — so concurrent calls * only share an in-flight request when they are for the same profile. The * digest, not the token, is used because query keys leave the service via @@ -422,7 +422,7 @@ export class ChompApiService extends BaseDataService< const jsonResponse = await this.fetchQuery({ queryKey: [`${this.name}:getAssociatedAddresses`, profileKey], staleTime: 0, - cacheTime: 0, + gcTime: 0, queryFn: async () => { const response = await fetch( new URL('/v1/auth/address', this.#baseUrl),