@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.
package/src/useQueries.ts CHANGED
@@ -1,28 +1,15 @@
1
- import { QueriesObserver, noop } from '@tanstack/query-core'
2
- import {
3
- createEffect,
4
- createMemo,
5
- createStore,
6
- merge,
7
- onCleanup,
8
- reconcile,
9
- runWithOwner,
10
- untrack,
11
- } from 'solid-js'
12
- import { useQueryClient } from './QueryClientProvider'
13
- import { useIsRestoring } from './isRestoring'
1
+ import { QueryObserver } from '@tanstack/query-core'
2
+ import { $TRACK, createMemo, repeat } from 'solid-js'
3
+ import { useBaseQuery } from './useBaseQuery'
14
4
  import type { QueryOptions, UseQueryResult } from './types'
15
5
  import type { Accessor } from 'solid-js'
16
6
  import type { QueryClient } from './QueryClient'
17
7
  import type {
18
8
  DefaultError,
19
9
  OmitKeyof,
20
- QueriesObserverOptions,
21
10
  QueriesPlaceholderDataFunction,
22
11
  QueryFunction,
23
12
  QueryKey,
24
- QueryObserverOptions,
25
- QueryObserverResult,
26
13
  ThrowOnError,
27
14
  } from '@tanstack/query-core'
28
15
 
@@ -35,14 +22,14 @@ type UseQueryOptionsForUseQueries<
35
22
  TQueryKey extends QueryKey = QueryKey,
36
23
  > = OmitKeyof<
37
24
  QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
38
- 'placeholderData' | 'suspense'
25
+ 'placeholderData'
39
26
  > & {
40
27
  placeholderData?: TQueryFnData | QueriesPlaceholderDataFunction<TQueryFnData>
41
28
  /**
42
- * @deprecated The `suspense` option has been deprecated in v5 and will be removed in the next major version.
43
- * The `data` property on useQueries is a plain object and not a SolidJS Resource.
44
- * It will not suspend when the data is loading.
45
- * Setting `suspense` to `true` will be a no-op.
29
+ * @deprecated The `suspense` option has been removed. Suspense is the
30
+ * model: each result's `data` is an async read that suspends into the
31
+ * nearest `<Loading>` boundary while its first fetch is in flight, same
32
+ * as `useQuery`. Setting `suspense` is a no-op.
46
33
  */
47
34
  suspense?: boolean
48
35
  }
@@ -140,7 +127,7 @@ type QueriesOptions<
140
127
  >
141
128
  : ReadonlyArray<unknown> extends T
142
129
  ? T
143
- : // If T is *some* array but we couldn't assign unknown[] to it, then it must hold some known/homogenous type!
130
+ : // If T is *some* array but we couldn't assign unknown[] to it, then it must hold some known/homogeneous type!
144
131
  // use this to infer the param types in the case of Array.map() argument
145
132
  T extends Array<
146
133
  UseQueryOptionsForUseQueries<
@@ -184,7 +171,7 @@ type QueriesResults<
184
171
 
185
172
  export function useQueries<
186
173
  T extends Array<any>,
187
- TCombinedResult extends QueriesResults<T> = QueriesResults<T>,
174
+ TCombinedResult = QueriesResults<T>,
188
175
  >(
189
176
  queriesOptions: Accessor<{
190
177
  queries:
@@ -194,95 +181,59 @@ export function useQueries<
194
181
  }>,
195
182
  queryClient?: Accessor<QueryClient>,
196
183
  ): TCombinedResult {
197
- const client = createMemo(() => useQueryClient(queryClient?.()))
198
- const isRestoring = useIsRestoring()
199
-
200
- const defaultedQueries = createMemo(() =>
201
- queriesOptions().queries.map((options) =>
202
- merge(client().defaultQueryOptions(options as QueryObserverOptions), {
203
- get _optimisticResults() {
204
- return isRestoring() ? 'isRestoring' : 'optimistic'
205
- },
206
- }),
207
- ),
208
- )
209
-
210
- const observer = untrack(
211
- () =>
212
- new QueriesObserver<TCombinedResult>(
213
- client(),
214
- defaultedQueries(),
215
- queriesOptions().combine
216
- ? ({
217
- combine: queriesOptions().combine,
218
- } as QueriesObserverOptions<TCombinedResult>)
219
- : undefined,
220
- ),
221
- )
222
-
223
- // Get initial optimistic result
224
- const [, getCombinedResult] = untrack(() =>
225
- observer.getOptimisticResult(
226
- defaultedQueries(),
227
- (queriesOptions() as QueriesObserverOptions<TCombinedResult>).combine,
228
- ),
229
- )
230
-
231
- const initialResult = getCombinedResult()
232
-
233
- // Store the combined result in a reactive store
234
- const [state, setState] = createStore<Array<QueryObserverResult>>(
235
- (Array.isArray(initialResult)
236
- ? initialResult
237
- : [initialResult]) as Array<QueryObserverResult>,
238
- )
239
-
240
- // Subscribe to the observer for updates reactively.
241
- // When isRestoring is true (persist client is restoring), we defer
242
- // subscription until restoring completes.
243
- let unsubscribe: () => void = noop
244
- createEffect(
245
- () => {
246
- if (!isRestoring()) {
247
- unsubscribe = observer.subscribe((result) => {
248
- runWithOwner(null, () => {
249
- setState(
250
- reconcile(
251
- [...result] as Array<QueryObserverResult>,
252
- // Use a key function that returns undefined so reconcile
253
- // uses positional matching and recursively updates nested properties
254
- () => undefined,
255
- ),
256
- )
257
- })
258
- })
259
- }
260
- },
261
- () => {},
262
- )
263
-
264
- onCleanup(() => {
265
- unsubscribe()
184
+ /**
185
+ * One read layer per position, count-keyed: each row owns a full
186
+ * `useBaseQuery` (observer lifecycle, version signal, async data node)
187
+ * whose options stay reactive through the per-index accessor — option
188
+ * changes flow into the existing row without tearing it down, while
189
+ * length changes create/dispose tail rows. Every result has identical
190
+ * semantics to `useQuery`: reads suspend, settled data is non-nullable.
191
+ */
192
+ const length = createMemo(() => queriesOptions().queries.length)
193
+ const results = repeat(length, (index) => {
194
+ // When the array shrinks, cache events can reach this row's
195
+ // subscriptions before disposal runs; the row keeps answering with its
196
+ // last options rather than reading past the end of the shorter array.
197
+ let lastOptions: any
198
+ return useBaseQuery(
199
+ () => {
200
+ const next = queriesOptions().queries[index]
201
+ if (next !== undefined) lastOptions = next
202
+ return lastOptions
203
+ },
204
+ QueryObserver,
205
+ queryClient,
206
+ )
266
207
  })
267
208
 
268
- // Update observer queries when options change reactively
269
- const trackedDefaultedQueries = createMemo(() => {
270
- const queries = defaultedQueries()
271
- runWithOwner(null, () => {
272
- observer.setQueries(
273
- queries,
274
- queriesOptions().combine
275
- ? ({
276
- combine: queriesOptions().combine,
277
- } as QueriesObserverOptions<TCombinedResult>)
278
- : undefined,
279
- )
280
- })
281
- return queries
209
+ const combined = createMemo(() => {
210
+ const combine = queriesOptions().combine
211
+ const list = results() as unknown as QueriesResults<T>
212
+ return combine ? combine(list) : (list as unknown as TCombinedResult)
282
213
  })
283
214
 
284
- // Force read of trackedDefaultedQueries to ensure it runs
285
- void trackedDefaultedQueries
286
-
287
- return state as unknown as TCombinedResult
215
+ // A stable facade over the combined result. Property reads pull through
216
+ // the memo so consumers track exactly what they touch; $TRACK hands list
217
+ // helpers (<For>, mapArray) the underlying array for diffing.
218
+ return new Proxy([] as unknown as TCombinedResult & object, {
219
+ get(_, prop) {
220
+ if (prop === $TRACK) return combined()
221
+ return Reflect.get(combined() as object, prop)
222
+ },
223
+ has(_, prop) {
224
+ if (prop === $TRACK) return true
225
+ return Reflect.has(combined() as object, prop)
226
+ },
227
+ ownKeys() {
228
+ return Reflect.ownKeys(combined() as object)
229
+ },
230
+ getOwnPropertyDescriptor(_, prop) {
231
+ const descriptor = Reflect.getOwnPropertyDescriptor(
232
+ combined() as object,
233
+ prop,
234
+ )
235
+ if (descriptor) descriptor.configurable = true
236
+ return descriptor
237
+ },
238
+ }) as TCombinedResult
288
239
  }
@@ -1,260 +0,0 @@
1
- import { hydrate } from '@tanstack/query-core'
2
- import { createContext, runWithOwner } from 'solid-js'
3
- import type { DehydratedState, QueryState } from '@tanstack/query-core'
4
- import type { QueryClient } from './QueryClient'
5
-
6
- type DehydratedQueryEntry = DehydratedState['queries'][number]
7
-
8
- /**
9
- * A single message on the dehydration channel. `entries` is *cumulative* —
10
- * every yield carries all entries settled so far. Two reasons:
11
- *
12
- * - It is what makes Solid's signal-path hydration replay lossless.
13
- * Yields still buffered when hydration begins are conflated to the
14
- * LATEST one (`normalizeIterator` drains synchronously available
15
- * results, keeps the last data yield, and delivers the stream's done
16
- * result on a subsequent pull), so each yield must be self-contained:
17
- * the latest cumulative snapshot alone carries everything the dropped
18
- * intermediates did. Requires the solid-js build with that conflation
19
- * behavior (> 2.0.0-beta.32); earlier betas pinned the replay at the
20
- * FIRST buffered yield, dropping every later entry and the `done`
21
- * marker.
22
- * - Entry objects keep their identity across yields, so seroval's
23
- * cross-reference serialization emits each entry once and later yields
24
- * only reference it — the cumulative shape costs bytes proportional to
25
- * the number of entries, not its square.
26
- *
27
- * `done: true` marks the final yield. The client uses it to release
28
- * subscribers still waiting for entries that will never arrive (e.g.
29
- * queries that errored during SSR and were not dehydrated).
30
- */
31
- export interface DehydrationChannelYield {
32
- entries: Array<DehydratedQueryEntry>
33
- done: boolean
34
- }
35
-
36
- /**
37
- * Server side of the library-owned serialization channel.
38
- *
39
- * Returns an AsyncIterable that yields a cumulative snapshot of the
40
- * dehydrated query cache (success entries, per query-core `dehydrate()`
41
- * shapes) every time a query settles during SSR. `QueryClientProvider`
42
- * holds it as a signal value, so Solid serializes it through the normal
43
- * per-computation path: the server runtime tees the iterator into the
44
- * hydration serializer (`ctx.serialize(id, tapped)` in solid-js'
45
- * `processResult`) and seroval streams each yield to the client as a
46
- * script chunk riding the SSR stream.
47
- *
48
- * The iterable must terminate for the SSR stream to complete: the
49
- * hydration serializer's `flush()` only fires its `onDone` once all
50
- * pending streams have closed, and the render root is disposed *after*
51
- * that, so neither `onCleanup` nor the serializer itself can close the
52
- * channel. Instead the channel closes itself on cache quiescence: after
53
- * every cache event (and once at creation) it schedules a timer-task
54
- * check; if no query is fetching by then, no further settle can occur —
55
- * suspense retry passes that start waterfall fetches are scheduled on
56
- * microtasks, so they have begun before the check runs — and the channel
57
- * emits its final cumulative snapshot with `done: true` and completes.
58
- *
59
- * Single-consumer by design: solid-js creates exactly one iterator from
60
- * the value and shares it between the memo and the serializer tap.
61
- */
62
- export function createServerDehydrationChannel(
63
- client: QueryClient,
64
- ): AsyncIterable<DehydrationChannelYield> {
65
- const cache = client.getQueryCache()
66
- // Entry objects are reused across yields while the query's state object
67
- // is unchanged, both so seroval can deduplicate them by reference and
68
- // so the client can cheaply skip already-applied entries.
69
- const entryCache = new Map<
70
- string,
71
- { state: QueryState<unknown, unknown>; entry: DehydratedQueryEntry }
72
- >()
73
-
74
- const snapshot = (): Array<DehydratedQueryEntry> => {
75
- const entries: Array<DehydratedQueryEntry> = []
76
- for (const query of cache.getAll()) {
77
- // Mirrors query-core's defaultShouldDehydrateQuery.
78
- if (query.state.status !== 'success') continue
79
- let cached = entryCache.get(query.queryHash)
80
- if (!cached || cached.state !== query.state) {
81
- cached = {
82
- state: query.state,
83
- entry: {
84
- dehydratedAt: Date.now(),
85
- state: query.state,
86
- queryKey: query.queryKey,
87
- queryHash: query.queryHash,
88
- ...(query.meta && { meta: query.meta }),
89
- ...(query.queryType && { queryType: query.queryType }),
90
- },
91
- }
92
- entryCache.set(query.queryHash, cached)
93
- }
94
- entries.push(cached.entry)
95
- }
96
- return entries
97
- }
98
-
99
- let closed = false
100
- let pull: ((result: IteratorResult<DehydrationChannelYield>) => void) | null =
101
- null
102
- const buffered: Array<DehydrationChannelYield> = []
103
-
104
- const emit = (value: DehydrationChannelYield) => {
105
- if (closed) return
106
- if (value.done) closed = true
107
- if (pull) {
108
- const resolve = pull
109
- pull = null
110
- resolve({ done: false, value })
111
- } else {
112
- buffered.push(value)
113
- }
114
- }
115
-
116
- let closeTimer: ReturnType<typeof setTimeout> | null = null
117
- const scheduleCloseCheck = () => {
118
- if (closed || closeTimer !== null) return
119
- closeTimer = setTimeout(() => {
120
- closeTimer = null
121
- if (closed) return
122
- if (client.isFetching() === 0) {
123
- unsubscribe()
124
- emit({ entries: snapshot(), done: true })
125
- }
126
- }, 0)
127
- }
128
-
129
- const unsubscribe = cache.subscribe((event) => {
130
- if (closed) return
131
- if (event.type === 'updated' && event.action.type === 'success') {
132
- emit({ entries: snapshot(), done: false })
133
- }
134
- scheduleCloseCheck()
135
- })
136
- scheduleCloseCheck()
137
-
138
- return {
139
- [Symbol.asyncIterator]() {
140
- return {
141
- next() {
142
- if (buffered.length > 0) {
143
- return Promise.resolve({ done: false, value: buffered.shift()! })
144
- }
145
- if (closed) {
146
- return Promise.resolve({
147
- done: true as const,
148
- value: undefined,
149
- })
150
- }
151
- return new Promise<IteratorResult<DehydrationChannelYield>>(
152
- (resolve) => {
153
- pull = resolve
154
- },
155
- )
156
- },
157
- return(value?: unknown) {
158
- if (!closed) {
159
- closed = true
160
- unsubscribe()
161
- if (closeTimer !== null) {
162
- clearTimeout(closeTimer)
163
- closeTimer = null
164
- }
165
- const resolve = pull
166
- pull = null
167
- resolve?.({ done: true, value: undefined })
168
- }
169
- return Promise.resolve({
170
- done: true as const,
171
- value: value as DehydrationChannelYield,
172
- })
173
- },
174
- }
175
- },
176
- }
177
- }
178
-
179
- interface HydrationCoordinator {
180
- /**
181
- * Prime the QueryClient from a channel yield. Entries already applied
182
- * (same queryHash and dataUpdatedAt) are skipped; the rest go through
183
- * query-core `hydrate()`, which keeps whichever data is newer.
184
- */
185
- applyYield: (value: DehydrationChannelYield) => void
186
- /**
187
- * Invoke `callback` (on a microtask) once the entry for `queryHash` has
188
- * been applied — or immediately-on-a-microtask if it already was, or
189
- * when the channel completes without one (SSR-errored queries are not
190
- * dehydrated, so their components must not wait forever).
191
- */
192
- whenQueryPrimed: (queryHash: string, callback: () => void) => void
193
- }
194
-
195
- /**
196
- * Client side of the channel. Created by `QueryClientProvider` on the
197
- * client and handed to `useBaseQuery` via context so hydrated components
198
- * can attach their observers as soon as their query's entry has been
199
- * primed — per query, not at global hydration end, which keeps
200
- * early-hydrated components live while other boundaries still stream.
201
- */
202
- export function createHydrationCoordinator(
203
- client: () => QueryClient,
204
- ): HydrationCoordinator {
205
- // queryHash -> dataUpdatedAt of the applied entry
206
- const applied = new Map<string, number>()
207
- const waiters = new Map<string, Array<() => void>>()
208
- let channelDone = false
209
-
210
- const fireWaiters = (queryHash: string) => {
211
- const callbacks = waiters.get(queryHash)
212
- if (!callbacks) return
213
- waiters.delete(queryHash)
214
- for (const callback of callbacks) queueMicrotask(callback)
215
- }
216
-
217
- return {
218
- applyYield(value) {
219
- const fresh = value.entries.filter(
220
- (entry) => applied.get(entry.queryHash) !== entry.state.dataUpdatedAt,
221
- )
222
- if (fresh.length > 0) {
223
- // hydrate() synchronously notifies cache subscribers which may
224
- // write to stores/signals; escape the owned scope (this runs
225
- // inside the provider's render effect) so those writes are
226
- // allowed.
227
- runWithOwner(null, () =>
228
- hydrate(client(), { queries: fresh, mutations: [] }),
229
- )
230
- for (const entry of fresh) {
231
- applied.set(entry.queryHash, entry.state.dataUpdatedAt)
232
- fireWaiters(entry.queryHash)
233
- }
234
- }
235
- if (value.done && !channelDone) {
236
- channelDone = true
237
- const remaining = [...waiters.values()]
238
- waiters.clear()
239
- for (const callbacks of remaining) {
240
- for (const callback of callbacks) queueMicrotask(callback)
241
- }
242
- }
243
- },
244
- whenQueryPrimed(queryHash, callback) {
245
- if (channelDone || applied.has(queryHash)) {
246
- queueMicrotask(callback)
247
- return
248
- }
249
- let list = waiters.get(queryHash)
250
- if (!list) {
251
- list = []
252
- waiters.set(queryHash, list)
253
- }
254
- list.push(callback)
255
- },
256
- }
257
- }
258
-
259
- export const HydrationCoordinatorContext =
260
- createContext<HydrationCoordinator | null>(null)