Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions .changeset/tidy-carrots-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@tanstack/react-query': patch
---

Recompute `useMutationState` when `filters` or `select` change. The result was only
rebuilt when the mutation cache notified, so a render carrying new options kept
serving the previous ones until something unrelated touched the cache. `useIsMutating`
is built on the same hook and was stale in the same way.
263 changes: 263 additions & 0 deletions packages/react-query/src/__tests__/useMutationState.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,4 +241,267 @@ describe('useMutationState', () => {

expect(variables).toEqual([[], [1], []])
})

it('should update the result when the filters change without a cache update', async () => {
const queryClient = new QueryClient()
const key1 = queryKey()
const key2 = queryKey()

function Variables({ mutationKey }: { mutationKey: Array<string> }) {
const variables = useMutationState({
filters: { mutationKey },
select: (mutation) => mutation.state.variables,
})

return <div>variables: {variables.join(',')}</div>
}

function Page() {
const [mutationKey, setMutationKey] = React.useState(key1)
const { mutate: mutate1 } = useMutation({
mutationKey: key1,
mutationFn: (input: number) => sleep(10).then(() => 'data' + input),
})
const { mutate: mutate2 } = useMutation({
mutationKey: key2,
mutationFn: (input: number) => sleep(10).then(() => 'data' + input),
})

return (
<div>
<Variables mutationKey={mutationKey} />
<button
onClick={() => {
mutate1(1)
mutate2(2)
}}
>
mutate
</button>
<button onClick={() => setMutationKey(key2)}>switch</button>
</div>
)
}

const rendered = renderWithClient(queryClient, <Page />)

fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
await vi.advanceTimersByTimeAsync(11)
expect(rendered.getByText('variables: 1')).toBeInTheDocument()

// only the filters change - nothing touches the mutation cache
fireEvent.click(rendered.getByRole('button', { name: /switch/i }))
expect(rendered.getByText('variables: 2')).toBeInTheDocument()
})

it('should update the result when the select changes', async () => {
const queryClient = new QueryClient()
const key = queryKey()

function Selected({ pick }: { pick: (m: any) => unknown }) {
const values = useMutationState({
filters: { mutationKey: key },
select: pick,
})

return <div>value: {String(values[0])}</div>
}

function Page() {
const [pick, setPick] = React.useState(
() => (m: any) => m.state.variables as unknown,
)
const { mutate } = useMutation({
mutationKey: key,
mutationFn: (input: number) => sleep(10).then(() => 'data' + input),
})

return (
<div>
<Selected pick={pick} />
<button onClick={() => mutate(7)}>mutate</button>
<button onClick={() => setPick(() => (m: any) => m.state.status)}>
switch
</button>
</div>
)
}

const rendered = renderWithClient(queryClient, <Page />)

fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
await vi.advanceTimersByTimeAsync(11)
expect(rendered.getByText('value: 7')).toBeInTheDocument()

fireEvent.click(rendered.getByRole('button', { name: /switch/i }))
expect(rendered.getByText('value: success')).toBeInTheDocument()
})

it('should empty the result when the filters stop matching', async () => {
const queryClient = new QueryClient()
const key = queryKey()
const other = queryKey()

function Variables({ mutationKey }: { mutationKey: Array<string> }) {
const variables = useMutationState({
filters: { mutationKey },
select: (mutation) => mutation.state.variables,
})

return <div>count: {variables.length}</div>
}

function Page() {
const [mutationKey, setMutationKey] = React.useState(key)
const { mutate } = useMutation({
mutationKey: key,
mutationFn: (input: number) => sleep(10).then(() => 'data' + input),
})

return (
<div>
<Variables mutationKey={mutationKey} />
<button onClick={() => mutate(1)}>mutate</button>
<button onClick={() => setMutationKey(other)}>switch</button>
</div>
)
}

const rendered = renderWithClient(queryClient, <Page />)

fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
await vi.advanceTimersByTimeAsync(11)
expect(rendered.getByText('count: 1')).toBeInTheDocument()

fireEvent.click(rendered.getByRole('button', { name: /switch/i }))
expect(rendered.getByText('count: 0')).toBeInTheDocument()
})

it('should keep the same result reference across an unrelated render', async () => {
const queryClient = new QueryClient()
const key = queryKey()
const seen: Array<unknown> = []

function Variables() {
const variables = useMutationState({
filters: { mutationKey: key },
select: (mutation) => ({ ...mutation.state }),
})
seen.push(variables)

return null
}

function Page() {
const [, rerender] = React.useState(0)
const { mutate } = useMutation({
mutationKey: key,
mutationFn: (input: number) => sleep(10).then(() => 'data' + input),
})

return (
<div>
<Variables />
<button onClick={() => mutate(1)}>mutate</button>
<button onClick={() => rerender((n) => n + 1)}>rerender</button>
</div>
)
}

const rendered = renderWithClient(queryClient, <Page />)

fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
await vi.advanceTimersByTimeAsync(11)
const before = seen.length
expect((seen[before - 1] as Array<unknown>).length).toBe(1)

fireEvent.click(rendered.getByRole('button', { name: /rerender/i }))

// the snapshot must stay referentially stable, or useSyncExternalStore loops
expect(seen.length).toBeGreaterThan(before)
expect(seen[seen.length - 1]).toBe(seen[before - 1])
})

it('should not re-run a stable select on an unrelated render', async () => {
const queryClient = new QueryClient()
const key = queryKey()
let selectCalls = 0
const select = (mutation: any) => {
selectCalls++

return mutation.state.variables
}

function Variables() {
const [, rerender] = React.useState(0)
useMutationState({ filters: { mutationKey: key }, select })

return <button onClick={() => rerender((n) => n + 1)}>rerender</button>
}

function Page() {
const { mutate } = useMutation({
mutationKey: key,
mutationFn: (input: number) => sleep(10).then(() => 'data' + input),
})

return (
<div>
<Variables />
<button onClick={() => mutate(1)}>mutate</button>
</div>
)
}

const rendered = renderWithClient(queryClient, <Page />)

fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
await vi.advanceTimersByTimeAsync(11)
const before = selectCalls

// the re-render starts inside the subtree, so nothing notifies the cache
fireEvent.click(rendered.getByRole('button', { name: /rerender/i }))
fireEvent.click(rendered.getByRole('button', { name: /rerender/i }))

expect(selectCalls).toBe(before)
})

it('should update useIsMutating when the filters change', async () => {
const queryClient = new QueryClient()
const key1 = queryKey()
const key2 = queryKey()

function Count({ mutationKey }: { mutationKey: Array<string> }) {
const count = useIsMutating({ mutationKey })

return <div>count: {count}</div>
}

function Page() {
const [mutationKey, setMutationKey] = React.useState(key1)
const { mutate } = useMutation({
mutationKey: key1,
mutationFn: (input: number) => sleep(50).then(() => 'data' + input),
})

return (
<div>
<Count mutationKey={mutationKey} />
<button onClick={() => mutate(1)}>mutate</button>
<button onClick={() => setMutationKey(key2)}>switch</button>
</div>
)
}

const rendered = renderWithClient(queryClient, <Page />)

fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
await vi.advanceTimersByTimeAsync(10)
expect(rendered.getByText('count: 1')).toBeInTheDocument()

fireEvent.click(rendered.getByRole('button', { name: /switch/i }))
expect(rendered.getByText('count: 0')).toBeInTheDocument()

await vi.advanceTimersByTimeAsync(41)
})
})
20 changes: 18 additions & 2 deletions packages/react-query/src/useMutationState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,24 @@ export function useMutationState<
const mutationCache = useQueryClient(queryClient).getMutationCache()
const optionsRef = React.useRef(options)
const result = React.useRef<Array<TResult>>(null)
if (result.current === null) {
result.current = getResult(mutationCache, options)
const filtersRef = React.useRef<MutationFilters | undefined>(undefined)
const selectRef = React.useRef(options.select)
// Also recomputed here, not only when the cache notifies: `getSnapshot` returns this
// ref, so a render with new options would otherwise keep the previous result. Guarded
// on the options, the way `QueryObserver` guards `select`, so an unrelated render does
// not re-run it.
const nextFilters = replaceEqualDeep(filtersRef.current, options.filters)
if (
result.current === null ||
nextFilters !== filtersRef.current ||
options.select !== selectRef.current
) {
filtersRef.current = nextFilters
selectRef.current = options.select
result.current =
result.current === null
? getResult(mutationCache, options)
: replaceEqualDeep(result.current, getResult(mutationCache, options))
}

React.useEffect(() => {
Expand Down