@kontsedal/olas-core 0.0.1-rc.1 → 0.0.2

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 (45) hide show
  1. package/dist/index.cjs +40 -10
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +32 -11
  4. package/dist/index.d.cts.map +1 -1
  5. package/dist/index.d.mts +32 -11
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +40 -11
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/{root-BImHnGj1.mjs → root-De-6KWIZ.mjs} +750 -149
  10. package/dist/root-De-6KWIZ.mjs.map +1 -0
  11. package/dist/{root-Bazp5_Ik.cjs → root-XKEsSmcd.cjs} +755 -148
  12. package/dist/root-XKEsSmcd.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-C-zV1JZA.d.mts} +215 -13
  18. package/dist/types-C-zV1JZA.d.mts.map +1 -0
  19. package/dist/{types-emq_lZd7.d.cts → types-DKfpkm17.d.cts} +215 -13
  20. package/dist/types-DKfpkm17.d.cts.map +1 -0
  21. package/package.json +1 -1
  22. package/src/controller/index.ts +6 -0
  23. package/src/controller/instance.ts +432 -18
  24. package/src/controller/root.ts +9 -1
  25. package/src/controller/types.ts +148 -7
  26. package/src/emitter.ts +34 -3
  27. package/src/forms/field.ts +73 -8
  28. package/src/forms/form-types.ts +16 -0
  29. package/src/forms/form.ts +218 -26
  30. package/src/index.ts +12 -1
  31. package/src/query/client.ts +161 -6
  32. package/src/query/define.ts +14 -0
  33. package/src/query/entry.ts +64 -42
  34. package/src/query/infinite.ts +77 -55
  35. package/src/query/mutation.ts +11 -21
  36. package/src/query/plugin.ts +50 -0
  37. package/src/query/use.ts +80 -3
  38. package/src/signals/readonly.ts +3 -3
  39. package/src/timing/debounced.ts +24 -4
  40. package/src/timing/throttled.ts +22 -3
  41. package/src/utils.ts +32 -4
  42. package/dist/root-BImHnGj1.mjs.map +0 -1
  43. package/dist/root-Bazp5_Ik.cjs.map +0 -1
  44. package/dist/types-CAMgqCMz.d.mts.map +0 -1
  45. package/dist/types-emq_lZd7.d.cts.map +0 -1
@@ -2,7 +2,12 @@ import type { DevtoolsEmitter } from '../devtools'
2
2
  import { createEmitter, type Emitter } from '../emitter'
3
3
  import { dispatchError, type ErrorHandler } from '../errors'
4
4
  import { bindFieldDevtoolsOwner, createField } from '../forms/field'
5
- import { bindTreeToDevtools, createFieldArray, createForm } from '../forms/form'
5
+ import {
6
+ bindTreeToDevtools,
7
+ bindTreeValidatorErrorReporter,
8
+ createFieldArray,
9
+ createForm,
10
+ } from '../forms/form'
6
11
  import type {
7
12
  FieldArray,
8
13
  FieldArrayOptions,
@@ -19,9 +24,19 @@ import { createMutation, type Mutation, type MutationSpec } from '../query/mutat
19
24
  import type { LocalCache, Query } from '../query/types'
20
25
  import { createInfiniteUse, createUse } from '../query/use'
21
26
  import type { Scope } from '../scope'
22
- import { effect as standaloneEffect } from '../signals'
27
+ import { computed, signal, effect as standaloneEffect } from '../signals'
23
28
  import { getFactory, getName } from './define'
24
- import type { ControllerDef, Ctx, Field } from './types'
29
+ import type {
30
+ Collection,
31
+ CollectionFactoryApi,
32
+ CollectionFactoryOptions,
33
+ CollectionFactoryResult,
34
+ CollectionHomogeneousOptions,
35
+ ControllerDef,
36
+ Ctx,
37
+ Field,
38
+ LazyChild,
39
+ } from './types'
25
40
 
26
41
  export type RootShared = {
27
42
  readonly devtools: DevtoolsEmitter
@@ -36,6 +51,17 @@ type LifecycleEntry =
36
51
  dispose: (() => void) | null
37
52
  }
38
53
  | { kind: 'cleanup'; dispose: () => void }
54
+ | {
55
+ /**
56
+ * Cache subscription via `ctx.use`. Suspend/resume call the
57
+ * `suspend`/`resume` hooks so the underlying entry's `refetchInterval`
58
+ * and event listeners pause for the duration. Spec §4.1.
59
+ */
60
+ kind: 'subscription-cache'
61
+ dispose: () => void
62
+ suspend: () => void
63
+ resume: () => void
64
+ }
39
65
  | { kind: 'child'; instance: ControllerInstance }
40
66
  | { kind: 'onDispose'; fn: () => void }
41
67
  | { kind: 'onSuspend'; fn: () => void }
@@ -109,7 +135,6 @@ export class ControllerInstance {
109
135
 
110
136
  dispose(): void {
111
137
  if (this.state === 'disposed') return
112
- const wasSuspended = this.state === 'suspended'
113
138
  this.state = 'disposed'
114
139
 
115
140
  for (let i = this.entries.length - 1; i >= 0; i--) {
@@ -130,8 +155,6 @@ export class ControllerInstance {
130
155
  if (__DEV__) {
131
156
  this.rootShared.devtools.emit({ type: 'controller:disposed', path: this.path })
132
157
  }
133
- // Silence "unused" — `wasSuspended` may inform future logic; intentionally a no-op for now.
134
- void wasSuspended
135
158
  }
136
159
 
137
160
  private disposeEntry(entry: LifecycleEntry): void {
@@ -143,6 +166,9 @@ export class ControllerInstance {
143
166
  case 'cleanup':
144
167
  entry.dispose()
145
168
  break
169
+ case 'subscription-cache':
170
+ entry.dispose()
171
+ break
146
172
  case 'child':
147
173
  entry.instance.dispose()
148
174
  break
@@ -172,6 +198,11 @@ export class ControllerInstance {
172
198
  entry.dispose?.()
173
199
  entry.dispose = null
174
200
  break
201
+ case 'subscription-cache':
202
+ // Pause `refetchInterval` + focus/online listeners + release the
203
+ // entry from this subscriber. Spec §4.1.
204
+ entry.suspend()
205
+ break
175
206
  case 'child':
176
207
  entry.instance.suspend()
177
208
  break
@@ -204,6 +235,11 @@ export class ControllerInstance {
204
235
  case 'effect':
205
236
  entry.dispose = standaloneEffect(entry.factory)
206
237
  break
238
+ case 'subscription-cache':
239
+ // Re-acquire the entry, restart `refetchInterval`, and re-check
240
+ // staleness (a stale entry refetches on resume — spec §4.1).
241
+ entry.resume()
242
+ break
207
243
  case 'child':
208
244
  entry.instance.resume()
209
245
  break
@@ -255,7 +291,12 @@ export class ControllerInstance {
255
291
  }
256
292
  }
257
293
  entry.factory = wrapped
258
- entry.dispose = standaloneEffect(wrapped)
294
+ // If we're suspended, register the entry but defer activation to
295
+ // `resume()` — otherwise the resume loop would overwrite a live
296
+ // `dispose` ref (the just-activated effect), leaking it.
297
+ if (self.state !== 'suspended') {
298
+ entry.dispose = standaloneEffect(wrapped)
299
+ }
259
300
  self.entries.push(entry)
260
301
  },
261
302
 
@@ -271,21 +312,31 @@ export class ControllerInstance {
271
312
  use(query: any, keyOrOptions?: any): any {
272
313
  const brand = (query as { __olas?: string }).__olas
273
314
  if (brand === 'infiniteQuery') {
274
- const { subscription, dispose: d } = createInfiniteUse(
315
+ const handle = createInfiniteUse(
275
316
  self.rootShared.queryClient,
276
317
  query as InfiniteQuery<unknown[], unknown, unknown>,
277
318
  keyOrOptions,
278
319
  )
279
- self.entries.push({ kind: 'cleanup', dispose: d })
280
- return subscription
320
+ self.entries.push({
321
+ kind: 'subscription-cache',
322
+ dispose: handle.dispose,
323
+ suspend: handle.suspend,
324
+ resume: handle.resume,
325
+ })
326
+ return handle.subscription
281
327
  }
282
- const { subscription, dispose: d } = createUse(
328
+ const handle = createUse(
283
329
  self.rootShared.queryClient,
284
330
  query as Query<unknown[], unknown>,
285
331
  keyOrOptions,
286
332
  )
287
- self.entries.push({ kind: 'cleanup', dispose: d })
288
- return subscription
333
+ self.entries.push({
334
+ kind: 'subscription-cache',
335
+ dispose: handle.dispose,
336
+ suspend: handle.suspend,
337
+ resume: handle.resume,
338
+ })
339
+ return handle.subscription
289
340
  },
290
341
 
291
342
  mutation<V, R>(spec: MutationSpec<V, R>): Mutation<V, R> {
@@ -301,13 +352,33 @@ export class ControllerInstance {
301
352
  },
302
353
 
303
354
  emitter<T>(): Emitter<T> {
304
- const e = createEmitter<T>()
355
+ const e = createEmitter<T>({
356
+ // Spec §20.6: emit-time handler throws must not block sibling
357
+ // handlers. Route to the root's onError with kind: 'emitter' and
358
+ // this controller's path.
359
+ onError: (err) => {
360
+ dispatchError(self.rootShared.onError, err, {
361
+ kind: 'emitter',
362
+ controllerPath: self.path,
363
+ })
364
+ },
365
+ })
305
366
  self.entries.push({ kind: 'cleanup', dispose: () => e.dispose() })
306
367
  return e
307
368
  },
308
369
 
309
370
  field<T>(initial: T, validators?: ReadonlyArray<Validator<T>>): Field<T> {
310
- const f = createField(initial, validators)
371
+ // Pass the reporter at construct time so the FIRST validator pass
372
+ // (which runs synchronously in the FieldImpl constructor's
373
+ // validator-effect) is covered.
374
+ const f = createField(initial, validators, {
375
+ onValidatorError: (err) => {
376
+ dispatchError(self.rootShared.onError, err, {
377
+ kind: 'effect',
378
+ controllerPath: self.path,
379
+ })
380
+ },
381
+ })
311
382
  self.entries.push({ kind: 'cleanup', dispose: () => f.dispose() })
312
383
  // Standalone fields (not inside a form) still publish field:validated
313
384
  // events. Use the controller path with field name "(field)" — the
@@ -321,7 +392,13 @@ export class ControllerInstance {
321
392
  },
322
393
 
323
394
  form<S extends FormSchema>(schema: S, options?: FormOptions<S>): Form<S> {
324
- const f = createForm(schema, options)
395
+ const reporter = (err: unknown): void => {
396
+ dispatchError(self.rootShared.onError, err, {
397
+ kind: 'effect',
398
+ controllerPath: self.path,
399
+ })
400
+ }
401
+ const f = createForm(schema, options, { onValidatorError: reporter })
325
402
  self.entries.push({ kind: 'cleanup', dispose: () => f.dispose() })
326
403
  // Make every leaf field publish `field:validated` to the devtools bus
327
404
  // with its key path inside the form. See spec §20.9.
@@ -332,6 +409,12 @@ export class ControllerInstance {
332
409
  self.rootShared.devtools,
333
410
  )
334
411
  self.entries.push({ kind: 'cleanup', dispose: stop })
412
+ // Bind the reporter onto every leaf in the tree too (the form itself
413
+ // got it via the constructor option; nested forms/arrays inside the
414
+ // schema didn't, since they were constructed by the caller before
415
+ // ctx.form ran). Idempotent — leaves that already got the reporter
416
+ // via ctx.field get the same one set again.
417
+ bindTreeValidatorErrorReporter(f as unknown as Form<FormSchema>, reporter)
335
418
  return f
336
419
  },
337
420
 
@@ -339,7 +422,13 @@ export class ControllerInstance {
339
422
  itemFactory: (initial?: ItemInitial<I>) => I,
340
423
  options?: FieldArrayOptions<I>,
341
424
  ): FieldArray<I> {
342
- const fa = createFieldArray<I>(itemFactory, options)
425
+ const reporter = (err: unknown): void => {
426
+ dispatchError(self.rootShared.onError, err, {
427
+ kind: 'effect',
428
+ controllerPath: self.path,
429
+ })
430
+ }
431
+ const fa = createFieldArray<I>(itemFactory, options, { onValidatorError: reporter })
343
432
  self.entries.push({ kind: 'cleanup', dispose: () => fa.dispose() })
344
433
  const stop = bindTreeToDevtools(
345
434
  fa as unknown as FieldArray<Field<unknown> | Form<FormSchema>>,
@@ -348,6 +437,10 @@ export class ControllerInstance {
348
437
  self.rootShared.devtools,
349
438
  )
350
439
  self.entries.push({ kind: 'cleanup', dispose: stop })
440
+ bindTreeValidatorErrorReporter(
441
+ fa as unknown as FieldArray<Field<unknown> | Form<FormSchema>>,
442
+ reporter,
443
+ )
351
444
  return fa
352
445
  },
353
446
 
@@ -407,7 +500,7 @@ export class ControllerInstance {
407
500
  def: ControllerDef<Props, Api>,
408
501
  props: Props,
409
502
  options?: { deps?: Partial<Record<string, unknown>> },
410
- ): { api: Api; dispose: () => void } {
503
+ ): { api: Api; dispose: () => void; suspend: () => void; resume: () => void } {
411
504
  const segment = self.makeChildSegment(getFactory(def), getName(def))
412
505
  const override = options?.deps
413
506
  const childDeps = override !== undefined ? { ...self.deps, ...override } : self.deps
@@ -432,6 +525,327 @@ export class ControllerInstance {
432
525
  })
433
526
  }
434
527
  },
528
+ // Suspend / resume cascade through the child instance's lifecycle
529
+ // entries (same code path as `root.suspend()`); the child's state
530
+ // machine handles the no-op cases (suspending a disposed child,
531
+ // resuming an active child) on its own — no need to track an
532
+ // extra flag here.
533
+ suspend: () => {
534
+ if (disposed) return
535
+ try {
536
+ childInstance.suspend()
537
+ } catch (err) {
538
+ dispatchError(self.rootShared.onError, err, {
539
+ kind: 'effect',
540
+ controllerPath: self.path,
541
+ })
542
+ }
543
+ },
544
+ resume: () => {
545
+ if (disposed) return
546
+ try {
547
+ childInstance.resume()
548
+ } catch (err) {
549
+ dispatchError(self.rootShared.onError, err, {
550
+ kind: 'effect',
551
+ controllerPath: self.path,
552
+ })
553
+ }
554
+ },
555
+ }
556
+ },
557
+
558
+ session<Props, Api>(
559
+ def: ControllerDef<Props, Api>,
560
+ props: Props,
561
+ options?: { deps?: Partial<Record<string, unknown>> },
562
+ ): readonly [Api, () => void] {
563
+ const segment = self.makeChildSegment(getFactory(def), getName(def))
564
+ const override = options?.deps
565
+ const childDeps = override !== undefined ? { ...self.deps, ...override } : self.deps
566
+ const childInstance = new ControllerInstance(self, self.rootShared, segment, childDeps)
567
+ const api = childInstance.construct(getFactory(def), props)
568
+ const entry: LifecycleEntry = { kind: 'child', instance: childInstance }
569
+ self.entries.push(entry)
570
+ let disposed = false
571
+ const dispose = (): void => {
572
+ if (disposed) return
573
+ disposed = true
574
+ const idx = self.entries.indexOf(entry)
575
+ if (idx >= 0) self.entries.splice(idx, 1)
576
+ try {
577
+ childInstance.dispose()
578
+ } catch (err) {
579
+ dispatchError(self.rootShared.onError, err, {
580
+ kind: 'effect',
581
+ controllerPath: self.path,
582
+ })
583
+ }
584
+ }
585
+ return [api, dispose] as const
586
+ },
587
+
588
+ collection<Item, K, Props, Api, R extends CollectionFactoryResult>(
589
+ options:
590
+ | CollectionHomogeneousOptions<Item, K, Props, Api>
591
+ | CollectionFactoryOptions<Item, K, R>,
592
+ ): Collection<K, Api> | Collection<K, CollectionFactoryApi<R>> {
593
+ type ChildInfo = {
594
+ instance: ControllerInstance
595
+ api: Api
596
+ entry: LifecycleEntry
597
+ // For factory form: the controller def used to construct this child.
598
+ // A different def on a future render means "rebuild with new type".
599
+ def: ControllerDef<unknown, unknown>
600
+ }
601
+ const childMap = new Map<K, ChildInfo>()
602
+ const items$ = signal<ReadonlyArray<{ key: K; api: Api }>>([])
603
+ const size$ = computed(() => items$.value.length)
604
+
605
+ const isFactoryForm =
606
+ (options as CollectionFactoryOptions<Item, K, R>).factory !== undefined
607
+
608
+ const buildChild = (
609
+ item: Item,
610
+ ): {
611
+ instance: ControllerInstance
612
+ api: Api
613
+ def: ControllerDef<unknown, unknown>
614
+ } | null => {
615
+ let def: ControllerDef<unknown, unknown>
616
+ let childProps: unknown
617
+ if (isFactoryForm) {
618
+ const factoryOpts = options as CollectionFactoryOptions<Item, K, R>
619
+ const result = factoryOpts.factory(item) as CollectionFactoryResult
620
+ def = result.controller as ControllerDef<unknown, unknown>
621
+ childProps = result.props
622
+ } else {
623
+ const homoOpts = options as CollectionHomogeneousOptions<Item, K, Props, Api>
624
+ def = homoOpts.controller as unknown as ControllerDef<unknown, unknown>
625
+ childProps = homoOpts.propsOf(item)
626
+ }
627
+ const segment = self.makeChildSegment(getFactory(def), getName(def))
628
+ const childDeps =
629
+ options.deps !== undefined ? { ...self.deps, ...options.deps } : self.deps
630
+ const instance = new ControllerInstance(self, self.rootShared, segment, childDeps)
631
+ try {
632
+ const api = instance.construct(
633
+ getFactory(def) as (ctx: Ctx, props: unknown) => Api,
634
+ childProps,
635
+ )
636
+ return { instance, api, def }
637
+ } catch (err) {
638
+ // SPEC §12.1.6: runtime construction errors in collection items
639
+ // route to onError; the bad item is skipped.
640
+ dispatchError(self.rootShared.onError, err, {
641
+ kind: 'construction',
642
+ controllerPath: self.path,
643
+ })
644
+ return null
645
+ }
646
+ }
647
+
648
+ const removeKey = (key: K): void => {
649
+ const info = childMap.get(key)
650
+ if (info === undefined) return
651
+ childMap.delete(key)
652
+ const idx = self.entries.indexOf(info.entry)
653
+ if (idx >= 0) self.entries.splice(idx, 1)
654
+ try {
655
+ info.instance.dispose()
656
+ } catch (err) {
657
+ dispatchError(self.rootShared.onError, err, {
658
+ kind: 'effect',
659
+ controllerPath: self.path,
660
+ })
661
+ }
662
+ }
663
+
664
+ const reconcile = (): void => {
665
+ const source = options.source.value
666
+ const itemByKey = new Map<K, Item>()
667
+ for (const item of source) {
668
+ const key = options.keyOf(item)
669
+ if (!itemByKey.has(key)) itemByKey.set(key, item)
670
+ }
671
+
672
+ // Drop removed keys.
673
+ for (const key of [...childMap.keys()]) {
674
+ if (!itemByKey.has(key)) removeKey(key)
675
+ }
676
+
677
+ // Add new keys + rebuild factory-form type changes.
678
+ for (const [key, item] of itemByKey) {
679
+ const existing = childMap.get(key)
680
+ if (existing !== undefined) {
681
+ if (isFactoryForm) {
682
+ const result = (options as CollectionFactoryOptions<Item, K, R>).factory(
683
+ item,
684
+ ) as CollectionFactoryResult
685
+ if ((result.controller as unknown) !== existing.def) {
686
+ removeKey(key)
687
+ const built = buildChild(item)
688
+ if (built !== null) {
689
+ const entry: LifecycleEntry = { kind: 'child', instance: built.instance }
690
+ self.entries.push(entry)
691
+ childMap.set(key, { ...built, entry })
692
+ }
693
+ }
694
+ }
695
+ continue
696
+ }
697
+ const built = buildChild(item)
698
+ if (built !== null) {
699
+ const entry: LifecycleEntry = { kind: 'child', instance: built.instance }
700
+ self.entries.push(entry)
701
+ childMap.set(key, { ...built, entry })
702
+ }
703
+ }
704
+
705
+ // Project to items signal in source order, deduped, skipping failures.
706
+ const next: Array<{ key: K; api: Api }> = []
707
+ const seen = new Set<K>()
708
+ for (const item of source) {
709
+ const key = options.keyOf(item)
710
+ if (seen.has(key)) continue
711
+ seen.add(key)
712
+ const info = childMap.get(key)
713
+ if (info !== undefined) next.push({ key, api: info.api })
714
+ }
715
+ items$.set(next)
716
+ }
717
+
718
+ // Register the diff loop as an 'effect' entry so it pauses on suspend
719
+ // and re-runs on resume — mirrors how `ctx.effect` is wired.
720
+ const wrapped = (): void => {
721
+ try {
722
+ reconcile()
723
+ } catch (err) {
724
+ dispatchError(self.rootShared.onError, err, {
725
+ kind: 'effect',
726
+ controllerPath: self.path,
727
+ })
728
+ }
729
+ }
730
+ const effectEntry: LifecycleEntry = {
731
+ kind: 'effect',
732
+ factory: wrapped,
733
+ dispose: null,
734
+ }
735
+ if (self.state !== 'suspended') {
736
+ effectEntry.dispose = standaloneEffect(wrapped)
737
+ }
738
+ self.entries.push(effectEntry)
739
+
740
+ return {
741
+ items: items$,
742
+ size: size$,
743
+ get: (key: K) => childMap.get(key)?.api,
744
+ has: (key: K) => childMap.has(key),
745
+ }
746
+ },
747
+
748
+ lazyChild<Props, Api>(
749
+ loader: () => Promise<ControllerDef<Props, Api>>,
750
+ props: Props,
751
+ options?: { deps?: Partial<Record<string, unknown>> },
752
+ ): LazyChild<Api> {
753
+ const status$ = signal<'idle' | 'loading' | 'ready' | 'error'>('idle')
754
+ const api$ = signal<Api | undefined>(undefined)
755
+ const error$ = signal<unknown | undefined>(undefined)
756
+
757
+ let childInstance: ControllerInstance | null = null
758
+ let childEntry: LifecycleEntry | null = null
759
+ let pendingLoad: Promise<Api> | null = null
760
+ let disposed = false
761
+
762
+ // Parent dispose flag; the child entry (when present) is disposed
763
+ // via the parent's normal cascade, so we don't double-tear-down.
764
+ const flagEntry: LifecycleEntry = {
765
+ kind: 'onDispose',
766
+ fn: () => {
767
+ disposed = true
768
+ },
769
+ }
770
+ self.entries.push(flagEntry)
771
+
772
+ const handleFailure = (err: unknown): void => {
773
+ status$.set('error')
774
+ error$.set(err)
775
+ dispatchError(self.rootShared.onError, err, {
776
+ kind: 'construction',
777
+ controllerPath: self.path,
778
+ })
779
+ }
780
+
781
+ const load = (): Promise<Api> => {
782
+ if (disposed) {
783
+ return Promise.reject(new Error('[olas] ctx.lazyChild: cannot load after dispose'))
784
+ }
785
+ if (pendingLoad !== null) return pendingLoad
786
+ status$.set('loading')
787
+ pendingLoad = loader().then(
788
+ (def) => {
789
+ if (disposed) {
790
+ throw new Error('[olas] ctx.lazyChild: disposed during load')
791
+ }
792
+ const segment = self.makeChildSegment(getFactory(def), getName(def))
793
+ const childDeps =
794
+ options?.deps !== undefined ? { ...self.deps, ...options.deps } : self.deps
795
+ const instance = new ControllerInstance(self, self.rootShared, segment, childDeps)
796
+ try {
797
+ const api = instance.construct(getFactory(def), props)
798
+ childInstance = instance
799
+ childEntry = { kind: 'child', instance }
800
+ self.entries.push(childEntry)
801
+ api$.set(api)
802
+ status$.set('ready')
803
+ return api
804
+ } catch (err) {
805
+ handleFailure(err)
806
+ throw err
807
+ }
808
+ },
809
+ (err) => {
810
+ if (disposed) throw err
811
+ handleFailure(err)
812
+ throw err
813
+ },
814
+ )
815
+ return pendingLoad
816
+ }
817
+
818
+ const dispose = (): void => {
819
+ if (disposed) return
820
+ disposed = true
821
+ if (childEntry !== null && childInstance !== null) {
822
+ const idx = self.entries.indexOf(childEntry)
823
+ if (idx >= 0) self.entries.splice(idx, 1)
824
+ try {
825
+ childInstance.dispose()
826
+ } catch (err) {
827
+ dispatchError(self.rootShared.onError, err, {
828
+ kind: 'effect',
829
+ controllerPath: self.path,
830
+ })
831
+ }
832
+ childInstance = null
833
+ childEntry = null
834
+ }
835
+ // Splice the parent-dispose flag entry too — its only job was to
836
+ // signal disposal to an in-flight loader, and `disposed` is now
837
+ // already true. Leaving it behind leaks one closure per ever-
838
+ // disposed lazyChild for the parent's remaining lifetime.
839
+ const flagIdx = self.entries.indexOf(flagEntry)
840
+ if (flagIdx >= 0) self.entries.splice(flagIdx, 1)
841
+ }
842
+
843
+ return {
844
+ status: status$,
845
+ api: api$,
846
+ error: error$,
847
+ load,
848
+ dispose,
435
849
  }
436
850
  },
437
851
 
@@ -48,7 +48,15 @@ export function createRootWithProps<Props, Api, TDeps extends Record<string, unk
48
48
  )
49
49
 
50
50
  // Bootstrap failure throws straight out of createRoot. Spec §12.1.5.
51
- const api = instance.construct(getFactory(def), props)
51
+ // Tear down the QueryClient and any plugins it spawned (window/storage
52
+ // listeners, transports) before re-throwing so the failure doesn't leak.
53
+ let api: Api
54
+ try {
55
+ api = instance.construct(getFactory(def), props)
56
+ } catch (err) {
57
+ queryClient.dispose()
58
+ throw err
59
+ }
52
60
 
53
61
  if (typeof api !== 'object' || api === null) {
54
62
  // Allow primitive APIs in principle but root controls must live somewhere.