@playfast/reform 1.0.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 (99) hide show
  1. package/README.md +103 -56
  2. package/package.json +17 -17
  3. package/src/boundary/boundary.test.ts +63 -49
  4. package/src/boundary/boundary.ts +144 -113
  5. package/src/calc/asyncCalc.invalidate.test.ts +518 -32
  6. package/src/calc/asyncCalc.test.ts +207 -213
  7. package/src/calc/asyncCalc.ts +131 -154
  8. package/src/calc/asyncData.ts +0 -37
  9. package/src/calc/calc.test.ts +7 -33
  10. package/src/calc/calc.ts +44 -58
  11. package/src/calc/calcFamily.test.ts +80 -34
  12. package/src/calc/calcFamily.ts +54 -65
  13. package/src/calc/compose.test.ts +1 -12
  14. package/src/calc/compose.ts +1 -36
  15. package/src/calc/queryState.ts +0 -23
  16. package/src/channel/channel.ts +126 -111
  17. package/src/compose/composition.test.ts +19 -0
  18. package/src/compose/composition.ts +351 -127
  19. package/src/compose/host.ts +0 -6
  20. package/src/compose/props.ts +13 -12
  21. package/src/compose/provide.ts +189 -62
  22. package/src/compose/slot.ts +158 -49
  23. package/src/compose/structure.test.ts +6 -9
  24. package/src/compose/structure.ts +108 -79
  25. package/src/compose/ui.test.ts +19 -7
  26. package/src/compose/ui.ts +155 -156
  27. package/src/compose/ui.typecheck.ts +40 -18
  28. package/src/definition/definition.ts +22 -41
  29. package/src/event/event.fromSource.test.ts +172 -0
  30. package/src/event/event.test.ts +10 -3
  31. package/src/event/event.ts +102 -27
  32. package/src/event/eventGroup.ts +0 -1
  33. package/src/feature/feature.mount.test.ts +122 -43
  34. package/src/feature/feature.test.ts +46 -21
  35. package/src/feature/feature.ts +1347 -256
  36. package/src/feature/feature.typecheck.ts +273 -68
  37. package/src/graph/closure.ts +403 -0
  38. package/src/index.ts +68 -126
  39. package/src/internal/bucketCache.ts +39 -0
  40. package/src/internal/capture.ts +9 -24
  41. package/src/internal/env.ts +7 -0
  42. package/src/internal/errors.test.ts +0 -6
  43. package/src/internal/errors.ts +37 -60
  44. package/src/internal/inspect.test.ts +0 -6
  45. package/src/internal/inspect.ts +0 -12
  46. package/src/internal/queryDriver.ts +105 -201
  47. package/src/internal/queryDriverStore.ts +156 -0
  48. package/src/internal/queryDriverTypes.ts +59 -0
  49. package/src/internal/queryEvents.ts +0 -12
  50. package/src/internal/queryStore.ts +0 -14
  51. package/src/internal/reuse.test.ts +10 -14
  52. package/src/internal/reuse.ts +5 -30
  53. package/src/internal/scheduler.ts +4 -44
  54. package/src/internal/seeds.ts +0 -14
  55. package/src/internal/sources.ts +26 -49
  56. package/src/internal/stateRegistry.ts +0 -14
  57. package/src/internal/store.test.ts +1 -3
  58. package/src/internal/store.ts +0 -37
  59. package/src/internal/track.ts +3 -20
  60. package/src/internal/variance.ts +5 -0
  61. package/src/internal.ts +222 -0
  62. package/src/namespace/namespace.test.ts +46 -0
  63. package/src/namespace/namespace.ts +106 -0
  64. package/src/procedure/procedure.ts +40 -35
  65. package/src/reducer/reducer.ts +78 -52
  66. package/src/remote/remoteState.test.ts +590 -397
  67. package/src/remote/remoteState.ts +425 -347
  68. package/src/remote/remoteState.typecheck.ts +47 -56
  69. package/src/runtime/appRuntime.activation.test.ts +100 -0
  70. package/src/runtime/appRuntime.test.ts +249 -0
  71. package/src/runtime/appRuntime.ts +415 -97
  72. package/src/runtime/bus.ts +2 -15
  73. package/src/runtime/eventBudget.test.ts +152 -0
  74. package/src/runtime/eventBudget.ts +120 -0
  75. package/src/runtime/hardening.test.ts +73 -13
  76. package/src/runtime/instrumentation.test.ts +55 -52
  77. package/src/runtime/instrumentation.ts +6 -60
  78. package/src/runtime/loop.test.ts +36 -17
  79. package/src/runtime/loop.ts +87 -88
  80. package/src/runtime/queries.ts +0 -17
  81. package/src/scene/featureScene.test.ts +61 -0
  82. package/src/scene/scene.ts +247 -104
  83. package/src/scene/seedScene.test.ts +42 -79
  84. package/src/state/state.nominal.typecheck.ts +68 -0
  85. package/src/state/state.test.ts +17 -0
  86. package/src/state/state.ts +80 -46
  87. package/src/state/stateFamily.test.ts +16 -11
  88. package/src/state/stateFamily.ts +55 -65
  89. package/src/state/stateGroup.ts +53 -64
  90. package/src/state/token.ts +40 -23
  91. package/src/synced/syncedStore.ts +46 -58
  92. package/src/testkit/flight.testkit.ts +75 -0
  93. package/src/ui/node.ts +0 -6
  94. package/src/ui/trigger.ts +0 -5
  95. package/src/wire/tree.test.ts +8 -7
  96. package/src/wire/tree.ts +0 -39
  97. package/src/wire/triggers.test.ts +6 -6
  98. package/src/wire/triggers.ts +14 -32
  99. package/src/internal/ctx.ts +0 -16
@@ -1,74 +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
+ type FeatureRequirement,
7
8
  isFeature,
8
- type ProvidedBy,
9
- type RequireCarrier,
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'
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']
13
51
 
14
- /**
15
- * Wire an implementation to a contract. Provides a `.make` presentation to a UI
16
- * contract, a child composition into a parent's slot, or a `Feature` into a slot.
17
- * The compose code never imports its implementations — they meet here.
18
- */
19
- export function provide<C extends UiContract>(
20
- contract: UiClass<C>,
21
- view: ViewImpl<C>,
22
- ): Layer.Layer<ViewImpl<C>>
23
- export function provide<Comp extends CompositionClass<any, any>>(
24
- hole: SlotClass<Comp>,
25
- composition: Comp,
26
- ): Layer.Layer<CompositionClass<unknown>>
27
- /**
28
- * Fill a slot with a feature. The slot's child composition type
29
- * (`CompositionClass<P, C>`) must match the feature's mounted composition, so the
30
- * facade/contract stays typed. The returned layer surfaces the feature's shared
31
- * requirement (`EngineServices | ProvidedBy<Requires>`) as an open `RIn`, so the
32
- * scene that wires it won't compile unless it provides the matching `Core` — even
33
- * for a lazy feature whose own `.live` layer isn't built until mount.
34
- */
35
- export function provide<
36
- Comp extends CompositionClass<any, any>,
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,
78
+ >(
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
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,
37
123
  ROut,
38
- Requires extends ReadonlyArray<RequireCarrier>,
124
+ DirectRequires extends ReadonlyArray<FeatureRequirement>,
39
125
  >(
40
- hole: SlotClass<Comp>,
41
- feature: FeatureClass<
42
- Comp extends CompositionClass<infer P, any> ? P : never,
43
- Comp extends CompositionClass<any, infer C> ? C : never,
44
- ROut,
45
- Requires
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>>,
159
+ ): Layer.Layer<
160
+ SlotService<
161
+ NameOfSlotClass<Slot>,
162
+ IdentityOfSlotClass<Slot>,
163
+ FeatureTargetOfSlotClass<Slot>['summary']
46
164
  >,
47
- ): Layer.Layer<SlotChild, never, EngineServices | ProvidedBy<Requires>>
48
- // 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
49
- export function provide(target: unknown, impl: unknown): Layer.Layer<never, never, unknown> {
50
- // Discriminate the target by its nominal brand, not by which DI-hole property
51
- // happens to exist: a UI contract provides under its `impl` tag, a slot under
52
- // its `tag`. Anything else is a wiring mistake. The overloads above pair
53
- // `impl` with the chosen tag; this implementation sees both erased, so view
54
- // the tag loosely (the one documented cast) to provide the service under it.
55
- // 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
56
- const tag = (isUi(target) ? target.impl : isSlot(target) ? target.tag : undefined) as
57
- | Context.Tag<unknown, unknown>
58
- | undefined
59
- if (tag === undefined) {
60
- return Layer.die(new InvalidProvideTarget())
165
+ never,
166
+ | EngineServices
167
+ | FeatureSummaryOpenServices<FeatureTargetOfSlotClass<Slot>['summary']>
168
+ | FeatureSummarySupportOpenServices<FeatureTargetOfSlotClass<Slot>['summary']>
169
+ >
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))
61
177
  }
62
- if (isFeature(impl)) {
63
- const eager = impl.eagerModule
64
- // Lazy: bind the `FeatureBinding`; the host drives load → mount, painting the
65
- // placeholders. Default: bind the composition (so the existing `Compose` path
66
- // renders it, no host, no flash) and merge the eager `.live` layer, whose `RIn`
67
- // surfaces to the scene. Either way the overload's return type carries the
68
- // shared `RIn`, so the scene still type-demands `Core`.
69
- return eager === undefined
70
- ? Layer.succeed(tag, impl.binding)
71
- : Layer.merge(Layer.succeed(tag, impl.binding.composition), eager.layer)
178
+ if (!isSlot(target)) {
179
+ return Layer.die(new InvalidProvideTarget())
72
180
  }
73
- 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
+ })
74
201
  }
@@ -1,68 +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'
16
+ import type { AnyComposition, CompositionClass } from './composition'
6
17
 
7
18
  export type { Node }
8
19
 
9
- /** Any composition, used where only the slot's structural role matters. */
10
- type AnyComposition = CompositionClass<any, any>
11
-
12
- /**
13
- * What a slot can be filled with: a plain child composition (the host renders it
14
- * directly), or a `FeatureBinding` from `provide(slot, Feature)` (the host drives
15
- * its lazy/eager mount lifecycle). The slot's DI tag holds one of these; the host
16
- * discriminates by the `FeatureBinding` brand.
17
- */
18
- export type SlotChild = CompositionClass<unknown> | FeatureBinding
19
-
20
- /**
21
- * Phantom instance shape: `ItemSlot` used as a *type* denotes this. It carries
22
- * the child *composition* type `Comp`, so the slot knows both the external props
23
- * the parent renders it with (`Comp['Props']`) and the child's full UI contract
24
- * (`Comp['Contract']` — props + events + slots) that reflection consumers, the
25
- * proof facade, drive.
26
- */
27
- export interface SlotInstance<Comp extends AnyComposition> {
28
- readonly _slot: Comp
20
+ export type { AnyComposition } from './composition'
21
+
22
+ type AnyProvideableFeature = ProvideableFeature<unknown, AnyFeatureGraphSummary>
23
+
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
29
81
  }
30
82
 
31
- /**
32
- * A DI hole for a child composition. The parent declares
33
- * `slot('Item')<typeof TodoItem>()` and renders through it; the concrete child
34
- * is wired with `provide(Item, TodoItem)`, so the parent never imports the
35
- * child's *implementation* (the `typeof` is a type-only reference to the
36
- * definition). `Comp` lives only in the phantom instance (so `SlotClass` is
37
- * covariant and a `{ Item: ItemSlot }` map is well-typed); the match is enforced
38
- * by `provide`'s signature, not the (invariant) tag.
39
- */
40
- /** Nominal brand identifying a slot at runtime (Effect's `TypeId` idiom). */
41
83
  export const SlotTypeId: unique symbol = Symbol.for('reform/Slot')
42
84
  export type SlotTypeId = typeof SlotTypeId
43
85
 
44
- export interface SlotClass<Comp extends AnyComposition = AnyComposition> {
45
- new (): SlotInstance<Comp>
46
- 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 {
47
102
  readonly kind: 'Slot'
48
- /** Internal DI tag the filling child (composition or feature binding) is provided under. */
49
- readonly tag: Context.Tag<SlotChild, SlotChild>
103
+ readonly name: N
104
+ }
105
+
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>
50
127
  }
51
128
 
52
- /** Whether a value is a slot discriminates a `provide` target by brand. */
53
- export const isSlot = (candidate: unknown): candidate is SlotClass =>
129
+ export const isSlot = (candidate: unknown): candidate is AnySlot =>
54
130
  (typeof candidate === 'function' || typeof candidate === 'object') &&
55
131
  candidate !== null &&
56
132
  SlotTypeId in candidate
57
133
 
58
- /** The props a slot renders with — the child composition's external props. */
59
- 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)}`
60
159
 
61
160
  export const slot =
62
- (name: string) =>
63
- // Defaulted so a slot whose child carries no meaningful props/contract can be
64
- // declared bare (`slot('Tree')()`); pass `typeof Child` to type the facade.
65
- <Comp extends AnyComposition = AnyComposition>(): SlotClass<Comp> => {
66
- const tag = Context.GenericTag<SlotChild, SlotChild>(`reform/slot/${name}`)
67
- 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
68
177
  }
@@ -7,17 +7,13 @@ import { absent, each, isStructure, mount, one, structureEquals, when } from './
7
7
 
8
8
  type List = Contract<typeof ListUi>
9
9
 
10
- // A minimal parent/child contract pair: a list parent with one keyed slot, so the
11
- // type-level `mount` checks have a real contract to bind against. Row carries a
12
- // props schema so its external props (`Comp['Props']`) are `{ label: string }`,
13
- // which is what each fill is checked against.
14
10
  class RowUi extends ui('Row')<{ props: { label: string } }>() {}
15
11
  class Row extends Composition.make('Row', {
16
12
  title: 'Row',
17
13
  props: S.Struct({ label: S.String }),
18
14
  ui: RowUi,
19
- }) {}
20
- class RowSlot extends slot('Row')<typeof Row>() {}
15
+ })<Row>() {}
16
+ class RowSlot extends slot('Row')<RowSlot, typeof Row>() {}
21
17
 
22
18
  class ListUi extends ui('List')<{
23
19
  props: { total: number }
@@ -26,7 +22,10 @@ class ListUi extends ui('List')<{
26
22
 
27
23
  describe('structure combinators', () => {
28
24
  test('each produces one keyed entry per item, props mapped', () => {
29
- 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
+ })
30
29
  expect(fill).toEqual({
31
30
  _tag: 'Each',
32
31
  items: [
@@ -65,8 +64,6 @@ describe('structure combinators', () => {
65
64
  })
66
65
  })
67
66
 
68
- // Type-level contract enforcement — never called; exists only so the negative
69
- // cases are typechecked. Each `@ts-expect-error` must sit on the line that errors.
70
67
  export const _typeChecks = (): void => {
71
68
  mount<List>({
72
69
  props: { total: 1 },