Skip to content

React suspense cache poisoning - #3898

Merged
JoviDeCroock merged 2 commits into
urql-graphql:mainfrom
cornedor:fix/react-suspense-cache-poisoning
Jul 10, 2026
Merged

React suspense cache poisoning#3898
JoviDeCroock merged 2 commits into
urql-graphql:mainfrom
cornedor:fix/react-suspense-cache-poisoning

Conversation

@cornedor

Copy link
Copy Markdown
Contributor

Summary

The suspense cache in useQuery can end up holding a promise that nothing will ever replace, and then throws that same promise on every render.

There were two ways to get in this state:

  1. When an in-flight operation was torn down while a component was suspended, the suspense promise stayed in the per-client cache forever and was re-thrown on every future render, permanently freezing the UI.
  2. When a cached suspense promise had already settled (e.g. after a subscription race), re-throwing it caused React to synchronously ping the resolved promise over and over, creating an infinite render loop.

Such entries are now evicted from the suspense cache and the query is executed again.

Set of changes

Only packages/react-urql. Nothing breaking, these were states you couldn't get out of before.

  • src/hooks/useQuery.ts: an onEnd on the result source that evicts the entry and resolves the promise if the source ended without a result, plus a settle handler on cached thenables that evicts them once they settle. In both cases the entry has lost the subscription that would have replaced it, so the next render has to start over.
  • src/hooks/useQuery.suspense.spec.tsx: one test per scenario. Both fail on main: the first hangs on the fallback, the second loops. There's a render counter in the test component so the loop fails as an assertion instead of hanging CI.

cornedor and others added 2 commits July 10, 2026 08:27
Two failing tests for the react bindings' per-client suspense cache:

1. When a teardown operation is dispatched while a useQuery is suspended
   (another consumer unmounting or re-executing, an exchange cancelling
   in-flight operations), the operation's result source ends without a
   result. The suspense promise never resolves, stays in the cache, and
   is re-thrown on every future render, so the component never leaves
   its Suspense fallback again.

2. When a suspense cache entry is (or becomes) an already-settled
   promise, useQuery re-throws it on every render. React re-attaches a
   ping listener each attempt, which fires immediately for a settled
   thenable: an unbounded render loop that freezes the tab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 82f88cf

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

This PR includes changesets to release 1 package
Name Type
urql 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

@JoviDeCroock
JoviDeCroock merged commit 6a98473 into urql-graphql:main Jul 10, 2026
6 checks passed
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.

2 participants