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 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..44aa180 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,61 @@ 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); + // 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 + // -> 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); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- trigger reactivation + 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', () => {