@tanstack/solid-query 5.91.1 → 6.0.0-alpha.0

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,21 +1,21 @@
1
1
  // Had to disable the lint rule because isServer type is defined as false
2
2
  // in solid-js/web package. I'll create a GitHub issue with them to see
3
3
  // why that happens.
4
- import { hydrate, notifyManager, shouldThrowError } from '@tanstack/query-core'
5
- import { isServer } from 'solid-js/web'
4
+ import { notifyManager, shouldThrowError } from '@tanstack/query-core'
6
5
  import {
7
- createComputed,
8
6
  createMemo,
9
- createResource,
10
- createSignal,
11
- on,
7
+ createRenderEffect,
8
+ createStore,
9
+ isPending,
12
10
  onCleanup,
11
+ reconcile,
12
+ refresh,
13
+ snapshot,
13
14
  } from 'solid-js'
14
- import { createStore, reconcile, unwrap } from 'solid-js/store'
15
15
  import { useQueryClient } from './QueryClientProvider'
16
16
  import { useIsRestoring } from './isRestoring'
17
17
  import type { UseBaseQueryOptions } from './types'
18
- import type { Accessor, Signal } from 'solid-js'
18
+ import type { Accessor } from 'solid-js'
19
19
  import type { QueryClient } from './QueryClient'
20
20
  import type {
21
21
  Query,
@@ -24,6 +24,29 @@ import type {
24
24
  QueryObserverResult,
25
25
  } from '@tanstack/query-core'
26
26
 
27
+ const isServer = typeof window === 'undefined'
28
+
29
+ /**
30
+ * During SSR, Solid's store is serialized by seroval which cannot handle
31
+ * functions. Strip `refetch`, `fetchNextPage`, and `fetchPreviousPage`
32
+ * from the observer result before it enters the store so serialization
33
+ * succeeds. On the client this is a no-op (returns the object as-is).
34
+ */
35
+ function _stripFnsForSSR<TData, TError>(
36
+ obj: QueryObserverResult<TData, TError>,
37
+ ): QueryObserverResult<TData, TError> {
38
+ if (!isServer) return obj
39
+ const out: Record<string, unknown> = {}
40
+ for (const k of Object.keys(obj)) {
41
+ if (k === 'refetch' || k === 'fetchNextPage' || k === 'fetchPreviousPage') {
42
+ out[k] = undefined
43
+ } else {
44
+ out[k] = (obj as any)[k]
45
+ }
46
+ }
47
+ return out as unknown as QueryObserverResult<TData, TError>
48
+ }
49
+
27
50
  function reconcileFn<TData, TError>(
28
51
  store: QueryObserverResult<TData, TError>,
29
52
  result: QueryObserverResult<TData, TError>,
@@ -33,11 +56,15 @@ function reconcileFn<TData, TError>(
33
56
  | ((oldData: TData | undefined, newData: TData) => TData),
34
57
  queryHash?: string,
35
58
  ): QueryObserverResult<TData, TError> {
36
- if (reconcileOption === false) return result
37
59
  if (typeof reconcileOption === 'function') {
38
60
  const newData = reconcileOption(store.data, result.data as TData)
39
61
  return { ...result, data: newData } as typeof result
40
62
  }
63
+
64
+ if (reconcileOption === false) return result
65
+
66
+ const key = reconcileOption
67
+
41
68
  let data = result.data
42
69
  if (store.data === undefined) {
43
70
  try {
@@ -55,8 +82,16 @@ function reconcileFn<TData, TError>(
55
82
  }
56
83
  }
57
84
  }
58
- const newData = reconcile(data, { key: reconcileOption })(store.data)
59
- return { ...result, data: newData } as typeof result
85
+ // reconcile() in Solid 2.0 mutates in place and returns void.
86
+ // We apply it to store.data so the store's nested signals update.
87
+ // On first load (store.data is undefined), there's nothing to reconcile against,
88
+ // so we just return the data as-is.
89
+ if (store.data !== undefined && data !== undefined) {
90
+ reconcile(data, key)(store.data)
91
+ // Return result with the existing store.data reference (now reconciled in place)
92
+ return { ...result, data: store.data } as typeof result
93
+ }
94
+ return { ...result, data } as typeof result
60
95
  }
61
96
 
62
97
  /**
@@ -75,7 +110,7 @@ const hydratableObserverResult = <
75
110
  ) => {
76
111
  if (!isServer) return result
77
112
  const obj: any = {
78
- ...unwrap(result),
113
+ ...snapshot(result),
79
114
  // During SSR, functions cannot be serialized, so we need to remove them
80
115
  // This is safe because we will add these functions back when the query is hydrated
81
116
  refetch: undefined,
@@ -138,15 +173,26 @@ export function useBaseQuery<
138
173
  }
139
174
  return defaultOptions
140
175
  })
141
- const initialOptions = defaultedOptions()
142
176
 
143
- const [observer, setObserver] = createSignal(
144
- new Observer(client(), defaultedOptions()),
177
+ const observer = new Observer(client(), defaultedOptions())
178
+
179
+ // Track options reactively so the queryResource memo re-runs on change.
180
+ const trackedDefaultedOptions = createMemo(() => defaultedOptions())
181
+
182
+ // Apply options in an effect to avoid store writes inside the memo.
183
+ // setOptions triggers updateResult → notify → subscription → setState,
184
+ // which must run in an effect context in Solid v2.
185
+ createRenderEffect(
186
+ () => trackedDefaultedOptions(),
187
+ (opts) => {
188
+ observer.setOptions(opts)
189
+ },
145
190
  )
146
191
 
147
- let observerResult = observer().getOptimisticResult(defaultedOptions())
148
- const [state, setState] =
149
- createStore<QueryObserverResult<TData, TError>>(observerResult)
192
+ let observerResult = observer.getOptimisticResult(defaultedOptions())
193
+ const [state, setState] = createStore<QueryObserverResult<TData, TError>>(
194
+ _stripFnsForSSR(observerResult),
195
+ )
150
196
 
151
197
  const createServerSubscriber = (
152
198
  resolve: (
@@ -154,9 +200,9 @@ export function useBaseQuery<
154
200
  ) => void,
155
201
  reject: (reason?: any) => void,
156
202
  ) => {
157
- return observer().subscribe((result) => {
203
+ return observer.subscribe((result) => {
158
204
  notifyManager.batchCalls(() => {
159
- const query = observer().getCurrentQuery()
205
+ const query = observer.getCurrentQuery()
160
206
  const unwrappedResult = hydratableObserverResult(query, result)
161
207
 
162
208
  if (result.data !== undefined && unwrappedResult.isError) {
@@ -178,55 +224,49 @@ export function useBaseQuery<
178
224
  }
179
225
 
180
226
  const createClientSubscriber = () => {
181
- const obs = observer()
182
- return obs.subscribe((result) => {
227
+ return observer.subscribe((result) => {
228
+ const previousResult = observerResult
183
229
  observerResult = result
230
+ setStateWithReconciliation(result)
184
231
  queueMicrotask(() => {
185
- if (unsubscribe) {
186
- refetch()
232
+ if (
233
+ unsubscribe &&
234
+ !disposed &&
235
+ (previousResult.isLoading !== result.isLoading ||
236
+ previousResult.isError !== result.isError)
237
+ ) {
238
+ try {
239
+ refresh(queryResource)
240
+ } catch {
241
+ // NotReadyError is expected when refreshing a memo that returns
242
+ // a Promise. The Loading boundary handles this during rendering,
243
+ // but when refresh is called from a microtask there is no boundary.
244
+ }
187
245
  }
188
246
  })
189
247
  })
190
248
  }
191
249
 
192
250
  function setStateWithReconciliation(res: typeof observerResult) {
193
- const opts = observer().options
194
- // @ts-expect-error - Reconcile option is not correctly typed internally
195
- const reconcileOptions = opts.reconcile
251
+ const opts = observer.options
252
+ const reconcileOptions = (opts as any).reconcile
253
+ const sanitized = _stripFnsForSSR(res)
196
254
 
197
255
  setState((store) => {
198
256
  return reconcileFn(
199
257
  store,
200
- res,
258
+ sanitized,
201
259
  reconcileOptions === undefined ? false : reconcileOptions,
202
260
  opts.queryHash,
203
261
  )
204
262
  })
205
263
  }
206
264
 
207
- function createDeepSignal<T>(): Signal<T> {
208
- return [
209
- () => state,
210
- (v: any) => {
211
- const unwrapped = unwrap(state)
212
- if (typeof v === 'function') {
213
- v = v(unwrapped)
214
- }
215
- // Hydration data exists on first load after SSR,
216
- // and should be removed from the observer result
217
- if (v?.hydrationData) {
218
- const { hydrationData, ...rest } = v
219
- v = rest
220
- }
221
- setStateWithReconciliation(v)
222
- },
223
- ] as Signal<T>
224
- }
225
-
226
265
  /**
227
266
  * Unsubscribe is set lazily, so that we can subscribe after hydration when needed.
228
267
  */
229
268
  let unsubscribe: (() => void) | null = null
269
+ let disposed = false
230
270
 
231
271
  /*
232
272
  Fixes #7275
@@ -236,115 +276,58 @@ export function useBaseQuery<
236
276
  but the resource is still in a loading state
237
277
  */
238
278
  let resolver: ((value: ResourceData) => void) | null = null
239
- const [queryResource, { refetch }] = createResource<ResourceData | undefined>(
279
+ const queryResource = createMemo<ResourceData>(
240
280
  () => {
241
- const obs = observer()
281
+ // Read trackedDefaultedOptions to ensure this memo re-runs when options change
282
+ const opts = trackedDefaultedOptions()
283
+ // Read isRestoring unconditionally so the memo re-runs when it changes
284
+ const restoring = isRestoring()
285
+
242
286
  return new Promise((resolve, reject) => {
243
287
  resolver = resolve
244
288
  if (isServer) {
245
- unsubscribe = createServerSubscriber(resolve, reject)
246
- } else if (!unsubscribe && !isRestoring()) {
289
+ unsubscribe = createServerSubscriber((data) => {
290
+ resolve(data as ResourceData)
291
+ }, reject)
292
+ } else if (!unsubscribe && !restoring) {
247
293
  unsubscribe = createClientSubscriber()
248
294
  }
249
- obs.updateResult()
295
+ // Use getOptimisticResult instead of updateResult to keep the memo
296
+ // free of store writes (updateResult triggers notify → setState).
297
+ const currentResult = observer.getOptimisticResult(opts)
298
+ observerResult = currentResult
250
299
 
251
300
  if (
252
- observerResult.isError &&
253
- !observerResult.isFetching &&
254
- !isRestoring() &&
255
- shouldThrowError(obs.options.throwOnError, [
256
- observerResult.error,
257
- obs.getCurrentQuery(),
301
+ currentResult.isError &&
302
+ !currentResult.isFetching &&
303
+ !restoring &&
304
+ shouldThrowError(opts.throwOnError, [
305
+ currentResult.error,
306
+ observer.getCurrentQuery(),
258
307
  ])
259
308
  ) {
260
- setStateWithReconciliation(observerResult)
261
- return reject(observerResult.error)
309
+ setStateWithReconciliation(currentResult)
310
+ return reject(currentResult.error)
262
311
  }
263
- if (!observerResult.isLoading) {
312
+ if (!currentResult.isLoading) {
264
313
  resolver = null
314
+ setStateWithReconciliation(currentResult)
265
315
  return resolve(
266
- hydratableObserverResult(obs.getCurrentQuery(), observerResult),
316
+ hydratableObserverResult(observer.getCurrentQuery(), currentResult),
267
317
  )
268
318
  }
269
319
 
270
- setStateWithReconciliation(observerResult)
320
+ // Defer the loading-state store write so it runs outside the memo.
321
+ queueMicrotask(() => setStateWithReconciliation(currentResult))
271
322
  })
272
323
  },
273
- {
274
- storage: createDeepSignal,
275
-
276
- get deferStream() {
277
- return options().deferStream
278
- },
279
-
280
- /**
281
- * If this resource was populated on the server (either sync render, or streamed in over time), onHydrated
282
- * will be called. This is the point at which we can hydrate the query cache state, and setup the query subscriber.
283
- *
284
- * Leveraging onHydrated allows us to plug into the async and streaming support that solidjs resources already support.
285
- *
286
- * Note that this is only invoked on the client, for queries that were originally run on the server.
287
- */
288
- onHydrated(_k, info) {
289
- if (info.value && 'hydrationData' in info.value) {
290
- hydrate(client(), {
291
- // @ts-expect-error - hydrationData is not correctly typed internally
292
- queries: [{ ...info.value.hydrationData }],
293
- })
294
- }
295
-
296
- if (unsubscribe) return
297
- /**
298
- * Do not refetch query on mount if query was fetched on server,
299
- * even if `staleTime` is not set.
300
- */
301
- const newOptions = { ...initialOptions }
302
- if (
303
- (initialOptions.staleTime || !initialOptions.initialData) &&
304
- info.value
305
- ) {
306
- newOptions.refetchOnMount = false
307
- }
308
- // Setting the options as an immutable object to prevent
309
- // wonky behavior with observer subscriptions
310
- observer().setOptions(newOptions)
311
- setStateWithReconciliation(observer().getOptimisticResult(newOptions))
312
- unsubscribe = createClientSubscriber()
313
- },
314
- },
315
- )
316
-
317
- createComputed(
318
- on(
319
- client,
320
- (c) => {
321
- if (unsubscribe) {
322
- unsubscribe()
323
- }
324
- const newObserver = new Observer(c, defaultedOptions())
325
- unsubscribe = createClientSubscriber()
326
- setObserver(newObserver)
327
- },
328
- {
329
- defer: true,
330
- },
331
- ),
332
- )
333
-
334
- createComputed(
335
- on(
336
- isRestoring,
337
- (restoring) => {
338
- if (!restoring && !isServer) {
339
- refetch()
340
- }
341
- },
342
- { defer: true },
343
- ),
324
+ observerResult,
325
+ { ssrSource: 'client' },
344
326
  )
345
327
 
346
328
  onCleanup(() => {
347
- if (isServer && queryResource.loading) {
329
+ disposed = true
330
+ if (isServer && isPending(queryResource)) {
348
331
  unsubscribeQueued = true
349
332
  return
350
333
  }
@@ -358,32 +341,43 @@ export function useBaseQuery<
358
341
  }
359
342
  })
360
343
 
361
- createComputed(
362
- on(
363
- [observer, defaultedOptions],
364
- ([obs, opts]) => {
365
- obs.setOptions(opts)
366
- setStateWithReconciliation(obs.getOptimisticResult(opts))
367
- refetch()
368
- },
369
- { defer: true },
370
- ),
371
- )
344
+ // Properties that should never throw — these let users access error info
345
+ // even outside an ErrorBoundary.
346
+ const errorPassthroughProps = new Set([
347
+ 'error',
348
+ 'isError',
349
+ 'failureCount',
350
+ 'failureReason',
351
+ 'errorUpdateCount',
352
+ 'errorUpdatedAt',
353
+ ])
372
354
 
373
- const handler = {
374
- get(
375
- target: QueryObserverResult<TData, TError>,
376
- prop: keyof QueryObserverResult<TData, TError>,
377
- ): any {
378
- if (prop === 'data') {
379
- if (state.data !== undefined) {
380
- return queryResource.latest?.data
381
- }
382
- return queryResource()?.data
355
+ // Return a proxy that throws on property access when throwOnError is enabled
356
+ return new Proxy(state, {
357
+ get(target, prop, receiver) {
358
+ // Always pass through symbols (needed for store internals, iteration, etc.)
359
+ if (typeof prop === 'symbol') {
360
+ return Reflect.get(target, prop, receiver)
383
361
  }
384
- return Reflect.get(target, prop)
385
- },
386
- }
387
362
 
388
- return new Proxy(state, handler)
363
+ // Always pass through error-related props without throwing
364
+ if (errorPassthroughProps.has(prop)) {
365
+ return Reflect.get(target, prop, receiver)
366
+ }
367
+
368
+ // Check throwOnError condition before returning the value
369
+ if (
370
+ state.isError &&
371
+ !state.isFetching &&
372
+ shouldThrowError(observer.options.throwOnError, [
373
+ state.error,
374
+ observer.getCurrentQuery(),
375
+ ])
376
+ ) {
377
+ throw state.error
378
+ }
379
+
380
+ return Reflect.get(target, prop, receiver)
381
+ },
382
+ })
389
383
  }
@@ -1,6 +1,10 @@
1
1
  import { MutationObserver, noop, shouldThrowError } from '@tanstack/query-core'
2
- import { createComputed, createMemo, on, onCleanup } from 'solid-js'
3
- import { createStore } from 'solid-js/store'
2
+ import {
3
+ createMemo,
4
+ createRenderEffect,
5
+ createStore,
6
+ onCleanup,
7
+ } from 'solid-js'
4
8
  import { useQueryClient } from './QueryClientProvider'
5
9
  import type { DefaultError } from '@tanstack/query-core'
6
10
  import type { QueryClient } from './QueryClient'
@@ -30,6 +34,11 @@ export function useMutation<
30
34
  TOnMutateResult
31
35
  >(client(), options())
32
36
 
37
+ // Track options changes and update observer
38
+ createMemo(() => {
39
+ observer.setOptions(options())
40
+ })
41
+
33
42
  const mutate: UseMutateFunction<
34
43
  TData,
35
44
  TError,
@@ -47,33 +56,34 @@ export function useMutation<
47
56
  mutateAsync: observer.getCurrentResult().mutate,
48
57
  })
49
58
 
50
- createComputed(() => {
51
- observer.setOptions(options())
52
- })
53
-
54
- createComputed(
55
- on(
56
- () => state.status,
57
- () => {
58
- if (
59
- state.isError &&
60
- shouldThrowError(observer.options.throwOnError, [state.error])
61
- ) {
62
- throw state.error
63
- }
64
- },
65
- ),
66
- )
67
-
68
59
  const unsubscribe = observer.subscribe((result) => {
69
- setState({
60
+ setState(() => ({
70
61
  ...result,
71
62
  mutate,
72
63
  mutateAsync: result.mutate,
73
- })
64
+ }))
74
65
  })
75
66
 
76
67
  onCleanup(unsubscribe)
77
68
 
69
+ // Use createRenderEffect to throw errors when throwOnError is set.
70
+ // The throw must happen in the compute function (first arg), not the effect
71
+ // function, so that the error goes through notifyStatus and gets wrapped as
72
+ // a StatusError with a source — which is required for <Errored> boundaries
73
+ // to capture it via CollectionQueue.notify.
74
+ createRenderEffect(
75
+ () => {
76
+ const isError = state.isError
77
+ const error = state.error
78
+ if (
79
+ isError &&
80
+ shouldThrowError(observer.options.throwOnError, [error as TError])
81
+ ) {
82
+ throw error
83
+ }
84
+ },
85
+ () => {},
86
+ )
87
+
78
88
  return state
79
89
  }
@@ -1,4 +1,4 @@
1
- import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js'
1
+ import { createMemo, createSignal, onCleanup } from 'solid-js'
2
2
  import { replaceEqualDeep } from '@tanstack/query-core'
3
3
  import { useQueryClient } from './QueryClientProvider'
4
4
  import type {
@@ -38,19 +38,17 @@ export function useMutationState<TResult = MutationState>(
38
38
  getResult(mutationCache(), options()),
39
39
  )
40
40
 
41
- createEffect(() => {
42
- const unsubscribe = mutationCache().subscribe(() => {
41
+ const unsubscribe = mutationCache().subscribe(() => {
42
+ setResult((prev) => {
43
43
  const nextResult = replaceEqualDeep(
44
- result(),
44
+ prev,
45
45
  getResult(mutationCache(), options()),
46
46
  )
47
- if (result() !== nextResult) {
48
- setResult(nextResult)
49
- }
47
+ return prev === nextResult ? prev : nextResult
50
48
  })
51
-
52
- onCleanup(unsubscribe)
53
49
  })
54
50
 
51
+ onCleanup(unsubscribe)
52
+
55
53
  return result
56
54
  }