Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/getconfig-subscribe-rebuild.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 16 additions & 4 deletions packages/fetchium/src/QueryResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export class QueryInstance<T extends Query> {
private params: QueryParams | undefined = undefined;

private unsubscribe?: () => void = undefined;
private lastSubscribeFn: QueryConfigOptions['subscribe'] = undefined;

private _relayState: RelayState<QueryResult<T>> | undefined = undefined;
private _isActive: boolean = false;
Expand Down Expand Up @@ -112,6 +113,7 @@ export class QueryInstance<T extends Query> {

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);
Expand Down Expand Up @@ -165,6 +167,8 @@ export class QueryInstance<T extends Query> {
}
}
} else if (paramsDidChange) {
// Force rebuild: the running subscriber captured the old params.
this.lastSubscribeFn = undefined;
this.setupSubscription();
this.runDebounced();
}
Expand Down Expand Up @@ -264,10 +268,13 @@ export class QueryInstance<T extends Query> {
}

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;
Expand Down Expand Up @@ -318,9 +325,14 @@ export class QueryInstance<T extends Query> {
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;
},
Expand Down
66 changes: 66 additions & 0 deletions packages/fetchium/src/__tests__/poll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
54 changes: 24 additions & 30 deletions packages/fetchium/src/__tests__/query-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down
Loading