From 0fdf8dbf2916d106af38c5af5e55677fb5918eab Mon Sep 17 00:00:00 2001 From: hamed bavar Date: Sun, 9 Aug 2026 18:18:32 +0330 Subject: [PATCH 1/2] fix(query-core): clear stale select error when observer switches to a query without data --- .changeset/clear-stale-select-error.md | 5 ++ .../src/__tests__/queryObserver.test.tsx | 76 +++++++++++++++++++ packages/query-core/src/queryObserver.ts | 4 + 3 files changed, 85 insertions(+) create mode 100644 .changeset/clear-stale-select-error.md diff --git a/.changeset/clear-stale-select-error.md b/.changeset/clear-stale-select-error.md new file mode 100644 index 00000000000..172e482aeb2 --- /dev/null +++ b/.changeset/clear-stale-select-error.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': patch +--- + +fix(query-core): clear a stale `select` error when the observer switches to a query without data, so the previous query's select error no longer leaks into the new pending result diff --git a/packages/query-core/src/__tests__/queryObserver.test.tsx b/packages/query-core/src/__tests__/queryObserver.test.tsx index 557ef79614d..cb6d10f5960 100644 --- a/packages/query-core/src/__tests__/queryObserver.test.tsx +++ b/packages/query-core/src/__tests__/queryObserver.test.tsx @@ -605,6 +605,82 @@ describe('queryObserver', () => { }) }) + it('should not leak the select error of the previous query into the result of a different query', async () => { + const key1 = queryKey() + const key2 = queryKey() + const observer = new QueryObserver(queryClient, { + queryKey: key1, + queryFn: () => sleep(10).then(() => ({ count: 1 })), + select: (): { count: number } => { + throw new Error('selector error') + }, + }) + const unsubscribe = observer.subscribe(() => {}) + await vi.advanceTimersByTimeAsync(10) + expect(observer.getCurrentResult()).toMatchObject({ + status: 'error', + }) + + observer.setOptions({ + queryKey: key2, + queryFn: () => sleep(10).then(() => ({ count: 2 })), + select: (data) => data, + }) + + expect(observer.getCurrentResult()).toMatchObject({ + status: 'pending', + data: undefined, + error: null, + }) + + await vi.advanceTimersByTimeAsync(10) + unsubscribe() + + expect(observer.getCurrentResult()).toMatchObject({ + status: 'success', + data: { count: 2 }, + error: null, + }) + }) + + it('should clear the select error when the query is reset', async () => { + const key = queryKey() + let shouldThrow = true + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => sleep(10).then(() => ({ count: 1 })), + select: (data): { count: number } => { + if (shouldThrow) { + throw new Error('selector error') + } + return data + }, + }) + const unsubscribe = observer.subscribe(() => {}) + await vi.advanceTimersByTimeAsync(10) + expect(observer.getCurrentResult()).toMatchObject({ + status: 'error', + }) + + shouldThrow = false + queryClient.resetQueries({ queryKey: key }) + + expect(observer.getCurrentResult()).toMatchObject({ + status: 'pending', + data: undefined, + error: null, + }) + + await vi.advanceTimersByTimeAsync(10) + unsubscribe() + + expect(observer.getCurrentResult()).toMatchObject({ + status: 'success', + data: { count: 1 }, + error: null, + }) + }) + it('should structurally share the selector', async () => { const key = queryKey() let count = 0 diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 954c969d548..2f6f6385b6b 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -545,6 +545,10 @@ export class QueryObserver< this.#selectError = selectError as TError } } + } 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 } if (this.#selectError) { From 68d1adb9c074d48b042db1905a8a09abf6435e6b Mon Sep 17 00:00:00 2001 From: hamed bavar Date: Sun, 9 Aug 2026 22:00:08 +0330 Subject: [PATCH 2/2] fix(query-core): reset isPlaceholderData on select-error results --- .changeset/clear-stale-select-error.md | 2 +- .../src/__tests__/queryObserver.test.tsx | 53 +++++++++++++++++++ packages/query-core/src/queryObserver.ts | 1 + 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/.changeset/clear-stale-select-error.md b/.changeset/clear-stale-select-error.md index 172e482aeb2..7b864d66d00 100644 --- a/.changeset/clear-stale-select-error.md +++ b/.changeset/clear-stale-select-error.md @@ -2,4 +2,4 @@ '@tanstack/query-core': patch --- -fix(query-core): clear a stale `select` error when the observer switches to a query without data, so the previous query's select error no longer leaks into the new pending result +fix(query-core): clear a stale `select` error when the observer switches to a query without data, and reset `isPlaceholderData` on select-error results to match the declared result types, so a previous query's select error no longer leaks into the new result diff --git a/packages/query-core/src/__tests__/queryObserver.test.tsx b/packages/query-core/src/__tests__/queryObserver.test.tsx index cb6d10f5960..c5dcc69e428 100644 --- a/packages/query-core/src/__tests__/queryObserver.test.tsx +++ b/packages/query-core/src/__tests__/queryObserver.test.tsx @@ -643,6 +643,59 @@ describe('queryObserver', () => { }) }) + it('should not leak a stale select error through the memoized placeholderData path', async () => { + const keyA = queryKey() + const keyB = queryKey() + const keyC = queryKey() + const placeholder = { count: 0 } + const observer = new QueryObserver(queryClient, { + queryKey: keyA, + queryFn: () => sleep(10).then(() => ({ count: 1 })), + placeholderData: placeholder, + select: (data) => ({ selected: data.count }), + }) + const unsubscribe = observer.subscribe(() => {}) + await vi.advanceTimersByTimeAsync(10) + expect(observer.getCurrentResult()).toMatchObject({ + status: 'success', + data: { selected: 1 }, + }) + + observer.setOptions({ + queryKey: keyB, + queryFn: () => sleep(10).then(() => ({ count: 2 })), + placeholderData: placeholder, + select: (): { selected: number } => { + throw new Error('selector error') + }, + }) + expect(observer.getCurrentResult()).toMatchObject({ + status: 'error', + }) + + observer.setOptions({ + queryKey: keyC, + queryFn: () => sleep(10).then(() => ({ count: 3 })), + placeholderData: placeholder, + select: (data) => ({ selected: data.count }), + }) + expect(observer.getCurrentResult()).toMatchObject({ + status: 'success', + data: { selected: 0 }, + error: null, + isPlaceholderData: true, + }) + + await vi.advanceTimersByTimeAsync(10) + unsubscribe() + + expect(observer.getCurrentResult()).toMatchObject({ + status: 'success', + data: { selected: 3 }, + error: null, + }) + }) + it('should clear the select error when the query is reset', async () => { const key = queryKey() let shouldThrow = true diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 2f6f6385b6b..437ce3bab78 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -556,6 +556,7 @@ export class QueryObserver< data = this.#selectResult errorUpdatedAt = Date.now() status = 'error' + isPlaceholderData = false } const isFetching = newState.fetchStatus === 'fetching'