@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,165 +1,389 @@
1
- import { Context, Effect, Layer, type Schema } from 'effect'
1
+ import { Context, Effect, GlobalValue, Layer, MutableRef, Option, Schema, Scope } from 'effect'
2
+ import type { AST } from 'effect/SchemaAST'
2
3
  import type { YieldWrap } from 'effect/Utils'
3
4
  import { type Manifest, definitionClass } from '../definition/definition'
4
- import { type Ctx } from '../internal/ctx'
5
5
  import type { Tracker } from '../internal/track'
6
6
  import { CurrentTracker } from '../internal/track'
7
- import type { SlotClass } from './slot'
7
+ import { type PropsId, type PropsTag, propsTag } from './props'
8
+ import type { AnySlot, AnySlotInstance, SlotClass, SlotInstance } from './slot'
8
9
  import type { Structure } from './structure'
9
- import type { UiClass, UiContract } from './ui'
10
- import { Props } from './props'
11
-
12
- /**
13
- * What a composition's logic returns for one frame: a serializable {@link Structure}
14
- * value — its computed props, per-slot fills, and event triggers, all as DATA. A body
15
- * never returns (nor evaluates) a React node, so the engine, the wire server, and the
16
- * proof harness consume the frame without React; presentation resolves the contract's
17
- * view separately, by name, on a host.
18
- */
10
+ import type { UiClass, UiContract, UiReflection } from './ui'
11
+
19
12
  export type Frame<C extends UiContract = UiContract> = Structure<C>
20
13
 
21
- /** Everything the host injects per render: the instance props and the dependency tracker. */
22
- export interface RenderEnv {
23
- readonly props: unknown
14
+ export interface RenderEnv<out P = unknown> {
15
+ readonly props: P
24
16
  readonly tracker: Tracker
25
17
  }
26
18
 
27
- /** A mounted composition: given a render environment, produces the current frame —
28
- * a {@link Structure} value (see `Frame`). */
29
- export interface CompositionService {
30
- readonly render: (env: RenderEnv) => Effect.Effect<Frame, never, never>
19
+ export type CompositionActivation = Layer.Layer<never, never, never>
20
+
21
+ export interface CompositionService<in out P = unknown, out C extends UiContract = UiContract> {
22
+ readonly render: (env: RenderEnv<P>) => Effect.Effect<Frame<C>, never, never>
23
+ readonly activation: (props: P) => Option.Option<CompositionActivation>
24
+ }
25
+
26
+ export const CompositionIdTypeId: unique symbol = Symbol.for('reform/CompositionId')
27
+ export type CompositionIdTypeId = typeof CompositionIdTypeId
28
+
29
+ export interface CompositionId<
30
+ out N extends string,
31
+ out Identity = unknown,
32
+ out RequiredSlots = unknown,
33
+ > {
34
+ readonly [CompositionIdTypeId]: {
35
+ readonly name: N
36
+ readonly identity: Identity
37
+ readonly requiredSlots: RequiredSlots
38
+ }
39
+ }
40
+
41
+ type IsExact<Left, Right> = [Left] extends [Right] ? ([Right] extends [Left] ? true : false) : false
42
+
43
+ type IsExactCompositionId<Service, N extends string, Identity> = Service extends {
44
+ readonly [CompositionIdTypeId]: {
45
+ readonly name: infer ServiceName extends string
46
+ readonly identity: infer ServiceIdentity
47
+ }
48
+ }
49
+ ? IsExact<ServiceName, N> extends true
50
+ ? IsExact<ServiceIdentity, Identity>
51
+ : false
52
+ : false
53
+
54
+ type IsAny<T> = 0 extends 1 & T ? true : false
55
+
56
+ export type HasExactCompositionId<Services, N extends string, Identity> =
57
+ IsAny<Services> extends true
58
+ ? false
59
+ : true extends IsExactCompositionId<Services, N, Identity>
60
+ ? true
61
+ : false
62
+
63
+ export type CompositionTag<
64
+ N extends string,
65
+ P,
66
+ C extends UiContract,
67
+ Identity = unknown,
68
+ > = Context.Tag<CompositionId<N, Identity, RequiredSlotIdentities<C>>, CompositionService<P, C>>
69
+
70
+ export type SlotClassesOf<C extends UiContract> = C extends {
71
+ readonly slots: infer Slots extends Record<string, AnySlotInstance>
72
+ }
73
+ ? {
74
+ readonly [K in keyof Slots]: Slots[K] extends SlotInstance<
75
+ infer Child,
76
+ infer SlotName,
77
+ infer SlotIdentity
78
+ >
79
+ ? SlotClass<Child, SlotName, SlotIdentity>
80
+ : never
81
+ }
82
+ : Readonly<Record<PropertyKey, never>>
83
+
84
+ type RequiredSlotIdentity<Slot> =
85
+ Slot extends SlotInstance<infer _Target, infer _Name, infer Identity> ? Identity : never
86
+
87
+ export type RequiredSlotIdentities<C extends UiContract> = C extends {
88
+ readonly slots: infer Slots extends Record<string, AnySlotInstance>
89
+ }
90
+ ? RequiredSlotIdentity<Slots[keyof Slots]>
91
+ : never
92
+
93
+ type CompositionSlots<C extends UiContract> = C extends {
94
+ readonly slots: infer Slots extends Record<string, AnySlotInstance>
95
+ }
96
+ ? [Slots[keyof Slots]] extends [never]
97
+ ? {}
98
+ : { readonly slots: SlotClassesOf<C> }
99
+ : {}
100
+
101
+ export type CompositionManifest<
102
+ C extends UiContract = UiContract,
103
+ N extends string = string,
104
+ > = Manifest &
105
+ Omit<CompositionConfig, 'slots' | 'ui'> &
106
+ CompositionSlots<C> & {
107
+ readonly kind: 'Composition'
108
+ readonly name: N
109
+ readonly ui: UiReflection<C>
110
+ }
111
+
112
+ export interface CompositionInstance<out Identity = unknown> {
113
+ readonly _compositionIdentity: Identity
114
+ }
115
+
116
+ export interface CompositionReflection<
117
+ out P = unknown,
118
+ out C extends UiContract = UiContract,
119
+ out S extends ReadonlyArray<unknown> = ReadonlyArray<unknown>,
120
+ out N extends string = string,
121
+ out Identity = unknown,
122
+ > {
123
+ new (): CompositionInstance<Identity>
124
+ readonly manifest: CompositionManifest<C, N>
125
+ readonly Props: P
126
+ readonly Contract: C
127
+ readonly States: S
128
+ readonly parseProps: (input: unknown) => Option.Option<P>
129
+ }
130
+
131
+ export interface CompositionPropsReceiver<in P> {
132
+ readonly acceptsProps: (props: P) => void
133
+ }
134
+
135
+ export type CompositionCapture<Result> = <
136
+ P,
137
+ C extends UiContract,
138
+ S extends ReadonlyArray<unknown>,
139
+ N extends string,
140
+ Identity,
141
+ >(
142
+ composition: CompositionClass<P, C, S, N, Identity>,
143
+ ) => Result
144
+
145
+ export interface AnyComposition extends CompositionReflection {
146
+ readonly capture: <Result>(visit: CompositionCapture<Result>) => Result
147
+ }
148
+
149
+ interface CompositionSchema {
150
+ readonly ast: AST
31
151
  }
32
152
 
33
- // Reflectable manifest/config carrier: only `title` + `ui` are required, and the
34
- // optional members drive `Config extends { props: … }` / `{ states: … }` inference
35
- // (PropsOf/StatesOf) plus the ergonomic `Composition.make({ title, ui })` form, so
36
- // they cannot become `Option<T>` (would break inference and every call site) or be
37
- // renamed `ExternalApi` (would break external importers of `CompositionConfig`).
38
- export interface CompositionConfig {
153
+ type GeneratorContext<Eff> = [Eff] extends [never]
154
+ ? never
155
+ : [Eff] extends [YieldWrap<Effect.Effect<infer _Success, infer _Error, infer R>>]
156
+ ? R
157
+ : never
158
+
159
+ // Property presence drives Config conditional inference; Option would erase it.
160
+ interface CompositionConfigOptionalFields {
161
+ readonly description: string
162
+ readonly props: CompositionSchema
163
+ readonly states: ReadonlyArray<unknown>
164
+ readonly calcs: ReadonlyArray<unknown>
165
+ readonly events: ReadonlyArray<unknown>
166
+ readonly slots: Record<string, AnySlot>
167
+ }
168
+
169
+ export interface CompositionConfig extends Partial<CompositionConfigOptionalFields> {
39
170
  readonly title: string
40
- // oxlint-disable-next-line reform-rules/no-optional-fields -- optional config field on a reflectable manifest carrier (see note above)
41
- readonly description?: string
42
- // `any` in the encoded/context slots is required by Schema's variance for
43
- // branded/Class schemas (e.g. `Todo`); only the decoded type `P` is used.
44
- // oxlint-disable-next-line reform-rules/no-optional-fields -- drives `Config extends { props: }` inference (PropsOf)
45
- readonly props?: Schema.Schema<any, any>
46
- // oxlint-disable-next-line reform-rules/no-optional-fields -- drives `Config extends { states: … }` inference (StatesOf)
47
- readonly states?: ReadonlyArray<unknown>
48
- // oxlint-disable-next-line reform-rules/no-optional-fields -- optional config field on a reflectable manifest carrier (see note above)
49
- readonly calcs?: ReadonlyArray<unknown>
50
- // oxlint-disable-next-line reform-rules/no-optional-fields -- optional config field on a reflectable manifest carrier (see note above)
51
- readonly events?: ReadonlyArray<unknown>
52
- // oxlint-disable-next-line reform-rules/no-optional-fields -- optional config field on a reflectable manifest carrier (see note above)
53
- readonly slots?: Record<string, SlotClass>
54
- // The UI contract this composition resolves. Typed as a manifest carrier (not
55
- // the full `UiClass`, whose invariant `impl` tag would force the contract type
56
- // to leak here) so reflection — the dev tool, proofs — can read `ui.manifest`.
57
- readonly ui: { readonly manifest: Manifest & { readonly kind: 'Ui' } }
58
- }
59
-
60
- type PropsOf<Config> = Config extends { props: Schema.Schema<infer P, any> } ? P : unknown
61
-
62
- /**
63
- * The composition's state-group tuple, recovered from the `const`-inferred config
64
- * so consumers can type its seeds. Erased to `readonly []` when no `states` are
65
- * declared; entries that are not state groups contribute no seed keys (`SeedsOf`).
66
- */
67
- type StatesOf<Config> = Config extends { states: infer S extends ReadonlyArray<unknown> }
171
+ readonly ui: UiReflection
172
+ }
173
+
174
+ type PropsOf<Config> = Config extends { props: infer PropsSchema }
175
+ ? PropsSchema extends Schema.Schema<infer P, infer _Encoded, infer _Context>
176
+ ? P
177
+ : unknown
178
+ : unknown
179
+
180
+ type EncodedPropsOf<Config> = Config extends { props: infer PropsSchema }
181
+ ? PropsSchema extends Schema.Schema<infer _Props, infer Encoded, infer _Context>
182
+ ? Encoded
183
+ : unknown
184
+ : unknown
185
+
186
+ type StatesOf<Config> = Config extends {
187
+ states: infer S extends ReadonlyArray<unknown>
188
+ }
68
189
  ? S
69
190
  : readonly []
70
191
 
71
192
  export interface CompositionClass<
72
- out P,
73
- out C extends UiContract = UiContract,
193
+ in out P,
194
+ in out C extends UiContract = UiContract,
74
195
  out S extends ReadonlyArray<unknown> = ReadonlyArray<unknown>,
75
- > {
76
- new (): {}
77
- readonly manifest: Manifest & { readonly kind: 'Composition' } & CompositionConfig
78
- /** Phantom carrying the props type for `provide(slot, composition)` checks. */
196
+ in out N extends string = string,
197
+ in out Identity = unknown,
198
+ >
199
+ extends CompositionReflection<P, C, S, N, Identity>, CompositionPropsReceiver<P> {
79
200
  readonly Props: P
80
- /**
81
- * Phantom carrying the resolved UI contract (props + events + slots). It lets
82
- * reflection consumers — proofs, the dev tool — recover the full contract from
83
- * a composition value and stay fully typed. Covariant (output only); the
84
- * invariant `impl` tag is never stored here, so no variance regression.
85
- */
86
201
  readonly Contract: C
87
- /**
88
- * Phantom carrying the state-group tuple, so `seedScene` can type a scene's
89
- * seeds against the actual state members. Same erasure trick as `Contract`.
90
- */
91
202
  readonly States: S
92
- readonly tag: Context.Tag<CompositionService, CompositionService>
93
- }
94
-
95
- /**
96
- * A component's logic. The *definition* (`Composition.make`) is a reflectable
97
- * manifest that doubles as a requirement/tag; the *implementation*
98
- * (`Composition.live`) is the synchronous body, provided separately and
99
- * checked against it.
100
- */
101
- /** The contract-carrying overlay on a composition config — keeps `C` inferable at
102
- * the `Composition.make` call site while the stored manifest sees only the erased
103
- * `{ manifest }` carrier. */
104
- interface UiCarrier<C extends UiContract> {
105
- readonly ui: UiClass<C>
106
- }
107
-
108
- export const make = <const Config extends CompositionConfig, C extends UiContract>(
109
- name: string,
110
- // Constrain `ui` to the full `UiClass<C>` so `C` is inferred here; the stored
111
- // manifest still sees only the erased `{ manifest }` carrier (CompositionConfig),
112
- // so the manifest type — and its variance — is unchanged.
113
- config: Config & UiCarrier<C>,
114
- ): CompositionClass<PropsOf<Config>, C, StatesOf<Config>> => {
115
- const tag = Context.GenericTag<CompositionService, CompositionService>(
116
- `reform/composition/${name}`,
117
- )
118
- const manifest = { kind: 'Composition' as const, name, ...config }
119
- return definitionClass<CompositionClass<PropsOf<Config>, C, StatesOf<Config>>>({ manifest, tag })
203
+ readonly identity: symbol
204
+ readonly props: PropsTag<P>
205
+ readonly tag: CompositionTag<N, P, C, Identity>
206
+ readonly capture: <Result>(visit: CompositionCapture<Result>) => Result
207
+ }
208
+
209
+ type CompositionDefinitionStatics<
210
+ P,
211
+ C extends UiContract,
212
+ S extends ReadonlyArray<unknown>,
213
+ N extends string,
214
+ Identity,
215
+ > = Pick<
216
+ CompositionClass<P, C, S, N, Identity>,
217
+ 'manifest' | 'identity' | 'props' | 'parseProps' | 'acceptsProps' | 'tag' | 'capture'
218
+ >
219
+
220
+ interface UiCarrier<C extends UiContract, UiName extends string> {
221
+ readonly ui: UiClass<C, UiName>
222
+ }
223
+
224
+ type ContractSlots<C extends UiContract> = CompositionSlots<C>
225
+
226
+ type ContextFreeProps<Config> = Config extends {
227
+ readonly props: infer PropsSchema
228
+ }
229
+ ? PropsSchema extends Schema.Schema<infer _P, infer _Encoded, infer Context>
230
+ ? [Context] extends [never]
231
+ ? unknown
232
+ : { readonly propsSchemaMustNotRequireServices: never }
233
+ : unknown
234
+ : unknown
235
+
236
+ const compositionDefinitionSequence: MutableRef.MutableRef<number> = GlobalValue.globalValue(
237
+ Symbol.for('reform/CompositionDefinitionSequence'),
238
+ () => MutableRef.make(0),
239
+ )
240
+
241
+ const nextCompositionTagKey = (name: string): string =>
242
+ `reform/composition/${name}/${MutableRef.incrementAndGet(compositionDefinitionSequence)}`
243
+
244
+ export const make =
245
+ <
246
+ const N extends string,
247
+ const Config extends CompositionConfig,
248
+ C extends UiContract,
249
+ const UiName extends string,
250
+ >(
251
+ name: N,
252
+ config: Config & UiCarrier<C, UiName> & ContractSlots<C> & ContextFreeProps<Config>,
253
+ ) =>
254
+ <Self>(): CompositionClass<PropsOf<Config>, C, StatesOf<Config>, N, Self> => {
255
+ const identity = Symbol(name)
256
+ const props = propsTag<PropsOf<Config>>()
257
+ const propsSchema = Schema.make<PropsOf<Config>, EncodedPropsOf<Config>>(
258
+ config.props?.ast ?? Schema.Unknown.ast,
259
+ )
260
+ const parseProps: (input: unknown) => Option.Option<PropsOf<Config>> =
261
+ Schema.decodeUnknownOption(propsSchema)
262
+ const tag = Context.GenericTag<
263
+ CompositionId<N, Self, RequiredSlotIdentities<C>>,
264
+ CompositionService<PropsOf<Config>, C>
265
+ >(nextCompositionTagKey(name))
266
+ const manifest = {
267
+ kind: 'Composition',
268
+ name,
269
+ ...config,
270
+ } satisfies CompositionManifest<C, N>
271
+ const composition = definitionClass<
272
+ CompositionClass<PropsOf<Config>, C, StatesOf<Config>, N, Self>
273
+ >({
274
+ manifest,
275
+ identity,
276
+ props,
277
+ parseProps,
278
+ acceptsProps: () => undefined,
279
+ tag,
280
+ capture: <Result>(visit: CompositionCapture<Result>): Result => visit(composition),
281
+ } satisfies CompositionDefinitionStatics<PropsOf<Config>, C, StatesOf<Config>, N, Self>)
282
+ return composition
283
+ }
284
+
285
+ type CompositionLayerInput<Eff> = Exclude<Exclude<GeneratorContext<Eff>, PropsId>, CurrentTracker>
286
+
287
+ type CompositionActivationLayerInput<Eff> = Exclude<
288
+ Exclude<GeneratorContext<Eff>, PropsId>,
289
+ Scope.Scope
290
+ >
291
+
292
+ export interface ScopedCompositionLiveConfig<
293
+ C extends UiContract,
294
+ RenderEff extends YieldWrap<Effect.Effect<unknown, never, unknown>>,
295
+ ActivationEff extends YieldWrap<Effect.Effect<unknown, never, unknown>>,
296
+ > {
297
+ readonly render: () => Generator<RenderEff, Frame<C>, never>
298
+ readonly activate: () => Generator<ActivationEff, void, never>
120
299
  }
121
300
 
122
- /**
123
- * Wire a composition's synchronous logic (D2): read state, acquire triggers,
124
- * resolve the UI, return the view. Its requirements (minus `Props`, which the
125
- * host injects per render) surface as the Layer's `RIn`; the runtime runs the
126
- * body per render within the captured context.
127
- */
128
301
  export const live = <
129
302
  P,
130
303
  C extends UiContract,
304
+ S extends ReadonlyArray<unknown>,
305
+ N extends string,
306
+ Identity,
131
307
  Eff extends YieldWrap<Effect.Effect<unknown, never, unknown>>,
132
308
  >(
133
- // The contract `C` is captured (not erased to `any`) so the body's returned
134
- // `Structure` is checked against the composition's contract.
135
- composition: CompositionClass<P, C>,
309
+ composition: CompositionClass<P, C, S, N, Identity>,
136
310
  body: () => Generator<Eff, Frame<C>, never>,
137
- ): Layer.Layer<CompositionService, never, Exclude<Ctx<Eff>, Props>> => {
138
- const logic = Effect.gen(body)
311
+ ): Layer.Layer<
312
+ CompositionId<N, Identity, RequiredSlotIdentities<C>>,
313
+ never,
314
+ CompositionLayerInput<Eff>
315
+ > => {
316
+ const logic: Effect.Effect<Frame<C>, never, GeneratorContext<Eff>> = Effect.gen(body).pipe(
317
+ Effect.orDie,
318
+ )
139
319
  return Layer.effect(
140
320
  composition.tag,
141
321
  Effect.gen(function* () {
142
- // Capture the build-time context (state stores, calc, ui, bus). `Props` and
143
- // the tracker are injected per render below.
144
- const context = yield* Effect.context<Exclude<Ctx<Eff>, Props>>()
145
- const render = (env: RenderEnv): Effect.Effect<Frame, never, never> =>
146
- // The per-render erasure seam: the body is total once fully provided (it is
147
- // synchronous, reads can't fail, every requirement is now satisfied — D2) and
148
- // its `Structure<C>` erases to the service's contract-agnostic `Frame`. `gen`
149
- // widens the error channel to the body's inferred `E` for a generic body, so
150
- // narrowing it (and the contract) back to the erased service type needs the
151
- // `unknown` bridge — the one cast this render boundary has always required.
152
- // oxlint-disable-next-line reform-rules/no-type-assertion -- the one documented render-boundary erasure: a fully-provided generic body's Structure<C> narrowed back to the contract-agnostic Frame service type
153
- logic.pipe(
154
- Effect.provideService(Props, env.props),
322
+ const context = yield* Effect.context<CompositionLayerInput<Eff>>()
323
+ const render = (env: RenderEnv<P>): Effect.Effect<Frame<C>, never, never> => {
324
+ return logic.pipe(
325
+ Effect.provideService(composition.props, env.props),
155
326
  Effect.provideService(CurrentTracker, env.tracker),
156
327
  Effect.provide(context),
157
- ) as unknown as Effect.Effect<Frame, never, never>
158
- return { render }
328
+ )
329
+ }
330
+ return { render, activation: () => Option.none() }
159
331
  }),
160
332
  )
161
333
  }
162
334
 
163
- /** Run a mounted composition for one frame, producing its `Frame` (a `Structure`). */
164
- export const render = (service: CompositionService, env: RenderEnv): Effect.Effect<Frame, never, never> =>
165
- service.render(env)
335
+ export const liveScoped = <
336
+ P,
337
+ C extends UiContract,
338
+ S extends ReadonlyArray<unknown>,
339
+ N extends string,
340
+ Identity,
341
+ RenderEff extends YieldWrap<Effect.Effect<unknown, never, unknown>>,
342
+ ActivationEff extends YieldWrap<Effect.Effect<unknown, never, unknown>>,
343
+ >(
344
+ composition: CompositionClass<P, C, S, N, Identity>,
345
+ config: ScopedCompositionLiveConfig<C, RenderEff, ActivationEff>,
346
+ ): Layer.Layer<
347
+ CompositionId<N, Identity, RequiredSlotIdentities<C>>,
348
+ never,
349
+ CompositionLayerInput<RenderEff> | CompositionActivationLayerInput<ActivationEff>
350
+ > => {
351
+ const renderLogic: Effect.Effect<Frame<C>, never, GeneratorContext<RenderEff>> = Effect.gen(
352
+ config.render,
353
+ ).pipe(Effect.orDie)
354
+ const activationLogic: Effect.Effect<void, never, GeneratorContext<ActivationEff>> = Effect.gen(
355
+ config.activate,
356
+ ).pipe(Effect.orDie)
357
+
358
+ return Layer.effect(
359
+ composition.tag,
360
+ Effect.gen(function* () {
361
+ const renderContext = yield* Effect.context<CompositionLayerInput<RenderEff>>()
362
+ const activationContext =
363
+ yield* Effect.context<CompositionActivationLayerInput<ActivationEff>>()
364
+ const render = (env: RenderEnv<P>): Effect.Effect<Frame<C>, never, never> =>
365
+ renderLogic.pipe(
366
+ Effect.provideService(composition.props, env.props),
367
+ Effect.provideService(CurrentTracker, env.tracker),
368
+ Effect.provide(renderContext),
369
+ )
370
+ const activation = (props: P): Option.Option<CompositionActivation> => {
371
+ const scoped = Layer.scopedDiscard(
372
+ activationLogic.pipe(Effect.provideService(composition.props, props)),
373
+ )
374
+ return Option.some(scoped.pipe(Layer.provide(Layer.succeedContext(activationContext))))
375
+ }
376
+ return { render, activation }
377
+ }),
378
+ )
379
+ }
380
+
381
+ export const render = <P, C extends UiContract>(
382
+ service: CompositionService<P, C>,
383
+ env: RenderEnv<P>,
384
+ ): Effect.Effect<Frame<C>, never, never> => service.render(env)
385
+
386
+ export const activation = <P, C extends UiContract>(
387
+ service: CompositionService<P, C>,
388
+ props: P,
389
+ ): Option.Option<CompositionActivation> => service.activation(props)
@@ -1,11 +1,5 @@
1
1
  import type { Node } from './slot'
2
2
 
3
- /**
4
- * A slot handle the host hands a presentation: BOTH renderable (call it / use as a
5
- * component to place the keyed children) AND inspectable (`.props` — the array of
6
- * per-child fill props the structure carried, one entry per mounted child). The
7
- * structure host (`renderStructure`) populates `.props` from the fills. Plan 06.
8
- */
9
3
  export interface SlotRenderer {
10
4
  (props: unknown): Node
11
5
  readonly props: ReadonlyArray<unknown>
@@ -1,14 +1,15 @@
1
1
  import { Context } from 'effect'
2
2
 
3
- /**
4
- * The current composition instance's props. Bound per render by the runtime; a
5
- * composition body reads them with `const { todo } = yield* Props`.
6
- *
7
- * (Per-instance typing of Props is DESIGN #26 — for now the value is opaque and
8
- * narrowed at the use site against the composition's declared props schema.)
9
- */
10
- const PropsBase: Context.TagClass<Props, 'reform/Props', any> = Context.Tag('reform/Props')<
11
- Props,
12
- any
13
- >()
14
- export class Props extends PropsBase {}
3
+ export const PropsIdTypeId: unique symbol = Symbol.for('reform/PropsId')
4
+ export type PropsIdTypeId = typeof PropsIdTypeId
5
+
6
+ export interface PropsId {
7
+ readonly [PropsIdTypeId]: PropsIdTypeId
8
+ }
9
+
10
+ export type PropsTag<P> = Context.Tag<PropsId, P>
11
+
12
+ export const propsTag = <P>(): PropsTag<P> => Context.GenericTag<PropsId, P>('reform/Props')
13
+
14
+ // Unknown keeps the compatibility reader from leaking untyped props.
15
+ export const Props: PropsTag<unknown> = propsTag<unknown>()