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

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,138 +1,85 @@
1
- // Had to disable the lint rule because isServer type is defined as false
2
- // in solid-js/web package. I'll create a GitHub issue with them to see
3
- // why that happens.
4
- import { notifyManager, shouldThrowError } from '@tanstack/query-core'
1
+ import { hydrate, noop, shouldThrowError } from '@tanstack/query-core'
5
2
  import {
3
+ createMemo,
4
+ createProjection,
6
5
  createRenderEffect,
7
6
  createSignal,
8
- createStore,
9
- isPending,
7
+ isPending as isValuePending,
10
8
  onCleanup,
11
- reconcile,
12
- refresh,
9
+ resolve,
13
10
  runWithOwner,
14
- snapshot,
11
+ sharedConfig,
15
12
  untrack,
16
- useContext,
17
13
  } from 'solid-js'
18
- import { useQueryClient } from './QueryClientProvider'
19
- import { HydrationCoordinatorContext } from './hydrationChannel'
14
+ import { HYDRATION_KEY_PREFIX, useQueryClient } from './QueryClientProvider'
20
15
  import { useIsRestoring } from './isRestoring'
21
- import type { UseBaseQueryOptions } from './types'
16
+ import type { UseBaseQueryOptions, UseBaseQueryResult } from './types'
22
17
  import type { Accessor } from 'solid-js'
23
18
  import type { QueryClient } from './QueryClient'
24
19
  import type {
20
+ DefaultedQueryObserverOptions,
25
21
  Query,
26
22
  QueryKey,
27
23
  QueryObserver,
28
24
  QueryObserverResult,
25
+ QueryState,
29
26
  } from '@tanstack/query-core'
30
27
 
31
28
  const isServer = typeof window === 'undefined'
32
29
 
33
30
  /**
34
- * During SSR, Solid's store is serialized by seroval which cannot handle
35
- * functions. Strip `refetch`, `fetchNextPage`, and `fetchPreviousPage`
36
- * from the observer result before it enters the store so serialization
37
- * succeeds. On the client this is a no-op (returns the object as-is).
31
+ * A read of a pending-idle query (disabled, or reset with nothing in flight)
32
+ * has no value and nothing to wait on. Parking the reader on a promise that
33
+ * never resolves suspends it into the nearest `<Loading>` boundary until the
34
+ * query actually starts fetching (enabling it, a refetch, a cache write) —
35
+ * at which point the version bump re-runs the compute and the superseded
36
+ * in-flight is ignored by the engine.
38
37
  */
39
- function _stripFnsForSSR<TData, TError>(
40
- obj: QueryObserverResult<TData, TError>,
41
- ): QueryObserverResult<TData, TError> {
42
- if (!isServer) return obj
43
- const out: Record<string, unknown> = {}
44
- for (const k of Object.keys(obj)) {
45
- if (k === 'refetch' || k === 'fetchNextPage' || k === 'fetchPreviousPage') {
46
- out[k] = undefined
47
- } else {
48
- out[k] = (obj as any)[k]
49
- }
50
- }
51
- return out as unknown as QueryObserverResult<TData, TError>
52
- }
38
+ const NEVER: Promise<never> = new Promise(noop)
53
39
 
54
- function reconcileFn<TData, TError>(
55
- store: QueryObserverResult<TData, TError>,
56
- result: QueryObserverResult<TData, TError>,
57
- reconcileOption:
58
- | string
59
- | false
60
- | ((oldData: TData | undefined, newData: TData) => TData),
61
- queryHash?: string,
62
- ): QueryObserverResult<TData, TError> {
63
- if (typeof reconcileOption === 'function') {
64
- const newData = reconcileOption(store.data, result.data as TData)
65
- return { ...result, data: newData } as typeof result
66
- }
40
+ /** The scalar half of QueryState — `data` flows through the async node, not here. */
41
+ type MetaState = Omit<QueryState<unknown, unknown>, 'data' | 'fetchMeta'>
67
42
 
68
- if (reconcileOption === false) return result
69
-
70
- const key = reconcileOption
71
-
72
- let data = result.data
73
- if (store.data === undefined) {
74
- try {
75
- data = structuredClone(data)
76
- } catch (error) {
77
- if (process.env.NODE_ENV !== 'production') {
78
- if (error instanceof Error) {
79
- console.warn(
80
- `Unable to correctly reconcile data for query key: ${queryHash}. ` +
81
- `Possibly because the query data contains data structures that aren't supported ` +
82
- `by the 'structuredClone' algorithm. Consider using a callback function instead ` +
83
- `to manage the reconciliation manually.\n\n Error Received: ${error.name} - ${error.message}`,
84
- )
85
- }
86
- }
87
- }
88
- }
89
- // reconcile() in Solid 2.0 mutates in place and returns void.
90
- // We apply it to store.data so the store's nested signals update.
91
- // On first load (store.data is undefined), there's nothing to reconcile against,
92
- // so we just return the data as-is.
93
- if (store.data !== undefined && data !== undefined) {
94
- reconcile(data, key)(store.data)
95
- // Return result with the existing store.data reference (now reconciled in place)
96
- return { ...result, data: store.data } as typeof result
43
+ /**
44
+ * A cache entry in Solid's hydration registry, serialized by the
45
+ * provider under `sq:<queryHash>` — see `serializeCacheOnServer`.
46
+ */
47
+ interface HydratedEntry {
48
+ data: unknown
49
+ t: number
50
+ }
51
+
52
+ function metaFrom(state: QueryState<any, any>): MetaState {
53
+ return {
54
+ dataUpdateCount: state.dataUpdateCount,
55
+ dataUpdatedAt: state.dataUpdatedAt,
56
+ error: state.error,
57
+ errorUpdateCount: state.errorUpdateCount,
58
+ errorUpdatedAt: state.errorUpdatedAt,
59
+ fetchFailureCount: state.fetchFailureCount,
60
+ fetchFailureReason: state.fetchFailureReason,
61
+ fetchStatus: state.fetchStatus,
62
+ isInvalidated: state.isInvalidated,
63
+ status: state.status,
97
64
  }
98
- return { ...result, data } as typeof result
99
65
  }
100
66
 
101
- /**
102
- * Prepare an observer result for SSR serialization: the resolved resource
103
- * value is serialized by seroval, which cannot handle functions, so strip
104
- * `refetch` (and the infinite-query pagers). They come back when the
105
- * observer attaches on the client.
106
- *
107
- * The query's dehydrated cache state does not ride the observer result —
108
- * it travels through the provider-owned dehydration channel (see
109
- * `hydrationChannel.ts`).
110
- */
111
- const hydratableObserverResult = <
67
+ /** Internal seam: everything the read layer builds, for hooks that extend
68
+ * the base result (`useInfiniteQuery` pagers ride the same entry). */
69
+ interface BaseQueryLayer<
112
70
  TQueryFnData,
113
71
  TError,
114
72
  TData,
73
+ TQueryData,
115
74
  TQueryKey extends QueryKey,
116
- TDataHydratable,
117
- >(
118
- _query: Query<TQueryFnData, TError, TData, TQueryKey>,
119
- result: QueryObserverResult<TDataHydratable, TError>,
120
- ) => {
121
- if (!isServer) return result
122
- const obj: any = {
123
- ...snapshot(result),
124
- // During SSR, functions cannot be serialized, so we need to remove them
125
- // This is safe because we will add these functions back when the query is hydrated
126
- refetch: undefined,
127
- }
128
-
129
- // If the query is an infinite query, we need to remove additional properties
130
- if ('fetchNextPage' in result) {
131
- obj.fetchNextPage = undefined
132
- obj.fetchPreviousPage = undefined
133
- }
134
-
135
- return obj
75
+ > {
76
+ result: UseBaseQueryResult<TData, TError>
77
+ observer: QueryObserver<TQueryFnData, TError, TData, TQueryData, TQueryKey>
78
+ /** Version-tracked: reading it subscribes to this query's cache events. */
79
+ query: () => Query<TQueryFnData, TError, TQueryData, TQueryKey>
80
+ defaultedOptions: Accessor<ReturnType<QueryClient['defaultQueryOptions']>>
81
+ isFetching: () => boolean
82
+ status: () => 'pending' | 'error' | 'success'
136
83
  }
137
84
 
138
85
  // Base Query Function that is used to create the query.
@@ -148,359 +95,674 @@ export function useBaseQuery<
148
95
  >,
149
96
  Observer: typeof QueryObserver,
150
97
  queryClient?: Accessor<QueryClient>,
151
- ) {
152
- type ResourceData = QueryObserverResult<TData, TError>
153
-
154
- // Use createSignal(fn) instead of createMemo so these derived memos have
155
- // _preventAutoDisposal set. Without it, a createMemo that no one reads
156
- // reactively gets auto-disposed in Solid v2, which cascades and disposes
157
- // the component's onCleanup, unsubscribing the observer before the fetch
158
- // completes.
159
- const [client] = createSignal(() => useQueryClient(queryClient?.()))
98
+ ): UseBaseQueryResult<TData, TError> {
99
+ return useBaseQueryLayer(options, Observer, queryClient).result
100
+ }
101
+
102
+ export function useBaseQueryLayer<
103
+ TQueryFnData,
104
+ TError,
105
+ TData,
106
+ TQueryData,
107
+ TQueryKey extends QueryKey,
108
+ >(
109
+ options: Accessor<
110
+ UseBaseQueryOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey>
111
+ >,
112
+ Observer: typeof QueryObserver,
113
+ queryClient?: Accessor<QueryClient>,
114
+ ): BaseQueryLayer<TQueryFnData, TError, TData, TQueryData, TQueryKey> {
115
+ const client = createMemo(() => useQueryClient(queryClient?.()))
160
116
  const isRestoring = useIsRestoring()
161
- // There are times when we run a query on the server but the resource is never read
162
- // This could lead to times when the queryObserver is unsubscribed before the resource has loaded
163
- // Causing a time out error. To prevent this we will queue the unsubscribe if the cleanup is called
164
- // before the resource has loaded
165
- let unsubscribeQueued = false
166
-
167
- const [defaultedOptions] = createSignal(() => {
168
- const defaultOptions = client().defaultQueryOptions(options())
169
- defaultOptions._optimisticResults = isRestoring()
170
- ? 'isRestoring'
171
- : 'optimistic'
172
- defaultOptions.structuralSharing = false
117
+
118
+ /**
119
+ * The most recently computed hash, updated on every options compute —
120
+ * including holds, where the memo's committed face still serves the old
121
+ * options. The cache-event filter below must match both: on a key switch
122
+ * the new key's fetch settles *before* the hold commits, so filtering by
123
+ * the committed hash alone drops that settle event and nothing downstream
124
+ * (data recompute, meta projection) ever learns the fetch finished.
125
+ */
126
+ let latestHash: string | undefined
127
+ let latestOptions:
128
+ | DefaultedQueryObserverOptions<
129
+ TQueryFnData,
130
+ TError,
131
+ TData,
132
+ TQueryData,
133
+ TQueryKey
134
+ >
135
+ | undefined
136
+ const defaultedOptions = createMemo(() => {
137
+ const defaulted = client().defaultQueryOptions(options())
138
+ defaulted._optimisticResults = isRestoring() ? 'isRestoring' : 'optimistic'
173
139
  if (isServer) {
174
- defaultOptions.retry = false
175
- defaultOptions.throwOnError = true
176
- // Enable prefetch during render for SSR - required for createResource to work
177
- // Without this, queries wait for effects which never run on the server
178
- defaultOptions.experimental_prefetchInRender = true
140
+ defaulted.retry = false
141
+ defaulted.throwOnError = true
179
142
  }
180
- return defaultOptions
143
+ latestHash = defaulted.queryHash
144
+ latestOptions = defaulted
145
+ return defaulted
181
146
  })
182
147
 
183
- const observer = untrack(() => new Observer(client(), defaultedOptions()))
184
-
185
- // Track options reactively so the queryResource memo re-runs on change.
186
- const [trackedDefaultedOptions] = createSignal(() => defaultedOptions())
148
+ /**
149
+ * The observer is a lifecycle/policy engine only. Registering it pins the
150
+ * query against garbage collection, engages mount-fetch policy
151
+ * (`shouldFetchOnMount`), schedules `refetchInterval`, and gates
152
+ * focus/reconnect refetches — all of which live behind observer
153
+ * registration in query-core. Reactivity never touches its notifications
154
+ * or result objects: the listener is a noop, and the reactive layer below
155
+ * derives everything from cache events and fetch promises.
156
+ *
157
+ * `let`, not `const`: a reactive `queryClient` accessor can swap clients
158
+ * mid-life, and the observer must be rebuilt against the new client (see
159
+ * `syncClient` below).
160
+ */
161
+ let observer = untrack(() => new Observer(client(), defaultedOptions()))
187
162
 
188
- // Apply options in an effect to avoid store writes inside the memo.
189
- // setOptions triggers updateResult → notify → subscription → setState,
190
- // which must run in an effect context in Solid v2.
191
163
  createRenderEffect(
192
- () => trackedDefaultedOptions(),
193
- (opts) => {
194
- // observer.setOptions synchronously invokes subscribers which write to
195
- // the store. In Solid v2, signal/store writes inside an owned scope
196
- // (like this render effect) throw. Escape the owner so subscriber
197
- // writes don't trip the guard.
198
- runWithOwner(null, () => observer.setOptions(opts))
199
- },
164
+ () => defaultedOptions(),
165
+ (opts) => observer.setOptions(opts),
200
166
  )
201
167
 
202
- let observerResult = untrack(() =>
203
- observer.getOptimisticResult(defaultedOptions()),
204
- )
168
+ /**
169
+ * `sharedConfig.hydrating` is Solid's own "this component is being
170
+ * hydrated" flag, captured at setup. (The server build's sharedConfig
171
+ * has no such field — undefined there, and unused.)
172
+ */
173
+ const hydratedMount =
174
+ !isServer && (sharedConfig as { hydrating?: boolean }).hydrating === true
205
175
 
206
- const [state, setState] = createStore<QueryObserverResult<TData, TError>>(
207
- _stripFnsForSSR(observerResult),
208
- )
176
+ /**
177
+ * One version signal per hook, bumped by cache events for this query hash.
178
+ * This is the entire subscription model: any state transition on the query
179
+ * (fetch dispatch, settle, invalidation, `setQueryData`, removal) re-runs
180
+ * the derived reads below, which pull fresh state/promises from the cache.
181
+ *
182
+ * `ownedWrite`: a compute that reads through `query()` can itself create
183
+ * the cache entry (`cache.build` → synchronous 'added' event → this
184
+ * write), so the first bump can land inside whatever computation pulled
185
+ * the entry into existence. This is channel-owned state, not that
186
+ * computation's own — the same pattern as the router's per-key version
187
+ * signals.
188
+ */
189
+ const [version, setVersion] = createSignal(0, { ownedWrite: true })
190
+ /**
191
+ * Whether this hook's serialized data entry has been applied to the
192
+ * cache (or was determined not to exist). Non-hydrated mounts are born
193
+ * primed. Gates the pull-model fetch: hydration's takeover recompute can
194
+ * run against a cache the priming hasn't reached yet, and fetching there
195
+ * would race data the stream already delivered. Created on both sides
196
+ * (unread on the server) for hydration id parity.
197
+ */
198
+ const [primed, setPrimed] = createSignal(!hydratedMount, {
199
+ ownedWrite: true,
200
+ })
201
+ const onCacheEvent = (event: { query: { queryHash: string } }) => {
202
+ // Match the committed options hash OR the latest computed one (they
203
+ // diverge during a hold — see `latestHash`).
204
+ if (
205
+ event.query.queryHash === untrack(defaultedOptions).queryHash ||
206
+ event.query.queryHash === latestHash
207
+ ) {
208
+ setVersion((v) => v + 1)
209
+ }
210
+ }
209
211
 
210
- const createServerSubscriber = (
211
- resolve: (
212
- data: ResourceData | PromiseLike<ResourceData | undefined> | undefined,
213
- ) => void,
214
- reject: (reason?: any) => void,
215
- ) => {
216
- return observer.subscribe((result) => {
217
- notifyManager.batchCalls(() => {
218
- const query = observer.getCurrentQuery()
219
- const unwrappedResult = hydratableObserverResult(query, result)
220
-
221
- if (result.data !== undefined && unwrappedResult.isError) {
222
- reject(unwrappedResult.error)
223
- unsubscribeIfQueued()
224
- } else {
225
- resolve(unwrappedResult)
226
- unsubscribeIfQueued()
227
- }
228
- })()
229
- })
212
+ let observerSub: (() => void) | null = null
213
+ let cacheSub: (() => void) | null = null
214
+ let disposed = false
215
+ /** Set once the mount flow decides the observer should be live — a client
216
+ * swap re-attaches the rebuilt observer iff its predecessor was attached. */
217
+ let shouldAttach = false
218
+ let activeClient = untrack(client)
219
+
220
+ const attach = () => {
221
+ if (!disposed && !observerSub && !untrack(isRestoring)) {
222
+ shouldAttach = true
223
+ observerSub = observer.subscribe(noop)
224
+ }
230
225
  }
231
226
 
232
- const unsubscribeIfQueued = () => {
233
- if (unsubscribeQueued) {
234
- unsubscribe?.()
235
- unsubscribeQueued = false
227
+ /**
228
+ * Idempotent client re-pointing, called from the tracked `query()` read
229
+ * (which every derived compute goes through) rather than a dedicated
230
+ * watcher node — hydration id assignment is positional, so a client-only
231
+ * effect here would shift every downstream id. On a swap the version
232
+ * subscription moves to the new cache and the observer is rebuilt against
233
+ * the new client, so policy fetches and cache events both follow the
234
+ * client the hook is actually reading.
235
+ */
236
+ const syncClient = (c: QueryClient) => {
237
+ if (isServer || c === activeClient) return
238
+ activeClient = c
239
+ cacheSub?.()
240
+ cacheSub = c.getQueryCache().subscribe(onCacheEvent)
241
+ observerSub?.()
242
+ observerSub = null
243
+ observer = new Observer(c, untrack(defaultedOptions))
244
+ if (shouldAttach && !disposed && !untrack(isRestoring)) {
245
+ observerSub = observer.subscribe(noop)
236
246
  }
237
247
  }
238
248
 
239
- const createClientSubscriber = () => {
240
- return observer.subscribe((result) => {
241
- const previousResult = observerResult
242
- observerResult = result
243
- runWithOwner(null, () => {
244
- setStateWithReconciliation(result)
245
- if (
246
- unsubscribe &&
247
- !disposed &&
248
- (previousResult.isLoading !== result.isLoading ||
249
- previousResult.isError !== result.isError)
250
- ) {
251
- try {
252
- refresh(queryResource)
253
- } catch {
254
- // NotReadyError is expected when refreshing a memo that returns
255
- // a Promise. The Loading boundary handles this during rendering.
256
- }
257
- }
258
- })
259
- })
249
+ /**
250
+ * Hydration priming — content-addressed, the Solid Router `query()`
251
+ * pattern. The provider serialized every query this request touched
252
+ * into Solid's hydration registry under `sq:<queryHash>` (raw pre-select
253
+ * data plus `dataUpdatedAt`); here the hook looks its hash up directly
254
+ * and reconstructs the cache entry through query-core `hydrate()`, so
255
+ * staleness policy stays honest. Content addressing means coverage is
256
+ * the CACHE's, not the rendered tree's: prefetched-never-rendered
257
+ * queries transfer, shared keys prime from whichever hook consumes the
258
+ * entry first, and — like the router — the registry can still be
259
+ * consulted past hydration end, so a component mounted long after (lazy route,
260
+ * post-hydration navigation) still adopts the server's payload instead
261
+ * of refetching. Entries are consumed one-shot (deleted on load): once
262
+ * the cache owns the data, a stale registry payload must never shadow
263
+ * it.
264
+ *
265
+ * Ordering matters twice over. The observer attaches only after priming
266
+ * resolves (on hydrated mounts via `primeAndAttach`; on later mounts by
267
+ * priming HERE, before the attach render effect below even exists —
268
+ * its effect half runs synchronously inside an active flush, and mount
269
+ * policy against a still-cold cache would refetch data the registry
270
+ * already holds). Settled entries prime synchronously at setup;
271
+ * streamed entries prime when their chunk lands, per query, so early
272
+ * components go live while later boundaries still stream. No entry
273
+ * (placeholder queries serialize nothing; SSR errors adopt at their
274
+ * boundary) attaches immediately and lets mount policy decide.
275
+ */
276
+ const prime = (entry?: Partial<HydratedEntry> | null) => {
277
+ if (entry != null && typeof entry === 'object' && (entry.t ?? 0) > 0) {
278
+ const c = untrack(client)
279
+ const opts = untrack(defaultedOptions)
280
+ // Escape the setup/owned scope: hydrate() synchronously notifies
281
+ // cache subscribers, which may write into other hooks' stores.
282
+ runWithOwner(null, () =>
283
+ hydrate(c, {
284
+ mutations: [],
285
+ queries: [
286
+ {
287
+ queryKey: opts.queryKey,
288
+ queryHash: opts.queryHash,
289
+ dehydratedAt: entry.t,
290
+ ...(opts.meta && { meta: opts.meta }),
291
+ ...((opts as { _type?: string })._type && {
292
+ queryType: (opts as { _type?: string })._type,
293
+ }),
294
+ state: {
295
+ data: entry.data,
296
+ dataUpdatedAt: entry.t,
297
+ dataUpdateCount: 1,
298
+ error: null,
299
+ errorUpdateCount: 0,
300
+ errorUpdatedAt: 0,
301
+ fetchFailureCount: 0,
302
+ fetchFailureReason: null,
303
+ fetchMeta: null,
304
+ isInvalidated: false,
305
+ status: 'success',
306
+ fetchStatus: 'idle',
307
+ },
308
+ },
309
+ ],
310
+ } as Parameters<typeof hydrate>[1]),
311
+ )
312
+ }
260
313
  }
261
314
 
262
- function setStateWithReconciliation(res: typeof observerResult) {
263
- const opts = observer.options
264
- const reconcileOptions = (opts as any).reconcile
265
- const sanitized = _stripFnsForSSR(res)
266
-
267
- setState((store) => {
268
- return reconcileFn(
269
- store,
270
- sanitized,
271
- reconcileOptions === undefined ? false : reconcileOptions,
272
- opts.queryHash,
315
+ if (!isServer) {
316
+ cacheSub = activeClient.getQueryCache().subscribe(onCacheEvent)
317
+ const sc = sharedConfig as unknown as {
318
+ has?: (id: string) => boolean
319
+ load?: (id: string) => any
320
+ }
321
+ const registryKey =
322
+ HYDRATION_KEY_PREFIX + untrack(defaultedOptions).queryHash
323
+ const primeAndAttach = (entry?: Partial<HydratedEntry> | null) => {
324
+ prime(entry)
325
+ setPrimed(true)
326
+ attach()
327
+ }
328
+ const settle = hydratedMount ? primeAndAttach : prime
329
+ if (sc.has?.(registryKey)) {
330
+ const entry = sc.load!(registryKey)
331
+ delete (globalThis as unknown as { _$HY: { r: Record<string, unknown> } })
332
+ ._$HY.r[registryKey]
333
+ if (entry != null && typeof entry === 'object') {
334
+ // Settled serialization refs are stamped `s`/`v`; still-streaming
335
+ // ones are plain thenable refs that land with their chunk.
336
+ if (entry.s === 1) settle(entry.v)
337
+ else if (entry.s === 2) settle()
338
+ else if (typeof entry.then === 'function')
339
+ entry.then(settle, () => settle())
340
+ else settle(entry)
341
+ } else {
342
+ settle(entry)
343
+ }
344
+ } else if (hydratedMount) {
345
+ primeAndAttach()
346
+ }
347
+ if (!hydratedMount) {
348
+ createRenderEffect(
349
+ () => isRestoring(),
350
+ (restoring) => {
351
+ if (!restoring) attach()
352
+ },
273
353
  )
354
+ }
355
+ onCleanup(() => {
356
+ disposed = true
357
+ observerSub?.()
358
+ observerSub = null
359
+ cacheSub?.()
360
+ cacheSub = null
274
361
  })
275
362
  }
276
363
 
277
- /**
278
- * Unsubscribe is set lazily, so that we can subscribe after hydration when needed.
279
- */
280
- let unsubscribe: (() => void) | null = null
281
- let disposed = false
364
+ const query = (): Query<TQueryFnData, TError, TQueryData, TQueryKey> => {
365
+ version()
366
+ const c = client()
367
+ syncClient(c)
368
+ return c.getQueryCache().build(c, defaultedOptions() as any) as any
369
+ }
370
+
371
+ const isEnabled = () => {
372
+ const opts = defaultedOptions()
373
+ const enabled =
374
+ typeof opts.enabled === 'function'
375
+ ? (opts.enabled as (q: any) => boolean)(untrack(query))
376
+ : opts.enabled
377
+ return enabled !== false
378
+ }
379
+
380
+ const resolvePlaceholder = (
381
+ opts: ReturnType<typeof defaultedOptions>,
382
+ ): TQueryData | undefined => {
383
+ const placeholder = opts.placeholderData
384
+ if (placeholder === undefined) return undefined
385
+ // The function form receives previous data/query in React Query as a
386
+ // `keepPreviousData` vehicle. Solid 2 holds the previous committed value
387
+ // natively while a new promise is pending, so previous-data plumbing is
388
+ // unnecessary here; a function placeholder computes from nothing.
389
+ return typeof placeholder === 'function'
390
+ ? (placeholder as () => TQueryData | undefined)()
391
+ : placeholder
392
+ }
282
393
 
283
394
  /**
284
- * Attach the client subscriber for a component that hydrated from SSR
285
- * output.
286
- *
287
- * During hydration Solid replays the `queryResource` memo below from the
288
- * serialized SSR value with `Promise` mocked, so the promise executor
289
- * that normally creates the client subscriber never runs (nor could it:
290
- * a mount refetch started from inside the replay would never settle).
291
- * The replay is detected without touching any internals: a real
292
- * `Promise` runs its executor synchronously, the hydration mock does
293
- * not, so `executorRan` stays false exactly when this compute was
294
- * replayed.
295
- *
296
- * The subscription is coordinated with the provider's dehydration
297
- * channel: it attaches once this query's entry has been primed into the
298
- * cache (or once the channel completes without one), so mount semantics
299
- * see the hydrated cache state — a still-fresh query does not refetch, a
300
- * stale one does, and cache writes that landed earlier are reconciled at
301
- * attach. The wait is per-query, not global-hydration-end: a component
302
- * hydrated from an early flush goes live while later boundaries are
303
- * still streaming, so it is not deaf to cache writes, is seen by
304
- * invalidateQueries' active-query refetch, and cannot be gc'ed while
305
- * visible. Without a provider (manual `queryClient` option) it falls
306
- * back to a plain microtask.
395
+ * The data compute. It returns either the settled `{ value }` root or a
396
+ * promise of it — the engine does the rest: pending reads suspend into
397
+ * `<Loading>` (holding the previous committed value on refetch),
398
+ * rejections surface to `<Errored>`, transitions hold commits until
399
+ * in-flight answers land, and on the server the settled value serializes
400
+ * for hydration adoption on the client.
307
401
  */
308
- const coordinator = useContext(HydrationCoordinatorContext)
309
- const attachHydratedSubscriber = () => {
310
- if (!unsubscribe && !disposed && !isRestoring()) {
311
- unsubscribe = createClientSubscriber()
312
- }
313
- }
314
- const scheduleHydratedAttach = () => {
315
- const queryHash = untrack(() => observer.getCurrentQuery().queryHash)
316
- if (coordinator) {
317
- coordinator.whenQueryPrimed(queryHash, attachHydratedSubscriber)
318
- } else {
319
- queueMicrotask(attachHydratedSubscriber)
402
+ // Stable promise identity across recomputes: mid-flight version bumps
403
+ // (fetch dispatch, observer updates) re-run the data compute while the
404
+ // same underlying fetch is pending. Chaining a fresh `.then` each time
405
+ // would hand the engine a new pending promise per recompute — the node
406
+ // keeps restarting its pending tracking and the settle can be missed
407
+ // (observed as `isPending` probes stuck true after the fetch lands).
408
+ let chained: {
409
+ base: Promise<unknown>
410
+ select: unknown
411
+ value: Promise<{ value: TData }>
412
+ } | null = null
413
+ const chainOnce = (
414
+ base: Promise<any>,
415
+ select: unknown,
416
+ wrap: (d: any) => { value: TData },
417
+ ): Promise<{ value: TData }> => {
418
+ if (chained?.base !== base || chained.select !== select) {
419
+ chained = { base, select, value: base.then(wrap) }
320
420
  }
421
+ return chained.value
321
422
  }
322
423
 
323
- /*
324
- Fixes #7275
325
- In a few cases, the observer could unmount before the resource is loaded.
326
- This leads to Suspense boundaries to be suspended indefinitely.
327
- This resolver will be called when the observer is unmounting
328
- but the resource is still in a loading state
329
- */
330
- let resolver: ((value: ResourceData) => void) | null = null
331
- // Use createSignal(fn) instead of createMemo so the derived memo has
332
- // _preventAutoDisposal set. Without it, a createMemo that no one reads
333
- // reactively gets auto-disposed in Solid v2, which would cascade-dispose
334
- // the component's onCleanup and unsubscribe the observer before fetch
335
- // completion.
336
- const [queryResource] = createSignal<ResourceData>(() => {
337
- // Read trackedDefaultedOptions to ensure this memo re-runs when options change
338
- const opts = trackedDefaultedOptions()
339
- // Read isRestoring unconditionally so the memo re-runs when it changes
340
- const restoring = isRestoring()
424
+ const computeData = (
425
+ prev?: TData,
426
+ ): { value: TData } | Promise<{ value: TData }> => {
427
+ const opts = defaultedOptions()
428
+ const q = query()
429
+ const state = q.state
430
+ const select = opts.select as ((d: TQueryData) => TData) | undefined
431
+ const wrap = (d: any): { value: TData } => ({
432
+ value: select ? select(d as TQueryData) : (d as TData),
433
+ })
341
434
 
342
- if (isServer) {
343
- // On retry passes (after the streaming Loading boundary awaits a
344
- // pending Promise), the QueryClient cache already has the data, so
345
- // `getOptimisticResult` returns a non-loading result synchronously.
346
- // Returning the value directly (instead of a fresh Promise that
347
- // resolves synchronously) lets Solid's `processResult` set
348
- // `comp.value` directly without queueing another async settle. If we
349
- // returned a new Promise on every retry, Solid's streaming Loading
350
- // boundary `while (ret.p.length)` loop in `createLoadingBoundary`
351
- // would never terminate, because each retry adds a new pending
352
- // Promise to the boundary's tracked set even when it resolves
353
- // synchronously.
354
- const cached = observer.getOptimisticResult(opts)
355
- if (!cached.isLoading) {
356
- observerResult = cached
357
- runWithOwner(null, () => {
358
- setStateWithReconciliation(cached)
359
- })
360
- return hydratableObserverResult(
361
- observer.getCurrentQuery(),
362
- cached,
363
- ) as ResourceData
364
- }
435
+ // Placeholder: show immediately instead of suspending while the first
436
+ // fetch runs. When the fetch lands the version bump swaps in real data.
437
+ if (state.data === undefined && state.status === 'pending') {
438
+ const placeholder = resolvePlaceholder(opts)
439
+ if (placeholder !== undefined) return wrap(placeholder)
365
440
  }
366
441
 
367
- const replayProbe = { executorRan: false }
368
- const resource = new Promise<ResourceData>((resolve, reject) => {
369
- replayProbe.executorRan = true
370
- resolver = resolve
371
- if (isServer) {
372
- unsubscribe = createServerSubscriber((data) => {
373
- resolve(data as ResourceData)
374
- }, reject)
375
- } else if (!unsubscribe && !restoring) {
376
- unsubscribe = createClientSubscriber()
442
+ // A fetch in flight (including paused-offline with a retryer attached):
443
+ // hand the engine the promise. First load suspends; refetch holds the
444
+ // previous committed value and marks the node pending — which is what
445
+ // lets a mutation's invalidations hold its settle transition until
446
+ // fresh data lands.
447
+ //
448
+ // Exception: a FIRST compute that already has a value to show
449
+ // (initialData, hydrated or shared cache data with a mount refetch in
450
+ // flight) returns the value — suspending would have nothing committed
451
+ // to hold, blanking the UI on mount. The refetch stays observable via
452
+ // fetchStatus; the settle lands through the next version bump.
453
+ if (state.fetchStatus !== 'idle') {
454
+ if (state.data === undefined || prev !== undefined) {
455
+ const promise = q.promise
456
+ if (promise) return chainOnce(promise, select, wrap)
377
457
  }
378
- // Use getOptimisticResult instead of updateResult to keep the memo
379
- // free of store writes (updateResult triggers notify → setState).
380
- const currentResult = observer.getOptimisticResult(opts)
381
- observerResult = currentResult
382
-
383
- // Store writes inside a memo's owned scope throw in Solid v2.
384
- // Escape the owner so setState calls are allowed.
385
- runWithOwner(null, () => {
386
- if (
387
- currentResult.isError &&
388
- !currentResult.isFetching &&
389
- !restoring &&
390
- shouldThrowError(opts.throwOnError, [
391
- currentResult.error,
392
- observer.getCurrentQuery(),
393
- ])
394
- ) {
395
- setStateWithReconciliation(currentResult)
396
- reject(currentResult.error)
397
- return
398
- }
399
- setStateWithReconciliation(currentResult)
400
- })
458
+ }
401
459
 
460
+ if (state.status === 'error') {
461
+ // No stale value to serve means the read cannot produce TData — the
462
+ // rejection surfaces through the graph to the nearest <Errored>.
463
+ // With stale data present, throw only if throwOnError opts in.
402
464
  if (
403
- currentResult.isError &&
404
- !currentResult.isFetching &&
405
- !restoring &&
406
- shouldThrowError(opts.throwOnError, [
407
- currentResult.error,
408
- observer.getCurrentQuery(),
465
+ state.data === undefined ||
466
+ shouldThrowError(opts.throwOnError as any, [
467
+ state.error as any,
468
+ q as any,
409
469
  ])
410
470
  ) {
411
- return
412
- }
413
- if (!currentResult.isLoading) {
414
- resolver = null
415
- return resolve(
416
- hydratableObserverResult(observer.getCurrentQuery(), currentResult),
417
- )
471
+ throw state.error
418
472
  }
419
- })
473
+ }
420
474
 
421
- if (!isServer && !replayProbe.executorRan) {
422
- // Hydration replay: `Promise` was mocked and the executor above never
423
- // ran, so no subscriber was created. Schedule the attach through the
424
- // provider's hydration coordinator (see scheduleHydratedAttach).
425
- scheduleHydratedAttach()
475
+ if (state.data !== undefined) return wrap(state.data)
476
+
477
+ // Pending-idle: nothing in flight, nothing cached. If the query is
478
+ // enabled, the tracked read itself starts the fetch (the router
479
+ // `query()` model — reads pull the async). This is not just the server
480
+ // path: on the client it is what revives a query whose enabling change
481
+ // arrives while the subtree is parked under a suspended boundary —
482
+ // parked boundaries hold effects, so the observer's option-driven fetch
483
+ // can never fire there, but computes still re-run. `q.fetch` dedupes
484
+ // against any fetch the observer already started.
485
+ if (isEnabled()) {
486
+ /**
487
+ * Never start a fetch inside the hydration window. Adoption
488
+ * trace-runs this compute even on a hit (solid mocks `fetch` there,
489
+ * but a TanStack fetch is invisible to that mock) — a query fetching
490
+ * here would wedge on hydration's Promise mock inside its queryFn
491
+ * and then swallow every later legitimate refetch through retryer
492
+ * dedupe. During hydration the value comes from adoption or node
493
+ * priming; if a fetch is genuinely needed, the post-priming observer
494
+ * attach issues it outside the window.
495
+ */
496
+ if (!isServer && (sharedConfig as { hydrating?: boolean }).hydrating) {
497
+ return NEVER
498
+ }
499
+ /**
500
+ * Same discipline until this hook's serialized node entry has primed
501
+ * the cache (or was found absent): the takeover recompute at
502
+ * hydration end can land before a streamed entry does, and fetching
503
+ * against the still-empty cache would race data the stream already
504
+ * carries. The read is tracked — priming resolves through
505
+ * `setPrimed`, re-running this compute, and an entry-less query
506
+ * falls through to a normal fetch here.
507
+ */
508
+ if (!primed()) {
509
+ return NEVER
510
+ }
511
+ /**
512
+ * Same discipline while a persister is restoring: the restored value
513
+ * is about to land in the cache, so pulling a fetch now would race
514
+ * it. The read is tracked — `isRestoring` flipping false re-runs
515
+ * this compute (and the deferred observer attach engages mount
516
+ * policy), so the fetch fires then if the restore left a gap.
517
+ */
518
+ if (isRestoring()) {
519
+ return NEVER
520
+ }
521
+ /**
522
+ * Sync observer policy before pulling. Under a suspended boundary
523
+ * the options render effect is deferred, so on an enabled flip or
524
+ * key change this compute runs first and the effect only lands
525
+ * after the revival fetch settles — at which point a "changed"
526
+ * options diff against now-stale data would issue a redundant
527
+ * policy refetch. Syncing here means any policy fetch starts now
528
+ * (and `q.fetch` dedupes into it), and the deferred effect later
529
+ * sees the identical options object — a no-op diff.
530
+ */
531
+ if (!isServer) observer.setOptions(opts as any)
532
+ return chainOnce(q.fetch(opts as any), select, wrap)
426
533
  }
534
+ return NEVER
535
+ }
427
536
 
428
- return resource
429
- })
537
+ /**
538
+ * THE data node: one projection, identical on both sides — the router
539
+ * "feed `query()` into `createProjection`" shape. The derive asks the
540
+ * cache for the answer (`computeData`) and the engine owns everything
541
+ * else: the server runs the derive, waits, and serializes the settled
542
+ * store under this node's id (`deferStream` holds the flush); pending
543
+ * reads suspend into `<Loading>`; a hydrating client adopts the
544
+ * serialized value and re-derives once hydration hands over; and every
545
+ * landing — refetch, invalidation, placeholder upgrade — auto-reconciles
546
+ * into the existing proxy graph (keyed by `reconcile`, default `'id'`),
547
+ * so deep reads are fine-grained and item identity survives fetches.
548
+ *
549
+ * The root wrapper exists because store roots must be objects —
550
+ * primitive data rides the `value` leaf as a plain signal read. The
551
+ * draft's committed `value` doubles as the previous-data signal for the
552
+ * first-paint exception in `computeData`.
553
+ *
554
+ * Default ('server') hydration semantics: the serialized value owns the
555
+ * node for the whole hydration window — server truth holds the document
556
+ * while the stream is open — and any cache write that lands mid-stream
557
+ * arms the engine's hydration-end takeover (a latched node that
558
+ * recomputes has diverged), committing when hydration completes.
559
+ * Requires solid-js > 2.0.0-rc.3 (before the takeover fix, a mid-stream
560
+ * divergence was lost rather than deferred).
561
+ */
562
+ const dataStore = createProjection<{ value: TData }>(
563
+ (draft) => computeData(draft.value as TData | undefined),
564
+ {} as { value: TData },
565
+ {
566
+ key: untrack(() => options().reconcile),
567
+ deferStream: untrack(() => options().deferStream),
568
+ },
569
+ )
570
+ const data = () => dataStore.value
430
571
 
431
- onCleanup(() => {
432
- disposed = true
433
- if (isServer && isPending(queryResource)) {
434
- unsubscribeQueued = true
435
- return
436
- }
437
- if (unsubscribe) {
438
- unsubscribe()
439
- unsubscribe = null
440
- }
441
- if (resolver && !isServer) {
442
- resolver(observerResult)
443
- resolver = null
444
- }
445
- })
572
+ /**
573
+ * Scalar result metadata.
574
+ *
575
+ * Client: a projection reconciled per-field from query state on every
576
+ * cache event, so each field is its own fine-grained signal — a component
577
+ * reading only `isFetching` re-runs on fetchStatus flips, not on every
578
+ * state transition.
579
+ *
580
+ * Server: boundaries guarantee that only settled state serializes, and
581
+ * meta must honor the same contract (solid's own `isPending` suspends
582
+ * rather than ever serialize `true`). A projection cannot: its compute
583
+ * runs once at setup and never re-pulls, freezing an in-flight snapshot
584
+ * ('pending'/'fetching') into HTML that the hydrating client — reading
585
+ * the primed, settled cache — contradicts. So on the server every meta
586
+ * read pulls live query state, and while the entry is unsettled (pending
587
+ * with a fetch in flight, or one it can start) the read ties itself to the data
588
+ * node, which throws the pending read and holds the boundary until
589
+ * settle. A disabled pending query is served as-is: 'pending' IS its
590
+ * settled SSR truth, and the client hydrates the identical state.
591
+ */
592
+ const serverMeta = (): MetaState => {
593
+ if (query().state.status === 'pending' && isEnabled()) data()
594
+ return metaFrom(query().state)
595
+ }
596
+ /**
597
+ * Created on BOTH sides even though the server never reads it: hydration
598
+ * id assignment is positional, so every reactive node a hook creates on
599
+ * the client must have a server counterpart (and vice versa) or every id
600
+ * downstream shifts and hydration key-misses the whole subtree.
601
+ */
602
+ const metaProjection = createProjection<MetaState>(
603
+ (draft) => {
604
+ Object.assign(draft, metaFrom(query().state))
605
+ },
606
+ untrack(() => metaFrom(query().state)),
607
+ )
608
+ const meta = isServer
609
+ ? new Proxy({} as MetaState, {
610
+ get: (_, key) => serverMeta()[key as keyof MetaState],
611
+ })
612
+ : metaProjection
446
613
 
447
- // Properties that should never throw — these let users access error info
448
- // even outside an ErrorBoundary.
449
- const errorPassthroughProps = new Set([
450
- 'error',
451
- 'isError',
452
- 'failureCount',
453
- 'failureReason',
454
- 'errorUpdateCount',
455
- 'errorUpdatedAt',
456
- ])
457
-
458
- // Return a proxy that throws on property access when throwOnError is enabled
459
- return new Proxy(state, {
460
- get(target, prop, receiver) {
461
- // Always pass through symbols (needed for store internals, iteration, etc.)
462
- if (typeof prop === 'symbol') {
463
- return Reflect.get(target, prop, receiver)
614
+ /**
615
+ * Server: read live counts instead of a setup snapshot — "mount" resolves
616
+ * post-settle there, so `isFetchedAfterMount` serializes `false`, which is
617
+ * exactly what the hydrating client computes (its mount snapshot equals
618
+ * the hydrated counts).
619
+ */
620
+ const mountedAt = isServer
621
+ ? {
622
+ get dataUpdateCount() {
623
+ return untrack(query).state.dataUpdateCount
624
+ },
625
+ get errorUpdateCount() {
626
+ return untrack(query).state.errorUpdateCount
627
+ },
464
628
  }
465
-
466
- // On the server, force a Suspense dependency on the query resource so
467
- // the per-route Loading boundary catches NotReadyError, awaits the
468
- // pending Promise, and re-renders with the resolved state. Without
469
- // this, JSX reads through the Proxy never subscribe to queryResource
470
- // and SSR HTML reflects the initial loading state.
471
- //
472
- // Read the value from the *resolved resource* rather than `state`. When
473
- // the boundary suspends and re-renders after the query settles, the
474
- // `state` store is not synced (the server subscriber resolves the
475
- // resource Promise but does not write the store), so reading `state`
476
- // would render stale loading values. Reading the resolved resource keeps
477
- // the streamed SSR HTML consistent with the serialized resource, which
478
- // is what the client hydrates against.
479
- if (isServer) {
480
- const resolved = queryResource()
481
- if (prop in resolved) {
482
- return Reflect.get(resolved, prop)
629
+ : untrack(() => {
630
+ const state = query().state
631
+ return {
632
+ dataUpdateCount: state.dataUpdateCount,
633
+ errorUpdateCount: state.errorUpdateCount,
483
634
  }
484
- }
635
+ })
485
636
 
486
- // Always pass through error-related props without throwing
487
- if (errorPassthroughProps.has(prop)) {
488
- return Reflect.get(target, prop, receiver)
489
- }
637
+ const hasPlaceholder = () =>
638
+ meta.status === 'pending' &&
639
+ untrack(() => resolvePlaceholder(defaultedOptions())) !== undefined
640
+ const status = () => (hasPlaceholder() ? 'success' : meta.status)
641
+ const isPending = () => status() === 'pending'
642
+ /**
643
+ * A refetch of settled data is a transition: the state write that flips
644
+ * `fetchStatus` to 'fetching' is held in the same batch as the pending
645
+ * data node, so the committed meta channel cannot show it mid-hold — by
646
+ * design, held updates commit atomically. The observable channel for
647
+ * "a new answer is in flight" during a hold is core's pending probe.
648
+ * The committed `fetchStatus` covers the first load (where the probe
649
+ * would see an uninitialized node) and untracked/imperative reads.
650
+ */
651
+ const isFetching = () =>
652
+ meta.fetchStatus === 'fetching' || isValuePending(() => data())
653
+ const isError = () => status() === 'error'
654
+ const isStale = () => {
655
+ const opts = defaultedOptions()
656
+ const q = untrack(query)
657
+ const staleTime =
658
+ typeof opts.staleTime === 'function'
659
+ ? (opts.staleTime as (q: any) => number | 'static')(q)
660
+ : opts.staleTime
661
+ version()
662
+ return q.isStaleByTime(staleTime)
663
+ }
490
664
 
491
- // Check throwOnError condition before returning the value
492
- if (
493
- state.isError &&
494
- !state.isFetching &&
495
- shouldThrowError(observer.options.throwOnError, [
496
- state.error,
497
- observer.getCurrentQuery(),
498
- ])
499
- ) {
500
- throw state.error
665
+ const result = {
666
+ get data() {
667
+ return data()
668
+ },
669
+ get error() {
670
+ return meta.error as TError | null
671
+ },
672
+ get status() {
673
+ return status()
674
+ },
675
+ get fetchStatus() {
676
+ return meta.fetchStatus
677
+ },
678
+ get isPending() {
679
+ return isPending()
680
+ },
681
+ get isSuccess() {
682
+ return status() === 'success'
683
+ },
684
+ get isError() {
685
+ return isError()
686
+ },
687
+ get isLoading() {
688
+ return isPending() && isFetching()
689
+ },
690
+ get isFetching() {
691
+ return isFetching()
692
+ },
693
+ get isRefetching() {
694
+ return isFetching() && !isPending()
695
+ },
696
+ get isPaused() {
697
+ return meta.fetchStatus === 'paused'
698
+ },
699
+ get isEnabled() {
700
+ return isEnabled()
701
+ },
702
+ get isLoadingError() {
703
+ return isError() && meta.dataUpdatedAt === 0
704
+ },
705
+ get isRefetchError() {
706
+ return isError() && meta.dataUpdatedAt !== 0
707
+ },
708
+ get isPlaceholderData() {
709
+ return hasPlaceholder()
710
+ },
711
+ get isStale() {
712
+ return isStale()
713
+ },
714
+ get isFetched() {
715
+ return meta.dataUpdateCount > 0 || meta.errorUpdateCount > 0
716
+ },
717
+ get isFetchedAfterMount() {
718
+ return (
719
+ meta.dataUpdateCount > mountedAt.dataUpdateCount ||
720
+ meta.errorUpdateCount > mountedAt.errorUpdateCount
721
+ )
722
+ },
723
+ get dataUpdatedAt() {
724
+ return meta.dataUpdatedAt
725
+ },
726
+ get errorUpdatedAt() {
727
+ return meta.errorUpdatedAt
728
+ },
729
+ get failureCount() {
730
+ return meta.fetchFailureCount
731
+ },
732
+ get failureReason() {
733
+ return meta.fetchFailureReason as TError | null
734
+ },
735
+ get errorUpdateCount() {
736
+ return meta.errorUpdateCount
737
+ },
738
+ get promise() {
739
+ return resolve(() => data())
740
+ },
741
+ refetch: ((refetchOptions) => {
742
+ // A held transition defers the setOptions render effect, so after a
743
+ // key switch the observer can still carry the previous key's options.
744
+ // Sync to the latest computed options first: an imperative refetch
745
+ // targets what the UI is currently asking for, not what last
746
+ // committed.
747
+ if (!isServer && latestOptions) {
748
+ observer.setOptions(latestOptions as any)
501
749
  }
750
+ return observer.refetch(refetchOptions)
751
+ }) as QueryObserverResult<TData, TError>['refetch'],
752
+ }
502
753
 
503
- return Reflect.get(target, prop, receiver)
504
- },
505
- })
754
+ return {
755
+ result: result as unknown as UseBaseQueryResult<TData, TError>,
756
+ observer,
757
+ query,
758
+ defaultedOptions,
759
+ isFetching,
760
+ status,
761
+ } as unknown as BaseQueryLayer<
762
+ TQueryFnData,
763
+ TError,
764
+ TData,
765
+ TQueryData,
766
+ TQueryKey
767
+ >
506
768
  }