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 +3 -2
- package/fesm2022/ngx-signal-query.mjs +232 -49
- package/fesm2022/ngx-signal-query.mjs.map +1 -1
- package/index.d.ts +49 -9
- package/package.json +1 -1
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,
|
|
36
|
-
|
|
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
|
-
|
|
708
|
-
|
|
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
|
|
1382
|
-
*
|
|
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
|