Skip to content

fix(query-core): clear stale select error when observer switches to a query without data - #11161

Merged
TkDodo merged 4 commits into
TanStack:mainfrom
hamed-bavar:fix/clear-stale-select-error
Aug 18, 2026
Merged

fix(query-core): clear stale select error when observer switches to a query without data#11161
TkDodo merged 4 commits into
TanStack:mainfrom
hamed-bavar:fix/clear-stale-select-error

Conversation

@hamed-bavar

@hamed-bavar hamed-bavar commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #11160

Problem

When a select function throws, the error is stored on the observer (#selectError) and surfaced on every subsequent result. It is only cleared inside the select block itself, which is skipped entirely while data is undefined. Switching the observer to a different queryKey, or resetting the query, therefore leaves the old error in place: the new pending query reports status: 'error' with the previous query's select error, and with throwOnError / suspense the stale error is thrown into the error boundary while the new query is still fetching.

Solution

In createResult, clear the stored select error whenever a result is built without data to select:

} else if (data === undefined) {
  // a stored select error belongs to previously selected data; once that
  // data is gone (query switch or reset), it must not leak into this result
  this.#selectError = null
}

A select error can only be produced by running select on some data — if the result being built has no data (fresh query after a key switch, or a reset query), that data is gone and the error cannot describe the current result.

The "keep the error while the same data and selector are in place" memoization is untouched: in that case the query's own data is defined (a select error implies the queryFn succeeded), so that path still goes through the select block as before. If the selector genuinely throws again for new data, the error is re-created — only stale errors are dropped.

Clearing is done at result-creation time rather than on query switch (#updateQuery) because resetQueries replaces the state of the same Query instance — a switch-based clear would miss it.

Tests

Two regression tests in queryObserver.test.tsx, written first and failing on main with the exact buggy behavior (status: 'error' where 'pending' is expected):

  • select throws on query A → setOptions to key B → result is pending with error: null, then succeeds
  • select throws → resetQueries → same expectation

Full query-core (509) and react-query (417) suites pass.

Related but distinct from #11011, which handles isPlaceholderData when select throws on placeholder data; the two changes touch different branches and compose.

Summary by CodeRabbit

  • Bug Fixes

    • Cleared stale selection errors when switching to a query without data.
    • Cleared previous selection errors when resetting a query before a successful refetch.
    • Prevented errors from earlier query results from appearing in subsequent query states.
    • Correctly reset placeholder-data status when transitioning between query states.
  • Tests

    • Added regression coverage for query switching, placeholder data, memoized results, and reset scenarios.

Update

After the review discussion below, this PR also resets isPlaceholderData in the select-error block. That is the same one-line fix as #11011 by @chatman-media (credit to that PR); folding it in aligns the runtime with the error-result types in types.ts (which declare isPlaceholderData: false) and closes the memoized-placeholderData leak found in review, so this PR no longer depends on #11011's merge order. A regression test covers the full leak chain.

@coderabbitai

coderabbitai Bot commented Aug 9, 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 Plus

Run ID: 4ef16b30-4108-4469-af8c-7512e9e8c001

📥 Commits

Reviewing files that changed from the base of the PR and between e546d03 and a650397.

📒 Files selected for processing (3)
  • .changeset/clear-stale-select-error.md
  • packages/query-core/src/__tests__/queryObserver.test.tsx
  • packages/query-core/src/queryObserver.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • .changeset/clear-stale-select-error.md
  • packages/query-core/src/tests/queryObserver.test.tsx
  • packages/query-core/src/queryObserver.ts

Included review availability: Your plan includes up to 10 reviews per rolling hour; 1 remains after this review.


📝 Walkthrough

Walkthrough

QueryObserver.createResult now clears stale select errors when data is unavailable. Regression tests cover query switches, placeholder data, and query resets. A patch changeset updates @tanstack/query-core.

Changes

Select error reset

Layer / File(s) Summary
Clear selection errors without data
packages/query-core/src/queryObserver.ts
QueryObserver.createResult clears the cached selection error when no data is available.
Validate query switch, placeholder, and reset behavior
packages/query-core/src/__tests__/queryObserver.test.tsx, .changeset/clear-stale-select-error.md
Regression tests cover query switches, memoized placeholder data, and query resets. The changeset records a patch release for @tanstack/query-core.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to a6503

The change clears stale select errors when an observer switches queries or resets, preventing incorrect error states during refetching; no actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the query-core fix for stale select errors when an observer switches to a query without data.
Description check ✅ Passed The description clearly documents the problem, solution, tests, linked issue, and placeholder-data update, but omits the template checklist headings.
Linked Issues check ✅ Passed The implementation and regression tests address issue #11160, including query switches, resets, pending results, and preservation of valid select errors.
Out of Scope Changes check ✅ Passed The code change, regression tests, changeset, and placeholder-data correction directly support the linked issue and stated objectives.
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.

@MILLERMARRU MILLERMARRU left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Traced #selectError through createResult to confirm this. It's an instance field that only gets touched inside the options.select && data !== undefined && !skipSelect block, so when the observer switches to a query whose current data is undefined (a fresh key, or right after a reset), that block is skipped entirely and whatever #selectError was left over from the previous query keeps sitting there. Then the unconditional if (this.#selectError) a few lines down turns that stale error into status: 'error' on a query that hasn't even started fetching yet. The fix closes exactly that hole by clearing it in the one case the original condition didn't cover.

One combination I don't see a test for: skipSelect gets set to true in the placeholderData memoization path (when prevResult.isPlaceholderData and the same placeholderData option is being reused), and in that path data ends up defined again (it's the placeholder), so neither branch of the if/else if runs. If a query switch lands on that exact memoized-placeholder path while a #selectError from the prior query is still set, I think it could still leak through. Narrow edge case, probably not worth blocking on, but might be worth a third branch or a comment confirming it's out of scope.

@hamed-bavar

hamed-bavar commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Good catch. I spent some time trying to reproduce this, and you're right that the memoized-placeholder path is the one exit my if/else if doesn't cover: skipSelect is true, data holds the reused placeholder, and a leftover #selectError would go straight into the if (this.#selectError) block.

But I couldn't reach that state without going through the bug #11011 fixes. The memoization only runs when prevResult.isPlaceholderData is true. Any result built while #selectError is set is an error result, and error results only keep isPlaceholderData: true because of the bug #11011 removes. #selectError is never touched outside createResult, so if the previous result wasn't an error, the stored error was already null.

The one repro I found on current main:

  1. selector succeeds on query A, so #selectResult is populated
  2. switch to query B with the same placeholderData reference; the selector throws on B's placeholder, and that error result wrongly keeps isPlaceholderData: true
  3. switch to query C: the memoization sees prevResult.isPlaceholderData, reuses A's stale result, and B's error lands on C

With #11011 applied step 3 is unreachable, so a clearing branch here turns into dead code the moment that merges. My preference is to leave this branch alone and let #11011 close the hole

@MILLERMARRU

Copy link
Copy Markdown

Traced this against current main line by line and your chain is right, isPlaceholderData only ever gets set at the one spot in the placeholder block, never reset in the if (this.#selectError) block, and #selectError is only ever written inside createResult's own select try/catch. So yes, the only way to reach prevResult.isPlaceholderData === true on a result that's actually an error is exactly the state #11011 fixes. Also checked types.ts: both QueryObserverLoadingErrorResult and QueryObserverRefetchErrorResult hard-code isPlaceholderData: false, so what's happening on current main isn't just an edge case, it's the runtime violating a combination the type system itself declares impossible. That's a stronger argument for #11011 landing than just this thread's repro.

Where I'd push back a little: "leave this branch alone and let #11011 close the hole" assumes #11011 actually lands, and it's been open since June 30 with zero reviews. This PR is moving, that one isn't. If this merges and releases before #11011 does (which looks likely at this rate), the gap you found is a known, filed, reachable bug shipping in a release, on purpose, because it's waiting on a dependency that has no timeline.

Given that, I'd just fold #11011's one-line fix into this PR instead of leaving the two coupled:

if (this.#selectError) {
  error = this.#selectError
  data = this.#selectResult
  errorUpdatedAt = Date.now()
  status = 'error'
  isPlaceholderData = false
}

That's not dead code the way a guard on the memoization branch would be, it's the actual fix for the type-contract violation, and it happens to also close the memoization hole as a side effect, since prevResult.isPlaceholderData can never observe true on an error result once this lands. #11161 stops depending on #11011 merging at all, either order works, and if #11011 does land later the same line collides in a rebase, which is a one-second fix, not a real cost. Feels like the more complete version of what this PR is already doing rather than a separate concern.

@hamed-bavar

Copy link
Copy Markdown
Contributor Author

Fair enough. The types.ts angle settles it for me: if the error result variants declare isPlaceholderData: false, the runtime shouldn't be able to produce anything else, and waiting on a PR that's had no reviews since June to restore that wasn't a great bet.

I folded the line into the error block here, plus a regression test for the exact chain from this thread (stable placeholderData reference; selector succeeds on A, throws on B, works on C; before the change the memoization handed A's stale result to C along with B's error).

Credit where due: that line is #11011 by @chatman-media, which also comes with its own test for the placeholder case. If that lands first I'll rebase; the collision is trivial in either order.

@TkDodo

TkDodo commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

#11011 was just merged

@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@nx-cloud

nx-cloud Bot commented Aug 18, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit a650397

Command Status Duration Result
nx affected --targets=test:sherif,test:knip,tes... ✅ Succeeded 4m 55s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 1m 50s View ↗

☁️ Nx Cloud last updated this comment at 2026-08-18 08:52:39 UTC

@pkg-pr-new

pkg-pr-new Bot commented Aug 18, 2026

Copy link
Copy Markdown
More templates

@tanstack/angular-query-experimental

npm i https://pkg.pr.new/@tanstack/angular-query-experimental@11161

@tanstack/eslint-plugin-query

npm i https://pkg.pr.new/@tanstack/eslint-plugin-query@11161

@tanstack/lit-query

npm i https://pkg.pr.new/@tanstack/lit-query@11161

@tanstack/preact-query

npm i https://pkg.pr.new/@tanstack/preact-query@11161

@tanstack/preact-query-devtools

npm i https://pkg.pr.new/@tanstack/preact-query-devtools@11161

@tanstack/preact-query-persist-client

npm i https://pkg.pr.new/@tanstack/preact-query-persist-client@11161

@tanstack/query-async-storage-persister

npm i https://pkg.pr.new/@tanstack/query-async-storage-persister@11161

@tanstack/query-broadcast-client-experimental

npm i https://pkg.pr.new/@tanstack/query-broadcast-client-experimental@11161

@tanstack/query-core

npm i https://pkg.pr.new/@tanstack/query-core@11161

@tanstack/query-devtools

npm i https://pkg.pr.new/@tanstack/query-devtools@11161

@tanstack/query-persist-client-core

npm i https://pkg.pr.new/@tanstack/query-persist-client-core@11161

@tanstack/query-sync-storage-persister

npm i https://pkg.pr.new/@tanstack/query-sync-storage-persister@11161

@tanstack/react-query

npm i https://pkg.pr.new/@tanstack/react-query@11161

@tanstack/react-query-devtools

npm i https://pkg.pr.new/@tanstack/react-query-devtools@11161

@tanstack/react-query-next-experimental

npm i https://pkg.pr.new/@tanstack/react-query-next-experimental@11161

@tanstack/react-query-persist-client

npm i https://pkg.pr.new/@tanstack/react-query-persist-client@11161

@tanstack/solid-query

npm i https://pkg.pr.new/@tanstack/solid-query@11161

@tanstack/solid-query-devtools

npm i https://pkg.pr.new/@tanstack/solid-query-devtools@11161

@tanstack/solid-query-persist-client

npm i https://pkg.pr.new/@tanstack/solid-query-persist-client@11161

@tanstack/svelte-query

npm i https://pkg.pr.new/@tanstack/svelte-query@11161

@tanstack/svelte-query-devtools

npm i https://pkg.pr.new/@tanstack/svelte-query-devtools@11161

@tanstack/svelte-query-persist-client

npm i https://pkg.pr.new/@tanstack/svelte-query-persist-client@11161

@tanstack/vue-query

npm i https://pkg.pr.new/@tanstack/vue-query@11161

@tanstack/vue-query-devtools

npm i https://pkg.pr.new/@tanstack/vue-query-devtools@11161

commit: a650397

@TkDodo
TkDodo merged commit 34f7cee into TanStack:main Aug 18, 2026
9 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.

Stale select error from a previous query leaks into the next query's pending result

3 participants