@tanstack/solid-query 6.0.0-beta.7 → 6.0.0-rc.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.
package/build/index.js CHANGED
@@ -1,9 +1,161 @@
1
- import { MutationObserver, shouldThrowError, QueriesObserver, QueryClient as QueryClient$1, replaceEqualDeep, noop, notifyManager, QueryObserver, InfiniteQueryObserver } from '@tanstack/query-core';
1
+ import { MutationObserver, shouldThrowError, QueriesObserver, QueryClient as QueryClient$1, replaceEqualDeep, noop, hydrate, notifyManager, QueryObserver, InfiniteQueryObserver } from '@tanstack/query-core';
2
2
  export * from '@tanstack/query-core';
3
- import { createContext, useContext, onCleanup, createMemo, untrack, createRenderEffect, createStore, runWithOwner, merge, createEffect, reconcile, createSignal, isPending, refresh, snapshot } from 'solid-js';
3
+ import { createContext, useContext, onCleanup, createSignal, createRenderEffect, createMemo, untrack, createStore, runWithOwner, merge, createEffect, reconcile, isPending, refresh, snapshot } from 'solid-js';
4
4
  import { createComponent } from '@solidjs/web';
5
5
 
6
6
  // src/useQuery.ts
7
+ function createServerDehydrationChannel(client) {
8
+ const cache = client.getQueryCache();
9
+ const entryCache = /* @__PURE__ */ new Map();
10
+ const snapshot2 = () => {
11
+ const entries = [];
12
+ for (const query of cache.getAll()) {
13
+ if (query.state.status !== "success") continue;
14
+ let cached = entryCache.get(query.queryHash);
15
+ if (!cached || cached.state !== query.state) {
16
+ cached = {
17
+ state: query.state,
18
+ entry: {
19
+ dehydratedAt: Date.now(),
20
+ state: query.state,
21
+ queryKey: query.queryKey,
22
+ queryHash: query.queryHash,
23
+ ...query.meta && { meta: query.meta },
24
+ ...query.queryType && { queryType: query.queryType }
25
+ }
26
+ };
27
+ entryCache.set(query.queryHash, cached);
28
+ }
29
+ entries.push(cached.entry);
30
+ }
31
+ return entries;
32
+ };
33
+ let closed = false;
34
+ let pull = null;
35
+ const buffered = [];
36
+ const emit = (value) => {
37
+ if (closed) return;
38
+ if (value.done) closed = true;
39
+ if (pull) {
40
+ const resolve = pull;
41
+ pull = null;
42
+ resolve({ done: false, value });
43
+ } else {
44
+ buffered.push(value);
45
+ }
46
+ };
47
+ let closeTimer = null;
48
+ const scheduleCloseCheck = () => {
49
+ if (closed || closeTimer !== null) return;
50
+ closeTimer = setTimeout(() => {
51
+ closeTimer = null;
52
+ if (closed) return;
53
+ if (client.isFetching() === 0) {
54
+ unsubscribe();
55
+ emit({ entries: snapshot2(), done: true });
56
+ }
57
+ }, 0);
58
+ };
59
+ const unsubscribe = cache.subscribe((event) => {
60
+ if (closed) return;
61
+ if (event.type === "updated" && event.action.type === "success") {
62
+ emit({ entries: snapshot2(), done: false });
63
+ }
64
+ scheduleCloseCheck();
65
+ });
66
+ scheduleCloseCheck();
67
+ return {
68
+ [Symbol.asyncIterator]() {
69
+ return {
70
+ next() {
71
+ if (buffered.length > 0) {
72
+ return Promise.resolve({ done: false, value: buffered.shift() });
73
+ }
74
+ if (closed) {
75
+ return Promise.resolve({
76
+ done: true,
77
+ value: void 0
78
+ });
79
+ }
80
+ return new Promise(
81
+ (resolve) => {
82
+ pull = resolve;
83
+ }
84
+ );
85
+ },
86
+ return(value) {
87
+ if (!closed) {
88
+ closed = true;
89
+ unsubscribe();
90
+ if (closeTimer !== null) {
91
+ clearTimeout(closeTimer);
92
+ closeTimer = null;
93
+ }
94
+ const resolve = pull;
95
+ pull = null;
96
+ resolve?.({ done: true, value: void 0 });
97
+ }
98
+ return Promise.resolve({
99
+ done: true,
100
+ value
101
+ });
102
+ }
103
+ };
104
+ }
105
+ };
106
+ }
107
+ function createHydrationCoordinator(client) {
108
+ const applied = /* @__PURE__ */ new Map();
109
+ const waiters = /* @__PURE__ */ new Map();
110
+ let channelDone = false;
111
+ const fireWaiters = (queryHash) => {
112
+ const callbacks = waiters.get(queryHash);
113
+ if (!callbacks) return;
114
+ waiters.delete(queryHash);
115
+ for (const callback of callbacks) queueMicrotask(callback);
116
+ };
117
+ return {
118
+ applyYield(value) {
119
+ const fresh = value.entries.filter(
120
+ (entry) => applied.get(entry.queryHash) !== entry.state.dataUpdatedAt
121
+ );
122
+ if (fresh.length > 0) {
123
+ runWithOwner(
124
+ null,
125
+ () => hydrate(client(), { queries: fresh, mutations: [] })
126
+ );
127
+ for (const entry of fresh) {
128
+ applied.set(entry.queryHash, entry.state.dataUpdatedAt);
129
+ fireWaiters(entry.queryHash);
130
+ }
131
+ }
132
+ if (value.done && !channelDone) {
133
+ channelDone = true;
134
+ const remaining = [...waiters.values()];
135
+ waiters.clear();
136
+ for (const callbacks of remaining) {
137
+ for (const callback of callbacks) queueMicrotask(callback);
138
+ }
139
+ }
140
+ },
141
+ whenQueryPrimed(queryHash, callback) {
142
+ if (channelDone || applied.has(queryHash)) {
143
+ queueMicrotask(callback);
144
+ return;
145
+ }
146
+ let list = waiters.get(queryHash);
147
+ if (!list) {
148
+ list = [];
149
+ waiters.set(queryHash, list);
150
+ }
151
+ list.push(callback);
152
+ }
153
+ };
154
+ }
155
+ var HydrationCoordinatorContext = createContext(null);
156
+
157
+ // src/QueryClientProvider.tsx
158
+ var isServer = typeof window === "undefined";
7
159
  var QueryClientContext = createContext(null);
8
160
  var useQueryClient = (queryClient) => {
9
161
  if (queryClient) {
@@ -18,10 +170,22 @@ var useQueryClient = (queryClient) => {
18
170
  var QueryClientProvider = (props) => {
19
171
  props.client.mount();
20
172
  onCleanup(() => props.client.unmount());
173
+ const [channelValue] = createSignal(() => isServer ? createServerDehydrationChannel(props.client) : void 0);
174
+ const coordinator = isServer ? null : createHydrationCoordinator(() => props.client);
175
+ createRenderEffect(() => isServer ? void 0 : channelValue(), (value) => {
176
+ if (value && coordinator) {
177
+ coordinator.applyYield(value);
178
+ }
179
+ });
21
180
  return createComponent(QueryClientContext, {
22
181
  value: () => props.client,
23
182
  get children() {
24
- return props.children;
183
+ return createComponent(HydrationCoordinatorContext, {
184
+ value: coordinator,
185
+ get children() {
186
+ return props.children;
187
+ }
188
+ });
25
189
  }
26
190
  });
27
191
  };
@@ -29,9 +193,9 @@ var IsRestoringContext = createContext(() => false);
29
193
  var useIsRestoring = () => useContext(IsRestoringContext);
30
194
 
31
195
  // src/useBaseQuery.ts
32
- var isServer = typeof window === "undefined";
196
+ var isServer2 = typeof window === "undefined";
33
197
  function _stripFnsForSSR(obj) {
34
- if (!isServer) return obj;
198
+ if (!isServer2) return obj;
35
199
  const out = {};
36
200
  for (const k of Object.keys(obj)) {
37
201
  if (k === "refetch" || k === "fetchNextPage" || k === "fetchPreviousPage") {
@@ -62,8 +226,8 @@ function reconcileFn(store, result, reconcileOption, queryHash) {
62
226
  }
63
227
  return { ...result, data };
64
228
  }
65
- var hydratableObserverResult = (query, result) => {
66
- if (!isServer) return result;
229
+ var hydratableObserverResult = (_query, result) => {
230
+ if (!isServer2) return result;
67
231
  const obj = {
68
232
  ...snapshot(result),
69
233
  // During SSR, functions cannot be serialized, so we need to remove them
@@ -74,12 +238,6 @@ var hydratableObserverResult = (query, result) => {
74
238
  obj.fetchNextPage = void 0;
75
239
  obj.fetchPreviousPage = void 0;
76
240
  }
77
- obj.hydrationData = {
78
- state: query.state,
79
- queryKey: query.queryKey,
80
- queryHash: query.queryHash,
81
- ...query.meta && { meta: query.meta }
82
- };
83
241
  return obj;
84
242
  };
85
243
  function useBaseQuery(options, Observer, queryClient) {
@@ -90,7 +248,7 @@ function useBaseQuery(options, Observer, queryClient) {
90
248
  const defaultOptions = client().defaultQueryOptions(options());
91
249
  defaultOptions._optimisticResults = isRestoring() ? "isRestoring" : "optimistic";
92
250
  defaultOptions.structuralSharing = false;
93
- if (isServer) {
251
+ if (isServer2) {
94
252
  defaultOptions.retry = false;
95
253
  defaultOptions.throwOnError = true;
96
254
  defaultOptions.experimental_prefetchInRender = true;
@@ -162,11 +320,25 @@ function useBaseQuery(options, Observer, queryClient) {
162
320
  }
163
321
  let unsubscribe = null;
164
322
  let disposed = false;
323
+ const coordinator = useContext(HydrationCoordinatorContext);
324
+ const attachHydratedSubscriber = () => {
325
+ if (!unsubscribe && !disposed && !isRestoring()) {
326
+ unsubscribe = createClientSubscriber();
327
+ }
328
+ };
329
+ const scheduleHydratedAttach = () => {
330
+ const queryHash = untrack(() => observer.getCurrentQuery().queryHash);
331
+ if (coordinator) {
332
+ coordinator.whenQueryPrimed(queryHash, attachHydratedSubscriber);
333
+ } else {
334
+ queueMicrotask(attachHydratedSubscriber);
335
+ }
336
+ };
165
337
  let resolver = null;
166
338
  const [queryResource] = createSignal(() => {
167
339
  const opts = trackedDefaultedOptions();
168
340
  const restoring = isRestoring();
169
- if (isServer) {
341
+ if (isServer2) {
170
342
  const cached = observer.getOptimisticResult(opts);
171
343
  if (!cached.isLoading) {
172
344
  observerResult = cached;
@@ -179,9 +351,11 @@ function useBaseQuery(options, Observer, queryClient) {
179
351
  );
180
352
  }
181
353
  }
182
- return new Promise((resolve, reject) => {
354
+ const replayProbe = { executorRan: false };
355
+ const resource = new Promise((resolve, reject) => {
356
+ replayProbe.executorRan = true;
183
357
  resolver = resolve;
184
- if (isServer) {
358
+ if (isServer2) {
185
359
  unsubscribe = createServerSubscriber((data) => {
186
360
  resolve(data);
187
361
  }, reject);
@@ -214,10 +388,14 @@ function useBaseQuery(options, Observer, queryClient) {
214
388
  );
215
389
  }
216
390
  });
391
+ if (!isServer2 && !replayProbe.executorRan) {
392
+ scheduleHydratedAttach();
393
+ }
394
+ return resource;
217
395
  });
218
396
  onCleanup(() => {
219
397
  disposed = true;
220
- if (isServer && isPending(queryResource)) {
398
+ if (isServer2 && isPending(queryResource)) {
221
399
  unsubscribeQueued = true;
222
400
  return;
223
401
  }
@@ -225,7 +403,7 @@ function useBaseQuery(options, Observer, queryClient) {
225
403
  unsubscribe();
226
404
  unsubscribe = null;
227
405
  }
228
- if (resolver && !isServer) {
406
+ if (resolver && !isServer2) {
229
407
  resolver(observerResult);
230
408
  resolver = null;
231
409
  }
@@ -243,7 +421,7 @@ function useBaseQuery(options, Observer, queryClient) {
243
421
  if (typeof prop === "symbol") {
244
422
  return Reflect.get(target, prop, receiver);
245
423
  }
246
- if (isServer) {
424
+ if (isServer2) {
247
425
  const resolved = queryResource();
248
426
  if (prop in resolved) {
249
427
  return Reflect.get(resolved, prop);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/solid-query",
3
- "version": "6.0.0-beta.7",
3
+ "version": "6.0.0-rc.0",
4
4
  "description": "Primitives for managing, caching and syncing asynchronous and remote data in Solid",
5
5
  "author": "tannerlinsley",
6
6
  "license": "MIT",
@@ -52,16 +52,16 @@
52
52
  "@babel/core": "^7.28.0",
53
53
  "@babel/preset-typescript": "^7.18.6",
54
54
  "@solidjs/testing-library": "^0.8.10",
55
- "@solidjs/web": "2.0.0-beta.29",
56
- "babel-preset-solid": "2.0.0-beta.29",
55
+ "@solidjs/vite-plugin": "^3.0.0-next.27",
56
+ "@solidjs/web": "^2.0.0-rc.0",
57
+ "babel-preset-solid": "^2.0.0-rc.0",
57
58
  "npm-run-all2": "^5.0.0",
58
- "solid-js": "2.0.0-beta.29",
59
+ "solid-js": "^2.0.0-rc.0",
59
60
  "tsup-preset-solid": "^2.2.0",
60
- "vite-plugin-solid": "3.0.0-next.21",
61
61
  "@tanstack/query-test-utils": "0.0.0"
62
62
  },
63
63
  "peerDependencies": {
64
- "solid-js": ">=2.0.0-beta.0 <3.0.0"
64
+ "solid-js": ">=2.0.0-rc.0 <3.0.0"
65
65
  },
66
66
  "scripts": {
67
67
  "clean": "premove ./build ./coverage ./dist-ts",
@@ -1,7 +1,21 @@
1
- import { createContext, onCleanup, useContext } from 'solid-js'
1
+ import {
2
+ createContext,
3
+ createRenderEffect,
4
+ createSignal,
5
+ onCleanup,
6
+ useContext,
7
+ } from 'solid-js'
8
+ import {
9
+ HydrationCoordinatorContext,
10
+ createHydrationCoordinator,
11
+ createServerDehydrationChannel,
12
+ } from './hydrationChannel'
13
+ import type { DehydrationChannelYield } from './hydrationChannel'
2
14
  import type { QueryClient } from './QueryClient'
3
15
  import type { JSX } from '@solidjs/web'
4
16
 
17
+ const isServer = typeof window === 'undefined'
18
+
5
19
  export const QueryClientContext = createContext<(() => QueryClient) | null>(
6
20
  null,
7
21
  )
@@ -30,9 +44,53 @@ export const QueryClientProvider = (
30
44
  props.client.mount()
31
45
  onCleanup(() => props.client.unmount())
32
46
 
47
+ // Library-owned serialization channel for SSR dehydration.
48
+ //
49
+ // Server: the computation's value IS the channel's async iterable, so
50
+ // Solid serializes it through its normal per-computation signal path:
51
+ // the server runtime tees the iterator into the hydration serializer
52
+ // (`ctx.serialize(id, tapped)` in solid-js' `processResult`) and
53
+ // seroval streams each cumulative dehydrated-cache snapshot to the
54
+ // client as a chunk riding the SSR stream, with the entry objects
55
+ // inside deduplicated by reference against everything else in the
56
+ // payload. Nothing reads the signal during SSR, so it never suspends
57
+ // anything.
58
+ //
59
+ // Client, hydrating: Solid replays the serialized iterable through the
60
+ // per-computation signal path (`hydrateSignalFromAsyncIterable`).
61
+ // Yields that were still buffered when hydration began are conflated
62
+ // to the LATEST yield (`normalizeIterator`) — lossless here because
63
+ // every yield is a cumulative snapshot — and live yields after that
64
+ // apply one at a time. Requires a solid-js build with the buffered
65
+ // async-iterable replay conflation fix (> 2.0.0-beta.32): before it,
66
+ // the replay dropped every buffered yield after the first, including
67
+ // the terminal `done` snapshot. The render effect below hands each
68
+ // signal value to the coordinator, which primes the QueryClient via
69
+ // query-core hydrate() (newer-wins) and unblocks `useBaseQuery`
70
+ // subscribers waiting on their query's entry.
71
+ //
72
+ // Client, fresh mount: the compute returns undefined and the effect
73
+ // never fires.
74
+ const [channelValue] = createSignal<DehydrationChannelYield | undefined>(
75
+ () => (isServer ? createServerDehydrationChannel(props.client) : undefined),
76
+ )
77
+ const coordinator = isServer
78
+ ? null
79
+ : createHydrationCoordinator(() => props.client)
80
+ createRenderEffect(
81
+ () => (isServer ? undefined : channelValue()),
82
+ (value) => {
83
+ if (value && coordinator) {
84
+ coordinator.applyYield(value)
85
+ }
86
+ },
87
+ )
88
+
33
89
  return (
34
90
  <QueryClientContext value={() => props.client}>
35
- {props.children}
91
+ <HydrationCoordinatorContext value={coordinator}>
92
+ {props.children}
93
+ </HydrationCoordinatorContext>
36
94
  </QueryClientContext>
37
95
  )
38
96
  }
@@ -0,0 +1,260 @@
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)