@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,78 +1,308 @@
1
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'
7
29
  import { CurrentEventBudget, type ReformOptions, resolveEventBudget } from './eventBudget'
8
30
  import { type Instrumentation } from './instrumentation'
31
+ import { EngineRequirements, type EngineServices } from './loop'
9
32
 
10
- export interface FeatureMountHandlersExternalApi {
11
- readonly props?: unknown
12
- readonly onLive: (runtime: RuntimeHandle) => void
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>
51
+
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
63
+ readonly onFailed: (error: FeatureLoadFailed) => void
64
+ }
65
+
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
13
80
  readonly onFailed: (error: FeatureLoadFailed) => void
14
81
  }
15
- export type FeatureMountHandlers = FeatureMountHandlersExternalApi
16
82
 
17
- export interface RuntimeHandle {
18
- read<Identifier, Service>(tag: Context.Tag<Identifier, Service>): Service
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>
19
131
  runRender(effect: Effect.Effect<Node, never, never>): Node
20
132
  dispatch(priority: Priority, event: Tagged): void
21
- mountFeature(binding: FeatureBinding, handlers: FeatureMountHandlers): () => void
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
22
165
  readonly instrumentation: Instrumentation
23
166
  }
24
167
 
25
- export interface AppRuntime extends RuntimeHandle {
168
+ export interface AppRuntime<Services = UnspecifiedRuntimeServices> extends RuntimeHandle<Services> {
26
169
  boot(): void
27
170
  dispose(): void
28
171
  isDisposed(): boolean
29
172
  }
30
173
 
31
- export const makeAppRuntime = (scene: Scene, options: ReformOptions = {}): 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>>> => {
32
185
  const instrumentation = sceneInstrumentation(scene)
33
186
  const provide = scene.provide.map(Layer.locally(CurrentEventBudget, resolveEventBudget(options)))
34
187
  const layer = provide.reduce((accLayer, nextLayer) => Layer.merge(accLayer, nextLayer))
35
188
  const runtime = ManagedRuntime.make(layer)
36
- const rootContext = forceSync(() => runtime.runSync(Effect.context<MountedServices>()))
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)
37
196
 
38
- // Inert past dispose: host may still render tearing-down trees; forked fibers outlive cleanup
197
+ // Hosts can still render tearing-down trees after the managed runtime is disposed.
39
198
  const status = { disposed: false, booted: false }
199
+ const activationDisposers = new Set<() => void>()
40
200
 
41
- // Self-contained Effect runs on default runtime after dispose (never throw mid-render)
201
+ // A closed render Effect remains safe on the default runtime after disposal.
42
202
  const runRender = (effect: Effect.Effect<Node, never, never>): Node =>
43
203
  status.disposed ? Effect.runSync(effect) : runtime.runSync(effect)
44
204
 
45
205
  const dispatch = (priority: Priority, event: Tagged): void => {
46
- if (status.disposed) {
206
+ if (status.disposed || Option.isNone(rootBus)) {
47
207
  return
48
208
  }
49
- runtime.runFork(publish(priority, event))
209
+ runtime.runFork(publish(priority, event).pipe(Effect.provideService(Bus, rootBus.value)))
50
210
  }
51
211
 
52
- const makeChildRuntime = <R>(context: Context.Context<R>): RuntimeHandle => ({
53
- read: <Identifier, Service>(tag: Context.Tag<Identifier, Service>): Service =>
54
- 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),
55
246
  runRender,
56
247
  dispatch,
248
+ mountScoped,
57
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),
58
253
  instrumentation,
59
254
  })
60
255
 
61
- const mountFeatureOnto = <R>(
62
- hostContext: Context.Context<R>,
63
- binding: FeatureBinding,
64
- 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
+ >,
65
269
  ): (() => void) => {
66
270
  if (status.disposed) {
67
271
  return () => {}
68
272
  }
69
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
+ })
70
300
  const fiber = runtime.runFork(
71
- mountFeature(binding, hostContext, handlers.props).pipe(
301
+ mount.pipe(
72
302
  Effect.provideService(Scope.Scope, scope),
73
303
  Effect.matchCause({
74
304
  onFailure: (cause) => {
75
- // Disposer-cancelled mounts are not failures
305
+ // Disposer interruption is cancellation, not a failed feature.
76
306
  if (!Cause.isInterruptedOnly(cause)) {
77
307
  handlers.onFailed(
78
308
  Option.getOrElse(
@@ -82,14 +312,13 @@ export const makeAppRuntime = (scene: Scene, options: ReformOptions = {}): AppRu
82
312
  )
83
313
  }
84
314
  },
85
- onSuccess: (context) => handlers.onLive(makeChildRuntime(context)),
315
+ onSuccess: () => undefined,
86
316
  }),
87
317
  ),
88
318
  )
89
319
  return () => {
90
- // Interrupt sync before scheduler: Scope.close alone does not interrupt (StrictMode)
320
+ // Interrupt first, then await finalizer registration before closing the scope.
91
321
  fiber.unsafeInterruptAsFork(fiber.id())
92
- // Await mount so mid-build finalizers register before scope reclaim; close on default runtime if disposed
93
322
  const close = Fiber.await(fiber).pipe(
94
323
  Effect.zipRight(Scope.close(scope, Exit.succeed(undefined))),
95
324
  )
@@ -101,13 +330,125 @@ export const makeAppRuntime = (scene: Scene, options: ReformOptions = {}): AppRu
101
330
  }
102
331
  }
103
332
 
104
- const app: AppRuntime = {
105
- // Read captured context so tearing-down trees never hit ManagedRuntime disposed
106
- read: <Identifier, Service>(tag: Context.Tag<Identifier, Service>): Service =>
107
- Context.unsafeGet(rootContext, tag),
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
+ >,
349
+ ): (() => void) => {
350
+ if (status.disposed) {
351
+ return () => {}
352
+ }
353
+ const scope = Effect.runSync(Scope.make())
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(
394
+ Effect.provideService(Scope.Scope, scope),
395
+ Effect.matchCause({
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,
407
+ }),
408
+ ),
409
+ )
410
+ return () => {
411
+ fiber.unsafeInterruptAsFork(fiber.id())
412
+ const close = Fiber.await(fiber).pipe(
413
+ Effect.zipRight(Scope.close(scope, Exit.succeed(undefined))),
414
+ )
415
+ if (status.disposed) {
416
+ Effect.runFork(close)
417
+ } else {
418
+ runtime.runFork(close)
419
+ }
420
+ }
421
+ }
422
+
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),
108
445
  runRender,
109
446
  dispatch,
447
+ mountScoped,
110
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),
111
452
  boot: () => {
112
453
  if (status.disposed || status.booted) {
113
454
  return
@@ -122,6 +463,7 @@ export const makeAppRuntime = (scene: Scene, options: ReformOptions = {}): AppRu
122
463
  return
123
464
  }
124
465
  status.disposed = true
466
+ activationDisposers.forEach((dispose) => dispose())
125
467
  void runtime.dispose()
126
468
  },
127
469
  isDisposed: () => status.disposed,
@@ -129,3 +471,22 @@ export const makeAppRuntime = (scene: Scene, options: ReformOptions = {}): AppRu
129
471
  }
130
472
  return app
131
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
+ }
@@ -112,10 +112,7 @@ it.live('crossing the hard limit halts the runtime with EventLoopOverflow', () =
112
112
  yield* publish('Normal', Event.construct(Bumped, { to: 9_999 }))
113
113
  yield* Effect.sleep(Duration.millis(20))
114
114
  expect(store.get()).toBe(before)
115
- }).pipe(
116
- Effect.provide(TestLayer),
117
- Logger.withMinimumLogLevel(LogLevel.None),
118
- )
115
+ }).pipe(Effect.provide(TestLayer), Logger.withMinimumLogLevel(LogLevel.None))
119
116
  })
120
117
 
121
118
  it.live('the window resets when the event loop yields — bursty traffic never trips', () => {
@@ -2,16 +2,17 @@ import { Boolean as Bool, Effect, FiberRef, GlobalValue, MutableRef, Option } fr
2
2
  import { isDev } from '../internal/env'
3
3
  import { EventLoopOverflow } from '../internal/errors'
4
4
 
5
- export interface ReformOptions {
6
- // oxlint-disable-next-line reform-rules/no-optional-fields -- presence-optional public config; absent fields fall back to the built-in defaults
7
- readonly maxEventsPerJsTick?: {
8
- // oxlint-disable-next-line reform-rules/no-optional-fields -- presence-optional public config; absent fields fall back to the built-in defaults
9
- readonly errorAt?: number
10
- // oxlint-disable-next-line reform-rules/no-optional-fields -- presence-optional public config; absent fields fall back to the built-in defaults
11
- readonly warningAt?: number
12
- }
5
+ interface EventLimitFields {
6
+ readonly errorAt: number
7
+ readonly warningAt: number
13
8
  }
14
9
 
10
+ interface ReformOptionFields {
11
+ readonly maxEventsPerJsTick: Partial<EventLimitFields>
12
+ }
13
+
14
+ export interface ReformOptions extends Partial<ReformOptionFields> {}
15
+
15
16
  export interface EventBudget {
16
17
  readonly errorAt: number
17
18
  readonly warningAt: Option.Option<number>
@@ -1,6 +1,7 @@
1
1
  import { expect, it } from '@effect/vitest'
2
2
  import { Cause, Duration, Effect, Exit, Layer, LogLevel, Logger, Schema as S, Scope } from 'effect'
3
- import { Channel, Channels, Engine, Event, Procedure, publish, Reducer, State } from '../index'
3
+ import { Channel, Engine, Event, Procedure, publish, Reducer, State } from '../index'
4
+ import { Channels } from '../channel/channel'
4
5
 
5
6
  it.live('a reducer that throws is isolated; the loop keeps draining', () => {
6
7
  class Counter extends State.make('counter', S.Number) {}
@@ -27,10 +28,7 @@ it.live('a reducer that throws is isolated; the loop keeps draining', () => {
27
28
  yield* publish('Normal', Event.construct(Good, {}))
28
29
  yield* Effect.sleep(Duration.millis(10))
29
30
  expect(store.get()).toBe(1)
30
- }).pipe(
31
- Effect.provide(TestLayer),
32
- Logger.withMinimumLogLevel(LogLevel.None),
33
- )
31
+ }).pipe(Effect.provide(TestLayer), Logger.withMinimumLogLevel(LogLevel.None))
34
32
  })
35
33
 
36
34
  it.effect('two different channels registered under one name are rejected', () => {
@@ -91,10 +89,7 @@ it.live('remount overlap keeps the channel alive', () => {
91
89
  const secondScope = yield* Scope.make()
92
90
 
93
91
  yield* Layer.buildWithScope(Channel.live(FirstLane), firstScope)
94
- yield* Layer.buildWithScope(
95
- Layer.mergeAll(Channel.live(SecondLane), WorkLive),
96
- secondScope,
97
- )
92
+ yield* Layer.buildWithScope(Layer.mergeAll(Channel.live(SecondLane), WorkLive), secondScope)
98
93
  yield* Scope.close(firstScope, Exit.void)
99
94
 
100
95
  yield* publish('Normal', Event.construct(Fired, {}))