Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .changeset/mutation-flight-scoped-subscription.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@tanstack/solid-query': patch
---

fix: scope useMutation's mutation-cache subscription to the flight. The
hook subscribed at mount with a bare reactive `client()` read (tripping
Solid's STRICT_READ_UNTRACKED dev diagnostic) and held the subscription
for its whole life even though the listener only matters while a mutation
is in flight. The subscription now starts in `run` against the same
client the mutation is built on and ends at settle: nothing reactive is
read at setup, the listener and the mutation can never sit on different
clients, and idle hooks hold no cache subscription.
58 changes: 58 additions & 0 deletions packages/solid-query/src/__tests__/useMutation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,64 @@ describe('useMutation', () => {
consoleMock.mockRestore()
})

it('should not emit a strict-read diagnostic on mount', () => {
const consoleMock = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined)

function Page() {
const mutation = useMutation(() => ({
mutationFn: () => Promise.resolve('mutation'),
}))

return <button disabled={mutation.isPending}>mutate</button>
}

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

expect(
consoleMock.mock.calls.filter((args) =>
args.some((arg) => String(arg).includes('STRICT_READ_UNTRACKED')),
),
).toEqual([])

consoleMock.mockRestore()
})

it('should hold no mutation-cache subscription while idle', async () => {
function Page() {
const mutation = useMutation(() => ({
mutationFn: () => sleep(10).then(() => 'result'),
}))

return (
<div>
<button onClick={() => mutation.mutate()}>mutate</button>
<span>status: {mutation.status}</span>
</div>
)
}

const cache = queryClient.getMutationCache()
const listeners = () =>
(cache as unknown as { listeners: Set<unknown> }).listeners.size
const idle = listeners()

const rendered = renderWithClient(queryClient, () => <Page />)
// Mounting alone subscribes nothing.
expect(listeners()).toBe(idle)

fireEvent.click(rendered.getByRole('button', { name: /mutate/i }))
await vi.advanceTimersByTimeAsync(0)
// In flight: exactly one flight listener.
expect(listeners()).toBe(idle + 1)

await vi.advanceTimersByTimeAsync(10)
expect(rendered.getByText('status: success')).toBeInTheDocument()
// Settled: the flight listener is gone.
expect(listeners()).toBe(idle)
})

it('should be able to call `onSuccess` and `onSettled` after each successful mutate', async () => {
let countRef = 0
const [count, setCount] = createSignal(0)
Expand Down
64 changes: 39 additions & 25 deletions packages/solid-query/src/useMutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,20 +114,20 @@ export function useMutation<
* Flight metadata (retry failures, offline pause) lives on the Mutation
* instance and changes during the await gap; a version bump per cache
* event for the active mutation keeps reads current without cloning
* observer results.
* observer results. The subscription is per-flight, created in `run`
* against the same client the mutation is built on: nothing reactive is
* read at setup (no client read to untrack), the listener and the
* mutation can never sit on different clients, and an idle hook holds no
* cache subscription at all.
*/
let activeMutation: Mutation<TData, TError, TVariables> | null = null
let unsubscribeFlight: (() => void) | null = null
const [flightVersion, setFlightVersion] = createSignal(0)
if (!isServer) {
const unsubscribe = client()
.getMutationCache()
.subscribe((event) => {
if ('mutation' in event && event.mutation === activeMutation) {
setFlightVersion((v) => v + 1)
}
})
onCleanup(unsubscribe)
}
if (!isServer)
onCleanup(() => {
unsubscribeFlight?.()
unsubscribeFlight = null
})

async function* run(
variables: TVariables,
Expand All @@ -144,19 +144,33 @@ export function useMutation<
if (!isServer) setFlight({ variables })
opts.onMutate?.(variables, callbackContext)

const mutation = client()
.getMutationCache()
.build(client(), {
...opts,
// Options-level callbacks re-run below, inside the transaction —
// executing them here (inside the await gap) would let their
// cache writes and invalidations escape the atomic settle.
onMutate: undefined,
onSuccess: undefined,
onError: undefined,
onSettled: undefined,
}) as unknown as Mutation<TData, TError, TVariables>
const c = untrack(client)
const mutation = c.getMutationCache().build(c, {
...opts,
// Options-level callbacks re-run below, inside the transaction —
// executing them here (inside the await gap) would let their
// cache writes and invalidations escape the atomic settle.
onMutate: undefined,
onSuccess: undefined,
onError: undefined,
onSettled: undefined,
}) as unknown as Mutation<TData, TError, TVariables>
activeMutation = mutation
if (!isServer) {
// A rapid re-mutate replaces the previous flight's listener.
unsubscribeFlight?.()
unsubscribeFlight = c.getMutationCache().subscribe((event) => {
if ('mutation' in event && event.mutation === activeMutation) {
setFlightVersion((v) => v + 1)
}
})
}
const settleFlight = () => {
if (activeMutation !== mutation) return
activeMutation = null
unsubscribeFlight?.()
unsubscribeFlight = null
}

/**
* Failure settle, shared by both paths. Callback routing mirrors
Expand Down Expand Up @@ -202,7 +216,7 @@ export function useMutation<
variables,
submittedAt: mutation.state.submittedAt,
})
activeMutation = null
settleFlight()
throw error
}

Expand Down Expand Up @@ -250,7 +264,7 @@ export function useMutation<
variables,
submittedAt: mutation.state.submittedAt,
})
activeMutation = null
settleFlight()
return result
}

Expand Down
Loading