diff --git a/.changeset/skip-gc-on-pause.md b/.changeset/skip-gc-on-pause.md new file mode 100644 index 0000000..61203f2 --- /dev/null +++ b/.changeset/skip-gc-on-pause.md @@ -0,0 +1,7 @@ +--- +"fetchium": patch +--- + +Paused queries no longer schedule garbage collection, so resuming reuses the cached result instead of refetching. Previously a paused query with a low `gcTime` was evicted immediately and recreated on resume, dropping its polling interval. GC still runs on genuine teardown. + +This relies on the `isPausing` flag signalium passes to `RelayHooks.deactivate` (see https://github.com/Signalium/signalium/pull/242), so the `signalium` peer requirement is now `>=3.0.3` (was `>=3.0.1`). diff --git a/package-lock.json b/package-lock.json index 8bf2727..d60d8ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12173,7 +12173,7 @@ } }, "packages/fetchium": { - "version": "0.3.0", + "version": "0.4.4", "license": "ISC", "bin": { "fetchium-agents": "plugin/install.mjs" @@ -12188,7 +12188,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "rollup-plugin-const-enum": "^1.1.4", - "signalium": "3.0.1", + "signalium": "3.0.3", "vite": "^7.1.2", "vite-plugin-babel": "^1.3.0", "vite-plugin-dts": "^4.5.4", @@ -12198,7 +12198,7 @@ }, "peerDependencies": { "react": ">=19.0.0", - "signalium": ">=3.0.1" + "signalium": ">=3.0.3" }, "peerDependenciesMeta": { "react": { @@ -12237,9 +12237,9 @@ "license": "MIT" }, "packages/fetchium/node_modules/signalium": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/signalium/-/signalium-3.0.1.tgz", - "integrity": "sha512-+MhlOxdvTugIDPRvH45CA0ZBJE+jG3FjchU6L+ocNWEGtryCkGcjTULlo1fTp1FZVWVrh/R2mIljK/p1goXAiw==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signalium/-/signalium-3.0.3.tgz", + "integrity": "sha512-hmseMzywyYnJaPX3Q3mtsvzLGRLrhgSZZmsgMkLrlkTmruVNHSiaQpuSWWGMUltKNwNjHLWhKkB4VieeZ/lwmQ==", "dev": true, "license": "ISC", "peerDependencies": { diff --git a/packages/fetchium/package.json b/packages/fetchium/package.json index 06f898b..c07b537 100644 --- a/packages/fetchium/package.json +++ b/packages/fetchium/package.json @@ -140,7 +140,7 @@ }, "peerDependencies": { "react": ">=19.0.0", - "signalium": ">=3.0.1" + "signalium": ">=3.0.3" }, "peerDependenciesMeta": { "react": { @@ -176,7 +176,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "rollup-plugin-const-enum": "^1.1.4", - "signalium": "3.0.1", + "signalium": "3.0.3", "vite": "^7.1.2", "vite-plugin-babel": "^1.3.0", "vite-plugin-dts": "^4.5.4", diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index c695e5c..ea5b55d 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -1,4 +1,11 @@ -import { relay, reactiveSignal, type RelayState, ReactivePromise, type ReadonlySignal } from 'signalium'; +import { + relay, + reactiveSignal, + type RelayState, + ReactivePromise, + type ReadonlySignal, + type DeactivateOptions, +} from 'signalium'; import { NetworkMode, type QueryResult, type EntityDef } from './types.js'; import { type QueryClient, @@ -113,7 +120,9 @@ export class QueryInstance { state => { this._relayState = state; - const deactivate = () => { + // When pausing (vs a genuine cleanup) we tear down the fetch/subscription + // but skip GC, so resuming reuses the cached result instead of refetching. + const deactivate = ({ isPausing = false }: DeactivateOptions = {}) => { this._isActive = false; clearTimeout(this.debounceTimer); @@ -130,6 +139,8 @@ export class QueryInstance { this.unsubscribe = undefined; this.lastSubscribeFn = undefined; + if (isPausing) return; + const gcTime = this.config?.gcTime ?? DEFAULT_GC_TIME; this.queryClient.gcManager.schedule(this.queryKey, gcTime, GcKeyType.Query); }; @@ -139,7 +150,7 @@ export class QueryInstance { this.wasPaused = isPaused; if (isPaused && !wasPaused && initialized) { - deactivate(); + deactivate({ isPausing: true }); return; } diff --git a/packages/fetchium/src/__tests__/network-mode.test.ts b/packages/fetchium/src/__tests__/network-mode.test.ts index bf95497..0e93e48 100644 --- a/packages/fetchium/src/__tests__/network-mode.test.ts +++ b/packages/fetchium/src/__tests__/network-mode.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { QueryClient, QueryClientContext } from '../QueryClient.js'; import { SyncQueryStore, MemoryPersistentStore } from '../stores/sync.js'; import { RESTQuery } from '../rest/index.js'; -import { fetchQuery } from '../query.js'; +import { fetchQuery, queryKeyForClass } from '../query.js'; import { NetworkManager } from '../NetworkManager.js'; import { NetworkMode } from '../types.js'; import { createMockFetch, testWithClient, sleep, createTestWatcher } from './utils.js'; @@ -596,4 +596,41 @@ describe('Network Mode', () => { }); }); }); + + describe('pause does not schedule garbage collection', () => { + // A temporary pause must not schedule GC (which would evict the cache and + // force a refetch on resume); a genuine teardown still does. + it('skips GC when an active query is paused offline, but schedules it on teardown', async () => { + mockFetch.get('/users/1', { id: '1', name: 'Alice' }); + + class GetUser extends RESTQuery { + path = '/users/1'; + result = t.object({ id: t.string, name: t.string }); + config = { networkMode: NetworkMode.Online, staleTime: 0 }; + } + + const queryKey = queryKeyForClass(GetUser, undefined); + const scheduleSpy = vi.spyOn(client.gcManager, 'schedule'); + const queryGcCalls = () => scheduleSpy.mock.calls.filter(call => call[0] === queryKey).length; + + const { unsub } = withContexts([[QueryClientContext, client]], () => + createTestWatcher(() => fetchQuery(GetUser).value), + ); + + await sleep(50); + expect(client.queryInstances.has(queryKey)).toBe(true); + expect(queryGcCalls()).toBe(0); + + // Network-driven pause: tears down the active fetch but must not GC. + networkManager.setNetworkStatus(false); + await sleep(20); + expect(queryGcCalls()).toBe(0); + expect(client.queryInstances.has(queryKey)).toBe(true); + + // Genuine teardown (last watcher removed) schedules GC as before. + unsub(); + await sleep(20); + expect(queryGcCalls()).toBeGreaterThan(0); + }); + }); });