-
-
Notifications
You must be signed in to change notification settings - Fork 3.5k
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
Changes from 3 commits
7953639
7eaaad7
f860b8d
5227bc3
5540f61
e5a60aa
32a55ad
e351336
999c8d2
c4db285
4bc297b
6a20d93
44bfedb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
|
@@ -123,6 +123,21 @@ export class QueriesObserver< | |||||||||
) | ||||||||||
|
||||||||||
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 || | ||||||||||
result.error !== prev.error | ||||||||||
|
// Only notify and update result if something has changed | |
if (shallowEqualObjects(nextResult, prevResult)) { | |
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.
I found that using shallowEqualObjects causes test failures because it compares ALL properties of QueryObserverResult, including timestamps like dataUpdatedAt. This means it detects changes even when the actual data hasn't changed. The individual property comparison (data, isPending, error) passes all tests. Should we stick with the explicit property checks, or would you prefer a different approach?
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.
but if dataUpdatedAt
changes, we need to inform observers about it, right? A component could render dataUpdatedAt
on the screen so it will want that information to be correct 🤔
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.
You're absolutely right. Components could render any property including dataUpdatedAt, so we need to detect all changes. I'll use shallowEqualObjects as you suggested.
The test failures appear to be because the tests expect combine not to re-run when metadata changes, but that expectation seems incorrect. When any property changes (even metadata), combine should re-execute to ensure the UI stays in sync.
Should I update the test expectations to reflect this correct behavior?
test file: packages/react-query/src/tests/useQueries.test.tsx > should optimize combine if it is a stable reference
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.
I've updated the test expectations to account for the shallowEqualObjects comparison. The tests now expect combine to re-run when metadata changes occur.
However, I'm not entirely confident whether having combine re-run on metadata changes is the intended behavior from this PR... Perhaps there's a performance consideration I'm missing, or my implementation itself might be incorrect.
The tests are passing with the updated expectations, but I believe a different approach might be more appropriate. I defer to your judgment on whether this is the desired behavior.
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.
yeah that test seems slightly off, and actually shows the bug: we get new metadata (dataUpdatedAt
), which is part of results
passed to combine
, but we don’t run it :/
I think I was basing this test off of select
on useQuery
, but the difference is that select
only gets data
passed while combine
gets passed everything.
Should I update the test expectations to reflect this correct behavior?
yes please, and we need to test combine with a stable reference differently, likely by triggering a re-render differently, e.g. just having an increment
that triggers an unrelated re-render, because in those cases, a stable combine shouldn’t re-run.
Outdated
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.
I don’t really like the early returns here and the duplicated assignment to #result
as well as calls to #notify
, makes it kinda hard to read what is going on. I think we’d want something like this:
- first, check for
hasIndexChange
- if we have an index change, we want to compute the difference for observers and destroy / re-create them, then assign
this.#observers = newObservers
- if we have an index change, we want to compute the difference for observers and destroy / re-create them, then assign
- then, we check for
hasResultChange
- if that’s the case, we want to assign
this.#result = newResult
, then check for listeners, then callthis.#notify()
.
- if that’s the case, we want to assign
this flow ties logic better to the conditions: when indices change, we need to re-do observers and assign them; when result changes, we need to re-assign it. If anything changes, we need to notify
(I’m assuming here that if indices change, we’ll also get a resultChange)
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.
Thank you for the detailed feedback! I've updated the code based on your suggestions.
To be honest, I was struggling to find the right approach, but the CodeRabbitAI feedback really helped clarify the path forward.
Would appreciate another review when you have time. Thanks!
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,134 @@ | ||
import { afterEach, beforeEach, describe, expect, it } from 'vitest' | ||
import { render, waitFor } from '@testing-library/react' | ||
import * as React from 'react' | ||
import { QueryClient, useQueries } from '@tanstack/react-query' | ||
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client' | ||
import type { | ||
PersistedClient, | ||
Persister, | ||
} from '@tanstack/query-persist-client-core' | ||
import type { QueryObserverResult } from '@tanstack/react-query' | ||
|
||
describe('useQueries with persist and memoized combine', () => { | ||
const storage: { [key: string]: string } = {} | ||
|
||
beforeEach(() => { | ||
Object.defineProperty(window, 'localStorage', { | ||
value: { | ||
getItem: (key: string) => storage[key] || null, | ||
setItem: (key: string, value: string) => { | ||
storage[key] = value | ||
}, | ||
removeItem: (key: string) => { | ||
delete storage[key] | ||
}, | ||
clear: () => { | ||
Object.keys(storage).forEach((key) => delete storage[key]) | ||
}, | ||
}, | ||
writable: true, | ||
}) | ||
}) | ||
|
||
afterEach(() => { | ||
Object.keys(storage).forEach((key) => delete storage[key]) | ||
}) | ||
|
||
it('should update UI when combine is memoized with persist', async () => { | ||
const queryClient = new QueryClient({ | ||
defaultOptions: { | ||
queries: { | ||
staleTime: 30_000, | ||
gcTime: 1000 * 60 * 60 * 24, | ||
}, | ||
}, | ||
}) | ||
|
||
const persister: Persister = { | ||
persistClient: (client: PersistedClient) => { | ||
storage['REACT_QUERY_OFFLINE_CACHE'] = JSON.stringify(client) | ||
return Promise.resolve() | ||
}, | ||
restoreClient: async () => { | ||
const stored = storage['REACT_QUERY_OFFLINE_CACHE'] | ||
if (stored) { | ||
await new Promise((resolve) => setTimeout(resolve, 10)) | ||
return JSON.parse(stored) as PersistedClient | ||
} | ||
return undefined | ||
}, | ||
removeClient: () => { | ||
delete storage['REACT_QUERY_OFFLINE_CACHE'] | ||
return Promise.resolve() | ||
}, | ||
} | ||
|
||
const persistedData: PersistedClient = { | ||
timestamp: Date.now(), | ||
buster: '', | ||
clientState: { | ||
mutations: [], | ||
queries: [1, 2, 3].map((id) => ({ | ||
queryHash: `["post",${id}]`, | ||
queryKey: ['post', id], | ||
state: { | ||
data: id, | ||
dataUpdateCount: 1, | ||
dataUpdatedAt: Date.now() - 1000, | ||
error: null, | ||
errorUpdateCount: 0, | ||
errorUpdatedAt: 0, | ||
fetchFailureCount: 0, | ||
fetchFailureReason: null, | ||
fetchMeta: null, | ||
isInvalidated: false, | ||
status: 'success' as const, | ||
fetchStatus: 'idle' as const, | ||
}, | ||
})), | ||
}, | ||
} | ||
|
||
storage['REACT_QUERY_OFFLINE_CACHE'] = JSON.stringify(persistedData) | ||
|
||
function TestComponent() { | ||
const combinedQueries = useQueries({ | ||
queries: [1, 2, 3].map((id) => ({ | ||
queryKey: ['post', id], | ||
queryFn: () => Promise.resolve(id), | ||
staleTime: 30_000, | ||
})), | ||
combine: React.useCallback( | ||
(results: Array<QueryObserverResult<number, Error>>) => ({ | ||
data: results.map((r) => r.data), | ||
isPending: results.some((r) => r.isPending), | ||
}), | ||
[], | ||
), | ||
}) | ||
|
||
return ( | ||
<div> | ||
<div data-testid="pending">{String(combinedQueries.isPending)}</div> | ||
<div data-testid="data"> | ||
{combinedQueries.data.filter((d) => d !== undefined).join(',')} | ||
</div> | ||
</div> | ||
) | ||
} | ||
|
||
const { getByTestId } = render( | ||
<PersistQueryClientProvider | ||
client={queryClient} | ||
persistOptions={{ persister }} | ||
> | ||
<TestComponent /> | ||
</PersistQueryClientProvider>, | ||
) | ||
|
||
await waitFor(() => { | ||
expect(getByTestId('pending').textContent).toBe('false') | ||
expect(getByTestId('data').textContent).toBe('1,2,3') | ||
}) | ||
}) | ||
}) |
Uh oh!
There was an error while loading. Please reload this page.