diff --git a/.changeset/getconfig-subscribe-rebuild.md b/.changeset/getconfig-subscribe-rebuild.md new file mode 100644 index 0000000..586bde3 --- /dev/null +++ b/.changeset/getconfig-subscribe-rebuild.md @@ -0,0 +1,5 @@ +--- +"fetchium": patch +--- + +`getConfig()` returning a different `subscribe` value mid-session now rebuilds the running subscriber. Previously, `setupSubscription` only consulted `config.subscribe` at activation, on params change, or when no subscriber was running, so patterns like `subscribe: poll({ interval: this.response?.ok ? 100 : 5000 })` and `subscribe: this.response?.status === 404 ? undefined : poll(...)` had no effect after the first activation. diff --git a/packages/fetchium/src/QueryResult.ts b/packages/fetchium/src/QueryResult.ts index 32582a8..413d8f5 100644 --- a/packages/fetchium/src/QueryResult.ts +++ b/packages/fetchium/src/QueryResult.ts @@ -33,6 +33,7 @@ export class QueryInstance { private params: QueryParams | undefined = undefined; private unsubscribe?: () => void = undefined; + private lastSubscribeFn: QueryConfigOptions['subscribe'] = undefined; private _relayState: RelayState> | undefined = undefined; private _isActive: boolean = false; @@ -112,6 +113,7 @@ export class QueryInstance { this.unsubscribe?.(); this.unsubscribe = undefined; + this.lastSubscribeFn = undefined; const gcTime = this.config?.gcTime ?? DEFAULT_GC_TIME; this.queryClient.gcManager.schedule(this.queryKey, gcTime, GcKeyType.Query); @@ -165,6 +167,8 @@ export class QueryInstance { } } } else if (paramsDidChange) { + // Force rebuild: the running subscriber captured the old params. + this.lastSubscribeFn = undefined; this.setupSubscription(); this.runDebounced(); } @@ -264,10 +268,13 @@ export class QueryInstance { } private setupSubscription(): void { + const subscribeFn = this.config?.subscribe; + if (subscribeFn === this.lastSubscribeFn) return; + this.unsubscribe?.(); this.unsubscribe = undefined; + this.lastSubscribeFn = subscribeFn; - const subscribeFn = this.config?.subscribe; if (!subscribeFn) return; const ctx = this._executionCtx; @@ -318,9 +325,14 @@ export class QueryInstance { const result = this.applyData(freshData, true); this.saveQueryMetadata(); - if (this.unsubscribe === undefined) { - this.setupSubscription(); - } + // adapter.send just populated this.response on the execution context. + // getConfig() implementations may branch on it to choose subscribe + // (e.g. poll interval based on response.ok), so the earlier resolve + // (via getOrCreateExecutionContext, before the fetch) produced a + // config computed against an undefined this.response. Re-resolve + // here so setupSubscription installs the response-aware subscribe. + this.resolveAndApplyOptions(); + this.setupSubscription(); return result; }, diff --git a/packages/fetchium/src/__tests__/poll.test.ts b/packages/fetchium/src/__tests__/poll.test.ts index a517a96..3d5af76 100644 --- a/packages/fetchium/src/__tests__/poll.test.ts +++ b/packages/fetchium/src/__tests__/poll.test.ts @@ -137,6 +137,72 @@ describe('poll() factory', () => { expect(callCount).toBeGreaterThanOrEqual(3); }); }); + + it('honors a state-dependent interval after first fetch resolves', async () => { + const { client, mockFetch } = getClient(); + let callCount = 0; + mockFetch.get('/dynamic-interval', () => ({ n: ++callCount })); + + class GetDynamicInterval extends RESTQuery { + path = '/dynamic-interval'; + result = { n: t.number }; + + getConfig() { + // Before first fetch: response is undefined → falsy branch (5000ms). + // After first fetch: response.ok === true → fast branch (100ms). + return { + subscribe: poll({ interval: this.response?.ok ? 100 : 5000 }), + }; + } + } + + await testWithClient(client, async () => { + const relay = fetchQuery(GetDynamicInterval); + await relay; + const callsAfterFirst = callCount; + + // Once response.ok is true, getConfig() returns poll({ interval: 100 }). + // 350ms should yield at least 2 additional polls. + await sleep(350); + + expect(callCount).toBeGreaterThanOrEqual(callsAfterFirst + 2); + }); + }); + + it('stops polling when getConfig() switches subscribe to undefined after an error', async () => { + const { client, mockFetch } = getClient(); + let callCount = 0; + // First call: 200 OK. + mockFetch.get('/maybe-gone', () => ({ n: ++callCount })); + // Subsequent calls: 404 (route reuse falls through to this last-match route). + mockFetch.get('/maybe-gone', () => ({ n: ++callCount }), { status: 404 }); + + class GetMaybeGone extends RESTQuery { + path = '/maybe-gone'; + result = { n: t.number }; + + getConfig() { + // Stop polling once a 404 has been observed. + return { + subscribe: this.response?.status === 404 ? undefined : poll({ interval: 100 }), + }; + } + } + + await testWithClient(client, async () => { + const relay = fetchQuery(GetMaybeGone); + await relay; + + // Let the 100ms poll tick at least once so a 404 lands. + await sleep(250); + const callsAtTerminal = callCount; + expect(callsAtTerminal).toBeGreaterThan(1); + + // After the 404, getConfig() returns subscribe: undefined → polling should stop. + await sleep(400); + expect(callCount).toBe(callsAtTerminal); + }); + }); }); describe('Multiple independent polls', () => { diff --git a/packages/fetchium/src/__tests__/query-stream.test.ts b/packages/fetchium/src/__tests__/query-stream.test.ts index e7f0b53..79e01bb 100644 --- a/packages/fetchium/src/__tests__/query-stream.test.ts +++ b/packages/fetchium/src/__tests__/query-stream.test.ts @@ -295,16 +295,14 @@ describe('Query Stream Option', () => { posts: t.array(t.entity(Post)), }; - getConfig() { - return { - subscribe: (onEvent: (event: MutationEvent) => void) => { - subscribeCount++; - return () => { - unsubscribeCount++; - }; - }, - }; - } + config = { + subscribe: (_onEvent: (event: MutationEvent) => void) => { + subscribeCount++; + return () => { + unsubscribeCount++; + }; + }, + }; } // First activation @@ -439,17 +437,15 @@ describe('Query Stream Option', () => { items: t.array(t.entity(Item)), }; - getConfig() { - return { - subscribe: (onEvent: (event: MutationEvent) => void) => { - const sub = { channelId: this.params.channelId, unsubscribed: false }; - subscriptions.push(sub); - return () => { - sub.unsubscribed = true; - }; - }, - }; - } + config = { + subscribe(this: any, _onEvent: (event: MutationEvent) => void) { + const sub = { channelId: this.params.channelId, unsubscribed: false }; + subscriptions.push(sub); + return () => { + sub.unsubscribed = true; + }; + }, + }; } const channelSignal = signal('ch-1'); @@ -507,15 +503,13 @@ describe('Query Stream Option', () => { items: t.array(t.entity(Item)), }; - getConfig() { - return { - subscribe: (onEvent: (event: MutationEvent) => void) => { - subscribeCount++; - latestOnEvent = onEvent; - return () => {}; - }, - }; - } + config = { + subscribe: (onEvent: (event: MutationEvent) => void) => { + subscribeCount++; + latestOnEvent = onEvent; + return () => {}; + }, + }; } const channelSignal = signal('ch-1');