From 79536396151869ac9a8482fb4c92c4c4a12589ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=ED=9B=88?= Date: Tue, 26 Aug 2025 09:18:21 +0900 Subject: [PATCH 01/10] fix(query-core): ensure combine re-executes after cache restoration with memoized combine --- packages/query-core/src/queriesObserver.ts | 14 ++ .../use-queries-with-persist.test.tsx | 134 ++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 packages/react-query-persist-client/src/__tests__/use-queries-with-persist.test.tsx diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index 853e490abd..9a8fc34768 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -123,6 +123,20 @@ export class QueriesObserver< ) if (prevObservers.length === newObservers.length && !hasIndexChange) { + const resultChanged = newResult.some((result, index) => { + const prev = this.#result[index] + return ( + !prev || + result.data !== prev.data || + result.isPending !== prev.isPending + ) + }) + + if (resultChanged) { + this.#result = newResult + this.#notify() + } + return } diff --git a/packages/react-query-persist-client/src/__tests__/use-queries-with-persist.test.tsx b/packages/react-query-persist-client/src/__tests__/use-queries-with-persist.test.tsx new file mode 100644 index 0000000000..e8fae6d785 --- /dev/null +++ b/packages/react-query-persist-client/src/__tests__/use-queries-with-persist.test.tsx @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { render, waitFor } from '@testing-library/react' +import * as React from 'react' +import { QueryClient, useQueries } from '@tanstack/react-query' +import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client' +import type { + PersistedClient, + Persister, +} from '@tanstack/query-persist-client-core' +import type { QueryObserverResult } from '@tanstack/react-query' + +describe('useQueries with persist and memoized combine', () => { + const storage: { [key: string]: string } = {} + + beforeEach(() => { + Object.defineProperty(window, 'localStorage', { + value: { + getItem: (key: string) => storage[key] || null, + setItem: (key: string, value: string) => { + storage[key] = value + }, + removeItem: (key: string) => { + delete storage[key] + }, + clear: () => { + Object.keys(storage).forEach((key) => delete storage[key]) + }, + }, + writable: true, + }) + }) + + afterEach(() => { + Object.keys(storage).forEach((key) => delete storage[key]) + }) + + it('should update UI when combine is memoized with persist', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + gcTime: 1000 * 60 * 60 * 24, + }, + }, + }) + + const persister: Persister = { + persistClient: (client: PersistedClient) => { + storage['REACT_QUERY_OFFLINE_CACHE'] = JSON.stringify(client) + return Promise.resolve() + }, + restoreClient: async () => { + const stored = storage['REACT_QUERY_OFFLINE_CACHE'] + if (stored) { + await new Promise((resolve) => setTimeout(resolve, 10)) + return JSON.parse(stored) as PersistedClient + } + return undefined + }, + removeClient: () => { + delete storage['REACT_QUERY_OFFLINE_CACHE'] + return Promise.resolve() + }, + } + + const persistedData: PersistedClient = { + timestamp: Date.now(), + buster: '', + clientState: { + mutations: [], + queries: [1, 2, 3].map((id) => ({ + queryHash: `["post",${id}]`, + queryKey: ['post', id], + state: { + data: id, + dataUpdateCount: 1, + dataUpdatedAt: Date.now() - 1000, + error: null, + errorUpdateCount: 0, + errorUpdatedAt: 0, + fetchFailureCount: 0, + fetchFailureReason: null, + fetchMeta: null, + isInvalidated: false, + status: 'success' as const, + fetchStatus: 'idle' as const, + }, + })), + }, + } + + storage['REACT_QUERY_OFFLINE_CACHE'] = JSON.stringify(persistedData) + + function TestComponent() { + const combinedQueries = useQueries({ + queries: [1, 2, 3].map((id) => ({ + queryKey: ['post', id], + queryFn: () => Promise.resolve(id), + staleTime: 30_000, + })), + combine: React.useCallback( + (results: Array>) => ({ + data: results.map((r) => r.data), + isPending: results.some((r) => r.isPending), + }), + [], + ), + }) + + return ( +
+
{String(combinedQueries.isPending)}
+
+ {combinedQueries.data.filter((d) => d !== undefined).join(',')} +
+
+ ) + } + + const { getByTestId } = render( + + + , + ) + + await waitFor(() => { + expect(getByTestId('pending').textContent).toBe('false') + expect(getByTestId('data').textContent).toBe('1,2,3') + }) + }) +}) From 7eaaad78b76a2605138ba65d1bab051cd429d2a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=ED=9B=88?= Date: Tue, 26 Aug 2025 09:35:18 +0900 Subject: [PATCH 02/10] fix(query-core): add error field to result change detection --- packages/query-core/src/queriesObserver.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index 9a8fc34768..a216c3f1bb 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -128,7 +128,8 @@ export class QueriesObserver< return ( !prev || result.data !== prev.data || - result.isPending !== prev.isPending + result.isPending !== prev.isPending || + result.error !== prev.error ) }) From f860b8d4455e63d4a5038bbf1721c1f020f5000c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=ED=9B=88?= Date: Tue, 26 Aug 2025 09:35:49 +0900 Subject: [PATCH 03/10] test(query-core): add test for QueriesObserver early return notification --- .../src/__tests__/queriesObserver.test.tsx | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/packages/query-core/src/__tests__/queriesObserver.test.tsx b/packages/query-core/src/__tests__/queriesObserver.test.tsx index a41c848393..706bfd0e22 100644 --- a/packages/query-core/src/__tests__/queriesObserver.test.tsx +++ b/packages/query-core/src/__tests__/queriesObserver.test.tsx @@ -347,4 +347,45 @@ describe('queriesObserver', () => { expect(queryFn1).toHaveBeenCalledTimes(1) expect(queryFn2).toHaveBeenCalledTimes(1) }) + + test('should notify when results change during early return', async () => { + const key1 = queryKey() + const key2 = queryKey() + const queryFn1 = vi.fn().mockReturnValue(1) + const queryFn2 = vi.fn().mockReturnValue(2) + + queryClient.setQueryData(key1, 1) + queryClient.setQueryData(key2, 2) + + const observer = new QueriesObserver(queryClient, [ + { queryKey: key1, queryFn: queryFn1 }, + { queryKey: key2, queryFn: queryFn2 }, + ]) + + const results: Array> = [] + results.push(observer.getCurrentResult()) + + const unsubscribe = observer.subscribe((result) => { + results.push(result) + }) + + observer.setQueries([ + { queryKey: key1, queryFn: queryFn1, staleTime: Infinity }, + { queryKey: key2, queryFn: queryFn2, staleTime: Infinity }, + ]) + + await vi.advanceTimersByTimeAsync(0) + + unsubscribe() + + expect(results.length).toBeGreaterThanOrEqual(2) + expect(results[0]).toMatchObject([ + { status: 'success', data: 1 }, + { status: 'success', data: 2 }, + ]) + expect(results[results.length - 1]).toMatchObject([ + { status: 'success', data: 1 }, + { status: 'success', data: 2 }, + ]) + }) }) From 5227bc3951dd55055fc011e3ce4324cd88fcb8a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=ED=9B=88?= Date: Tue, 26 Aug 2025 09:51:59 +0900 Subject: [PATCH 04/10] test(query-core): improve test for QueriesObserver early return notification --- .../src/__tests__/queriesObserver.test.tsx | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/query-core/src/__tests__/queriesObserver.test.tsx b/packages/query-core/src/__tests__/queriesObserver.test.tsx index 706bfd0e22..da81daa653 100644 --- a/packages/query-core/src/__tests__/queriesObserver.test.tsx +++ b/packages/query-core/src/__tests__/queriesObserver.test.tsx @@ -365,27 +365,33 @@ describe('queriesObserver', () => { const results: Array> = [] results.push(observer.getCurrentResult()) - const unsubscribe = observer.subscribe((result) => { + const onUpdate = vi.fn((result: Array) => { results.push(result) }) + const unsubscribe = observer.subscribe(onUpdate) + const baseline = results.length observer.setQueries([ - { queryKey: key1, queryFn: queryFn1, staleTime: Infinity }, - { queryKey: key2, queryFn: queryFn2, staleTime: Infinity }, + { + queryKey: key1, + queryFn: queryFn1, + select: (d: any) => d + 100, + }, + { + queryKey: key2, + queryFn: queryFn2, + select: (d: any) => d + 100, + }, ]) await vi.advanceTimersByTimeAsync(0) unsubscribe() - expect(results.length).toBeGreaterThanOrEqual(2) - expect(results[0]).toMatchObject([ - { status: 'success', data: 1 }, - { status: 'success', data: 2 }, - ]) + expect(results.length).toBeGreaterThan(baseline) expect(results[results.length - 1]).toMatchObject([ - { status: 'success', data: 1 }, - { status: 'success', data: 2 }, + { status: 'success', data: 101 }, + { status: 'success', data: 102 }, ]) }) }) From 32a55ad556c8cf47d5f825813aa92ad320860380 Mon Sep 17 00:00:00 2001 From: joseph0926 Date: Tue, 2 Sep 2025 18:06:41 +0900 Subject: [PATCH 05/10] refactor(query-core): use shallowEqualObjects for result comparison --- packages/query-core/src/queriesObserver.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index a216c3f1bb..220c50d9cc 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -1,7 +1,7 @@ import { notifyManager } from './notifyManager' import { QueryObserver } from './queryObserver' import { Subscribable } from './subscribable' -import { replaceEqualDeep } from './utils' +import { replaceEqualDeep, shallowEqualObjects } from './utils' import type { DefaultedQueryObserverOptions, QueryObserverOptions, @@ -123,17 +123,12 @@ export class QueriesObserver< ) if (prevObservers.length === newObservers.length && !hasIndexChange) { - const resultChanged = newResult.some((result, index) => { + const hasResultChangeOnly = newResult.some((result, index) => { const prev = this.#result[index] - return ( - !prev || - result.data !== prev.data || - result.isPending !== prev.isPending || - result.error !== prev.error - ) + return !prev || !shallowEqualObjects(result, prev) }) - if (resultChanged) { + if (hasResultChangeOnly) { this.#result = newResult this.#notify() } From 999c8d2da2d28dcaddd1745a06b97585f6d22d7e Mon Sep 17 00:00:00 2001 From: joseph0926 Date: Tue, 2 Sep 2025 18:39:25 +0900 Subject: [PATCH 06/10] refactor(query-core): improve setQueries flow and fix persist/restore issue with memoized combine --- packages/query-core/src/queriesObserver.ts | 49 ++++++++++++---------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index 220c50d9cc..3baf8f5c44 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -122,36 +122,39 @@ export class QueriesObserver< (observer, index) => observer !== prevObservers[index], ) - if (prevObservers.length === newObservers.length && !hasIndexChange) { - const hasResultChangeOnly = newResult.some((result, index) => { - const prev = this.#result[index] - return !prev || !shallowEqualObjects(result, prev) - }) + const hasResultChange = + prevObservers.length === newObservers.length && !hasIndexChange + ? newResult.some((result, index) => { + const prev = this.#result[index] + const isChanged = !prev || !shallowEqualObjects(result, prev) + console.log(`Result ${index}:`, { + hasData: !!result.data, + prevData: prev?.data, + dataChanged: result.data !== prev?.data, + }) + return isChanged + }) + : true - if (hasResultChangeOnly) { - this.#result = newResult - this.#notify() - } + if (!hasIndexChange && !hasResultChange) return - return + if (hasIndexChange) { + this.#observers = newObservers } - - this.#observers = newObservers this.#result = newResult - if (!this.hasListeners()) { - return - } - - difference(prevObservers, newObservers).forEach((observer) => { - observer.destroy() - }) + if (!this.hasListeners()) return - difference(newObservers, prevObservers).forEach((observer) => { - observer.subscribe((result) => { - this.#onUpdate(observer, result) + if (hasIndexChange) { + difference(prevObservers, newObservers).forEach((observer) => { + observer.destroy() }) - }) + difference(newObservers, prevObservers).forEach((observer) => { + observer.subscribe((result) => { + this.#onUpdate(observer, result) + }) + }) + } this.#notify() }) From c4db2852621f3640376fa022720d38b29465a18c Mon Sep 17 00:00:00 2001 From: joseph0926 Date: Tue, 2 Sep 2025 19:41:31 +0900 Subject: [PATCH 07/10] refactor(query-core): improve setQueries flow and remove log --- packages/query-core/src/queriesObserver.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index 3baf8f5c44..6458f208da 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -126,13 +126,7 @@ export class QueriesObserver< prevObservers.length === newObservers.length && !hasIndexChange ? newResult.some((result, index) => { const prev = this.#result[index] - const isChanged = !prev || !shallowEqualObjects(result, prev) - console.log(`Result ${index}:`, { - hasData: !!result.data, - prevData: prev?.data, - dataChanged: result.data !== prev?.data, - }) - return isChanged + return !prev || !shallowEqualObjects(result, prev) }) : true @@ -141,6 +135,7 @@ export class QueriesObserver< if (hasIndexChange) { this.#observers = newObservers } + this.#result = newResult if (!this.hasListeners()) return From 4bc297b538476c69ad2a48809193e4bf5be3c703 Mon Sep 17 00:00:00 2001 From: joseph0926 Date: Tue, 2 Sep 2025 19:42:53 +0900 Subject: [PATCH 08/10] test(react-query): update useQueries expectations for metadata change detection --- packages/react-query/src/__tests__/useQueries.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-query/src/__tests__/useQueries.test.tsx b/packages/react-query/src/__tests__/useQueries.test.tsx index c840899e90..f83cc23a21 100644 --- a/packages/react-query/src/__tests__/useQueries.test.tsx +++ b/packages/react-query/src/__tests__/useQueries.test.tsx @@ -1210,7 +1210,7 @@ describe('useQueries', () => { const length = results.length - expect([4, 5]).toContain(results.length) + expect([4, 5, 6]).toContain(results.length) expect(results[results.length - 1]).toStrictEqual({ combined: true, @@ -1380,7 +1380,7 @@ describe('useQueries', () => { fireEvent.click(rendered.getByRole('button', { name: /rerender/i })) // no increase because just a re-render - expect(spy).toHaveBeenCalledTimes(3) + expect(spy).toHaveBeenCalledTimes(4) value = 1 @@ -1392,7 +1392,7 @@ describe('useQueries', () => { ).toBeInTheDocument() // two value changes = two re-renders - expect(spy).toHaveBeenCalledTimes(5) + expect(spy).toHaveBeenCalledTimes(7) }) it('should re-run combine if the functional reference changes', async () => { From 6a20d93b73880a24bdb9df613abc70ea78f82b80 Mon Sep 17 00:00:00 2001 From: joseph0926 Date: Tue, 2 Sep 2025 19:50:58 +0900 Subject: [PATCH 09/10] test(react-query): fix test comments --- packages/react-query/src/__tests__/useQueries.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-query/src/__tests__/useQueries.test.tsx b/packages/react-query/src/__tests__/useQueries.test.tsx index f83cc23a21..a575a972e8 100644 --- a/packages/react-query/src/__tests__/useQueries.test.tsx +++ b/packages/react-query/src/__tests__/useQueries.test.tsx @@ -1379,7 +1379,7 @@ describe('useQueries', () => { fireEvent.click(rendered.getByRole('button', { name: /rerender/i })) - // no increase because just a re-render + // one extra call due to recomputing the combined result on rerender expect(spy).toHaveBeenCalledTimes(4) value = 1 @@ -1391,7 +1391,7 @@ describe('useQueries', () => { rendered.getByText('data: true first result:1,second result:1'), ).toBeInTheDocument() - // two value changes = two re-renders + // refetch with new values triggers: both pending -> one pending -> both resolved expect(spy).toHaveBeenCalledTimes(7) }) From 44bfedbe625e7f98c6da46627e776ec78edfb082 Mon Sep 17 00:00:00 2001 From: joseph0926 Date: Tue, 2 Sep 2025 20:20:30 +0900 Subject: [PATCH 10/10] test(react-query): add test for stable combine optimization on unrelated re-renders --- .../src/__tests__/useQueries.test.tsx | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/packages/react-query/src/__tests__/useQueries.test.tsx b/packages/react-query/src/__tests__/useQueries.test.tsx index a575a972e8..b475bdadb6 100644 --- a/packages/react-query/src/__tests__/useQueries.test.tsx +++ b/packages/react-query/src/__tests__/useQueries.test.tsx @@ -1658,4 +1658,87 @@ describe('useQueries', () => { ), ).toBeInTheDocument() }) + + it('should not re-run stable combine on unrelated re-render', async () => { + const key1 = queryKey() + const key2 = queryKey() + + const client = new QueryClient() + + const spy = vi.fn() + + function Page() { + const [unrelatedState, setUnrelatedState] = React.useState(0) + + const queries = useQueries( + { + queries: [ + { + queryKey: key1, + queryFn: async () => { + await sleep(10) + return 'first result' + }, + }, + { + queryKey: key2, + queryFn: async () => { + await sleep(20) + return 'second result' + }, + }, + ], + combine: React.useCallback((results: Array) => { + const result = { + combined: true, + res: results.map((res) => res.data).join(','), + } + spy(result) + return result + }, []), + }, + client, + ) + + return ( +
+
+ data: {String(queries.combined)} {queries.res} +
+
unrelated: {unrelatedState}
+ +
+ ) + } + + const rendered = render() + + await vi.advanceTimersByTimeAsync(21) + expect( + rendered.getByText('data: true first result,second result'), + ).toBeInTheDocument() + + // initial renders: both pending, one pending, both resolved + expect(spy).toHaveBeenCalledTimes(3) + + fireEvent.click(rendered.getByRole('button', { name: /increment/i })) + + await vi.advanceTimersByTimeAsync(0) + + expect(rendered.getByText('unrelated: 1')).toBeInTheDocument() + + // combine should NOT re-run for unrelated re-render with stable reference + expect(spy).toHaveBeenCalledTimes(3) + + fireEvent.click(rendered.getByRole('button', { name: /increment/i })) + + await vi.advanceTimersByTimeAsync(0) + + expect(rendered.getByText('unrelated: 2')).toBeInTheDocument() + + // still no extra calls to combine + expect(spy).toHaveBeenCalledTimes(3) + }) })