From 0a000a642a19e9194f6d96c025d4f9160e4a8c0b Mon Sep 17 00:00:00 2001 From: Mark Stacey Date: Tue, 28 Jul 2026 15:38:44 -0230 Subject: [PATCH 01/10] chore: update `@tanstack/query-core` to v5 in remaining packages A recent PR updated this package to v5 in one package (#9563). This PR updates to v5 in all remaining packages. --- packages/base-data-service/package.json | 2 +- packages/chomp-api-service/package.json | 2 +- .../package.json | 2 +- packages/sample-controllers/package.json | 2 +- packages/sentinel-api-service/package.json | 2 +- packages/wallet-framework-docs/package.json | 2 +- yarn.config.cjs | 4 +--- yarn.lock | 19 ++++++------------- 8 files changed, 13 insertions(+), 22 deletions(-) diff --git a/packages/base-data-service/package.json b/packages/base-data-service/package.json index 32a4ca2d3a7..0ba3f0fb40c 100644 --- a/packages/base-data-service/package.json +++ b/packages/base-data-service/package.json @@ -59,7 +59,7 @@ "@metamask/messenger": "^2.0.0", "@metamask/storage-service": "^1.0.2", "@metamask/utils": "^11.11.0", - "@tanstack/query-core": "^4.43.0", + "@tanstack/query-core": "^5.62.16", "cockatiel": "^3.1.2", "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" diff --git a/packages/chomp-api-service/package.json b/packages/chomp-api-service/package.json index b5d79e415ba..cb3a0f15e8a 100644 --- a/packages/chomp-api-service/package.json +++ b/packages/chomp-api-service/package.json @@ -58,7 +58,7 @@ "@metamask/messenger": "^2.0.0", "@metamask/superstruct": "^3.1.0", "@metamask/utils": "^11.11.0", - "@tanstack/query-core": "^4.43.0" + "@tanstack/query-core": "^5.62.16" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", diff --git a/packages/money-account-api-data-service/package.json b/packages/money-account-api-data-service/package.json index ed792384533..2ff826dce82 100644 --- a/packages/money-account-api-data-service/package.json +++ b/packages/money-account-api-data-service/package.json @@ -60,7 +60,7 @@ "@metamask/messenger": "^2.0.0", "@metamask/superstruct": "^3.1.0", "@metamask/utils": "^11.11.0", - "@tanstack/query-core": "^4.43.0" + "@tanstack/query-core": "^5.62.16" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", diff --git a/packages/sample-controllers/package.json b/packages/sample-controllers/package.json index 25f00dd4560..339af99d42b 100644 --- a/packages/sample-controllers/package.json +++ b/packages/sample-controllers/package.json @@ -61,7 +61,7 @@ "@metamask/network-controller": "^35.0.1", "@metamask/superstruct": "^3.1.0", "@metamask/utils": "^11.11.0", - "@tanstack/query-core": "^4.43.0" + "@tanstack/query-core": "^5.62.16" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", diff --git a/packages/sentinel-api-service/package.json b/packages/sentinel-api-service/package.json index b41f3d14fcb..a5370148807 100644 --- a/packages/sentinel-api-service/package.json +++ b/packages/sentinel-api-service/package.json @@ -60,7 +60,7 @@ "@metamask/messenger": "^2.0.0", "@metamask/superstruct": "^3.1.0", "@metamask/utils": "^11.11.0", - "@tanstack/query-core": "^4.43.0" + "@tanstack/query-core": "^5.62.16" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", diff --git a/packages/wallet-framework-docs/package.json b/packages/wallet-framework-docs/package.json index 777d122ea05..1db2dc874d7 100644 --- a/packages/wallet-framework-docs/package.json +++ b/packages/wallet-framework-docs/package.json @@ -42,7 +42,7 @@ "@metamask/messenger": "^2.0.0", "@metamask/superstruct": "^3.1.0", "@metamask/utils": "^11.11.0", - "@tanstack/query-core": "^4.43.0" + "@tanstack/query-core": "^5.62.16" }, "devDependencies": { "@docusaurus/core": "^3.10.1", diff --git a/yarn.config.cjs b/yarn.config.cjs index 1e1dc0fbf90..2b55c2fd360 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -23,9 +23,7 @@ const { inspect } = require('util'); * Only intended as temporary measures to faciliate upgrades and releases. * This should trend towards empty. */ -const ALLOWED_INCONSISTENT_DEPENDENCIES = { - '@tanstack/query-core': ['^4.43.0'], -}; +const ALLOWED_INCONSISTENT_DEPENDENCIES = {}; /** * These packages are allowed as peer dependencies without requiring installation as diff --git a/yarn.lock b/yarn.lock index a5561d619e9..f2303d444b3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6212,7 +6212,7 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/storage-service": "npm:^1.0.2" "@metamask/utils": "npm:^11.11.0" - "@tanstack/query-core": "npm:^4.43.0" + "@tanstack/query-core": "npm:^5.62.16" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" "@types/lodash": "npm:^4.14.191" @@ -6395,7 +6395,7 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" - "@tanstack/query-core": "npm:^4.43.0" + "@tanstack/query-core": "npm:^5.62.16" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" @@ -7836,7 +7836,7 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" - "@tanstack/query-core": "npm:^4.43.0" + "@tanstack/query-core": "npm:^5.62.16" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" @@ -8782,7 +8782,7 @@ __metadata: "@metamask/network-controller": "npm:^35.0.1" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" - "@tanstack/query-core": "npm:^4.43.0" + "@tanstack/query-core": "npm:^5.62.16" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" @@ -8878,7 +8878,7 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" - "@tanstack/query-core": "npm:^4.43.0" + "@tanstack/query-core": "npm:^5.62.16" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" @@ -9516,7 +9516,7 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" - "@tanstack/query-core": "npm:^4.43.0" + "@tanstack/query-core": "npm:^5.62.16" "@types/jest": "npm:^30.0.0" "@types/react": "npm:^19.0.0" deepmerge: "npm:^4.2.2" @@ -11510,13 +11510,6 @@ __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/react-query@npm:^5.62.16": version: 5.101.2 resolution: "@tanstack/react-query@npm:5.101.2" From 37f8a9ae45bf7b30b256a363e71c8ed11906eebf Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 30 Jul 2026 14:45:43 +0100 Subject: [PATCH 02/10] fix(base-data-service): adapt to `@tanstack/query-core` v5 API The v5 bump updated the package.json versions but left the v4 API usage in place, so the build fails. This adapts the code to v5. * `invalidateQueries` filter: the generic is now the query key (not page data), so drop the `Json` argument * handle the new `skipToken` sentinel by typing `queryFn` as a concrete function (data services never use it) * `fetchInfiniteQuery`: pass `initialPageParam` and inject page param resolvers at fetch time, since v5 no longer accepts an explicit `pageParam` via `fetchMore` meta. This keeps cursor pagination working for consumers that do not define page param callbacks * chomp: `cacheTime` is now `gcTime` * tests: `hashQueryKey` is now `hashKey`, and `dehydrate` adds a `dehydratedAt` field --- .../src/BaseDataService.test.ts | 7 +- .../base-data-service/src/BaseDataService.ts | 93 ++++++++++++++----- .../tests/ExampleDataService.ts | 10 +- .../src/chomp-api-service.ts | 4 +- 4 files changed, 85 insertions(+), 29 deletions(-) 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 6c9b1146e19..e6738cfc00e 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), From 06e8e45f5b674c6abf6f8a264fabdb43bf529947 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 3 Aug 2026 13:23:53 +0100 Subject: [PATCH 03/10] fix: lint misc --- .../base-data-service/src/BaseDataService.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index e6738cfc00e..795f0be608e 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -305,17 +305,22 @@ export class BaseDataService< // explicit `pageParam` (see below). initialPageParam?: TPageParam; getNextPageParam?: GetNextPageParamFunction; - getPreviousPageParam?: GetPreviousPageParamFunction; + getPreviousPageParam?: GetPreviousPageParamFunction< + TPageParam, + TQueryFnData + >; }, pageParam?: TPageParam, ): Promise { const cache = this.#queryClient.getQueryCache(); - const query = cache.find>( - { - queryKey: options.queryKey, - }, - ); + const query = cache.find< + TQueryFnData, + TError, + InfiniteData + >({ + queryKey: options.queryKey, + }); if (!query?.state.data || pageParam === undefined) { const result = await this.#queryClient.fetchInfiniteQuery< From 473047f577f21ee264334934cd11bddc3ee61cad Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Mon, 3 Aug 2026 23:07:17 +0100 Subject: [PATCH 04/10] docs(changelog): add `@tanstack/query-core` v5 update entries --- packages/base-data-service/CHANGELOG.md | 2 ++ packages/chomp-api-service/CHANGELOG.md | 4 ++++ packages/money-account-api-data-service/CHANGELOG.md | 4 ++++ packages/sample-controllers/CHANGELOG.md | 1 + packages/sentinel-api-service/CHANGELOG.md | 4 ++++ 5 files changed, 15 insertions(+) diff --git a/packages/base-data-service/CHANGELOG.md b/packages/base-data-service/CHANGELOG.md index 87aa5230ac5..3038d44ed51 100644 --- a/packages/base-data-service/CHANGELOG.md +++ b/packages/base-data-service/CHANGELOG.md @@ -38,6 +38,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Remove `TPageData` type parameter from `invalidateQueries` method ([#9526](https://github.com/MetaMask/core/pull/9526)) - This is technically a breaking change, but this was not used in any of our codebases +- **BREAKING:** Update `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) + - The option types accepted by `fetchQuery`, `fetchInfiniteQuery`, and `invalidateQueries` now follow the query-core v5 API. Subclasses may need to rename `cacheTime` to `gcTime`, and infinite queries no longer accept an explicit page param through the `fetchMore` meta. - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) - Bump `@metamask/controller-utils` from `^12.1.0` to `^12.3.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) - Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) diff --git a/packages/chomp-api-service/CHANGELOG.md b/packages/chomp-api-service/CHANGELOG.md index b1a57a81648..64574db4dc9 100644 --- a/packages/chomp-api-service/CHANGELOG.md +++ b/packages/chomp-api-service/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Update `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) + ## [4.0.0] ### Added diff --git a/packages/money-account-api-data-service/CHANGELOG.md b/packages/money-account-api-data-service/CHANGELOG.md index 1c20eb5f817..159ede624e1 100644 --- a/packages/money-account-api-data-service/CHANGELOG.md +++ b/packages/money-account-api-data-service/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Update `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) + ## [0.4.0] ### Changed diff --git a/packages/sample-controllers/CHANGELOG.md b/packages/sample-controllers/CHANGELOG.md index 4b6d5957be5..e55c60c7647 100644 --- a/packages/sample-controllers/CHANGELOG.md +++ b/packages/sample-controllers/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Update `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) ## [5.0.4] diff --git a/packages/sentinel-api-service/CHANGELOG.md b/packages/sentinel-api-service/CHANGELOG.md index ecef2d16016..bba6b94d3aa 100644 --- a/packages/sentinel-api-service/CHANGELOG.md +++ b/packages/sentinel-api-service/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Update `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) + ## [1.0.0] ### Added From bf76bb25b8a1cb13a84dabf37218126ff7df647b Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 4 Aug 2026 11:13:36 +0100 Subject: [PATCH 05/10] docs(changelog): use `Bump` wording to match convention --- packages/base-data-service/CHANGELOG.md | 2 +- packages/chomp-api-service/CHANGELOG.md | 2 +- packages/money-account-api-data-service/CHANGELOG.md | 2 +- packages/sample-controllers/CHANGELOG.md | 2 +- packages/sentinel-api-service/CHANGELOG.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/base-data-service/CHANGELOG.md b/packages/base-data-service/CHANGELOG.md index 3038d44ed51..65c473ee126 100644 --- a/packages/base-data-service/CHANGELOG.md +++ b/packages/base-data-service/CHANGELOG.md @@ -38,7 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Remove `TPageData` type parameter from `invalidateQueries` method ([#9526](https://github.com/MetaMask/core/pull/9526)) - This is technically a breaking change, but this was not used in any of our codebases -- **BREAKING:** Update `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) +- **BREAKING:** Bump `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) - The option types accepted by `fetchQuery`, `fetchInfiniteQuery`, and `invalidateQueries` now follow the query-core v5 API. Subclasses may need to rename `cacheTime` to `gcTime`, and infinite queries no longer accept an explicit page param through the `fetchMore` meta. - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) - Bump `@metamask/controller-utils` from `^12.1.0` to `^12.3.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) diff --git a/packages/chomp-api-service/CHANGELOG.md b/packages/chomp-api-service/CHANGELOG.md index 64574db4dc9..527d342f4cc 100644 --- a/packages/chomp-api-service/CHANGELOG.md +++ b/packages/chomp-api-service/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Update `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) +- Bump `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) ## [4.0.0] diff --git a/packages/money-account-api-data-service/CHANGELOG.md b/packages/money-account-api-data-service/CHANGELOG.md index 159ede624e1..459ca3e6fcc 100644 --- a/packages/money-account-api-data-service/CHANGELOG.md +++ b/packages/money-account-api-data-service/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Update `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) +- Bump `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) ## [0.4.0] diff --git a/packages/sample-controllers/CHANGELOG.md b/packages/sample-controllers/CHANGELOG.md index e55c60c7647..ab146f19620 100644 --- a/packages/sample-controllers/CHANGELOG.md +++ b/packages/sample-controllers/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Update `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) +- Bump `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) ## [5.0.4] diff --git a/packages/sentinel-api-service/CHANGELOG.md b/packages/sentinel-api-service/CHANGELOG.md index bba6b94d3aa..171c3e96c56 100644 --- a/packages/sentinel-api-service/CHANGELOG.md +++ b/packages/sentinel-api-service/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Update `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) +- Bump `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) ## [1.0.0] From af6441dbcc813ea3d67e67c58eb1e26c69bbb07d Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 4 Aug 2026 12:08:09 +0100 Subject: [PATCH 06/10] fix(base-data-service): keep infinite query refetch safe without page param callbacks query-core v5 walks `getNextPageParam` when it refetches an infinite query that has more than one page cached. Consumers that paginate by explicit cursor without defining `getNextPageParam` (like `MoneyAccountApiDataService`) would throw once a second page was cached and a stale refetch with no page param ran. Default `getNextPageParam` to a resolver that returns `null` so the refetch rebuilds just the first page instead of throwing. The consumer repopulates the cache by navigating again with explicit page params. Also adds a `fetchInfiniteQuery` test suite covering forward and backward navigation with and without page param callbacks, single page returns, `staleTime` deduplication, and this refetch regression. Flagged by Cursor Bugbot on #9712. --- ...BaseDataService.fetchInfiniteQuery.test.ts | 294 ++++++++++++++++++ .../base-data-service/src/BaseDataService.ts | 7 + 2 files changed, 301 insertions(+) create mode 100644 packages/base-data-service/src/BaseDataService.fetchInfiniteQuery.test.ts diff --git a/packages/base-data-service/src/BaseDataService.fetchInfiniteQuery.test.ts b/packages/base-data-service/src/BaseDataService.fetchInfiniteQuery.test.ts new file mode 100644 index 00000000000..17ba17660ae --- /dev/null +++ b/packages/base-data-service/src/BaseDataService.fetchInfiniteQuery.test.ts @@ -0,0 +1,294 @@ +import { Messenger } from '@metamask/messenger'; +import { ConstantBackoff } from 'cockatiel'; + +import { + BaseDataService, + DataServiceCacheUpdatedEvent, + DataServiceGranularCacheUpdatedEvent, + DataServiceInvalidateQueriesAction, +} from './BaseDataService.js'; + +/** + * These tests exercise `fetchInfiniteQuery` in isolation, using an in-memory + * paginator instead of HTTP mocks. The point is to pin down the pagination + * behaviour that has to stay identical to query-core v4: fetching an arbitrary + * page by explicit page param, in both directions, whether or not the consumer + * provides `getNextPageParam` / `getPreviousPageParam`. + */ + +const serviceName = 'PaginatedService'; + +// A cursor encodes the offset of a page's first item. +type Cursor = string; +type PageParam = { after?: Cursor; before?: Cursor }; + +type Page = { + data: string[]; + pageInfo: { + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: Cursor | null; + endCursor: Cursor | null; + }; +}; + +const PAGE_SIZE = 3; +// 9 items => three pages: [item-0..2], [item-3..5], [item-6..8]. +const DATASET = Array.from({ length: 9 }, (_, index) => `item-${index}`); + +/** + * Serve a single page from the in-memory dataset. + * + * @param pageParam - The requested page. `undefined` or an `after`/`before` + * cursor. `after` fetches the page starting at the cursor offset; `before` + * fetches the page ending just before the cursor offset. + * @returns The requested page with cursor metadata. + */ +function fetchPage(pageParam?: PageParam): Page { + let offset = 0; + if (pageParam?.after !== undefined) { + offset = Number(pageParam.after); + } else if (pageParam?.before !== undefined) { + offset = Number(pageParam.before) - PAGE_SIZE; + } + + const data = DATASET.slice(offset, offset + PAGE_SIZE); + const hasNextPage = offset + PAGE_SIZE < DATASET.length; + const hasPreviousPage = offset > 0; + + return { + data, + pageInfo: { + hasNextPage, + hasPreviousPage, + startCursor: hasPreviousPage ? String(offset) : null, + endCursor: hasNextPage ? String(offset + PAGE_SIZE) : null, + }, + }; +} + +type PaginatedServiceActions = + DataServiceInvalidateQueriesAction; +type PaginatedServiceEvents = + | DataServiceCacheUpdatedEvent + | DataServiceGranularCacheUpdatedEvent; +type PaginatedServiceMessenger = Messenger< + typeof serviceName, + PaginatedServiceActions, + PaginatedServiceEvents +>; + +class PaginatedService extends BaseDataService< + typeof serviceName, + PaginatedServiceMessenger +> { + // Records every page param the query function is actually invoked with. + readonly queryFnCalls: (PageParam | undefined)[] = []; + + constructor( + messenger: PaginatedServiceMessenger, + { staleTime = Infinity }: { staleTime?: number } = {}, + ) { + super({ + name: serviceName, + messenger, + policyOptions: { maxRetries: 0, backoff: new ConstantBackoff(0) }, + }); + this.#staleTime = staleTime; + } + + readonly #staleTime: number; + + /** + * Paginate using page-param callbacks, the way a well-behaved consumer would. + * + * @param pageParam - The page to fetch. + * @returns The requested page. + */ + async withCallbacks(pageParam?: PageParam): Promise { + return this.fetchInfiniteQuery( + { + queryKey: [`${this.name}:withCallbacks`], + queryFn: async ({ pageParam: param }) => { + this.queryFnCalls.push(param); + return fetchPage(param); + }, + getNextPageParam: (lastPage) => + lastPage.pageInfo.hasNextPage && lastPage.pageInfo.endCursor + ? { after: lastPage.pageInfo.endCursor } + : undefined, + getPreviousPageParam: (firstPage) => + firstPage.pageInfo.hasPreviousPage && firstPage.pageInfo.startCursor + ? { before: firstPage.pageInfo.startCursor } + : undefined, + staleTime: this.#staleTime, + }, + pageParam, + ); + } + + /** + * Paginate without any page-param callbacks, relying purely on the explicit + * page param passed to the base method (the `MoneyAccountApiDataService` + * shape). + * + * @param pageParam - The page to fetch. + * @returns The requested page. + */ + async withoutCallbacks(pageParam?: PageParam): Promise { + return this.fetchInfiniteQuery( + { + queryKey: [`${this.name}:withoutCallbacks`], + queryFn: async ({ pageParam: param }) => { + this.queryFnCalls.push(param); + return fetchPage(param); + }, + staleTime: this.#staleTime, + }, + pageParam, + ); + } +} + +describe('BaseDataService: fetchInfiniteQuery', () => { + let service: PaginatedService; + + const createService = (options?: { + staleTime?: number; + }): PaginatedService => { + const messenger = new Messenger({ namespace: serviceName }); + service = new PaginatedService(messenger, options); + return service; + }; + + afterEach(() => { + service?.destroy(); + }); + + describe('with page-param callbacks', () => { + it('returns the first page on a cold fetch', async () => { + createService(); + + const page = await service.withCallbacks(); + + expect(page.data).toStrictEqual(['item-0', 'item-1', 'item-2']); + expect(page.pageInfo.hasPreviousPage).toBe(false); + }); + + it('jumps directly to a page by cursor on a cold cache', async () => { + createService(); + + const page = await service.withCallbacks({ after: '6' }); + + expect(page.data).toStrictEqual(['item-6', 'item-7', 'item-8']); + }); + + it('paginates forward across every page', async () => { + createService(); + + const page1 = await service.withCallbacks(); + const page2 = await service.withCallbacks({ + after: page1.pageInfo.endCursor as string, + }); + const page3 = await service.withCallbacks({ + after: page2.pageInfo.endCursor as string, + }); + + expect(page1.data).toStrictEqual(['item-0', 'item-1', 'item-2']); + expect(page2.data).toStrictEqual(['item-3', 'item-4', 'item-5']); + expect(page3.data).toStrictEqual(['item-6', 'item-7', 'item-8']); + }); + + it('paginates backward to the previous page', async () => { + createService(); + + // Start in the middle so there is a previous page to go back to. + const middle = await service.withCallbacks({ after: '3' }); + expect(middle.data).toStrictEqual(['item-3', 'item-4', 'item-5']); + + const previous = await service.withCallbacks({ + before: middle.pageInfo.startCursor as string, + }); + + expect(previous.data).toStrictEqual(['item-0', 'item-1', 'item-2']); + }); + + it('returns only the requested page, not the accumulated data', async () => { + createService(); + + await service.withCallbacks(); + await service.withCallbacks({ after: '3' }); + const page3 = await service.withCallbacks({ after: '6' }); + + expect(page3.data).toHaveLength(PAGE_SIZE); + expect(page3.data).toStrictEqual(['item-6', 'item-7', 'item-8']); + }); + + it('does not refetch a fresh cached page', async () => { + createService({ staleTime: Infinity }); + + await service.withCallbacks(); + await service.withCallbacks(); + + expect(service.queryFnCalls).toHaveLength(1); + }); + + it('keeps navigation correct after refetching stale pages', async () => { + createService({ staleTime: 0 }); + + await service.withCallbacks(); + await service.withCallbacks({ after: '3' }); + await service.withCallbacks({ after: '6' }); + + // A param-less call is stale, so query-core rebuilds all cached pages. + // This exercises the full-rebuild path, which must use the consumer's + // page-param callbacks and not any resolvers injected while paging. + const rebuilt = await service.withCallbacks(); + expect(rebuilt.data).toStrictEqual(['item-0', 'item-1', 'item-2']); + + const page2Again = await service.withCallbacks({ after: '3' }); + expect(page2Again.data).toStrictEqual(['item-3', 'item-4', 'item-5']); + }); + }); + + describe('without page-param callbacks', () => { + it('fetches an arbitrary page by explicit cursor (forward)', async () => { + createService(); + + const page1 = await service.withoutCallbacks(); + expect(page1.data).toStrictEqual(['item-0', 'item-1', 'item-2']); + + const page2 = await service.withoutCallbacks({ after: '3' }); + expect(page2.data).toStrictEqual(['item-3', 'item-4', 'item-5']); + }); + + it('fetches the correct page content for a `before` cursor', async () => { + createService(); + + // Cold jump into the middle, then ask for the page before it. + const middle = await service.withoutCallbacks({ after: '3' }); + expect(middle.data).toStrictEqual(['item-3', 'item-4', 'item-5']); + + const previous = await service.withoutCallbacks({ before: '3' }); + expect(previous.data).toStrictEqual(['item-0', 'item-1', 'item-2']); + }); + + it('refetches stale multi-page state without page-param callbacks', async () => { + createService({ staleTime: 0 }); + + await service.withoutCallbacks(); + await service.withoutCallbacks({ after: '3' }); + await service.withoutCallbacks({ after: '6' }); + + // Stale, so query-core rebuilds every cached page by walking forward from + // the first one. With no consumer `getNextPageParam`, that walk must not + // throw (the base service supplies a no-op resolver). + const rebuilt = await service.withoutCallbacks(); + expect(rebuilt.data).toStrictEqual(['item-0', 'item-1', 'item-2']); + + // Navigation still works after the rebuild. + const page2Again = await service.withoutCallbacks({ after: '3' }); + expect(page2Again.data).toStrictEqual(['item-3', 'item-4', 'item-5']); + }); + }); +}); diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 795f0be608e..3e2f6cccf6c 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -335,6 +335,13 @@ export class BaseDataService< // 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, + // Provide a no-op `getNextPageParam` when the consumer omits one. + // query-core v5 walks `getNextPageParam` when it refetches a multi-page + // infinite query, so a missing resolver would throw once more than one + // page has been cached. The no-op rebuilds the cache down to the first + // page; the consumer repopulates it by re-navigating with explicit + // page params. + getNextPageParam: options.getNextPageParam ?? ((): null => null), queryFn: (context) => this.#policy.execute(() => options.queryFn({ From b802b77995356a12b795bdbeca39caa74bef5d78 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 4 Aug 2026 12:14:56 +0100 Subject: [PATCH 07/10] style(base-data-service): fix formatting in fetchInfiniteQuery test --- .../src/BaseDataService.fetchInfiniteQuery.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.fetchInfiniteQuery.test.ts b/packages/base-data-service/src/BaseDataService.fetchInfiniteQuery.test.ts index 17ba17660ae..5bb9e50e4d7 100644 --- a/packages/base-data-service/src/BaseDataService.fetchInfiniteQuery.test.ts +++ b/packages/base-data-service/src/BaseDataService.fetchInfiniteQuery.test.ts @@ -67,8 +67,9 @@ function fetchPage(pageParam?: PageParam): Page { }; } -type PaginatedServiceActions = - DataServiceInvalidateQueriesAction; +type PaginatedServiceActions = DataServiceInvalidateQueriesAction< + typeof serviceName +>; type PaginatedServiceEvents = | DataServiceCacheUpdatedEvent | DataServiceGranularCacheUpdatedEvent; From 5a1dcce2a853e683f575097273bf7f4ead6d1bbb Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 4 Aug 2026 12:24:34 +0100 Subject: [PATCH 08/10] test(base-data-service): use withService pattern in fetchInfiniteQuery tests --- ...BaseDataService.fetchInfiniteQuery.test.ts | 215 ++++++++++-------- 1 file changed, 119 insertions(+), 96 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.fetchInfiniteQuery.test.ts b/packages/base-data-service/src/BaseDataService.fetchInfiniteQuery.test.ts index 5bb9e50e4d7..be41448f979 100644 --- a/packages/base-data-service/src/BaseDataService.fetchInfiniteQuery.test.ts +++ b/packages/base-data-service/src/BaseDataService.fetchInfiniteQuery.test.ts @@ -151,145 +151,168 @@ class PaginatedService extends BaseDataService< } } -describe('BaseDataService: fetchInfiniteQuery', () => { - let service: PaginatedService; - - const createService = (options?: { - staleTime?: number; - }): PaginatedService => { - const messenger = new Messenger({ namespace: serviceName }); - service = new PaginatedService(messenger, options); - return service; - }; +/** + * The options bag that `withService` takes. + */ +type WithServiceOptions = { + staleTime?: number; +}; - afterEach(() => { - service?.destroy(); - }); +type WithServiceCallback = (payload: { + service: PaginatedService; + messenger: PaginatedServiceMessenger; +}) => Promise | ReturnValue; +/** + * Construct a `PaginatedService`, pass it to the given function, and tear it + * down afterward. + * + * @param args - Either a function, or an options bag + a function. The options + * bag configures the service (currently just `staleTime`). The function is + * called with the new service and its messenger. + * @returns The same return value as the given function. + */ +async function withService( + ...args: + | [WithServiceCallback] + | [WithServiceOptions, WithServiceCallback] +): Promise { + const [{ staleTime }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + const messenger = new Messenger({ namespace: serviceName }); + const service = new PaginatedService(messenger, { staleTime }); + try { + return await testFunction({ service, messenger }); + } finally { + service.destroy(); + } +} + +describe('BaseDataService: fetchInfiniteQuery', () => { describe('with page-param callbacks', () => { it('returns the first page on a cold fetch', async () => { - createService(); + await withService(async ({ service }) => { + const page = await service.withCallbacks(); - const page = await service.withCallbacks(); - - expect(page.data).toStrictEqual(['item-0', 'item-1', 'item-2']); - expect(page.pageInfo.hasPreviousPage).toBe(false); + expect(page.data).toStrictEqual(['item-0', 'item-1', 'item-2']); + expect(page.pageInfo.hasPreviousPage).toBe(false); + }); }); it('jumps directly to a page by cursor on a cold cache', async () => { - createService(); + await withService(async ({ service }) => { + const page = await service.withCallbacks({ after: '6' }); - const page = await service.withCallbacks({ after: '6' }); - - expect(page.data).toStrictEqual(['item-6', 'item-7', 'item-8']); + expect(page.data).toStrictEqual(['item-6', 'item-7', 'item-8']); + }); }); it('paginates forward across every page', async () => { - createService(); - - const page1 = await service.withCallbacks(); - const page2 = await service.withCallbacks({ - after: page1.pageInfo.endCursor as string, - }); - const page3 = await service.withCallbacks({ - after: page2.pageInfo.endCursor as string, + await withService(async ({ service }) => { + const page1 = await service.withCallbacks(); + const page2 = await service.withCallbacks({ + after: page1.pageInfo.endCursor as string, + }); + const page3 = await service.withCallbacks({ + after: page2.pageInfo.endCursor as string, + }); + + expect(page1.data).toStrictEqual(['item-0', 'item-1', 'item-2']); + expect(page2.data).toStrictEqual(['item-3', 'item-4', 'item-5']); + expect(page3.data).toStrictEqual(['item-6', 'item-7', 'item-8']); }); - - expect(page1.data).toStrictEqual(['item-0', 'item-1', 'item-2']); - expect(page2.data).toStrictEqual(['item-3', 'item-4', 'item-5']); - expect(page3.data).toStrictEqual(['item-6', 'item-7', 'item-8']); }); it('paginates backward to the previous page', async () => { - createService(); + await withService(async ({ service }) => { + // Start in the middle so there is a previous page to go back to. + const middle = await service.withCallbacks({ after: '3' }); + expect(middle.data).toStrictEqual(['item-3', 'item-4', 'item-5']); - // Start in the middle so there is a previous page to go back to. - const middle = await service.withCallbacks({ after: '3' }); - expect(middle.data).toStrictEqual(['item-3', 'item-4', 'item-5']); + const previous = await service.withCallbacks({ + before: middle.pageInfo.startCursor as string, + }); - const previous = await service.withCallbacks({ - before: middle.pageInfo.startCursor as string, + expect(previous.data).toStrictEqual(['item-0', 'item-1', 'item-2']); }); - - expect(previous.data).toStrictEqual(['item-0', 'item-1', 'item-2']); }); it('returns only the requested page, not the accumulated data', async () => { - createService(); - - await service.withCallbacks(); - await service.withCallbacks({ after: '3' }); - const page3 = await service.withCallbacks({ after: '6' }); + await withService(async ({ service }) => { + await service.withCallbacks(); + await service.withCallbacks({ after: '3' }); + const page3 = await service.withCallbacks({ after: '6' }); - expect(page3.data).toHaveLength(PAGE_SIZE); - expect(page3.data).toStrictEqual(['item-6', 'item-7', 'item-8']); + expect(page3.data).toHaveLength(PAGE_SIZE); + expect(page3.data).toStrictEqual(['item-6', 'item-7', 'item-8']); + }); }); it('does not refetch a fresh cached page', async () => { - createService({ staleTime: Infinity }); - - await service.withCallbacks(); - await service.withCallbacks(); + await withService({ staleTime: Infinity }, async ({ service }) => { + await service.withCallbacks(); + await service.withCallbacks(); - expect(service.queryFnCalls).toHaveLength(1); + expect(service.queryFnCalls).toHaveLength(1); + }); }); it('keeps navigation correct after refetching stale pages', async () => { - createService({ staleTime: 0 }); - - await service.withCallbacks(); - await service.withCallbacks({ after: '3' }); - await service.withCallbacks({ after: '6' }); - - // A param-less call is stale, so query-core rebuilds all cached pages. - // This exercises the full-rebuild path, which must use the consumer's - // page-param callbacks and not any resolvers injected while paging. - const rebuilt = await service.withCallbacks(); - expect(rebuilt.data).toStrictEqual(['item-0', 'item-1', 'item-2']); - - const page2Again = await service.withCallbacks({ after: '3' }); - expect(page2Again.data).toStrictEqual(['item-3', 'item-4', 'item-5']); + await withService({ staleTime: 0 }, async ({ service }) => { + await service.withCallbacks(); + await service.withCallbacks({ after: '3' }); + await service.withCallbacks({ after: '6' }); + + // A param-less call is stale, so query-core rebuilds all cached pages. + // This exercises the full-rebuild path, which must use the consumer's + // page-param callbacks and not any resolvers injected while paging. + const rebuilt = await service.withCallbacks(); + expect(rebuilt.data).toStrictEqual(['item-0', 'item-1', 'item-2']); + + const page2Again = await service.withCallbacks({ after: '3' }); + expect(page2Again.data).toStrictEqual(['item-3', 'item-4', 'item-5']); + }); }); }); describe('without page-param callbacks', () => { it('fetches an arbitrary page by explicit cursor (forward)', async () => { - createService(); - - const page1 = await service.withoutCallbacks(); - expect(page1.data).toStrictEqual(['item-0', 'item-1', 'item-2']); + await withService(async ({ service }) => { + const page1 = await service.withoutCallbacks(); + expect(page1.data).toStrictEqual(['item-0', 'item-1', 'item-2']); - const page2 = await service.withoutCallbacks({ after: '3' }); - expect(page2.data).toStrictEqual(['item-3', 'item-4', 'item-5']); + const page2 = await service.withoutCallbacks({ after: '3' }); + expect(page2.data).toStrictEqual(['item-3', 'item-4', 'item-5']); + }); }); it('fetches the correct page content for a `before` cursor', async () => { - createService(); + await withService(async ({ service }) => { + // Cold jump into the middle, then ask for the page before it. + const middle = await service.withoutCallbacks({ after: '3' }); + expect(middle.data).toStrictEqual(['item-3', 'item-4', 'item-5']); - // Cold jump into the middle, then ask for the page before it. - const middle = await service.withoutCallbacks({ after: '3' }); - expect(middle.data).toStrictEqual(['item-3', 'item-4', 'item-5']); - - const previous = await service.withoutCallbacks({ before: '3' }); - expect(previous.data).toStrictEqual(['item-0', 'item-1', 'item-2']); + const previous = await service.withoutCallbacks({ before: '3' }); + expect(previous.data).toStrictEqual(['item-0', 'item-1', 'item-2']); + }); }); it('refetches stale multi-page state without page-param callbacks', async () => { - createService({ staleTime: 0 }); - - await service.withoutCallbacks(); - await service.withoutCallbacks({ after: '3' }); - await service.withoutCallbacks({ after: '6' }); - - // Stale, so query-core rebuilds every cached page by walking forward from - // the first one. With no consumer `getNextPageParam`, that walk must not - // throw (the base service supplies a no-op resolver). - const rebuilt = await service.withoutCallbacks(); - expect(rebuilt.data).toStrictEqual(['item-0', 'item-1', 'item-2']); - - // Navigation still works after the rebuild. - const page2Again = await service.withoutCallbacks({ after: '3' }); - expect(page2Again.data).toStrictEqual(['item-3', 'item-4', 'item-5']); + await withService({ staleTime: 0 }, async ({ service }) => { + await service.withoutCallbacks(); + await service.withoutCallbacks({ after: '3' }); + await service.withoutCallbacks({ after: '6' }); + + // Stale, so query-core rebuilds every cached page by walking forward + // from the first one. With no consumer `getNextPageParam`, that walk + // must not throw (the base service supplies a no-op resolver). + const rebuilt = await service.withoutCallbacks(); + expect(rebuilt.data).toStrictEqual(['item-0', 'item-1', 'item-2']); + + // Navigation still works after the rebuild. + const page2Again = await service.withoutCallbacks({ after: '3' }); + expect(page2Again.data).toStrictEqual(['item-3', 'item-4', 'item-5']); + }); }); }); }); From c40fdbdf86b1422e786f0a04b2187fdfc5668b21 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 4 Aug 2026 14:30:53 +0100 Subject: [PATCH 09/10] test(base-data-service): collapse fetchInfiniteQuery tests into BaseDataService.test.ts --- ...BaseDataService.fetchInfiniteQuery.test.ts | 318 ----------------- .../src/BaseDataService.test.ts | 324 +++++++++++++++++- 2 files changed, 322 insertions(+), 320 deletions(-) delete mode 100644 packages/base-data-service/src/BaseDataService.fetchInfiniteQuery.test.ts diff --git a/packages/base-data-service/src/BaseDataService.fetchInfiniteQuery.test.ts b/packages/base-data-service/src/BaseDataService.fetchInfiniteQuery.test.ts deleted file mode 100644 index be41448f979..00000000000 --- a/packages/base-data-service/src/BaseDataService.fetchInfiniteQuery.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { Messenger } from '@metamask/messenger'; -import { ConstantBackoff } from 'cockatiel'; - -import { - BaseDataService, - DataServiceCacheUpdatedEvent, - DataServiceGranularCacheUpdatedEvent, - DataServiceInvalidateQueriesAction, -} from './BaseDataService.js'; - -/** - * These tests exercise `fetchInfiniteQuery` in isolation, using an in-memory - * paginator instead of HTTP mocks. The point is to pin down the pagination - * behaviour that has to stay identical to query-core v4: fetching an arbitrary - * page by explicit page param, in both directions, whether or not the consumer - * provides `getNextPageParam` / `getPreviousPageParam`. - */ - -const serviceName = 'PaginatedService'; - -// A cursor encodes the offset of a page's first item. -type Cursor = string; -type PageParam = { after?: Cursor; before?: Cursor }; - -type Page = { - data: string[]; - pageInfo: { - hasNextPage: boolean; - hasPreviousPage: boolean; - startCursor: Cursor | null; - endCursor: Cursor | null; - }; -}; - -const PAGE_SIZE = 3; -// 9 items => three pages: [item-0..2], [item-3..5], [item-6..8]. -const DATASET = Array.from({ length: 9 }, (_, index) => `item-${index}`); - -/** - * Serve a single page from the in-memory dataset. - * - * @param pageParam - The requested page. `undefined` or an `after`/`before` - * cursor. `after` fetches the page starting at the cursor offset; `before` - * fetches the page ending just before the cursor offset. - * @returns The requested page with cursor metadata. - */ -function fetchPage(pageParam?: PageParam): Page { - let offset = 0; - if (pageParam?.after !== undefined) { - offset = Number(pageParam.after); - } else if (pageParam?.before !== undefined) { - offset = Number(pageParam.before) - PAGE_SIZE; - } - - const data = DATASET.slice(offset, offset + PAGE_SIZE); - const hasNextPage = offset + PAGE_SIZE < DATASET.length; - const hasPreviousPage = offset > 0; - - return { - data, - pageInfo: { - hasNextPage, - hasPreviousPage, - startCursor: hasPreviousPage ? String(offset) : null, - endCursor: hasNextPage ? String(offset + PAGE_SIZE) : null, - }, - }; -} - -type PaginatedServiceActions = DataServiceInvalidateQueriesAction< - typeof serviceName ->; -type PaginatedServiceEvents = - | DataServiceCacheUpdatedEvent - | DataServiceGranularCacheUpdatedEvent; -type PaginatedServiceMessenger = Messenger< - typeof serviceName, - PaginatedServiceActions, - PaginatedServiceEvents ->; - -class PaginatedService extends BaseDataService< - typeof serviceName, - PaginatedServiceMessenger -> { - // Records every page param the query function is actually invoked with. - readonly queryFnCalls: (PageParam | undefined)[] = []; - - constructor( - messenger: PaginatedServiceMessenger, - { staleTime = Infinity }: { staleTime?: number } = {}, - ) { - super({ - name: serviceName, - messenger, - policyOptions: { maxRetries: 0, backoff: new ConstantBackoff(0) }, - }); - this.#staleTime = staleTime; - } - - readonly #staleTime: number; - - /** - * Paginate using page-param callbacks, the way a well-behaved consumer would. - * - * @param pageParam - The page to fetch. - * @returns The requested page. - */ - async withCallbacks(pageParam?: PageParam): Promise { - return this.fetchInfiniteQuery( - { - queryKey: [`${this.name}:withCallbacks`], - queryFn: async ({ pageParam: param }) => { - this.queryFnCalls.push(param); - return fetchPage(param); - }, - getNextPageParam: (lastPage) => - lastPage.pageInfo.hasNextPage && lastPage.pageInfo.endCursor - ? { after: lastPage.pageInfo.endCursor } - : undefined, - getPreviousPageParam: (firstPage) => - firstPage.pageInfo.hasPreviousPage && firstPage.pageInfo.startCursor - ? { before: firstPage.pageInfo.startCursor } - : undefined, - staleTime: this.#staleTime, - }, - pageParam, - ); - } - - /** - * Paginate without any page-param callbacks, relying purely on the explicit - * page param passed to the base method (the `MoneyAccountApiDataService` - * shape). - * - * @param pageParam - The page to fetch. - * @returns The requested page. - */ - async withoutCallbacks(pageParam?: PageParam): Promise { - return this.fetchInfiniteQuery( - { - queryKey: [`${this.name}:withoutCallbacks`], - queryFn: async ({ pageParam: param }) => { - this.queryFnCalls.push(param); - return fetchPage(param); - }, - staleTime: this.#staleTime, - }, - pageParam, - ); - } -} - -/** - * The options bag that `withService` takes. - */ -type WithServiceOptions = { - staleTime?: number; -}; - -type WithServiceCallback = (payload: { - service: PaginatedService; - messenger: PaginatedServiceMessenger; -}) => Promise | ReturnValue; - -/** - * Construct a `PaginatedService`, pass it to the given function, and tear it - * down afterward. - * - * @param args - Either a function, or an options bag + a function. The options - * bag configures the service (currently just `staleTime`). The function is - * called with the new service and its messenger. - * @returns The same return value as the given function. - */ -async function withService( - ...args: - | [WithServiceCallback] - | [WithServiceOptions, WithServiceCallback] -): Promise { - const [{ staleTime }, testFunction] = - args.length === 2 ? args : [{}, args[0]]; - const messenger = new Messenger({ namespace: serviceName }); - const service = new PaginatedService(messenger, { staleTime }); - try { - return await testFunction({ service, messenger }); - } finally { - service.destroy(); - } -} - -describe('BaseDataService: fetchInfiniteQuery', () => { - describe('with page-param callbacks', () => { - it('returns the first page on a cold fetch', async () => { - await withService(async ({ service }) => { - const page = await service.withCallbacks(); - - expect(page.data).toStrictEqual(['item-0', 'item-1', 'item-2']); - expect(page.pageInfo.hasPreviousPage).toBe(false); - }); - }); - - it('jumps directly to a page by cursor on a cold cache', async () => { - await withService(async ({ service }) => { - const page = await service.withCallbacks({ after: '6' }); - - expect(page.data).toStrictEqual(['item-6', 'item-7', 'item-8']); - }); - }); - - it('paginates forward across every page', async () => { - await withService(async ({ service }) => { - const page1 = await service.withCallbacks(); - const page2 = await service.withCallbacks({ - after: page1.pageInfo.endCursor as string, - }); - const page3 = await service.withCallbacks({ - after: page2.pageInfo.endCursor as string, - }); - - expect(page1.data).toStrictEqual(['item-0', 'item-1', 'item-2']); - expect(page2.data).toStrictEqual(['item-3', 'item-4', 'item-5']); - expect(page3.data).toStrictEqual(['item-6', 'item-7', 'item-8']); - }); - }); - - it('paginates backward to the previous page', async () => { - await withService(async ({ service }) => { - // Start in the middle so there is a previous page to go back to. - const middle = await service.withCallbacks({ after: '3' }); - expect(middle.data).toStrictEqual(['item-3', 'item-4', 'item-5']); - - const previous = await service.withCallbacks({ - before: middle.pageInfo.startCursor as string, - }); - - expect(previous.data).toStrictEqual(['item-0', 'item-1', 'item-2']); - }); - }); - - it('returns only the requested page, not the accumulated data', async () => { - await withService(async ({ service }) => { - await service.withCallbacks(); - await service.withCallbacks({ after: '3' }); - const page3 = await service.withCallbacks({ after: '6' }); - - expect(page3.data).toHaveLength(PAGE_SIZE); - expect(page3.data).toStrictEqual(['item-6', 'item-7', 'item-8']); - }); - }); - - it('does not refetch a fresh cached page', async () => { - await withService({ staleTime: Infinity }, async ({ service }) => { - await service.withCallbacks(); - await service.withCallbacks(); - - expect(service.queryFnCalls).toHaveLength(1); - }); - }); - - it('keeps navigation correct after refetching stale pages', async () => { - await withService({ staleTime: 0 }, async ({ service }) => { - await service.withCallbacks(); - await service.withCallbacks({ after: '3' }); - await service.withCallbacks({ after: '6' }); - - // A param-less call is stale, so query-core rebuilds all cached pages. - // This exercises the full-rebuild path, which must use the consumer's - // page-param callbacks and not any resolvers injected while paging. - const rebuilt = await service.withCallbacks(); - expect(rebuilt.data).toStrictEqual(['item-0', 'item-1', 'item-2']); - - const page2Again = await service.withCallbacks({ after: '3' }); - expect(page2Again.data).toStrictEqual(['item-3', 'item-4', 'item-5']); - }); - }); - }); - - describe('without page-param callbacks', () => { - it('fetches an arbitrary page by explicit cursor (forward)', async () => { - await withService(async ({ service }) => { - const page1 = await service.withoutCallbacks(); - expect(page1.data).toStrictEqual(['item-0', 'item-1', 'item-2']); - - const page2 = await service.withoutCallbacks({ after: '3' }); - expect(page2.data).toStrictEqual(['item-3', 'item-4', 'item-5']); - }); - }); - - it('fetches the correct page content for a `before` cursor', async () => { - await withService(async ({ service }) => { - // Cold jump into the middle, then ask for the page before it. - const middle = await service.withoutCallbacks({ after: '3' }); - expect(middle.data).toStrictEqual(['item-3', 'item-4', 'item-5']); - - const previous = await service.withoutCallbacks({ before: '3' }); - expect(previous.data).toStrictEqual(['item-0', 'item-1', 'item-2']); - }); - }); - - it('refetches stale multi-page state without page-param callbacks', async () => { - await withService({ staleTime: 0 }, async ({ service }) => { - await service.withoutCallbacks(); - await service.withoutCallbacks({ after: '3' }); - await service.withoutCallbacks({ after: '6' }); - - // Stale, so query-core rebuilds every cached page by walking forward - // from the first one. With no consumer `getNextPageParam`, that walk - // must not throw (the base service supplies a no-op resolver). - const rebuilt = await service.withoutCallbacks(); - expect(rebuilt.data).toStrictEqual(['item-0', 'item-1', 'item-2']); - - // Navigation still works after the rebuild. - const page2Again = await service.withoutCallbacks({ after: '3' }); - expect(page2Again.data).toStrictEqual(['item-3', 'item-4', 'item-5']); - }); - }); - }); -}); diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 152eb6bd816..e604553978e 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -1,6 +1,6 @@ import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; import { hashKey } from '@tanstack/query-core'; -import { BrokenCircuitError } from 'cockatiel'; +import { BrokenCircuitError, ConstantBackoff } from 'cockatiel'; import { cleanAll } from 'nock'; import { @@ -15,7 +15,13 @@ import { TRANSACTIONS_PAGE_2_CURSOR, TRANSACTIONS_PAGE_3_CURSOR, } from '../tests/mocks.js'; -import { STORAGE_SERVICE_KEY } from './BaseDataService.js'; +import { + BaseDataService, + DataServiceCacheUpdatedEvent, + DataServiceGranularCacheUpdatedEvent, + DataServiceInvalidateQueriesAction, + STORAGE_SERVICE_KEY, +} from './BaseDataService.js'; const TEST_ADDRESS = '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520'; @@ -25,6 +31,183 @@ const MOCK_ASSETS = [ 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', ]; +// --- `fetchInfiniteQuery` test harness ------------------------------------- +// An in-memory paginator plus a service that exposes `fetchInfiniteQuery` both +// with and without page-param callbacks, used to pin down the pagination +// behaviour that must stay identical to query-core v4. + +const paginatedServiceName = 'PaginatedService'; + +// A cursor encodes the offset of a page's first item. +type Cursor = string; +type PageParam = { after?: Cursor; before?: Cursor }; + +type Page = { + data: string[]; + pageInfo: { + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor: Cursor | null; + endCursor: Cursor | null; + }; +}; + +const PAGE_SIZE = 3; +// 9 items => three pages: [item-0..2], [item-3..5], [item-6..8]. +const DATASET = Array.from({ length: 9 }, (_, index) => `item-${index}`); + +/** + * Serve a single page from the in-memory dataset. + * + * @param pageParam - The requested page. `undefined` or an `after`/`before` + * cursor. `after` fetches the page starting at the cursor offset; `before` + * fetches the page ending just before the cursor offset. + * @returns The requested page with cursor metadata. + */ +function fetchPage(pageParam?: PageParam): Page { + let offset = 0; + if (pageParam?.after !== undefined) { + offset = Number(pageParam.after); + } else if (pageParam?.before !== undefined) { + offset = Number(pageParam.before) - PAGE_SIZE; + } + + const data = DATASET.slice(offset, offset + PAGE_SIZE); + const hasNextPage = offset + PAGE_SIZE < DATASET.length; + const hasPreviousPage = offset > 0; + + return { + data, + pageInfo: { + hasNextPage, + hasPreviousPage, + startCursor: hasPreviousPage ? String(offset) : null, + endCursor: hasNextPage ? String(offset + PAGE_SIZE) : null, + }, + }; +} + +type PaginatedServiceActions = DataServiceInvalidateQueriesAction< + typeof paginatedServiceName +>; +type PaginatedServiceEvents = + | DataServiceCacheUpdatedEvent + | DataServiceGranularCacheUpdatedEvent; +type PaginatedServiceMessenger = Messenger< + typeof paginatedServiceName, + PaginatedServiceActions, + PaginatedServiceEvents +>; + +class PaginatedService extends BaseDataService< + typeof paginatedServiceName, + PaginatedServiceMessenger +> { + // Records every page param the query function is actually invoked with. + readonly queryFnCalls: (PageParam | undefined)[] = []; + + constructor( + messenger: PaginatedServiceMessenger, + { staleTime = Infinity }: { staleTime?: number } = {}, + ) { + super({ + name: paginatedServiceName, + messenger, + policyOptions: { maxRetries: 0, backoff: new ConstantBackoff(0) }, + }); + this.#staleTime = staleTime; + } + + readonly #staleTime: number; + + /** + * Paginate using page-param callbacks, the way a well-behaved consumer would. + * + * @param pageParam - The page to fetch. + * @returns The requested page. + */ + async withCallbacks(pageParam?: PageParam): Promise { + return this.fetchInfiniteQuery( + { + queryKey: [`${this.name}:withCallbacks`], + queryFn: async ({ pageParam: param }) => { + this.queryFnCalls.push(param); + return fetchPage(param); + }, + getNextPageParam: (lastPage) => + lastPage.pageInfo.hasNextPage && lastPage.pageInfo.endCursor + ? { after: lastPage.pageInfo.endCursor } + : undefined, + getPreviousPageParam: (firstPage) => + firstPage.pageInfo.hasPreviousPage && firstPage.pageInfo.startCursor + ? { before: firstPage.pageInfo.startCursor } + : undefined, + staleTime: this.#staleTime, + }, + pageParam, + ); + } + + /** + * Paginate without any page-param callbacks, relying purely on the explicit + * page param passed to the base method (the `MoneyAccountApiDataService` + * shape). + * + * @param pageParam - The page to fetch. + * @returns The requested page. + */ + async withoutCallbacks(pageParam?: PageParam): Promise { + return this.fetchInfiniteQuery( + { + queryKey: [`${this.name}:withoutCallbacks`], + queryFn: async ({ pageParam: param }) => { + this.queryFnCalls.push(param); + return fetchPage(param); + }, + staleTime: this.#staleTime, + }, + pageParam, + ); + } +} + +/** + * The options bag that `withService` takes. + */ +type WithServiceOptions = { + staleTime?: number; +}; + +type WithServiceCallback = (payload: { + service: PaginatedService; + messenger: PaginatedServiceMessenger; +}) => Promise | ReturnValue; + +/** + * Construct a `PaginatedService`, pass it to the given function, and tear it + * down afterward. + * + * @param args - Either a function, or an options bag + a function. The options + * bag configures the service (currently just `staleTime`). The function is + * called with the new service and its messenger. + * @returns The same return value as the given function. + */ +async function withService( + ...args: + | [WithServiceCallback] + | [WithServiceOptions, WithServiceCallback] +): Promise { + const [{ staleTime }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + const messenger = new Messenger({ namespace: paginatedServiceName }); + const service = new PaginatedService(messenger, { staleTime }); + try { + return await testFunction({ service, messenger }); + } finally { + service.destroy(); + } +} + describe('BaseDataService', () => { beforeAll(() => { jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); @@ -628,4 +811,141 @@ describe('BaseDataService', () => { expect(publishSpy).not.toHaveBeenCalled(); }); }); + + describe('fetchInfiniteQuery', () => { + beforeAll(() => { + jest.useRealTimers(); + }); + + afterAll(() => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); + }); + + describe('with page-param callbacks', () => { + it('returns the first page on a cold fetch', async () => { + await withService(async ({ service }) => { + const page = await service.withCallbacks(); + + expect(page.data).toStrictEqual(['item-0', 'item-1', 'item-2']); + expect(page.pageInfo.hasPreviousPage).toBe(false); + }); + }); + + it('jumps directly to a page by cursor on a cold cache', async () => { + await withService(async ({ service }) => { + const page = await service.withCallbacks({ after: '6' }); + + expect(page.data).toStrictEqual(['item-6', 'item-7', 'item-8']); + }); + }); + + it('paginates forward across every page', async () => { + await withService(async ({ service }) => { + const page1 = await service.withCallbacks(); + const page2 = await service.withCallbacks({ + after: page1.pageInfo.endCursor as string, + }); + const page3 = await service.withCallbacks({ + after: page2.pageInfo.endCursor as string, + }); + + expect(page1.data).toStrictEqual(['item-0', 'item-1', 'item-2']); + expect(page2.data).toStrictEqual(['item-3', 'item-4', 'item-5']); + expect(page3.data).toStrictEqual(['item-6', 'item-7', 'item-8']); + }); + }); + + it('paginates backward to the previous page', async () => { + await withService(async ({ service }) => { + // Start in the middle so there is a previous page to go back to. + const middle = await service.withCallbacks({ after: '3' }); + expect(middle.data).toStrictEqual(['item-3', 'item-4', 'item-5']); + + const previous = await service.withCallbacks({ + before: middle.pageInfo.startCursor as string, + }); + + expect(previous.data).toStrictEqual(['item-0', 'item-1', 'item-2']); + }); + }); + + it('returns only the requested page, not the accumulated data', async () => { + await withService(async ({ service }) => { + await service.withCallbacks(); + await service.withCallbacks({ after: '3' }); + const page3 = await service.withCallbacks({ after: '6' }); + + expect(page3.data).toHaveLength(PAGE_SIZE); + expect(page3.data).toStrictEqual(['item-6', 'item-7', 'item-8']); + }); + }); + + it('does not refetch a fresh cached page', async () => { + await withService({ staleTime: Infinity }, async ({ service }) => { + await service.withCallbacks(); + await service.withCallbacks(); + + expect(service.queryFnCalls).toHaveLength(1); + }); + }); + + it('keeps navigation correct after refetching stale pages', async () => { + await withService({ staleTime: 0 }, async ({ service }) => { + await service.withCallbacks(); + await service.withCallbacks({ after: '3' }); + await service.withCallbacks({ after: '6' }); + + // A param-less call is stale, so query-core rebuilds all cached pages. + // This exercises the full-rebuild path, which must use the consumer's + // page-param callbacks and not any resolvers injected while paging. + const rebuilt = await service.withCallbacks(); + expect(rebuilt.data).toStrictEqual(['item-0', 'item-1', 'item-2']); + + const page2Again = await service.withCallbacks({ after: '3' }); + expect(page2Again.data).toStrictEqual(['item-3', 'item-4', 'item-5']); + }); + }); + }); + + describe('without page-param callbacks', () => { + it('fetches an arbitrary page by explicit cursor (forward)', async () => { + await withService(async ({ service }) => { + const page1 = await service.withoutCallbacks(); + expect(page1.data).toStrictEqual(['item-0', 'item-1', 'item-2']); + + const page2 = await service.withoutCallbacks({ after: '3' }); + expect(page2.data).toStrictEqual(['item-3', 'item-4', 'item-5']); + }); + }); + + it('fetches the correct page content for a `before` cursor', async () => { + await withService(async ({ service }) => { + // Cold jump into the middle, then ask for the page before it. + const middle = await service.withoutCallbacks({ after: '3' }); + expect(middle.data).toStrictEqual(['item-3', 'item-4', 'item-5']); + + const previous = await service.withoutCallbacks({ before: '3' }); + expect(previous.data).toStrictEqual(['item-0', 'item-1', 'item-2']); + }); + }); + + it('refetches stale multi-page state without page-param callbacks', async () => { + await withService({ staleTime: 0 }, async ({ service }) => { + await service.withoutCallbacks(); + await service.withoutCallbacks({ after: '3' }); + await service.withoutCallbacks({ after: '6' }); + + // Stale, so query-core rebuilds every cached page by walking forward + // from the first one. With no consumer `getNextPageParam`, that walk + // must not throw (the base service supplies a no-op resolver). + const rebuilt = await service.withoutCallbacks(); + expect(rebuilt.data).toStrictEqual(['item-0', 'item-1', 'item-2']); + + // Navigation still works after the rebuild. + const page2Again = await service.withoutCallbacks({ after: '3' }); + expect(page2Again.data).toStrictEqual(['item-3', 'item-4', 'item-5']); + }); + }); + }); + }); }); From ed03d2513633a721289d3f6b3d50153a373a2bb5 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 4 Aug 2026 15:27:14 +0100 Subject: [PATCH 10/10] fix(base-data-service): preserve a null initial page param in fetchInfiniteQuery --- .../src/BaseDataService.test.ts | 38 ++++++++++++++++++- .../base-data-service/src/BaseDataService.ts | 25 +++++++----- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index e604553978e..3c82a53cd15 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -104,7 +104,7 @@ class PaginatedService extends BaseDataService< PaginatedServiceMessenger > { // Records every page param the query function is actually invoked with. - readonly queryFnCalls: (PageParam | undefined)[] = []; + readonly queryFnCalls: (PageParam | null | undefined)[] = []; constructor( messenger: PaginatedServiceMessenger, @@ -169,6 +169,32 @@ class PaginatedService extends BaseDataService< pageParam, ); } + + /** + * Paginate with a consumer-provided `initialPageParam`, used to check that a + * `null` initial param (a valid `Json` first-page sentinel) is preserved. + * + * @param initialPageParam - The initial page param to configure. + * @returns The first page. + */ + async withInitialPageParam( + initialPageParam: PageParam | null, + ): Promise { + return this.fetchInfiniteQuery< + Page, + unknown, + Page, + [string], + PageParam | null + >({ + queryKey: [`${this.name}:withInitialPageParam`], + queryFn: async ({ pageParam: param }) => { + this.queryFnCalls.push(param); + return fetchPage(param ?? undefined); + }, + initialPageParam, + }); + } } /** @@ -946,6 +972,16 @@ describe('BaseDataService', () => { expect(page2Again.data).toStrictEqual(['item-3', 'item-4', 'item-5']); }); }); + + it('preserves a `null` consumer `initialPageParam`', async () => { + await withService(async ({ service }) => { + await service.withInitialPageParam(null); + + // `null` is a valid page param, so it must reach the query function + // rather than being coerced to `undefined`. + expect(service.queryFnCalls).toStrictEqual([null]); + }); + }); }); }); }); diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index 3e2f6cccf6c..e8446b3f6d3 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -323,6 +323,19 @@ export class BaseDataService< }); if (!query?.state.data || pageParam === undefined) { + // query-core v5 requires an `initialPageParam`, which becomes the param of + // the first (and only) page this fetches. Prefer an explicit per-call + // `pageParam` (a cold jump to a specific page); otherwise use the + // consumer's `initialPageParam`. Branching on a strict `undefined` check + // (rather than `??`) preserves `null`, which is a valid `Json` page param + // and query-core's usual first-page sentinel. + let initialPageParam: TPageParam; + if (pageParam === undefined) { + initialPageParam = options.initialPageParam as TPageParam; + } else { + initialPageParam = pageParam; + } + const result = await this.#queryClient.fetchInfiniteQuery< TQueryFnData, TError, @@ -331,10 +344,7 @@ export class BaseDataService< 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, + initialPageParam, // Provide a no-op `getNextPageParam` when the consumer omits one. // query-core v5 walks `getNextPageParam` when it refetches a multi-page // infinite query, so a missing resolver would throw once more than one @@ -343,12 +353,7 @@ export class BaseDataService< // page params. getNextPageParam: options.getNextPageParam ?? ((): null => null), queryFn: (context) => - this.#policy.execute(() => - options.queryFn({ - ...context, - pageParam: context.pageParam ?? pageParam, - }), - ), + this.#policy.execute(() => options.queryFn(context)), }); return result.pages[0];