diff --git a/.changeset/wild-rabbits-jump.md b/.changeset/wild-rabbits-jump.md new file mode 100644 index 00000000000..1d6b19c286b --- /dev/null +++ b/.changeset/wild-rabbits-jump.md @@ -0,0 +1,6 @@ +--- +'@tanstack/react-query': patch +'@tanstack/query-core': patch +--- + +fix(suspense): skip calling combine when queries would suspend diff --git a/packages/query-core/src/__tests__/queriesObserver.test.tsx b/packages/query-core/src/__tests__/queriesObserver.test.tsx index 504f5b8379a..1adfa6195a6 100644 --- a/packages/query-core/src/__tests__/queriesObserver.test.tsx +++ b/packages/query-core/src/__tests__/queriesObserver.test.tsx @@ -473,6 +473,82 @@ describe('queriesObserver', () => { expect(newCombined.count).toBe(2) }) + it('should skip combine notifications while suspense queries have no data', async () => { + const key = queryKey() + const combine = vi.fn((results: Array) => + results.map((result) => result.data), + ) + const query = { + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + staleTime: Infinity, + suspense: true, + } + + queryClient.setQueryData(key, 'data') + + const observer = new QueriesObserver>(queryClient, [query], { + combine, + }) + + const [rawResult, getCombinedResult] = observer.getOptimisticResult( + [query], + combine, + ) + expect(getCombinedResult(rawResult)).toEqual(['data']) + expect(combine).toHaveBeenCalledTimes(1) + + const unsubscribe = observer.subscribe(() => undefined) + + void queryClient.resetQueries({ queryKey: key }) + expect(combine).toHaveBeenCalledTimes(1) + + unsubscribe() + }) + + it('should skip combine notifications after suspense is enabled without structural changes', async () => { + const key = queryKey() + const combine = vi.fn((results: Array) => + results.map((result) => result.data), + ) + const query = { + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + staleTime: Infinity, + suspense: false, + } + + queryClient.setQueryData(key, 'data') + + const observer = new QueriesObserver>(queryClient, [query], { + combine, + }) + + const [rawResult, getCombinedResult] = observer.getOptimisticResult( + [query], + combine, + ) + expect(getCombinedResult(rawResult)).toEqual(['data']) + expect(combine).toHaveBeenCalledTimes(1) + + const unsubscribe = observer.subscribe(() => undefined) + + observer.setQueries( + [ + { + ...query, + suspense: true, + }, + ], + { combine }, + ) + + void queryClient.resetQueries({ queryKey: key }) + expect(combine).toHaveBeenCalledTimes(1) + + unsubscribe() + }) + it('should handle queries being removed with stable combine reference', () => { const combine = vi.fn((results: Array) => ({ count: results.length, diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index 67dd088f9ae..4fcf8e5d41e 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -249,6 +249,17 @@ export class QueriesObserver< return input as any } + #shouldSkipCombine(): boolean { + return ( + this.#options?.combine !== undefined && + this.#observers.some((observer, index) => { + return ( + observer.options.suspense && this.#result[index]?.data === undefined + ) + }) + ) + } + #findMatchingObservers( queries: Array, ): Array { @@ -294,11 +305,14 @@ export class QueriesObserver< #notify(): void { if (this.hasListeners()) { - const previousResult = this.#combinedResult const newTracked = this.#trackResult(this.#result, this.#observerMatches) - const newResult = this.#combineResult(newTracked, this.#options?.combine) + const shouldSkipCombine = this.#shouldSkipCombine() + const previousResult = this.#combinedResult + const newResult = shouldSkipCombine + ? previousResult + : this.#combineResult(newTracked, this.#options?.combine) - if (previousResult !== newResult) { + if (shouldSkipCombine || previousResult !== newResult) { notifyManager.batch(() => { this.listeners.forEach((listener) => { listener(this.#result) diff --git a/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx b/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx index d8838aee0da..6b5b327b6ee 100644 --- a/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx +++ b/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx @@ -274,6 +274,74 @@ describe('useSuspenseQueries', () => { expect(spy).toHaveBeenCalled() }) + it('should not call combine while reset queries are pending again', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + let shouldError = false + + function Page() { + const data = useSuspenseQueries({ + queries: [ + { + queryKey: key, + queryFn: () => + sleep(10).then(() => { + if (shouldError) { + throw new Error('Suspense Error Bingo') + } + + return 'data' + }), + retry: false, + }, + ], + combine: (result) => result.map((query) => query.data.toUpperCase()), + }) + + return ( +
+ +
data: {data.join(',')}
+
+ ) + } + + const rendered = renderWithClient( + queryClient, +
error boundary
}> + + + +
, + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data: DATA')).toBeInTheDocument() + + shouldError = true + + expect(() => { + fireEvent.click(rendered.getByText('reset')) + }).not.toThrow() + + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + + expect(consoleMock.mock.calls[0]?.[1]).toStrictEqual( + new Error('Suspense Error Bingo'), + ) + + consoleMock.mockRestore() + }) + it('should handle duplicate query keys without infinite loops', async () => { const key = queryKey() const localDuration = 10