-
-
Notifications
You must be signed in to change notification settings - Fork 3.4k
fix(query-core): ensure combine re-executes after cache restoration with memoized combine #9592
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
fix(query-core): ensure combine re-executes after cache restoration with memoized combine #9592
Conversation
…ith memoized combine
WalkthroughAdds result-change detection to QueriesObserver’s stable-observers path so it emits notifications when underlying results’ data, isPending, or error change even if observer identity/count remain the same. Adds tests: a QueriesObserver unit test for early-return notifications and an integration test for useQueries with persistence and a memoized combine. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor App
participant Persist as PersistQueryClientProvider
participant Persister as StoragePersister
participant QC as QueryClient/Cache
participant QO as QueriesObserver
participant Component
participant Combine as combine()
App->>Persist: mount with persistOptions
Persist->>Persister: restoreClient()
Persister-->>Persist: deserialized state
Persist->>QC: hydrate(state)
QC-->>QO: emit results (from cache)
rect rgba(230,245,255,0.6)
note over QO: stable-observers path — compare results
QO->>QO: compare newResult vs this.#result (data / isPending / error)
alt resultChanged
QO->>QO: update this.#result
QO-->>Component: notify subscribers
Component->>Combine: call combine(results)
Combine-->>Component: combined output
Component-->>App: render with pending=false and data
else noChange
QO-->>Component: no notify
end
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Assessment against linked issues
Poem
Tip 🔌 Remote MCP (Model Context Protocol) integration is now available!Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats. 📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro 💡 Knowledge Base configuration:
You can enable these sources in your CodeRabbit configuration. 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
✨ Finishing Touches
🧪 Generate unit tests
🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
View your CI Pipeline Execution ↗ for commit 5227bc3
☁️ Nx Cloud last updated this comment at |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #9592 +/- ##
===========================================
+ Coverage 45.15% 59.27% +14.11%
===========================================
Files 208 137 -71
Lines 8323 5571 -2752
Branches 1886 1504 -382
===========================================
- Hits 3758 3302 -456
+ Misses 4118 1965 -2153
+ Partials 447 304 -143 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🧹 Nitpick comments (3)
packages/query-core/src/queriesObserver.ts (1)
125-140
: Alternative: always update + rely on #notify gating to avoid extra emissions.Given #notify only dispatches when the combined result identity changes (replaceEqualDeep + previousResult !== newResult), we can simplify by always updating #result and calling #notify in this stable-observers branch. This keeps correctness simple and lets existing memoization handle spurious updates. Use this if you prefer clarity over a micro-optimization.
Apply this minimal alternative:
- 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 + this.#result = newResult + this.#notify() + returnpackages/react-query-persist-client/src/__tests__/use-queries-with-persist.test.tsx (2)
94-118
: Add a companion test where combine depends on fields other than data/isPending (e.g., error or isFetching).This guards against regressions if result-change detection misses non-data/isPending updates.
Proposed additional test (can live in the same file):
it('should re-run memoized combine when only error/fetching changes after persist restore', async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, gcTime: 24 * 60 * 60 * 1000 } }, }) // Seed one successful and one errored query const persistedData: PersistedClient = { timestamp: Date.now(), buster: '', clientState: { mutations: [], queries: [ { queryHash: '["ok",1]', queryKey: ['ok', 1], state: { data: 1, dataUpdateCount: 1, dataUpdatedAt: Date.now() - 500, error: null, errorUpdateCount: 0, errorUpdatedAt: 0, fetchFailureCount: 0, fetchFailureReason: null, fetchMeta: null, isInvalidated: false, status: 'success' as const, fetchStatus: 'idle' as const, }, }, { queryHash: '["err",2]', queryKey: ['err', 2], state: { data: undefined, dataUpdateCount: 0, dataUpdatedAt: 0, error: new Error('boom'), errorUpdateCount: 1, errorUpdatedAt: Date.now() - 500, fetchFailureCount: 1, fetchFailureReason: 'boom', fetchMeta: null, isInvalidated: false, status: 'error' as const, fetchStatus: 'idle' as const, }, }, ], }, } localStorage.setItem('REACT_QUERY_OFFLINE_CACHE', JSON.stringify(persistedData)) const persister: Persister = { persistClient: (c) => { localStorage.setItem('REACT_QUERY_OFFLINE_CACHE', JSON.stringify(c)) return Promise.resolve() }, restoreClient: async () => JSON.parse(localStorage.getItem('REACT_QUERY_OFFLINE_CACHE')!) as PersistedClient, removeClient: async () => { localStorage.removeItem('REACT_QUERY_OFFLINE_CACHE') }, } function TestComponent() { const result = useQueries({ queries: [ { queryKey: ['ok', 1], queryFn: () => Promise.resolve(1), staleTime: 30_000 }, { queryKey: ['err', 2], queryFn: () => Promise.reject(new Error('boom')), staleTime: 30_000 }, ], combine: React.useCallback( (results: Array<QueryObserverResult<number, Error>>) => ({ anyError: results.some((r) => r.isError), anyFetching: results.some((r) => r.isFetching), }), [], ), }) return ( <div> <div data-testid="anyError">{String(result.anyError)}</div> <div data-testid="anyFetching">{String(result.anyFetching)}</div> </div> ) } const { getByTestId } = render( <PersistQueryClientProvider client={queryClient} persistOptions={{ persister }}> <TestComponent /> </PersistQueryClientProvider>, ) await waitFor(() => { expect(getByTestId('anyError').textContent).toBe('true') // restored cache should not be fetching expect(getByTestId('anyFetching').textContent).toBe('false') }) })
37-45
: Tiny cleanup: unmount and clear QueryClient between tests to avoid cross-test leakage.Not required for this PR, but it improves test hygiene if more tests are added.
Example:
- const { getByTestId } = render( + const { getByTestId, unmount } = render( <PersistQueryClientProvider client={queryClient} persistOptions={{ persister }} > <TestComponent /> </PersistQueryClientProvider>, ) await waitFor(() => { expect(getByTestId('pending').textContent).toBe('false') expect(getByTestId('data').textContent).toBe('1,2,3') }) + + unmount() + queryClient.clear()Also applies to: 120-127, 129-133
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (2)
packages/query-core/src/queriesObserver.ts
(1 hunks)packages/react-query-persist-client/src/__tests__/use-queries-with-persist.test.tsx
(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/react-query-persist-client/src/__tests__/use-queries-with-persist.test.tsx (2)
packages/query-persist-client-core/src/persist.ts (2)
Persister
(12-16)PersistedClient
(18-22)packages/query-core/src/types.ts (1)
QueryObserverResult
(899-904)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: Test
- GitHub Check: Preview
🔇 Additional comments (2)
packages/query-core/src/queriesObserver.ts (1)
105-121
: Control flow looks sound; observer bookkeeping is maintained.
- #observerMatches is refreshed before potential early return.
- Skipping updates to #observers in the stable-identity branch is correct since identities didn’t change.
- Subscriptions are stable; no unnecessary destroy/subscribe churn.
If you keep the current property-based check, please add a test where only error or fetchStatus changes after restore (data unchanged) to ensure combine re-runs. I can draft it if helpful.
Also applies to: 143-161
packages/react-query-persist-client/src/__tests__/use-queries-with-persist.test.tsx (1)
37-133
: Great regression test covering the persist + memoized combine scenario.The test reliably reproduces the issue and validates pending/data after hydration. Nice use of an in-memory persister with a small restore delay.
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Broaden change detection: compare full result references, not only data/isPending.
Limiting detection to data and isPending risks missing updates when other fields change (e.g., status, error, fetchStatus, isFetching, isPaused, isStale). If a consumer’s combine reads any of those, UI may remain stale. A reference inequality check covers all observable result changes with minimal cost because QueryObserver emits a new result object when any tracked prop changes.
Apply this diff to generalize resultChanged:
- 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()
- }
+ const resultChanged = newResult.some(
+ (result, index) => result !== this.#result[index],
+ )
+
+ if (!resultChanged) return
+ this.#result = newResult
+ this.#notify()
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
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 | |
if (prevObservers.length === newObservers.length && !hasIndexChange) { | |
const resultChanged = newResult.some( | |
(result, index) => result !== this.#result[index], | |
) | |
if (!resultChanged) return | |
this.#result = newResult | |
this.#notify() | |
return | |
} |
🤖 Prompt for AI Agents
In packages/query-core/src/queriesObserver.ts around lines 125 to 140, the
change detection only compares result.data and result.isPending which misses
other observable fields; replace that predicate with a full reference inequality
check (e.g., compare newResult[index] !== this.#result[index]) so any updated
result object triggers update; if any reference differs, assign this.#result =
newResult and call this.#notify() before returning.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🧹 Nitpick comments (1)
packages/query-core/src/__tests__/queriesObserver.test.tsx (1)
377-379
: Timers may be unnecessary; prefer awaiting microtasks or keep if needed for consistencynotifyManager in query-core often schedules microtasks rather than timers. vi.advanceTimersByTimeAsync(0) is harmless but might be redundant here. If the test remains flaky in CI, keep it; otherwise consider awaiting a microtask flush for determinism:
await Promise.resolve()
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (2)
packages/query-core/src/__tests__/queriesObserver.test.tsx
(1 hunks)packages/query-core/src/queriesObserver.ts
(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/query-core/src/queriesObserver.ts
🧰 Additional context used
🧬 Code graph analysis (1)
packages/query-core/src/__tests__/queriesObserver.test.tsx (2)
packages/query-core/src/queriesObserver.ts (3)
observer
(269-275)QueriesObserver
(35-292)result
(201-216)packages/query-core/src/types.ts (1)
QueryObserverResult
(899-904)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: Preview
- GitHub Check: Test
Fixes: #9586
Fix combine function not re-executing after cache restoration with memoized combine
When using
useQueries
with a memoizedcombine
function (viauseCallback
) alongsidePersistQueryClientProvider
, the UI fails to update after page refresh even though cached data exists.The Problem
We discovered this issue while working with React Query's persist functionality. After refreshing the page, the combine function would return stale pending state despite the cache containing valid data. The root cause was in
QueriesObserver
'ssetQueries
method.During our debugging process, we traced through the execution flow and found that when
isRestoring
changes fromtrue
tofalse
,setQueries
gets called but hits an early return condition. Since the same observer instances are reused and no index changes occur, the method returns early without updating the results or notifying listeners.The issue becomes more critical with React 19's automatic memoization, where all functions will be memoized by default.
The Solution
We modified the early return logic in
setQueries
to check if the actual results have changed. When observers haven't changed but the data or pending state differs, we now updatethis.#result
and callthis.#notify()
to trigger the combine function re-execution.This ensures the UI updates correctly while maintaining the performance optimization of not recreating observers unnecessarily.
Testing
Added test case to verify combine function works correctly with memoization and persist. All existing tests pass without modification.
Summary by CodeRabbit
Bug Fixes
Tests