feat(client): add retryExchange and a dev-only devtoolsExchange - #4450
Open
gilgardosh wants to merge 3 commits into
Open
feat(client): add retryExchange and a dev-only devtoolsExchange#4450gilgardosh wants to merge 3 commits into
gilgardosh wants to merge 3 commits into
Conversation
gilgardosh
temporarily deployed
to
accounter-fullstack
September 10, 2026 17:44 — with
GitHub Actions
Inactive
gilgardosh
temporarily deployed
to
accounter-fullstack
September 10, 2026 17:44 — with
GitHub Actions
Inactive
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Prevent mutation retries, add devtools coverage, and correct the changelog wording.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds urql network retry handling and development-only devtools support.
Changes:
- Adds retry and devtools dependencies.
- Updates exchange configuration and tests.
- Updates lockfile and changesets.
File summaries
| File | Summary |
|---|---|
yarn.lock |
Locks the new urql dependencies. |
packages/client/src/providers/urql.tsx |
Configures devtools and retry exchanges; mutation retries require correction. |
packages/client/src/__tests__/urql-client.test.ts |
Tests exchange ordering and retry behavior; devtools coverage is missing. |
packages/client/package.json |
Adds urql exchange dependencies. |
.changeset/urql-retry-and-devtools-exchanges.md |
Documents the feature; mutation behavior wording needs correction. |
.changeset/@accounter_client-4450-dependencies.md |
Records dependency updates. |
Review details
Suppressed comments (2)
.changeset/urql-retry-and-devtools-exchanges.md:10
- This changelog claim is also inaccurate:
@urql/exchange-retry's default only filters for network errors and does not exclude mutations. After making the predicate explicitly reject mutation operations, change this wording from “by default” to “explicitly” so the release note does not promise behavior the current implementation does not provide.
Mutations are excluded by `retryExchange`'s default and stay that way, because none of ours are
packages/client/src/providers/urql.tsx:247
- The new devtools branch is not asserted: the test mocks
devtoolsExchange, but never checks that it is included first in development (or omitted from production). A regression could remove the exchange or invert theDEVcondition while the current suite stays green. Add coverage for the development exchange list and keep an automated production-build check if tree-shaking is part of this contract.
// Dev only, and first so it observes every operation and result. Tree-shaken
// from production builds by the constant condition.
...(import.meta.env.DEV ? [devtoolsExchange] : []),
- Files reviewed: 5/6 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
gilgardosh
force-pushed
the
claude/urql-step-8a-graphql-url
branch
from
September 12, 2026 12:44
fcd2717 to
10aad16
Compare
A single network blip was fatal: the operation failed, a toast appeared, and nothing recovered short of the user navigating again. `retryExchange` now retries on `error.networkError` only — never on GraphQL errors, which are answers rather than failures and will not change on a second attempt. Mutations are excluded by `retryExchange`'s default and stay that way; none of ours are idempotent. Placement matters more than it looks. `retryExchange` sits after `authExchange` and immediately before `fetchExchange`, so auth observes a single settled result rather than each attempt and a retry can never drive `didAuthError` or `refreshAuth`. Two tests pin the ordering and the `retryIf` predicate. `devtoolsExchange` goes first in the chain behind `import.meta.env.DEV`, so the urql browser devtools finally attach in development. A production build confirms it is tree-shaken out and that `retryExchange` is not. The chain also gains a comment marking where `cacheExchange` belongs — between the error handler and auth — for when normalized caching lands. Nothing occupies that slot today: passing an explicit `exchanges` array means urql installs no cache of its own. Extending `urql-client.test.ts` needed two things worth knowing: its `vi.mock` of `urql` is wholesale, so the new modules need their own mocks or the real ones load into the mocked graph; and the mock factories needed real call signatures, without which `.mock.calls[0][0]` types as an empty tuple and `tsc` rejects it while vitest passes. Step 8b of the urql quick-wins sequence, and the last of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbrGL3NndzRiEwJkwHxnbm
I claimed in this PR that `@urql/exchange-retry` excludes mutations by default. It does not. Checking the source, the retry gate is `retryIf`'s return value alone: if (!res.error || (retryIf ? !retryIf(res.error, res.operation) : ...)) There is no `operation.kind` check anywhere in that decision — the one `kind` reference in the exchange is an unrelated teardown filter. So `retryIf: error => !!error.networkError` would have resubmitted any mutation that failed mid-flight, and none of ours are idempotent: a create or delete that timed out after the server had already applied it would have been sent twice. `retryIf` now takes the operation it is already handed and guards on `operation.kind !== 'mutation'`. Queries and subscriptions are unaffected. A test covers the mutation case directly, and the comment at the call site says why the guard exists so it is not mistaken for redundancy with a library default that does not exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbrGL3NndzRiEwJkwHxnbm
gilgardosh
force-pushed
the
claude/urql-step-8a-graphql-url
branch
from
September 12, 2026 12:53
10aad16 to
4d4a0f1
Compare
gilgardosh
force-pushed
the
claude/urql-step-8b-retry-devtools
branch
from
September 12, 2026 12:53
e9e6db1 to
64ebb2e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Step 8b of 10 — the last step in the urql quick-wins sequence. Tracking doc in #4437.
retryExchangeA single network blip was fatal: the operation failed, a toast appeared, and nothing recovered short of the user navigating again.
Retries fire on
error.networkErroronly — never on GraphQL errors, which are answers rather than failures and won't change on a second attempt.An earlier revision of this PR claimed
@urql/exchange-retryexcludes mutations on its own. That was wrong, caught in review, and it was a real duplicate-write bug rather than a documentation slip.Checking the installed source, the retry gate is
retryIf's return value alone:There is no
operation.kindcheck in that decision. The exchange's onlykindreference is an unrelated teardown filter. So the originalretryIf: error => !!error.networkErrorwould have resubmitted any mutation that failed mid-flight — acreateBusinessordeleteChargethat timed out after the server applied it would have been sent twice, and none of our mutations are idempotent.The guard is now explicit:
The comment at the call site says it's load-bearing, so nobody later removes it as redundant with a library default that doesn't exist. A test covers the mutation case directly.
Exchange order
retryExchangesits afterauthExchange, immediately beforefetchExchange, so auth observes a single settled result rather than each attempt and a retry can never drivedidAuthError/refreshAuth. This is reversed from the original plan, which had it beforeauthExchange— that would have re-run the auth logic per attempt for no benefit. A test pins the ordering.The chain also gains a comment marking where
cacheExchangebelongs, for when normalized caching lands. Nothing occupies that slot today — passing an explicitexchangesarray is exactly why urql installs no cache of its own.devtoolsExchangeAdded first in the chain behind
import.meta.env.DEV, so the urql browser devtools finally attach in development. They previously didn't work at all. Verified against a real production build rather than assumed:Two harness gotchas, for the next person
Extending
urql-client.test.tsneeded both:vi.mock('urql', ...)is wholesale — only four exports. The new modules need their ownvi.mockentries or the real ones load into the mocked graph..mock.calls[0][0]types as an empty tuple, andtscrejects the test while vitest passes it happily. I hit this: the suite was green whiletsc --noEmithad three errors, which also silently skipped thevite build(the script istsc && vite build) and left a bundle check reading a staledist.Testing
All 14 pre-existing auth cases still pass.
renovate.json'surqlgroup already matches@urql{/,}**, so both new packages are covered without a change.🤖 Generated with Claude Code
https://claude.ai/code/session_01TbrGL3NndzRiEwJkwHxnbm