Skip to content

(core) - Refetch queries invalidated while their request is in flight - #3925

Open
Karanvir1729 wants to merge 1 commit into
urql-graphql:mainfrom
Karanvir1729:fix/core-cache-inflight-invalidation
Open

Karanvir1729 wants to merge 1 commit into
urql-graphql:mainfrom
Karanvir1729:fix/core-cache-inflight-invalidation

Conversation

@Karanvir1729

Copy link
Copy Markdown

Refs #3157, #3912. No issue is open for this.

Summary

When a mutation result invalidates a typename while a query with that typename has a request in flight, the default cacheExchange can fail to refetch the query. The in-flight answer, which may predate the mutation, is then cached and delivered with stale: false, and the app stays on it until the next invalidation.

Two paths lead there:

  • The query has a cached result. cacheExchange deletes it and calls reexecuteOperation, and the client drops that operation if the key is still in dispatched (nextOperation in packages/core/src/client.ts). That deduplication is deliberate (fix(core): Strictly deduplicate operations for cache-and-network and network-only #3157), so this PR leaves it alone.
  • The query has no cached result, either on its first request or because an earlier mutation already deleted it. operationCache has no entry for it, so the invalidation skips it. This covers two mutations close together, where the second lands during the refetch caused by the first.

Measured at f57b089 with a real Client, cacheExchange and fetchExchange over a faked fetch that computes each answer when the request arrives and holds query answers. Each row gave the same outcome in 3 of 3 runs before and 3 of 3 after. The server ends at v2, except where noted.

scenario before: queries sent, last result after
mutation with nothing in flight (control) 2, v2 2, v2
mutation during the first request 1, v1, and a later cache-first read gives v1 2, v2, and that read gives v2
mutation during cache-and-network's request 2, v1 3, v2
second mutation during the first mutation's refetch (server at v3) 2, v2 3, v3
network-only query merged into an identical one in flight, sent after the mutation 1, both promises v1 2, both promises v2
refetch in flight, mutation sends another, the newer answers first 3, v1 4, v2
same, the older answers first 3, v2 4, v2

Set of changes

  • packages/core/src/exchanges/cache.ts: records the typenames invalidated while each query's request is in flight. If the request's last result has one of them, the result isn't cached, is marked stale: true, and the query is reexecuted as network-only. This is the pattern fix(graphcache): Deduplicate cache misses of in-flight operations #3912 uses in Graphcache for subscription results. The client's deduplication is unchanged, so merged queries still share one request at a time.
  • packages/core/src/exchanges/cache.test.ts: 13 tests with a real Client and a network exchange the test answers by hand, with no timers, so they can't pass on timing. They include the failure paths: a failed query, a failed mutation, an older request failing after a newer one answered, a mutation with no matching typename, teardown, and incremental results.
  • A patch changeset for @urql/core. Not breaking.

Behaviour worth checking:

  • The result only may be older. A mutation the server ran before computing the query still costs one refetch, the same count as when the query finishes first.
  • toPromise() on such a query now resolves with the refetch's result, or its error if the refetch fails.
  • Tracking is per key, and the client can have two requests in flight for one key: once a query has a result, its own stale re-emission clears dispatched. That is where the extra request in the last two rows comes from. In that case an older answer can show briefly with stale: false before the refetch answers, and if the oldest request answers or fails after the refetch too, it still wins, as it does today. One of the two failing also clears the tracking for the other, so a mutation during the rest of that request is missed. Cancelling superseded requests in fetchExchange would close these; it is left out of this change.
  • additionalTypenames on a subscription counts as an invalidation here, as it already does for cached results. A steady stream of such events refetches back to back, one request at a time, with stale set until a request completes with no event during it, and toPromise() stays pending that long. Before this change it resolved on the first answer.

Checks:

  • vitest run packages/core/src/exchanges/cache.test.ts: without the cache.ts change 8 of 22 fail, 3 of 3 runs; with it all 22 pass, 3 of 3 runs. The other 5 new tests are controls that pass either way.
  • vitest run packages/core: 19 files, 202 tests pass, 3 of 3 runs. vitest run packages/react-urql/src: 10 files, 55 tests pass, 1 run.
  • ESLint and Prettier on the changed files and tsc --noEmit in packages/core are clean. Breaking any one of 11 parts of the change, one at a time, makes at least one test fail.
  • Not run: the other packages' suites (only the root, @urql/core and urql were installed), the Cypress tests, the builds, and root pnpm run check.

AI assistance: Claude (Claude Code) investigated this, wrote the fix and the tests, ran the checks above, and drafted this description. I have not run them or reviewed the change by hand yet.

A mutation or subscription result that invalidates a typename while a
query with that typename has a request in flight could fail to refetch
the query: the client deduplicated the reexecute, or the document cache
had no entry for the query yet. The in-flight answer was then cached and
delivered as fresh even though it may predate the mutation.

cacheExchange now records the typenames invalidated during a query's
request. If the request's last result has one of them, the result isn't
cached, is marked as stale, and the query is reexecuted as network-only.
The client's deduplication is unchanged.
@changeset-bot

changeset-bot Bot commented Nov 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1387ec5

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@urql/core Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant