Skip to content

Conversation

@joseph0926
Copy link
Contributor

@joseph0926 joseph0926 commented Sep 9, 2025

Fixes #8781
Related PR: #9618

Description

combine function with stable reference not updating correctly when queries change
On the other hand, combine implemented as an inline function works correctly because the reference changes each time.

combine !== this.#lastCombine

Problem

When using useQueries with a stable reference for the combine function, the combined result was not updated immediately when the queries array changed. This resulted in stale data being returned for one render cycle.

Root Cause

The caching logic in #combineResult was comparing this.#result to determine cache invalidation, but the actual data being processed was the input parameter. When getOptimisticResult generated a new result array, this.#result hadn't been updated yet (happens later in useEffect), causing incorrect cache hits.

Solution

Added input.length !== this.#lastInput?.length check to the cache invalidation logic. This ensures the cache is properly invalidated when the number of queries changes, which is the most common scenario.

Why length comparison is sufficient

While comparing only the length might seem incomplete, it works because,

  1. Most real-world cases involve changing the number of queries
  2. For same-length changes, setQueries is called via useEffect which updates this.#result for the next render
  3. This approach maintains the original performance optimization intent while fixing the bug

Testing

  • stable reference combine should update immediately when queries change
  • inline combine should update immediately when queries change
  • should handle dynamic query array changes correctly
  • should handle same length but different queries
  • should handle query order changes with same length
  • should handle combine function that transforms data
  • should not break when switching between stable and inline combine

All existing tests continue to pass, ensuring backward compatibility.


No breaking changes – this is a targeted optimization

Alternative approaches considered

  • Full input reference comparison: Would cause excessive cache misses
    • input.length !== this.#lastInput: This has been confirmed to significantly impact existing caching to the point of breaking existing caching tests.
  • Content-based comparison: Performance overhead
    • While it might be possible to achieve this by adding an additional flag to #combineResult and redesigning it, we determined that the overall impact would be too significant.
  • Separate optimistic cache: Over-engineering for the use case
    • Perhaps something like this...? #combineCache = new WeakMap<Array<QueryObserverResult>, TCombinedResult>()
      It might be possible, but I judged it to be overhead.

Notes

This is regarding the feedback from the previous PR.
#9618 (comment)

  1. Comparing lengths doesn't seem to be the main solution.
  2. The crucial point is why changes to input aren't detected in this.#result !== this.#lastResult.

My response to this
#9618 (comment)

  1. That's correct. Length comparison is a second-best solution to resolve the issue while preventing existing tests from failing.
  2. The reason it wasn't caught by that conditional statement was due to timing differences (details are provided in the link).
    So my conclusion was that the length comparison is a reasonable compromise—it resolves the issue and detects input changes while minimizing impact on existing functionality (the cache). I ended the discussion by asking if my reasoning was correct.

Summary by CodeRabbit

  • Bug Fixes

    • Improved combine behavior for multi-query results to update immediately when the number of queries changes, including during optimistic updates.
    • More reliable handling of dynamic query arrays, including additions, removals, and reorderings, ensuring combined results reflect the current set and order.
  • Tests

    • Added comprehensive test coverage for useQueries with combine, covering stable and inline functions, changing query counts, reordering, and data alignment scenarios.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 9, 2025

Walkthrough

Adds query-core logic to recompute combined results when the number of observed queries changes, and introduces tests in query-core and react-query to validate correct behavior for stable and inline combine functions as query arrays grow, shrink, reorder, or switch combine references.

Changes

Cohort / File(s) Summary
Core observer combine logic
packages/query-core/src/queriesObserver.ts
Stores last input array and forces recomputation when input length changes; updates internal caches for combine results accordingly.
Query-core tests
packages/query-core/src/__tests__/queriesObserver.test.tsx
Adds test ensuring stable combine invalidates and reflects new query count after updates.
React bindings tests for combine
packages/react-query/src/__tests__/useQueries-combine.test.tsx
New test suite covering stable vs inline combine, dynamic query arrays, reordering, identical-length changes, and switching combine references.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant C as Component/useQueries
  participant O as QueriesObserver
  participant Q as QueryCache

  C->>O: observe(queries, { combine })
  O->>Q: subscribe to query results
  Q-->>O: results update (array)

  rect rgba(230,245,255,0.6)
  note over O: Combine memoization
  O->>O: #combineResult(input)
  alt New combine fn OR results changed OR input.length changed
    O->>O: Recompute combined result<br/>Update #lastInput, #lastCombine, #lastResult
  else No relevant change
    O->>O: Return cached #lastResult
  end
  end

  O-->>C: emit combined result
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

package: query-core, package: react-query

Suggested reviewers

  • TkDodo

Pre-merge checks (5 passed)

✅ Passed checks (5 passed)
Check name Status Explanation
Title Check ✅ Passed The title succinctly and accurately identifies the primary change in query-core by specifying the fix for cache invalidation when using a stable-reference combine function, directly reflecting the core update made in this pull request.
Linked Issues Check ✅ Passed The modifications implement the specific fix requested in issue #8781 by invalidating the cache when the number of queries changes, align stable-reference behavior with inline combine, and include tests that verify the corrected sequence of query lengths, thereby satisfying the linked issue’s objectives.
Out of Scope Changes Check ✅ Passed All code modifications and added tests are focused on enhancing the combine cache logic for stable-reference functions and validating that behavior, with no unrelated or extraneous changes introduced.
Description Check ✅ Passed The description clearly outlines the context of the bug, its root cause, the implemented solution, and the testing scope, all of which directly relate to the code changes introduced in the pull request.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.

Poem

Thump-thump goes my eager paw,
Counting queries—one, then more—aha!
When lengths shift, I recombine,
Carrots aligned, results divine. 🥕
Stable minds, inline rhyme,
Cache now hops exactly in time.

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Comment @coderabbitai help to get the list of available commands and usage tips.

@nx-cloud
Copy link

nx-cloud bot commented Sep 9, 2025

View your CI Pipeline Execution ↗ for commit 02ddb7e

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

☁️ Nx Cloud last updated this comment at 2025-09-09 23:30:22 UTC

@pkg-pr-new
Copy link

pkg-pr-new bot commented Sep 9, 2025

More templates

@tanstack/angular-query-devtools-experimental

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

@tanstack/angular-query-experimental

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

@tanstack/eslint-plugin-query

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

@tanstack/query-async-storage-persister

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

@tanstack/query-broadcast-client-experimental

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

@tanstack/query-core

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

@tanstack/query-devtools

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

@tanstack/query-persist-client-core

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

@tanstack/query-sync-storage-persister

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

@tanstack/react-query

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

@tanstack/react-query-devtools

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

@tanstack/react-query-next-experimental

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

@tanstack/react-query-persist-client

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

@tanstack/solid-query

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

@tanstack/solid-query-devtools

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

@tanstack/solid-query-persist-client

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

@tanstack/svelte-query

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

@tanstack/svelte-query-devtools

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

@tanstack/svelte-query-persist-client

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

@tanstack/vue-query

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

@tanstack/vue-query-devtools

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

commit: 02ddb7e

Copy link
Contributor

@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: 0

🧹 Nitpick comments (5)
packages/query-core/src/queriesObserver.ts (2)

46-47: Store only the input length (or compare to current result length) to reduce state and coupling

You only need the length. Two lightweight options:

  • Option A (keep a field): store a number
  • Option B (no new field): compare against this.#result.length

Apply Option A:

-  // Tracks the last input passed to #combineResult to detect query count changes in optimistic updates
-  #lastInput?: Array<QueryObserverResult>
+  // Tracks last input length to detect query-count changes during optimistic updates
+  #lastInputLen?: number

And below:

-        input.length !== this.#lastInput?.length ||
+        input.length !== this.#lastInputLen ||
-        this.#lastInput = input
+        this.#lastInputLen = input.length

Or Option B (remove field entirely):

-        input.length !== this.#lastInput?.length ||
+        input.length !== this.#result.length ||

…and drop the #lastInput field and its assignment.


172-194: Add shrink-path coverage at core level

We have a growth test for stable combine; add a matching shrink test (length 2 → 1) exercising the same optimistic path to guard against regressions.

I can draft the core test mirroring the growth case if you want it included in this PR.

Also applies to: 279-287

packages/query-core/src/__tests__/queriesObserver.test.tsx (1)

398-446: Consider adding the symmetric shrink case

Add a second test that starts with 2 queries and then removes one with the same stable combine, asserting a call where call[0].length === 1 and final result length is 1. This closes the gap for length decreases.

Example to append at the end of this file:

test('combine with stable reference should invalidate cache when query count shrinks', async () => {
  const key1 = queryKey()
  const key2 = queryKey()
  const queryFn1 = vi.fn().mockReturnValue(1)
  const queryFn2 = vi.fn().mockReturnValue(2)
  const combine = vi.fn((results) => results)

  const observer = new QueriesObserver(
    queryClient,
    [
      { queryKey: key1, queryFn: queryFn1 },
      { queryKey: key2, queryFn: queryFn2 },
    ],
    { combine },
  )

  const results: Array<Array<QueryObserverResult>> = []
  const unsubscribe = observer.subscribe((r) => results.push(r))
  try {
    await vi.advanceTimersByTimeAsync(0)
    const initialCalls = combine.mock.calls.length
    const baseline = results.length

    observer.setQueries([{ queryKey: key1, queryFn: queryFn1 }], { combine })
    await vi.advanceTimersByTimeAsync(0)

    expect(combine.mock.calls.length).toBeGreaterThan(initialCalls)
    expect(
      combine.mock.calls.some((c) => Array.isArray(c[0]) && c[0].length === 1),
    ).toBe(true)
    expect(results.length).toBeGreaterThan(baseline)
    expect(results[results.length - 1]).toHaveLength(1)
  } finally {
    unsubscribe()
  }
})
packages/react-query/src/__tests__/useQueries-combine.test.tsx (2)

231-274: Optional: assert combine invocation to guard perf

Wrap stableCombine in a spy and assert minimal recomputes across same-length changes to catch accidental cache busts.

Example:

-    const stableCombine = (results: any) => results
+    const stableCombine = vi.fn((results: any) => results)
...
-    rerender({ keys: ['c', 'd'] })
+    const callsBefore = stableCombine.mock.calls.length
+    rerender({ keys: ['c', 'd'] })
+    expect(stableCombine.mock.calls.length).toBeGreaterThanOrEqual(callsBefore)

1-92: Nit: avoid any in tests where simple generics suffice

Tighten types for results to improve editor help without noise.

For example:

-    const stableCombine = (results: any) => results
+    const stableCombine = <T extends unknown[]>(results: T) => results
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ccedf33 and 02ddb7e.

📒 Files selected for processing (3)
  • packages/query-core/src/__tests__/queriesObserver.test.tsx (1 hunks)
  • packages/query-core/src/queriesObserver.ts (2 hunks)
  • packages/react-query/src/__tests__/useQueries-combine.test.tsx (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
packages/react-query/src/__tests__/useQueries-combine.test.tsx (2)
packages/query-core/src/queriesObserver.ts (2)
  • queries (239-265)
  • result (196-211)
packages/react-query/src/__tests__/useQueries.test.tsx (1)
  • key1 (1091-1190)
packages/query-core/src/queriesObserver.ts (1)
packages/query-core/src/types.ts (1)
  • QueryObserverResult (899-904)
packages/query-core/src/__tests__/queriesObserver.test.tsx (1)
packages/query-core/src/queriesObserver.ts (3)
  • observer (267-273)
  • QueriesObserver (35-290)
  • result (196-211)
⏰ 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
🔇 Additional comments (6)
packages/query-core/src/queriesObserver.ts (1)

221-223: Targeted cache-bust on input length change looks correct

This condition plugs the optimistic-result gap for stable combine refs without regressing existing memoization. Updating #lastInput inside the recompute branch is also correct.

Also applies to: 227-232

packages/query-core/src/__tests__/queriesObserver.test.tsx (1)

398-446: Good regression test for stable-combine growth

The assertions prove the cache is invalidated when the query count increases and that combine receives a 2-length array.

packages/react-query/src/__tests__/useQueries-combine.test.tsx (4)

10-51: Stable combine immediate updates: nice, focused signal check

Synchronous length checks on rerender validate the intended behavior for stable refs.


94-140: Dynamic add/remove coverage is solid

Covers grow/shrink without relying on data resolution. Good.


142-186: Transforming combine path covered

Verifies memoization still works when combine returns a different shape.


188-229: Switching stable ↔ inline combine

Ensures no regressions when toggling references. All good.

@joseph0926
Copy link
Contributor Author

Length comparison is not a fundamental solution
I will close this PR.

@joseph0926 joseph0926 closed this Sep 10, 2025
@joseph0926 joseph0926 deleted the fix/combine-stable-reference-caching branch September 10, 2025 20:35
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.

combine stable reference is not called with correct parameters

2 participants