Skip to content

feat(client): add retryExchange and a dev-only devtoolsExchange - #4450

Open
gilgardosh wants to merge 3 commits into
claude/urql-step-8a-graphql-urlfrom
claude/urql-step-8b-retry-devtools
Open

feat(client): add retryExchange and a dev-only devtoolsExchange#4450
gilgardosh wants to merge 3 commits into
claude/urql-step-8a-graphql-urlfrom
claude/urql-step-8b-retry-devtools

Conversation

@gilgardosh

@gilgardosh gilgardosh commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Step 8b of 10 — the last step in the urql quick-wins sequence. Tracking doc in #4437.

⚠️ Stacked on #4449 (step 8a) → #4448#4447#4446#4444. Retarget as the stack merges.

retryExchange

A single network blip was fatal: the operation failed, a toast appeared, and nothing recovered short of the user navigating again.

Retries fire on error.networkError only — never on GraphQL errors, which are answers rather than failures and won't change on a second attempt.

⚠️ Correction: mutations are NOT excluded by default

An earlier revision of this PR claimed @urql/exchange-retry excludes 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:

if (!res.error || (retryIf ? !retryIf(res.error, res.operation) : !retryWith && !res.error.networkError)) {

There is no operation.kind check in that decision. The exchange's only kind reference is an unrelated teardown filter. So the original retryIf: error => !!error.networkError would have resubmitted any mutation that failed mid-flight — a createBusiness or deleteCharge that timed out after the server applied it would have been sent twice, and none of our mutations are idempotent.

The guard is now explicit:

retryIf: (error, operation) => operation.kind !== 'mutation' && !!error.networkError,

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

devtoolsExchange (dev)  →  mapExchange  →  [cacheExchange slot]  →  authExchange  →  retryExchange  →  fetchExchange

retryExchange sits after authExchange, immediately before fetchExchange, so auth observes a single settled result rather than each attempt and a retry can never drive didAuthError/refreshAuth. This is reversed from the original plan, which had it before authExchange — 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 cacheExchange belongs, for when normalized caching lands. Nothing occupies that slot today — passing an explicit exchanges array is exactly why urql installs no cache of its own.

devtoolsExchange

Added 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:

devtools in prod bundle?      absent (tree-shaken)
retryExchange in prod bundle? present, as expected

Two harness gotchas, for the next person

Extending urql-client.test.ts needed both:

  1. Its vi.mock('urql', ...) is wholesale — only four exports. The new modules need their own vi.mock entries or the real ones load into the mocked graph.
  2. The mock factories needed real call signatures. Without them .mock.calls[0][0] types as an empty tuple, and tsc rejects the test while vitest passes it happily. I hit this: the suite was green while tsc --noEmit had three errors, which also silently skipped the vite build (the script is tsc && vite build) and left a bundle check reading a stale dist.

Testing

yarn test:client   48 files, 378 passed, 0 skipped
yarn lint          0 errors
tsc --noEmit       clean
vite build         ✓ built

All 14 pre-existing auth cases still pass. renovate.json's urql group already matches @urql{/,}**, so both new packages are covered without a change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TbrGL3NndzRiEwJkwHxnbm

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 the DEV condition 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.

Comment thread packages/client/src/providers/urql.tsx Outdated
@gilgardosh
gilgardosh force-pushed the claude/urql-step-8a-graphql-url branch from fcd2717 to 10aad16 Compare September 12, 2026 12:44
claude and others added 3 commits September 12, 2026 12:51
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
gilgardosh force-pushed the claude/urql-step-8a-graphql-url branch from 10aad16 to 4d4a0f1 Compare September 12, 2026 12:53
@gilgardosh
gilgardosh force-pushed the claude/urql-step-8b-retry-devtools branch from e9e6db1 to 64ebb2e Compare September 12, 2026 12:53
@gilgardosh
gilgardosh deployed to accounter-fullstack September 12, 2026 12:53 — with GitHub Actions Active
@gilgardosh
gilgardosh deployed to accounter-fullstack September 12, 2026 12:53 — with GitHub Actions Active
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.

3 participants