@playfast/reform-proof 1.2.0 → 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/package.json +1 -1
- package/src/assert.ts +65 -0
- package/src/engine.ts +30 -1402
- package/src/engineMounted.ts +271 -0
- package/src/engineMounts.ts +165 -0
- package/src/engineRender.ts +136 -0
- package/src/engineSink.ts +195 -0
- package/src/engineTree.ts +298 -0
- package/src/engineTreeDriver.ts +247 -0
- package/src/engineTreeFacade.ts +183 -0
- package/src/engineTreeTypes.ts +103 -0
- package/src/errors.ts +19 -0
- package/src/facade.ts +78 -0
- package/src/index.ts +61 -276
- package/src/product.ts +108 -0
- package/src/proofCase.ts +46 -0
- package/src/test-clock.test.ts +135 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { Effect, Match, Option, Record as Rec } from 'effect'
|
|
2
|
+
import { isFeatureBinding } from '@playfast/reform'
|
|
3
|
+
import { formatFingerprint } from './fingerprint'
|
|
4
|
+
import {
|
|
5
|
+
eventsOf,
|
|
6
|
+
type HostRuntime,
|
|
7
|
+
renderCompositionRuntime,
|
|
8
|
+
SETTLE_MAX_RENDERS,
|
|
9
|
+
settleDrain,
|
|
10
|
+
settleStep,
|
|
11
|
+
type Sink,
|
|
12
|
+
uiNameOf,
|
|
13
|
+
} from './engineSink'
|
|
14
|
+
import type { AnyComposition } from '@playfast/reform/internal'
|
|
15
|
+
import {
|
|
16
|
+
ensureSettled,
|
|
17
|
+
readRuntimeSlotChild,
|
|
18
|
+
type SettleProgress,
|
|
19
|
+
slotsOf,
|
|
20
|
+
structureFills,
|
|
21
|
+
} from './engineTree'
|
|
22
|
+
import { makeFeatureMountRegistry } from './engineMounts'
|
|
23
|
+
import {
|
|
24
|
+
encodeIdentityPart,
|
|
25
|
+
fillEntries,
|
|
26
|
+
type RenderCompositionInput,
|
|
27
|
+
type TreeDriver,
|
|
28
|
+
type TreeNode,
|
|
29
|
+
type TreeRenderState,
|
|
30
|
+
treeFingerprint,
|
|
31
|
+
} from './engineTreeTypes'
|
|
32
|
+
|
|
33
|
+
export const makeTreeDriver = (
|
|
34
|
+
runtime: HostRuntime,
|
|
35
|
+
root: AnyComposition,
|
|
36
|
+
rootProps: unknown,
|
|
37
|
+
sink: Sink,
|
|
38
|
+
): TreeDriver => { const status = { disposed: false }
|
|
39
|
+
const registry = makeFeatureMountRegistry(status)
|
|
40
|
+
const { disposeStale, mountId, retryMount } = registry
|
|
41
|
+
const featureRecordForRender = registry.recordForRender
|
|
42
|
+
|
|
43
|
+
const renderComposition = ({
|
|
44
|
+
runtime: currentRuntime,
|
|
45
|
+
comp,
|
|
46
|
+
props,
|
|
47
|
+
key,
|
|
48
|
+
path,
|
|
49
|
+
depth,
|
|
50
|
+
seen,
|
|
51
|
+
mountMissing,
|
|
52
|
+
}: RenderCompositionInput): TreeNode => {
|
|
53
|
+
const endRenderSpan = sink.instrumentation.uiRendered(uiNameOf(comp))
|
|
54
|
+
const frame = renderCompositionRuntime(currentRuntime, comp, props)
|
|
55
|
+
endRenderSpan()
|
|
56
|
+
sink.api.record({
|
|
57
|
+
name: uiNameOf(comp),
|
|
58
|
+
props: frame.props,
|
|
59
|
+
events: eventsOf(frame),
|
|
60
|
+
...(Option.isSome(key) ? { key: key.value } : {}),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const fills = structureFills(frame)
|
|
64
|
+
const renderedSlots = Rec.map(slotsOf(comp), (slotClass, slotName) => {
|
|
65
|
+
const fill = fills[slotName]
|
|
66
|
+
if (fill === undefined) {
|
|
67
|
+
return []
|
|
68
|
+
}
|
|
69
|
+
const child = readRuntimeSlotChild({
|
|
70
|
+
runtime: currentRuntime,
|
|
71
|
+
slotDefinition: slotClass,
|
|
72
|
+
parent: comp,
|
|
73
|
+
})
|
|
74
|
+
return fillEntries(slotName, fill).flatMap((entry) => {
|
|
75
|
+
const childPath = `${path}${encodeIdentityPart(slotName)}${encodeIdentityPart(entry.key)}`
|
|
76
|
+
if (!isFeatureBinding(child)) {
|
|
77
|
+
return [
|
|
78
|
+
renderComposition({
|
|
79
|
+
runtime: currentRuntime,
|
|
80
|
+
comp: child,
|
|
81
|
+
props: entry.props,
|
|
82
|
+
key: Option.some(entry.key),
|
|
83
|
+
path: childPath,
|
|
84
|
+
depth: depth + 1,
|
|
85
|
+
seen,
|
|
86
|
+
mountMissing,
|
|
87
|
+
}),
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
const id = mountId({
|
|
91
|
+
parent: currentRuntime,
|
|
92
|
+
parentPath: path,
|
|
93
|
+
slotName,
|
|
94
|
+
key: entry.key,
|
|
95
|
+
binding: child,
|
|
96
|
+
})
|
|
97
|
+
seen.add(id)
|
|
98
|
+
const record = featureRecordForRender(
|
|
99
|
+
{
|
|
100
|
+
id,
|
|
101
|
+
parent: currentRuntime,
|
|
102
|
+
binding: child,
|
|
103
|
+
depth: depth + 1,
|
|
104
|
+
props: entry.props,
|
|
105
|
+
},
|
|
106
|
+
mountMissing,
|
|
107
|
+
)
|
|
108
|
+
if (record === undefined) {
|
|
109
|
+
return []
|
|
110
|
+
}
|
|
111
|
+
return child.capture<ReadonlyArray<TreeNode>>((binding) =>
|
|
112
|
+
Match.value(record.state).pipe(
|
|
113
|
+
Match.when({ _tag: 'Live' }, (live) => [
|
|
114
|
+
renderComposition({
|
|
115
|
+
runtime: live.runtime,
|
|
116
|
+
comp: binding.composition,
|
|
117
|
+
props: entry.props,
|
|
118
|
+
key: Option.some(entry.key),
|
|
119
|
+
path: childPath,
|
|
120
|
+
depth: depth + 1,
|
|
121
|
+
seen,
|
|
122
|
+
mountMissing,
|
|
123
|
+
}),
|
|
124
|
+
]),
|
|
125
|
+
Match.when({ _tag: 'Failed' }, (failed) =>
|
|
126
|
+
Option.match(binding.placeholder, {
|
|
127
|
+
onNone: () => [],
|
|
128
|
+
onSome: (placeholder) => [
|
|
129
|
+
renderComposition({
|
|
130
|
+
runtime: currentRuntime,
|
|
131
|
+
comp: placeholder.failed,
|
|
132
|
+
props: {
|
|
133
|
+
error: failed.error,
|
|
134
|
+
retry: () => retryMount(record),
|
|
135
|
+
},
|
|
136
|
+
key: Option.some(entry.key),
|
|
137
|
+
path: `${childPath}/failed`,
|
|
138
|
+
depth: depth + 1,
|
|
139
|
+
seen,
|
|
140
|
+
mountMissing,
|
|
141
|
+
}),
|
|
142
|
+
],
|
|
143
|
+
}),
|
|
144
|
+
),
|
|
145
|
+
Match.when({ _tag: 'Loading' }, () =>
|
|
146
|
+
Option.match(binding.placeholder, {
|
|
147
|
+
onNone: () => [],
|
|
148
|
+
onSome: (placeholder) => [
|
|
149
|
+
renderComposition({
|
|
150
|
+
runtime: currentRuntime,
|
|
151
|
+
comp: placeholder.loading,
|
|
152
|
+
props: entry.props,
|
|
153
|
+
key: Option.some(entry.key),
|
|
154
|
+
path: `${childPath}/loading`,
|
|
155
|
+
depth: depth + 1,
|
|
156
|
+
seen,
|
|
157
|
+
mountMissing,
|
|
158
|
+
}),
|
|
159
|
+
],
|
|
160
|
+
}),
|
|
161
|
+
),
|
|
162
|
+
Match.exhaustive,
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
path,
|
|
170
|
+
depth,
|
|
171
|
+
key,
|
|
172
|
+
comp,
|
|
173
|
+
props: frame.props,
|
|
174
|
+
events: eventsOf(frame),
|
|
175
|
+
slots: renderedSlots,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const render = (): TreeRenderState => {
|
|
179
|
+
// Discover the desired graph without acquiring missing Features, then release
|
|
180
|
+
// removed scopes deepest-first before the live pass mounts replacements.
|
|
181
|
+
sink.reset()
|
|
182
|
+
const retained = new Set<string>()
|
|
183
|
+
renderComposition({
|
|
184
|
+
runtime,
|
|
185
|
+
comp: root,
|
|
186
|
+
props: rootProps,
|
|
187
|
+
key: Option.none(),
|
|
188
|
+
path: 'root',
|
|
189
|
+
depth: 0,
|
|
190
|
+
seen: retained,
|
|
191
|
+
mountMissing: false,
|
|
192
|
+
})
|
|
193
|
+
disposeStale(retained)
|
|
194
|
+
sink.reset()
|
|
195
|
+
const seen = new Set<string>()
|
|
196
|
+
const rootNode = renderComposition({
|
|
197
|
+
runtime,
|
|
198
|
+
comp: root,
|
|
199
|
+
props: rootProps,
|
|
200
|
+
key: Option.none(),
|
|
201
|
+
path: 'root',
|
|
202
|
+
depth: 0,
|
|
203
|
+
seen,
|
|
204
|
+
mountMissing: true,
|
|
205
|
+
})
|
|
206
|
+
return {
|
|
207
|
+
root: rootNode,
|
|
208
|
+
fingerprint: formatFingerprint(treeFingerprint(rootNode)),
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const settle = Effect.gen(function* () {
|
|
213
|
+
yield* settleDrain
|
|
214
|
+
const first = render()
|
|
215
|
+
const progress = yield* Effect.iterate(
|
|
216
|
+
{
|
|
217
|
+
previous: first.fingerprint,
|
|
218
|
+
remaining: SETTLE_MAX_RENDERS,
|
|
219
|
+
stable: false,
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
while: (progress: SettleProgress) => !progress.stable && progress.remaining > 0,
|
|
223
|
+
body: (progress) =>
|
|
224
|
+
Effect.gen(function* () {
|
|
225
|
+
yield* settleDrain
|
|
226
|
+
yield* settleStep
|
|
227
|
+
const current = render()
|
|
228
|
+
return {
|
|
229
|
+
previous: current.fingerprint,
|
|
230
|
+
remaining: progress.remaining - 1,
|
|
231
|
+
stable: current.fingerprint === progress.previous,
|
|
232
|
+
}
|
|
233
|
+
}),
|
|
234
|
+
},
|
|
235
|
+
)
|
|
236
|
+
yield* ensureSettled(progress)
|
|
237
|
+
})
|
|
238
|
+
const dispose = (): void => {
|
|
239
|
+
if (status.disposed) {
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
status.disposed = true
|
|
243
|
+
registry.disposeAll()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { render, settle, dispose }
|
|
247
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { Array as Arr, type Duration, Effect, Option, TestClock } from 'effect'
|
|
2
|
+
import { AssertionFailed, NoTestClock, UnknownAction, UnknownSlot } from './errors'
|
|
3
|
+
import { matchProps } from './assert'
|
|
4
|
+
import { type RuntimeHandle, type UiContract } from '@playfast/reform'
|
|
5
|
+
import type { AnyComposition } from '@playfast/reform/internal'
|
|
6
|
+
import {
|
|
7
|
+
type HostRuntime,
|
|
8
|
+
keyed,
|
|
9
|
+
type MountedFacadeErasure,
|
|
10
|
+
type MountedSlotFacadeErasure,
|
|
11
|
+
type RuntimeRootComposition,
|
|
12
|
+
type Sink,
|
|
13
|
+
uiNameOf,
|
|
14
|
+
} from './engineSink'
|
|
15
|
+
import { makeTreeDriver } from './engineTreeDriver'
|
|
16
|
+
import type { RuntimeTreeFacade, RuntimeTreeFacadeErasure, TreeNode } from './engineTreeTypes'
|
|
17
|
+
|
|
18
|
+
export function makeRuntimeTreeFacade<
|
|
19
|
+
Services,
|
|
20
|
+
RootIdentifier extends NoInfer<Services>,
|
|
21
|
+
P,
|
|
22
|
+
C extends UiContract,
|
|
23
|
+
S extends ReadonlyArray<unknown>,
|
|
24
|
+
N extends string,
|
|
25
|
+
Identity,
|
|
26
|
+
>(
|
|
27
|
+
runtime: RuntimeHandle<Services>,
|
|
28
|
+
root: RuntimeRootComposition<RootIdentifier, P, C, S, N, Identity>,
|
|
29
|
+
rootProps: P,
|
|
30
|
+
sink: Sink,
|
|
31
|
+
dispatched: Set<string>,
|
|
32
|
+
): RuntimeTreeFacade<C>
|
|
33
|
+
export function makeRuntimeTreeFacade(
|
|
34
|
+
runtime: HostRuntime,
|
|
35
|
+
root: AnyComposition,
|
|
36
|
+
rootProps: unknown,
|
|
37
|
+
sink: Sink,
|
|
38
|
+
dispatched: Set<string>,
|
|
39
|
+
): RuntimeTreeFacadeErasure {
|
|
40
|
+
const driver = makeTreeDriver(runtime, root, rootProps, sink)
|
|
41
|
+
const currentRoot = (): TreeNode => driver.render().root
|
|
42
|
+
|
|
43
|
+
// The nested-Feature twin of the mounted facade's `advance`: same virtual clock
|
|
44
|
+
// off the same runtime, settled through this driver instead.
|
|
45
|
+
const advance = (duration: Duration.DurationInput): Effect.Effect<void, never, never> =>
|
|
46
|
+
Option.match(runtime.readOption(TestClock.TestClock), {
|
|
47
|
+
onNone: () => Effect.dieMessage(new NoTestClock().message),
|
|
48
|
+
onSome: (clock) => clock.adjust(duration).pipe(Effect.flatMap(() => driver.settle)),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// `peek` is the non-dying half of `resolve`: None once the node has left the
|
|
52
|
+
// tree, so an action that removed its own node can still report a result.
|
|
53
|
+
const nodeFacade = (
|
|
54
|
+
resolve: () => TreeNode,
|
|
55
|
+
peek: () => Option.Option<TreeNode>,
|
|
56
|
+
): MountedFacadeErasure => ({
|
|
57
|
+
props: Effect.sync(() => resolve().props),
|
|
58
|
+
expectProps: (partial) =>
|
|
59
|
+
Effect.sync(() => {
|
|
60
|
+
const mismatch = matchProps({
|
|
61
|
+
actual: resolve().props,
|
|
62
|
+
expected: partial,
|
|
63
|
+
})
|
|
64
|
+
if (mismatch !== undefined) {
|
|
65
|
+
return Effect.runSync(
|
|
66
|
+
Effect.dieMessage(new AssertionFailed({ detail: mismatch }).message),
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
}),
|
|
70
|
+
actions: keyed(
|
|
71
|
+
(event) => (payload: unknown) =>
|
|
72
|
+
Effect.gen(function* () {
|
|
73
|
+
const node = resolve()
|
|
74
|
+
const trigger = node.events[event]
|
|
75
|
+
if (trigger === undefined) {
|
|
76
|
+
return yield* Effect.dieMessage(
|
|
77
|
+
new UnknownAction({
|
|
78
|
+
composition: uiNameOf(node.comp),
|
|
79
|
+
action: event,
|
|
80
|
+
rendered: 1,
|
|
81
|
+
}).message,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
dispatched.add(event)
|
|
85
|
+
trigger(payload)
|
|
86
|
+
yield* driver.settle
|
|
87
|
+
// A self-removing action leaves nothing to re-read; report what the
|
|
88
|
+
// node last rendered instead of dying on the vanished child.
|
|
89
|
+
return Option.match(peek(), {
|
|
90
|
+
onNone: () => node.props,
|
|
91
|
+
onSome: (settled) => settled.props,
|
|
92
|
+
})
|
|
93
|
+
}),
|
|
94
|
+
),
|
|
95
|
+
slots: keyed((slotName) => slotFacade(resolve, slotName)),
|
|
96
|
+
frame: driver.settle,
|
|
97
|
+
advance,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const slotFacade = (
|
|
101
|
+
resolveParent: () => TreeNode,
|
|
102
|
+
slotName: string,
|
|
103
|
+
): MountedSlotFacadeErasure => {
|
|
104
|
+
const children = (): ReadonlyArray<TreeNode> => {
|
|
105
|
+
const parent = resolveParent()
|
|
106
|
+
const resolved = parent.slots[slotName]
|
|
107
|
+
if (resolved === undefined) {
|
|
108
|
+
return Effect.runSync(
|
|
109
|
+
Effect.dieMessage(
|
|
110
|
+
new UnknownSlot({ parent: uiNameOf(parent.comp), slot: slotName }).message,
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
return resolved
|
|
115
|
+
}
|
|
116
|
+
const peekAt = (index: number): Option.Option<TreeNode> =>
|
|
117
|
+
Option.fromNullable(children()[index])
|
|
118
|
+
const peekByKey = (key: string): Option.Option<TreeNode> =>
|
|
119
|
+
Arr.findFirst(children(), (candidate) => Option.contains(candidate.key, key))
|
|
120
|
+
const childAt = (index: number): MountedFacadeErasure =>
|
|
121
|
+
nodeFacade(
|
|
122
|
+
() => children()[index] ?? missingChild(index),
|
|
123
|
+
() => peekAt(index),
|
|
124
|
+
)
|
|
125
|
+
const childByKey = (key: string): MountedFacadeErasure =>
|
|
126
|
+
nodeFacade(
|
|
127
|
+
() => Option.getOrElse(peekByKey(key), () => missingKey(key)),
|
|
128
|
+
() => peekByKey(key),
|
|
129
|
+
)
|
|
130
|
+
const missingChild = (index: number): never => {
|
|
131
|
+
const parent = resolveParent()
|
|
132
|
+
return Effect.runSync(
|
|
133
|
+
Effect.dieMessage(
|
|
134
|
+
new UnknownSlot({
|
|
135
|
+
parent: uiNameOf(parent.comp),
|
|
136
|
+
slot: `${slotName}[${index}]`,
|
|
137
|
+
}).message,
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
const missingKey = (key: string): never => {
|
|
142
|
+
const parent = resolveParent()
|
|
143
|
+
return Effect.runSync(
|
|
144
|
+
Effect.dieMessage(
|
|
145
|
+
new UnknownSlot({
|
|
146
|
+
parent: uiNameOf(parent.comp),
|
|
147
|
+
slot: `${slotName}[key=${key}]`,
|
|
148
|
+
}).message,
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
const first = childAt(0)
|
|
153
|
+
return {
|
|
154
|
+
first: Effect.sync(() => childAt(0)),
|
|
155
|
+
at: (index) => Effect.sync(() => childAt(index)),
|
|
156
|
+
all: Effect.sync(() => children().map((_child, index) => childAt(index))),
|
|
157
|
+
byKey: (key) =>
|
|
158
|
+
Effect.sync(() => {
|
|
159
|
+
const child = Arr.findFirst(children(), (candidate) =>
|
|
160
|
+
Option.contains(candidate.key, key),
|
|
161
|
+
)
|
|
162
|
+
return Option.isNone(child) ? missingKey(key) : childByKey(key)
|
|
163
|
+
}),
|
|
164
|
+
where: (predicate) =>
|
|
165
|
+
Effect.sync(() =>
|
|
166
|
+
children().flatMap((child, index) => (predicate(child.props) ? [childAt(index)] : [])),
|
|
167
|
+
),
|
|
168
|
+
count: Effect.sync(() => children().length),
|
|
169
|
+
props: first.props,
|
|
170
|
+
expectProps: first.expectProps,
|
|
171
|
+
actions: first.actions,
|
|
172
|
+
slots: first.slots,
|
|
173
|
+
frame: driver.settle,
|
|
174
|
+
advance,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
facade: nodeFacade(currentRoot, () => Option.some(currentRoot())),
|
|
180
|
+
settle: driver.settle,
|
|
181
|
+
dispose: driver.dispose,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { type Effect, Match, type Option, Record as Rec } from 'effect'
|
|
2
|
+
import type { FeatureLoadFailed, SlotFill, Trigger, UiContract } from '@playfast/reform'
|
|
3
|
+
import type { AnyComposition, AnyFeatureBinding } from '@playfast/reform/internal'
|
|
4
|
+
import {
|
|
5
|
+
type HostRuntime,
|
|
6
|
+
type MountedFacade,
|
|
7
|
+
type MountedFacadeHandleErasure,
|
|
8
|
+
uiNameOf,
|
|
9
|
+
} from './engineSink'
|
|
10
|
+
|
|
11
|
+
export type FeatureMountState =
|
|
12
|
+
| { readonly _tag: 'Loading' }
|
|
13
|
+
| { readonly _tag: 'Live'; readonly runtime: HostRuntime }
|
|
14
|
+
| { readonly _tag: 'Failed'; readonly error: FeatureLoadFailed }
|
|
15
|
+
|
|
16
|
+
export interface TreeFeatureMount {
|
|
17
|
+
readonly id: string
|
|
18
|
+
readonly binding: AnyFeatureBinding
|
|
19
|
+
readonly parent: HostRuntime
|
|
20
|
+
readonly depth: number
|
|
21
|
+
readonly props: unknown
|
|
22
|
+
attempt: number
|
|
23
|
+
state: FeatureMountState
|
|
24
|
+
dispose: () => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TreeNode {
|
|
28
|
+
readonly path: string
|
|
29
|
+
readonly depth: number
|
|
30
|
+
readonly key: Option.Option<string>
|
|
31
|
+
readonly comp: AnyComposition
|
|
32
|
+
readonly props: unknown
|
|
33
|
+
readonly events: Record<string, Trigger<unknown>>
|
|
34
|
+
readonly slots: Readonly<Record<string, ReadonlyArray<TreeNode>>>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TreeRenderState {
|
|
38
|
+
readonly root: TreeNode
|
|
39
|
+
readonly fingerprint: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RuntimeTreeFacade<C extends UiContract> {
|
|
43
|
+
readonly facade: MountedFacade<C>
|
|
44
|
+
readonly settle: Effect.Effect<void, never, never>
|
|
45
|
+
dispose(): void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RuntimeTreeFacadeErasure extends MountedFacadeHandleErasure {
|
|
49
|
+
dispose(): void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface TreeDriver {
|
|
53
|
+
readonly render: () => TreeRenderState
|
|
54
|
+
readonly settle: Effect.Effect<void, never, never>
|
|
55
|
+
readonly dispose: () => void
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface MountIdentityInput {
|
|
59
|
+
readonly parent: HostRuntime
|
|
60
|
+
readonly parentPath: string
|
|
61
|
+
readonly slotName: string
|
|
62
|
+
readonly key: string
|
|
63
|
+
readonly binding: AnyFeatureBinding
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface FeatureRecordInput {
|
|
67
|
+
readonly id: string
|
|
68
|
+
readonly parent: HostRuntime
|
|
69
|
+
readonly binding: AnyFeatureBinding
|
|
70
|
+
readonly depth: number
|
|
71
|
+
readonly props: unknown
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface RenderCompositionInput {
|
|
75
|
+
readonly runtime: HostRuntime
|
|
76
|
+
readonly comp: AnyComposition
|
|
77
|
+
readonly props: unknown
|
|
78
|
+
readonly key: Option.Option<string>
|
|
79
|
+
readonly path: string
|
|
80
|
+
readonly depth: number
|
|
81
|
+
readonly seen: Set<string>
|
|
82
|
+
readonly mountMissing: boolean
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const encodeIdentityPart = (part: string): string => `${part.length}:${part}`
|
|
86
|
+
|
|
87
|
+
export const fillEntries = (
|
|
88
|
+
slotName: string,
|
|
89
|
+
fill: SlotFill<unknown>,
|
|
90
|
+
): ReadonlyArray<{ readonly key: string; readonly props: unknown }> =>
|
|
91
|
+
Match.value(fill).pipe(
|
|
92
|
+
Match.when({ _tag: 'Each' }, (each) => each.items),
|
|
93
|
+
Match.when({ _tag: 'One' }, (oneFill) => [{ key: `${slotName}.0`, props: oneFill.props }]),
|
|
94
|
+
Match.when({ _tag: 'Absent' }, () => []),
|
|
95
|
+
Match.exhaustive,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
export const treeFingerprint = (node: TreeNode): unknown => [
|
|
99
|
+
uiNameOf(node.comp),
|
|
100
|
+
node.key,
|
|
101
|
+
node.props,
|
|
102
|
+
Rec.map(node.slots, (children) => children.map(treeFingerprint)),
|
|
103
|
+
]
|
package/src/errors.ts
CHANGED
|
@@ -40,3 +40,22 @@ export class UnknownSlot extends UnknownSlotBase {
|
|
|
40
40
|
return `reform-proof: '${this.parent}' has no slot '${this.slot}'`
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
/** The `Data.TaggedError(tag)` shape for a payload-free error: a `void` argument. */
|
|
45
|
+
type TaggedErrorClassVoid<Tag extends string> = new (
|
|
46
|
+
args: void,
|
|
47
|
+
) => Cause.YieldableError & { readonly _tag: Tag }
|
|
48
|
+
|
|
49
|
+
const NoTestClockBase: TaggedErrorClassVoid<'reform-proof/NoTestClock'> = Data.TaggedError(
|
|
50
|
+
'reform-proof/NoTestClock',
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `app.advance(...)` ran against a scene whose runtime still holds the real clock,
|
|
55
|
+
* so there is no product time to move.
|
|
56
|
+
*/
|
|
57
|
+
export class NoTestClock extends NoTestClockBase {
|
|
58
|
+
override get message(): string {
|
|
59
|
+
return "reform-proof: app.advance(...) needs a virtual clock — wrap the scene's layers with `Proof.withTestClock(...)`. Without it the scene's procedures sleep on the real clock and only elapse in real time."
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/facade.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Duration, Effect } from 'effect'
|
|
2
|
+
import type {
|
|
3
|
+
SlotContract as ReformSlotContract,
|
|
4
|
+
Trigger,
|
|
5
|
+
UiContract,
|
|
6
|
+
} from '@playfast/reform'
|
|
7
|
+
|
|
8
|
+
export type EventsOf<C extends UiContract> = C extends {
|
|
9
|
+
readonly events: infer E extends NonNullable<UiContract['events']>
|
|
10
|
+
}
|
|
11
|
+
? E
|
|
12
|
+
: Readonly<Record<PropertyKey, never>>
|
|
13
|
+
export type EventNamesOf<C extends UiContract> = C extends {
|
|
14
|
+
readonly events: NonNullable<UiContract['events']>
|
|
15
|
+
}
|
|
16
|
+
? keyof EventsOf<C>
|
|
17
|
+
: never
|
|
18
|
+
export type SlotsOf<C extends UiContract> = C extends {
|
|
19
|
+
readonly slots: infer S extends NonNullable<UiContract['slots']>
|
|
20
|
+
}
|
|
21
|
+
? S
|
|
22
|
+
: Readonly<Record<PropertyKey, never>>
|
|
23
|
+
export type PayloadOf<T> = T extends Trigger<infer P> ? P : never
|
|
24
|
+
export type ContractOf<Comp> = Comp extends {
|
|
25
|
+
readonly Contract: infer C extends UiContract
|
|
26
|
+
}
|
|
27
|
+
? C
|
|
28
|
+
: never
|
|
29
|
+
export type ContractOfSlot<S> = ReformSlotContract<S>
|
|
30
|
+
|
|
31
|
+
export type ActionsOf<C extends UiContract, R = never> = C extends {
|
|
32
|
+
readonly events: NonNullable<UiContract['events']>
|
|
33
|
+
}
|
|
34
|
+
? {
|
|
35
|
+
readonly [K in keyof EventsOf<C>]: (
|
|
36
|
+
payload: PayloadOf<EventsOf<C>[K]>,
|
|
37
|
+
) => Effect.Effect<C['props'], never, R>
|
|
38
|
+
}
|
|
39
|
+
: Readonly<Record<PropertyKey, never>>
|
|
40
|
+
export type SlotFacadesOf<C extends UiContract, R = never> = C extends {
|
|
41
|
+
readonly slots: NonNullable<UiContract['slots']>
|
|
42
|
+
}
|
|
43
|
+
? {
|
|
44
|
+
readonly [K in keyof SlotsOf<C>]: SlotFacade<ContractOfSlot<SlotsOf<C>[K]>, R>
|
|
45
|
+
}
|
|
46
|
+
: Readonly<Record<PropertyKey, never>>
|
|
47
|
+
|
|
48
|
+
export type Action<R = never> = (payload: unknown) => Effect.Effect<unknown, never, R>
|
|
49
|
+
|
|
50
|
+
export interface Facade<C extends UiContract, R = never> {
|
|
51
|
+
readonly props: Effect.Effect<C['props'], never, R>
|
|
52
|
+
readonly expectProps: (partial: Partial<C['props']>) => Effect.Effect<void, never, R>
|
|
53
|
+
readonly actions: ActionsOf<C, R>
|
|
54
|
+
readonly slots: SlotFacadesOf<C, R>
|
|
55
|
+
readonly frame: Effect.Effect<void, never, R>
|
|
56
|
+
/**
|
|
57
|
+
* Elapse `duration` of the scene's product time, then settle. Every timer the
|
|
58
|
+
* scene's procedures hold — `Effect.sleep`, `Schedule`, `Clock` reads — moves by
|
|
59
|
+
* exactly this much and no real time passes, so a 1500ms poll interval is observed
|
|
60
|
+
* in microseconds and never races the runner.
|
|
61
|
+
*
|
|
62
|
+
* Requires the scene's layers to be wrapped in `Proof.withTestClock(...)`. Against
|
|
63
|
+
* the real clock there is no virtual time to move, so the proof dies with
|
|
64
|
+
* `NoTestClock` instead of silently doing nothing.
|
|
65
|
+
*/
|
|
66
|
+
readonly advance: (duration: Duration.DurationInput) => Effect.Effect<void, never, R>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface SlotFacade<C extends UiContract, R = never> extends Facade<C, R> {
|
|
70
|
+
readonly first: Effect.Effect<Facade<C, R>, never, R>
|
|
71
|
+
readonly at: (index: number) => Effect.Effect<Facade<C, R>, never, R>
|
|
72
|
+
readonly all: Effect.Effect<ReadonlyArray<Facade<C, R>>, never, R>
|
|
73
|
+
readonly byKey: (key: string) => Effect.Effect<Facade<C, R>, never, R>
|
|
74
|
+
readonly where: (
|
|
75
|
+
predicate: (props: C['props']) => boolean,
|
|
76
|
+
) => Effect.Effect<ReadonlyArray<Facade<C, R>>, never, R>
|
|
77
|
+
readonly count: Effect.Effect<number, never, R>
|
|
78
|
+
}
|