Skip to content

Conversation

joseph0926
Copy link
Contributor

@joseph0926 joseph0926 commented Sep 5, 2025

Fixes #9610

Description

This PR fixes the double-fetching issue that happens when using HydrationBoundary with server-side data fetching patterns such as React Router’s loader.

Problem

When revisiting a page that already has fresh server-side data

  1. On the first visit, everything works correctly. The new query is hydrated immediately through useMemo
  2. On subsequent visits, the query already exists in the cache, so hydration is deferred until useEffect
  3. Between render and effect, useQuery runs and sees stale data
  4. With the default refetchOnMount: true, this triggers an unnecessary refetch
  5. After that, useEffect runs and hydrates with the fresh server data
  6. As a result, the same data is fetched twice—once on the server and once again on the client

Example with React Router

The react-router used in the test was created with pnpm dlx create-react-router@latest, and its version was v7.8.2.

export async function loader() {
  const data = await fetchData()
  return { dehydratedState: dehydrate(queryClient) }
}

function Page() {
  const { dehydratedState } = useLoaderData()
  return (
    <HydrationBoundary state={dehydratedState}>
      <Component />
    </HydrationBoundary>
  )
}

Investigation

I added logging to better understand the execution order

  • HydrationBoundary’s useMemo runs first and defers hydration of existing queries
  • QueryObserver.onSubscribe runs before useEffect, and since the query appears stale, it triggers a refetch

The core issue is that deferring hydration avoids updates during transitions, but this also creates a short gap where queries look stale and trigger refetching.

Solution

I introduced an internal _pendingHydration flag to mark queries that are waiting to be hydrated

  1. HydrationBoundary sets this flag on existing queries before deferring hydration
  2. QueryObserver checks the flag during onSubscribe and skips the refetch if it is set
  3. Once hydration completes, HydrationBoundary removes the flag
  4. If refetchOnMount: 'always' is explicitly set, the refetch still occurs as intended

For implementation, I used as any when setting the flag. I think this is acceptable because the flag is purely internal, temporary, and cleared immediately after use. Adding it to the public Query type would unnecessarily expose details that don’t belong in the public API.

However, to be certain, I added a comment to the section where the flag was set.


Testing

  • Verified that no double fetch occurs on revisits with fresh hydrated data.
  • Confirmed that refetchOnMount: 'always' is still respected.
  • Checked behavior with multiple observers subscribing to the same query.

No breaking changes – this is a targeted optimization

Summary by CodeRabbit

  • Bug Fixes

    • Prevents unnecessary refetches during hydration by marking existing queries during render to avoid double-fetching; markers are cleared after hydration.
    • Ensures refetch still occurs when refetch-on-mount is explicitly "always" (including when provided as a function).
    • Correctly handles multiple observers subscribing to the same query during hydration.
  • Performance

    • Reduces redundant network requests on initial mount after hydration.
  • Tests

    • Added hydration tests covering skip-on-fresh-data, forced refetch, and multiple-observer scenarios.

Copy link

coderabbitai bot commented Sep 5, 2025

Walkthrough

Implements a temporary per-query runtime hydration flag set during render to suppress refetch-on-mount for existing hydrated queries (unless resolved refetchOnMount === "always"); QueryObserver reads the flag on first subscribe to decide whether to fetch; HydrationBoundary clears the flag in an effect after hydrate().

Changes

Cohort / File(s) Summary
Query observer change
packages/query-core/src/queryObserver.ts
On first listener, QueryObserver reads a temporary (query as any)._pendingHydration flag, resolves refetchOnMount (value or function) into localOptions, and if hydration is pending and resolvedRefetchOnMount !== 'always' it skips the fetch and calls updateResult(); otherwise normal fetch logic runs.
HydrationBoundary render/effect updates
packages/react-query/src/HydrationBoundary.tsx
During render, mark existing cache queries with (query as any)._pendingHydration = true to signal pending hydration. In a useEffect after hydrating the queued queries, iterate the hydration queue and delete the _pendingHydration flag from each live query. No public API changes.
Tests (react)
packages/react-query/src/__tests__/HydrationBoundary.test.tsx
Adds tests covering: no refetch on subsequent visits with fresh hydrated data; refetch when refetchOnMount: 'always'; multiple observers during hydration; refetchOnMount as a function returning 'always'. Also imports screen. One test appears duplicated in the patch.
Tests (core)
packages/query-core/src/__tests__/queryObserver.test.tsx
Adds hydration-flag handling tests verifying that _pendingHydration suppresses fetches, that refetchOnMount: 'always' forces fetch, and that functional refetchOnMount is invoked with the internal query object.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant HB as HydrationBoundary
  participant QC as QueryCache
  participant Q as Query
  participant Comp as Component
  participant QO as QueryObserver

  rect rgba(230,240,255,0.5)
    note over HB: Render-phase marking
    HB->>QC: lookup existing queries from dehydrated state
    QC-->>HB: return live queries
    HB->>Q: set (query as any)._pendingHydration = true
  end

  rect rgba(240,255,240,0.5)
    note over Comp: Component mounts / observer subscribes
    Comp->>QO: subscribe (first listener)
    QO->>Q: read _pendingHydration
    alt _pendingHydration && resolvedRefetchOnMount != "always"
      QO->>Comp: updateResult() (skip fetch)
    else
      QO->>Q: shouldFetchOnMount(...)
      alt should fetch
        QO->>Q: execute fetch
      else
        QO->>Comp: updateResult() (no fetch)
      end
    end
  end

  rect rgba(255,245,230,0.5)
    note over HB: Effect-phase cleanup
    HB->>QC: iterate hydrationQueue
    QC->>Q: delete (query as any)._pendingHydration
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Assessment against linked issues

Objective Addressed Explanation
Prevent double-fetch on subsequent visits when hydrated data is fresh (#9610)
Allow explicit override to still refetch via refetchOnMount: "always" (#9610)
Ensure per-render hydration marker is cleared after hydrate() to restore normal behavior (#9610)

Assessment against linked issues: Out-of-scope changes

Code Change Explanation
Duplicate test addition of the same HydrationBoundary case (packages/react-query/src/__tests__/HydrationBoundary.test.tsx) Duplicate test appears accidental and is not part of the functional objectives; should be removed or deduplicated.

Suggested reviewers

  • TkDodo
  • manudeli

Poem

I tuck a tiny flag inside the cache so neat,
A hush for mounts where fresh data meets.
No extra fetch, no doubled race—
I nibble once and hold my place.
Unless you call "always"—then I sprint with cheerful feet. 🥕🐇


📜 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 1b0cda7 and ed09f82.

📒 Files selected for processing (1)
  • packages/query-core/src/queryObserver.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/query-core/src/queryObserver.ts
✨ 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.

Copy link

nx-cloud bot commented Sep 5, 2025

View your CI Pipeline Execution ↗ for commit a8e760f

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

☁️ Nx Cloud last updated this comment at 2025-09-08 13:24:17 UTC

Copy link

pkg-pr-new bot commented Sep 5, 2025

More templates

@tanstack/angular-query-devtools-experimental

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

@tanstack/angular-query-experimental

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

@tanstack/eslint-plugin-query

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

@tanstack/query-async-storage-persister

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

@tanstack/query-broadcast-client-experimental

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

@tanstack/query-core

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

@tanstack/query-devtools

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

@tanstack/query-persist-client-core

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

@tanstack/query-sync-storage-persister

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

@tanstack/react-query

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

@tanstack/react-query-devtools

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

@tanstack/react-query-next-experimental

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

@tanstack/react-query-persist-client

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

@tanstack/solid-query

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

@tanstack/solid-query-devtools

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

@tanstack/solid-query-persist-client

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

@tanstack/svelte-query

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

@tanstack/svelte-query-devtools

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

@tanstack/svelte-query-persist-client

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

@tanstack/vue-query

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

@tanstack/vue-query-devtools

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

commit: ed09f82

@joseph0926
Copy link
Contributor Author

joseph0926 commented Sep 5, 2025

The test failure seems to be occurring in nx run @tanstack/solid-query-devtools:test:types, which is unrelated to this PR.

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: 2

🧹 Nitpick comments (7)
packages/query-core/src/queryObserver.ts (2)

98-105: Clarify cleanup strategy for the hydration flag

Comment says it’s “immediately removed in QueryObserver.onSubscribe”, but QueryObserver explicitly doesn’t delete it; HydrationBoundary’s effect removes it. Update the comment to avoid confusion and prevent future regressions.

-      // Check hydration flag but DON'T delete it
-      // The flag is only meaningful during the current render cycle
+      // Check hydration flag but DON'T delete it here.
+      // The flag is cleared in HydrationBoundary's effect after hydrate().

468-478: Optional: avoid transient “fetching” state during pending hydration

getOptimisticResult may set fetching state while we plan to skip fetch due to pending hydration, causing UI spinners without network traffic. Consider honoring _pendingHydration in the optimistic “fetchOnMount” path.

Would you like me to draft a minimal change that checks (query as any)._pendingHydration before applying fetchState in the optimistic branch?

Also applies to: 484-486

packages/react-query/src/HydrationBoundary.tsx (2)

98-101: Comment contradicts implementation

The flag is cleared in the effect below, not in QueryObserver.onSubscribe. Please align the comment.

-              // Temporary flag to prevent double-fetching during hydration
-              // Will be immediately removed in QueryObserver.onSubscribe
+              // Temporary flag to prevent double-fetching during hydration.
+              // Cleared in the effect below after hydrate() completes.

95-102: Optional: use a shared Symbol for the internal flag to avoid key collisions

A unique Symbol avoids accidental user collision with _pendingHydration. We can export an internal symbol from query-core and use it here.

I can wire this up with a non-breaking internal export if you want.

packages/react-query/src/__tests__/HydrationBoundary.test.tsx (3)

493-553: Nice coverage for revisit without refetch

Consider clearing the client at the end to avoid cross-test leakage.

   expect(screen.getByText('fresh-from-server')).toBeInTheDocument()
 
   vi.useRealTimers()
+  queryClient.clear()

555-619: Covers 'always' string; add a test for function form too

Good test for the 'always' literal. Please also add a case where refetchOnMount is a function returning 'always' to guard against regressions.

Example:

test('should refetch when refetchOnMount is a function returning "always"', async () => {
  vi.useFakeTimers()
  const queryFn = vi.fn().mockResolvedValueOnce('initial').mockResolvedValueOnce('refetched')
  const qc = new QueryClient()
  function Page() {
    const { data } = useQuery({
      queryKey: ['fn-always'],
      queryFn,
      refetchOnMount: () => 'always',
    })
    return <div>{data}</div>
  }
  const { rerender } = render(<QueryClientProvider client={qc}><Page /></QueryClientProvider>)
  await vi.waitFor(() => expect(queryFn).toHaveBeenCalledTimes(1))
  const serverQC = new QueryClient()
  await serverQC.prefetchQuery({ queryKey: ['fn-always'], queryFn: () => Promise.resolve('from-server') })
  const state = dehydrate(serverQC)
  queryFn.mockClear()
  rerender(<QueryClientProvider client={qc}><HydrationBoundary state={state}><Page /></HydrationBoundary></QueryClientProvider>)
  await vi.runOnlyPendingTimersAsync()
  expect(queryFn).toHaveBeenCalledTimes(1)
  vi.useRealTimers()
  qc.clear()
})

620-690: Good multi-observer scenario

Looks correct and guards the intended behavior. Same small cleanup suggestion as above.

   expect(screen.getByText('B: fresh-from-server')).toBeInTheDocument()
 
   vi.useRealTimers()
+  queryClient.clear()
📜 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 2283633 and ce1abf3.

📒 Files selected for processing (3)
  • packages/query-core/src/queryObserver.ts (1 hunks)
  • packages/react-query/src/HydrationBoundary.tsx (2 hunks)
  • packages/react-query/src/__tests__/HydrationBoundary.test.tsx (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
packages/react-query/src/HydrationBoundary.tsx (1)
packages/query-core/src/queryObserver.ts (1)
  • query (711-728)
packages/react-query/src/__tests__/HydrationBoundary.test.tsx (2)
packages/query-core/src/hydration.ts (1)
  • dehydrate (122-163)
packages/react-query/src/HydrationBoundary.tsx (1)
  • HydrationBoundary (25-122)
⏰ 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

Comment on lines 95 to 102
existingQueries.forEach((q) => {
const query = queryCache.get(q.queryHash)
if (query) {
// Temporary flag to prevent double-fetching during hydration
// Will be immediately removed in QueryObserver.onSubscribe
;(query as any)._pendingHydration = true
}
})
Copy link

@coderabbitai coderabbitai bot Sep 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Safer flag lifecycle: avoid mutating queries during render; set/cleanup in layout effect

Setting the flag inside useMemo mutates cache objects during render. If a transition is aborted pre-commit, cleanup never runs and the flag can stick. Move flagging into a parent layout effect (runs before children subscribe) and add a fallback cleanup.

Targeted changes:

  • Remove render-phase flagging.
  • Add a React.useLayoutEffect that sets the flag for hydrationQueue and cleans it up on unmount/change.
  • Keep the existing effect for hydrate().
@@
-        if (existingQueries.length > 0) {
-          existingQueries.forEach((q) => {
-            const query = queryCache.get(q.queryHash)
-            if (query) {
-              // Temporary flag to prevent double-fetching during hydration
-              // Will be immediately removed in QueryObserver.onSubscribe
-              ;(query as any)._pendingHydration = true
-            }
-          })
-          return existingQueries
-        }
+        if (existingQueries.length > 0) {
+          return existingQueries
+        }
@@
   React.useEffect(() => {
     if (hydrationQueue) {
       hydrate(client, { queries: hydrationQueue }, optionsRef.current)
-      hydrationQueue.forEach((q) => {
-        const query = client.getQueryCache().get(q.queryHash)
-        if (query) {
-          delete (query as any)._pendingHydration
-        }
-      })
     }
   }, [client, hydrationQueue])
+
+  // Ensure flags are set before children subscribe; also clean up on unmount/change.
+  React.useLayoutEffect(() => {
+    if (!hydrationQueue?.length) return
+    const cache = client.getQueryCache()
+    const flagged: string[] = []
+    hydrationQueue.forEach((q) => {
+      const query = cache.get(q.queryHash)
+      if (query) {
+        ;(query as any)._pendingHydration = true
+        flagged.push(q.queryHash)
+      }
+    })
+    return () => {
+      flagged.forEach((hash) => {
+        const q = cache.get(hash)
+        if (q) delete (q as any)._pendingHydration
+      })
+    }
+  }, [client, hydrationQueue])
+
+  // Clear flags after hydrate() completes
+  React.useEffect(() => {
+    if (!hydrationQueue?.length) return
+    const cache = client.getQueryCache()
+    hydrationQueue.forEach((q) => {
+      const query = cache.get(q.queryHash)
+      if (query) delete (query as any)._pendingHydration
+    })
+  }, [client, hydrationQueue])

If you prefer minimal scope, we can keep render-phase logic and add a test to prove flags don’t persist after an aborted transition.

Also applies to: 112-117


🏁 Script executed:

#!/bin/bash
file=$(find . -type f -name 'HydrationBoundary.tsx')
if [ -z "$file" ]; then
  echo "File not found"
else
  sed -n '1,200p' "$file"
fi

Length of output: 4661


Move hydration flagging into a useLayoutEffect
Remove the _pendingHydration assignment from the render‐phase useMemo and instead set and clean up the flag inside a React.useLayoutEffect before children subscribe and on unmount/change.

🤖 Prompt for AI Agents
In packages/react-query/src/HydrationBoundary.tsx around lines 95 to 102, remove
the render-phase assignment of (query as any)._pendingHydration inside the
useMemo and instead set these flags inside a React.useLayoutEffect: in the
effect iterate existingQueries, set _pendingHydration = true on each query, and
return a cleanup that deletes the _pendingHydration flag for each query (also
ensure cleanup runs when dependencies change); finally delete the original
assignment from the memo so flagging only happens in the layout effect.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since useLayoutEffect runs after the DOM update, isn't it already too late at the time of component rendering?

For example, the following timing issues may exist

  1. HydrationBoundary renders
  2. Child component renders (useQuery call) // No flag
  3. useLayoutEffect executes // May be delayed

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@joseph0926 You're absolutely right! That's an excellent catch on the React lifecycle timing.

The timing you described is exactly correct:

  1. HydrationBoundary renders (useMemo runs)
  2. Child components render (useQuery hooks execute, QueryObserver.onSubscribe runs)
  3. useLayoutEffect executes (too late - the unwanted refetch already happened)

The _pendingHydration flag must be set during the render phase (in useMemo) because:

  • QueryObserver.onSubscribe runs when child components render and subscribe to queries
  • This happens before any layout effects execute
  • If the flag isn't set yet, the unwanted refetch will be triggered before useLayoutEffect has a chance to set the flag

Your original implementation is correct - the flag needs to be set synchronously during the render phase to be available when children subscribe. Moving it to useLayoutEffect would completely break the double-fetching prevention.

The render-phase mutation, while not ideal from a purity standpoint, is necessary here because the flag needs to exist at the exact moment child components subscribe to prevent the unwanted refetch behavior.

🐰💭
Sometimes the render phase is exactly where we need to be...

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

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

103-114: Avoid invoking refetchOnMount function twice on mount.

Call the function once, pass its resolved value to shouldFetchOnMount to prevent duplicate user callbacks/side effects.

-      const resolvedRefetchOnMount =
+      const resolvedRefetchOnMount =
         typeof this.options.refetchOnMount === 'function'
           ? this.options.refetchOnMount(this.#currentQuery)
           : this.options.refetchOnMount

-      const shouldSkipFetch =
-        hasPendingHydration && resolvedRefetchOnMount !== 'always'
+      const localOptions = {
+        ...this.options,
+        // ensure shouldFetchOnMount doesn't call the function again
+        refetchOnMount: resolvedRefetchOnMount,
+      } as typeof this.options
+
+      const shouldSkipFetch =
+        hasPendingHydration && resolvedRefetchOnMount !== 'always'

-      if (
-        shouldFetchOnMount(this.#currentQuery, this.options) &&
-        !shouldSkipFetch
-      ) {
+      const shouldFetch =
+        shouldFetchOnMount(this.#currentQuery, localOptions) && !shouldSkipFetch
+
+      if (shouldFetch) {
         this.#executeFetch()
       } else {
         this.updateResult()
       }

98-102: Guard against lingering _pendingHydration suppressing needed fetches.

If HydrationBoundary unmounts before its effect runs, the flag might remain and skip future mount fetches. Consider a fallback (e.g., clear-on-unsubscribe/update, or only skip once per observer tick).

📜 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 ce1abf3 and 6bf0980.

📒 Files selected for processing (3)
  • packages/query-core/src/queryObserver.ts (1 hunks)
  • packages/react-query/src/HydrationBoundary.tsx (2 hunks)
  • packages/react-query/src/__tests__/HydrationBoundary.test.tsx (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/react-query/src/HydrationBoundary.tsx
  • packages/react-query/src/tests/HydrationBoundary.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: Preview
  • GitHub Check: Test
🔇 Additional comments (1)
packages/query-core/src/queryObserver.ts (1)

98-114: Hydration-aware mount logic looks correct; honors function-valued refetchOnMount('always).

Reading _pendingHydration and resolving refetchOnMount before skipping prevents the double-fetch and preserves 'always'. LGTM.

Copy link

codecov bot commented Sep 5, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 59.74%. Comparing base (ccedf33) to head (a8e760f).

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##             main    #9617       +/-   ##
===========================================
+ Coverage   45.50%   59.74%   +14.23%     
===========================================
  Files         209      138       -71     
  Lines        8377     5631     -2746     
  Branches     1897     1529      -368     
===========================================
- Hits         3812     3364      -448     
+ Misses       4118     1963     -2155     
+ 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.49% <100.00%> (+0.01%) ⬆️
@tanstack/query-devtools 3.48% <ø> (ø)
@tanstack/query-persist-client-core 79.60% <ø> (ø)
@tanstack/query-sync-storage-persister 84.61% <ø> (ø)
@tanstack/query-test-utils ∅ <ø> (∅)
@tanstack/react-query 96.10% <100.00%> (+0.10%) ⬆️
@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: 0

🧹 Nitpick comments (5)
packages/query-core/src/__tests__/queryObserver.test.tsx (5)

1505-1511: Assert the query was found before mutating internal flag

If find ever returns undefined, subsequent expectations would be misleading. Add an explicit guard in each test.

Apply this diff in each test right after obtaining query:

-      const query = queryClient.getQueryCache().find({ queryKey: key })
+      const query = queryClient.getQueryCache().find({ queryKey: key })
+      expect(query).toBeDefined()
       if (query) {
         ;(query as any)._pendingHydration = true
       }

Also applies to: 1532-1535, 1559-1562


1516-1523: Strengthen the first test by asserting the observer result uses hydrated data

This proves we didn’t fetch and we’re reading the cached value under hydration gating.

Apply:

       const unsubscribe = observer.subscribe(() => undefined)
       await vi.advanceTimersByTimeAsync(0)

       expect(queryFn).toHaveBeenCalledTimes(0)
+      expect(observer.getCurrentResult()).toMatchObject({
+        status: 'success',
+        fetchStatus: 'idle',
+        data: 'initial-data',
+      })

       expect((query as any)?._pendingHydration).toBe(true)

1571-1579: Also assert evaluation order: refetchOnMount callback runs before the fetch

Locks in the intended onSubscribe resolution sequence.

Apply:

       expect(refetchOnMountFn).toHaveBeenCalledTimes(1)
       expect(refetchOnMountFn).toHaveBeenCalledWith(query)

       expect(queryFn).toHaveBeenCalledTimes(1)
+      expect(
+        refetchOnMountFn.mock.invocationCallOrder[0] <
+          queryFn.mock.invocationCallOrder[0],
+      ).toBe(true)

1497-1581: DRY up test setup for pending hydration

Reduce duplication and make intent explicit by extracting a small helper.

Add this helper near the top of the file (outside the changed block):

function flagPendingHydrationForKey(
  qc: QueryClient,
  key: ReadonlyArray<unknown>,
  data: unknown = 'initial-data',
) {
  qc.setQueryData(key, data)
  const q = qc.getQueryCache().find({ queryKey: key })
  expect(q).toBeDefined()
  ;(q as any)!._pendingHydration = true
  return q!
}

Then replace the repeated setup in each test:

-      queryClient.setQueryData(key, 'initial-data')
-
-      const query = queryClient.getQueryCache().find({ queryKey: key })
-      if (query) {
-        ;(query as any)._pendingHydration = true
-      }
+      const query = flagPendingHydrationForKey(queryClient, key, 'initial-data')

1497-1581: Optional: add a multi-subscriber case to mirror real-world mounting

Validates that multiple observers mounting during pending hydration don’t trigger a fetch.

You can append:

test('should not fetch with multiple observers during pending hydration', async () => {
  const key = queryKey()
  const queryFn = vi.fn().mockResolvedValue('data')
  const query = flagPendingHydrationForKey(queryClient, key, 'initial-data')

  const observer1 = new QueryObserver(queryClient, { queryKey: key, queryFn })
  const observer2 = new QueryObserver(queryClient, { queryKey: key, queryFn })

  const u1 = observer1.subscribe(() => undefined)
  const u2 = observer2.subscribe(() => undefined)
  await vi.advanceTimersByTimeAsync(0)

  expect(queryFn).toHaveBeenCalledTimes(0)
  expect((query as any)._pendingHydration).toBe(true)

  u1(); u2()
})
📜 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 295469d and a4fdbf3.

📒 Files selected for processing (1)
  • packages/query-core/src/__tests__/queryObserver.test.tsx (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/query-core/src/__tests__/queryObserver.test.tsx (1)
packages/query-core/src/queryObserver.ts (2)
  • query (721-738)
  • QueryObserver (39-764)
⏰ 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 (1)
packages/query-core/src/__tests__/queryObserver.test.tsx (1)

1498-1581: Hydration gating cases well covered

Good additions: skip when _pendingHydration is set, override with refetchOnMount: 'always', and functional form coverage including param passing. This aligns with queryObserver’s new onSubscribe logic.

@joseph0926 joseph0926 closed this Sep 9, 2025
@joseph0926 joseph0926 deleted the fix/hydration-boundary-double-fetching branch September 9, 2025 10:44
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.

HydrationBoundary double fetching on subsequent visits
1 participant