Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .changeset/solid-query-untracked-initial-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/solid-query': patch
---

fix(solid-query): untrack the one-shot client and options reads the hooks make on mount

`useQuery`, `useQueries`, `useMutation`, `useIsFetching`, `useIsMutating` and `useMutationState` each read their `client` memo and their options accessor directly while seeding an observer or a signal. Later changes reach those observers through `setOptions`/`setQueries` and the effects around them, so the initial reads are one-shot by design — but they were still made in a tracking scope. On Solid 2 that makes every hook call log a `[STRICT_READ_UNTRACKED]` diagnostic on mount, and a hook called from inside a computation had that computation re-run and its observer rebuilt whenever the client or the options changed. The reads are now untracked.
172 changes: 172 additions & 0 deletions packages/solid-query/src/__tests__/untrackedReads.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createComputed, createRoot, createSignal } from 'solid-js'
import { queryKey, sleep } from '@tanstack/query-test-utils'
import {
QueryClient,
useIsFetching,
useIsMutating,
useMutation,
useMutationState,
useQueries,
useQuery,
} from '..'

/**
* Every hook seeds its state from the current client and options before handing
* later changes to an observer. Those initial reads are one-shot by design, so
* they must not register as dependencies of whatever is running when the hook is
* called — Solid's `STRICT_READ_UNTRACKED` diagnostics report them, and a caller
* inside a tracking scope gets its computation re-run and its hook rebuilt.
*
* Each test calls a hook inside a computation that reads nothing itself, then
* invalidates the sources the hook read. The computation must not re-run.
*/
function countRunsOf(run: () => void) {
let runs = 0
const dispose = createRoot((disposeRoot) => {
createComputed(() => {
runs++
run()
})
return disposeRoot
})
return { runs: () => runs, dispose }
}

describe('untracked reads', () => {
let queryClient: QueryClient
let otherClient: QueryClient

beforeEach(() => {
vi.useFakeTimers()
queryClient = new QueryClient()
otherClient = new QueryClient()
})

afterEach(() => {
queryClient.clear()
otherClient.clear()
vi.useRealTimers()
})

it('should not track the reads useQuery makes while creating its observer', () => {
const [client, setClient] = createSignal(queryClient)
const [key, setKey] = createSignal(queryKey())

const { runs, dispose } = countRunsOf(() => {
useQuery(
() => ({
queryKey: key(),
queryFn: () => sleep(10).then(() => 'data'),
}),
client,
)
})

expect(runs()).toBe(1)

setKey(queryKey())
setClient(otherClient)

expect(runs()).toBe(1)
dispose()
})

it('should not track the reads useQueries makes while creating its observer', () => {
const [client, setClient] = createSignal(queryClient)
const [key, setKey] = createSignal(queryKey())

const { runs, dispose } = countRunsOf(() => {
useQueries(
() => ({
queries: [
{ queryKey: key(), queryFn: () => sleep(10).then(() => 'data') },
],
}),
client,
)
})

expect(runs()).toBe(1)

setKey(queryKey())
setClient(otherClient)

expect(runs()).toBe(1)
dispose()
})

it('should not track the reads useMutation makes while creating its observer', () => {
const [client, setClient] = createSignal(queryClient)
const [key, setKey] = createSignal(queryKey())

const { runs, dispose } = countRunsOf(() => {
useMutation(
() => ({
mutationKey: key(),
mutationFn: () => Promise.resolve('data'),
}),
client,
)
})

expect(runs()).toBe(1)

setKey(queryKey())
setClient(otherClient)

expect(runs()).toBe(1)
dispose()
})

it('should not track the reads useIsFetching makes while seeding its result', () => {
const [client, setClient] = createSignal(queryClient)
const [key, setKey] = createSignal(queryKey())

const { runs, dispose } = countRunsOf(() => {
useIsFetching(() => ({ queryKey: key() }), client)
})

expect(runs()).toBe(1)

setKey(queryKey())
setClient(otherClient)

expect(runs()).toBe(1)
dispose()
})

it('should not track the reads useIsMutating makes while seeding its result', () => {
const [client, setClient] = createSignal(queryClient)
const [key, setKey] = createSignal(queryKey())

const { runs, dispose } = countRunsOf(() => {
useIsMutating(() => ({ mutationKey: key() }), client)
})

expect(runs()).toBe(1)

setKey(queryKey())
setClient(otherClient)

expect(runs()).toBe(1)
dispose()
})

it('should not track the reads useMutationState makes while seeding its result', () => {
const [client, setClient] = createSignal(queryClient)
const [key, setKey] = createSignal(queryKey())

const { runs, dispose } = countRunsOf(() => {
useMutationState(() => ({ filters: { mutationKey: key() } }), client)
})

expect(runs()).toBe(1)

setKey(queryKey())
setClient(otherClient)

expect(runs()).toBe(1)
dispose()
})
})
12 changes: 9 additions & 3 deletions packages/solid-query/src/useBaseQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
createSignal,
on,
onCleanup,
untrack,
} from 'solid-js'
import { createStore, reconcile, unwrap } from 'solid-js/store'
import { useQueryClientResolver } from './QueryClientProvider'
Expand Down Expand Up @@ -136,13 +137,18 @@ export function useBaseQuery<
}
return defaultOptions
})
const initialOptions = defaultedOptions()
// The initial options, the observer and its first result are all read once,
// to seed state that is kept up to date afterwards by the computations below.
// None of these reads should register as a dependency.
const initialOptions = untrack(defaultedOptions)

const [observer, setObserver] = createSignal(
new Observer(client(), defaultedOptions()),
untrack(() => new Observer(client(), defaultedOptions())),
)

let observerResult = observer().getOptimisticResult(defaultedOptions())
let observerResult = untrack(() =>
observer().getOptimisticResult(defaultedOptions()),
)
const [state, setState] =
createStore<QueryObserverResult<TData, TError>>(observerResult)

Expand Down
13 changes: 11 additions & 2 deletions packages/solid-query/src/useIsFetching.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js'
import {
createEffect,
createMemo,
createSignal,
onCleanup,
untrack,
} from 'solid-js'
import { useQueryClientResolver } from './QueryClientProvider'
import type { QueryFilters } from '@tanstack/query-core'
import type { QueryClient } from './QueryClient'
Expand Down Expand Up @@ -34,7 +40,10 @@ export function useIsFetching(
const client = createMemo(() => resolveClient())
const queryCache = createMemo(() => client().getQueryCache())

const [fetches, setFetches] = createSignal(client().isFetching(filters?.()))
// Seeding the signal is a one-shot read; the effect below keeps it current.
const [fetches, setFetches] = createSignal(
untrack(() => client().isFetching(filters?.())),
)

createEffect(() => {
setFetches(client().isFetching(filters?.()))
Expand Down
11 changes: 9 additions & 2 deletions packages/solid-query/src/useIsMutating.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js'
import {
createEffect,
createMemo,
createSignal,
onCleanup,
untrack,
} from 'solid-js'
import { useQueryClientResolver } from './QueryClientProvider'
import type { MutationFilters } from '@tanstack/query-core'
import type { QueryClient } from './QueryClient'
Expand Down Expand Up @@ -33,8 +39,9 @@ export function useIsMutating(
const client = createMemo(() => resolveClient())
const mutationCache = createMemo(() => client().getMutationCache())

// Seeding the signal is a one-shot read; the effect below keeps it current.
const [mutations, setMutations] = createSignal(
client().isMutating(filters?.()),
untrack(() => client().isMutating(filters?.())),
)

createEffect(() => {
Expand Down
18 changes: 11 additions & 7 deletions packages/solid-query/src/useMutation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { MutationObserver, noop, shouldThrowError } from '@tanstack/query-core'
import { createComputed, createMemo, on, onCleanup } from 'solid-js'
import { createComputed, createMemo, on, onCleanup, untrack } from 'solid-js'
import { createStore } from 'solid-js/store'
import { useQueryClientResolver } from './QueryClientProvider'
import type { DefaultError } from '@tanstack/query-core'
Expand Down Expand Up @@ -182,12 +182,16 @@ export function useMutation<
const resolveClient = useQueryClientResolver(queryClient)
const client = createMemo(() => resolveClient())

const observer = new MutationObserver<
TData,
TError,
TVariables,
TOnMutateResult
>(client(), options())
// The observer is created once, from the current client and options; later
// changes are handed to it through `setOptions` below. Reading them here is
// deliberately one-shot, so it must not register as a dependency.
const observer = untrack(
() =>
new MutationObserver<TData, TError, TVariables, TOnMutateResult>(
client(),
options(),
),
)

const mutate: UseMutateFunction<
TData,
Expand Down
11 changes: 9 additions & 2 deletions packages/solid-query/src/useMutationState.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js'
import {
createEffect,
createMemo,
createSignal,
onCleanup,
untrack,
} from 'solid-js'
import { replaceEqualDeep } from '@tanstack/query-core'
import { useQueryClientResolver } from './QueryClientProvider'
import type {
Expand Down Expand Up @@ -133,8 +139,9 @@ export function useMutationState<
const client = createMemo(() => resolveClient())
const mutationCache = createMemo(() => client().getMutationCache())

// Seeding the signal is a one-shot read; the effect below keeps it current.
const [result, setResult] = createSignal(
getResult(mutationCache(), options()),
untrack(() => getResult(mutationCache(), options())),
)

createEffect(() => {
Expand Down
53 changes: 32 additions & 21 deletions packages/solid-query/src/useQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
on,
onCleanup,
onMount,
untrack,
} from 'solid-js'
import { useQueryClientResolver } from './QueryClientProvider'
import { useIsRestoring } from './isRestoring'
Expand Down Expand Up @@ -300,21 +301,29 @@ export function useQueries<
),
)

const observer = new QueriesObserver(
client(),
defaultedQueries(),
queriesOptions().combine
? ({
combine: queriesOptions().combine,
} as QueriesObserverOptions<TCombinedResult>)
: undefined,
// The observer and the initial store contents are seeded once; the queries are
// kept up to date afterwards by `setQueries` below, so these reads are
// deliberately one-shot and must not register as dependencies.
const observer = untrack(
() =>
new QueriesObserver(
client(),
defaultedQueries(),
queriesOptions().combine
? ({
combine: queriesOptions().combine,
} as QueriesObserverOptions<TCombinedResult>)
: undefined,
),
)

const [state, setState] = createStore<TCombinedResult>(
observer.getOptimisticResult(
defaultedQueries(),
(queriesOptions() as QueriesObserverOptions<TCombinedResult>).combine,
)[1](),
untrack(() =>
observer.getOptimisticResult(
defaultedQueries(),
(queriesOptions() as QueriesObserverOptions<TCombinedResult>).combine,
)[1](),
),
)

createRenderEffect(
Expand Down Expand Up @@ -346,14 +355,16 @@ export function useQueries<
),
)

batch(() => {
const dataResources_ = dataResources()
for (let index = 0; index < dataResources_.length; index++) {
const dataResource = dataResources_[index]!
dataResource[1].mutate(() => unwrap(state[index]!.data))
dataResource[1].refetch()
}
})
untrack(() =>
batch(() => {
const dataResources_ = dataResources()
for (let index = 0; index < dataResources_.length; index++) {
const dataResource = dataResources_[index]!
dataResource[1].mutate(() => unwrap(state[index]!.data))
dataResource[1].refetch()
}
}),
)

let taskQueue: Array<() => void> = []
const subscribeToObserver = () =>
Expand Down Expand Up @@ -424,7 +435,7 @@ export function useQueries<
return new Proxy(s, handler(index))
})

const [proxyState, setProxyState] = createStore(getProxies())
const [proxyState, setProxyState] = createStore(untrack(getProxies))
createRenderEffect(() => setProxyState(getProxies()))

return proxyState as TCombinedResult
Expand Down