From 0a6c9fb5c6f253abb42149b8cfe42b5e9cd11368 Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Fri, 10 Apr 2026 10:39:38 -0700 Subject: [PATCH 1/3] fix(fetchium): Recover relay after deactivation during in-flight refetch When a relay deactivates mid-refetch, deactivate() aborts the fetch and clears _abortController. The signalium ReactivePromiseImpl still shows isPending=true until the AbortError settles async. On reactivation, runDebounced() sees isPending=true and bails out, so no new fetch starts. The AbortError then permanently poisons the relay to Rejected. Detect this state (isPending && _abortController === undefined) on reactivation and call runQueryImmediately() instead. The new setPromise() call replaces _promise, causing the stale AbortError to hit signalium's promise !== this._promise guard and be silently discarded. Closes COR2-504 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/fetchium/src/QueryResult.ts | 16 +++++- .../fetchium/src/__tests__/reactivity.test.ts | 56 ++++++++++++++++++- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index b59878f..cafc8c5 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -145,9 +145,19 @@ export class QueryInstance { this.setupSubscription(); } - const refreshStaleOnReconnect = this.config?.refreshStaleOnReconnect ?? true; - if (refreshStaleOnReconnect && this.isStale) { - this.runDebounced(); + // If the relay shows pending but the abort controller is gone, the + // previous fetch was aborted during deactivation. runDebounced() + // would bail out because isPending is still true from the doomed + // promise. Force an immediate refetch so the new setPromise() call + // replaces _promise, causing the stale AbortError rejection to hit + // the `promise !== this._promise` guard and be silently ignored. + if (this.relayState.isPending && this._abortController === undefined) { + this.runQueryImmediately(); + } else { + const refreshStaleOnReconnect = this.config?.refreshStaleOnReconnect ?? true; + if (refreshStaleOnReconnect && this.isStale) { + this.runDebounced(); + } } } else if (paramsDidChange) { this.setupSubscription(); diff --git a/packages/fetchium/src/__tests__/reactivity.test.ts b/packages/fetchium/src/__tests__/reactivity.test.ts index 6909e00..9109728 100644 --- a/packages/fetchium/src/__tests__/reactivity.test.ts +++ b/packages/fetchium/src/__tests__/reactivity.test.ts @@ -3,7 +3,7 @@ import { t } from '../typeDefs.js'; import { RESTQuery } from '../rest/index.js'; import { fetchQuery } from '../query.js'; import { watcher, reactive } from 'signalium'; -import { testWithClient, setupTestClient } from './utils.js'; +import { testWithClient, sleep, setupTestClient } from './utils.js'; /** * Signalium Reactivity Tests @@ -72,6 +72,60 @@ describe('Signalium Reactivity', () => { expect(relay.error).toBe(error); }); }); + + it('should recover after deactivation during in-flight refetch', async () => { + const { client, mockFetch } = getClient(); + + class GetPrice extends RESTQuery { + path = '/price'; + result = { price: t.number }; + config = { staleTime: 50, retry: false }; + } + + // Phase 1: Initial fetch succeeds + mockFetch.get('/price', { price: 100 }); + + await testWithClient(client, async () => { + const relay = fetchQuery(GetPrice); + await relay; + expect(relay.value!).toMatchObject({ price: 100 }); + }); + + // Wait for staleTime to expire + await sleep(100); + + // Phase 2: Resubscribe (triggers stale refetch), then deactivate mid-flight. + // The long delay ensures the fetch is still in-flight when the watcher ends. + mockFetch.get('/price', { price: 200 }, { delay: 500 }); + + await testWithClient(client, async () => { + const relay = fetchQuery(GetPrice); + // Access value to trigger the stale refetch via runDebounced + relay.value; + await sleep(20); // Let setTimeout(0) fire so the refetch starts + // testWithClient ends here -> watcher unsubscribes -> relay deactivates + // -> AbortController.abort() -> in-flight fetch will reject with AbortError + }); + + // Let the AbortError microtask settle + await sleep(50); + + // Phase 3: Resubscribe again. The relay must recover and fetch fresh data + // instead of being permanently stuck with the AbortError. + mockFetch.get('/price', { price: 300 }, { delay: 50 }); + + await testWithClient(client, async () => { + const relay = fetchQuery(GetPrice); + relay.value; + + // Wait for the recovery fetch to complete + await sleep(200); + + expect(relay.isPending).toBe(false); + expect(relay.isRejected).toBe(false); + expect(relay.value!).toMatchObject({ price: 300 }); + }); + }); }); describe('Reactive Computations', () => { From 47748157264d815ad850818d3c93f90309516ed4 Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Fri, 10 Apr 2026 10:44:29 -0700 Subject: [PATCH 2/3] fix lint: suppress no-unused-expressions for relay.value access in test Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/fetchium/src/__tests__/reactivity.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/fetchium/src/__tests__/reactivity.test.ts b/packages/fetchium/src/__tests__/reactivity.test.ts index 9109728..44aa180 100644 --- a/packages/fetchium/src/__tests__/reactivity.test.ts +++ b/packages/fetchium/src/__tests__/reactivity.test.ts @@ -100,7 +100,7 @@ describe('Signalium Reactivity', () => { await testWithClient(client, async () => { const relay = fetchQuery(GetPrice); - // Access value to trigger the stale refetch via runDebounced + // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- trigger stale refetch relay.value; await sleep(20); // Let setTimeout(0) fire so the refetch starts // testWithClient ends here -> watcher unsubscribes -> relay deactivates @@ -116,6 +116,7 @@ describe('Signalium Reactivity', () => { await testWithClient(client, async () => { const relay = fetchQuery(GetPrice); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- trigger reactivation relay.value; // Wait for the recovery fetch to complete From f58b8ef71f515cdf10e4ae6d8b6a153f69f96a20 Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Fri, 10 Apr 2026 10:58:26 -0700 Subject: [PATCH 3/3] Add changeset for relay deactivation recovery fix Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/fix-relay-deactivation-recovery.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-relay-deactivation-recovery.md diff --git a/.changeset/fix-relay-deactivation-recovery.md b/.changeset/fix-relay-deactivation-recovery.md new file mode 100644 index 0000000..9b44116 --- /dev/null +++ b/.changeset/fix-relay-deactivation-recovery.md @@ -0,0 +1,5 @@ +--- +"fetchium": patch +--- + +Fix relay not recovering after deactivation during in-flight refetch