@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.
@@ -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
+ }
@@ -1,23 +1,22 @@
1
- import { Effect, Match, Option, Record as Rec } from 'effect'
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'
2
4
  import {
3
- type CompositionClass,
4
- type FeatureBinding,
5
- type RuntimeHandle,
6
- type SlotFill,
7
- type Trigger,
8
- } from '@playfast/reform'
9
- import type { MountedFacade } from './runtimeFacade'
10
- import { uiNameOf } from './sink'
5
+ type HostRuntime,
6
+ type MountedFacade,
7
+ type MountedFacadeHandleErasure,
8
+ uiNameOf,
9
+ } from './engineSink'
11
10
 
12
11
  export type FeatureMountState =
13
12
  | { readonly _tag: 'Loading' }
14
- | { readonly _tag: 'Live'; readonly runtime: RuntimeHandle }
15
- | { readonly _tag: 'Failed'; readonly error: unknown }
13
+ | { readonly _tag: 'Live'; readonly runtime: HostRuntime }
14
+ | { readonly _tag: 'Failed'; readonly error: FeatureLoadFailed }
16
15
 
17
16
  export interface TreeFeatureMount {
18
17
  readonly id: string
19
- readonly binding: FeatureBinding
20
- readonly parent: RuntimeHandle
18
+ readonly binding: AnyFeatureBinding
19
+ readonly parent: HostRuntime
21
20
  readonly depth: number
22
21
  readonly props: unknown
23
22
  attempt: number
@@ -29,7 +28,7 @@ export interface TreeNode {
29
28
  readonly path: string
30
29
  readonly depth: number
31
30
  readonly key: Option.Option<string>
32
- readonly comp: CompositionClass<unknown>
31
+ readonly comp: AnyComposition
33
32
  readonly props: unknown
34
33
  readonly events: Record<string, Trigger<unknown>>
35
34
  readonly slots: Readonly<Record<string, ReadonlyArray<TreeNode>>>
@@ -40,12 +39,16 @@ export interface TreeRenderState {
40
39
  readonly fingerprint: string
41
40
  }
42
41
 
43
- export interface RuntimeTreeFacade {
44
- readonly facade: MountedFacade
42
+ export interface RuntimeTreeFacade<C extends UiContract> {
43
+ readonly facade: MountedFacade<C>
45
44
  readonly settle: Effect.Effect<void, never, never>
46
45
  dispose(): void
47
46
  }
48
47
 
48
+ export interface RuntimeTreeFacadeErasure extends MountedFacadeHandleErasure {
49
+ dispose(): void
50
+ }
51
+
49
52
  export interface TreeDriver {
50
53
  readonly render: () => TreeRenderState
51
54
  readonly settle: Effect.Effect<void, never, never>
@@ -53,29 +56,30 @@ export interface TreeDriver {
53
56
  }
54
57
 
55
58
  export interface MountIdentityInput {
56
- readonly parent: RuntimeHandle
59
+ readonly parent: HostRuntime
57
60
  readonly parentPath: string
58
61
  readonly slotName: string
59
62
  readonly key: string
60
- readonly binding: FeatureBinding
63
+ readonly binding: AnyFeatureBinding
61
64
  }
62
65
 
63
66
  export interface FeatureRecordInput {
64
67
  readonly id: string
65
- readonly parent: RuntimeHandle
66
- readonly binding: FeatureBinding
68
+ readonly parent: HostRuntime
69
+ readonly binding: AnyFeatureBinding
67
70
  readonly depth: number
68
71
  readonly props: unknown
69
72
  }
70
73
 
71
74
  export interface RenderCompositionInput {
72
- readonly runtime: RuntimeHandle
73
- readonly comp: CompositionClass<unknown>
75
+ readonly runtime: HostRuntime
76
+ readonly comp: AnyComposition
74
77
  readonly props: unknown
75
78
  readonly key: Option.Option<string>
76
79
  readonly path: string
77
80
  readonly depth: number
78
81
  readonly seen: Set<string>
82
+ readonly mountMissing: boolean
79
83
  }
80
84
 
81
85
  export const encodeIdentityPart = (part: string): string => `${part.length}:${part}`
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
+ }