From 68eae2570fa6c4518ab46b1ccdce244be2034646 Mon Sep 17 00:00:00 2001 From: John Whiles Date: Mon, 13 Jul 2026 12:55:50 +0100 Subject: [PATCH] fix(react-data-query): keep actively observed queries on service cache removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data services garbage-collect their internal cache entries (created via `fetchQuery`, so never observed) `cacheTime` after each fetch and publish a `removed` cache event. `createUIQueryClient` honoured that event by removing the UI-side query even while components actively observed it — unsupported by TanStack Query — so any consumer whose refetch interval met or exceeded the service-side `cacheTime` (default 5 minutes) had its data deleted out from under a mounted observer on a timer. A `removed` event only means the service no longer caches the query, not that the data is invalid, so it now only removes queries with no observers; observed queries keep their data and refetch on their own schedule. Co-Authored-By: Claude Fable 5 --- packages/react-data-query/CHANGELOG.md | 5 ++ .../src/createUIQueryClient.test.ts | 80 ++++++++++++++++++- .../src/createUIQueryClient.ts | 9 ++- 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/packages/react-data-query/CHANGELOG.md b/packages/react-data-query/CHANGELOG.md index 26b8a96fc2..9f51c7b671 100644 --- a/packages/react-data-query/CHANGELOG.md +++ b/packages/react-data-query/CHANGELOG.md @@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) - Make `react-dom` and `react-native` peer dependencies optional ([#9295](https://github.com/MetaMask/core/pull/9295)) +### Fixed + +- Keep actively observed queries when a data service removes its cache entry ([#9490](https://github.com/MetaMask/core/pull/9490)) + - Data services garbage-collect their internal cache entries (which never have observers) `cacheTime` after each fetch, publishing a `removed` cache event. Previously `createUIQueryClient` honoured that event by removing the UI-side query even while components actively observed it — an operation unsupported by TanStack Query — so mounted consumers whose refetch interval met or exceeded the service-side `cacheTime` lost their data on a timer. `removed` events now only remove queries with no observers; observed queries keep their data and refetch on their own schedule. + ## [0.2.1] ### Changed diff --git a/packages/react-data-query/src/createUIQueryClient.test.ts b/packages/react-data-query/src/createUIQueryClient.test.ts index 1a02cb08fd..167f7d889d 100644 --- a/packages/react-data-query/src/createUIQueryClient.test.ts +++ b/packages/react-data-query/src/createUIQueryClient.test.ts @@ -218,7 +218,7 @@ describe('createUIQueryClient', () => { observerB.destroy(); }); - it('synchronizes cache removal after remove event', async () => { + it('retains actively observed queries when the service cache entry is removed', async () => { const { messenger, clientA, clientB } = createClients(); const observerA = new QueryObserver(clientA, { @@ -245,10 +245,14 @@ describe('createUIQueryClient', () => { }); }); - await Promise.all([promiseA, promiseB]); + const [dataBefore] = await Promise.all([promiseA, promiseB]); const hash = hashQueryKey(getAssetsQueryKey); + // Simulates the data service garbage collecting its own cache entry + // (which happens `cacheTime` after a fetch, since service-side queries + // never have observers). This must not destroy data that mounted + // consumers are still observing. messenger.publish(`ExampleDataService:cacheUpdated:${hash}`, { type: 'removed', state: null, @@ -256,13 +260,83 @@ describe('createUIQueryClient', () => { const queryData = clientA.getQueryData(getAssetsQueryKey); - expect(queryData).toBeUndefined(); + expect(queryData).toStrictEqual(dataBefore); expect(queryData).toStrictEqual(clientB.getQueryData(getAssetsQueryKey)); observerA.destroy(); observerB.destroy(); }); + it('keeps observed queries functional after a service cache removal event', async () => { + const { messenger, clientA } = createClients(); + + const observer = new QueryObserver(clientA, { + queryKey: getAssetsQueryKey, + }); + + await new Promise((resolve) => { + observer.subscribe((event) => { + if (event.status === 'success') { + resolve(event.data); + } + }); + }); + + const hash = hashQueryKey(getAssetsQueryKey); + + messenger.publish(`ExampleDataService:cacheUpdated:${hash}`, { + type: 'removed', + state: null, + }); + + // Replace the mock response and invalidate: the observer must still be + // wired to a live cache entry that refetches fresh data end-to-end. + mockAssets({ + status: 200, + body: [], + }); + + await clientA.invalidateQueries(); + + expect(clientA.getQueryData(getAssetsQueryKey)).toStrictEqual([]); + + observer.destroy(); + }); + + it('removes unobserved queries when the service cache entry is removed', async () => { + const { messenger, clientA } = createClients(); + + const hash = hashQueryKey(getAssetsQueryKey); + const subscribeSpy = jest.spyOn(messenger, 'subscribe'); + + const observer = new QueryObserver(clientA, { + queryKey: getAssetsQueryKey, + }); + + await new Promise((resolve) => { + observer.subscribe((event) => { + if (event.status === 'success') { + resolve(event.data); + } + }); + }); + + const cacheListener = subscribeSpy.mock.calls.find( + ([eventType]) => eventType === `ExampleDataService:cacheUpdated:${hash}`, + )?.[1] as (payload: { type: 'removed'; state: null }) => void; + + // Destroying the observer unsubscribes the listener from the messenger, + // but a `removed` event may already be in flight — deliver it directly to + // simulate that race. With no observers left, the query is removed. + observer.destroy(); + + expect(clientA.getQueryData(getAssetsQueryKey)).toBeDefined(); + + cacheListener({ type: 'removed', state: null }); + + expect(clientA.getQueryData(getAssetsQueryKey)).toBeUndefined(); + }); + it('fetches using paginated observers', async () => { const { clientA, clientB } = createClients(); diff --git a/packages/react-data-query/src/createUIQueryClient.ts b/packages/react-data-query/src/createUIQueryClient.ts index 8e714fd563..ee364ccf89 100644 --- a/packages/react-data-query/src/createUIQueryClient.ts +++ b/packages/react-data-query/src/createUIQueryClient.ts @@ -114,7 +114,14 @@ export function createUIQueryClient( if (payload.type === 'removed') { const currentQuery = cache.get(hash); - if (currentQuery) { + // A `removed` event only means the data service no longer caches + // this query (typically its internal cache entry was garbage + // collected), not that the data is invalid. Removing a query that + // still has observers is unsupported by tanstack and destroys data + // a mounted consumer is rendering, so only unobserved queries are + // removed; observed ones keep their data and refetch on their own + // schedule. + if (currentQuery?.getObserversCount() === 0) { cache.remove(currentQuery); } } else {