Skip to content
Merged
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/reconcile-on-applydata-throw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'fetchium': patch
---

Fix reactive `getConfig()` not reacting to error responses when the response body fails to parse against the result schema. Previously `runQuery` only called `reconcileSubscription` after `applyData` succeeded, so a 404 (or any other status) whose body did not match the entity shape would throw inside `parseEntities`, skip the reconcile, and leave the running subscriber installed against stale config. The reconcile call is now in a `finally` block so it fires after every fetch attempt, regardless of whether parsing succeeds.
22 changes: 13 additions & 9 deletions packages/fetchium/src/QueryResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,15 +328,19 @@ export class QueryInstance<T extends Query> {

return withRetry(
async () => {
const freshData = await adapter.send(ctx, signal);
this.updatedAt = Date.now();

const result = this.applyData(freshData, true);
this.saveQueryMetadata();

this.reconcileSubscription();

return result;
try {
const freshData = await adapter.send(ctx, signal);
this.updatedAt = Date.now();

const result = this.applyData(freshData, true);
this.saveQueryMetadata();

return result;
} finally {
// In finally so reactive getConfig() reacts to error responses
// (e.g. 404 → subscribe: undefined) even when applyData throws.
this.reconcileSubscription();
}
},
this.retryConfig,
signal,
Expand Down
32 changes: 27 additions & 5 deletions packages/fetchium/src/__tests__/poll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import { reactiveSignal } from 'signalium';
import { RESTQuery } from '../rest/index.js';
import { fetchQuery } from '../query.js';
import { Entity } from '../proxy.js';
import { testWithClient, sleep, setupTestClient } from './utils.js';
import { t } from '../typeDefs.js';
import { poll } from '../subscriptions/polling.js';
Expand Down Expand Up @@ -175,14 +176,34 @@ describe('poll() factory', () => {
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.
mockFetch.get('/maybe-gone', () => ({ n: ++callCount }), { status: 404 });
class Item extends Entity {
__typename = t.typename('PollStopItem');
id = t.id;
name = t.string;
}

// First call: 200 OK with valid entity body so the poll subscriber installs.
mockFetch.get('/maybe-gone', () => {
callCount++;
return { __typename: 'PollStopItem', id: '1', name: 'ok' };
});
// Subsequent calls: 404 with an error body that does NOT match the entity
// shape, so applyData throws via parseEntities. reconcileSubscription
// must still fire so the reactive getConfig sees the response transition.
mockFetch.get(
'/maybe-gone',
() => {
callCount++;
return { error: 'Not found' };
},
{ status: 404 },
);

class GetMaybeGone extends RESTQuery {
path = '/maybe-gone';
result = { n: t.number };
result = t.entity(Item);

config = { retry: { retries: 0 } as const };

getConfig() {
const is404 = reactiveSignal(() => {
Expand All @@ -191,6 +212,7 @@ describe('poll() factory', () => {
}).value;

return {
...this.config,
subscribe: is404 ? undefined : poll({ interval: 100 }),
};
}
Expand Down
Loading