@playfast/reform 1.1.1 → 1.2.0

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 (85) hide show
  1. package/README.md +85 -55
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +62 -16
  4. package/src/boundary/boundary.ts +141 -32
  5. package/src/calc/asyncCalc.invalidate.test.ts +516 -18
  6. package/src/calc/asyncCalc.test.ts +207 -175
  7. package/src/calc/asyncCalc.ts +168 -39
  8. package/src/calc/calc.test.ts +3 -5
  9. package/src/calc/calc.ts +44 -18
  10. package/src/calc/calcFamily.test.ts +80 -22
  11. package/src/calc/calcFamily.ts +56 -25
  12. package/src/calc/compose.ts +1 -14
  13. package/src/channel/channel.ts +128 -11
  14. package/src/compose/composition.test.ts +19 -0
  15. package/src/compose/composition.ts +346 -62
  16. package/src/compose/props.ts +13 -6
  17. package/src/compose/provide.ts +187 -46
  18. package/src/compose/slot.ts +156 -19
  19. package/src/compose/structure.test.ts +6 -3
  20. package/src/compose/structure.ts +106 -13
  21. package/src/compose/ui.test.ts +19 -3
  22. package/src/compose/ui.ts +147 -54
  23. package/src/compose/ui.typecheck.ts +40 -4
  24. package/src/definition/definition.ts +22 -9
  25. package/src/event/event.fromSource.test.ts +172 -0
  26. package/src/event/event.test.ts +10 -0
  27. package/src/event/event.ts +102 -18
  28. package/src/feature/feature.mount.test.ts +123 -56
  29. package/src/feature/feature.test.ts +67 -63
  30. package/src/feature/feature.ts +1317 -197
  31. package/src/feature/feature.typecheck.ts +253 -61
  32. package/src/graph/closure.ts +403 -0
  33. package/src/index.ts +171 -245
  34. package/src/internal/bucketCache.ts +39 -0
  35. package/src/internal/capture.ts +9 -4
  36. package/src/internal/env.ts +1 -3
  37. package/src/internal/errors.ts +21 -10
  38. package/src/internal/queryDriver.ts +120 -15
  39. package/src/internal/queryDriverStore.ts +8 -5
  40. package/src/internal/queryDriverTypes.ts +3 -1
  41. package/src/internal/reuse.test.ts +10 -3
  42. package/src/internal/reuse.ts +5 -2
  43. package/src/internal/scheduler.ts +4 -1
  44. package/src/internal/sources.ts +25 -11
  45. package/src/internal/track.ts +3 -2
  46. package/src/internal.ts +222 -0
  47. package/src/namespace/namespace.ts +46 -25
  48. package/src/procedure/procedure.ts +38 -11
  49. package/src/reducer/reducer.ts +78 -34
  50. package/src/remote/remoteState.test.ts +515 -339
  51. package/src/remote/remoteState.ts +572 -107
  52. package/src/remote/remoteState.typecheck.ts +47 -23
  53. package/src/runtime/appRuntime.activation.test.ts +100 -0
  54. package/src/runtime/appRuntime.test.ts +157 -105
  55. package/src/runtime/appRuntime.ts +394 -33
  56. package/src/runtime/eventBudget.test.ts +1 -4
  57. package/src/runtime/eventBudget.ts +9 -8
  58. package/src/runtime/hardening.test.ts +4 -9
  59. package/src/runtime/instrumentation.test.ts +174 -192
  60. package/src/runtime/instrumentation.ts +6 -11
  61. package/src/runtime/loop.test.ts +36 -0
  62. package/src/runtime/loop.ts +69 -40
  63. package/src/scene/featureScene.test.ts +4 -2
  64. package/src/scene/scene.ts +234 -64
  65. package/src/scene/seedScene.test.ts +69 -86
  66. package/src/state/state.nominal.typecheck.ts +68 -0
  67. package/src/state/state.test.ts +17 -0
  68. package/src/state/state.ts +79 -26
  69. package/src/state/stateFamily.test.ts +14 -0
  70. package/src/state/stateFamily.ts +55 -22
  71. package/src/state/stateGroup.ts +53 -17
  72. package/src/state/token.ts +40 -10
  73. package/src/synced/syncedStore.ts +46 -23
  74. package/src/testkit/flight.testkit.ts +75 -0
  75. package/src/wire/tree.test.ts +8 -4
  76. package/src/wire/triggers.test.ts +6 -2
  77. package/src/wire/triggers.ts +14 -10
  78. package/src/calc/asyncCalcDefinitions.ts +0 -48
  79. package/src/channel/procedureRegistry.ts +0 -90
  80. package/src/feature/featureBinding.ts +0 -48
  81. package/src/internal/ctx.ts +0 -9
  82. package/src/remote/remoteStateDefinition.ts +0 -135
  83. package/src/remote/remoteStateLayers.ts +0 -118
  84. package/src/remote/remoteStateLiveTypes.ts +0 -59
  85. package/src/remote/remoteStateSend.ts +0 -64
@@ -1,60 +1,201 @@
1
- import { type Context, Layer } from 'effect'
1
+ import { Context, Effect, Layer, Predicate } from 'effect'
2
2
  import { InvalidProvideTarget } from '../internal/errors'
3
- import type { CompositionClass } from './composition'
4
3
  import {
4
+ type AnyFeatureBinding,
5
5
  type EngineServices,
6
- type FeatureClass,
6
+ type FeatureBinding,
7
7
  type FeatureRequirement,
8
8
  isFeature,
9
- type ProvidedBy,
9
+ tree,
10
10
  } from '../feature/feature'
11
- import { isSlot, type SlotChild, type SlotClass } from './slot'
12
- import { isUi, type UiClass, type UiContract, type ViewImpl } from './ui'
13
- import type { AnyValue } from '../internal/variance'
14
-
15
- export function provide<C extends UiContract>(
16
- contract: UiClass<C>,
17
- view: ViewImpl<C>,
18
- ): Layer.Layer<ViewImpl<C>>
19
- export function provide<Comp extends CompositionClass<AnyValue, AnyValue>>(
20
- hole: SlotClass<Comp>,
21
- composition: Comp,
22
- ): Layer.Layer<CompositionClass<unknown>>
23
- export function provide<
24
- Comp extends CompositionClass<AnyValue, AnyValue>,
25
- ROut,
26
- Requires extends ReadonlyArray<FeatureRequirement>,
27
- SupportRequires extends ReadonlyArray<FeatureRequirement>,
11
+ import type {
12
+ AnyComposition,
13
+ CompositionClass,
14
+ CompositionId,
15
+ RequiredSlotIdentities,
16
+ } from './composition'
17
+ import type {
18
+ AnyFeatureGraphSummary,
19
+ CompositionChildSummary,
20
+ ExactServiceExclude,
21
+ FeatureSummaryOpenServices,
22
+ FeatureSummarySupportOpenServices,
23
+ } from '../graph/closure'
24
+ import {
25
+ type AnyFeatureSlotTarget,
26
+ type AnySlot,
27
+ isSlot,
28
+ type SlotClass,
29
+ type SlotService,
30
+ type SlotTarget,
31
+ } from './slot'
32
+ import {
33
+ type CallableView,
34
+ type Contract,
35
+ type AnyUi,
36
+ isUi,
37
+ type UiName,
38
+ type UiContract,
39
+ type UiService,
40
+ type ViewImpl,
41
+ } from './ui'
42
+
43
+ type TargetOfSlotClass<Slot extends AnySlot> = InstanceType<Slot>['_slot']
44
+ type NameOfSlotClass<Slot extends AnySlot> = InstanceType<Slot>['_slotName']
45
+ type IdentityOfSlotClass<Slot extends AnySlot> = InstanceType<Slot>['_slotIdentity']
46
+ type FeatureTargetOfSlotClass<Slot extends AnySlot> =
47
+ TargetOfSlotClass<Slot> extends AnyFeatureSlotTarget ? TargetOfSlotClass<Slot> : never
48
+
49
+ type CompositionIdentityOf<Composition extends AnyComposition> =
50
+ InstanceType<Composition>['_compositionIdentity']
51
+
52
+ // Preserve exact residual inputs that Layer.provideMerge's union subtraction hides.
53
+ export const provideMergeExact =
54
+ <Provided, ProviderError, ProviderInput>(
55
+ provider: Layer.Layer<Provided, ProviderError, ProviderInput>,
56
+ ) =>
57
+ <Output, Error, Input>(
58
+ consumer: Layer.Layer<Output, Error, Input>,
59
+ ): Layer.Layer<
60
+ Provided | Output,
61
+ ProviderError | Error,
62
+ ProviderInput | Exclude<Input, Provided> | ExactServiceExclude<Input, Provided>
63
+ > =>
64
+ Layer.merge(
65
+ Layer.provideMerge(provider)(consumer),
66
+ Layer.effectDiscard(Effect.context<ExactServiceExclude<Input, Provided>>()),
67
+ )
68
+
69
+ const compositionSlotLayer = <
70
+ Target extends SlotTarget,
71
+ SlotName extends string,
72
+ SlotIdentity,
73
+ P,
74
+ C extends UiContract,
75
+ S extends ReadonlyArray<unknown>,
76
+ CompositionName extends string,
77
+ CompositionIdentity,
28
78
  >(
29
- hole: SlotClass<Comp>,
30
- feature: FeatureClass<
31
- Comp extends CompositionClass<infer P, AnyValue> ? P : never,
32
- Comp extends CompositionClass<AnyValue, infer C> ? C : never,
33
- ROut,
34
- Requires,
35
- SupportRequires
79
+ slotDefinition: SlotClass<Target, SlotName, SlotIdentity>,
80
+ composition: CompositionClass<P, C, S, CompositionName, CompositionIdentity>,
81
+ ): Layer.Layer<SlotService<SlotName, SlotIdentity, CompositionChildSummary<CompositionIdentity>>> =>
82
+ Layer.succeed(
83
+ Context.GenericTag<
84
+ SlotService<SlotName, SlotIdentity, CompositionChildSummary<CompositionIdentity>>,
85
+ AnyComposition
86
+ >(slotDefinition.tag.key),
87
+ composition,
88
+ )
89
+
90
+ export const provideExisting = <
91
+ P,
92
+ C extends UiContract,
93
+ S extends ReadonlyArray<unknown>,
94
+ CompositionName extends string,
95
+ CompositionIdentity,
96
+ SlotName extends string,
97
+ SlotIdentity,
98
+ >(
99
+ hole: SlotClass<
100
+ CompositionClass<P, C, S, CompositionName, CompositionIdentity>,
101
+ SlotName,
102
+ SlotIdentity
36
103
  >,
104
+ composition: NoInfer<CompositionClass<P, C, S, CompositionName, CompositionIdentity>>,
105
+ ): Layer.Layer<
106
+ SlotService<SlotName, SlotIdentity, CompositionChildSummary<CompositionIdentity>>,
107
+ never,
108
+ CompositionId<CompositionName, CompositionIdentity, RequiredSlotIdentities<C>>
109
+ > =>
110
+ Layer.effect(
111
+ Context.GenericTag<
112
+ SlotService<SlotName, SlotIdentity, CompositionChildSummary<CompositionIdentity>>,
113
+ CompositionClass<P, C, S, CompositionName, CompositionIdentity>
114
+ >(hole.tag.key),
115
+ Effect.as(composition.tag, composition),
116
+ )
117
+
118
+ const featureSlotLayer = <
119
+ Target extends SlotTarget,
120
+ SlotName extends string,
121
+ SlotIdentity,
122
+ Summary extends AnyFeatureGraphSummary,
123
+ ROut,
124
+ DirectRequires extends ReadonlyArray<FeatureRequirement>,
125
+ >(
126
+ slotDefinition: SlotClass<Target, SlotName, SlotIdentity>,
127
+ binding: FeatureBinding<Summary, ROut, DirectRequires>,
128
+ ): Layer.Layer<SlotService<SlotName, SlotIdentity, Summary>> =>
129
+ Layer.succeed(
130
+ Context.GenericTag<SlotService<SlotName, SlotIdentity, Summary>, AnyFeatureBinding>(
131
+ slotDefinition.tag.key,
132
+ ),
133
+ binding,
134
+ )
135
+
136
+ const isComposition = (candidate: unknown): candidate is AnyComposition =>
137
+ (typeof candidate === 'function' || typeof candidate === 'object') &&
138
+ candidate !== null &&
139
+ Predicate.hasProperty(candidate, 'manifest') &&
140
+ Predicate.isRecord(candidate.manifest) &&
141
+ candidate.manifest['kind'] === 'Composition' &&
142
+ Predicate.hasProperty(candidate, 'capture') &&
143
+ typeof candidate.capture === 'function'
144
+
145
+ const isCallableView = (candidate: unknown): candidate is CallableView =>
146
+ typeof candidate === 'function'
147
+
148
+ export function provide<U extends AnyUi>(
149
+ contract: U,
150
+ view: ViewImpl<Contract<U>>,
151
+ ): Layer.Layer<UiService<UiName<U>>>
152
+ export function provide<Comp extends AnyComposition, N extends string, Identity>(
153
+ hole: SlotClass<Comp, N, Identity>,
154
+ composition: NoInfer<Comp>,
155
+ ): Layer.Layer<SlotService<N, Identity, CompositionChildSummary<CompositionIdentityOf<Comp>>>>
156
+ export function provide<Slot extends AnySlot>(
157
+ hole: Slot,
158
+ feature: NoInfer<FeatureTargetOfSlotClass<Slot>>,
37
159
  ): Layer.Layer<
38
- SlotChild,
160
+ SlotService<
161
+ NameOfSlotClass<Slot>,
162
+ IdentityOfSlotClass<Slot>,
163
+ FeatureTargetOfSlotClass<Slot>['summary']
164
+ >,
39
165
  never,
40
- EngineServices | ProvidedBy<Requires> | ProvidedBy<SupportRequires>
166
+ | EngineServices
167
+ | FeatureSummaryOpenServices<FeatureTargetOfSlotClass<Slot>['summary']>
168
+ | FeatureSummarySupportOpenServices<FeatureTargetOfSlotClass<Slot>['summary']>
41
169
  >
42
- // oxlint-disable-next-line reform-rules/no-multiple-primitive-params -- erased implementation signature of the 2-arg overloaded `provide`; the typed public API is the overloads above
43
- export function provide(target: unknown, impl: unknown): Layer.Layer<never, never, unknown> {
44
- // Brand-discriminate: UI → impl tag, slot → tag; cast is the erased DI seam
45
- // oxlint-disable-next-line reform-rules/no-type-assertion -- erased DI tag widening at the provide seam (Context.Tag is invariant); overloads pair impl with the right tag
46
- const tag = (isUi(target) ? target.impl : isSlot(target) ? target.tag : undefined) as
47
- | Context.Tag<unknown, unknown>
48
- | undefined
49
- if (tag === undefined) {
50
- return Layer.die(new InvalidProvideTarget())
170
+ export function provide(...args: readonly [target: unknown, implementation: unknown]): unknown {
171
+ const [target, implementation] = args
172
+ if (isUi(target)) {
173
+ if (!isCallableView(implementation)) {
174
+ return Layer.die(new InvalidProvideTarget())
175
+ }
176
+ return target.capture<unknown>((contract) => Layer.succeed(contract.provider, implementation))
51
177
  }
52
- if (isFeature(impl)) {
53
- // Default: merge live module now; lazy: eager placeholder support only
54
- const eager = impl.eagerModule ?? impl.supportModule
55
- return eager === undefined
56
- ? Layer.succeed(tag, impl.binding)
57
- : Layer.merge(Layer.succeed(tag, impl.binding), eager.layer)
178
+ if (!isSlot(target)) {
179
+ return Layer.die(new InvalidProvideTarget())
58
180
  }
59
- return Layer.succeed(tag, impl)
181
+ return target.capture<unknown>((slotDefinition) => {
182
+ if (isFeature(implementation)) {
183
+ const native = tree<typeof implementation>(implementation.token)
184
+ return native.binding.capture<unknown>((featureBinding) => {
185
+ // Lazy features merge only eager support; default features merge their live module.
186
+ const eager = native.eagerModule ?? native.supportModule
187
+ const binding = featureSlotLayer(slotDefinition, featureBinding)
188
+ if (eager === undefined) {
189
+ return binding
190
+ }
191
+ return eager.capture<unknown>((module) => Layer.merge(binding, module.layer))
192
+ })
193
+ }
194
+ if (!isComposition(implementation)) {
195
+ return Layer.die(new InvalidProvideTarget())
196
+ }
197
+ return implementation.capture<unknown>((composition) =>
198
+ compositionSlotLayer(slotDefinition, composition),
199
+ )
200
+ })
60
201
  }
@@ -1,40 +1,177 @@
1
- import { Context } from 'effect'
2
- import { definitionClass } from '../definition/definition'
3
- import type { CompositionClass } from './composition'
4
- import type { FeatureBinding } from '../feature/feature'
1
+ import { Context, GlobalValue, MutableRef } from 'effect'
2
+ import { type Manifest, definitionClass } from '../definition/definition'
3
+ import type { AnyFeatureBinding, ProvideableFeature } from '../feature/feature'
4
+ import type {
5
+ AnyFeatureGraphSummary,
6
+ FeatureSummaryCompositionIdentity,
7
+ FeatureSummaryCompositionProps,
8
+ FeatureSummaryContract,
9
+ FeatureSummaryMountProps,
10
+ FeatureSummaryName,
11
+ FeatureSummaryStates,
12
+ FeatureSummaryIdentity,
13
+ SlotBindingSummary,
14
+ } from '../graph/closure'
5
15
  import type { Node } from '../ui/node'
6
- import type { AnyValue } from '../internal/variance'
16
+ import type { AnyComposition, CompositionClass } from './composition'
7
17
 
8
18
  export type { Node }
9
19
 
10
- type AnyComposition = CompositionClass<AnyValue, AnyValue>
20
+ export type { AnyComposition } from './composition'
11
21
 
12
- export type SlotChild = CompositionClass<unknown> | FeatureBinding
22
+ type AnyProvideableFeature = ProvideableFeature<unknown, AnyFeatureGraphSummary>
13
23
 
14
- export interface SlotInstance<Comp extends AnyComposition> {
15
- readonly _slot: Comp
24
+ export type FeatureSlotTarget<Feature extends AnyProvideableFeature> = ProvideableFeature<
25
+ FeatureSummaryIdentity<Feature['summary']>,
26
+ Feature['summary']
27
+ >
28
+
29
+ export type AnyFeatureSlotTarget = FeatureSlotTarget<AnyProvideableFeature>
30
+
31
+ export type SlotTarget = AnyComposition | AnyFeatureSlotTarget
32
+
33
+ export type CompositionOfSlotTarget<Target extends SlotTarget> = Target extends AnyComposition
34
+ ? Target
35
+ : Target extends AnyFeatureSlotTarget
36
+ ? CompositionClass<
37
+ FeatureSummaryCompositionProps<Target['summary']>,
38
+ FeatureSummaryContract<Target['summary']>,
39
+ FeatureSummaryStates<Target['summary']>,
40
+ FeatureSummaryName<Target['summary']>,
41
+ FeatureSummaryCompositionIdentity<Target['summary']>
42
+ >
43
+ : never
44
+
45
+ export type PropsOfSlotTarget<Target extends SlotTarget> = Target extends AnyComposition
46
+ ? Target['Props']
47
+ : Target extends AnyFeatureSlotTarget
48
+ ? FeatureSummaryMountProps<Target['summary']>
49
+ : never
50
+
51
+ export type ContractOfSlotTarget<Target extends SlotTarget> = Target extends AnyComposition
52
+ ? Target['Contract']
53
+ : Target extends AnyFeatureSlotTarget
54
+ ? FeatureSummaryContract<Target['summary']>
55
+ : never
56
+
57
+ export type SlotChild<Target extends SlotTarget> = Target extends AnyComposition
58
+ ? Target | (AnyFeatureBinding & { readonly composition: Target })
59
+ : Target extends AnyFeatureSlotTarget
60
+ ? AnyFeatureBinding & {
61
+ readonly summary: Target['summary']
62
+ }
63
+ : never
64
+
65
+ export type AnySlotChild = AnyComposition | AnyFeatureBinding
66
+
67
+ export interface AnySlotInstance {
68
+ readonly _slot: SlotTarget
69
+ readonly _slotName: string
70
+ readonly _slotIdentity: unknown
71
+ }
72
+
73
+ export interface SlotInstance<
74
+ Target extends SlotTarget,
75
+ N extends string,
76
+ Identity,
77
+ > extends AnySlotInstance {
78
+ readonly _slot: Target
79
+ readonly _slotName: N
80
+ readonly _slotIdentity: Identity
16
81
  }
17
82
 
18
83
  export const SlotTypeId: unique symbol = Symbol.for('reform/Slot')
19
84
  export type SlotTypeId = typeof SlotTypeId
20
85
 
21
- export interface SlotClass<Comp extends AnyComposition = AnyComposition> {
22
- new (): SlotInstance<Comp>
23
- readonly [SlotTypeId]: SlotTypeId
86
+ export const SlotServiceTypeId: unique symbol = Symbol.for('reform/SlotService')
87
+ export type SlotServiceTypeId = typeof SlotServiceTypeId
88
+
89
+ export interface SlotService<
90
+ N extends string,
91
+ Identity = unknown,
92
+ out Binding extends SlotBindingSummary = SlotBindingSummary,
93
+ > {
94
+ readonly [SlotServiceTypeId]: {
95
+ readonly name: N
96
+ readonly identity: Identity
97
+ readonly binding: Binding
98
+ }
99
+ }
100
+
101
+ export interface SlotManifest<N extends string = string> extends Manifest {
24
102
  readonly kind: 'Slot'
25
- readonly tag: Context.Tag<SlotChild, SlotChild>
103
+ readonly name: N
26
104
  }
27
105
 
28
- export const isSlot = (candidate: unknown): candidate is SlotClass =>
106
+ export type SlotCapture<Result> = <Target extends SlotTarget, N extends string, Identity>(
107
+ slotDefinition: SlotClass<Target, N, Identity>,
108
+ ) => Result
109
+
110
+ export interface AnySlot {
111
+ new (): AnySlotInstance
112
+ readonly [SlotTypeId]: SlotTypeId
113
+ readonly manifest: SlotManifest
114
+ readonly identity: symbol
115
+ readonly capture: <Result>(visit: SlotCapture<Result>) => Result
116
+ }
117
+
118
+ export interface SlotClass<Target extends SlotTarget, N extends string, Identity> {
119
+ new (): SlotInstance<Target, N, Identity>
120
+ readonly [SlotTypeId]: SlotTypeId
121
+ readonly manifest: SlotManifest<N>
122
+ readonly identity: symbol
123
+ readonly capture: <Result>(visit: SlotCapture<Result>) => Result
124
+ readonly tag: Context.Tag<SlotService<N, Identity>, SlotChild<Target>>
125
+ // Same runtime key, widened only after provide's typed overloads validate the child.
126
+ readonly provider: Context.Tag<SlotService<N, Identity>, AnySlotChild>
127
+ }
128
+
129
+ export const isSlot = (candidate: unknown): candidate is AnySlot =>
29
130
  (typeof candidate === 'function' || typeof candidate === 'object') &&
30
131
  candidate !== null &&
31
132
  SlotTypeId in candidate
32
133
 
33
- export type SlotProps<S> = S extends SlotInstance<infer Comp> ? Comp['Props'] : never
134
+ export type TargetOfSlot<S> =
135
+ S extends SlotInstance<infer Target, infer _N, infer _Identity> ? Target : never
136
+
137
+ export type SlotProps<S> =
138
+ S extends SlotInstance<infer Target, infer _N, infer _Identity>
139
+ ? PropsOfSlotTarget<Target>
140
+ : never
141
+
142
+ export type SlotComposition<S> =
143
+ S extends SlotInstance<infer Target, infer _N, infer _Identity>
144
+ ? CompositionOfSlotTarget<Target>
145
+ : never
146
+
147
+ export type SlotContract<S> =
148
+ S extends SlotInstance<infer Target, infer _N, infer _Identity>
149
+ ? ContractOfSlotTarget<Target>
150
+ : never
151
+
152
+ const slotDefinitionSequence: MutableRef.MutableRef<number> = GlobalValue.globalValue(
153
+ Symbol.for('reform/SlotDefinitionSequence'),
154
+ () => MutableRef.make(0),
155
+ )
156
+
157
+ const nextSlotTagKey = (name: string): string =>
158
+ `reform/slot/${name}/${MutableRef.incrementAndGet(slotDefinitionSequence)}`
34
159
 
35
160
  export const slot =
36
- (name: string) =>
37
- <Comp extends AnyComposition = AnyComposition>(): SlotClass<Comp> => {
38
- const tag = Context.GenericTag<SlotChild, SlotChild>(`reform/slot/${name}`)
39
- return definitionClass<SlotClass<Comp>>({ [SlotTypeId]: SlotTypeId, kind: 'Slot' as const, tag })
161
+ <const N extends string>(name: N) =>
162
+ <Self, Target extends SlotTarget = AnyComposition>(): SlotClass<Target, N, Self> => {
163
+ const key = nextSlotTagKey(name)
164
+ const identity = Symbol(name)
165
+ const tag = Context.GenericTag<SlotService<N, Self>, SlotChild<Target>>(key)
166
+ const provider = Context.GenericTag<SlotService<N, Self>, AnySlotChild>(key)
167
+ const manifest: SlotManifest<N> = { kind: 'Slot', name }
168
+ const slotDefinition: SlotClass<Target, N, Self> = definitionClass<SlotClass<Target, N, Self>>({
169
+ [SlotTypeId]: SlotTypeId,
170
+ manifest,
171
+ identity,
172
+ tag,
173
+ provider,
174
+ capture: <Result>(visit: SlotCapture<Result>): Result => visit(slotDefinition),
175
+ })
176
+ return slotDefinition
40
177
  }
@@ -12,8 +12,8 @@ class Row extends Composition.make('Row', {
12
12
  title: 'Row',
13
13
  props: S.Struct({ label: S.String }),
14
14
  ui: RowUi,
15
- }) {}
16
- class RowSlot extends slot('Row')<typeof Row>() {}
15
+ })<Row>() {}
16
+ class RowSlot extends slot('Row')<RowSlot, typeof Row>() {}
17
17
 
18
18
  class ListUi extends ui('List')<{
19
19
  props: { total: number }
@@ -22,7 +22,10 @@ class ListUi extends ui('List')<{
22
22
 
23
23
  describe('structure combinators', () => {
24
24
  test('each produces one keyed entry per item, props mapped', () => {
25
- const fill = each([{ id: 'a' }, { id: 'b' }], { key: (t) => t.id, props: (t) => ({ label: t.id }) })
25
+ const fill = each([{ id: 'a' }, { id: 'b' }], {
26
+ key: (t) => t.id,
27
+ props: (t) => ({ label: t.id }),
28
+ })
26
29
  expect(fill).toEqual({
27
30
  _tag: 'Each',
28
31
  items: [
@@ -1,4 +1,5 @@
1
- import { Struct } from 'effect'
1
+ import { Option, Struct } from 'effect'
2
+ import { reuse } from '../internal/reuse'
2
3
  import type { SlotProps } from './slot'
3
4
  import type { EventsOf, UiContract } from './ui'
4
5
 
@@ -22,37 +23,129 @@ export type StructureSlots<C extends UiContract> = C extends {
22
23
  slots: infer S
23
24
  }
24
25
  ? { readonly [K in keyof S]: SlotFill<SlotProps<S[K]>> }
25
- : Record<never, never>
26
+ : Readonly<Record<PropertyKey, never>>
26
27
 
27
- // events ride on the frame for hosts but are not serializable — excluded from structureEquals
28
- export interface Structure<C extends UiContract> {
28
+ interface SerializableFrame<C extends UiContract> {
29
29
  readonly props: C['props']
30
30
  readonly slots: StructureSlots<C>
31
- // oxlint-disable-next-line reform-rules/no-optional-fields -- serializable frame shape; triggers ride optionally and the field is intentionally omitted from structureEquals
32
- readonly events?: EventsOf<C>
33
31
  }
34
32
 
33
+ interface TriggeredFrame<C extends UiContract> extends SerializableFrame<C> {
34
+ readonly events: EventsOf<C>
35
+ }
36
+
37
+ type MountFrame<C extends UiContract> = SerializableFrame<C> | TriggeredFrame<C>
38
+
35
39
  export const StructureTypeId: unique symbol = Symbol.for('reform/Structure')
36
40
  export type StructureTypeId = typeof StructureTypeId
37
41
 
38
- export const mount = <C extends UiContract>(structure: Structure<C>): Structure<C> =>
39
- // oxlint-disable-next-line reform-rules/no-object-assign -- merge infers the empty `events` default as EventsOf<C>; a spread would force an `as` cast instead
40
- Object.assign({ [StructureTypeId]: StructureTypeId, events: {} }, structure)
42
+ export interface Structure<C extends UiContract> {
43
+ readonly [StructureTypeId]: StructureTypeId
44
+ readonly props: C['props']
45
+ readonly slots: StructureSlots<C>
46
+ readonly events: EventsOf<C> | undefined
47
+ }
48
+
49
+ const frameEvents = <C extends UiContract>(frame: MountFrame<C>): Option.Option<EventsOf<C>> =>
50
+ 'events' in frame ? Option.some(frame.events) : Option.none<EventsOf<C>>()
51
+
52
+ export const mount = <C extends UiContract>(frame: MountFrame<C>): Structure<C> => ({
53
+ events: Option.getOrUndefined(frameEvents(frame)),
54
+ props: frame.props,
55
+ slots: frame.slots,
56
+ [StructureTypeId]: StructureTypeId,
57
+ })
41
58
 
42
- // key is the single source for both wire key and per-item StateFamily key
43
59
  export interface EachOptions<T, P> {
44
60
  readonly key: (item: T) => string
45
61
  readonly props: (item: T) => P
46
62
  }
47
63
 
48
- export const each = <T, P>(collection: Iterable<T>, options: EachOptions<T, P>): EachFill<P> => ({
64
+ interface EachRetention {
65
+ readonly elements: ReadonlyArray<unknown>
66
+ readonly fill: unknown
67
+ }
68
+
69
+ interface CollectionRetentions {
70
+ readonly byProps: WeakMap<object, WeakMap<object, EachRetention>>
71
+ }
72
+
73
+ // Stable collection and callback identities retain unchanged fills by reference.
74
+ const eachRetentions = new WeakMap<object, CollectionRetentions>()
75
+ const latestEachRetentions = new WeakMap<object, EachRetention>()
76
+
77
+ const hasIdentity = (candidate: unknown): candidate is object =>
78
+ (typeof candidate === 'object' && candidate !== null) || typeof candidate === 'function'
79
+
80
+ const collectionRetentionsFor = (collection: unknown): CollectionRetentions | undefined => {
81
+ if (!hasIdentity(collection)) {
82
+ return undefined
83
+ }
84
+ return (
85
+ eachRetentions.get(collection) ?? {
86
+ byProps: new WeakMap<object, WeakMap<object, EachRetention>>(),
87
+ }
88
+ )
89
+ }
90
+
91
+ const sameElements = (previous: ReadonlyArray<unknown>, next: ReadonlyArray<unknown>): boolean =>
92
+ previous.length === next.length &&
93
+ previous.every((element, index) => Object.is(element, next[index]))
94
+
95
+ const isEachFill = <P>(candidate: unknown): candidate is EachFill<P> =>
96
+ isRecord(candidate) &&
97
+ candidate['_tag'] === 'Each' &&
98
+ Array.isArray(candidate['items']) &&
99
+ candidate['items'].every(
100
+ (entry) => isRecord(entry) && typeof entry['key'] === 'string' && 'props' in entry,
101
+ )
102
+
103
+ const materializeEach = <T, P>(
104
+ elements: ReadonlyArray<T>,
105
+ options: EachOptions<T, P>,
106
+ ): EachFill<P> => ({
49
107
  _tag: 'Each',
50
- items: Array.from(collection, (element) => ({
108
+ items: elements.map((element) => ({
51
109
  key: options.key(element),
52
110
  props: options.props(element),
53
111
  })),
54
112
  })
55
113
 
114
+ const retainEach = <P>(previous: unknown, next: EachFill<P>): EachFill<P> => {
115
+ if (!isEachFill<P>(previous)) {
116
+ return next
117
+ }
118
+ if (deepEquals({ left: previous, right: next })) {
119
+ return previous
120
+ }
121
+ return reuse(previous, next)
122
+ }
123
+
124
+ export const each = <T, P>(collection: Iterable<T>, options: EachOptions<T, P>): EachFill<P> => {
125
+ const elements = Array.from(collection)
126
+ const collectionRetentions = collectionRetentionsFor(collection)
127
+ const retained = collectionRetentions?.byProps.get(options.props)?.get(options.key)
128
+ const latest = hasIdentity(collection) ? latestEachRetentions.get(collection) : undefined
129
+ if (
130
+ retained !== undefined &&
131
+ sameElements(retained.elements, elements) &&
132
+ isEachFill<P>(retained.fill)
133
+ ) {
134
+ return retained.fill
135
+ }
136
+ const fill = retainEach(retained?.fill ?? latest?.fill, materializeEach(elements, options))
137
+ if (hasIdentity(collection) && collectionRetentions !== undefined) {
138
+ const byKey =
139
+ collectionRetentions.byProps.get(options.props) ?? new WeakMap<object, EachRetention>()
140
+ const retention = { elements, fill }
141
+ byKey.set(options.key, retention)
142
+ collectionRetentions.byProps.set(options.props, byKey)
143
+ eachRetentions.set(collection, collectionRetentions)
144
+ latestEachRetentions.set(collection, retention)
145
+ }
146
+ return fill
147
+ }
148
+
56
149
  export const one = <P>(props: P): OneFill<P> => ({ _tag: 'One', props })
57
150
 
58
151
  export const absent: AbsentFill = { _tag: 'Absent' }
@@ -96,10 +189,10 @@ const deepEquals = ({ left, right }: ValuePair): boolean => {
96
189
  return false
97
190
  }
98
191
 
99
- // events intentionally excluded (fresh function identities would break settle fixpoint)
100
192
  export const structureEquals = (
101
193
  left: Structure<UiContract>,
102
194
  right: Structure<UiContract>,
103
195
  ): boolean =>
196
+ // Events are fresh trigger functions, so equality covers serializable frame data only.
104
197
  deepEquals({ left: left.props, right: right.props }) &&
105
198
  deepEquals({ left: left.slots, right: right.slots })
@@ -1,6 +1,6 @@
1
- import { expect, test } from 'vitest'
2
- import { Schema as S } from 'effect'
3
- import { isUi, ui } from '../index'
1
+ import { expect, it, test } from '@effect/vitest'
2
+ import { Effect, Layer, Schema as S } from 'effect'
3
+ import { isUi, provide, Ui, ui } from '../index'
4
4
 
5
5
  test('type-only `ui` carries no wire schema', () => {
6
6
  class CounterUi extends ui('Counter')<{ props: { count: number } }>() {}
@@ -28,3 +28,19 @@ test('schema `ui` with no events omits the events schema', () => {
28
28
  expect(S.isSchema(TitleUi.manifest.props)).toBe(true)
29
29
  expect(TitleUi.manifest.events).toBeUndefined()
30
30
  })
31
+
32
+ it.live('same-shaped UI definitions resolve their own views', () => {
33
+ class FirstUi extends ui('UiNominalFirst')<{ props: { label: string } }>() {}
34
+ class SecondUi extends ui('UiNominalSecond')<{ props: { label: string } }>() {}
35
+ const FirstView = Ui.make(FirstUi, ({ label }) => `first:${label}`)
36
+ const SecondView = Ui.make(SecondUi, ({ label }) => `second:${label}`)
37
+ const Live = Layer.mergeAll(provide(FirstUi, FirstView), provide(SecondUi, SecondView))
38
+
39
+ return Effect.gen(function* () {
40
+ const first = yield* FirstUi.impl
41
+ const second = yield* SecondUi.impl
42
+
43
+ expect(first({ label: 'value' }, {}, {})).toBe('first:value')
44
+ expect(second({ label: 'value' }, {}, {})).toBe('second:value')
45
+ }).pipe(Effect.provide(Live))
46
+ })