diff --git a/.changeset/clear-stale-select-error.md b/.changeset/clear-stale-select-error.md new file mode 100644 index 00000000000..7b864d66d00 --- /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, 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 557ef79614d..c5dcc69e428 100644 --- a/packages/query-core/src/__tests__/queryObserver.test.tsx +++ b/packages/query-core/src/__tests__/queryObserver.test.tsx @@ -605,6 +605,135 @@ 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 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 + 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..437ce3bab78 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) { @@ -552,6 +556,7 @@ export class QueryObserver< data = this.#selectResult errorUpdatedAt = Date.now() status = 'error' + isPlaceholderData = false } const isFetching = newState.fetchStatus === 'fetching'