@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.
Files changed (40) hide show
  1. package/dist/index.cjs +2 -1
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +13 -2
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +13 -2
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +2 -2
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/{root-BImHnGj1.mjs → root-BCZDC5Fv.mjs} +442 -139
  10. package/dist/root-BCZDC5Fv.mjs.map +1 -0
  11. package/dist/{root-Bazp5_Ik.cjs → root-DXV1gVbQ.cjs} +447 -138
  12. package/dist/root-DXV1gVbQ.cjs.map +1 -0
  13. package/dist/testing.cjs +1 -1
  14. package/dist/testing.d.cts +1 -1
  15. package/dist/testing.d.mts +1 -1
  16. package/dist/testing.mjs +1 -1
  17. package/dist/{types-CAMgqCMz.d.mts → types-CffZ1QXt.d.cts} +82 -10
  18. package/dist/types-CffZ1QXt.d.cts.map +1 -0
  19. package/dist/{types-emq_lZd7.d.cts → types-DSlDowpE.d.mts} +82 -10
  20. package/dist/types-DSlDowpE.d.mts.map +1 -0
  21. package/package.json +1 -1
  22. package/src/controller/instance.ts +115 -15
  23. package/src/controller/root.ts +9 -1
  24. package/src/controller/types.ts +17 -7
  25. package/src/forms/field.ts +73 -8
  26. package/src/forms/form-types.ts +16 -0
  27. package/src/forms/form.ts +171 -21
  28. package/src/index.ts +5 -0
  29. package/src/query/client.ts +161 -6
  30. package/src/query/define.ts +14 -0
  31. package/src/query/entry.ts +64 -42
  32. package/src/query/infinite.ts +77 -55
  33. package/src/query/mutation.ts +11 -21
  34. package/src/query/plugin.ts +50 -0
  35. package/src/query/use.ts +80 -3
  36. package/src/utils.ts +24 -0
  37. package/dist/root-BImHnGj1.mjs.map +0 -1
  38. package/dist/root-Bazp5_Ik.cjs.map +0 -1
  39. package/dist/types-CAMgqCMz.d.mts.map +0 -1
  40. package/dist/types-emq_lZd7.d.cts.map +0 -1
@@ -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?: { data: T; lastUpdatedAt: number },
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) return
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
- entry = new ClientEntry<T>(this, internal, args, keyArgs, internal.__spec, hydrated)
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
 
@@ -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>
@@ -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
- this.scheduleStaleness()
66
- this.isStale.set(this.staleTime === 0)
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
- reject(this.error.peek())
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
- const snapshotIds = new WeakMap<Snapshot, number>()
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
- const timer = setTimeout(() => {
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
  }