@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,140 +1,417 @@
1
- import { Cause, Context, Effect, Exit, Layer, ManagedRuntime, Option, Scope } from 'effect'
1
+ import { Cause, Context, Effect, Exit, Fiber, Layer, ManagedRuntime, Option, Scope } from 'effect'
2
+ import type {
3
+ CompositionActivation,
4
+ CompositionId,
5
+ RequiredSlotIdentities,
6
+ } from '../compose/composition'
7
+ import type { UiContract } from '../compose/ui'
8
+ import {
9
+ type AnyFeatureBinding,
10
+ type CapturedFeatureBinding,
11
+ type FeatureBinding,
12
+ FeatureInput,
13
+ type FeatureRequirement,
14
+ } from '../feature/feature'
15
+ import type {
16
+ AnyFeatureGraphSummary,
17
+ FeatureSummaryCompositionIdentity,
18
+ FeatureSummaryContract,
19
+ FeatureSummaryInput,
20
+ FeatureSummaryMountProps,
21
+ FeatureSummaryName,
22
+ FeatureSummaryOpenServices,
23
+ FeatureSummaryUsesInput,
24
+ } from '../graph/closure'
2
25
  import { FeatureLoadFailed, forceSync } from '../internal/errors'
3
- import { type FeatureBinding, mountFeature } from '../feature/feature'
4
26
  import { type Node } from '../ui/node'
5
- import { type MountedServices, type Scene, sceneInstrumentation } from '../scene/scene'
6
- import { publish, type Priority, type Tagged } from './bus'
27
+ import { type AnyScene, type CapturedScene, type Scene, sceneInstrumentation } from '../scene/scene'
28
+ import { Bus, publish, type Priority, type Tagged } from './bus'
29
+ import { CurrentEventBudget, type ReformOptions, resolveEventBudget } from './eventBudget'
7
30
  import { type Instrumentation } from './instrumentation'
31
+ import { EngineRequirements, type EngineServices } from './loop'
8
32
 
9
- // The renderer-neutral scene runtime: one `ManagedRuntime` over a scene's closed
10
- // layers, with the read/render/dispatch/feature-mount surface every host shares.
11
- // `@playfast/reform-react` builds its React lifecycle (StrictMode-deferred
12
- // dispose, providers) ON TOP of this; headless tests, proofs, and drive scripts
13
- // consume it directly — the "testing runtime" with no React and no harness.
33
+ export const UnspecifiedRuntimeServicesTypeId: unique symbol = Symbol.for(
34
+ 'reform/UnspecifiedRuntimeServices',
35
+ )
36
+ export type UnspecifiedRuntimeServicesTypeId = typeof UnspecifiedRuntimeServicesTypeId
37
+ export interface UnspecifiedRuntimeServices {
38
+ readonly [UnspecifiedRuntimeServicesTypeId]: UnspecifiedRuntimeServicesTypeId
39
+ }
40
+
41
+ export type FeatureMountHandlersExternalApi<UsesInput extends boolean, Input, Services> = {
42
+ readonly onLive: (runtime: RuntimeHandle<Services>) => void
43
+ readonly onFailed: (error: FeatureLoadFailed) => void
44
+ } & (UsesInput extends true ? { readonly props: Input } : { readonly props?: never })
45
+
46
+ export type FeatureMountHandlers<
47
+ UsesInput extends boolean,
48
+ Input,
49
+ Services,
50
+ > = FeatureMountHandlersExternalApi<UsesInput, Input, Services>
14
51
 
15
- /** The lazy-feature lifecycle callbacks a host hands to `mountFeature`. */
16
- export interface FeatureMountHandlers {
17
- readonly onLive: (runtime: RuntimeHandle) => void
52
+ export type RuntimeFeatureAvailability<Services, OpenServices> = [
53
+ EngineServices | OpenServices,
54
+ ] extends [Services]
55
+ ? object
56
+ : {
57
+ readonly missingFeatureServices: Exclude<EngineServices | OpenServices, Services>
58
+ }
59
+
60
+ export interface AnyFeatureMountHandlers {
61
+ readonly props: unknown
62
+ readonly onLive: <Services>(runtime: RuntimeHandle<Services>) => void
18
63
  readonly onFailed: (error: FeatureLoadFailed) => void
19
64
  }
20
65
 
21
- /**
22
- * The capabilities a host needs from a built reform runtime — what `Compose`
23
- * (React) and the proof facade depend on. A feature mount yields a child handle
24
- * over the feature's merged context, sharing the parent's render/dispatch.
25
- */
26
- export interface RuntimeHandle {
27
- /**
28
- * Resolve any reform DI tag (a composition's mounted logic, a slot's wired
29
- * child) from the app context. The mounted runtime provides every reform
30
- * service, so this never fails for tags from the same app.
31
- */
32
- read<A>(tag: Context.Tag<A, A>): A
33
- /** Run a fully-provided, synchronous render to its node (D2). */
66
+ export interface CapturedFeatureMountHandlers<
67
+ Props,
68
+ HostServices,
69
+ FeatureServices,
70
+ C extends UiContract,
71
+ N extends string,
72
+ Identity,
73
+ > {
74
+ readonly props: Props
75
+ readonly onLive: (
76
+ runtime: RuntimeHandle<
77
+ HostServices | FeatureServices | CompositionId<N, Identity, RequiredSlotIdentities<C>>
78
+ >,
79
+ ) => void
80
+ readonly onFailed: (error: FeatureLoadFailed) => void
81
+ }
82
+
83
+ const isObjectLike = (candidate: unknown): candidate is object =>
84
+ (typeof candidate === 'object' || typeof candidate === 'function') && candidate !== null
85
+
86
+ const isContextTag = (candidate: unknown): candidate is Context.Tag<unknown, unknown> =>
87
+ isObjectLike(candidate) &&
88
+ Context.TagTypeId in candidate &&
89
+ 'key' in candidate &&
90
+ typeof candidate.key === 'string'
91
+
92
+ const isUnknownArray = (candidate: unknown): candidate is ReadonlyArray<unknown> =>
93
+ Array.isArray(candidate)
94
+
95
+ const requirementAvailable = <Services>(
96
+ context: Context.Context<Services>,
97
+ requirement: unknown,
98
+ ): boolean => {
99
+ if (isContextTag(requirement)) {
100
+ return Option.isSome(Context.getOption(context, requirement))
101
+ }
102
+ if (!isObjectLike(requirement)) {
103
+ return false
104
+ }
105
+ if ('store' in requirement) {
106
+ return requirementAvailable(context, requirement.store)
107
+ }
108
+ if ('members' in requirement && isUnknownArray(requirement.members)) {
109
+ return requirement.members.every((member) => requirementAvailable(context, member))
110
+ }
111
+ return false
112
+ }
113
+
114
+ const hasFeatureRequirements = <
115
+ Services,
116
+ OpenServices,
117
+ const DirectRequires extends ReadonlyArray<FeatureRequirement>,
118
+ >(
119
+ context: Context.Context<Services>,
120
+ requires: DirectRequires,
121
+ ): context is Context.Context<Services | EngineServices | OpenServices> =>
122
+ EngineRequirements.every((tag) => requirementAvailable(context, tag)) &&
123
+ requires.every((requirement) => requirementAvailable(context, requirement))
124
+
125
+ const featureMountFailure = (message: string): FeatureLoadFailed =>
126
+ new FeatureLoadFailed({ cause: message })
127
+
128
+ export interface RuntimeHandle<Services = UnspecifiedRuntimeServices> {
129
+ read<Identifier extends Services, Service>(tag: Context.Tag<Identifier, Service>): Service
130
+ readOption<Identifier, Service>(tag: Context.Tag<Identifier, Service>): Option.Option<Service>
34
131
  runRender(effect: Effect.Effect<Node, never, never>): Node
35
- /** Dispatch a runtime/boot event onto the bus. */
36
132
  dispatch(priority: Priority, event: Tagged): void
37
- /**
38
- * Mount a feature against this runtime: load its module (lazy = dynamic import),
39
- * build its `.live` layer on a fresh scope sharing this runtime's engine, and
40
- * call `onLive` with a child runtime that reads the feature's composition/stores
41
- * (and the shared services), or `onFailed` with a typed `FeatureLoadFailed`.
42
- * Returns a disposer that closes the feature's scope — stopping its fibers and
43
- * reclaiming its reducers/stores.
44
- */
45
- mountFeature(binding: FeatureBinding, handlers: FeatureMountHandlers): () => void
46
- /**
47
- * The scene's profiling hooks (`noopInstrumentation` unless `profileScene`d).
48
- * On the handle (not only the app runtime) because render spans are recorded
49
- * by whatever holds the handle — React `Compose`, the proof engine — and a
50
- * feature's child handle must record into the same profiler as its parent.
51
- */
133
+ mountScoped(activation: CompositionActivation): () => void
134
+ mountFeature<
135
+ Summary extends AnyFeatureGraphSummary,
136
+ ROut,
137
+ const DirectRequires extends ReadonlyArray<FeatureRequirement>,
138
+ >(
139
+ binding: [EngineServices | FeatureSummaryOpenServices<Summary>] extends [Services]
140
+ ? FeatureBinding<Summary, ROut, DirectRequires>
141
+ : never,
142
+ handlers: FeatureMountHandlers<
143
+ FeatureSummaryUsesInput<Summary>,
144
+ FeatureSummaryInput<Summary>,
145
+ Services | ROut
146
+ >,
147
+ ): () => void
148
+ mountCapturedFeature<
149
+ Summary extends AnyFeatureGraphSummary,
150
+ ROut,
151
+ const DirectRequires extends ReadonlyArray<FeatureRequirement>,
152
+ >(
153
+ binding: CapturedFeatureBinding<Summary, ROut, DirectRequires> &
154
+ RuntimeFeatureAvailability<Services, FeatureSummaryOpenServices<Summary>>,
155
+ handlers: CapturedFeatureMountHandlers<
156
+ FeatureSummaryMountProps<Summary>,
157
+ Services,
158
+ ROut,
159
+ FeatureSummaryContract<Summary>,
160
+ FeatureSummaryName<Summary>,
161
+ FeatureSummaryCompositionIdentity<Summary>
162
+ >,
163
+ ): () => void
164
+ mountAnyFeature(binding: AnyFeatureBinding, handlers: AnyFeatureMountHandlers): () => void
52
165
  readonly instrumentation: Instrumentation
53
166
  }
54
167
 
55
- /** A built scene runtime: the handle plus its boot/dispose lifecycle. */
56
- export interface AppRuntime extends RuntimeHandle {
57
- /** Dispatch the scene's boot events, once — later calls are no-ops. */
168
+ export interface AppRuntime<Services = UnspecifiedRuntimeServices> extends RuntimeHandle<Services> {
58
169
  boot(): void
59
- /** Interrupt the runtime and latch the handle inert. Immediate and idempotent. */
60
170
  dispose(): void
61
- /** True once disposed, so a host can rebuild rather than reuse a dead runtime. */
62
171
  isDisposed(): boolean
63
172
  }
64
173
 
65
- /**
66
- * Build the concrete runtime over a `ManagedRuntime` made from a scene's closed
67
- * layers. The mounted runtime provides every reform tag, so reading any one
68
- * needs no further context; we erase the requirement the generic tag carries.
69
- */
70
- export const makeAppRuntime = (scene: Scene): AppRuntime => {
174
+ const makeCapturedAppRuntime = <
175
+ C extends UiContract,
176
+ S extends ReadonlyArray<unknown>,
177
+ Services,
178
+ P,
179
+ N extends string,
180
+ Identity,
181
+ >(
182
+ scene: CapturedScene<C, S, Services, P, N, Identity>,
183
+ options: ReformOptions = {},
184
+ ): AppRuntime<Services | CompositionId<N, Identity, RequiredSlotIdentities<C>>> => {
71
185
  const instrumentation = sceneInstrumentation(scene)
72
- // A scene always provides at least one layer; merge them into the app layer.
73
- const layer = scene.provide.reduce((accLayer, nextLayer) => Layer.merge(accLayer, nextLayer))
186
+ const provide = scene.provide.map(Layer.locally(CurrentEventBudget, resolveEventBudget(options)))
187
+ const layer = provide.reduce((accLayer, nextLayer) => Layer.merge(accLayer, nextLayer))
74
188
  const runtime = ManagedRuntime.make(layer)
75
- // The full app context (all services), captured once. A feature's `.live` layer
76
- // is built against this so it shares the one engine (bus + registries). Typed as
77
- // the host's `MountedServices` subset; at runtime it carries every service.
78
- const rootContext = forceSync(() => runtime.runSync(Effect.context<MountedServices>()))
79
-
80
- // Past actual dispose the `ManagedRuntime` is interrupted and every `runSync` /
81
- // `runFork` against it throws `ManagedRuntime disposed`. A host can still render
82
- // a tearing-down tree, and forked fibers (a RemoteState load, a boot dispatch)
83
- // outlive the host cleanup so the runtime is made inert past dispose instead
84
- // of throwing into a render. A `const` latch mutated in place.
189
+ const capturedContext = forceSync(() => runtime.runSync(Effect.context<Services>()))
190
+ const rootCompositionService = Option.getOrThrow(
191
+ Context.getOption(capturedContext, scene.composition.tag),
192
+ )
193
+ // Re-add the exact tag so the captured context retains the root's nominal identity.
194
+ const rootContext = Context.add(capturedContext, scene.composition.tag, rootCompositionService)
195
+ const rootBus = Context.getOption(rootContext, Bus)
196
+
197
+ // Hosts can still render tearing-down trees after the managed runtime is disposed.
85
198
  const status = { disposed: false, booted: false }
199
+ const activationDisposers = new Set<() => void>()
86
200
 
87
- // A render is a self-contained `Effect<Node, never, never>` (no service needs),
88
- // so once the managed runtime is gone it still runs on the default runtime —
89
- // yielding a real node from the current store values, never a throw mid-render.
201
+ // A closed render Effect remains safe on the default runtime after disposal.
90
202
  const runRender = (effect: Effect.Effect<Node, never, never>): Node =>
91
203
  status.disposed ? Effect.runSync(effect) : runtime.runSync(effect)
92
204
 
93
- // A dispatch past dispose has no live bus to reach; drop it.
94
205
  const dispatch = (priority: Priority, event: Tagged): void => {
95
- if (status.disposed) {
206
+ if (status.disposed || Option.isNone(rootBus)) {
96
207
  return
97
208
  }
98
- runtime.runFork(publish(priority, event))
209
+ runtime.runFork(publish(priority, event).pipe(Effect.provideService(Bus, rootBus.value)))
99
210
  }
100
211
 
101
- // A child runtime over a feature's merged context (feature services + the shared
102
- // engine). Reads resolve from that context; renders/dispatch share the
103
- // inert-past-dispose helpers above.
104
- const makeChildRuntime = (context: Context.Context<unknown>): RuntimeHandle => ({
105
- read: <A>(tag: Context.Tag<A, A>): A => Context.unsafeGet(context, tag),
212
+ const mountScoped = (activation: CompositionActivation): (() => void) => {
213
+ if (status.disposed) {
214
+ return () => {}
215
+ }
216
+ const scope = Effect.runSync(Scope.make())
217
+ const liveness = { active: true }
218
+ const fiber = runtime.runFork(
219
+ Layer.build(activation).pipe(Effect.provideService(Scope.Scope, scope)),
220
+ )
221
+ const dispose = (): void => {
222
+ if (!liveness.active) {
223
+ return
224
+ }
225
+ liveness.active = false
226
+ fiber.unsafeInterruptAsFork(fiber.id())
227
+ const close = Fiber.await(fiber).pipe(
228
+ Effect.zipRight(Scope.close(scope, Exit.succeed(undefined))),
229
+ Effect.ensuring(Effect.sync(() => activationDisposers.delete(dispose))),
230
+ )
231
+ Effect.runFork(close)
232
+ }
233
+ activationDisposers.add(dispose)
234
+ return dispose
235
+ }
236
+
237
+ const makeChildRuntime = <ChildServices>(
238
+ context: Context.Context<ChildServices>,
239
+ ): RuntimeHandle<ChildServices> => ({
240
+ read: <Identifier extends ChildServices, Service>(
241
+ tag: Context.Tag<Identifier, Service>,
242
+ ): Service => Context.get(context, tag),
243
+ readOption: <Identifier, Service>(
244
+ tag: Context.Tag<Identifier, Service>,
245
+ ): Option.Option<Service> => Context.getOption(context, tag),
106
246
  runRender,
107
247
  dispatch,
108
- mountFeature: (binding, handlers) => mountFeatureOnto(binding, handlers),
248
+ mountScoped,
249
+ mountFeature: (binding, handlers) => mountFeatureOnto(context, binding, handlers),
250
+ mountCapturedFeature: (binding, handlers) =>
251
+ mountCapturedFeatureOnto(context, binding, handlers),
252
+ mountAnyFeature: (binding, handlers) => mountAnyFeatureOnto(context, binding, handlers),
109
253
  instrumentation,
110
254
  })
111
255
 
112
- const mountFeatureOnto = (
113
- binding: FeatureBinding,
114
- handlers: FeatureMountHandlers,
256
+ const mountFeatureOnto = <
257
+ HostServices,
258
+ Summary extends AnyFeatureGraphSummary,
259
+ ROut,
260
+ const DirectRequires extends ReadonlyArray<FeatureRequirement>,
261
+ >(
262
+ hostContext: Context.Context<HostServices>,
263
+ binding: FeatureBinding<Summary, ROut, DirectRequires>,
264
+ handlers: FeatureMountHandlers<
265
+ FeatureSummaryUsesInput<Summary>,
266
+ FeatureSummaryInput<Summary>,
267
+ HostServices | ROut
268
+ >,
269
+ ): (() => void) => {
270
+ if (status.disposed) {
271
+ return () => {}
272
+ }
273
+ const scope = Effect.runSync(Scope.make())
274
+ const mount = Effect.flatMap(binding.load, (loaded) => {
275
+ if (
276
+ !hasFeatureRequirements<HostServices, FeatureSummaryOpenServices<Summary>, DirectRequires>(
277
+ hostContext,
278
+ loaded.requires,
279
+ )
280
+ ) {
281
+ return Effect.fail(
282
+ featureMountFailure(
283
+ `Feature ${binding.manifest.name} requires services missing from the host runtime`,
284
+ ),
285
+ )
286
+ }
287
+ return Effect.gen(function* () {
288
+ const bus = Context.get(hostContext, Bus)
289
+ const featureContext = Context.add(hostContext, FeatureInput, handlers.props)
290
+ const context = yield* Layer.build(
291
+ Layer.provide(loaded.layer, Layer.succeedContext(featureContext)),
292
+ )
293
+ yield* Effect.forEach(binding.boot, (event) => publish('High', event), {
294
+ discard: true,
295
+ }).pipe(Effect.provideService(Bus, bus))
296
+ const merged = Context.merge(hostContext, context)
297
+ yield* Effect.sync(() => handlers.onLive(makeChildRuntime<HostServices | ROut>(merged)))
298
+ })
299
+ })
300
+ const fiber = runtime.runFork(
301
+ mount.pipe(
302
+ Effect.provideService(Scope.Scope, scope),
303
+ Effect.matchCause({
304
+ onFailure: (cause) => {
305
+ // Disposer interruption is cancellation, not a failed feature.
306
+ if (!Cause.isInterruptedOnly(cause)) {
307
+ handlers.onFailed(
308
+ Option.getOrElse(
309
+ Cause.failureOption(cause),
310
+ () => new FeatureLoadFailed({ cause }),
311
+ ),
312
+ )
313
+ }
314
+ },
315
+ onSuccess: () => undefined,
316
+ }),
317
+ ),
318
+ )
319
+ return () => {
320
+ // Interrupt first, then await finalizer registration before closing the scope.
321
+ fiber.unsafeInterruptAsFork(fiber.id())
322
+ const close = Fiber.await(fiber).pipe(
323
+ Effect.zipRight(Scope.close(scope, Exit.succeed(undefined))),
324
+ )
325
+ if (status.disposed) {
326
+ Effect.runFork(close)
327
+ } else {
328
+ runtime.runFork(close)
329
+ }
330
+ }
331
+ }
332
+
333
+ const mountCapturedFeatureOnto = <
334
+ HostServices,
335
+ Summary extends AnyFeatureGraphSummary,
336
+ ROut,
337
+ const DirectRequires extends ReadonlyArray<FeatureRequirement>,
338
+ >(
339
+ hostContext: Context.Context<HostServices>,
340
+ binding: CapturedFeatureBinding<Summary, ROut, DirectRequires>,
341
+ handlers: CapturedFeatureMountHandlers<
342
+ FeatureSummaryMountProps<Summary>,
343
+ HostServices,
344
+ ROut,
345
+ FeatureSummaryContract<Summary>,
346
+ FeatureSummaryName<Summary>,
347
+ FeatureSummaryCompositionIdentity<Summary>
348
+ >,
115
349
  ): (() => void) => {
116
- // Don't mount onto a runtime that's already torn down — the tree is unmounting.
117
350
  if (status.disposed) {
118
351
  return () => {}
119
352
  }
120
353
  const scope = Effect.runSync(Scope.make())
121
- runtime.runFork(
122
- mountFeature(binding, rootContext).pipe(
354
+ const mount = Effect.flatMap(binding.load, (loaded) => {
355
+ if (
356
+ !hasFeatureRequirements<HostServices, FeatureSummaryOpenServices<Summary>, DirectRequires>(
357
+ hostContext,
358
+ loaded.requires,
359
+ )
360
+ ) {
361
+ return Effect.fail(
362
+ featureMountFailure(
363
+ `Feature ${binding.manifest.name} requires services missing from the host runtime`,
364
+ ),
365
+ )
366
+ }
367
+ const input = loaded.usesInput ? binding.parseInput(handlers.props) : Option.some(undefined)
368
+ return Option.match(input, {
369
+ onNone: () =>
370
+ Effect.fail(
371
+ featureMountFailure(`Feature ${binding.manifest.name} received invalid props`),
372
+ ),
373
+ onSome: (props) =>
374
+ Effect.gen(function* () {
375
+ const bus = Context.get(hostContext, Bus)
376
+ const featureContext = Context.add(hostContext, FeatureInput, props)
377
+ const context = yield* Layer.build(
378
+ Layer.provide(loaded.layer, Layer.succeedContext(featureContext)),
379
+ )
380
+ yield* Effect.forEach(binding.boot, (event) => publish('High', event), {
381
+ discard: true,
382
+ }).pipe(Effect.provideService(Bus, bus))
383
+ const merged = Context.merge(hostContext, context)
384
+ const compositionService = Option.getOrThrow(
385
+ Context.getOption(merged, binding.composition.tag),
386
+ )
387
+ const rooted = Context.add(merged, binding.composition.tag, compositionService)
388
+ yield* Effect.sync(() => handlers.onLive(makeChildRuntime(rooted)))
389
+ }),
390
+ })
391
+ })
392
+ const fiber = runtime.runFork(
393
+ mount.pipe(
123
394
  Effect.provideService(Scope.Scope, scope),
124
395
  Effect.matchCause({
125
- onFailure: (cause) =>
126
- handlers.onFailed(
127
- Option.getOrElse(Cause.failureOption(cause), () => new FeatureLoadFailed({ cause })),
128
- ),
129
- onSuccess: (context) => handlers.onLive(makeChildRuntime(context)),
396
+ onFailure: (cause) => {
397
+ if (!Cause.isInterruptedOnly(cause)) {
398
+ handlers.onFailed(
399
+ Option.getOrElse(
400
+ Cause.failureOption(cause),
401
+ () => new FeatureLoadFailed({ cause }),
402
+ ),
403
+ )
404
+ }
405
+ },
406
+ onSuccess: () => undefined,
130
407
  }),
131
408
  ),
132
409
  )
133
410
  return () => {
134
- // The scope's finalizers (reducer/store reclaim) are self-contained, so when
135
- // the managed runtime is already disposed they close on the default runtime
136
- // rather than throwing through a dead `runFork`.
137
- const close = Scope.close(scope, Exit.succeed(undefined))
411
+ fiber.unsafeInterruptAsFork(fiber.id())
412
+ const close = Fiber.await(fiber).pipe(
413
+ Effect.zipRight(Scope.close(scope, Exit.succeed(undefined))),
414
+ )
138
415
  if (status.disposed) {
139
416
  Effect.runFork(close)
140
417
  } else {
@@ -143,14 +420,35 @@ export const makeAppRuntime = (scene: Scene): AppRuntime => {
143
420
  }
144
421
  }
145
422
 
146
- const app: AppRuntime = {
147
- // Read from the captured context (it carries every service), not
148
- // `runtime.runSync(tag)`, so a read from a tearing-down tree can't throw
149
- // `ManagedRuntime disposed` — and it drops the cast the runSync form needed.
150
- read: <A>(tag: Context.Tag<A, A>): A => Context.unsafeGet(rootContext, tag),
423
+ const mountAnyFeatureOnto = <HostServices>(
424
+ hostContext: Context.Context<HostServices>,
425
+ binding: AnyFeatureBinding,
426
+ handlers: AnyFeatureMountHandlers,
427
+ ): (() => void) =>
428
+ binding.capture((exactBinding) =>
429
+ mountCapturedFeatureOnto(hostContext, exactBinding, {
430
+ props: handlers.props,
431
+ onLive: (childRuntime) => handlers.onLive(childRuntime),
432
+ onFailed: handlers.onFailed,
433
+ }),
434
+ )
435
+
436
+ type RootServices = Services | CompositionId<N, Identity, RequiredSlotIdentities<C>>
437
+ const app: AppRuntime<RootServices> = {
438
+ // Captured reads stay available while the managed runtime is tearing down.
439
+ read: <Identifier extends RootServices, Service>(
440
+ tag: Context.Tag<Identifier, Service>,
441
+ ): Service => Context.get(rootContext, tag),
442
+ readOption: <Identifier, Service>(
443
+ tag: Context.Tag<Identifier, Service>,
444
+ ): Option.Option<Service> => Context.getOption(rootContext, tag),
151
445
  runRender,
152
446
  dispatch,
153
- mountFeature: (binding, handlers) => mountFeatureOnto(binding, handlers),
447
+ mountScoped,
448
+ mountFeature: (binding, handlers) => mountFeatureOnto(rootContext, binding, handlers),
449
+ mountCapturedFeature: (binding, handlers) =>
450
+ mountCapturedFeatureOnto(rootContext, binding, handlers),
451
+ mountAnyFeature: (binding, handlers) => mountAnyFeatureOnto(rootContext, binding, handlers),
154
452
  boot: () => {
155
453
  if (status.disposed || status.booted) {
156
454
  return
@@ -165,6 +463,7 @@ export const makeAppRuntime = (scene: Scene): AppRuntime => {
165
463
  return
166
464
  }
167
465
  status.disposed = true
466
+ activationDisposers.forEach((dispose) => dispose())
168
467
  void runtime.dispose()
169
468
  },
170
469
  isDisposed: () => status.disposed,
@@ -172,3 +471,22 @@ export const makeAppRuntime = (scene: Scene): AppRuntime => {
172
471
  }
173
472
  return app
174
473
  }
474
+
475
+ export function makeAppRuntime<
476
+ C extends UiContract,
477
+ S extends ReadonlyArray<unknown>,
478
+ Services,
479
+ P,
480
+ N extends string,
481
+ Identity,
482
+ >(
483
+ scene: CapturedScene<C, S, Services, P, N, Identity>,
484
+ options?: ReformOptions,
485
+ ): AppRuntime<Services | CompositionId<N, Identity, RequiredSlotIdentities<C>>>
486
+ export function makeAppRuntime<C extends UiContract, S extends ReadonlyArray<unknown>>(
487
+ scene: Scene<C, S>,
488
+ options?: ReformOptions,
489
+ ): AppRuntime<UnspecifiedRuntimeServices>
490
+ export function makeAppRuntime(scene: AnyScene, options: ReformOptions = {}): unknown {
491
+ return scene.captureAny<unknown>((exactScene) => makeCapturedAppRuntime(exactScene, options))
492
+ }
@@ -1,22 +1,14 @@
1
1
  import { Context, Effect, Layer, PubSub } from 'effect'
2
2
 
3
- /** A dispatched event: a tagged data record (`{ _tag, ...payload }`). */
4
3
  export interface Tagged {
5
4
  readonly _tag: string
6
5
  }
7
6
 
8
- /**
9
- * Re-narrow an erased `Tagged` envelope to a handler's declared event union. The
10
- * loop and channels only route events whose tag is in a handler's `handles`, so
11
- * at runtime the envelope IS one of those events; the registries store it as the
12
- * generic `Tagged`, and this restores the typed view. The one documented home
13
- * for that boundary cast, shared by `Reducer.live` and `Procedure.live`.
14
- */
7
+ // Re-narrow routed Tagged to handler event union (handles set guarantees membership)
15
8
  export const narrowHandled = <E>(event: Tagged): E =>
16
9
  // oxlint-disable-next-line reform-rules/no-type-assertion -- documented runtime re-narrow of a routed Tagged envelope to its handler's declared event union (see jsdoc above)
17
10
  event as never
18
11
 
19
- /** UI-originated events are High; procedure-emitted follow-ups are Normal (DESIGN #46). */
20
12
  export type Priority = 'High' | 'Normal'
21
13
 
22
14
  export interface Envelope {
@@ -28,14 +20,9 @@ const BusBase: Context.TagClass<Bus, 'reform/Bus', PubSub.PubSub<Envelope>> = Co
28
20
  'reform/Bus',
29
21
  )<Bus, PubSub.PubSub<Envelope>>()
30
22
 
31
- /** The single dispatch bus: an unbounded PubSub fanned out to the loop and procedures. */
32
23
  export class Bus extends BusBase {}
33
24
 
34
- // Unbounded so `publish` never suspends: UI triggers dispatch synchronously
35
- // (`Runtime.runSync`, no fiber fork per event), and an event storm can't apply
36
- // backpressure to the UI thread. The single drain loop empties the bus every
37
- // microtask, so it does not accumulate. Swap to `PubSub.dropping(N)` if bounded
38
- // memory is preferred over never dropping (publish stays synchronous either way).
25
+ // Unbounded so publish never suspends (UI runSync); drain empties every microtask
39
26
  export const busLayer: Layer.Layer<Bus> = Layer.scoped(Bus, PubSub.unbounded<Envelope>())
40
27
 
41
28
  export const publish = (priority: Priority, event: Tagged): Effect.Effect<void, never, Bus> =>