@playfast/reform-proof 1.2.0 → 1.3.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/dist/assert.d.ts +15 -0
  2. package/dist/assert.d.ts.map +1 -0
  3. package/dist/assert.js +39 -0
  4. package/dist/assert.js.map +1 -0
  5. package/dist/engine.d.ts +16 -0
  6. package/dist/engine.d.ts.map +1 -0
  7. package/dist/engine.js +119 -0
  8. package/dist/engine.js.map +1 -0
  9. package/dist/engineMounted.d.ts +18 -0
  10. package/dist/engineMounted.d.ts.map +1 -0
  11. package/dist/engineMounted.js +96 -0
  12. package/dist/engineMounted.js.map +1 -0
  13. package/dist/engineMounts.d.ts +13 -0
  14. package/dist/engineMounts.d.ts.map +1 -0
  15. package/dist/engineMounts.js +116 -0
  16. package/dist/engineMounts.js.map +1 -0
  17. package/dist/engineRender.d.ts +10 -0
  18. package/dist/engineRender.d.ts.map +1 -0
  19. package/dist/engineRender.js +64 -0
  20. package/dist/engineRender.js.map +1 -0
  21. package/dist/engineSink.d.ts +66 -0
  22. package/dist/engineSink.d.ts.map +1 -0
  23. package/dist/engineSink.js +72 -0
  24. package/dist/engineSink.js.map +1 -0
  25. package/dist/engineTree.d.ts +63 -0
  26. package/dist/engineTree.d.ts.map +1 -0
  27. package/dist/engineTree.js +137 -0
  28. package/dist/engineTree.js.map +1 -0
  29. package/dist/engineTreeDriver.d.ts +5 -0
  30. package/dist/engineTreeDriver.d.ts.map +1 -0
  31. package/dist/engineTreeDriver.js +187 -0
  32. package/dist/engineTreeDriver.js.map +1 -0
  33. package/dist/engineTreeFacade.d.ts +5 -0
  34. package/dist/engineTreeFacade.d.ts.map +1 -0
  35. package/dist/engineTreeFacade.js +105 -0
  36. package/dist/engineTreeFacade.js.map +1 -0
  37. package/dist/engineTreeTypes.d.ts +80 -0
  38. package/dist/engineTreeTypes.d.ts.map +1 -0
  39. package/dist/engineTreeTypes.js +11 -0
  40. package/dist/engineTreeTypes.js.map +1 -0
  41. package/dist/errors.d.ts +39 -0
  42. package/dist/errors.d.ts.map +1 -0
  43. package/dist/errors.js +30 -0
  44. package/dist/errors.js.map +1 -0
  45. package/dist/facade.d.ts +54 -0
  46. package/dist/facade.d.ts.map +1 -0
  47. package/dist/facade.js +2 -0
  48. package/dist/facade.js.map +1 -0
  49. package/dist/fingerprint.d.ts +2 -0
  50. package/dist/fingerprint.d.ts.map +1 -0
  51. package/dist/fingerprint.js +55 -0
  52. package/dist/fingerprint.js.map +1 -0
  53. package/dist/index.d.ts +88 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +73 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/product.d.ts +56 -0
  58. package/dist/product.d.ts.map +1 -0
  59. package/dist/product.js +35 -0
  60. package/dist/product.js.map +1 -0
  61. package/dist/proofCase.d.ts +24 -0
  62. package/dist/proofCase.d.ts.map +1 -0
  63. package/dist/proofCase.js +2 -0
  64. package/dist/proofCase.js.map +1 -0
  65. package/dist/runner.d.ts +15 -0
  66. package/dist/runner.d.ts.map +1 -0
  67. package/dist/runner.js +13 -0
  68. package/dist/runner.js.map +1 -0
  69. package/package.json +17 -5
  70. package/src/assert.ts +65 -0
  71. package/src/engine.ts +30 -1402
  72. package/src/engineMounted.ts +271 -0
  73. package/src/engineMounts.ts +165 -0
  74. package/src/engineRender.ts +136 -0
  75. package/src/engineSink.ts +195 -0
  76. package/src/engineTree.ts +298 -0
  77. package/src/engineTreeDriver.ts +247 -0
  78. package/src/engineTreeFacade.ts +183 -0
  79. package/src/engineTreeTypes.ts +103 -0
  80. package/src/errors.ts +19 -0
  81. package/src/facade.ts +78 -0
  82. package/src/index.ts +61 -276
  83. package/src/product.ts +108 -0
  84. package/src/proofCase.ts +46 -0
  85. package/src/test-clock.test.ts +135 -0
package/src/engine.ts CHANGED
@@ -1,1414 +1,41 @@
1
- import {
2
- Array as Arr,
3
- Cause,
4
- Context,
5
- Effect,
6
- Layer,
7
- ManagedRuntime,
8
- Match,
9
- MutableRef,
10
- Option,
11
- Order,
12
- Record as Rec,
13
- Ref,
14
- } from 'effect'
1
+ import { Cause, Effect, Option, Record as Rec, Ref } from 'effect'
15
2
  import { yieldWrapGet } from 'effect/Utils'
16
- import { AssertionFailed, UnknownAction, UnknownSlot } from './errors'
17
3
  import {
18
4
  type CapturedScene,
19
- Composition,
20
5
  type CompositionClass,
21
- type CompositionService,
22
- type FeatureLoadFailed,
23
- isFeatureBinding,
24
- isSlot,
25
- isStructure,
26
6
  makeAppRuntime,
27
- publish,
28
- type RuntimeHandle,
29
7
  sceneInstrumentation,
30
- type SlotFill,
31
- type Structure,
32
- type Trigger,
33
8
  type UiContract,
34
9
  } from '@playfast/reform'
10
+ import { type AnyComposition, type AnyScene } from '@playfast/reform/internal'
35
11
  import {
36
- type AnyComposition,
37
- type AnyFeatureBinding,
38
- type AnyScene,
39
- type AnySlot,
40
- type AnySlotChild,
41
- Bus,
42
- type CapturedRender,
43
- CaptureSink,
44
- type CaptureSinkApi,
45
- type Instrumentation,
46
- noopInstrumentation,
47
- } from '@playfast/reform/internal'
48
- import { matchProps } from './index'
49
- import type {
50
- DriveResult,
51
- Facade,
52
- Proof,
53
- ProofCase,
54
- ProductComposition,
55
- ProofResult,
56
- SlotFacade,
57
- SlotFacadesOf,
58
- StepFrame,
59
- } from './index'
60
- import { formatFingerprint } from './fingerprint'
61
-
62
- type RenderRuntime = Pick<RuntimeHandle, 'dispatch' | 'instrumentation' | 'readOption'>
63
-
64
- type HostRuntime = Pick<
65
- RuntimeHandle,
66
- 'dispatch' | 'instrumentation' | 'mountAnyFeature' | 'readOption'
67
- >
68
-
69
- type RuntimeRootComposition<
70
- Identifier,
71
- P,
72
- C extends UiContract,
73
- S extends ReadonlyArray<unknown>,
74
- N extends string,
75
- Identity,
76
- > = CompositionClass<P, C, S, N, Identity> & {
77
- readonly tag: Context.Tag<Identifier, CompositionService<P, C>>
78
- }
79
-
80
- interface MountedFacadeErasure {
81
- readonly props: Effect.Effect<unknown, never, never>
82
- readonly expectProps: (partial: unknown) => Effect.Effect<void, never, never>
83
- readonly actions: Readonly<
84
- Record<string, (payload: unknown) => Effect.Effect<unknown, never, never>>
85
- >
86
- readonly slots: Readonly<Record<string, MountedSlotFacadeErasure>>
87
- readonly frame: Effect.Effect<void, never, never>
88
- }
89
-
90
- interface MountedSlotFacadeErasure extends MountedFacadeErasure {
91
- readonly first: Effect.Effect<MountedFacadeErasure, never, never>
92
- readonly at: (index: number) => Effect.Effect<MountedFacadeErasure, never, never>
93
- readonly all: Effect.Effect<ReadonlyArray<MountedFacadeErasure>, never, never>
94
- readonly byKey: (key: string) => Effect.Effect<MountedFacadeErasure, never, never>
95
- readonly where: (
96
- predicate: (props: unknown) => boolean,
97
- ) => Effect.Effect<ReadonlyArray<MountedFacadeErasure>, never, never>
98
- readonly count: Effect.Effect<number, never, never>
99
- }
100
-
101
- interface MountedFacadeHandleErasure {
102
- readonly facade: {
103
- readonly props: Effect.Effect<unknown, never, never>
104
- readonly expectProps: (partial: never) => Effect.Effect<void, never, never>
105
- readonly actions: object
106
- readonly slots: object
107
- readonly frame: Effect.Effect<void, never, never>
108
- }
109
- readonly settle: Effect.Effect<void, never, never>
110
- }
111
-
112
- type Settlement = Effect.Effect<void, never, never>
113
- type SettleBoundary = (settlement: Settlement) => Settlement
114
-
115
- const directSettle: SettleBoundary = (settlement) => settlement
116
-
117
- // Cooperative yields flush synchronous fibers; the real-time sleep prevents a
118
- // genuinely delayed flow from looking stable between renders.
119
- const SETTLE_DRAIN = 30
120
- const SETTLE_STEP = '1 milli'
121
- const SETTLE_MAX_RENDERS = 100
122
-
123
- const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
124
-
125
- const keyed = <V>(get: (key: string) => V): Record<string, V> => {
126
- const target: Record<string, V> = Object.create(null)
127
- return new Proxy(target, { get: (_target, key) => get(String(key)) })
128
- }
129
-
130
- const messageOf = (error: unknown): string =>
131
- error instanceof Error ? error.message : String(error)
132
-
133
- const uiNameOf = (comp: AnyComposition): string => comp.manifest.ui.manifest.name
134
-
135
- // UiContract erases event values to never, so this boundary needs no assertion.
136
- const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> => ({
137
- ...structure.events,
138
- })
139
-
140
- interface ExpectedProps {
141
- readonly actual: unknown
142
- readonly expected: unknown
143
- }
144
-
145
- const expectMatchingProps = ({
146
- actual,
147
- expected,
148
- }: ExpectedProps): Effect.Effect<void, never, never> =>
149
- Option.match(Option.fromNullable(matchProps({ actual, expected })), {
150
- onNone: () => Effect.void,
151
- onSome: (detail) => Effect.dieMessage(new AssertionFailed({ detail }).message),
152
- })
153
-
154
- const invalidCompositionProps = (composition: AnyComposition): string =>
155
- `reform-proof: invalid props for composition ${composition.manifest.name}`
156
-
157
- const renderCompositionRuntime = (
158
- runtime: RenderRuntime,
159
- composition: AnyComposition,
160
- props: unknown,
161
- ): Structure<UiContract> =>
162
- composition.capture(
163
- <P, C extends UiContract, S extends ReadonlyArray<unknown>, N extends string, Identity>(
164
- exact: CompositionClass<P, C, S, N, Identity>,
165
- ): Structure<UiContract> => {
166
- const resolved = Option.flatMap(exact.parseProps(props), (parsed) =>
167
- Option.map(runtime.readOption(exact.tag), (service) => ({
168
- parsed,
169
- service,
170
- })),
171
- )
172
- return Option.match(resolved, {
173
- onNone: () => Effect.runSync(Effect.dieMessage(invalidCompositionProps(composition))),
174
- onSome: ({ parsed, service }) => {
175
- const frame = Effect.runSync(
176
- Composition.render(service, {
177
- props: parsed,
178
- tracker: { add: () => {} },
179
- }),
180
- )
181
- if (isStructure(frame)) {
182
- return frame
183
- }
184
- return Effect.runSync(
185
- Effect.dieMessage(
186
- `reform-proof: composition ${composition.manifest.name} did not return a Structure`,
187
- ),
188
- )
189
- },
190
- })
191
- },
192
- )
193
-
194
- export interface Sink {
195
- readonly api: CaptureSinkApi
196
- readonly captures: ReadonlyArray<CapturedRender>
197
- readonly instrumentation: Instrumentation
198
- reset(): void
199
- }
200
-
201
- export const makeSink = (instrumentation: Instrumentation = noopInstrumentation): Sink => {
202
- // CaptureSink.record is synchronous, so this uses MutableRef rather than Ref.
203
- const captures = MutableRef.make<CapturedRender[]>([])
204
- return {
205
- api: {
206
- record: (capture) => {
207
- MutableRef.update(captures, (current) => [...current, capture])
208
- },
209
- },
210
- get captures() {
211
- return MutableRef.get(captures)
212
- },
213
- instrumentation,
214
- reset: () => {
215
- MutableRef.set(captures, [])
216
- },
217
- }
218
- }
219
-
220
- export type MountedSlotFacadesOf<C extends UiContract> = SlotFacadesOf<C, never>
221
-
222
- export type MountedFacade<C extends UiContract> = Facade<C, never>
223
-
224
- export type MountedSlotFacade<C extends UiContract> = SlotFacade<C, never>
225
-
226
- export const proofLayer = <
227
- C extends UiContract,
228
- S extends ReadonlyArray<unknown>,
229
- Services,
230
- P,
231
- N extends string,
232
- Identity,
233
- >(
234
- scene: CapturedScene<C, S, Services, P, N, Identity>,
235
- sink: Sink,
236
- ): Layer.Layer<Services | CaptureSink, never, never> => {
237
- const sceneLayer = scene.provide.reduce((merged, layer) => Layer.merge(merged, layer))
238
- return Layer.merge(sceneLayer, Layer.succeed(CaptureSink, sink.api))
239
- }
240
-
241
- const missingRuntimeBus = (): never =>
242
- Effect.runSync(Effect.dieMessage('reform-proof: scene does not provide Bus'))
243
-
244
- const renderRuntimeFromContext = <Services>(
245
- context: Context.Context<Services>,
246
- instrumentation: Instrumentation,
247
- ): RenderRuntime => {
248
- const bus = Context.getOption(context, Bus)
249
- return {
250
- dispatch: (priority, event) =>
251
- Option.match(bus, {
252
- onNone: missingRuntimeBus,
253
- onSome: (service) =>
254
- Effect.runSync(publish(priority, event).pipe(Effect.provideService(Bus, service))),
255
- }),
256
- instrumentation,
257
- readOption: (tag) => Context.getOption(context, tag),
258
- }
259
- }
260
-
261
- interface Mounted {
262
- readonly comp: AnyComposition
263
- readonly props: unknown
264
- readonly ref: NodeRef
265
- }
266
-
267
- interface SlotInstanceRef {
268
- readonly name: string
269
- readonly index: number
270
- readonly key: Option.Option<string>
271
- readonly keyOccurrence: number
272
- }
273
-
274
- interface NodeRef {
275
- readonly name: string
276
- readonly parent: Option.Option<NodeRef>
277
- readonly slot: Option.Option<SlotInstanceRef>
278
- }
279
-
280
- interface NodeCapture {
281
- readonly ref: NodeRef
282
- readonly capture: CapturedRender
283
- }
284
-
285
- interface CaptureLookup {
286
- readonly captures: ReadonlyArray<NodeCapture>
287
- record(ref: NodeRef, capture: CapturedRender): void
288
- reset(): void
289
- }
290
-
291
- const makeCaptureLookup = (): CaptureLookup => {
292
- const captures = MutableRef.make<ReadonlyArray<NodeCapture>>([])
293
- return {
294
- get captures() {
295
- return MutableRef.get(captures)
296
- },
297
- record: (ref, capture) => {
298
- MutableRef.update(captures, (current) => [...current, { ref, capture }])
299
- },
300
- reset: () => {
301
- MutableRef.set(captures, [])
302
- },
303
- }
304
- }
305
-
306
- const sameSlotInstance = (expected: SlotInstanceRef, actual: SlotInstanceRef): boolean =>
307
- expected.name === actual.name &&
308
- Option.match(expected.key, {
309
- onNone: () => expected.index === actual.index,
310
- onSome: (key) =>
311
- expected.keyOccurrence === actual.keyOccurrence && Option.contains(actual.key, key),
312
- })
313
-
314
- const sameNodeRef = (expected: NodeRef, actual: NodeRef): boolean =>
315
- expected.name === actual.name &&
316
- Option.match(expected.parent, {
317
- onNone: () => Option.isNone(actual.parent) && Option.isNone(actual.slot),
318
- onSome: (expectedParent) =>
319
- Option.isSome(actual.parent) &&
320
- Option.isSome(expected.slot) &&
321
- Option.isSome(actual.slot) &&
322
- sameNodeRef(expectedParent, actual.parent.value) &&
323
- sameSlotInstance(expected.slot.value, actual.slot.value),
324
- })
325
-
326
- const rootRef = (root: AnyComposition): NodeRef => ({
327
- name: uiNameOf(root),
328
- parent: Option.none(),
329
- slot: Option.none(),
330
- })
331
-
332
- const nodeCaptureFor = (lookup: CaptureLookup, ref: NodeRef): Option.Option<CapturedRender> =>
333
- Option.map(
334
- Arr.findFirst(lookup.captures, (entry) => sameNodeRef(ref, entry.ref)),
335
- (entry) => entry.capture,
336
- )
337
-
338
- const slotCapturesFor = (
339
- lookup: CaptureLookup,
340
- parent: NodeRef,
341
- slotName: string,
342
- ): ReadonlyArray<NodeCapture> =>
343
- lookup.captures.filter(
344
- (entry) =>
345
- Option.isSome(entry.ref.parent) &&
346
- Option.isSome(entry.ref.slot) &&
347
- sameNodeRef(parent, entry.ref.parent.value) &&
348
- entry.ref.slot.value.name === slotName,
349
- )
350
-
351
- interface SettleProgress {
352
- readonly previous: string
353
- readonly remaining: number
354
- readonly stable: boolean
355
- }
356
-
357
- const ensureSettled = (progress: SettleProgress): Effect.Effect<void, never, never> => {
358
- if (progress.stable) {
359
- return Effect.void
360
- }
361
- return Effect.dieMessage(`reform-proof: tree did not settle after ${SETTLE_MAX_RENDERS} renders`)
362
- }
363
-
364
- interface PumpStep {
365
- readonly input: unknown
366
- readonly index: number
367
- }
368
-
369
- const childComposition = (child: AnySlotChild): AnyComposition =>
370
- isFeatureBinding(child) ? child.capture<AnyComposition>((binding) => binding.composition) : child
371
-
372
- const isUnknownRecord = (candidate: unknown): candidate is Record<string, unknown> =>
373
- typeof candidate === 'object' && candidate !== null && !Array.isArray(candidate)
374
-
375
- const slotsOf = (comp: AnyComposition): Record<string, AnySlot> =>
376
- comp.capture<Record<string, AnySlot>>((exact) => {
377
- if (!('slots' in exact.manifest)) {
378
- return {}
379
- }
380
- const candidate: unknown = exact.manifest.slots
381
- if (!isUnknownRecord(candidate)) {
382
- return {}
383
- }
384
- return Rec.fromEntries(
385
- Rec.toEntries(candidate).flatMap(
386
- ([name, slotDefinition]): ReadonlyArray<readonly [string, AnySlot]> =>
387
- isSlot(slotDefinition) ? [[name, slotDefinition]] : [],
388
- ),
389
- )
390
- })
391
-
392
- interface RuntimeSlotRead {
393
- readonly runtime: RenderRuntime
394
- readonly slotDefinition: AnySlot
395
- readonly parent: AnyComposition
396
- }
397
-
398
- const readRuntimeSlotChild = ({ runtime, slotDefinition, parent }: RuntimeSlotRead): AnySlotChild =>
399
- slotDefinition.capture<AnySlotChild>((exact) =>
400
- Option.match(runtime.readOption(exact.provider), {
401
- onNone: () =>
402
- Effect.runSync(
403
- Effect.dieMessage(`reform-proof: slot provider missing in ${uiNameOf(parent)}`),
404
- ),
405
- onSome: (child) => child,
406
- }),
407
- )
408
-
409
- // UiContract erases per-slot fill types here, so recover them by discriminant.
410
- const isSlotFill = (candidate: unknown): candidate is SlotFill<unknown> =>
411
- typeof candidate === 'object' &&
412
- candidate !== null &&
413
- '_tag' in candidate &&
414
- (candidate._tag === 'Each' || candidate._tag === 'One' || candidate._tag === 'Absent')
415
-
416
- const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
417
- Rec.fromEntries(
418
- Rec.toEntries(structure.slots).flatMap(
419
- ([slotName, fillValue]): ReadonlyArray<readonly [string, SlotFill<unknown>]> =>
420
- isSlotFill(fillValue) ? [[slotName, fillValue]] : [],
421
- ),
422
- )
423
-
424
- const enqueueFill = (
425
- parent: NodeRef,
426
- slotName: string,
427
- fill: SlotFill<unknown>,
428
- child: AnyComposition,
429
- ): ReadonlyArray<Mounted> =>
430
- Match.value(fill).pipe(
431
- Match.when({ _tag: 'Each' }, (each) =>
432
- each.items.map((entry, index) => ({
433
- comp: child,
434
- props: entry.props,
435
- ref: {
436
- name: uiNameOf(child),
437
- parent: Option.some(parent),
438
- slot: Option.some({
439
- name: slotName,
440
- index,
441
- key: Option.some(entry.key),
442
- keyOccurrence: each.items
443
- .slice(0, index)
444
- .filter((candidate) => candidate.key === entry.key).length,
445
- }),
446
- },
447
- })),
448
- ),
449
- Match.when({ _tag: 'One' }, (oneFill) => [
450
- {
451
- comp: child,
452
- props: oneFill.props,
453
- ref: {
454
- name: uiNameOf(child),
455
- parent: Option.some(parent),
456
- slot: Option.some({
457
- name: slotName,
458
- index: 0,
459
- key: Option.some(`${slotName}.0`),
460
- keyOccurrence: 0,
461
- }),
462
- },
463
- },
464
- ]),
465
- Match.when({ _tag: 'Absent' }, () => []),
466
- Match.exhaustive,
467
- )
468
-
469
- const driveStructure = (
470
- comp: AnyComposition,
471
- structure: Structure<UiContract>,
472
- ref: NodeRef,
473
- sink: Sink,
474
- lookup: CaptureLookup,
475
- bindings: ReadonlyMap<AnySlot, AnyComposition>,
476
- ): ReadonlyArray<Mounted> => {
477
- const key = Option.flatMap(ref.slot, (instance) => instance.key)
478
- const capture: CapturedRender = {
479
- name: uiNameOf(comp),
480
- props: structure.props,
481
- events: eventsOf(structure),
482
- ...(Option.isSome(key) ? { key: key.value } : {}),
483
- }
484
- sink.api.record(capture)
485
- lookup.record(ref, capture)
486
- const fills = structureFills(structure)
487
- return Rec.toEntries(slotsOf(comp)).flatMap(([slotName, slotClass]) => {
488
- const child = bindings.get(slotClass)
489
- const fill = fills[slotName]
490
- return child === undefined || fill === undefined ? [] : enqueueFill(ref, slotName, fill, child)
491
- })
492
- }
493
-
494
- const fingerprint = (lookup: CaptureLookup): string =>
495
- formatFingerprint(lookup.captures.map(({ ref, capture }) => [ref, capture.props]))
496
-
497
- export function makeFacade<
498
- C extends UiContract,
499
- S extends ReadonlyArray<unknown>,
500
- Services,
501
- P,
502
- N extends string,
503
- Identity,
504
- >(
505
- runtime: ManagedRuntime.ManagedRuntime<Services | CaptureSink, never>,
506
- scene: CapturedScene<C, S, Services, P, N, Identity>,
507
- sink: Sink,
508
- dispatched: Set<string>,
509
- ): {
510
- readonly facade: Facade<C, never>
511
- readonly settle: Effect.Effect<void, never, never>
512
- } {
513
- const context = runtime.runSync(Effect.context<Services | CaptureSink>())
514
- return makeMountedFacade(
515
- renderRuntimeFromContext(context, sink.instrumentation),
516
- scene.composition,
517
- sink,
518
- dispatched,
519
- (settlement) => Effect.promise(() => runtime.runPromise(settlement)),
520
- )
521
- }
522
-
523
- const collectRuntimeBindings = (
524
- root: AnyComposition,
525
- runtime: RenderRuntime,
526
- ): Map<AnySlot, AnyComposition> => {
527
- const bindings = new Map<AnySlot, AnyComposition>()
528
- const walk = (comp: AnyComposition): void => {
529
- Rec.values(slotsOf(comp)).forEach((slotClass) => {
530
- if (bindings.has(slotClass)) {
531
- return
532
- }
533
- const child = childComposition(
534
- readRuntimeSlotChild({
535
- runtime,
536
- slotDefinition: slotClass,
537
- parent: comp,
538
- }),
539
- )
540
- bindings.set(slotClass, child)
541
- walk(child)
542
- })
543
- }
544
- walk(root)
545
- return bindings
546
- }
547
-
548
- const renderMountedRuntime = (
549
- runtime: RenderRuntime,
550
- mounted: Mounted,
551
- bindings: ReadonlyMap<AnySlot, AnyComposition>,
552
- sink: Sink,
553
- lookup: CaptureLookup,
554
- ): ReadonlyArray<Mounted> => {
555
- const endRenderSpan = sink.instrumentation.uiRendered(uiNameOf(mounted.comp))
556
- const frame = renderCompositionRuntime(runtime, mounted.comp, mounted.props)
557
- endRenderSpan()
558
- return driveStructure(mounted.comp, frame, mounted.ref, sink, lookup, bindings)
559
- }
560
-
561
- const renderLevelRuntime = (
562
- runtime: RenderRuntime,
563
- frontier: ReadonlyArray<Mounted>,
564
- bindings: ReadonlyMap<AnySlot, AnyComposition>,
565
- sink: Sink,
566
- lookup: CaptureLookup,
567
- ): void => {
568
- if (frontier.length === 0) {
569
- return
570
- }
571
- const levels = frontier.map((mounted) =>
572
- renderMountedRuntime(runtime, mounted, bindings, sink, lookup),
573
- )
574
- renderLevelRuntime(runtime, levels.flat(), bindings, sink, lookup)
575
- }
576
-
577
- const renderTreeRuntime = (
578
- runtime: RenderRuntime,
579
- root: AnyComposition,
580
- bindings: ReadonlyMap<AnySlot, AnyComposition>,
581
- sink: Sink,
582
- lookup: CaptureLookup,
583
- ): Effect.Effect<void, never, never> =>
584
- Effect.sync(() => {
585
- sink.reset()
586
- lookup.reset()
587
- renderLevelRuntime(
588
- runtime,
589
- [{ comp: root, props: {}, ref: rootRef(root) }],
590
- bindings,
591
- sink,
592
- lookup,
593
- )
594
- })
595
-
596
- const settleTreeRuntime = Effect.fn('settleTreeRuntime')(function* (
597
- runtime: RenderRuntime,
598
- root: AnyComposition,
599
- bindings: ReadonlyMap<AnySlot, AnyComposition>,
600
- sink: Sink,
601
- lookup: CaptureLookup,
602
- ): Effect.fn.Return<void, never, never> {
603
- yield* settleDrain
604
- yield* renderTreeRuntime(runtime, root, bindings, sink, lookup)
605
- const progress = yield* Effect.iterate(
606
- {
607
- previous: fingerprint(lookup),
608
- remaining: SETTLE_MAX_RENDERS,
609
- stable: false,
610
- },
611
- {
612
- while: (progress: SettleProgress) => !progress.stable && progress.remaining > 0,
613
- body: (progress) =>
614
- Effect.gen(function* () {
615
- yield* settleDrain
616
- yield* Effect.sleep(SETTLE_STEP)
617
- yield* renderTreeRuntime(runtime, root, bindings, sink, lookup)
618
- const current = fingerprint(lookup)
619
- return {
620
- previous: current,
621
- remaining: progress.remaining - 1,
622
- stable: current === progress.previous,
623
- }
624
- }),
625
- },
626
- )
627
- yield* ensureSettled(progress)
628
- })
629
-
630
- function makeMountedFacade<
631
- P,
632
- C extends UiContract,
633
- S extends ReadonlyArray<unknown>,
634
- N extends string,
635
- Identity,
636
- >(
637
- runtime: RenderRuntime,
638
- root: CompositionClass<P, C, S, N, Identity>,
639
- sink: Sink,
640
- dispatched: Set<string>,
641
- settleBoundary: SettleBoundary,
642
- ): {
643
- readonly facade: MountedFacade<C>
644
- readonly settle: Effect.Effect<void, never, never>
645
- }
646
- function makeMountedFacade(
647
- runtime: RenderRuntime,
648
- root: AnyComposition,
649
- sink: Sink,
650
- dispatched: Set<string>,
651
- settleBoundary: SettleBoundary,
652
- ): MountedFacadeHandleErasure
653
- function makeMountedFacade(
654
- runtime: RenderRuntime,
655
- root: AnyComposition,
656
- sink: Sink,
657
- dispatched: Set<string>,
658
- settleBoundary: SettleBoundary,
659
- ): MountedFacadeHandleErasure {
660
- const bindings = collectRuntimeBindings(root, runtime)
661
- const lookup = makeCaptureLookup()
662
- const settle = settleBoundary(settleTreeRuntime(runtime, root, bindings, sink, lookup))
663
- const rerender = renderTreeRuntime(runtime, root, bindings, sink, lookup)
664
-
665
- const triggerOf = (ref: NodeRef, event: string): Effect.Effect<Trigger<unknown>, never, never> =>
666
- Option.match(
667
- Option.flatMap(nodeCaptureFor(lookup, ref), (capture) =>
668
- Option.fromNullable(capture.events[event]),
669
- ),
670
- {
671
- onNone: () =>
672
- Effect.dieMessage(
673
- new UnknownAction({
674
- composition: ref.name,
675
- action: event,
676
- rendered: lookup.captures.filter((entry) => entry.ref.name === ref.name).length,
677
- }).message,
678
- ),
679
- onSome: Effect.succeed,
680
- },
681
- )
682
-
683
- const propsFor = (ref: NodeRef): Effect.Effect<unknown, never, never> =>
684
- Effect.map(rerender, () =>
685
- Option.match(nodeCaptureFor(lookup, ref), {
686
- onNone: () => undefined,
687
- onSome: (capture) => capture.props,
688
- }),
689
- )
690
-
691
- const nodeFacade = (ref: NodeRef, comp: AnyComposition): MountedFacadeErasure => ({
692
- props: propsFor(ref),
693
- expectProps: (partial) =>
694
- propsFor(ref).pipe(
695
- Effect.flatMap((props) => expectMatchingProps({ actual: props, expected: partial })),
696
- ),
697
- actions: keyed(
698
- (event) => (payload: unknown) =>
699
- rerender.pipe(
700
- Effect.flatMap(() => triggerOf(ref, event)),
701
- Effect.flatMap((trigger) =>
702
- Effect.sync(() => {
703
- dispatched.add(event)
704
- trigger(payload)
705
- }),
706
- ),
707
- Effect.flatMap(() => settle),
708
- Effect.flatMap(() => propsFor(ref)),
709
- ),
710
- ),
711
- slots: keyed((slotName) => slotFacade(ref, comp, slotName)),
712
- frame: settle,
713
- })
714
-
715
- const slotFacade = (
716
- parentRef: NodeRef,
717
- parent: AnyComposition,
718
- slotName: string,
719
- ): MountedSlotFacadeErasure => {
720
- const slotClass = slotsOf(parent)[slotName]
721
- const child = slotClass && bindings.get(slotClass)
722
- if (child === undefined) {
723
- return Effect.runSync(
724
- Effect.dieMessage(new UnknownSlot({ parent: uiNameOf(parent), slot: slotName }).message),
725
- )
726
- }
727
- const childName = uiNameOf(child)
728
- const childRefAt = (index: number): NodeRef =>
729
- slotCapturesFor(lookup, parentRef, slotName)[index]?.ref ?? {
730
- name: childName,
731
- parent: Option.some(parentRef),
732
- slot: Option.some({
733
- name: slotName,
734
- index,
735
- key: Option.none(),
736
- keyOccurrence: 0,
737
- }),
738
- }
739
- const atIndex = (index: number): Effect.Effect<MountedFacadeErasure, never, never> =>
740
- Effect.as(rerender, nodeFacade(childRefAt(index), child))
741
- const first = nodeFacade(childRefAt(0), child)
742
- return {
743
- first: atIndex(0),
744
- at: atIndex,
745
- all: Effect.map(rerender, () =>
746
- slotCapturesFor(lookup, parentRef, slotName).map((entry) => nodeFacade(entry.ref, child)),
747
- ),
748
- byKey: (key) =>
749
- Effect.flatMap(rerender, () =>
750
- Option.match(
751
- Arr.findFirst(
752
- slotCapturesFor(lookup, parentRef, slotName),
753
- (entry) => entry.capture.key === key,
754
- ),
755
- {
756
- onNone: () =>
757
- Effect.dieMessage(
758
- new UnknownSlot({
759
- parent: uiNameOf(parent),
760
- slot: `${slotName}[key=${key}]`,
761
- }).message,
762
- ),
763
- onSome: (entry) => Effect.succeed(nodeFacade(entry.ref, child)),
764
- },
765
- ),
766
- ),
767
- where: (predicate) =>
768
- Effect.map(rerender, () =>
769
- slotCapturesFor(lookup, parentRef, slotName).flatMap((entry) =>
770
- predicate(entry.capture.props) ? [nodeFacade(entry.ref, child)] : [],
771
- ),
772
- ),
773
- count: Effect.map(rerender, () => slotCapturesFor(lookup, parentRef, slotName).length),
774
- props: first.props,
775
- expectProps: first.expectProps,
776
- actions: first.actions,
777
- slots: first.slots,
778
- frame: first.frame,
779
- }
780
- }
781
-
782
- return {
783
- facade: nodeFacade(rootRef(root), root),
784
- settle,
785
- }
786
- }
787
-
788
- export const makeProofFacade = <
789
- C extends UiContract,
790
- S extends ReadonlyArray<unknown>,
791
- Services,
792
- P,
793
- N extends string,
794
- Identity,
795
- >(
796
- runtime: ManagedRuntime.ManagedRuntime<Services | CaptureSink, never>,
797
- scene: CapturedScene<C, S, Services, P, N, Identity>,
798
- sink: Sink,
799
- dispatched: Set<string>,
800
- ): {
801
- readonly facade: Facade<C, never>
802
- readonly settle: Effect.Effect<void, never, never>
803
- } => {
804
- return makeFacade(runtime, scene, sink, dispatched)
805
- }
806
-
807
- export const makeRuntimeHandleFacade = <
808
- Services,
809
- RootIdentifier extends NoInfer<Services>,
810
- P,
811
- C extends UiContract,
812
- S extends ReadonlyArray<unknown>,
813
- N extends string,
814
- Identity,
815
- >(
816
- runtime: RuntimeHandle<Services>,
817
- root: RuntimeRootComposition<RootIdentifier, P, C, S, N, Identity>,
818
- sink: Sink,
819
- dispatched: Set<string>,
820
- ): {
821
- readonly facade: MountedFacade<C>
822
- readonly settle: Effect.Effect<void, never, never>
823
- } => makeMountedFacade(runtime, root, sink, dispatched, directSettle)
824
-
825
- type FeatureMountState =
826
- | { readonly _tag: 'Loading' }
827
- | { readonly _tag: 'Live'; readonly runtime: HostRuntime }
828
- | { readonly _tag: 'Failed'; readonly error: FeatureLoadFailed }
829
-
830
- interface TreeFeatureMount {
831
- readonly id: string
832
- readonly binding: AnyFeatureBinding
833
- readonly parent: HostRuntime
834
- readonly depth: number
835
- readonly props: unknown
836
- attempt: number
837
- state: FeatureMountState
838
- dispose: () => void
839
- }
840
-
841
- interface TreeNode {
842
- readonly path: string
843
- readonly depth: number
844
- readonly key: Option.Option<string>
845
- readonly comp: AnyComposition
846
- readonly props: unknown
847
- readonly events: Record<string, Trigger<unknown>>
848
- readonly slots: Readonly<Record<string, ReadonlyArray<TreeNode>>>
849
- }
850
-
851
- interface TreeRenderState {
852
- readonly root: TreeNode
853
- readonly fingerprint: string
854
- }
855
-
856
- export interface RuntimeTreeFacade<C extends UiContract> {
857
- readonly facade: MountedFacade<C>
858
- readonly settle: Effect.Effect<void, never, never>
859
- dispose(): void
860
- }
861
-
862
- interface RuntimeTreeFacadeErasure extends MountedFacadeHandleErasure {
863
- dispose(): void
864
- }
865
-
866
- interface TreeDriver {
867
- readonly render: () => TreeRenderState
868
- readonly settle: Effect.Effect<void, never, never>
869
- readonly dispose: () => void
870
- }
871
-
872
- interface MountIdentityInput {
873
- readonly parent: HostRuntime
874
- readonly parentPath: string
875
- readonly slotName: string
876
- readonly key: string
877
- readonly binding: AnyFeatureBinding
878
- }
879
-
880
- interface FeatureRecordInput {
881
- readonly id: string
882
- readonly parent: HostRuntime
883
- readonly binding: AnyFeatureBinding
884
- readonly depth: number
885
- readonly props: unknown
886
- }
887
-
888
- interface RenderCompositionInput {
889
- readonly runtime: HostRuntime
890
- readonly comp: AnyComposition
891
- readonly props: unknown
892
- readonly key: Option.Option<string>
893
- readonly path: string
894
- readonly depth: number
895
- readonly seen: Set<string>
896
- readonly mountMissing: boolean
897
- }
898
-
899
- const encodeIdentityPart = (part: string): string => `${part.length}:${part}`
900
-
901
- const fillEntries = (
902
- slotName: string,
903
- fill: SlotFill<unknown>,
904
- ): ReadonlyArray<{ readonly key: string; readonly props: unknown }> =>
905
- Match.value(fill).pipe(
906
- Match.when({ _tag: 'Each' }, (each) => each.items),
907
- Match.when({ _tag: 'One' }, (oneFill) => [{ key: `${slotName}.0`, props: oneFill.props }]),
908
- Match.when({ _tag: 'Absent' }, () => []),
909
- Match.exhaustive,
910
- )
911
-
912
- const treeFingerprint = (node: TreeNode): unknown => [
913
- uiNameOf(node.comp),
914
- node.key,
915
- node.props,
916
- Rec.map(node.slots, (children) => children.map(treeFingerprint)),
917
- ]
918
-
919
- const makeTreeDriver = (
920
- runtime: HostRuntime,
921
- root: AnyComposition,
922
- rootProps: unknown,
923
- sink: Sink,
924
- ): TreeDriver => {
925
- const mounts = new Map<string, TreeFeatureMount>()
926
- const runtimeIds = new WeakMap<HostRuntime, number>()
927
- const bindingIds = new WeakMap<object, number>()
928
- const sequence = { runtime: 0, binding: 0 }
929
- const status = { disposed: false }
930
-
931
- const identity = <Subject extends object>(
932
- identities: WeakMap<Subject, number>,
933
- subject: Subject,
934
- next: () => number,
935
- ): number => {
936
- const current = identities.get(subject)
937
- if (current !== undefined) {
938
- return current
939
- }
940
- const created = next()
941
- identities.set(subject, created)
942
- return created
943
- }
944
-
945
- const mountId = ({ parent, parentPath, slotName, key, binding }: MountIdentityInput): string => {
946
- const runtimeId = identity(runtimeIds, parent, () => ++sequence.runtime)
947
- const bindingId = identity(bindingIds, binding, () => ++sequence.binding)
948
- return [String(runtimeId), parentPath, slotName, String(bindingId), key]
949
- .map(encodeIdentityPart)
950
- .join('')
951
- }
952
-
953
- const startMount = (record: TreeFeatureMount): void => {
954
- record.attempt += 1
955
- const attempt = record.attempt
956
- record.binding.capture<void>((binding) => {
957
- if (binding.strategy === 'default') {
958
- record.state = { _tag: 'Live', runtime: record.parent }
959
- binding.boot.forEach((event) => record.parent.dispatch('High', event))
960
- record.dispose = () => {}
961
- return
962
- }
963
- record.state = { _tag: 'Loading' }
964
- record.dispose = record.parent.mountAnyFeature(binding, {
965
- props: record.props,
966
- onLive: (childRuntime) => {
967
- if (status.disposed || record.attempt !== attempt || mounts.get(record.id) !== record) {
968
- return
969
- }
970
- record.state = { _tag: 'Live', runtime: childRuntime }
971
- },
972
- onFailed: (error) => {
973
- if (status.disposed || record.attempt !== attempt || mounts.get(record.id) !== record) {
974
- return
975
- }
976
- record.state = { _tag: 'Failed', error }
977
- },
978
- })
979
- })
980
- }
981
-
982
- const retryMount = (record: TreeFeatureMount): void => {
983
- if (status.disposed || mounts.get(record.id) !== record || record.state._tag !== 'Failed') {
984
- return
985
- }
986
- record.dispose()
987
- startMount(record)
988
- }
989
-
990
- const featureRecord = ({
991
- id,
992
- parent,
993
- binding,
994
- depth,
995
- props,
996
- }: FeatureRecordInput): TreeFeatureMount => {
997
- const current = mounts.get(id)
998
- if (current !== undefined && current.binding === binding && current.parent === parent) {
999
- return current
1000
- }
1001
- if (current !== undefined) {
1002
- current.attempt += 1
1003
- current.dispose()
1004
- }
1005
- const created: TreeFeatureMount = {
1006
- id,
1007
- binding,
1008
- parent,
1009
- depth,
1010
- props,
1011
- attempt: 0,
1012
- state: { _tag: 'Loading' },
1013
- dispose: () => {},
1014
- }
1015
- mounts.set(id, created)
1016
- startMount(created)
1017
- return created
1018
- }
1019
- const featureRecordForRender = (
1020
- input: FeatureRecordInput,
1021
- mountMissing: boolean,
1022
- ): TreeFeatureMount | undefined => {
1023
- const current = mounts.get(input.id)
1024
- if (current !== undefined || !mountMissing) {
1025
- return current
1026
- }
1027
- return featureRecord(input)
1028
- }
1029
-
1030
- const renderComposition = ({
1031
- runtime: currentRuntime,
1032
- comp,
1033
- props,
1034
- key,
1035
- path,
1036
- depth,
1037
- seen,
1038
- mountMissing,
1039
- }: RenderCompositionInput): TreeNode => {
1040
- const endRenderSpan = sink.instrumentation.uiRendered(uiNameOf(comp))
1041
- const frame = renderCompositionRuntime(currentRuntime, comp, props)
1042
- endRenderSpan()
1043
- sink.api.record({
1044
- name: uiNameOf(comp),
1045
- props: frame.props,
1046
- events: eventsOf(frame),
1047
- ...(Option.isSome(key) ? { key: key.value } : {}),
1048
- })
1049
-
1050
- const fills = structureFills(frame)
1051
- const renderedSlots = Rec.map(slotsOf(comp), (slotClass, slotName) => {
1052
- const fill = fills[slotName]
1053
- if (fill === undefined) {
1054
- return []
1055
- }
1056
- const child = readRuntimeSlotChild({
1057
- runtime: currentRuntime,
1058
- slotDefinition: slotClass,
1059
- parent: comp,
1060
- })
1061
- return fillEntries(slotName, fill).flatMap((entry) => {
1062
- const childPath = `${path}${encodeIdentityPart(slotName)}${encodeIdentityPart(entry.key)}`
1063
- if (!isFeatureBinding(child)) {
1064
- return [
1065
- renderComposition({
1066
- runtime: currentRuntime,
1067
- comp: child,
1068
- props: entry.props,
1069
- key: Option.some(entry.key),
1070
- path: childPath,
1071
- depth: depth + 1,
1072
- seen,
1073
- mountMissing,
1074
- }),
1075
- ]
1076
- }
1077
- const id = mountId({
1078
- parent: currentRuntime,
1079
- parentPath: path,
1080
- slotName,
1081
- key: entry.key,
1082
- binding: child,
1083
- })
1084
- seen.add(id)
1085
- const record = featureRecordForRender(
1086
- {
1087
- id,
1088
- parent: currentRuntime,
1089
- binding: child,
1090
- depth: depth + 1,
1091
- props: entry.props,
1092
- },
1093
- mountMissing,
1094
- )
1095
- if (record === undefined) {
1096
- return []
1097
- }
1098
- return child.capture<ReadonlyArray<TreeNode>>((binding) =>
1099
- Match.value(record.state).pipe(
1100
- Match.when({ _tag: 'Live' }, (live) => [
1101
- renderComposition({
1102
- runtime: live.runtime,
1103
- comp: binding.composition,
1104
- props: entry.props,
1105
- key: Option.some(entry.key),
1106
- path: childPath,
1107
- depth: depth + 1,
1108
- seen,
1109
- mountMissing,
1110
- }),
1111
- ]),
1112
- Match.when({ _tag: 'Failed' }, (failed) =>
1113
- Option.match(binding.placeholder, {
1114
- onNone: () => [],
1115
- onSome: (placeholder) => [
1116
- renderComposition({
1117
- runtime: currentRuntime,
1118
- comp: placeholder.failed,
1119
- props: {
1120
- error: failed.error,
1121
- retry: () => retryMount(record),
1122
- },
1123
- key: Option.some(entry.key),
1124
- path: `${childPath}/failed`,
1125
- depth: depth + 1,
1126
- seen,
1127
- mountMissing,
1128
- }),
1129
- ],
1130
- }),
1131
- ),
1132
- Match.when({ _tag: 'Loading' }, () =>
1133
- Option.match(binding.placeholder, {
1134
- onNone: () => [],
1135
- onSome: (placeholder) => [
1136
- renderComposition({
1137
- runtime: currentRuntime,
1138
- comp: placeholder.loading,
1139
- props: entry.props,
1140
- key: Option.some(entry.key),
1141
- path: `${childPath}/loading`,
1142
- depth: depth + 1,
1143
- seen,
1144
- mountMissing,
1145
- }),
1146
- ],
1147
- }),
1148
- ),
1149
- Match.exhaustive,
1150
- ),
1151
- )
1152
- })
1153
- })
1154
-
1155
- return {
1156
- path,
1157
- depth,
1158
- key,
1159
- comp,
1160
- props: frame.props,
1161
- events: eventsOf(frame),
1162
- slots: renderedSlots,
1163
- }
1164
- }
1165
-
1166
- const disposeStale = (seen: ReadonlySet<string>): void => {
1167
- Arr.sortWith(
1168
- Array.from(mounts.values()).filter((record) => !seen.has(record.id)),
1169
- (record) => -record.depth,
1170
- Order.number,
1171
- ).forEach((record) => {
1172
- mounts.delete(record.id)
1173
- record.attempt += 1
1174
- record.dispose()
1175
- })
1176
- }
1177
-
1178
- const render = (): TreeRenderState => {
1179
- // Discover the desired graph without acquiring missing Features, then release
1180
- // removed scopes deepest-first before the live pass mounts replacements.
1181
- sink.reset()
1182
- const retained = new Set<string>()
1183
- renderComposition({
1184
- runtime,
1185
- comp: root,
1186
- props: rootProps,
1187
- key: Option.none(),
1188
- path: 'root',
1189
- depth: 0,
1190
- seen: retained,
1191
- mountMissing: false,
1192
- })
1193
- disposeStale(retained)
1194
- sink.reset()
1195
- const seen = new Set<string>()
1196
- const rootNode = renderComposition({
1197
- runtime,
1198
- comp: root,
1199
- props: rootProps,
1200
- key: Option.none(),
1201
- path: 'root',
1202
- depth: 0,
1203
- seen,
1204
- mountMissing: true,
1205
- })
1206
- return {
1207
- root: rootNode,
1208
- fingerprint: formatFingerprint(treeFingerprint(rootNode)),
1209
- }
1210
- }
1211
-
1212
- const settle = Effect.gen(function* () {
1213
- yield* settleDrain
1214
- const first = render()
1215
- const progress = yield* Effect.iterate(
1216
- {
1217
- previous: first.fingerprint,
1218
- remaining: SETTLE_MAX_RENDERS,
1219
- stable: false,
1220
- },
1221
- {
1222
- while: (progress: SettleProgress) => !progress.stable && progress.remaining > 0,
1223
- body: (progress) =>
1224
- Effect.gen(function* () {
1225
- yield* settleDrain
1226
- yield* Effect.sleep(SETTLE_STEP)
1227
- const current = render()
1228
- return {
1229
- previous: current.fingerprint,
1230
- remaining: progress.remaining - 1,
1231
- stable: current.fingerprint === progress.previous,
1232
- }
1233
- }),
1234
- },
1235
- )
1236
- yield* ensureSettled(progress)
1237
- })
1238
-
1239
- const dispose = (): void => {
1240
- if (status.disposed) {
1241
- return
1242
- }
1243
- status.disposed = true
1244
- Arr.sortWith(Array.from(mounts.values()), (record) => -record.depth, Order.number).forEach(
1245
- (record) => {
1246
- record.attempt += 1
1247
- record.dispose()
1248
- },
1249
- )
1250
- mounts.clear()
1251
- }
1252
-
1253
- return { render, settle, dispose }
1254
- }
1255
-
1256
- export function makeRuntimeTreeFacade<
1257
- Services,
1258
- RootIdentifier extends NoInfer<Services>,
1259
- P,
1260
- C extends UiContract,
1261
- S extends ReadonlyArray<unknown>,
1262
- N extends string,
1263
- Identity,
1264
- >(
1265
- runtime: RuntimeHandle<Services>,
1266
- root: RuntimeRootComposition<RootIdentifier, P, C, S, N, Identity>,
1267
- rootProps: P,
1268
- sink: Sink,
1269
- dispatched: Set<string>,
1270
- ): RuntimeTreeFacade<C>
1271
- export function makeRuntimeTreeFacade(
1272
- runtime: HostRuntime,
1273
- root: AnyComposition,
1274
- rootProps: unknown,
1275
- sink: Sink,
1276
- dispatched: Set<string>,
1277
- ): RuntimeTreeFacadeErasure {
1278
- const driver = makeTreeDriver(runtime, root, rootProps, sink)
1279
- const currentRoot = (): TreeNode => driver.render().root
1280
-
1281
- // `peek` is the non-dying half of `resolve`: None once the node has left the
1282
- // tree, so an action that removed its own node can still report a result.
1283
- const nodeFacade = (
1284
- resolve: () => TreeNode,
1285
- peek: () => Option.Option<TreeNode>,
1286
- ): MountedFacadeErasure => ({
1287
- props: Effect.sync(() => resolve().props),
1288
- expectProps: (partial) =>
1289
- Effect.sync(() => {
1290
- const mismatch = matchProps({
1291
- actual: resolve().props,
1292
- expected: partial,
1293
- })
1294
- if (mismatch !== undefined) {
1295
- return Effect.runSync(
1296
- Effect.dieMessage(new AssertionFailed({ detail: mismatch }).message),
1297
- )
1298
- }
1299
- }),
1300
- actions: keyed(
1301
- (event) => (payload: unknown) =>
1302
- Effect.gen(function* () {
1303
- const node = resolve()
1304
- const trigger = node.events[event]
1305
- if (trigger === undefined) {
1306
- return yield* Effect.dieMessage(
1307
- new UnknownAction({
1308
- composition: uiNameOf(node.comp),
1309
- action: event,
1310
- rendered: 1,
1311
- }).message,
1312
- )
1313
- }
1314
- dispatched.add(event)
1315
- trigger(payload)
1316
- yield* driver.settle
1317
- // A self-removing action leaves nothing to re-read; report what the
1318
- // node last rendered instead of dying on the vanished child.
1319
- return Option.match(peek(), {
1320
- onNone: () => node.props,
1321
- onSome: (settled) => settled.props,
1322
- })
1323
- }),
1324
- ),
1325
- slots: keyed((slotName) => slotFacade(resolve, slotName)),
1326
- frame: driver.settle,
1327
- })
1328
-
1329
- const slotFacade = (
1330
- resolveParent: () => TreeNode,
1331
- slotName: string,
1332
- ): MountedSlotFacadeErasure => {
1333
- const children = (): ReadonlyArray<TreeNode> => {
1334
- const parent = resolveParent()
1335
- const resolved = parent.slots[slotName]
1336
- if (resolved === undefined) {
1337
- return Effect.runSync(
1338
- Effect.dieMessage(
1339
- new UnknownSlot({ parent: uiNameOf(parent.comp), slot: slotName }).message,
1340
- ),
1341
- )
1342
- }
1343
- return resolved
1344
- }
1345
- const peekAt = (index: number): Option.Option<TreeNode> =>
1346
- Option.fromNullable(children()[index])
1347
- const peekByKey = (key: string): Option.Option<TreeNode> =>
1348
- Arr.findFirst(children(), (candidate) => Option.contains(candidate.key, key))
1349
- const childAt = (index: number): MountedFacadeErasure =>
1350
- nodeFacade(
1351
- () => children()[index] ?? missingChild(index),
1352
- () => peekAt(index),
1353
- )
1354
- const childByKey = (key: string): MountedFacadeErasure =>
1355
- nodeFacade(
1356
- () => Option.getOrElse(peekByKey(key), () => missingKey(key)),
1357
- () => peekByKey(key),
1358
- )
1359
- const missingChild = (index: number): never => {
1360
- const parent = resolveParent()
1361
- return Effect.runSync(
1362
- Effect.dieMessage(
1363
- new UnknownSlot({
1364
- parent: uiNameOf(parent.comp),
1365
- slot: `${slotName}[${index}]`,
1366
- }).message,
1367
- ),
1368
- )
1369
- }
1370
- const missingKey = (key: string): never => {
1371
- const parent = resolveParent()
1372
- return Effect.runSync(
1373
- Effect.dieMessage(
1374
- new UnknownSlot({
1375
- parent: uiNameOf(parent.comp),
1376
- slot: `${slotName}[key=${key}]`,
1377
- }).message,
1378
- ),
1379
- )
1380
- }
1381
- const first = childAt(0)
1382
- return {
1383
- first: Effect.sync(() => childAt(0)),
1384
- at: (index) => Effect.sync(() => childAt(index)),
1385
- all: Effect.sync(() => children().map((_child, index) => childAt(index))),
1386
- byKey: (key) =>
1387
- Effect.sync(() => {
1388
- const child = Arr.findFirst(children(), (candidate) =>
1389
- Option.contains(candidate.key, key),
1390
- )
1391
- return Option.isNone(child) ? missingKey(key) : childByKey(key)
1392
- }),
1393
- where: (predicate) =>
1394
- Effect.sync(() =>
1395
- children().flatMap((child, index) => (predicate(child.props) ? [childAt(index)] : [])),
1396
- ),
1397
- count: Effect.sync(() => children().length),
1398
- props: first.props,
1399
- expectProps: first.expectProps,
1400
- actions: first.actions,
1401
- slots: first.slots,
1402
- frame: driver.settle,
1403
- }
1404
- }
1405
-
1406
- return {
1407
- facade: nodeFacade(currentRoot, () => Option.some(currentRoot())),
1408
- settle: driver.settle,
1409
- dispose: driver.dispose,
1410
- }
1411
- }
12
+ invalidCompositionProps,
13
+ type HostRuntime,
14
+ makeSink,
15
+ messageOf,
16
+ type RenderRuntime,
17
+ type Sink,
18
+ } from './engineSink'
19
+ import { childComposition, readRuntimeSlotChild, slotsOf } from './engineTree'
20
+ import type { RuntimeTreeFacade } from './engineTreeTypes'
21
+ import { makeRuntimeTreeFacade } from './engineTreeFacade'
22
+ import type { ProofCase, ProofResult } from './proofCase'
23
+ import type { PumpStep } from './engineTree'
24
+ import type { DriveResult, StepFrame } from './index'
25
+ import type { ProductComposition } from './product'
26
+ import type { Proof } from './index'
27
+
28
+ export { makeSink } from './engineSink'
29
+ export type { Sink } from './engineSink'
30
+ export { proofLayer } from './engineTree'
31
+ export { makeFacade, makeProofFacade, makeRuntimeHandleFacade } from './engineMounted'
32
+ export type {
33
+ MountedFacade,
34
+ MountedSlotFacade,
35
+ MountedSlotFacadesOf,
36
+ } from './engineSink'
37
+ export { makeRuntimeTreeFacade } from './engineTreeFacade'
38
+ export type { RuntimeTreeFacade } from './engineTreeTypes'
1412
39
 
1413
40
  const proofRootProps = <
1414
41
  P,
@@ -1655,3 +282,4 @@ export const driveProofEffect = (proof: Proof): Effect.Effect<DriveResult, never
1655
282
 
1656
283
  export const driveProof = (proof: Proof): Promise<DriveResult> =>
1657
284
  Effect.runPromise(driveProofEffect(proof))
285
+