Skip to content

Conversation

joseph0926
Copy link
Contributor

@joseph0926 joseph0926 commented Aug 26, 2025

Fixes: #9586

Fix combine function not re-executing after cache restoration with memoized combine

When using useQueries with a memoized combine function (via useCallback) alongside PersistQueryClientProvider, 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's setQueries method.

During our debugging process, we traced through the execution flow and found that when isRestoring changes from true to false, 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 update this.#result and call this.#notify() to trigger the combine function re-execution.

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
}

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

    • Emit updates when query results (data, pending state, or errors) change even if observer identity and count remain unchanged, ensuring UI stays in sync.
  • Tests

    • Added integration test validating useQueries with persisted state and a memoized combine function restores and renders pending/data after hydration.
    • Added test ensuring observers notify when underlying results change during early-return paths.

Copy link

coderabbitai bot commented Aug 26, 2025

Walkthrough

Adds 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

Cohort / File(s) Summary
Core observer result-change detection
packages/query-core/src/queriesObserver.ts
Adds logic in the stable-observers branch to diff newResult against this.#result (checks data, isPending, error), set this.#result and call this.#notify() when differences are found; otherwise return early.
QueriesObserver unit test
packages/query-core/src/__tests__/queriesObserver.test.tsx
New test "should notify when results change during early return" verifying notifications are emitted and final results reflect transformed data when results change during reconfiguration.
React persist integration test for useQueries + combine
packages/react-query-persist-client/src/__tests__/use-queries-with-persist.test.tsx
New integration test mocking localStorage persister, preloading persisted results for three queries, using a memoized combine in useQueries, and asserting isPending becomes false and combined data is restored after hydration.

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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Assessment against linked issues

Objective Addressed Explanation
Trigger combine after StoragePersister hydration when queries aren’t stale, preventing stuck isPending state ([#9586])
Ensure behavior works with memoized combine function ([#9586])

Poem

I cached three carrots, snug and sweet,
Restored them gently to my feet.
The watcher sniffed and gave a cheer,
Combine returned the harvest near.
Hooray — no pending, snacks appear! 🥕✨

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 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.

📥 Commits

Reviewing files that changed from the base of the PR and between f860b8d and 5227bc3.

📒 Files selected for processing (1)
  • packages/query-core/src/__tests__/queriesObserver.test.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/query-core/src/tests/queriesObserver.test.tsx
⏰ 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
✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

nx-cloud bot commented Aug 26, 2025

View your CI Pipeline Execution ↗ for commit 5227bc3

Command Status Duration Result
nx affected --targets=test:sherif,test:knip,tes... ✅ Succeeded 3m 27s View ↗
nx run-many --target=build --exclude=examples/*... ✅ Succeeded 1m 20s View ↗

☁️ Nx Cloud last updated this comment at 2025-08-26 00:56:31 UTC

Copy link

pkg-pr-new bot commented Aug 26, 2025

More templates

@tanstack/angular-query-devtools-experimental

npm i https://pkg.pr.new/@tanstack/angular-query-devtools-experimental@9592

@tanstack/angular-query-experimental

npm i https://pkg.pr.new/@tanstack/angular-query-experimental@9592

@tanstack/eslint-plugin-query

npm i https://pkg.pr.new/@tanstack/eslint-plugin-query@9592

@tanstack/query-async-storage-persister

npm i https://pkg.pr.new/@tanstack/query-async-storage-persister@9592

@tanstack/query-broadcast-client-experimental

npm i https://pkg.pr.new/@tanstack/query-broadcast-client-experimental@9592

@tanstack/query-core

npm i https://pkg.pr.new/@tanstack/query-core@9592

@tanstack/query-devtools

npm i https://pkg.pr.new/@tanstack/query-devtools@9592

@tanstack/query-persist-client-core

npm i https://pkg.pr.new/@tanstack/query-persist-client-core@9592

@tanstack/query-sync-storage-persister

npm i https://pkg.pr.new/@tanstack/query-sync-storage-persister@9592

@tanstack/react-query

npm i https://pkg.pr.new/@tanstack/react-query@9592

@tanstack/react-query-devtools

npm i https://pkg.pr.new/@tanstack/react-query-devtools@9592

@tanstack/react-query-next-experimental

npm i https://pkg.pr.new/@tanstack/react-query-next-experimental@9592

@tanstack/react-query-persist-client

npm i https://pkg.pr.new/@tanstack/react-query-persist-client@9592

@tanstack/solid-query

npm i https://pkg.pr.new/@tanstack/solid-query@9592

@tanstack/solid-query-devtools

npm i https://pkg.pr.new/@tanstack/solid-query-devtools@9592

@tanstack/solid-query-persist-client

npm i https://pkg.pr.new/@tanstack/solid-query-persist-client@9592

@tanstack/svelte-query

npm i https://pkg.pr.new/@tanstack/svelte-query@9592

@tanstack/svelte-query-devtools

npm i https://pkg.pr.new/@tanstack/svelte-query-devtools@9592

@tanstack/svelte-query-persist-client

npm i https://pkg.pr.new/@tanstack/svelte-query-persist-client@9592

@tanstack/vue-query

npm i https://pkg.pr.new/@tanstack/vue-query@9592

@tanstack/vue-query-devtools

npm i https://pkg.pr.new/@tanstack/vue-query-devtools@9592

commit: 5227bc3

Copy link

codecov bot commented Aug 26, 2025

Codecov Report

❌ Patch coverage is 80.00000% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.27%. Comparing base (a1b1279) to head (5227bc3).

Additional details and impacted files

Impacted file tree graph

@@             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     
Components Coverage Δ
@tanstack/angular-query-devtools-experimental ∅ <ø> (∅)
@tanstack/angular-query-experimental 87.00% <ø> (ø)
@tanstack/eslint-plugin-query ∅ <ø> (∅)
@tanstack/query-async-storage-persister 43.85% <ø> (ø)
@tanstack/query-broadcast-client-experimental 24.39% <ø> (ø)
@tanstack/query-codemods ∅ <ø> (∅)
@tanstack/query-core 97.30% <80.00%> (-0.10%) ⬇️
@tanstack/query-devtools 3.48% <ø> (ø)
@tanstack/query-persist-client-core 79.47% <ø> (ø)
@tanstack/query-sync-storage-persister 84.61% <ø> (ø)
@tanstack/query-test-utils ∅ <ø> (∅)
@tanstack/react-query 95.95% <ø> (ø)
@tanstack/react-query-devtools 10.00% <ø> (ø)
@tanstack/react-query-next-experimental ∅ <ø> (∅)
@tanstack/react-query-persist-client 100.00% <ø> (ø)
@tanstack/solid-query 78.13% <ø> (ø)
@tanstack/solid-query-devtools ∅ <ø> (∅)
@tanstack/solid-query-persist-client 100.00% <ø> (ø)
@tanstack/svelte-query 87.58% <ø> (ø)
@tanstack/svelte-query-devtools ∅ <ø> (∅)
@tanstack/svelte-query-persist-client 100.00% <ø> (ø)
@tanstack/vue-query 71.10% <ø> (ø)
@tanstack/vue-query-devtools ∅ <ø> (∅)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link

@coderabbitai coderabbitai bot left a 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()
+        return
packages/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.

📥 Commits

Reviewing files that changed from the base of the PR and between a1b1279 and 7953639.

📒 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.

Comment on lines 125 to 140
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
Copy link

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.

Suggested change
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.

Copy link

@coderabbitai coderabbitai bot left a 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 consistency

notifyManager 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.

📥 Commits

Reviewing files that changed from the base of the PR and between 7953639 and f860b8d.

📒 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

useQueries doesn’t call combine when StoragePersister deserializes data
1 participant