@tanstack/solid-query 6.0.0-rc.0 → 6.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,12 @@
1
1
  import { InfiniteQueryObserver } from '@tanstack/query-core'
2
2
  import { createMemo } from 'solid-js'
3
- import { useBaseQuery } from './useBaseQuery'
3
+ import { useBaseQueryLayer } from './useBaseQuery'
4
4
  import type {
5
5
  DefaultError,
6
+ FetchNextPageOptions,
7
+ FetchPreviousPageOptions,
6
8
  InfiniteData,
9
+ InfiniteQueryObserverResult,
7
10
  QueryKey,
8
11
  QueryObserver,
9
12
  } from '@tanstack/query-core'
@@ -19,6 +22,58 @@ import type {
19
22
  UndefinedInitialDataInfiniteOptions,
20
23
  } from './infiniteQueryOptions'
21
24
 
25
+ /**
26
+ * Page-boundary checks, mirrored from query-core's `infiniteQueryBehavior`
27
+ * (they are module-private there — not exported through the package index).
28
+ * Pure functions of options + cached data; keep byte-compatible with core.
29
+ */
30
+ interface PageParamsOptions {
31
+ getNextPageParam: (
32
+ lastPage: unknown,
33
+ pages: Array<unknown>,
34
+ lastPageParam: unknown,
35
+ pageParams: Array<unknown>,
36
+ ) => unknown
37
+ getPreviousPageParam?: (
38
+ firstPage: unknown,
39
+ pages: Array<unknown>,
40
+ firstPageParam: unknown,
41
+ pageParams: Array<unknown>,
42
+ ) => unknown
43
+ }
44
+
45
+ function hasNextPage(
46
+ options: PageParamsOptions,
47
+ data?: InfiniteData<unknown>,
48
+ ): boolean {
49
+ if (!data || data.pages.length === 0) return false
50
+ const lastIndex = data.pages.length - 1
51
+ return (
52
+ options.getNextPageParam(
53
+ data.pages[lastIndex],
54
+ data.pages,
55
+ data.pageParams[lastIndex],
56
+ data.pageParams,
57
+ ) != null
58
+ )
59
+ }
60
+
61
+ function hasPreviousPage(
62
+ options: PageParamsOptions,
63
+ data?: InfiniteData<unknown>,
64
+ ): boolean {
65
+ if (!data || data.pages.length === 0 || !options.getPreviousPageParam)
66
+ return false
67
+ return (
68
+ options.getPreviousPageParam(
69
+ data.pages[0],
70
+ data.pages,
71
+ data.pageParams[0],
72
+ data.pageParams,
73
+ ) != null
74
+ )
75
+ }
76
+
22
77
  export function useInfiniteQuery<
23
78
  TQueryFnData,
24
79
  TError = DefaultError,
@@ -68,9 +123,106 @@ export function useInfiniteQuery<
68
123
  >,
69
124
  queryClient?: Accessor<QueryClient>,
70
125
  ): UseInfiniteQueryResult<TData, TError> {
71
- return useBaseQuery(
72
- createMemo(() => options()),
126
+ /**
127
+ * Pagers ride the same entry as every other read: the base layer owns
128
+ * the data node, meta projection, and lifecycle observer (an
129
+ * `InfiniteQueryObserver`, so mount policy and refetches use the
130
+ * infinite behavior). This layer adds the page surface on top.
131
+ */
132
+ const layer = useBaseQueryLayer(
133
+ // `_type` is how query-core stamps a cache entry as infinite
134
+ // (`Query.setOptions` → `#queryType`), which makes every fetch attach
135
+ // `infiniteQueryBehavior`. Core only sets it inside
136
+ // `InfiniteQueryObserver.setOptions`, but the adapter's pull path can
137
+ // build and fetch the entry first (reactive key change recomputes the
138
+ // data memo before the deferred observer effect runs) — without the
139
+ // stamp that fetch would run as a plain query and corrupt the entry.
140
+ createMemo(() => ({ ...options(), _type: 'infinite' }) as any),
73
141
  InfiniteQueryObserver as typeof QueryObserver,
74
142
  queryClient,
75
- ) as UseInfiniteQueryResult<TData, TError>
143
+ )
144
+ const observer = layer.observer as unknown as InfiniteQueryObserver<
145
+ TQueryFnData,
146
+ TError,
147
+ TData,
148
+ TQueryKey,
149
+ TPageParam
150
+ >
151
+
152
+ /** Committed fetch direction — set when a page fetch dispatches,
153
+ * version-tracked through the layer's query accessor. */
154
+ const direction = () =>
155
+ (layer.query().state.fetchMeta as any)?.fetchMore?.direction
156
+
157
+ const pageOptions = () =>
158
+ layer.defaultedOptions() as unknown as PageParamsOptions
159
+ const infiniteData = () =>
160
+ layer.query().state.data as InfiniteData<unknown> | undefined
161
+
162
+ const isFetchingNextPage = () =>
163
+ layer.isFetching() && direction() === 'forward'
164
+ const isFetchingPreviousPage = () =>
165
+ layer.isFetching() && direction() === 'backward'
166
+ const isError = () => layer.status() === 'error'
167
+
168
+ // The base result's getters transfer as-is; the infinite surface layers
169
+ // over them, and isRefetching/isRefetchError narrow to exclude page
170
+ // fetches (matching core's InfiniteQueryObserver result shape).
171
+ const result = Object.defineProperties(
172
+ {},
173
+ {
174
+ ...Object.getOwnPropertyDescriptors(layer.result),
175
+ fetchNextPage: {
176
+ value: (fetchOptions?: FetchNextPageOptions) =>
177
+ observer.fetchNextPage(fetchOptions),
178
+ enumerable: true,
179
+ },
180
+ fetchPreviousPage: {
181
+ value: (fetchOptions?: FetchPreviousPageOptions) =>
182
+ observer.fetchPreviousPage(fetchOptions),
183
+ enumerable: true,
184
+ },
185
+ hasNextPage: {
186
+ get: () => hasNextPage(pageOptions(), infiniteData()),
187
+ enumerable: true,
188
+ },
189
+ hasPreviousPage: {
190
+ get: () => hasPreviousPage(pageOptions(), infiniteData()),
191
+ enumerable: true,
192
+ },
193
+ isFetchingNextPage: {
194
+ get: isFetchingNextPage,
195
+ enumerable: true,
196
+ },
197
+ isFetchingPreviousPage: {
198
+ get: isFetchingPreviousPage,
199
+ enumerable: true,
200
+ },
201
+ isFetchNextPageError: {
202
+ get: () => isError() && direction() === 'forward',
203
+ enumerable: true,
204
+ },
205
+ isFetchPreviousPageError: {
206
+ get: () => isError() && direction() === 'backward',
207
+ enumerable: true,
208
+ },
209
+ isRefetching: {
210
+ get: () =>
211
+ layer.isFetching() &&
212
+ layer.status() !== 'pending' &&
213
+ !isFetchingNextPage() &&
214
+ !isFetchingPreviousPage(),
215
+ enumerable: true,
216
+ },
217
+ isRefetchError: {
218
+ get: () => {
219
+ const errored = isError() && layer.query().state.dataUpdatedAt !== 0
220
+ return errored && direction() === undefined
221
+ },
222
+ enumerable: true,
223
+ },
224
+ },
225
+ ) as InfiniteQueryObserverResult<TData, TError>
226
+
227
+ return result as unknown as UseInfiniteQueryResult<TData, TError>
76
228
  }
@@ -1,4 +1,5 @@
1
- import { createMemo, createSignal, onCleanup, untrack } from 'solid-js'
1
+ import { createMemo } from 'solid-js'
2
+ import { createCacheAggregate } from './cacheAggregate'
2
3
  import { useQueryClient } from './QueryClientProvider'
3
4
  import type { QueryFilters } from '@tanstack/query-core'
4
5
  import type { QueryClient } from './QueryClient'
@@ -9,20 +10,9 @@ export function useIsFetching(
9
10
  queryClient?: Accessor<QueryClient>,
10
11
  ): Accessor<number> {
11
12
  const client = createMemo(() => useQueryClient(queryClient?.()))
12
- const queryCache = createMemo(() => client().getQueryCache())
13
-
14
- const [fetches, setFetches] = createSignal(
15
- untrack(() => client().isFetching(filters?.())),
16
- { ownedWrite: true },
17
- )
18
-
19
- const unsubscribe = untrack(() =>
20
- queryCache().subscribe(() => {
21
- setFetches(client().isFetching(filters?.()))
22
- }),
13
+ return createCacheAggregate(
14
+ (onEvent) => client().getQueryCache().subscribe(onEvent),
15
+ () => client().isFetching(filters?.()),
16
+ 0,
23
17
  )
24
-
25
- onCleanup(unsubscribe)
26
-
27
- return fetches
28
18
  }
@@ -1,4 +1,5 @@
1
- import { createMemo, createSignal, onCleanup, untrack } from 'solid-js'
1
+ import { createMemo } from 'solid-js'
2
+ import { createCacheAggregate } from './cacheAggregate'
2
3
  import { useQueryClient } from './QueryClientProvider'
3
4
  import type { MutationFilters } from '@tanstack/query-core'
4
5
  import type { QueryClient } from './QueryClient'
@@ -9,20 +10,9 @@ export function useIsMutating(
9
10
  queryClient?: Accessor<QueryClient>,
10
11
  ): Accessor<number> {
11
12
  const client = createMemo(() => useQueryClient(queryClient?.()))
12
- const mutationCache = createMemo(() => client().getMutationCache())
13
-
14
- const [mutations, setMutations] = createSignal(
15
- untrack(() => client().isMutating(filters?.())),
16
- { ownedWrite: true },
17
- )
18
-
19
- const unsubscribe = untrack(() =>
20
- mutationCache().subscribe((_result) => {
21
- setMutations(client().isMutating(filters?.()))
22
- }),
13
+ return createCacheAggregate(
14
+ (onEvent) => client().getMutationCache().subscribe(onEvent),
15
+ () => client().isMutating(filters?.()),
16
+ 0,
23
17
  )
24
-
25
- onCleanup(unsubscribe)
26
-
27
- return mutations
28
18
  }
@@ -1,14 +1,19 @@
1
- import { MutationObserver, noop, shouldThrowError } from '@tanstack/query-core'
1
+ import { noop, shouldThrowError } from '@tanstack/query-core'
2
2
  import {
3
+ action,
3
4
  createMemo,
5
+ createOptimistic,
4
6
  createRenderEffect,
5
- createStore,
7
+ createSignal,
6
8
  onCleanup,
7
- runWithOwner,
8
9
  untrack,
9
10
  } from 'solid-js'
10
11
  import { useQueryClient } from './QueryClientProvider'
11
- import type { DefaultError } from '@tanstack/query-core'
12
+ import type {
13
+ DefaultError,
14
+ Mutation,
15
+ MutationFunctionContext,
16
+ } from '@tanstack/query-core'
12
17
  import type { QueryClient } from './QueryClient'
13
18
  import type {
14
19
  UseMutateFunction,
@@ -17,7 +22,74 @@ import type {
17
22
  } from './types'
18
23
  import type { Accessor } from 'solid-js'
19
24
 
20
- // HOOK
25
+ const isServer = typeof window === 'undefined'
26
+
27
+ /**
28
+ * Durable mutation state: written post-`yield` inside the action's
29
+ * transaction, so it commits atomically with everything else the settle
30
+ * carries (invalidation-triggered refetches included).
31
+ */
32
+ interface SettledState<TData, TError, TVariables> {
33
+ status: 'idle' | 'success' | 'error'
34
+ data: TData | undefined
35
+ error: TError | null
36
+ variables: TVariables | undefined
37
+ submittedAt: number
38
+ }
39
+
40
+ const IDLE: SettledState<any, any, any> = {
41
+ status: 'idle',
42
+ data: undefined,
43
+ error: null,
44
+ variables: undefined,
45
+ submittedAt: 0,
46
+ }
47
+
48
+ /**
49
+ * Drive the mutation generator without transaction semantics — the server
50
+ * has no interactive paint to keep atomic. Yielded thenable values are
51
+ * awaited and their results passed back in, matching what `action` does
52
+ * minus the transition bookkeeping.
53
+ */
54
+ async function drain<TReturn>(
55
+ iterator: AsyncGenerator<unknown, TReturn, unknown>,
56
+ ): Promise<TReturn> {
57
+ let input: unknown
58
+ for (;;) {
59
+ const result = await iterator.next(input)
60
+ if (result.done) return result.value
61
+ input = result.value != null ? await result.value : undefined
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Mutations ride core `action`: each `mutate()` call is one transaction.
67
+ *
68
+ * - Transient flight state (`isPending`, in-flight `variables`) is a
69
+ * `createOptimistic` overlay written at the top of the action — visible
70
+ * immediately, dropped automatically when the transition settles. The
71
+ * engine's revert-on-settle IS the submission state machine.
72
+ * - `options.onMutate(variables)` runs inside the same window: it is the
73
+ * place to apply the caller's own optimistic overlays
74
+ * (`createOptimistic` / `createOptimisticStore` writes). No context
75
+ * object, no rollback plumbing — reverting is the engine's job.
76
+ * - The fetch itself goes through query-core:
77
+ * `mutationCache.build().execute()` supplies retry/backoff, offline
78
+ * pausing, scoped serial execution, and cache-level lifecycle callbacks.
79
+ * No `MutationObserver` — the adapter reads the Mutation's state
80
+ * directly where flight metadata (failureCount, isPaused) is needed.
81
+ * - Options-level callbacks are stripped from the built mutation and
82
+ * re-run *post-`yield`*, inside the transaction: an
83
+ * `onSuccess → invalidateQueries` chain issues version bumps whose
84
+ * refetches hold this very transition, so mutation success and fresh
85
+ * query data land in one atomic paint (and a single-flight payload has
86
+ * already primed the cache by the time `execute` resolves).
87
+ * - One `mutate`, returning a safe-to-ignore promise: rejection is also
88
+ * routed into reactive state, and a no-op catch keeps the ignored
89
+ * branch from surfacing as an unhandled rejection. The
90
+ * `mutate`/`mutateAsync` split existed for React's callback-vs-promise
91
+ * error routing; it has no space here.
92
+ */
21
93
  export function useMutation<
22
94
  TData = unknown,
23
95
  TError = DefaultError,
@@ -29,74 +101,249 @@ export function useMutation<
29
101
  ): UseMutationResult<TData, TError, TVariables, TOnMutateResult> {
30
102
  const client = createMemo(() => useQueryClient(queryClient?.()))
31
103
 
32
- const observer = untrack(
33
- () =>
34
- new MutationObserver<TData, TError, TVariables, TOnMutateResult>(
35
- client(),
36
- options(),
37
- ),
38
- )
104
+ /** Optimistic overlay: non-null exactly while a mutation is in flight. */
105
+ const [flight, setFlight] = createOptimistic<{
106
+ variables: TVariables
107
+ } | null>(null)
39
108
 
40
- // Track options changes and update observer
41
- createRenderEffect(
42
- () => options(),
43
- (opts) => {
44
- observer.setOptions(opts)
45
- },
46
- )
109
+ const [settled, setSettled] = createSignal<
110
+ SettledState<TData, TError, TVariables>
111
+ >(IDLE as SettledState<TData, TError, TVariables>)
112
+
113
+ /**
114
+ * Flight metadata (retry failures, offline pause) lives on the Mutation
115
+ * instance and changes during the await gap; a version bump per cache
116
+ * event for the active mutation keeps reads current without cloning
117
+ * observer results.
118
+ */
119
+ let activeMutation: Mutation<TData, TError, TVariables> | null = null
120
+ const [flightVersion, setFlightVersion] = createSignal(0)
121
+ if (!isServer) {
122
+ const unsubscribe = client()
123
+ .getMutationCache()
124
+ .subscribe((event) => {
125
+ if ('mutation' in event && event.mutation === activeMutation) {
126
+ setFlightVersion((v) => v + 1)
127
+ }
128
+ })
129
+ onCleanup(unsubscribe)
130
+ }
131
+
132
+ async function* run(
133
+ variables: TVariables,
134
+ ): AsyncGenerator<unknown, TData, unknown> {
135
+ const opts = untrack(options)
136
+ const callbackContext: MutationFunctionContext = {
137
+ client: untrack(client),
138
+ meta: opts.meta,
139
+ mutationKey: opts.mutationKey,
140
+ }
141
+ // Server render is pure (rc.3 deprecates server setter calls): the
142
+ // drain path only needs the return value/throw, so all reactive
143
+ // bookkeeping — flight overlay and durable settle — is client-only.
144
+ if (!isServer) setFlight({ variables })
145
+ opts.onMutate?.(variables, callbackContext)
146
+
147
+ const mutation = client()
148
+ .getMutationCache()
149
+ .build(client(), {
150
+ ...opts,
151
+ // Options-level callbacks re-run below, inside the transaction —
152
+ // executing them here (inside the await gap) would let their
153
+ // cache writes and invalidations escape the atomic settle.
154
+ onMutate: undefined,
155
+ onSuccess: undefined,
156
+ onError: undefined,
157
+ onSettled: undefined,
158
+ }) as unknown as Mutation<TData, TError, TVariables>
159
+ activeMutation = mutation
160
+
161
+ /**
162
+ * Failure settle, shared by both paths. Callback routing mirrors
163
+ * query-core's `Mutation.execute` (which never sees these callbacks —
164
+ * they are stripped from the built mutation and re-run here, inside
165
+ * the transaction): `onError` and an error-shaped `onSettled` run,
166
+ * their own failures are reported as unhandled rejections but never
167
+ * displace `error`, then the durable error state commits and the
168
+ * mutate promise rejects with `error`.
169
+ */
170
+ // Must be an async generator: async `yield` awaits its value in place,
171
+ // so a rejecting callback promise throws at the yield inside the local
172
+ // try/catch. A sync generator delegated from an async one gets its
173
+ // values unwrapped by the async-from-sync adapter *outside* the inner
174
+ // frame — the rejection would bypass these catches and kill the run.
175
+ // eslint-disable-next-line @typescript-eslint/require-await -- async for yield-await semantics, not await expressions
176
+ async function* settleFailure(
177
+ error: TError,
178
+ ): AsyncGenerator<unknown, never, unknown> {
179
+ try {
180
+ if (opts.onError)
181
+ yield opts.onError(error, variables, undefined, callbackContext)
182
+ } catch (callbackError) {
183
+ void Promise.reject(callbackError)
184
+ }
185
+ try {
186
+ if (opts.onSettled)
187
+ yield opts.onSettled(
188
+ undefined,
189
+ error,
190
+ variables,
191
+ undefined,
192
+ callbackContext,
193
+ )
194
+ } catch (callbackError) {
195
+ void Promise.reject(callbackError)
196
+ }
197
+ if (!isServer)
198
+ setSettled({
199
+ status: 'error',
200
+ data: undefined,
201
+ error,
202
+ variables,
203
+ submittedAt: mutation.state.submittedAt,
204
+ })
205
+ activeMutation = null
206
+ throw error
207
+ }
208
+
209
+ let result: TData
210
+ try {
211
+ result = await mutation.execute(variables)
212
+ } catch (error) {
213
+ yield // re-enter the transaction before writing
214
+ yield* settleFailure(error as TError)
215
+ // Unreachable — settleFailure always throws. Explicit for TS's
216
+ // definite-assignment analysis of `result` (yield* delegation is
217
+ // not narrowed as never-returning).
218
+ throw error
219
+ }
220
+ yield // re-enter the transaction before writing
221
+ try {
222
+ if (opts.onSuccess)
223
+ yield opts.onSuccess(
224
+ result,
225
+ variables,
226
+ undefined as TOnMutateResult,
227
+ callbackContext,
228
+ )
229
+ if (opts.onSettled)
230
+ yield opts.onSettled(
231
+ result,
232
+ null,
233
+ variables,
234
+ undefined,
235
+ callbackContext,
236
+ )
237
+ } catch (callbackError) {
238
+ // Mirror core: a success-path callback failure fails the mutation —
239
+ // even though the cache entry committed success, the hook settles
240
+ // to error with the callback error, and awaiting mutate rejects.
241
+ yield* settleFailure(callbackError as TError)
242
+ // Unreachable — settleFailure always throws (see above).
243
+ throw callbackError
244
+ }
245
+ if (!isServer)
246
+ setSettled({
247
+ status: 'success',
248
+ data: result,
249
+ error: null,
250
+ variables,
251
+ submittedAt: mutation.state.submittedAt,
252
+ })
253
+ activeMutation = null
254
+ return result
255
+ }
256
+
257
+ const invoke: (variables: TVariables) => Promise<TData> = isServer
258
+ ? (variables) => drain(run(variables))
259
+ : action(run)
47
260
 
48
261
  const mutate: UseMutateFunction<
49
262
  TData,
50
263
  TError,
51
264
  TVariables,
52
265
  TOnMutateResult
53
- > = (variables, mutateOptions) => {
54
- observer.mutate(variables, mutateOptions).catch(noop)
266
+ > = (variables) => {
267
+ const promise = invoke(variables)
268
+ // Errors also land in reactive state; ignoring the promise must not
269
+ // surface an unhandled rejection. Awaiting it still rejects.
270
+ promise.catch(noop)
271
+ return promise
55
272
  }
56
273
 
57
- const initialResult = untrack(() => observer.getCurrentResult())
58
- const [state, setState] = createStore<
59
- UseMutationResult<TData, TError, TVariables, TOnMutateResult>
60
- >({
61
- ...initialResult,
62
- mutate,
63
- mutateAsync: initialResult.mutate,
64
- })
65
-
66
- const unsubscribe = observer.subscribe((result) => {
67
- // observer.mutate may be invoked synchronously from an owned scope (e.g.
68
- // during render or inside an effect), which triggers this subscriber
69
- // synchronously. Store writes inside an owned scope throw in Solid v2, so
70
- // escape the owner to allow the setState write — matching useBaseQuery.
71
- runWithOwner(null, () => {
72
- setState(() => ({
73
- ...result,
74
- mutate,
75
- mutateAsync: result.mutate,
76
- }))
77
- })
78
- })
79
-
80
- onCleanup(unsubscribe)
81
-
82
- // Use createRenderEffect to throw errors when throwOnError is set.
83
- // The throw must happen in the compute function (first arg), not the effect
84
- // function, so that the error goes through notifyStatus and gets wrapped as
85
- // a StatusError with a source — which is required for <Errored> boundaries
86
- // to capture it via CollectionQueue.notify.
274
+ const status = () => (flight() !== null ? 'pending' : settled().status)
275
+ const isPending = () => flight() !== null
276
+
277
+ /**
278
+ * Errors surface to `<Errored>` when `throwOnError` opts in. The throw
279
+ * must happen in the compute half so it routes through notifyStatus and
280
+ * boundary accounting.
281
+ */
87
282
  createRenderEffect(
88
283
  () => {
89
- const isError = state.isError
90
- const error = state.error
284
+ const state = settled()
91
285
  if (
92
- isError &&
93
- shouldThrowError(observer.options.throwOnError, [error as TError])
286
+ state.status === 'error' &&
287
+ shouldThrowError(untrack(options).throwOnError, [state.error as TError])
94
288
  ) {
95
- throw error
289
+ throw state.error
96
290
  }
97
291
  },
98
292
  () => {},
99
293
  )
100
294
 
101
- return state
295
+ const result = {
296
+ mutate,
297
+ get data() {
298
+ return settled().data
299
+ },
300
+ get error() {
301
+ return flight() !== null ? null : settled().error
302
+ },
303
+ get variables() {
304
+ return flight()?.variables ?? settled().variables
305
+ },
306
+ get status() {
307
+ return status()
308
+ },
309
+ get isPending() {
310
+ return isPending()
311
+ },
312
+ get isIdle() {
313
+ return status() === 'idle'
314
+ },
315
+ get isSuccess() {
316
+ return status() === 'success'
317
+ },
318
+ get isError() {
319
+ return status() === 'error'
320
+ },
321
+ get submittedAt() {
322
+ return flight() !== null
323
+ ? (flightVersion(), activeMutation?.state.submittedAt ?? 0)
324
+ : settled().submittedAt
325
+ },
326
+ get failureCount() {
327
+ flightVersion()
328
+ return activeMutation?.state.failureCount ?? 0
329
+ },
330
+ get failureReason() {
331
+ flightVersion()
332
+ return (activeMutation?.state.failureReason ?? null) as TError | null
333
+ },
334
+ get isPaused() {
335
+ flightVersion()
336
+ return activeMutation?.state.isPaused ?? false
337
+ },
338
+ reset: () => {
339
+ setSettled(IDLE as SettledState<TData, TError, TVariables>)
340
+ },
341
+ }
342
+
343
+ return result as unknown as UseMutationResult<
344
+ TData,
345
+ TError,
346
+ TVariables,
347
+ TOnMutateResult
348
+ >
102
349
  }
@@ -1,5 +1,6 @@
1
- import { createMemo, createSignal, onCleanup, untrack } from 'solid-js'
1
+ import { createMemo } from 'solid-js'
2
2
  import { replaceEqualDeep } from '@tanstack/query-core'
3
+ import { createCacheAggregate } from './cacheAggregate'
3
4
  import { useQueryClient } from './QueryClientProvider'
4
5
  import type {
5
6
  Mutation,
@@ -32,26 +33,12 @@ export function useMutationState<TResult = MutationState>(
32
33
  queryClient?: Accessor<QueryClient>,
33
34
  ): Accessor<Array<TResult>> {
34
35
  const client = createMemo(() => useQueryClient(queryClient?.()))
35
- const mutationCache = createMemo(() => client().getMutationCache())
36
-
37
- const [result, setResult] = createSignal(
38
- untrack(() => getResult(mutationCache(), options())),
39
- { ownedWrite: true },
40
- )
41
-
42
- const unsubscribe = untrack(() =>
43
- mutationCache().subscribe(() => {
44
- setResult((prev) => {
45
- const nextResult = replaceEqualDeep(
46
- prev,
47
- getResult(mutationCache(), options()),
48
- )
49
- return prev === nextResult ? prev : nextResult
50
- })
51
- }),
36
+ return createCacheAggregate<Array<TResult>>(
37
+ (onEvent) => client().getMutationCache().subscribe(onEvent),
38
+ // Structural sharing against the previous committed value keeps
39
+ // unchanged mutation states referentially stable across syncs.
40
+ (prev) =>
41
+ replaceEqualDeep(prev, getResult(client().getMutationCache(), options())),
42
+ [],
52
43
  )
53
-
54
- onCleanup(unsubscribe)
55
-
56
- return result
57
44
  }