Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions packages/query-core/src/__tests__/queriesObserver.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,4 +347,45 @@ describe('queriesObserver', () => {
expect(queryFn1).toHaveBeenCalledTimes(1)
expect(queryFn2).toHaveBeenCalledTimes(1)
})

test('should notify when results change during early return', async () => {
const key1 = queryKey()
const key2 = queryKey()
const queryFn1 = vi.fn().mockReturnValue(1)
const queryFn2 = vi.fn().mockReturnValue(2)

queryClient.setQueryData(key1, 1)
queryClient.setQueryData(key2, 2)

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

const results: Array<Array<QueryObserverResult>> = []
results.push(observer.getCurrentResult())

const unsubscribe = observer.subscribe((result) => {
results.push(result)
})

observer.setQueries([
{ queryKey: key1, queryFn: queryFn1, staleTime: Infinity },
{ queryKey: key2, queryFn: queryFn2, staleTime: Infinity },
])

await vi.advanceTimersByTimeAsync(0)

unsubscribe()

expect(results.length).toBeGreaterThanOrEqual(2)
expect(results[0]).toMatchObject([
{ status: 'success', data: 1 },
{ status: 'success', data: 2 },
])
expect(results[results.length - 1]).toMatchObject([
{ status: 'success', data: 1 },
{ status: 'success', data: 2 },
])
})
})
15 changes: 15 additions & 0 deletions packages/query-core/src/queriesObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

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

checking for these 3 props seems a bit random. I think we’d want to use our shallowEqualObjects helper from the query-core to just compare result with prev. This is also what we do inside queryObserver:

// Only notify and update result if something has changed
if (shallowEqualObjects(nextResult, prevResult)) {
return
}

Copy link
Contributor Author

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?

Copy link
Collaborator

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 🤔

Copy link
Contributor Author

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

4bc297b

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.

Copy link
Collaborator

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.

)
})

if (resultChanged) {
this.#result = newResult
this.#notify()
}
Copy link
Collaborator

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
  • then, we check for hasResultChange
    • if that’s the case, we want to assign this.#result = newResult, then check for listeners, then call this.#notify().

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)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

999c8d2

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!


return
}

Expand Down
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')
})
})
})
Loading