diff --git a/.changeset/solid-query-untracked-initial-reads.md b/.changeset/solid-query-untracked-initial-reads.md new file mode 100644 index 0000000000..3019c82eab --- /dev/null +++ b/.changeset/solid-query-untracked-initial-reads.md @@ -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. diff --git a/packages/solid-query/src/__tests__/untrackedReads.test.tsx b/packages/solid-query/src/__tests__/untrackedReads.test.tsx new file mode 100644 index 0000000000..cf5a640ab7 --- /dev/null +++ b/packages/solid-query/src/__tests__/untrackedReads.test.tsx @@ -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() + }) +}) diff --git a/packages/solid-query/src/useBaseQuery.ts b/packages/solid-query/src/useBaseQuery.ts index 1cc45a420b..1fbe96aeb3 100644 --- a/packages/solid-query/src/useBaseQuery.ts +++ b/packages/solid-query/src/useBaseQuery.ts @@ -10,6 +10,7 @@ import { createSignal, on, onCleanup, + untrack, } from 'solid-js' import { createStore, reconcile, unwrap } from 'solid-js/store' import { useQueryClientResolver } from './QueryClientProvider' @@ -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>(observerResult) diff --git a/packages/solid-query/src/useIsFetching.ts b/packages/solid-query/src/useIsFetching.ts index b7772d3774..515b492e86 100644 --- a/packages/solid-query/src/useIsFetching.ts +++ b/packages/solid-query/src/useIsFetching.ts @@ -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' @@ -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?.())) diff --git a/packages/solid-query/src/useIsMutating.ts b/packages/solid-query/src/useIsMutating.ts index eb1dfbf420..a6d67fa747 100644 --- a/packages/solid-query/src/useIsMutating.ts +++ b/packages/solid-query/src/useIsMutating.ts @@ -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' @@ -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(() => { diff --git a/packages/solid-query/src/useMutation.ts b/packages/solid-query/src/useMutation.ts index 741176e25c..935b84da8c 100644 --- a/packages/solid-query/src/useMutation.ts +++ b/packages/solid-query/src/useMutation.ts @@ -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' @@ -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( + client(), + options(), + ), + ) const mutate: UseMutateFunction< TData, diff --git a/packages/solid-query/src/useMutationState.ts b/packages/solid-query/src/useMutationState.ts index ad90a85cb9..71252b7ed8 100644 --- a/packages/solid-query/src/useMutationState.ts +++ b/packages/solid-query/src/useMutationState.ts @@ -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 { @@ -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(() => { diff --git a/packages/solid-query/src/useQueries.ts b/packages/solid-query/src/useQueries.ts index 7a00ef972c..8b7b9a122f 100644 --- a/packages/solid-query/src/useQueries.ts +++ b/packages/solid-query/src/useQueries.ts @@ -10,6 +10,7 @@ import { on, onCleanup, onMount, + untrack, } from 'solid-js' import { useQueryClientResolver } from './QueryClientProvider' import { useIsRestoring } from './isRestoring' @@ -300,21 +301,29 @@ export function useQueries< ), ) - const observer = new QueriesObserver( - client(), - defaultedQueries(), - queriesOptions().combine - ? ({ - combine: queriesOptions().combine, - } as QueriesObserverOptions) - : 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) + : undefined, + ), ) const [state, setState] = createStore( - observer.getOptimisticResult( - defaultedQueries(), - (queriesOptions() as QueriesObserverOptions).combine, - )[1](), + untrack(() => + observer.getOptimisticResult( + defaultedQueries(), + (queriesOptions() as QueriesObserverOptions).combine, + )[1](), + ), ) createRenderEffect( @@ -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 = () => @@ -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