Skip to content

Commit 0367c45

Browse files
Jwhilesclaude
andcommitted
fix(react-data-query): keep actively observed queries on service cache removal
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 <noreply@anthropic.com>
1 parent 8f0baff commit 0367c45

3 files changed

Lines changed: 90 additions & 4 deletions

File tree

packages/react-data-query/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074))
1313
- Make `react-dom` and `react-native` peer dependencies optional ([#9295](https://github.com/MetaMask/core/pull/9295))
1414

15+
### Fixed
16+
17+
- Keep actively observed queries when a data service removes its cache entry ([#0000](https://github.com/MetaMask/core/pull/0000))
18+
- 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.
19+
1520
## [0.2.1]
1621

1722
### Changed

packages/react-data-query/src/createUIQueryClient.test.ts

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ describe('createUIQueryClient', () => {
218218
observerB.destroy();
219219
});
220220

221-
it('synchronizes cache removal after remove event', async () => {
221+
it('retains actively observed queries when the service cache entry is removed', async () => {
222222
const { messenger, clientA, clientB } = createClients();
223223

224224
const observerA = new QueryObserver(clientA, {
@@ -245,24 +245,98 @@ describe('createUIQueryClient', () => {
245245
});
246246
});
247247

248-
await Promise.all([promiseA, promiseB]);
248+
const [dataBefore] = await Promise.all([promiseA, promiseB]);
249249

250250
const hash = hashQueryKey(getAssetsQueryKey);
251251

252+
// Simulates the data service garbage collecting its own cache entry
253+
// (which happens `cacheTime` after a fetch, since service-side queries
254+
// never have observers). This must not destroy data that mounted
255+
// consumers are still observing.
252256
messenger.publish(`ExampleDataService:cacheUpdated:${hash}`, {
253257
type: 'removed',
254258
state: null,
255259
});
256260

257261
const queryData = clientA.getQueryData(getAssetsQueryKey);
258262

259-
expect(queryData).toBeUndefined();
263+
expect(queryData).toStrictEqual(dataBefore);
260264
expect(queryData).toStrictEqual(clientB.getQueryData(getAssetsQueryKey));
261265

262266
observerA.destroy();
263267
observerB.destroy();
264268
});
265269

270+
it('keeps observed queries functional after a service cache removal event', async () => {
271+
const { messenger, clientA } = createClients();
272+
273+
const observer = new QueryObserver(clientA, {
274+
queryKey: getAssetsQueryKey,
275+
});
276+
277+
await new Promise((resolve) => {
278+
observer.subscribe((event) => {
279+
if (event.status === 'success') {
280+
resolve(event.data);
281+
}
282+
});
283+
});
284+
285+
const hash = hashQueryKey(getAssetsQueryKey);
286+
287+
messenger.publish(`ExampleDataService:cacheUpdated:${hash}`, {
288+
type: 'removed',
289+
state: null,
290+
});
291+
292+
// Replace the mock response and invalidate: the observer must still be
293+
// wired to a live cache entry that refetches fresh data end-to-end.
294+
mockAssets({
295+
status: 200,
296+
body: [],
297+
});
298+
299+
await clientA.invalidateQueries();
300+
301+
expect(clientA.getQueryData(getAssetsQueryKey)).toStrictEqual([]);
302+
303+
observer.destroy();
304+
});
305+
306+
it('removes unobserved queries when the service cache entry is removed', async () => {
307+
const { messenger, clientA } = createClients();
308+
309+
const hash = hashQueryKey(getAssetsQueryKey);
310+
const subscribeSpy = jest.spyOn(messenger, 'subscribe');
311+
312+
const observer = new QueryObserver(clientA, {
313+
queryKey: getAssetsQueryKey,
314+
});
315+
316+
await new Promise((resolve) => {
317+
observer.subscribe((event) => {
318+
if (event.status === 'success') {
319+
resolve(event.data);
320+
}
321+
});
322+
});
323+
324+
const cacheListener = subscribeSpy.mock.calls.find(
325+
([eventType]) => eventType === `ExampleDataService:cacheUpdated:${hash}`,
326+
)?.[1] as (payload: { type: 'removed'; state: null }) => void;
327+
328+
// Destroying the observer unsubscribes the listener from the messenger,
329+
// but a `removed` event may already be in flight — deliver it directly to
330+
// simulate that race. With no observers left, the query is removed.
331+
observer.destroy();
332+
333+
expect(clientA.getQueryData(getAssetsQueryKey)).toBeDefined();
334+
335+
cacheListener({ type: 'removed', state: null });
336+
337+
expect(clientA.getQueryData(getAssetsQueryKey)).toBeUndefined();
338+
});
339+
266340
it('fetches using paginated observers', async () => {
267341
const { clientA, clientB } = createClients();
268342

packages/react-data-query/src/createUIQueryClient.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,14 @@ export function createUIQueryClient(
114114
if (payload.type === 'removed') {
115115
const currentQuery = cache.get(hash);
116116

117-
if (currentQuery) {
117+
// A `removed` event only means the data service no longer caches
118+
// this query (typically its internal cache entry was garbage
119+
// collected), not that the data is invalid. Removing a query that
120+
// still has observers is unsupported by tanstack and destroys data
121+
// a mounted consumer is rendering, so only unobserved queries are
122+
// removed; observed ones keep their data and refetch on their own
123+
// schedule.
124+
if (currentQuery?.getObserversCount() === 0) {
118125
cache.remove(currentQuery);
119126
}
120127
} else {

0 commit comments

Comments
 (0)