@kontsedal/olas-core 0.0.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.
@@ -24,9 +24,19 @@ import { createMutation, type Mutation, type MutationSpec } from '../query/mutat
24
24
  import type { LocalCache, Query } from '../query/types'
25
25
  import { createInfiniteUse, createUse } from '../query/use'
26
26
  import type { Scope } from '../scope'
27
- import { effect as standaloneEffect } from '../signals'
27
+ import { computed, signal, effect as standaloneEffect } from '../signals'
28
28
  import { getFactory, getName } from './define'
29
- 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'
30
40
 
31
41
  export type RootShared = {
32
42
  readonly devtools: DevtoolsEmitter
@@ -342,7 +352,17 @@ export class ControllerInstance {
342
352
  },
343
353
 
344
354
  emitter<T>(): Emitter<T> {
345
- 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
+ })
346
366
  self.entries.push({ kind: 'cleanup', dispose: () => e.dispose() })
347
367
  return e
348
368
  },
@@ -535,6 +555,300 @@ export class ControllerInstance {
535
555
  }
536
556
  },
537
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,
849
+ }
850
+ },
851
+
538
852
  onDispose(fn) {
539
853
  self.entries.push({
540
854
  kind: 'onDispose',
@@ -79,6 +79,80 @@ export type CtrlProps<C> = C extends ControllerDef<infer P, unknown> ? P : never
79
79
  /** Extract a controller's Api type. */
80
80
  export type CtrlApi<C> = C extends ControllerDef<unknown, infer A> ? A : never
81
81
 
82
+ /**
83
+ * The reactive surface returned by `ctx.collection(...)`. `items` is the
84
+ * canonical ordered view (source-order, with any construction-failed items
85
+ * filtered out); `size` mirrors `items.length`; `get` / `has` are
86
+ * imperative key lookups. SPEC §11.1.
87
+ */
88
+ export type Collection<K, Api> = {
89
+ readonly items: ReadSignal<ReadonlyArray<{ readonly key: K; readonly api: Api }>>
90
+ readonly size: ReadSignal<number>
91
+ get(key: K): Api | undefined
92
+ has(key: K): boolean
93
+ }
94
+
95
+ /**
96
+ * Homogeneous form of `ctx.collection`: one controller def for every item,
97
+ * with `propsOf` projecting each item to the controller's `Props`. Construct
98
+ * happens once per new key — `propsOf` is **not** re-applied for unchanged
99
+ * keys.
100
+ */
101
+ export type CollectionHomogeneousOptions<Item, K, Props, Api, TDeps = AmbientDeps> = {
102
+ readonly source: ReadSignal<readonly Item[]>
103
+ readonly keyOf: (item: Item) => K
104
+ readonly controller: ControllerDef<Props, Api>
105
+ readonly propsOf: (item: Item) => Props
106
+ readonly factory?: never
107
+ readonly propsFor?: never
108
+ readonly deps?: Partial<TDeps>
109
+ }
110
+
111
+ /**
112
+ * Heterogeneous form of `ctx.collection`: a single `factory` decides per-item
113
+ * which controller + props to construct. When a key's factory result picks a
114
+ * *different* controller than last time, the existing child is disposed and
115
+ * the new one constructed (type-discriminant rebuild).
116
+ *
117
+ * `R` is the factory's *return type* (typically inferred as the union of the
118
+ * branches' `{ controller, props }` shapes). `Api` is then projected out as
119
+ * the union of every branch's controller Api via `CollectionFactoryApi<R>` —
120
+ * unlike a single `Api` generic, the union doesn't collapse to the first
121
+ * branch.
122
+ */
123
+ export type CollectionFactoryOptions<Item, K, R, TDeps = AmbientDeps> = {
124
+ readonly source: ReadSignal<readonly Item[]>
125
+ readonly keyOf: (item: Item) => K
126
+ readonly controller?: never
127
+ readonly propsOf?: never
128
+ readonly factory: (item: Item) => R
129
+ readonly deps?: Partial<TDeps>
130
+ }
131
+
132
+ /** Constraint for the factory form's return shape. */
133
+ // biome-ignore lint/suspicious/noExplicitAny: per-branch types vary
134
+ export type CollectionFactoryResult = { controller: ControllerDef<any, any>; props: any }
135
+
136
+ /** Extract the union of every branch's controller Api. Distributes over R. */
137
+ export type CollectionFactoryApi<R> = R extends {
138
+ // biome-ignore lint/suspicious/noExplicitAny: distributive infer across the union
139
+ controller: ControllerDef<any, infer A>
140
+ }
141
+ ? A
142
+ : never
143
+
144
+ /**
145
+ * Handle returned by `ctx.lazyChild(...)`. `status` walks `idle → loading →
146
+ * (ready | error)`; `api` becomes defined once `status === 'ready'`. SPEC §16.5.
147
+ */
148
+ export type LazyChild<Api> = {
149
+ readonly status: ReadSignal<'idle' | 'loading' | 'ready' | 'error'>
150
+ readonly api: ReadSignal<Api | undefined>
151
+ readonly error: ReadSignal<unknown | undefined>
152
+ load(): Promise<Api>
153
+ dispose(): void
154
+ }
155
+
82
156
  /**
83
157
  * `ctx` is the lifecycle-bound surface every controller factory receives.
84
158
  * Every primitive constructed through `ctx` is owned by the controller and
@@ -150,6 +224,63 @@ export type Ctx<TDeps = AmbientDeps> = {
150
224
  options?: { deps?: Partial<TDeps> },
151
225
  ): { api: Api; dispose: () => void; suspend: () => void; resume: () => void }
152
226
 
227
+ /**
228
+ * Ephemeral child controller bound to either (a) the explicit `dispose()`
229
+ * call returned in the tuple, or (b) the parent's disposal — whichever
230
+ * comes first. Same lifecycle semantics as `ctx.attach` minus suspend /
231
+ * resume (sessions are short-lived, not pause-able). Returns a `[api,
232
+ * dispose]` tuple so the api shape is exactly the controller's return
233
+ * type, with no wrapper to unpack.
234
+ *
235
+ * Use cases: modal forms, inline edit sessions, wizards, command palette.
236
+ * SPEC §11.1.
237
+ */
238
+ session<Props, Api>(
239
+ def: ControllerDef<Props, Api>,
240
+ props: Props,
241
+ options?: { deps?: Partial<TDeps> },
242
+ ): readonly [api: Api, dispose: () => void]
243
+
244
+ /**
245
+ * Diff-by-key set of child controllers driven by a reactive `source`.
246
+ * On every change to `source`, the collection:
247
+ * - **new keys** → construct a child via `controller` + `propsOf(item)`
248
+ * (or `factory(item)` for the heterogeneous form);
249
+ * - **removed keys** → dispose that child;
250
+ * - **unchanged keys** → leave it alone (`propsOf` is NOT re-applied).
251
+ *
252
+ * For per-item type-discriminated children, use the `factory` form —
253
+ * type changes for an existing key dispose and reconstruct.
254
+ *
255
+ * Construction errors (factory or controller throw) are routed to
256
+ * `onError` with `kind: 'construction'` and the item is **skipped** —
257
+ * the collection's surface shows one fewer entry. The diff loop does
258
+ * not re-throw. SPEC §11.1, §12.1.6.
259
+ */
260
+ collection<Item, K, Props, Api>(
261
+ options: CollectionHomogeneousOptions<Item, K, Props, Api, TDeps>,
262
+ ): Collection<K, Api>
263
+ collection<Item, K, R extends CollectionFactoryResult>(
264
+ options: CollectionFactoryOptions<Item, K, R, TDeps>,
265
+ ): Collection<K, CollectionFactoryApi<R>>
266
+
267
+ /**
268
+ * Code-split child controller. The loader is invoked on `load()`
269
+ * (idempotent), then the controller is constructed with the supplied
270
+ * `props`. `status` / `api` / `error` are reactive signals; subscribe
271
+ * via `use(child.api)` in your view layer.
272
+ *
273
+ * Parent disposal disposes the loaded child (if any) and flags any
274
+ * in-flight load so its eventual settle is dropped on the floor.
275
+ * Construction or import failures route through `onError` with
276
+ * `kind: 'construction'`. SPEC §16.5.
277
+ */
278
+ lazyChild<Props, Api>(
279
+ loader: () => Promise<ControllerDef<Props, Api>>,
280
+ props: Props,
281
+ options?: { deps?: Partial<TDeps> },
282
+ ): LazyChild<Api>
283
+
153
284
  effect(fn: () => void | (() => void)): void
154
285
 
155
286
  on<T>(emitter: Emitter<T>, handler: (value: T) => void): void
package/src/emitter.ts CHANGED
@@ -20,17 +20,43 @@ export type Emitter<T> = {
20
20
 
21
21
  type AnyHandler = (value: unknown) => void
22
22
 
23
+ /**
24
+ * Optional escape hatch for emit-time handler throws. If supplied, a thrown
25
+ * handler is reported here and emission continues with the remaining handlers
26
+ * (spec §20.6 — one throwing handler must not block the rest). If absent,
27
+ * the throw is logged via `console.error`.
28
+ */
29
+ export type EmitterErrorReporter = (err: unknown) => void
30
+
23
31
  class EmitterImpl<T> {
24
32
  private handlers = new Set<AnyHandler>()
25
33
  private disposed = false
26
34
 
35
+ constructor(private onError?: EmitterErrorReporter) {}
36
+
27
37
  emit(value: T): void {
28
38
  if (this.disposed) return
29
39
  // Snapshot so a handler that unsubscribes itself (or another) doesn't
30
40
  // mutate the set mid-iteration.
31
41
  const snapshot = Array.from(this.handlers)
32
42
  for (const handler of snapshot) {
33
- handler(value as unknown)
43
+ try {
44
+ handler(value as unknown)
45
+ } catch (err) {
46
+ // Spec §20.6: isolate handler throws so siblings still fire.
47
+ if (this.onError) {
48
+ try {
49
+ this.onError(err)
50
+ } catch {
51
+ // Reporter itself threw — last resort.
52
+ // eslint-disable-next-line no-console
53
+ console.error('[olas] emitter handler threw and reporter threw:', err)
54
+ }
55
+ } else {
56
+ // eslint-disable-next-line no-console
57
+ console.error('[olas] emitter handler threw:', err)
58
+ }
59
+ }
34
60
  }
35
61
  }
36
62
 
@@ -67,9 +93,14 @@ class EmitterImpl<T> {
67
93
  * (or the emitter is disposed). Use this for emitters that live outside any
68
94
  * single controller — typically in deps. Use `ctx.emitter()` for emitters that
69
95
  * should auto-clean with a controller.
96
+ *
97
+ * Pass `onError` to receive emit-time handler throws (spec §20.6 — one
98
+ * throwing handler must not block the rest of the fan-out). `ctx.emitter()`
99
+ * wires this to the root's `onError` so deps-level emitters get isolation
100
+ * by default when constructed via `ctx`.
70
101
  */
71
- export function createEmitter<T = void>(): Emitter<T> {
72
- const impl = new EmitterImpl<T>()
102
+ export function createEmitter<T = void>(options?: { onError?: EmitterErrorReporter }): Emitter<T> {
103
+ const impl = new EmitterImpl<T>(options?.onError)
73
104
  return {
74
105
  emit: ((value?: T) => impl.emit(value as T)) as Emitter<T>['emit'],
75
106
  on: (handler) => impl.on(handler),
package/src/forms/form.ts CHANGED
@@ -202,10 +202,42 @@ class FormImpl<S extends FormSchema> implements Form<S> {
202
202
  }
203
203
  } else if (isFieldArray(child)) {
204
204
  const arr = child
205
- // Replace items: clear, then add each
206
- arr.clear()
207
- for (const itemVal of val as unknown[]) {
208
- arr.add(itemVal as ItemInitial<Field<unknown>>)
205
+ const newValues = val as unknown[]
206
+ if (asInitial) {
207
+ // Reset-style application: replace items wholesale and re-anchor
208
+ // them as the new initial so a later `reset()` returns here.
209
+ arr.clear()
210
+ for (const itemVal of newValues) {
211
+ arr.add(itemVal as ItemInitial<Field<unknown>>)
212
+ }
213
+ // Internal: re-anchor the initialItems list. `replaceInitialItems`
214
+ // is only exposed for this exact use case.
215
+ ;(
216
+ arr as unknown as {
217
+ replaceInitialItems: (items: ReadonlyArray<unknown>) => void
218
+ }
219
+ ).replaceInitialItems(newValues)
220
+ } else {
221
+ // User-driven patch: preserve item identity where the lengths
222
+ // overlap so touched / dirty / in-flight validators on existing
223
+ // items survive. Tail diff handles grow / shrink.
224
+ const current = arr.items.peek() as ReadonlyArray<Field<unknown> | Form<FormSchema>>
225
+ const overlap = Math.min(current.length, newValues.length)
226
+ for (let i = 0; i < overlap; i++) {
227
+ const item = current[i]
228
+ const v = newValues[i]
229
+ if (isForm(item)) {
230
+ item.set(v as DeepPartial<FormValue<FormSchema>>)
231
+ } else {
232
+ ;(item as Field<unknown>).set(v)
233
+ }
234
+ }
235
+ for (let i = current.length; i < newValues.length; i++) {
236
+ arr.add(newValues[i] as ItemInitial<Field<unknown>>)
237
+ }
238
+ for (let i = current.length - 1; i >= newValues.length; i--) {
239
+ arr.remove(i)
240
+ }
209
241
  }
210
242
  } else {
211
243
  const f = child as Field<unknown>
@@ -403,7 +435,7 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
403
435
  private readonly topLevelValidating$: Signal<boolean> = signal(false)
404
436
 
405
437
  private readonly itemFactory: (initial?: ItemInitial<I>) => I
406
- private readonly initialItems: Array<ItemInitial<I>> = []
438
+ private initialItems: Array<ItemInitial<I>> = []
407
439
  private readonly validators: ReadonlyArray<FieldArrayValidator<I>>
408
440
  private currentValidatorRun = 0
409
441
  private currentValidatorAbort: AbortController | null = null
@@ -528,6 +560,16 @@ class FieldArrayImpl<I extends Field<any> | Form<any>> implements FieldArray<I>
528
560
  this.items$.set([])
529
561
  }
530
562
 
563
+ /**
564
+ * Internal — used by `Form.resetWithInitial` to re-anchor the array's
565
+ * initial items after a parent-driven `applyPartial(..., asInitial: true)`.
566
+ * Without this, a subsequent `reset()` would revert to the construction-
567
+ * time initials rather than the most-recently-applied ones.
568
+ */
569
+ replaceInitialItems(items: ReadonlyArray<ItemInitial<I>>): void {
570
+ this.initialItems = [...items]
571
+ }
572
+
531
573
  reset(): void {
532
574
  if (this.disposed) return
533
575
  batch(() => {
package/src/index.ts CHANGED
@@ -3,11 +3,17 @@
3
3
  // Controller container
4
4
  export type {
5
5
  AmbientDeps,
6
+ Collection,
7
+ CollectionFactoryApi,
8
+ CollectionFactoryOptions,
9
+ CollectionFactoryResult,
10
+ CollectionHomogeneousOptions,
6
11
  ControllerDef,
7
12
  CtrlApi,
8
13
  CtrlProps,
9
14
  Ctx,
10
15
  Field,
16
+ LazyChild,
11
17
  Root,
12
18
  RootOptions,
13
19
  } from './controller'
@@ -16,7 +22,7 @@ export { createRoot, defineController } from './controller'
16
22
  export type { DebugBus, DebugCacheEntry, DebugEvent } from './devtools'
17
23
 
18
24
  // Emitter
19
- export type { Emitter } from './emitter'
25
+ export type { Emitter, EmitterErrorReporter } from './emitter'
20
26
  export { createEmitter } from './emitter'
21
27
  export type { ErrorContext } from './errors'
22
28
  // Forms — stdlib validators + debouncedValidator
@@ -8,15 +8,15 @@ import type { ReadSignal } from './types'
8
8
  * Internal helper — not exported from the package's public surface.
9
9
  */
10
10
  export function readOnly<T>(source: ReadSignal<T>): ReadSignal<T> {
11
- return {
11
+ return Object.freeze({
12
12
  get value() {
13
13
  return source.value
14
14
  },
15
15
  peek() {
16
16
  return source.peek()
17
17
  },
18
- subscribe(handler) {
18
+ subscribe(handler: (value: T) => void) {
19
19
  return source.subscribe(handler)
20
20
  },
21
- }
21
+ })
22
22
  }