Skip to content

fix(query-core): resolve suspense when query data is set programmatically#11036

Open
AmariahAK wants to merge 6 commits into
TanStack:mainfrom
AmariahAK:main
Open

fix(query-core): resolve suspense when query data is set programmatically#11036
AmariahAK wants to merge 6 commits into
TanStack:mainfrom
AmariahAK:main

Conversation

@AmariahAK

@AmariahAK AmariahAK commented Jul 8, 2026

Copy link
Copy Markdown

PR Description

When `useSuspenseQuery` is used with `streamedQuery` or `setQueryData`,
the Suspense boundary stays stuck on the original fetch promise even
after data arrives in the cache. This happens because `fetchOptimistic`
in `queryObserver.ts` returned `query.fetch().then(...)` — a promise
that only resolves when the `queryFn` completes, ignoring cache updates
from other sources.

**Fix:** Modified `fetchOptimistic` to use `Promise.race` between the
fetch promise and a query cache subscriber. The subscriber listens for
`'updated'` events on the same query hash and resolves immediately when
`query.state.data` becomes defined — whether via `setQueryData`, a
streamed chunk, or any other mechanism. The fetch continues running
in the background; only the suspense block is released early.

**Why `Promise.race` over alternatives:**
- Resolving the fetch promise early would abort the ongoing fetch
  (breaks streamed queries' abort signal — the approach attempted in
  preview PR #10994)
- Switching to an observer-based throw in `useBaseQuery` would add
  undue complexity
- `Promise.race` with the existing cache notification system is a
  20-line diff that touches nothing else

**Files changed:**

| File | Change |
|------|--------|
| `packages/query-core/src/queryObserver.ts` | +20/−1 in `fetchOptimistic` |
| `packages/react-query/src/__tests__/useSuspenseQuery.test.tsx` | +102 lines (3 new tests) |
| `.changeset/resolve-suspense-setquerydata.md` | changeset (patch) |

**New tests:**
1. `setQueryData` called while fetch is in-flight → suspense releases
2. `streamedQuery` first chunk → suspense releases
3. `setQueryData` before component mounts → suspense never shows

## ✅ Checklist

- [x] I have followed the steps in the
  [Contributing guide](https://github.com/TanStack/query/blob/main/CONTRIBUTING.md).
- [x] I have tested this code locally with `pnpm run test:pr`.

@tanstack/query-core:test:lib → 546 passed, 0 failures
@tanstack/react-query:test:lib → 567 passed, 0 failures


## 🚀 Release Impact

- [x] This change affects published code, and I have generated a
  [changeset](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).
- [ ] This change is docs/CI/dev-only (no release).

Closes #10924

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **Bug Fixes**
  * Improved React Suspense behavior when cached query data is set programmatically during an in-flight request, allowing cached data to render immediately.
  * Ensured Suspense releases as soon as streamed/experimental streamed query data yields its first available chunk.
  * Preloading cache data before mount prevents unnecessary initial suspension; additionally, setting `undefined` no longer incorrectly releases Suspense.
* **Tests**
  * Added Suspense coverage for manual cache updates mid-fetch, streamed first-chunk release, preloaded-cache mounting, and the `undefined` negative case.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

…ally

When useSuspenseQuery is used and setQueryData populates the cache
while a fetch is in-flight, the suspense boundary stays stuck on the
original fetch promise. This prevents streamedQuery and manual cache
updates from releasing suspense.

Modify fetchOptimistic in queryObserver to use Promise.race between
the fetch promise and a cache subscriber that listens for 'updated'
events. When data appears in the cache via setQueryData or a streamed
chunk, the race resolves immediately without aborting the fetch.
This allows the suspense boundary to release and show the available
data while the fetch continues in the background.

Closes TanStack#10924

Co-authored-by: atlarix-agent <agent@atlarix.dev>
Co-authored-by: atlarix-agent <agent@atlarix.dev>
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 200cb2ef-c4fb-464b-ac3b-2844f5fdfa3c

📥 Commits

Reviewing files that changed from the base of the PR and between 8bf5144 and 7bbe725.

📒 Files selected for processing (2)
  • packages/query-core/src/queryObserver.ts
  • packages/react-query/src/__tests__/useSuspenseQuery.test.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/query-core/src/queryObserver.ts
  • packages/react-query/src/tests/useSuspenseQuery.test.tsx

📝 Walkthrough

Walkthrough

fetchOptimistic now resolves against cache updates as well as fetch completion, allowing Suspense to release when query data is set programmatically or streamed into the cache. Tests cover in-flight updates, streamed chunks, preloaded data, and undefined values.

Changes

Suspense Resolution Fix

Layer / File(s) Summary
fetchOptimistic race implementation
packages/query-core/src/queryObserver.ts
Replaces the single fetch completion path with a Promise.race between fetch completion and a cache subscription that detects defined data for the matching query.
Suspense release validation and release metadata
packages/react-query/src/__tests__/useSuspenseQuery.test.tsx, .changeset/resolve-suspense-setquerydata.md
Adds coverage for programmatic, streamed, preloaded, and undefined cache data, and records a patch changeset.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant QueryObserver
  participant QueryFn
  participant QueryCache
  QueryObserver->>QueryFn: query.fetch()
  QueryObserver->>QueryCache: subscribe(updated)
  alt cache data arrives first
    QueryCache-->>QueryObserver: updated event with defined data
    QueryObserver->>QueryCache: unsubscribe()
    QueryObserver-->>QueryObserver: createResult()
  else fetch completes first
    QueryFn-->>QueryObserver: fetch resolved
    QueryObserver->>QueryCache: unsubscribe()
    QueryObserver-->>QueryObserver: createResult()
  end
Loading

Suggested labels: package: query-core, package: react-query

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: resolving Suspense when query data is set programmatically.
Description check ✅ Passed The description covers the change, checklist items, release impact, and test notes, though its headings differ from the template.
Linked Issues check ✅ Passed The code and tests address #10924 by releasing Suspense when cache data appears from setQueryData or streamed chunks.
Out of Scope Changes check ✅ Passed The changes are focused on the Suspense fix, related tests, and a changeset, with no obvious unrelated additions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0f9c9f6e-0fdd-44e8-b976-3179c1f4098f

📥 Commits

Reviewing files that changed from the base of the PR and between 64241b8 and 60a34d7.

📒 Files selected for processing (3)
  • .changeset/resolve-suspense-setquerydata.md
  • packages/query-core/src/queryObserver.ts
  • packages/react-query/src/__tests__/useSuspenseQuery.test.tsx

Comment thread packages/query-core/src/queryObserver.ts
…h rejection

unsubscribe() was only called in the .then() success path. If
query.fetch() rejected before the cache subscriber resolved the race,
the subscription leaked — staying registered on QueryCache indefinitely
and checking a stale queryHash on every 'updated' event.

Use .finally() to ensure unsubscribe() runs on both success and
rejection, preserving the original promise type through the chain.

Co-authored-by: atlarix-agent <agent@atlarix.dev>
@gonzoblasco

Copy link
Copy Markdown

Thanks for this PR — the Promise.race approach is elegantly minimal compared to alternatives. I've been reviewing the implementation and have a few observations:


1. Floating promise when fetch wins the race

When the fetch promise resolves first, .finally(() => unsubscribe()) correctly removes the cache subscriber, but the second promise (the subscriber one) remains in a permanently pending state. This is benign in practice (the listener is removed, so no leaks via the Set), but some linting rules (@typescript-eslint/no-floating-promises) and environments flag this pattern.

A cleaner approach would be to make the subscriber promise self-resolving on cleanup:

let resolveEarly: ((result: QueryObserverResult<TData, TError>) => void) | undefined

const cachePromise = new Promise<QueryObserverResult<TData, TError>>((resolve) => {
  resolveEarly = resolve
  unsubscribe = this.#client.getQueryCache().subscribe((event) => {
    if (
      event.type === 'updated' &&
      event.query.queryHash === query.queryHash &&
      query.state.data !== undefined
    ) {
      unsubscribe()
      resolve(this.createResult(query, defaultedOptions))
    }
  })
})

return Promise.race([
  query.fetch().then(() => {
    return this.createResult(query, defaultedOptions)
  }).finally(() => {
    unsubscribe()
    // Resolve the floating promise to avoid it pending forever
    if (resolveEarly) {
      // Subscriber never fired — resolve with current result so the promise settles
      // This value is ignored by Promise.race since the fetch branch already won
      resolveEarly(this.createResult(query, defaultedOptions))
    }
  }),
  cachePromise,
])

This ensures both promises always settle, regardless of which wins.


2. No cleanup if the component unmounts mid-fetch

If the consuming component unmounts while fetchOptimistic is in-flight, the cache subscriber remains active until the fetch promise settles (via .finally). During that window, any setQueryData call on the same queryHash will trigger createResult and resolve the promise — but nobody is listening anymore. Not a correctness bug, but unnecessary work.

Consider adding an AbortSignal-aware cleanup, or at minimum documenting that fetchOptimistic assumes the caller will remain mounted through resolution.


3. Multiple observers on the same query hash

The cache subscriber listens to 'updated' events filtered by queryHash. If two useSuspenseQuery instances share the same key and one receives setQueryData, both subscribers fire. This is actually correct behavior (both should unsuspend), but worth noting in a test case to prevent regressions if the filter logic changes later.


4. Test coverage consideration

The three tests are solid. One edge case that might be worth adding: what happens when setQueryData is called with undefined? The current check query.state.data !== undefined guards against this, but an explicit test would document the intent.


Overall, this is the right approach — much less invasive than #10994 and uses the existing notification system well. The main actionable item is settling the floating promise in point 1.

AmariahAK and others added 3 commits July 15, 2026 12:05
…ndefined test

- Resolve the subscriber promise when fetch succeeds so both branches
  of Promise.race always settle, avoiding a permanently pending promise
- Move resolution into .then() rather than .finally() so fetch failures
  still propagate through Promise.race rejection
- Add test: setQueryData with undefined must NOT release suspense,
  documenting the query.state.data !== undefined guard

Co-authored-by: atlarix-agent <agent@atlarix.dev>
@AmariahAK

Copy link
Copy Markdown
Author

@gonzoblasco hey, apologies for seeing this late
So ive done the things you suggested
So could you re-take a look when you get time?

@gonzoblasco

Copy link
Copy Markdown

@AmariahAK thanks for the quick turnaround. I took a look at the latest commit.

The floating promise fix looks good — the resolveEarly pattern in .then() ensures both branches of Promise.race always settle. The setQueryData(undefined) test is a nice addition that documents the guard correctly.

One thing still open: the unmount cleanup concern. If the component unmounts while fetchOptimistic is in-flight, the cache subscriber stays registered until the fetch promise settles (via .finally). During that window, any updated event for the same queryHash would call resolve(this.createResult(...)) on a stale observer, which is harmless in practice (React won't commit the result) but the subscriber itself leaks in the QueryCache.subscribers Set.

A simple guard would be to check if the observer is still mounted before resolving:

unsubscribe = this.#client.getQueryCache().subscribe((event) => {
  if (
    event.type === 'updated' &&
    event.query.queryHash === query.queryHash &&
    query.state.data !== undefined
  ) {
    unsubscribe()
    resolve(this.createResult(query, defaultedOptions))
  }
})

Could add a this.#isMounted check or use an AbortSignal from the observer's lifecycle. But honestly, for a patch release this is probably fine — the leak is bounded (until fetch completes) and the impact is minimal.

Re: #10994 — Dominik's approach is more structural but has a larger blast radius. I think both PRs solve the core issue, and whichever the maintainers prefer is fine. Yours is the safer incremental fix.

@AmariahAK

Copy link
Copy Markdown
Author

@gonzoblasco thanks for the re-review. On the unmount cleanup concern — I looked into adding a liveness guard (hasListeners() check before resolve) but it breaks during normal suspense: since useSyncExternalStore only subscribes in the commit phase and fetchOptimistic throws during render, the observer has zero listeners at the point the subscriber fires, even for a fully alive component. Any check tied to the listener count would block the cache subscriber from ever resolving.

I agree with your assessment that this is acceptable for a patch release , the subscriber entry lives only until query.fetch() settles via .finally(), and any stale resolve() on a destroyed observer is harmless (React ignores it). Happy to revisit with an AbortSignal-based approach in a follow-up if the maintainers want it, but the cost/benefit doesn't justify holding this fix.

All other points are resolved — floating promise settled, undefined test added, 546/567 tests passing. Ready for another look when you have time.

@gonzoblasco

Copy link
Copy Markdown

@AmariahAK just re-reviewed the latest diff. Everything looks good:

  • ✅ Floating promise settled via resolveEarly in the .then() branch
  • setQueryData(undefined) test documents the guard correctly
  • .finally(() => unsubscribe()) ensures cleanup
  • ✅ Tests pass (546/567)

Your reasoning on the hasListeners() guard makes sense — during Suspense the observer has zero listeners at that point, so any listener-based check would block resolution. Agreed that the bounded subscriber lifetime (until fetch settles) is acceptable for a patch release.

This LGTM. Ready for maintainer review. 🚀

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