Repository: TanStack/query (ts)

Issue:
v4: Using hydrate during an ongoing "initial" fetch overwrites the isFetching status
### Describe the bug

If you `hydrate` a previous state while an "initial" fetch is ongoing the `status`, `fetchStatus` and derived booleans no longer reflect the state of the query.

### Your minimal, reproducible example

https://codesandbox.io/s/zen-diffie-klzj6l?file=/src/App.js

### Steps to reproduce

### In the attached CodeSandbox
1. Refresh the Page
2. Click "Hydrate State" button within 5secs

### Expected behavior

As a user, I expected the `isFetching` status to always be `true` when the query `isFetching`

### How often does this bug happen?

Every time

### Screenshots or Videos

_No response_

### Platform

- OS: Windows 10
- Browser: Chrome 117.0.5938.132
- Project: React, TypeScript via Vite


### Tanstack Query adapter

react-query

### TanStack Query version

v4.35.3

### TypeScript version

v5.0.2

### Additional context

For context on how we stumbled across this... We wrote a custom `Persister` that stores the cache encrypted in IndexedDB. It uses a per-user `key` that is returned from the API. Because of this, some `useQuery` calls have to happen first before we can know the current `user` and their `key`. As such the `Persister` is inactive initially but when a `key` is set, it attempts to `hydrate` (using IndexedDB) and becomes a functional `Persister`.

This generally works well, as the hydrated state ([as per the docs](https://tanstack.com/query/latest/docs/react/reference/hydration#:~:text=If%20the%20queries%20included%20in%20dehydration%20already%20exist%20in%20the%20queryCache%2C%20hydrate%20does%20not%20overwrite%20them%20and%20they%20will%20be%20silently%20discarded.)) does not overwrite any queries that might have happened in the meantime. It does however remove any subtle loading indicators, for those queries that are still being refreshed in the background. Hence this bug report.

Pull request:
fix(hydration): do not alter fetchStatus when hydrating existing queries
fixes #6087

Patch:
diff --git a/packages/query-core/src/hydration.ts b/packages/query-core/src/hydration.ts
index 6191f7f1e..8eae288a6 100644
--- a/packages/query-core/src/hydration.ts
+++ b/packages/query-core/src/hydration.ts
@@ -139,19 +139,15 @@ export function hydrate(
     )
   })
 
-  queries.forEach((dehydratedQuery) => {
-    const query = queryCache.get(dehydratedQuery.queryHash)
-
-    // Reset fetch status to idle in the dehydrated state to avoid
-    // query being stuck in fetching state upon hydration
-    const dehydratedQueryState = {
-      ...dehydratedQuery.state,
-      fetchStatus: 'idle' as const,
-    }
+  queries.forEach(({ queryKey, state, queryHash }) => {
+    const query = queryCache.get(queryHash)
 
     // Do not hydrate if an existing query exists with newer data
     if (query) {
-      if (query.state.dataUpdatedAt < dehydratedQueryState.dataUpdatedAt) {
+      if (query.state.dataUpdatedAt < state.dataUpdatedAt) {
+        // omit fetchStatus from dehydrated state
+        // so that query stays in its current fetchStatus
+        const { fetchStatus: _ignored, ...dehydratedQueryState } = state
         query.setState(dehydratedQueryState)
       }
       return
@@ -162,10 +158,15 @@ export function hydrate(
       client,
       {
         ...options?.defaultOptions?.queries,
-        queryKey: dehydratedQuery.queryKey,
-        queryHash: dehydratedQuery.queryHash,
+        queryKey,
+        queryHash,
+      },
+      // Reset fetch status to idle to avoid
+      // query being stuck in fetching state upon hydration
+      {
+        ...state,
+        fetchStatus: 'idle',
       },
-      dehydratedQueryState,
     )
   })
 }


Test patch:
diff --git a/packages/query-core/src/tests/hydration.test.tsx b/packages/query-core/src/tests/hydration.test.tsx
index 90d268e2e..4882e81b2 100644
--- a/packages/query-core/src/tests/hydration.test.tsx
+++ b/packages/query-core/src/tests/hydration.test.tsx
@@ -427,7 +427,7 @@ describe('dehydration and rehydration', () => {
     queryClient.clear()
   })
 
-  test('should set the fetchStatus to idle in all cases when dehydrating', async () => {
+  test('should set the fetchStatus to idle when creating a query with dehydrate', async () => {
     const queryCache = new QueryCache()
     const queryClient = createQueryClient({ queryCache })
 
@@ -466,4 +466,36 @@ describe('dehydration and rehydration', () => {
     hydrate(hydrationClient, parsed)
     expect(hydrationCache.find(['string'])?.state.fetchStatus).toBe('idle')
   })
+
+  test('should not change fetchStatus when updating a query with dehydrate', async () => {
+    const queryClient = createQueryClient()
+
+    const options = {
+      queryKey: ['string'],
+      queryFn: async () => {
+        await sleep(10)
+        return 'string'
+      },
+    } as const
+
+    await queryClient.prefetchQuery(options)
+
+    const dehydrated = dehydrate(queryClient)
+    expect(
+      dehydrated.queries.find((q) => q.queryHash === '["string"]')?.state
+        .fetchStatus,
+    ).toBe('idle')
+    const stringified = JSON.stringify(dehydrated)
+
+    // ---
+    const parsed = JSON.parse(stringified)
+    const hydrationCache = new QueryCache()
+    const hydrationClient = createQueryClient({ queryCache: hydrationCache })
+
+    const promise = hydrationClient.prefetchQuery(options)
+    hydrate(hydrationClient, parsed)
+    expect(hydrationCache.find(['string'])?.state.fetchStatus).toBe('fetching')
+    await promise
+    expect(hydrationCache.find(['string'])?.state.fetchStatus).toBe('idle')
+  })
 })