ngx-signal-query 1.8.0-dev.2 → 1.8.0-dev.3

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/README.md CHANGED
@@ -29,11 +29,12 @@ know from it carries over.
29
29
  - 🔁 Retries, polling, and refetch on window focus and reconnect
30
30
  - ✏️ Mutations with async lifecycle hooks and optimistic updates
31
31
  - 🌍 App-wide defaults and global indicators (`injectIsFetching()`, `injectIsMutating()`)
32
+ - 🖥️ Server rendering: the server waits for every fetch, and `withHydration()` brings the data over to the browser
32
33
 
33
34
  ## Not here (yet)
34
35
 
35
- Infinite queries, SSR cache hydration, devtools and persistence. If you need
36
- them today, TanStack Query has them.
36
+ Infinite queries, devtools and persistence. If you need them today, TanStack
37
+ Query has them.
37
38
 
38
39
  ## Installation
39
40
 
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { inject, PLATFORM_ID, signal, PendingTasks, DestroyRef, Injectable, InjectionToken, EnvironmentInjector, untracked, runInInjectionContext, makeEnvironmentProviders, assertInInjectionContext, effect, computed, Injector } from '@angular/core';
2
+ import { inject, PLATFORM_ID, makeStateKey, TransferState, NgZone, signal, PendingTasks, DestroyRef, Injectable, InjectionToken, EnvironmentInjector, untracked, runInInjectionContext, makeEnvironmentProviders, assertInInjectionContext, effect, computed, Injector } from '@angular/core';
3
3
  import { isPlatformBrowser, DOCUMENT } from '@angular/common';
4
4
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
5
5
  import { fromEvent, filter, Subscription, defer, isObservable, from, take, retry, timer, tap, throwIfEmpty, map, concatMap, catchError, of, endWith, concat, ignoreElements, EMPTY } from 'rxjs';
@@ -70,48 +70,6 @@ function onReconnect(listener) {
70
70
  .subscribe(() => listener());
71
71
  }
72
72
 
73
- /** @internal */
74
- class Cache {
75
- #entries = signal([], ...(ngDevMode ? [{ debugName: "#entries" }] : []));
76
- // Reactive snapshot of all entries; updated on add/remove/clear so that
77
- // findAll() (and its computed consumers like isFetching) react to the
78
- // collection. Protected: only subclasses read it, never external code.
79
- entries = this.#entries.asReadonly();
80
- #entriesMap = new Map();
81
- getAll() {
82
- return Array.from(this.#entriesMap.values());
83
- }
84
- clear() {
85
- this.#entriesMap.clear();
86
- this.#sync();
87
- }
88
- // Updates the snapshot again once the current computation is over, in a
89
- // microtask. An entry added in the middle of a computation is in the
90
- // snapshot at once, but a computed that read the snapshot earlier in that
91
- // computation doesn't hear of it: it only starts listening once its
92
- // computation is done. The second update reaches it.
93
- notifyLater() {
94
- queueMicrotask(() => this.#sync());
95
- }
96
- addEntry(key, entry) {
97
- this.#entriesMap.set(key, entry);
98
- this.#sync();
99
- return entry;
100
- }
101
- getEntry(key) {
102
- return this.#entriesMap.get(key);
103
- }
104
- removeEntry(key) {
105
- const removed = this.#entriesMap.delete(key);
106
- if (removed)
107
- this.#sync();
108
- return removed;
109
- }
110
- #sync() {
111
- this.#entries.set(Array.from(this.#entriesMap.values()));
112
- }
113
- }
114
-
115
73
  // Resolves an updater: calls it with the previous value if it's a function,
116
74
  // otherwise uses it as the value directly.
117
75
  /** @internal */
@@ -245,6 +203,54 @@ function replaceData(prevData, data, structuralSharing) {
245
203
  }
246
204
  return structuralSharing ? replaceEqualDeep(prevData, data) : data;
247
205
  }
206
+ // Past this many levels survivesJson gives up: a value that references
207
+ // itself never ends, and real data is never this deep.
208
+ const MAX_JSON_DEPTH = 500;
209
+ // Whether JSON carries the value over as it is: JSON.parse gives back a value
210
+ // equal to it, of the same kind. Strings, finite numbers, booleans, null,
211
+ // plain objects and arrays without holes do. A Date, a Map, a class instance,
212
+ // a BigInt, NaN, undefined in an array, or a value that references itself
213
+ // don't. An object property that's undefined counts as carried: JSON drops
214
+ // it, and reading it gives undefined all the same. Symbol keys and extra
215
+ // properties on an array aren't checked, and don't make it over.
216
+ /** @internal */
217
+ function survivesJson(value, depth = 0) {
218
+ if (value === null)
219
+ return true;
220
+ if (typeof value === 'string' || typeof value === 'boolean')
221
+ return true;
222
+ // NaN and Infinity come back as null.
223
+ if (typeof value === 'number')
224
+ return Number.isFinite(value);
225
+ // undefined, a function or a symbol is dropped, or comes back as null; a
226
+ // BigInt can't be written at all.
227
+ if (typeof value !== 'object')
228
+ return false;
229
+ if (depth > MAX_JSON_DEPTH)
230
+ return false;
231
+ if (Array.isArray(value)) {
232
+ for (let i = 0; i < value.length; i++) {
233
+ // An item that's undefined, a hole included, comes back as null.
234
+ if (!survivesJson(value[i], depth + 1))
235
+ return false;
236
+ }
237
+ return true;
238
+ }
239
+ // JSON.parse makes objects whose prototype is Object.prototype. Whatever
240
+ // has another one, a Date, a Map, a class instance, comes back as something
241
+ // else. One with none comes back as an ordinary object, which reads the
242
+ // same.
243
+ const prototype = Object.getPrototypeOf(value);
244
+ if (prototype !== Object.prototype && prototype !== null)
245
+ return false;
246
+ // for...in rather than Object.keys(): no array to allocate per object.
247
+ for (const key in value) {
248
+ const item = value[key];
249
+ if (item !== undefined && !survivesJson(item, depth + 1))
250
+ return false;
251
+ }
252
+ return true;
253
+ }
248
254
  // The longest delay a browser timer holds, in ms. A longer one overflows and
249
255
  // the timer fires at once.
250
256
  const MAX_TIMEOUT = 2 ** 31 - 1;
@@ -277,6 +283,126 @@ function deferred() {
277
283
  return { promise, resolve, reject };
278
284
  }
279
285
 
286
+ /**
287
+ * Where in Angular's TransferState the page carries the cache.
288
+ *
289
+ * @internal
290
+ */
291
+ const TRANSFER_KEY = makeStateKey('ngx-signal-query');
292
+ /**
293
+ * Hands the cache from a server render over to the browser through Angular's
294
+ * TransferState: on the server, the queries go into the page once the render
295
+ * is done; in the browser, they go back into the cache, as fetched when the
296
+ * server fetched them. Must run in an injection context.
297
+ *
298
+ * @internal
299
+ */
300
+ function transferCache(cache, gcTime) {
301
+ const transferState = inject(TransferState);
302
+ if (!inBrowser()) {
303
+ // Called as Angular writes the state into the page, once the app is
304
+ // stable: every fetch of the render has landed by then.
305
+ transferState.onSerialize(TRANSFER_KEY, () => dehydrate(cache));
306
+ return;
307
+ }
308
+ const queries = transferState.get(TRANSFER_KEY, null);
309
+ if (!queries)
310
+ return;
311
+ // Read once: from now on the cache holds the data.
312
+ transferState.remove(TRANSFER_KEY);
313
+ // Outside Angular's zone: with zone.js, the gc timer of a query no
314
+ // component shows yet would keep the app from counting as stable, and
315
+ // Angular's HTTP transfer cache would go on answering its requests with the
316
+ // server's responses until then.
317
+ inject(NgZone).runOutsideAngular(() => hydrate(cache, queries, gcTime));
318
+ }
319
+ // The queries the page carries: those that succeeded, with data JSON carries
320
+ // as is. Any other is left to the browser, which fetches it as it would
321
+ // without a server render. Data JSON can't carry would reach the browser as
322
+ // something else, a Date as a string, or not at all: a BigInt or a value that
323
+ // references itself fails the whole page's state, Angular's own included.
324
+ function dehydrate(cache) {
325
+ return cache.getAll().flatMap((query) => {
326
+ const { status, data, updatedAt, isInvalidated } = query.state();
327
+ if (status !== 'success')
328
+ return [];
329
+ if (!survivesJson(data)) {
330
+ warnLeftOut(query.queryHash);
331
+ return [];
332
+ }
333
+ return [
334
+ {
335
+ key: query.key,
336
+ hash: query.queryHash,
337
+ data,
338
+ updatedAt,
339
+ ...(isInvalidated && { isInvalidated }),
340
+ },
341
+ ];
342
+ });
343
+ }
344
+ // Puts the queries the page carries into a cache nothing has read yet. One
345
+ // that nothing uses is kept for gcTime, as data written with setQueryData is.
346
+ function hydrate(cache, queries, gcTime) {
347
+ for (const { key, hash, data, updatedAt, isInvalidated } of queries) {
348
+ const query = cache.getOrCreate(key, gcTime, hash);
349
+ query.setData(data, { updatedAt });
350
+ if (isInvalidated)
351
+ query.invalidate();
352
+ }
353
+ }
354
+ // Tells the developer why a query loads again in the browser. Development
355
+ // builds only: a production build drops it with the rest of Angular's
356
+ // development checks.
357
+ function warnLeftOut(queryHash) {
358
+ if (typeof ngDevMode === 'undefined' || ngDevMode) {
359
+ // eslint-disable-next-line no-console
360
+ console.warn(`ngx-signal-query: query ${queryHash} stays on the server: JSON can't carry its data as is (a Date, a Map or a class instance, say), so the browser fetches it again. Return plain data from its queryFn to bring it over.`);
361
+ }
362
+ }
363
+
364
+ /** @internal */
365
+ class Cache {
366
+ #entries = signal([], ...(ngDevMode ? [{ debugName: "#entries" }] : []));
367
+ // Reactive snapshot of all entries; updated on add/remove/clear so that
368
+ // findAll() (and its computed consumers like isFetching) react to the
369
+ // collection. Protected: only subclasses read it, never external code.
370
+ entries = this.#entries.asReadonly();
371
+ #entriesMap = new Map();
372
+ getAll() {
373
+ return Array.from(this.#entriesMap.values());
374
+ }
375
+ clear() {
376
+ this.#entriesMap.clear();
377
+ this.#sync();
378
+ }
379
+ // Updates the snapshot again once the current computation is over, in a
380
+ // microtask. An entry added in the middle of a computation is in the
381
+ // snapshot at once, but a computed that read the snapshot earlier in that
382
+ // computation doesn't hear of it: it only starts listening once its
383
+ // computation is done. The second update reaches it.
384
+ notifyLater() {
385
+ queueMicrotask(() => this.#sync());
386
+ }
387
+ addEntry(key, entry) {
388
+ this.#entriesMap.set(key, entry);
389
+ this.#sync();
390
+ return entry;
391
+ }
392
+ getEntry(key) {
393
+ return this.#entriesMap.get(key);
394
+ }
395
+ removeEntry(key) {
396
+ const removed = this.#entriesMap.delete(key);
397
+ if (removed)
398
+ this.#sync();
399
+ return removed;
400
+ }
401
+ #sync() {
402
+ this.#entries.set(Array.from(this.#entriesMap.values()));
403
+ }
404
+ }
405
+
280
406
  /**
281
407
  * The error a promise of a query's data rejects with when the fetch stops
282
408
  * with no data to give: it was cancelled before the query had any
@@ -704,8 +830,9 @@ class QueryCache extends Cache {
704
830
  // app busy, and no timer of theirs may run on.
705
831
  inject(DestroyRef).onDestroy(() => this.clear());
706
832
  }
707
- getOrCreate(key, gcTime) {
708
- const queryHash = hashKey(key);
833
+ // The hash is the key's, unless the caller brings the one the query had
834
+ // elsewhere, as a query the server hands over does.
835
+ getOrCreate(key, gcTime, queryHash = hashKey(key)) {
709
836
  const exist = this.getEntry(queryHash);
710
837
  if (exist)
711
838
  return exist;
@@ -961,6 +1088,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.21", ngImpo
961
1088
 
962
1089
  /** @internal */
963
1090
  const QUERY_CLIENT_CONFIG = new InjectionToken('QUERY_CLIENT_CONFIG');
1091
+ /**
1092
+ * Set by {@link withHydration}: the client hands its cache from the server
1093
+ * render over to the browser.
1094
+ *
1095
+ * @internal
1096
+ */
1097
+ const QUERY_CLIENT_HYDRATION = new InjectionToken('QUERY_CLIENT_HYDRATION');
964
1098
 
965
1099
  const DEFAULT_GC_TIME = 5 * 60 * 1000;
966
1100
  /**
@@ -1000,6 +1134,12 @@ class QueryClient {
1000
1134
  /** @internal */
1001
1135
  constructor() {
1002
1136
  inject(DestroyRef).onDestroy(() => (this.#destroyed = true));
1137
+ // With withHydration(), the cache carries over from the server render.
1138
+ // Here, before anything can read the cache: whatever reads it first, a
1139
+ // component or an app initializer, finds the server's data in it.
1140
+ if (inject(QUERY_CLIENT_HYDRATION, { optional: true })) {
1141
+ transferCache(this.#cache, this.#gcTime());
1142
+ }
1003
1143
  // When the user comes back to the page, or the network comes back, each
1004
1144
  // query refetches if one of its observers wants that. Untracked like the
1005
1145
  // methods: an event fired from an effect mustn't make the effect depend
@@ -1365,6 +1505,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.21", ngImpo
1365
1505
  var QueryClientFeatureKind;
1366
1506
  (function (QueryClientFeatureKind) {
1367
1507
  QueryClientFeatureKind[QueryClientFeatureKind["DefaultOptions"] = 0] = "DefaultOptions";
1508
+ QueryClientFeatureKind[QueryClientFeatureKind["Hydration"] = 1] = "Hydration";
1368
1509
  })(QueryClientFeatureKind || (QueryClientFeatureKind = {}));
1369
1510
  /** @internal */
1370
1511
  function queryClientFeature(kind, providers) {
@@ -1378,11 +1519,12 @@ function queryClientFeature(kind, providers) {
1378
1519
  * `bootstrapApplication`). A client provided by another environment injector
1379
1520
  * goes with it: once that injector is destroyed, its queries stop, and none
1380
1521
  * of their fetches keeps the app busy. Pass features such as
1381
- * {@link withDefaultOptions} to configure defaults. Registering the same
1382
- * feature twice throws.
1522
+ * {@link withDefaultOptions} to configure defaults, or {@link withHydration}
1523
+ * to bring the data of a server render over to the browser. Registering the
1524
+ * same feature twice throws.
1383
1525
  *
1384
1526
  * @param features - Optional {@link QueryClientFeature}s (e.g.
1385
- * {@link withDefaultOptions}).
1527
+ * {@link withDefaultOptions}, {@link withHydration}).
1386
1528
  * @returns Environment providers to add to the app's provider list.
1387
1529
  *
1388
1530
  * @example
@@ -2222,6 +2364,47 @@ function withDefaultOptions(options) {
2222
2364
  ]);
2223
2365
  }
2224
2366
 
2367
+ /**
2368
+ * Feature for {@link provideQueryClient} that brings the data a server render
2369
+ * fetched over to the browser, so the page shows it at once rather than a
2370
+ * loading state. Add it to the providers the server and the browser share. It
2371
+ * works with Angular's `provideClientHydration()` or without it.
2372
+ *
2373
+ * Only queries that succeeded go over, and only data JSON carries as is:
2374
+ * strings, numbers, booleans, `null`, plain objects and arrays. A query whose
2375
+ * data holds a `Date`, a `Map` or a class instance stays on the server, and
2376
+ * the browser fetches it again; in development the server logs a warning
2377
+ * naming it.
2378
+ *
2379
+ * In the browser the data counts as fetched when the server fetched it, and
2380
+ * `staleTime` decides as for any data in the cache: with the default of `0`,
2381
+ * a component refetches its query in the background as it appears, showing
2382
+ * the server's data meanwhile. Set a `staleTime` to skip that request.
2383
+ *
2384
+ * Give it to one client per app: clients provided by other environment
2385
+ * injectors would share one place in the page, and only one of them would get
2386
+ * its data over.
2387
+ *
2388
+ * @returns A {@link QueryClientFeature} to pass to {@link provideQueryClient}.
2389
+ *
2390
+ * @example
2391
+ * ```ts
2392
+ * export const appConfig: ApplicationConfig = {
2393
+ * providers: [
2394
+ * provideQueryClient(
2395
+ * withDefaultOptions({ queries: { staleTime: 30_000 } }),
2396
+ * withHydration(),
2397
+ * ),
2398
+ * ],
2399
+ * }
2400
+ * ```
2401
+ */
2402
+ function withHydration() {
2403
+ return queryClientFeature(QueryClientFeatureKind.Hydration, [
2404
+ { provide: QUERY_CLIENT_HYDRATION, useValue: true },
2405
+ ]);
2406
+ }
2407
+
2225
2408
  /*
2226
2409
  * Public API Surface of ngx-query
2227
2410
  */
@@ -2230,5 +2413,5 @@ function withDefaultOptions(options) {
2230
2413
  * Generated bundle index. Do not edit.
2231
2414
  */
2232
2415
 
2233
- export { CancelledError, QueryClient, QueryClientFeatureKind, injectIsFetching, injectIsMutating, injectMutation, injectQueries, injectQuery, injectQueryClient, keepPreviousData, mutationOptions, provideQueryClient, queryOptions, withDefaultOptions };
2416
+ export { CancelledError, QueryClient, QueryClientFeatureKind, injectIsFetching, injectIsMutating, injectMutation, injectQueries, injectQuery, injectQueryClient, keepPreviousData, mutationOptions, provideQueryClient, queryOptions, withDefaultOptions, withHydration };
2234
2417
  //# sourceMappingURL=ngx-signal-query.mjs.map