@kontsedal/olas-core 0.0.1-rc.1 → 0.0.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/dist/index.cjs +2 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -2
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +13 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/index.mjs.map +1 -1
- package/dist/{root-BImHnGj1.mjs → root-BCZDC5Fv.mjs} +442 -139
- package/dist/root-BCZDC5Fv.mjs.map +1 -0
- package/dist/{root-Bazp5_Ik.cjs → root-DXV1gVbQ.cjs} +447 -138
- package/dist/root-DXV1gVbQ.cjs.map +1 -0
- package/dist/testing.cjs +1 -1
- package/dist/testing.d.cts +1 -1
- package/dist/testing.d.mts +1 -1
- package/dist/testing.mjs +1 -1
- package/dist/{types-CAMgqCMz.d.mts → types-CffZ1QXt.d.cts} +82 -10
- package/dist/types-CffZ1QXt.d.cts.map +1 -0
- package/dist/{types-emq_lZd7.d.cts → types-DSlDowpE.d.mts} +82 -10
- package/dist/types-DSlDowpE.d.mts.map +1 -0
- package/package.json +1 -1
- package/src/controller/instance.ts +115 -15
- package/src/controller/root.ts +9 -1
- package/src/controller/types.ts +17 -7
- package/src/forms/field.ts +73 -8
- package/src/forms/form-types.ts +16 -0
- package/src/forms/form.ts +171 -21
- package/src/index.ts +5 -0
- package/src/query/client.ts +161 -6
- package/src/query/define.ts +14 -0
- package/src/query/entry.ts +64 -42
- package/src/query/infinite.ts +77 -55
- package/src/query/mutation.ts +11 -21
- package/src/query/plugin.ts +50 -0
- package/src/query/use.ts +80 -3
- package/src/utils.ts +24 -0
- package/dist/root-BImHnGj1.mjs.map +0 -1
- package/dist/root-Bazp5_Ik.cjs.map +0 -1
- package/dist/types-CAMgqCMz.d.mts.map +0 -1
- package/dist/types-emq_lZd7.d.cts.map +0 -1
package/src/query/client.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { DevtoolsEmitter } from '../devtools'
|
|
2
2
|
import { dispatchError, type ErrorHandler } from '../errors'
|
|
3
3
|
import { type Signal, signal } from '../signals'
|
|
4
|
+
import { isAbortError } from '../utils'
|
|
4
5
|
import { Entry } from './entry'
|
|
5
6
|
import { subscribeReconnect, subscribeWindowFocus } from './focus-online'
|
|
6
7
|
import { InfiniteEntry, type InfiniteQuery, type InfiniteQuerySpec } from './infinite'
|
|
@@ -51,7 +52,16 @@ export class ClientEntry<T> {
|
|
|
51
52
|
callArgs: readonly unknown[],
|
|
52
53
|
keyArgs: readonly unknown[],
|
|
53
54
|
spec: QuerySpec<any, T>,
|
|
54
|
-
hydrated
|
|
55
|
+
hydrated: { data: T; lastUpdatedAt: number } | undefined,
|
|
56
|
+
/**
|
|
57
|
+
* Prepared by `QueryClient.bindEntry` (which is the only construction
|
|
58
|
+
* site). When the query has a `queryId`, the closure calls back into
|
|
59
|
+
* `QueryClient.emitSetData` with `source: 'fetch'` after every
|
|
60
|
+
* successful fetch. We accept it pre-built rather than reach into the
|
|
61
|
+
* client from here because `emitSetData` is `private` on `QueryClient`
|
|
62
|
+
* — restricting the access path is the whole point.
|
|
63
|
+
*/
|
|
64
|
+
onFetchSuccess: ((data: T) => void) | undefined,
|
|
55
65
|
) {
|
|
56
66
|
this.client = client
|
|
57
67
|
this.query = query
|
|
@@ -87,6 +97,7 @@ export class ClientEntry<T> {
|
|
|
87
97
|
}),
|
|
88
98
|
}
|
|
89
99
|
: undefined,
|
|
100
|
+
onSuccessData: onFetchSuccess,
|
|
90
101
|
})
|
|
91
102
|
}
|
|
92
103
|
|
|
@@ -218,6 +229,13 @@ export class InfiniteClientEntry<TPage, TItem, PageParam> {
|
|
|
218
229
|
callArgs: readonly unknown[],
|
|
219
230
|
keyArgs: readonly unknown[],
|
|
220
231
|
spec: InfiniteQuerySpec<any, PageParam, TPage, TItem>,
|
|
232
|
+
/**
|
|
233
|
+
* Prepared by `QueryClient.bindInfiniteEntry`. When the infinite query
|
|
234
|
+
* has a `queryId`, the closure calls back into `QueryClient.emitSetData`
|
|
235
|
+
* with `kind: 'infinite', source: 'fetch'` after every successful page
|
|
236
|
+
* write (initial, next, prev). Mirrors `ClientEntry.onFetchSuccess`.
|
|
237
|
+
*/
|
|
238
|
+
onFetchSuccess: ((pages: TPage[]) => void) | undefined,
|
|
221
239
|
) {
|
|
222
240
|
this.client = client
|
|
223
241
|
this.query = query
|
|
@@ -237,6 +255,11 @@ export class InfiniteClientEntry<TPage, TItem, PageParam> {
|
|
|
237
255
|
staleTime: spec.staleTime,
|
|
238
256
|
retry: spec.retry as RetryPolicy | undefined,
|
|
239
257
|
retryDelay: spec.retryDelay as RetryDelay | undefined,
|
|
258
|
+
// Fire SetDataEvent { kind: 'infinite', source: 'fetch' } whenever a
|
|
259
|
+
// fetch settles successfully. Plugins (e.g. entity normalization) use
|
|
260
|
+
// this to walk the pages and update their normalized stores. Mirrors
|
|
261
|
+
// the regular `ClientEntry`'s `onFetchSuccess` closure plumbing.
|
|
262
|
+
onSuccessData: onFetchSuccess,
|
|
240
263
|
})
|
|
241
264
|
}
|
|
242
265
|
|
|
@@ -394,6 +417,9 @@ export class QueryClient {
|
|
|
394
417
|
applyRemoteInvalidate(queryId, keyArgs) {
|
|
395
418
|
self.applyRemoteInvalidate(queryId, keyArgs)
|
|
396
419
|
},
|
|
420
|
+
setEntryData(queryId, keyArgs, updater) {
|
|
421
|
+
self.setEntryData(queryId, keyArgs, updater)
|
|
422
|
+
},
|
|
397
423
|
subscribedKeys(queryId) {
|
|
398
424
|
return self.subscribedKeysFor(queryId)
|
|
399
425
|
},
|
|
@@ -412,11 +438,29 @@ export class QueryClient {
|
|
|
412
438
|
}
|
|
413
439
|
}
|
|
414
440
|
|
|
441
|
+
/**
|
|
442
|
+
* Emit a `SetDataEvent` to every installed plugin. The `source` field
|
|
443
|
+
* tells layered plugins where the write originated:
|
|
444
|
+
* - `'set'`: explicit `client.setData`, including mutations and plugin-
|
|
445
|
+
* initiated `setEntryData` calls (e.g. entity backpropagation).
|
|
446
|
+
* - `'fetch'`: a query fetcher resolved successfully (`Entry.applySuccess`
|
|
447
|
+
* reaches this through `onSuccessData`), or hydrated data was first
|
|
448
|
+
* bound (a per-tab arrival of pre-fetched data; cross-tab skips
|
|
449
|
+
* `'fetch'` so this stays a per-tab concern).
|
|
450
|
+
* - `'remote'`: `applyRemoteSetData` — cross-tab / server-push. Mirrors
|
|
451
|
+
* `isRemote === true`.
|
|
452
|
+
*
|
|
453
|
+
* Private — fetcher-success emission goes through the `onFetchSuccess`
|
|
454
|
+
* closure that `bindEntry` builds and hands to each new `ClientEntry`.
|
|
455
|
+
* Hydrated emission goes through this method directly from `bindEntry`.
|
|
456
|
+
* Mutation / remote paths call it from within QueryClient methods.
|
|
457
|
+
*/
|
|
415
458
|
private emitSetData(
|
|
416
459
|
query: AnyQuery | AnyInfiniteQuery,
|
|
417
460
|
keyArgs: readonly unknown[],
|
|
418
461
|
data: unknown,
|
|
419
462
|
kind: 'data' | 'infinite',
|
|
463
|
+
source: 'set' | 'fetch' | 'remote',
|
|
420
464
|
): void {
|
|
421
465
|
if (this.plugins.length === 0) return
|
|
422
466
|
const queryId = query.__spec.queryId
|
|
@@ -427,6 +471,7 @@ export class QueryClient {
|
|
|
427
471
|
data,
|
|
428
472
|
kind,
|
|
429
473
|
isRemote: this.applyingRemote,
|
|
474
|
+
source,
|
|
430
475
|
}
|
|
431
476
|
for (const plugin of this.plugins) {
|
|
432
477
|
if (plugin.onSetData) {
|
|
@@ -521,12 +566,54 @@ export class QueryClient {
|
|
|
521
566
|
this.applyingRemote = true
|
|
522
567
|
try {
|
|
523
568
|
entry.entry.setData(() => data as never)
|
|
524
|
-
this.emitSetData(internal, entry.keyArgs, data, 'data')
|
|
569
|
+
this.emitSetData(internal, entry.keyArgs, data, 'data', 'remote')
|
|
525
570
|
} finally {
|
|
526
571
|
this.applyingRemote = false
|
|
527
572
|
}
|
|
528
573
|
}
|
|
529
574
|
|
|
575
|
+
/**
|
|
576
|
+
* Local-originated `setData` keyed by `queryId + keyArgs`. Plugin-facing
|
|
577
|
+
* (exposed via `QueryClientPluginApi.setEntryData`); used by the
|
|
578
|
+
* `@kontsedal/olas-entities` plugin to backpropagate entity patches into
|
|
579
|
+
* every query holding the entity, without forcing the plugin to recover
|
|
580
|
+
* the original `callArgs`.
|
|
581
|
+
*
|
|
582
|
+
* Drops silently in the same cases as `applyRemoteSetData` (unknown
|
|
583
|
+
* queryId / infinite query / no local entry). Emits `SetDataEvent` with
|
|
584
|
+
* `isRemote: false`, `source: 'set'` — cross-tab WILL rebroadcast.
|
|
585
|
+
*/
|
|
586
|
+
setEntryData(
|
|
587
|
+
queryId: string,
|
|
588
|
+
keyArgs: readonly unknown[],
|
|
589
|
+
updater: (prev: unknown) => unknown,
|
|
590
|
+
): void {
|
|
591
|
+
const query = lookupRegisteredQuery(queryId)
|
|
592
|
+
if (!query) return
|
|
593
|
+
const hash = stableHash(keyArgs)
|
|
594
|
+
if (query.__olas === 'query') {
|
|
595
|
+
const internal = query as unknown as AnyQuery
|
|
596
|
+
const map = this.maps.get(internal)
|
|
597
|
+
if (!map) return
|
|
598
|
+
const entry = map.get(hash)
|
|
599
|
+
if (!entry) return
|
|
600
|
+
entry.entry.setData(updater as (prev: unknown) => never)
|
|
601
|
+
this.emitSetData(internal, entry.keyArgs, entry.entry.data.peek(), 'data', 'set')
|
|
602
|
+
return
|
|
603
|
+
}
|
|
604
|
+
// Infinite query. The plugin's `SetDataEvent.data` for `kind: 'infinite'`
|
|
605
|
+
// is `TPage[]` (the pages array), so the same path-based walk + write
|
|
606
|
+
// mechanics apply — we just route through `InfiniteEntry.setData` and
|
|
607
|
+
// re-emit with `kind: 'infinite'`.
|
|
608
|
+
const internal = query as unknown as AnyInfiniteQuery
|
|
609
|
+
const map = this.infiniteMaps.get(internal)
|
|
610
|
+
if (!map) return
|
|
611
|
+
const entry = map.get(hash)
|
|
612
|
+
if (!entry) return
|
|
613
|
+
entry.entry.setData(updater as (prev: unknown[] | undefined) => unknown[])
|
|
614
|
+
this.emitSetData(internal, entry.keyArgs, entry.entry.pages.peek(), 'infinite', 'set')
|
|
615
|
+
}
|
|
616
|
+
|
|
530
617
|
applyRemoteInvalidate(queryId: string, keyArgs: readonly unknown[]): void {
|
|
531
618
|
const query = lookupRegisteredQuery(queryId)
|
|
532
619
|
if (!query) return
|
|
@@ -542,6 +629,9 @@ export class QueryClient {
|
|
|
542
629
|
// Emit AFTER kicking off invalidate so plugins reading entry state see
|
|
543
630
|
// post-invalidation values, mirroring setData's emit-after-write order.
|
|
544
631
|
entry.entry.invalidate().catch((err) => {
|
|
632
|
+
// Two rapid invalidates on the same key supersede each other, which
|
|
633
|
+
// raises AbortError. That's not a cache error — swallow.
|
|
634
|
+
if (isAbortError(err)) return
|
|
545
635
|
dispatchError(this.onError, err, {
|
|
546
636
|
kind: 'cache',
|
|
547
637
|
controllerPath: [],
|
|
@@ -555,7 +645,19 @@ export class QueryClient {
|
|
|
555
645
|
}
|
|
556
646
|
|
|
557
647
|
hydrate(state: DehydratedState): void {
|
|
558
|
-
if (state.version !== 1)
|
|
648
|
+
if (state.version !== 1) {
|
|
649
|
+
// Silent drop hid schema-bumped payloads. Warn so a future spec bump is
|
|
650
|
+
// detectable from the client side without code archaeology.
|
|
651
|
+
if (__DEV__) {
|
|
652
|
+
// eslint-disable-next-line no-console
|
|
653
|
+
console.warn(
|
|
654
|
+
'[olas] hydrate(): unsupported state.version =',
|
|
655
|
+
state.version,
|
|
656
|
+
'— expected 1. Dropping payload; cache will fetch fresh.',
|
|
657
|
+
)
|
|
658
|
+
}
|
|
659
|
+
return
|
|
660
|
+
}
|
|
559
661
|
for (const entry of state.entries) {
|
|
560
662
|
const hash = stableHash(entry.key)
|
|
561
663
|
this.hydratedData.set(hash, {
|
|
@@ -653,6 +755,17 @@ export class QueryClient {
|
|
|
653
755
|
if (tasks.length === 0) return
|
|
654
756
|
await Promise.all(tasks)
|
|
655
757
|
}
|
|
758
|
+
// The 100-iteration safety bound exists so a pathological setup that
|
|
759
|
+
// keeps starting new fetches doesn't lock the dehydrate path. Warn so
|
|
760
|
+
// it's surfaced — silent success would let an SSR dehydrate ship an
|
|
761
|
+
// incomplete payload looking like a clean one.
|
|
762
|
+
if (__DEV__) {
|
|
763
|
+
// eslint-disable-next-line no-console
|
|
764
|
+
console.warn(
|
|
765
|
+
'[olas] waitForIdle(): exited via the 100-iteration safety bound — ' +
|
|
766
|
+
'the cache or mutations keep restarting. The dehydrate payload may be incomplete.',
|
|
767
|
+
)
|
|
768
|
+
}
|
|
656
769
|
}
|
|
657
770
|
|
|
658
771
|
bindEntry<Args extends unknown[], T>(query: Query<Args, T>, args: Args): ClientEntry<T> {
|
|
@@ -670,12 +783,40 @@ export class QueryClient {
|
|
|
670
783
|
if (!entry) {
|
|
671
784
|
const hydrated = this.hydratedData.get(hash) as { data: T; lastUpdatedAt: number } | undefined
|
|
672
785
|
if (hydrated) this.hydratedData.delete(hash)
|
|
673
|
-
|
|
786
|
+
// Build the fetcher-success emitter here so `emitSetData` can stay
|
|
787
|
+
// `private` — `ClientEntry` doesn't reach back into the client to call
|
|
788
|
+
// it; the closure captures (query, keyArgs, this) in this scope and
|
|
789
|
+
// is consumed by `Entry.onSuccessData` from inside `applySuccess`.
|
|
790
|
+
const onFetchSuccess: ((data: T) => void) | undefined =
|
|
791
|
+
internal.__spec.queryId != null
|
|
792
|
+
? (data) => this.emitSetData(internal, keyArgs, data, 'data', 'fetch')
|
|
793
|
+
: undefined
|
|
794
|
+
entry = new ClientEntry<T>(
|
|
795
|
+
this,
|
|
796
|
+
internal,
|
|
797
|
+
args,
|
|
798
|
+
keyArgs,
|
|
799
|
+
internal.__spec,
|
|
800
|
+
hydrated,
|
|
801
|
+
onFetchSuccess,
|
|
802
|
+
)
|
|
674
803
|
map.set(hash, entry as ClientEntry<unknown>)
|
|
675
804
|
// The entry is created without an immediate subscriber (callers like
|
|
676
805
|
// `prefetch`/`setData`/`invalidate` reach `bindEntry` first; subscribing
|
|
677
806
|
// callers then call `acquire()` right after, which clears the gc timer).
|
|
678
807
|
entry.scheduleGcIfOrphan()
|
|
808
|
+
// Hydrated data lands in `initialData` on the new Entry — `applySuccess`
|
|
809
|
+
// is never called, so plugins observing fetch results (entities, ...)
|
|
810
|
+
// would otherwise miss every hydrated row. Emit a SetDataEvent with
|
|
811
|
+
// `source: 'fetch'` here once the entry is registered (and the plugin
|
|
812
|
+
// is already init'd, since the QueryClient constructor runs hydrate →
|
|
813
|
+
// plugin init before any subscriber can reach bindEntry). The fetch
|
|
814
|
+
// source is correct for layered consumers: each tab hydrates
|
|
815
|
+
// independently, so cross-tab's "skip 'fetch'" gate continues to do
|
|
816
|
+
// the right thing.
|
|
817
|
+
if (hydrated !== undefined) {
|
|
818
|
+
this.emitSetData(internal, keyArgs, hydrated.data, 'data', 'fetch')
|
|
819
|
+
}
|
|
679
820
|
}
|
|
680
821
|
return entry
|
|
681
822
|
}
|
|
@@ -708,6 +849,7 @@ export class QueryClient {
|
|
|
708
849
|
this.devtools?.emit({ type: 'cache:invalidated', queryKey: keyArgs })
|
|
709
850
|
}
|
|
710
851
|
entry.entry.invalidate().catch((err) => {
|
|
852
|
+
if (isAbortError(err)) return
|
|
711
853
|
dispatchError(this.onError, err, {
|
|
712
854
|
kind: 'cache',
|
|
713
855
|
controllerPath: [],
|
|
@@ -727,6 +869,7 @@ export class QueryClient {
|
|
|
727
869
|
this.devtools?.emit({ type: 'cache:invalidated', queryKey: entry.keyArgs })
|
|
728
870
|
}
|
|
729
871
|
entry.entry.invalidate().catch((err) => {
|
|
872
|
+
if (isAbortError(err)) return
|
|
730
873
|
dispatchError(this.onError, err, {
|
|
731
874
|
kind: 'cache',
|
|
732
875
|
controllerPath: [],
|
|
@@ -747,7 +890,7 @@ export class QueryClient {
|
|
|
747
890
|
// Read the post-update value to broadcast — plugins want the new state,
|
|
748
891
|
// not the updater function (which would be uncloneable across
|
|
749
892
|
// BroadcastChannel).
|
|
750
|
-
this.emitSetData(entry.query, entry.keyArgs, entry.entry.data.peek(), 'data')
|
|
893
|
+
this.emitSetData(entry.query, entry.keyArgs, entry.entry.data.peek(), 'data', 'set')
|
|
751
894
|
return snapshot
|
|
752
895
|
}
|
|
753
896
|
|
|
@@ -767,12 +910,22 @@ export class QueryClient {
|
|
|
767
910
|
const hash = stableHash(keyArgs)
|
|
768
911
|
let entry = map.get(hash) as InfiniteClientEntry<TPage, TItem, unknown> | undefined
|
|
769
912
|
if (!entry) {
|
|
913
|
+
// Mirror the regular-query plumbing in `bindEntry`: build the
|
|
914
|
+
// SetDataEvent emitter here so `emitSetData` stays private. The
|
|
915
|
+
// closure captures (query, keyArgs, this) and is consumed by
|
|
916
|
+
// `InfiniteEntry.onSuccessData` from inside each successful page
|
|
917
|
+
// batch (initial, next, prev).
|
|
918
|
+
const onFetchSuccess: ((pages: TPage[]) => void) | undefined =
|
|
919
|
+
internal.__spec.queryId != null
|
|
920
|
+
? (pages) => this.emitSetData(internal, keyArgs, pages, 'infinite', 'fetch')
|
|
921
|
+
: undefined
|
|
770
922
|
entry = new InfiniteClientEntry<TPage, TItem, unknown>(
|
|
771
923
|
this,
|
|
772
924
|
internal,
|
|
773
925
|
args,
|
|
774
926
|
keyArgs,
|
|
775
927
|
internal.__spec,
|
|
928
|
+
onFetchSuccess,
|
|
776
929
|
)
|
|
777
930
|
map.set(hash, entry as InfiniteClientEntry<unknown, unknown, unknown>)
|
|
778
931
|
entry.scheduleGcIfOrphan()
|
|
@@ -805,6 +958,7 @@ export class QueryClient {
|
|
|
805
958
|
const entry = map.get(hash)
|
|
806
959
|
if (!entry) return
|
|
807
960
|
entry.entry.invalidate().catch((err) => {
|
|
961
|
+
if (isAbortError(err)) return
|
|
808
962
|
dispatchError(this.onError, err, {
|
|
809
963
|
kind: 'cache',
|
|
810
964
|
controllerPath: [],
|
|
@@ -820,6 +974,7 @@ export class QueryClient {
|
|
|
820
974
|
if (!map) return
|
|
821
975
|
for (const entry of map.values()) {
|
|
822
976
|
entry.entry.invalidate().catch((err) => {
|
|
977
|
+
if (isAbortError(err)) return
|
|
823
978
|
dispatchError(this.onError, err, {
|
|
824
979
|
kind: 'cache',
|
|
825
980
|
controllerPath: [],
|
|
@@ -837,7 +992,7 @@ export class QueryClient {
|
|
|
837
992
|
): Snapshot {
|
|
838
993
|
const entry = this.bindInfiniteEntry(query, args)
|
|
839
994
|
const snapshot = entry.entry.setData(updater)
|
|
840
|
-
this.emitSetData(entry.query, entry.keyArgs, entry.entry.pages.peek(), 'infinite')
|
|
995
|
+
this.emitSetData(entry.query, entry.keyArgs, entry.entry.pages.peek(), 'infinite', 'set')
|
|
841
996
|
return snapshot
|
|
842
997
|
}
|
|
843
998
|
|
package/src/query/define.ts
CHANGED
|
@@ -72,6 +72,13 @@ export function defineQuery<Args extends unknown[], T>(spec: QuerySpec<Args, T>)
|
|
|
72
72
|
if (!first) {
|
|
73
73
|
return Promise.reject(new Error('[olas] prefetch called before any root has subscribed'))
|
|
74
74
|
}
|
|
75
|
+
if (__DEV__ && clients.size > 1) {
|
|
76
|
+
// eslint-disable-next-line no-console
|
|
77
|
+
console.warn(
|
|
78
|
+
'[olas] query.prefetch() is ambiguous when multiple roots are registered; ' +
|
|
79
|
+
'using an arbitrary root. Call `root.prefetch(query, args)` (or per-root) to be explicit.',
|
|
80
|
+
)
|
|
81
|
+
}
|
|
75
82
|
return first.prefetch(query as Query<Args, T>, args)
|
|
76
83
|
},
|
|
77
84
|
} satisfies QueryInternal<Args, T>
|
|
@@ -145,6 +152,13 @@ export function defineInfiniteQuery<Args extends unknown[], PageParam, TPage, TI
|
|
|
145
152
|
if (!first) {
|
|
146
153
|
return Promise.reject(new Error('[olas] prefetch called before any root has subscribed'))
|
|
147
154
|
}
|
|
155
|
+
if (__DEV__ && clients.size > 1) {
|
|
156
|
+
// eslint-disable-next-line no-console
|
|
157
|
+
console.warn(
|
|
158
|
+
'[olas] infiniteQuery.prefetch() is ambiguous when multiple roots are registered; ' +
|
|
159
|
+
'using an arbitrary root. Call `root.prefetch(query, args)` (or per-root) to be explicit.',
|
|
160
|
+
)
|
|
161
|
+
}
|
|
148
162
|
return first.prefetchInfinite(query as InfiniteQuery<Args, TPage, TItem>, args)
|
|
149
163
|
},
|
|
150
164
|
} satisfies InfiniteQueryInternal<Args, TPage, TItem>
|
package/src/query/entry.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { batch, type Signal, signal } from '../signals'
|
|
2
|
-
import { isAbortError } from '../utils'
|
|
2
|
+
import { abortableSleep, isAbortError } from '../utils'
|
|
3
3
|
import type { AsyncStatus, RetryDelay, RetryPolicy, Snapshot } from './types'
|
|
4
4
|
|
|
5
5
|
export type EntryEvents = {
|
|
@@ -16,6 +16,21 @@ export type EntryOptions<T> = {
|
|
|
16
16
|
retry?: RetryPolicy
|
|
17
17
|
retryDelay?: RetryDelay
|
|
18
18
|
events?: EntryEvents
|
|
19
|
+
/**
|
|
20
|
+
* Fired after a successful fetch result is written to `data`. Used by the
|
|
21
|
+
* `QueryClient` to emit a plugin-visible `SetDataEvent` with
|
|
22
|
+
* `source: 'fetch'` (devtools events live on `events` above). Distinct
|
|
23
|
+
* from `events.onFetchSuccess`, which carries timing info for the
|
|
24
|
+
* devtools bus.
|
|
25
|
+
*
|
|
26
|
+
* Privileged closure — set up by `ClientEntry` to call
|
|
27
|
+
* `client.emitSetData(...)`, which already individually try/catches every
|
|
28
|
+
* plugin via `callPlugin` and routes thrown exceptions through `onError`
|
|
29
|
+
* with `kind: 'plugin'`. Not wrapped here; an exception escaping this
|
|
30
|
+
* callback is a programming error in core (not a plugin) and SHOULD
|
|
31
|
+
* surface so the bug is visible.
|
|
32
|
+
*/
|
|
33
|
+
onSuccessData?: (data: T) => void
|
|
19
34
|
}
|
|
20
35
|
|
|
21
36
|
type SnapshotRecord<T> = {
|
|
@@ -51,7 +66,16 @@ export class Entry<T> {
|
|
|
51
66
|
private nextSnapshotId = 0
|
|
52
67
|
private disposed = false
|
|
53
68
|
private readonly events: EntryEvents
|
|
69
|
+
// Stored at `unknown` (not `T`) to keep `Entry<T>` covariant in `T`. The
|
|
70
|
+
// callback only forwards the value through; Entry never inspects it.
|
|
71
|
+
private readonly onSuccessData: ((data: unknown) => void) | undefined
|
|
54
72
|
private fetchStartTime = 0
|
|
73
|
+
/**
|
|
74
|
+
* Promises returned by `firstValue()` that haven't settled. Rejected on
|
|
75
|
+
* `dispose()` so awaiters (most notably `prefetch` and `subscription.firstValue`)
|
|
76
|
+
* don't hang when the controller tree is torn down mid-fetch.
|
|
77
|
+
*/
|
|
78
|
+
private pendingFirstValueRejects: Array<(err: unknown) => void> = []
|
|
55
79
|
|
|
56
80
|
constructor(options: EntryOptions<T>) {
|
|
57
81
|
this.fetcherProvider = options.fetcher
|
|
@@ -59,11 +83,31 @@ export class Entry<T> {
|
|
|
59
83
|
this.retry = options.retry ?? 0
|
|
60
84
|
this.retryDelay = options.retryDelay ?? 1000
|
|
61
85
|
this.events = options.events ?? {}
|
|
86
|
+
this.onSuccessData = options.onSuccessData as ((data: unknown) => void) | undefined
|
|
62
87
|
this.data = signal<T | undefined>(options.initialData)
|
|
63
88
|
if (options.initialData !== undefined) {
|
|
64
89
|
this.status = signal<AsyncStatus>('success')
|
|
65
|
-
|
|
66
|
-
|
|
90
|
+
// For hydrated data, derive `isStale` from the *actual* age of the
|
|
91
|
+
// payload, not the timer alone — otherwise a payload older than
|
|
92
|
+
// `staleTime` would read `isStale === false` until the (fresh, full-
|
|
93
|
+
// length) timer fires. `isStaleNow()` already does this correctly for
|
|
94
|
+
// the subscribe-time refetch check; mirror that here for the signal.
|
|
95
|
+
if (this.staleTime === 0) {
|
|
96
|
+
this.isStale.set(true)
|
|
97
|
+
} else {
|
|
98
|
+
const last = options.initialUpdatedAt
|
|
99
|
+
const alreadyStale = last === undefined || Date.now() - last >= this.staleTime
|
|
100
|
+
this.isStale.set(alreadyStale)
|
|
101
|
+
// Only schedule a timer if the data isn't already stale. If it is,
|
|
102
|
+
// there's nothing to wait for.
|
|
103
|
+
if (!alreadyStale) {
|
|
104
|
+
const remaining = this.staleTime - (Date.now() - (last as number))
|
|
105
|
+
this.staleTimer = setTimeout(() => {
|
|
106
|
+
this.staleTimer = null
|
|
107
|
+
if (!this.disposed) this.isStale.set(true)
|
|
108
|
+
}, remaining)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
67
111
|
} else {
|
|
68
112
|
this.status = signal<AsyncStatus>('idle')
|
|
69
113
|
}
|
|
@@ -151,6 +195,7 @@ export class Entry<T> {
|
|
|
151
195
|
} catch {
|
|
152
196
|
// devtools handlers must not break the program.
|
|
153
197
|
}
|
|
198
|
+
this.onSuccessData?.(result)
|
|
154
199
|
return result
|
|
155
200
|
}
|
|
156
201
|
|
|
@@ -241,19 +286,10 @@ export class Entry<T> {
|
|
|
241
286
|
}
|
|
242
287
|
}
|
|
243
288
|
|
|
244
|
-
finalizeSnapshot(snapshot: Snapshot): void {
|
|
245
|
-
const id = snapshotIds.get(snapshot)
|
|
246
|
-
if (id === undefined) return
|
|
247
|
-
const record = this.snapshots.find((s) => s.live && s.id === id)
|
|
248
|
-
if (!record) return
|
|
249
|
-
record.live = false
|
|
250
|
-
this.snapshots = this.snapshots.filter((s) => s !== record)
|
|
251
|
-
if (!this.snapshots.some((s) => s.live)) {
|
|
252
|
-
this.hasPendingMutations.set(false)
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
289
|
firstValue(): Promise<T> {
|
|
290
|
+
if (this.disposed) {
|
|
291
|
+
return Promise.reject(new DOMException('Entry disposed', 'AbortError'))
|
|
292
|
+
}
|
|
257
293
|
if (this.status.peek() === 'success') {
|
|
258
294
|
return Promise.resolve(this.data.peek() as T)
|
|
259
295
|
}
|
|
@@ -261,13 +297,19 @@ export class Entry<T> {
|
|
|
261
297
|
return Promise.reject(this.error.peek())
|
|
262
298
|
}
|
|
263
299
|
return new Promise<T>((resolve, reject) => {
|
|
300
|
+
const tracked = (err: unknown): void => {
|
|
301
|
+
this.pendingFirstValueRejects = this.pendingFirstValueRejects.filter((f) => f !== tracked)
|
|
302
|
+
reject(err)
|
|
303
|
+
}
|
|
304
|
+
this.pendingFirstValueRejects.push(tracked)
|
|
264
305
|
const unsub = this.status.subscribe((s) => {
|
|
265
306
|
if (s === 'success') {
|
|
266
307
|
unsub()
|
|
308
|
+
this.pendingFirstValueRejects = this.pendingFirstValueRejects.filter((f) => f !== tracked)
|
|
267
309
|
resolve(this.data.peek() as T)
|
|
268
310
|
} else if (s === 'error') {
|
|
269
311
|
unsub()
|
|
270
|
-
|
|
312
|
+
tracked(this.error.peek())
|
|
271
313
|
}
|
|
272
314
|
})
|
|
273
315
|
})
|
|
@@ -292,31 +334,11 @@ export class Entry<T> {
|
|
|
292
334
|
}
|
|
293
335
|
this.currentAbort?.abort()
|
|
294
336
|
this.currentAbort = null
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
export function tagSnapshot(snapshot: Snapshot, id: number): Snapshot {
|
|
301
|
-
snapshotIds.set(snapshot, id)
|
|
302
|
-
return snapshot
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
306
|
-
return new Promise((resolve, reject) => {
|
|
307
|
-
if (signal.aborted) {
|
|
308
|
-
reject(new DOMException('Aborted', 'AbortError'))
|
|
309
|
-
return
|
|
337
|
+
if (this.pendingFirstValueRejects.length > 0) {
|
|
338
|
+
const disposed = new DOMException('Entry disposed', 'AbortError')
|
|
339
|
+
const rejects = this.pendingFirstValueRejects
|
|
340
|
+
this.pendingFirstValueRejects = []
|
|
341
|
+
for (const fn of rejects) fn(disposed)
|
|
310
342
|
}
|
|
311
|
-
|
|
312
|
-
signal.removeEventListener('abort', onAbort)
|
|
313
|
-
resolve()
|
|
314
|
-
}, ms)
|
|
315
|
-
const onAbort = () => {
|
|
316
|
-
clearTimeout(timer)
|
|
317
|
-
signal.removeEventListener('abort', onAbort)
|
|
318
|
-
reject(new DOMException('Aborted', 'AbortError'))
|
|
319
|
-
}
|
|
320
|
-
signal.addEventListener('abort', onAbort, { once: true })
|
|
321
|
-
})
|
|
343
|
+
}
|
|
322
344
|
}
|