From 9b8c36e4eb4334aecdc065c61183f0c44955b53e Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Thu, 11 Jun 2026 16:04:21 -0500 Subject: [PATCH 1/3] Skip GC when a query is paused Deactivating a query always scheduled garbage collection. A temporary pause (signalium's PauseSignalsProvider, or a network-driven pause) with a low gcTime evicted the query immediately and recreated it on resume, dropping its polling interval. Read the isPausing flag signalium now passes to RelayHooks.deactivate and skip GC while pausing. The active fetch and subscription are still torn down; only GC is skipped, so the cached result survives and resume reuses it. GC still runs on a genuine teardown. Co-Authored-By: Claude Opus 4.8 --- .changeset/skip-gc-on-pause.md | 5 +++ packages/fetchium/src/QueryResult.ts | 17 ++++++-- .../src/__tests__/network-mode.test.ts | 39 ++++++++++++++++++- 3 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 .changeset/skip-gc-on-pause.md diff --git a/.changeset/skip-gc-on-pause.md b/.changeset/skip-gc-on-pause.md new file mode 100644 index 0000000..6aafbec --- /dev/null +++ b/.changeset/skip-gc-on-pause.md @@ -0,0 +1,5 @@ +--- +"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. 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); + }); + }); }); From b63d6cddd39d87cdd9bf54b0071b151dcbe9ebba Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Fri, 12 Jun 2026 10:29:11 -0500 Subject: [PATCH 2/3] require signalium >= 3.0.3 --- .changeset/skip-gc-on-pause.md | 4 +++- package-lock.json | 12 ++++++------ packages/fetchium/package.json | 4 ++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.changeset/skip-gc-on-pause.md b/.changeset/skip-gc-on-pause.md index 6aafbec..7be5d4b 100644 --- a/.changeset/skip-gc-on-pause.md +++ b/.changeset/skip-gc-on-pause.md @@ -1,5 +1,7 @@ --- -"fetchium": patch +"fetchium": minor --- 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", From 201a587284ade0df3cbacc7eb91e5b0deab2d43d Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Fri, 12 Jun 2026 10:39:16 -0500 Subject: [PATCH 3/3] Downgrade changeset to patch signalium shipped the isPausing flag in a patch (3.0.3), so tightening the peer floor is backward compatible within the 3.0.x line, not breaking. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/skip-gc-on-pause.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/skip-gc-on-pause.md b/.changeset/skip-gc-on-pause.md index 7be5d4b..61203f2 100644 --- a/.changeset/skip-gc-on-pause.md +++ b/.changeset/skip-gc-on-pause.md @@ -1,5 +1,5 @@ --- -"fetchium": minor +"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.