From 1187bd5787dbf6c40f144ceb1eaec92ad9d7d385 Mon Sep 17 00:00:00 2001 From: Elias Djurfeldt Date: Fri, 11 Sep 2026 14:12:30 +0200 Subject: [PATCH] fix(core): Block every reexecute of an in-flight operation `reexecuteOperation` blocks a reexecute of an operation that is still in flight, but the blocking branch also clears that operation's `dispatched` flag. The next reexecute during the same request therefore passes `nextOperation`'s deduplication check and a second network request goes out. The flag only has to be cleared when the reexecute replaced an operation already waiting in the queue, so the queued operation is not deduplicated as the queue drains. The stale-result path in `onPush` still unblocks an operation whose result came back partial or optimistic. --- .changeset/brave-moons-repeat.md | 5 +++ packages/core/src/client.test.ts | 55 ++++++++++++++++++++++++++++++++ packages/core/src/client.ts | 2 +- 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 .changeset/brave-moons-repeat.md diff --git a/.changeset/brave-moons-repeat.md b/.changeset/brave-moons-repeat.md new file mode 100644 index 0000000000..b24ee2a899 --- /dev/null +++ b/.changeset/brave-moons-repeat.md @@ -0,0 +1,5 @@ +--- +'@urql/core': patch +--- + +Fix `client.reexecuteOperation` letting every second reexecute of an in-flight operation through. The in-flight block cleared the operation's `dispatched` flag, so the next reexecute during the same request passed the deduplication check and sent a second network request. The flag is now only cleared when the reexecute replaced an operation that was already waiting in the queue. diff --git a/packages/core/src/client.test.ts b/packages/core/src/client.test.ts index bad8c24734..496c78db4c 100755 --- a/packages/core/src/client.test.ts +++ b/packages/core/src/client.test.ts @@ -860,6 +860,61 @@ describe('deduplication behavior', () => { expect(onOperation).toHaveBeenCalledTimes(2); expect(onResult).toHaveBeenCalledTimes(2); }); + + it('blocks repeated reexecutes of operations that are in-flight', async () => { + const onOperation = vi.fn(); + const onResult = vi.fn(); + + let resolve; + const exchange: Exchange = () => ops$ => + pipe( + ops$, + filter(op => op.kind !== 'teardown'), + onPush(onOperation), + mergeMap(op => + fromPromise( + new Promise(res => { + resolve = res; + }).then(() => ({ + hasNext: false, + stale: false, + data: 'test', + operation: op, + })) + ) + ) + ); + + const client = createClient({ + url: 'test', + exchanges: [exchange], + }); + + const operation = makeOperation('query', queryOperation, { + ...queryOperation.context, + requestPolicy: 'cache-first', + }); + + pipe(client.executeRequestOperation(operation), subscribe(onResult)); + expect(onOperation).toHaveBeenCalledTimes(1); + + client.reexecuteOperation(operation); + await Promise.resolve(); + client.reexecuteOperation(operation); + await Promise.resolve(); + client.reexecuteOperation(operation); + await Promise.resolve(); + + expect(onOperation).toHaveBeenCalledTimes(1); + + resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(onOperation).toHaveBeenCalledTimes(1); + expect(onResult).toHaveBeenCalledTimes(1); + }); }); describe('shared sources behavior', () => { diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 3a7c0f2fde..1eeda65e61 100755 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -724,7 +724,7 @@ export const Client: new (opts: ClientOptions) => Client = function Client( queue.push(operation); Promise.resolve().then(dispatchOperation); } else { - dispatched.delete(operation.key); + if (queued) dispatched.delete(operation.key); Promise.resolve().then(dispatchOperation); } }