@playfast/reform-proof 1.1.1 → 1.2.1

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.
package/src/runner.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { Context, Effect, Layer } from 'effect'
2
- import { driveProof, executeProof } from './execution'
2
+ import { driveProof, driveProofEffect, executeProof, executeProofEffect } from './engine'
3
3
  import type { DriveResult, Proof, ProofResult } from './index'
4
4
 
5
5
  export interface ProofRunnerApi {
6
+ readonly executeProofEffect: (proof: Proof) => Effect.Effect<ProofResult, never, never>
7
+ readonly driveProofEffect: (proof: Proof) => Effect.Effect<DriveResult, never, never>
6
8
  readonly executeProof: (proof: Proof) => Promise<ProofResult>
7
9
  readonly driveProof: (proof: Proof) => Promise<DriveResult>
8
10
  }
@@ -13,6 +15,8 @@ const ProofRunnerBase: Context.TagClass<ProofRunner, 'reform-proof/ProofRunner',
13
15
  export class ProofRunner extends ProofRunnerBase {}
14
16
 
15
17
  export const proofRunnerLayer: Layer.Layer<ProofRunner> = Layer.succeed(ProofRunner, {
18
+ executeProofEffect,
19
+ driveProofEffect,
16
20
  executeProof,
17
21
  driveProof,
18
22
  })
@@ -19,7 +19,10 @@ import { Product, Proof, ProductRequirement, expect as proofExpect } from './ind
19
19
  class CountState extends State.make('scount', S.Number) {}
20
20
  class MiniStates extends StateGroup.make(CountState) {}
21
21
  class Bumped extends Event.make('SBumped', S.Struct({})) {}
22
- class BumpReducer extends Reducer.make('SBumpReducer', { states: [CountState], events: [Bumped] }) {}
22
+ class BumpReducer extends Reducer.make('SBumpReducer', {
23
+ states: [CountState],
24
+ events: [Bumped],
25
+ }) {}
23
26
  const BumpReducerLive = Reducer.live(BumpReducer, (n) => n + 1)
24
27
 
25
28
  class CounterUi extends ui('SCounter')<{
@@ -31,14 +34,17 @@ class Counter extends Composition.make('SCounter', {
31
34
  states: [MiniStates],
32
35
  events: [Bumped],
33
36
  ui: CounterUi,
34
- }) {}
37
+ })<Counter>() {}
35
38
  const CounterLive = Composition.live(Counter, function* () {
36
39
  const count = yield* StateGroup.select(MiniStates, 'scount')
37
40
  const bump = yield* Event.trigger(Bumped)
38
41
  return mount({ props: { count }, slots: {}, events: { bump } })
39
42
  })
40
43
 
41
- const presentations = provide(CounterUi, Ui.make(CounterUi, () => null))
44
+ const presentations = provide(
45
+ CounterUi,
46
+ Ui.make(CounterUi, () => null),
47
+ )
42
48
  const Views = CounterLive.pipe(Layer.provideMerge(presentations))
43
49
  const Logic = BumpReducerLive.pipe(
44
50
  Layer.provideMerge(Layer.mergeAll(Engine, StateGroup.live(MiniStates, { scount: 5 }))),
@@ -4,7 +4,6 @@ import {
4
4
  Engine,
5
5
  each,
6
6
  mount,
7
- Props,
8
7
  provide,
9
8
  scene,
10
9
  slot,
@@ -22,13 +21,15 @@ class TodosState extends State.make(
22
21
  ) {}
23
22
  class TodoStates extends StateGroup.make(TodosState) {}
24
23
 
25
- class ItemUi extends ui('LocItem')<{ props: { id: string; done: boolean } }>() {}
24
+ class ItemUi extends ui('LocItem')<{
25
+ props: { id: string; done: boolean }
26
+ }>() {}
26
27
  class Item extends Composition.make('LocItem', {
27
28
  title: 'LocItem',
28
29
  props: S.Struct({ id: S.String, done: S.Boolean }),
29
30
  ui: ItemUi,
30
- }) {}
31
- class ItemSlot extends slot('LocItem')<typeof Item>() {}
31
+ })<Item>() {}
32
+ class ItemSlot extends slot('LocItem')<ItemSlot, typeof Item>() {}
32
33
 
33
34
  class ListUi extends ui('LocList')<{
34
35
  props: { total: number }
@@ -39,25 +40,34 @@ class List extends Composition.make('LocList', {
39
40
  states: [TodoStates],
40
41
  slots: { Item: ItemSlot },
41
42
  ui: ListUi,
42
- }) {}
43
+ })<List>() {}
43
44
 
44
45
  const ListLive = Composition.live(List, function* () {
45
46
  const todos = yield* StateGroup.select(TodoStates, 'locTodos')
46
47
  return mount({
47
48
  props: { total: todos.length },
48
49
  slots: {
49
- Item: each(todos, { key: (t) => t.id, props: (t) => ({ id: t.id, done: t.done }) }),
50
+ Item: each(todos, {
51
+ key: (t) => t.id,
52
+ props: (t) => ({ id: t.id, done: t.done }),
53
+ }),
50
54
  },
51
55
  })
52
56
  })
53
57
  const ItemLive = Composition.live(Item, function* () {
54
- const props: { id: string; done: boolean } = yield* Props
58
+ const props = yield* Item.props
55
59
  return mount({ props, slots: {} })
56
60
  })
57
61
 
58
62
  const presentations = Layer.mergeAll(
59
- provide(ListUi, Ui.make(ListUi, () => null)),
60
- provide(ItemUi, Ui.make(ItemUi, () => null)),
63
+ provide(
64
+ ListUi,
65
+ Ui.make(ListUi, () => null),
66
+ ),
67
+ provide(
68
+ ItemUi,
69
+ Ui.make(ItemUi, () => null),
70
+ ),
61
71
  )
62
72
  const wiring = provide(ItemSlot, Item)
63
73
  const Views = Layer.mergeAll(ListLive, ItemLive, wiring).pipe(Layer.provideMerge(presentations))
@@ -0,0 +1,135 @@
1
+ import { Duration, Effect, Layer, Schema as S } from 'effect'
2
+ import {
3
+ Channel,
4
+ Composition,
5
+ Engine,
6
+ Event,
7
+ mount,
8
+ Procedure,
9
+ provide,
10
+ publish,
11
+ Reducer,
12
+ scene,
13
+ State,
14
+ StateGroup,
15
+ type Trigger,
16
+ Ui,
17
+ ui,
18
+ } from '@playfast/reform'
19
+ import { describe, expect, test } from 'vitest'
20
+ import { Product, Proof, ProductRequirement, expect as proofExpect } from './index'
21
+
22
+ // A scene's product timers live inside its procedures, on the real clock: a proof
23
+ // could only observe one by waiting it out, which spends real seconds AND races the
24
+ // runner (a fixed sleep past the timer assumes the woken fiber is scheduled before
25
+ // the next read). `Proof.withTestClock` puts a virtual clock behind the scene so
26
+ // `app.advance(...)` moves that time exactly, instantly, and only on demand.
27
+
28
+ const FLASH = Duration.millis(1500)
29
+
30
+ class FlashState extends State.make('flash', S.Boolean) {}
31
+ class FlashStates extends StateGroup.make(FlashState) {}
32
+
33
+ class Submitted extends Event.make('TCSubmitted', S.Struct({})) {}
34
+ class FlashEnded extends Event.make('TCFlashEnded', S.Struct({})) {}
35
+
36
+ class EndFlash extends Reducer.make('TCEndFlash', {
37
+ states: [FlashState],
38
+ events: [FlashEnded],
39
+ }) {}
40
+ const EndFlashLive = Reducer.live(EndFlash, () => false)
41
+
42
+ class FlashLane extends Channel.make('TCFlashLane', { policy: { _tag: 'latest' } }) {}
43
+ class FlashTimer extends Procedure.make('TCFlashTimer', {
44
+ events: [Submitted],
45
+ channel: FlashLane,
46
+ }) {}
47
+ // The product timer under test: a success flash that clears itself 1500ms later.
48
+ const FlashTimerLive = Procedure.live(FlashTimer, function* () {
49
+ yield* Effect.sleep(FLASH)
50
+ yield* publish('Normal', Event.construct(FlashEnded, {}))
51
+ })
52
+
53
+ class FlashUi extends ui('TCFlash')<{
54
+ props: { flash: boolean }
55
+ events: { submit: Trigger<Record<string, never>> }
56
+ }>() {}
57
+ class Flash extends Composition.make('TCFlash', {
58
+ title: 'TCFlash',
59
+ states: [FlashStates],
60
+ events: [Submitted],
61
+ ui: FlashUi,
62
+ })<Flash>() {}
63
+ const FlashLive = Composition.live(Flash, function* () {
64
+ const flash = yield* StateGroup.select(FlashStates, 'flash')
65
+ const submit = yield* Event.trigger(Submitted)
66
+ return mount({ props: { flash }, slots: {}, events: { submit } })
67
+ })
68
+
69
+ const presentations = provide(FlashUi, Ui.make(FlashUi, () => null))
70
+ const Views = FlashLive.pipe(Layer.provideMerge(presentations))
71
+ const Logic = Layer.mergeAll(EndFlashLive, FlashTimerLive, Channel.live(FlashLane)).pipe(
72
+ Layer.provideMerge(Layer.mergeAll(Engine, StateGroup.live(FlashStates, { flash: true }))),
73
+ )
74
+ const App = Views.pipe(Layer.provideMerge(Logic))
75
+
76
+ const VirtualScene = scene(Flash, { provide: [Proof.withTestClock(App)] })
77
+ const RealScene = scene(Flash, { provide: [App] })
78
+
79
+ class ClearsFlash extends ProductRequirement.make(Flash, 'clears the flash after the window', {
80
+ events: ['submit'],
81
+ }) {}
82
+ class FlashProduct extends Product.make(Flash, { requirements: [ClearsFlash] }) {}
83
+
84
+ describe('Proof.withTestClock', () => {
85
+ test('app.advance elapses a product timer without spending real time', async () => {
86
+ const proof = Proof.implement(ClearsFlash, VirtualScene, function* (app) {
87
+ yield* proofExpect((yield* app.props).flash).toBe(true)
88
+ yield* app.actions.submit({})
89
+ // The timer is armed but no virtual time has passed, so the flash still shows.
90
+ // On the real clock this frame is a race; here it is a fact.
91
+ yield* proofExpect((yield* app.props).flash).toBe(true)
92
+ yield* app.advance(FLASH)
93
+ yield* proofExpect((yield* app.props).flash).toBe(false)
94
+ })
95
+
96
+ const startedAt = performance.now()
97
+ const result = await Proof.run(Proof.suite(FlashProduct, { proofs: [proof] }))
98
+ const elapsed = performance.now() - startedAt
99
+
100
+ expect(result.results.map((entry) => entry.error)).toEqual([undefined])
101
+ expect(result.ok).toBe(true)
102
+ // The assertion that matters: 1500ms of product time cost a fraction of it in
103
+ // real time. Generous enough not to flake on a loaded runner, tight enough that
104
+ // a regression to the real clock (>= 1500ms) fails here.
105
+ expect(elapsed).toBeLessThan(750)
106
+ })
107
+
108
+ test('the timer does not fire on its own — only advancing moves it', async () => {
109
+ // The complement of the test above: if the virtual clock were a no-op and the
110
+ // procedure were really sleeping on the wall clock, this proof would go green the
111
+ // moment the settle loop outlasted the window. It must stay red instead.
112
+ const proof = Proof.implement(ClearsFlash, VirtualScene, function* (app) {
113
+ yield* app.actions.submit({})
114
+ yield* app.frame
115
+ yield* app.frame
116
+ yield* proofExpect((yield* app.props).flash).toBe(false)
117
+ })
118
+
119
+ const result = await Proof.run(Proof.suite(FlashProduct, { proofs: [proof] }))
120
+
121
+ expect(result.ok).toBe(false)
122
+ })
123
+
124
+ test('app.advance against the real clock dies rather than silently no-opping', async () => {
125
+ const proof = Proof.implement(ClearsFlash, RealScene, function* (app) {
126
+ yield* app.actions.submit({})
127
+ yield* app.advance(FLASH)
128
+ })
129
+
130
+ const result = await Proof.run(Proof.suite(FlashProduct, { proofs: [proof] }))
131
+
132
+ expect(result.ok).toBe(false)
133
+ expect(result.results[0]?.error).toContain('Proof.withTestClock')
134
+ })
135
+ })
@@ -25,24 +25,27 @@ class BumpReducer extends Reducer.make('BumpReducer', { states: [CountState], ev
25
25
  const BumpReducerLive = Reducer.live(BumpReducer, (n) => n + 1)
26
26
 
27
27
  class LabelUi extends ui('Label')<{ props: { text: string } }>() {}
28
- class Label extends Composition.make('Label', { title: 'Label', ui: LabelUi }) {}
28
+
29
+ class Label extends Composition.make('Label', { title: 'Label', ui: LabelUi })<Label>() {}
29
30
  const LabelLive = Composition.live(Label, function* () {
30
31
  return mount({ props: { text: 'count' }, slots: {} })
31
32
  })
32
- class LabelSlot extends slot('Label')<typeof Label>() {}
33
+
34
+ class LabelSlot extends slot('Label')<LabelSlot, typeof Label>() {}
33
35
 
34
36
  class CounterUi extends ui('Counter')<{
35
37
  props: { count: number }
36
38
  slots: { Label: LabelSlot }
37
39
  events: { bump: Trigger<Record<string, never>> }
38
40
  }>() {}
41
+
39
42
  class Counter extends Composition.make('Counter', {
40
43
  title: 'Counter',
41
44
  states: [MiniStates],
42
45
  events: [Bumped],
43
46
  slots: { Label: LabelSlot },
44
47
  ui: CounterUi,
45
- }) {}
48
+ })<Counter>() {}
46
49
  const CounterLive = Composition.live(Counter, function* () {
47
50
  const count = yield* StateGroup.select(MiniStates, 'count')
48
51
  const bump = yield* Event.trigger(Bumped)
@@ -1,19 +0,0 @@
1
- import type { UiCapture } from '@playfast/reform'
2
-
3
- export interface StepFrame {
4
- readonly index: number
5
- readonly captures: ReadonlyArray<UiCapture>
6
- }
7
-
8
- export interface DriveResult {
9
- readonly requirement: string
10
- readonly frames: ReadonlyArray<StepFrame>
11
- readonly ok: boolean
12
- // oxlint-disable-next-line reform-rules/no-optional-fields -- serialized result DTO read as `error ?? …` by the editor timeline (external); optional kept for wire compat
13
- readonly error?: string
14
- }
15
-
16
- export interface ProofDriver {
17
- readonly requirement: string
18
- readonly run: () => Promise<DriveResult>
19
- }
package/src/execution.ts DELETED
@@ -1,100 +0,0 @@
1
- import { Cause, Effect, Option, Ref } from 'effect'
2
- import { yieldWrapGet } from 'effect/Utils'
3
- import {
4
- Bus,
5
- type CompositionService,
6
- publish,
7
- type Scene,
8
- sceneInstrumentation,
9
- } from '@playfast/reform'
10
- import type { DriveResult, Proof, ProofResult, StepFrame } from './index'
11
- import { makeFacadeEffect } from './facade'
12
- import { makeSink, messageOf, proofLayer, type RuntimeServices, type Sink } from './sink'
13
- import type { PumpStep } from './treeBindings'
14
-
15
- const missingCoverage = (proof: Proof, dispatched: Set<string>): ReadonlyArray<string> =>
16
- Option.match(proof.requirement.manifest.events, {
17
- onNone: () => [],
18
- onSome: (declared) => declared.map(String).filter((event) => !dispatched.has(event)),
19
- })
20
-
21
- const bootScene = (scene: Scene): Effect.Effect<void, never, Bus> =>
22
- Effect.forEach(
23
- Option.getOrElse(Option.fromNullable(scene.boot), () => []),
24
- (event) => publish('High', event),
25
- ).pipe(Effect.asVoid)
26
-
27
- const executeProofEffect = Effect.fn('executeProofEffect')(function* (
28
- proof: Proof,
29
- sink: Sink,
30
- ): Effect.fn.Return<ProofResult, never, RuntimeServices> {
31
- const dispatched = new Set<string>()
32
- const statement = proof.requirement.manifest.statement
33
- return yield* Effect.gen(function* () {
34
- const { facade, settle } = yield* makeFacadeEffect(proof.scene.composition, sink, dispatched)
35
- yield* bootScene(proof.scene)
36
- yield* settle
37
- yield* Effect.gen(() => proof.body(facade))
38
- const missing = missingCoverage(proof, dispatched)
39
- const coverageError = `requirement declares events never dispatched: ${missing.join(', ')}`
40
- return missing.length > 0
41
- ? { requirement: statement, ok: false, error: coverageError }
42
- : { requirement: statement, ok: true }
43
- }).pipe(
44
- Effect.catchAllCause((cause) =>
45
- Effect.succeed({ requirement: statement, ok: false, error: messageOf(Cause.squash(cause)) }),
46
- ),
47
- )
48
- })
49
-
50
- export const executeProof = async (proof: Proof): Promise<ProofResult> => {
51
- const sink = makeSink(sceneInstrumentation(proof.scene))
52
- return Effect.runPromise(executeProofEffect(proof, sink).pipe(Effect.provide(proofLayer(proof.scene, sink))))
53
- }
54
-
55
- const driveProofEffect = Effect.fn('driveProofEffect')(function* (
56
- proof: Proof,
57
- sink: Sink,
58
- ): Effect.fn.Return<DriveResult, never, RuntimeServices> {
59
- const frames = yield* Ref.make<ReadonlyArray<StepFrame>>([])
60
- const statement = proof.requirement.manifest.statement
61
- const dispatched = new Set<string>()
62
- const record = (index: number): Effect.Effect<void> =>
63
- Ref.update(frames, (current) => [...current, { index, captures: [...sink.captures] }])
64
- const pump = Effect.fn('pump')(function* (
65
- generator: ReturnType<Proof['body']>,
66
- step: PumpStep,
67
- ): Effect.fn.Return<void, unknown, CompositionService> {
68
- const next = generator.next(step.input)
69
- if (next.done === true) {
70
- return
71
- }
72
- const output = yield* yieldWrapGet(next.value)
73
- yield* record(step.index)
74
- yield* pump(generator, { input: output, index: step.index + 1 })
75
- })
76
- return yield* Effect.gen(function* () {
77
- const { facade, settle } = yield* makeFacadeEffect(proof.scene.composition, sink, dispatched)
78
- yield* bootScene(proof.scene)
79
- yield* settle
80
- yield* record(0)
81
- yield* pump(proof.body(facade), { input: undefined, index: 1 })
82
- return { requirement: statement, frames: yield* Ref.get(frames), ok: true }
83
- }).pipe(
84
- Effect.catchAllCause((cause) =>
85
- Ref.get(frames).pipe(
86
- Effect.map((collected) => ({
87
- requirement: statement,
88
- frames: collected,
89
- ok: false,
90
- error: messageOf(Cause.squash(cause)),
91
- })),
92
- ),
93
- ),
94
- )
95
- })
96
-
97
- export const driveProof = async (proof: Proof): Promise<DriveResult> => {
98
- const sink = makeSink(sceneInstrumentation(proof.scene))
99
- return Effect.runPromise(driveProofEffect(proof, sink).pipe(Effect.provide(proofLayer(proof.scene, sink))))
100
- }
@@ -1,269 +0,0 @@
1
- import { Array as Arr, Effect, Option, Record as Rec } from 'effect'
2
- import {
3
- Composition,
4
- type CompositionClass,
5
- isStructure,
6
- type RenderEnv,
7
- type RuntimeHandle,
8
- type SlotClass,
9
- type Trigger,
10
- } from '@playfast/reform'
11
- import { AssertionFailed, UnknownAction, UnknownSlot } from './errors'
12
- import { matchProps } from './assertions'
13
- import { capturesFor, fingerprint } from './facade'
14
- import {
15
- keyed,
16
- SETTLE_MAX_RENDERS,
17
- SETTLE_STEP,
18
- settleDrain,
19
- type Sink,
20
- uiNameOf,
21
- } from './sink'
22
- import {
23
- childComposition,
24
- driveStructure,
25
- type Mounted,
26
- type NodeRef,
27
- type SettleProgress,
28
- slotsOf,
29
- } from './treeBindings'
30
-
31
- export interface MountedFacade {
32
- readonly props: Effect.Effect<unknown, never, never>
33
- readonly expectProps: (partial: unknown) => Effect.Effect<void, never, never>
34
- readonly actions: Record<string, (payload: unknown) => Effect.Effect<unknown, never, never>>
35
- readonly slots: Record<string, MountedSlotFacade>
36
- readonly frame: Effect.Effect<void, never, never>
37
- }
38
-
39
- export interface MountedSlotFacade extends MountedFacade {
40
- readonly first: Effect.Effect<MountedFacade, never, never>
41
- readonly at: (index: number) => Effect.Effect<MountedFacade, never, never>
42
- readonly all: Effect.Effect<ReadonlyArray<MountedFacade>, never, never>
43
- readonly byKey: (key: string) => Effect.Effect<MountedFacade, never, never>
44
- readonly where: (
45
- predicate: (props: unknown) => boolean,
46
- ) => Effect.Effect<ReadonlyArray<MountedFacade>, never, never>
47
- readonly count: Effect.Effect<number, never, never>
48
- }
49
-
50
- const collectRuntimeBindings = (
51
- root: CompositionClass<unknown>,
52
- runtime: RuntimeHandle,
53
- ): Map<SlotClass, CompositionClass<unknown>> => {
54
- const bindings = new Map<SlotClass, CompositionClass<unknown>>()
55
- const walk = (comp: CompositionClass<unknown>): void => {
56
- Rec.values(slotsOf(comp)).forEach((slotClass) => {
57
- if (bindings.has(slotClass)) {
58
- return
59
- }
60
- const child = childComposition(runtime.read(slotClass.tag))
61
- bindings.set(slotClass, child)
62
- walk(child)
63
- })
64
- }
65
- walk(root)
66
- return bindings
67
- }
68
-
69
- const renderMountedRuntime = (
70
- runtime: RuntimeHandle,
71
- mounted: Mounted,
72
- bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
73
- sink: Sink,
74
- ): ReadonlyArray<Mounted> => {
75
- const service = runtime.read(mounted.comp.tag)
76
- const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
77
- const endRenderSpan = sink.instrumentation.uiRendered(uiNameOf(mounted.comp))
78
- const frame = Effect.runSync(Composition.render(service, env))
79
- endRenderSpan()
80
- if (!isStructure(frame)) {
81
- return Effect.runSync(
82
- Effect.dieMessage(
83
- `reform-proof: composition ${uiNameOf(mounted.comp)} did not return a Structure`,
84
- ),
85
- )
86
- }
87
- return driveStructure(mounted.comp, frame, mounted.key, sink, bindings)
88
- }
89
-
90
- const renderLevelRuntime = (
91
- runtime: RuntimeHandle,
92
- frontier: ReadonlyArray<Mounted>,
93
- bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
94
- sink: Sink,
95
- ): void => {
96
- if (frontier.length === 0) {
97
- return
98
- }
99
- const levels = frontier.map((mounted) => renderMountedRuntime(runtime, mounted, bindings, sink))
100
- renderLevelRuntime(runtime, levels.flat(), bindings, sink)
101
- }
102
-
103
- const renderTreeRuntime = (
104
- runtime: RuntimeHandle,
105
- root: CompositionClass<unknown>,
106
- bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
107
- sink: Sink,
108
- ): Effect.Effect<void, never, never> =>
109
- Effect.sync(() => {
110
- sink.reset()
111
- renderLevelRuntime(runtime, [{ comp: root, props: {}, key: Option.none() }], bindings, sink)
112
- })
113
-
114
- const settleTreeRuntime = Effect.fn('settleTreeRuntime')(function* (
115
- runtime: RuntimeHandle,
116
- root: CompositionClass<unknown>,
117
- bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
118
- sink: Sink,
119
- ): Effect.fn.Return<void, never, never> {
120
- yield* settleDrain
121
- yield* renderTreeRuntime(runtime, root, bindings, sink)
122
- yield* Effect.iterate(
123
- {
124
- previous: fingerprint(sink),
125
- remaining: SETTLE_MAX_RENDERS,
126
- stable: false,
127
- },
128
- {
129
- while: (progress: SettleProgress) => !progress.stable && progress.remaining > 0,
130
- body: (progress) =>
131
- Effect.gen(function* () {
132
- yield* settleDrain
133
- yield* Effect.sleep(SETTLE_STEP)
134
- yield* renderTreeRuntime(runtime, root, bindings, sink)
135
- const current = fingerprint(sink)
136
- return {
137
- previous: current,
138
- remaining: progress.remaining - 1,
139
- stable: current === progress.previous,
140
- }
141
- }),
142
- },
143
- )
144
- })
145
-
146
- const makeMountedFacade = (
147
- runtime: RuntimeHandle,
148
- root: CompositionClass<unknown>,
149
- sink: Sink,
150
- dispatched: Set<string>,
151
- ): {
152
- readonly facade: MountedFacade
153
- readonly settle: Effect.Effect<void, never, never>
154
- } => {
155
- const bindings = collectRuntimeBindings(root, runtime)
156
- const settle = settleTreeRuntime(runtime, root, bindings, sink)
157
- const rerender = renderTreeRuntime(runtime, root, bindings, sink)
158
-
159
- const triggerOf = (ref: NodeRef, event: string): Effect.Effect<Trigger<unknown>, never, never> =>
160
- Option.match(Option.fromNullable(capturesFor(sink, ref.name)[ref.index]?.events[event]), {
161
- onNone: () =>
162
- Effect.dieMessage(
163
- new UnknownAction({
164
- composition: ref.name,
165
- action: event,
166
- rendered: capturesFor(sink, ref.name).length,
167
- }).message,
168
- ),
169
- onSome: Effect.succeed,
170
- })
171
-
172
- const propsFor = (ref: NodeRef): Effect.Effect<unknown, never, never> =>
173
- Effect.map(rerender, () => capturesFor(sink, ref.name)[ref.index]?.props)
174
-
175
- const nodeFacade = (ref: NodeRef, comp: CompositionClass<unknown>): MountedFacade => ({
176
- props: propsFor(ref),
177
- expectProps: (partial) =>
178
- propsFor(ref).pipe(
179
- Effect.flatMap((props) => {
180
- const mismatch = matchProps({ actual: props, expected: partial })
181
- return mismatch === undefined
182
- ? Effect.void
183
- : Effect.dieMessage(new AssertionFailed({ detail: mismatch }).message)
184
- }),
185
- ),
186
- actions: keyed(
187
- (event) => (payload: unknown) =>
188
- rerender.pipe(
189
- Effect.flatMap(() => triggerOf(ref, event)),
190
- Effect.flatMap((trigger) =>
191
- Effect.sync(() => {
192
- dispatched.add(event)
193
- trigger(payload)
194
- }),
195
- ),
196
- Effect.flatMap(() => settle),
197
- Effect.flatMap(() => propsFor(ref)),
198
- ),
199
- ),
200
- slots: keyed((slotName) => slotFacade(comp, slotName)),
201
- frame: settle,
202
- })
203
-
204
- const slotFacade = (parent: CompositionClass<unknown>, slotName: string): MountedSlotFacade => {
205
- const slotClass = slotsOf(parent)[slotName]
206
- const child = slotClass && bindings.get(slotClass)
207
- if (child === undefined) {
208
- return Effect.runSync(
209
- Effect.dieMessage(new UnknownSlot({ parent: uiNameOf(parent), slot: slotName }).message),
210
- )
211
- }
212
- const childName = uiNameOf(child)
213
- const atIndex = (index: number): Effect.Effect<MountedFacade, never, never> =>
214
- Effect.as(rerender, nodeFacade({ name: childName, index }, child))
215
- const first = nodeFacade({ name: childName, index: 0 }, child)
216
- const indexOfKey = (key: string): Option.Option<number> =>
217
- Arr.findFirstIndex(capturesFor(sink, childName), (capture) => capture.key === key)
218
- return {
219
- first: atIndex(0),
220
- at: atIndex,
221
- all: Effect.map(rerender, () =>
222
- capturesFor(sink, childName).map((_capture, index) =>
223
- nodeFacade({ name: childName, index }, child),
224
- ),
225
- ),
226
- byKey: (key) =>
227
- Effect.flatMap(rerender, () =>
228
- Option.match(indexOfKey(key), {
229
- onNone: () =>
230
- Effect.dieMessage(
231
- new UnknownSlot({
232
- parent: uiNameOf(parent),
233
- slot: `${slotName}[key=${key}]`,
234
- }).message,
235
- ),
236
- onSome: (index) => Effect.succeed(nodeFacade({ name: childName, index }, child)),
237
- }),
238
- ),
239
- where: (predicate) =>
240
- Effect.map(rerender, () =>
241
- capturesFor(sink, childName).flatMap((capture, index) =>
242
- predicate(capture.props) ? [nodeFacade({ name: childName, index }, child)] : [],
243
- ),
244
- ),
245
- count: Effect.map(rerender, () => capturesFor(sink, childName).length),
246
- props: first.props,
247
- expectProps: first.expectProps,
248
- actions: first.actions,
249
- slots: first.slots,
250
- frame: first.frame,
251
- }
252
- }
253
-
254
- return {
255
- facade: nodeFacade({ name: uiNameOf(root), index: 0 }, root),
256
- settle,
257
- }
258
- }
259
-
260
- export const makeRuntimeHandleFacade = (
261
- runtime: RuntimeHandle,
262
- root: CompositionClass<unknown>,
263
- sink: Sink,
264
- dispatched: Set<string>,
265
- ): {
266
- readonly facade: MountedFacade
267
- readonly settle: Effect.Effect<void, never, never>
268
- } => makeMountedFacade(runtime, root, sink, dispatched)
269
-