@playfast/reform-remote 0.0.3 → 0.0.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@playfast/reform-remote",
3
3
  "playbook": "./playbook",
4
- "version": "0.0.3",
4
+ "version": "0.0.5",
5
5
  "type": "module",
6
6
  "description": "Run a reform scene's logic on the server and stream its rendered UI to a thin client over any duplex transport.",
7
7
  "keywords": [
package/src/client.ts CHANGED
@@ -227,47 +227,52 @@ const WireNodeView = ({
227
227
  ? encoded
228
228
  : Schema.decodeUnknownSync(registered.propsSchema)(encoded)
229
229
 
230
- const children = Wire.childrenOf(tree, node.id)
231
- const slots: Record<string, (slotProps?: { readonly slotKey?: string }) => ReactNode> = {}
232
- for (const slotName of new Set(children.map((child) => child.slot))) {
233
- if (slotName === null) continue
234
- // A slot is a COMPONENT rendering that slot's wire children — usable as `<slots.Foo/>`
235
- // (Ui.make convention) or `slots.Foo()` (thunk convention). The function is cached so its
236
- // identity is STABLE across renders (see `slotCache` above); it reads `latest.current` so
237
- // each invocation renders against the current tree/config.
238
- //
239
- // KEYED SLOTS: when the caller passes `slotKey` (`<slots.Row slotKey={id} />`), render only
240
- // the ONE wire child whose `key` matches — the per-item identity the server captured from
241
- // the parent's React `key` (see WireNode.key). This is what lets a LIST slot be invoked once
242
- // per item without each call rendering the WHOLE list (the duplicate-rows bug). With NO
243
- // `slotKey` the behaviour is unchanged: render every child of the slot (correct for a
244
- // singleton slot rendered once, e.g. `<slots.Create/>`).
230
+ // A slot resolves to a COMPONENT rendering that slot's wire children usable as
231
+ // `<slots.Foo/>` (Ui.make convention) or `slots.Foo()` (thunk convention). The function is
232
+ // cached so its identity is STABLE across renders (see `slotCache` above); it reads
233
+ // `latest.current` so each invocation renders against the current tree/config.
234
+ //
235
+ // KEYED SLOTS: when the caller passes `slotKey` (`<slots.Row slotKey={id} />`), render only
236
+ // the ONE wire child whose `key` matches the per-item identity the server captured from
237
+ // the parent's React `key` (see WireNode.key). This is what lets a LIST slot be invoked once
238
+ // per item without each call rendering the WHOLE list (the duplicate-rows bug). With NO
239
+ // `slotKey` the behaviour is unchanged: render every child of the slot (correct for a
240
+ // singleton slot rendered once, e.g. `<slots.Create/>`).
241
+ const slotFor = (slotName: string): ((slotProps?: { readonly slotKey?: string }) => ReactNode) => {
245
242
  const cached = slotCache.current[slotName]
246
- const stable =
247
- cached ??
248
- ((slotProps?: { readonly slotKey?: string }): ReactNode => {
249
- const { node: currentNode, tree: currentTree, config: currentConfig } = latest.current
250
- const requestedKey = slotProps?.slotKey
251
- return createElement(
252
- Fragment,
253
- null,
254
- ...Wire.childrenOf(currentTree, currentNode.id)
255
- .filter((child) => child.slot === slotName)
256
- .filter((child) => requestedKey === undefined || child.key === requestedKey)
257
- .map((child) =>
258
- createElement(WireNodeView, {
259
- key: child.id,
260
- node: child,
261
- tree: currentTree,
262
- config: currentConfig,
263
- }),
264
- ),
265
- )
266
- })
243
+ if (cached !== undefined) return cached
244
+ const stable = (slotProps?: { readonly slotKey?: string }): ReactNode => {
245
+ const { node: currentNode, tree: currentTree, config: currentConfig } = latest.current
246
+ const requestedKey = slotProps?.slotKey
247
+ return createElement(
248
+ Fragment,
249
+ null,
250
+ ...Wire.childrenOf(currentTree, currentNode.id)
251
+ .filter((child) => child.slot === slotName)
252
+ .filter((child) => requestedKey === undefined || child.key === requestedKey)
253
+ .map((child) =>
254
+ createElement(WireNodeView, {
255
+ key: child.id,
256
+ node: child,
257
+ tree: currentTree,
258
+ config: currentConfig,
259
+ }),
260
+ ),
261
+ )
262
+ }
267
263
  slotCache.current[slotName] = stable
268
- slots[slotName] = stable
264
+ return stable
269
265
  }
270
266
 
267
+ // Resolve slots LAZILY by name: a view referencing `<slots.Item/>` when that slot has no
268
+ // wire children this frame (e.g. an empty list) gets a component that renders nothing,
269
+ // rather than `undefined` (which React rejects as an invalid element type). Mirrors the
270
+ // engine's total slot proxies — every declared slot is always callable.
271
+ const slots: Record<string, (slotProps?: { readonly slotKey?: string }) => ReactNode> = new Proxy(
272
+ Object.create(null),
273
+ { get: (_target, key) => (typeof key === 'string' ? slotFor(key) : undefined) },
274
+ )
275
+
271
276
  return registered.view(props, slots, events)
272
277
  }
273
278
 
package/src/fixtures.ts CHANGED
@@ -15,6 +15,9 @@ import {
15
15
  StateGroup,
16
16
  Ui,
17
17
  type WiredUi,
18
+ each,
19
+ mount,
20
+ one,
18
21
  provide,
19
22
  scene,
20
23
  slot,
@@ -25,10 +28,10 @@ import {
25
28
  * Shared scenes the remote-transport tests render server-side. Each is a closed
26
29
  * `Scene` wired the canonical way — compositions + reducers in `Layer.mergeAll`,
27
30
  * then `provideMerge(presentation)` (views + seeded state) and `provideMerge(Engine)`
28
- * — so they exercise the real runtime, not a stub. The presentations here are the
29
- * server-side `Ui.make` views that *expand* the tree (a list view maps its state to
30
- * N slot children); the client renders the resulting wire tree with its own
31
- * `RemoteView`s (thunk slots), which the transport tests supply separately.
31
+ * — so they exercise the real runtime, not a stub. The compositions return
32
+ * headless structures; the server expands slot fills from those structures and the
33
+ * client renders the resulting wire tree with its own `RemoteView`s (thunk slots),
34
+ * which the transport tests supply separately.
32
35
  */
33
36
 
34
37
  // ── counter: a flat composition with own state + one event ────────────────────
@@ -60,13 +63,33 @@ export const counterScene = (boot?: ReadonlyArray<EventOf<'Bumped', { by: number
60
63
  Composition.live(Counter, function* () {
61
64
  const count = yield* StateGroup.select(Counters, 'count')
62
65
  const bump = yield* Event.trigger(Bumped)
63
- return (yield* CounterUi)({ count }, { bump })
66
+ return mount({ props: { count }, slots: {}, events: { bump } })
64
67
  }),
65
68
  Reducer.live(Bump, (n, event) => n + event.by),
66
69
  ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
67
70
  return scene(Counter, boot === undefined ? { provide: [app] } : { provide: [app], boot })
68
71
  }
69
72
 
73
+ // ── structure counter: kept as explicit structure-event coverage. The `bump`
74
+ // trigger rides ON the structure, so the server reads it from `structure.events`,
75
+ // registers the handle, and produces the same wire shape as a legacy view body.
76
+ // ───────────────────────────────────────────────────────────────────────────────
77
+ export const structureCounterScene = (): Scene => {
78
+ const presentation = Layer.mergeAll(
79
+ provide(CounterUi, Ui.make(CounterUi, ({ count }) => `count:${count}`)),
80
+ StateGroup.live(Counters, { count: 0 }),
81
+ )
82
+ const app = Layer.mergeAll(
83
+ Composition.live(Counter, function* () {
84
+ const count = yield* StateGroup.select(Counters, 'count')
85
+ const bump = yield* Event.trigger(Bumped)
86
+ return mount({ props: { count }, slots: {}, events: { bump } })
87
+ }),
88
+ Reducer.live(Bump, (n, event) => n + event.by),
89
+ ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
90
+ return scene(Counter, { provide: [app] })
91
+ }
92
+
70
93
  // ── option: a flat composition whose prop is an `Option` (an Effect-native, non-JSON
71
94
  // value) — exercises the symmetric schema encode/decode so the client receives a REAL
72
95
  // `Option`, not its raw `{_tag,value}` wire shape. ──────────────────────────────────
@@ -86,7 +109,7 @@ export const optionScene = (initial: Option.Option<string> = Option.some('hi')):
86
109
  const app = Layer.mergeAll(
87
110
  Composition.live(Optional, function* () {
88
111
  const label = yield* StateGroup.select(Labels, 'label')
89
- return (yield* OptionalUi)({ label })
112
+ return mount({ props: { label }, slots: {} })
90
113
  }),
91
114
  ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
92
115
  return scene(Optional, { provide: [app] })
@@ -117,15 +140,14 @@ export const slottedScene = (): Scene => {
117
140
  )
118
141
  const app = Layer.mergeAll(
119
142
  Composition.live(Shell, function* () {
120
- const view = yield* ShellUi
121
- return view({})
143
+ return mount({ props: {}, slots: { Main: one({}) } })
122
144
  }),
123
145
  Composition.live(Panel, function* () {
124
146
  const greeting = yield* Greeting
125
147
  const greet = yield* Event.trigger(Greeted)
126
148
  const clearTrigger = yield* Event.trigger(Cleared)
127
149
  const clear = (): void => clearTrigger({})
128
- return (yield* PanelUi)({ greeting }, { greet, clear })
150
+ return mount({ props: { greeting }, slots: {}, events: { greet, clear } })
129
151
  }),
130
152
  Reducer.live(SetGreeting, (_greeting, event) => event.text),
131
153
  Reducer.live(ClearGreeting, () => ''),
@@ -171,12 +193,12 @@ export const listScene = (
171
193
  const presentation = Layer.mergeAll(
172
194
  provide(
173
195
  ListUi,
174
- Ui.make(ListUi, ({ items }, slots) =>
196
+ Ui.make(ListUi, (_props, slots) =>
175
197
  createElement(
176
198
  Fragment,
177
199
  null,
178
200
  createElement(slots.Bar, {}),
179
- ...items.map((item) => createElement(slots.Item, { id: item.id, label: item.label })),
201
+ createElement(slots.Item, {}),
180
202
  ),
181
203
  ),
182
204
  ),
@@ -189,12 +211,20 @@ export const listScene = (
189
211
  const app = Layer.mergeAll(
190
212
  Composition.live(ListComp, function* () {
191
213
  const items = yield* Items
192
- const view = yield* ListUi
193
- return view({ items })
214
+ return mount({
215
+ props: { items },
216
+ slots: {
217
+ Bar: one({}),
218
+ Item: each(items, {
219
+ key: (item) => item.id,
220
+ props: (item) => ({ id: item.id, label: item.label }),
221
+ }),
222
+ },
223
+ })
194
224
  }),
195
225
  Composition.live(Bar, function* () {
196
226
  const add = yield* Event.trigger(Added)
197
- return (yield* BarUi)({}, { add })
227
+ return mount({ props: {}, slots: {}, events: { add } })
198
228
  }),
199
229
  Composition.live(ItemComp, function* () {
200
230
  const item = (yield* Props) as { id: string; label: string }
@@ -202,7 +232,7 @@ export const listScene = (
202
232
  // The item binds its own id, so the wire `remove` carries no payload — the
203
233
  // client fires it knowing only the handle.
204
234
  const remove = (): void => removeTrigger({ id: item.id })
205
- return (yield* ItemUi)({ label: item.label }, { remove })
235
+ return mount({ props: { label: item.label }, slots: {}, events: { remove } })
206
236
  }),
207
237
  Reducer.live(AddItem, (items, event) => [...items, { id: event.id, label: event.label }]),
208
238
  Reducer.live(RemoveItem, (items, event) => items.filter((item) => item.id !== event.id)),
@@ -228,7 +258,7 @@ export const asyncCounterScene = (delay: Duration.DurationInput = '40 millis'):
228
258
  Composition.live(Counter, function* () {
229
259
  const count = yield* StateGroup.select(Counters, 'count')
230
260
  const bump = yield* Event.trigger(Bumped)
231
- return (yield* CounterUi)({ count }, { bump })
261
+ return mount({ props: { count }, slots: {}, events: { bump } })
232
262
  }),
233
263
  Reducer.live(Bump, (n, event) => n + event.by),
234
264
  // The procedure runs on the bus AFTER the scene boots: it sleeps past the opening
package/src/index.ts CHANGED
@@ -14,7 +14,7 @@ export * as Transport from './transport'
14
14
  export * as Memory from './memory'
15
15
  export * as React from './react'
16
16
 
17
- export { makeRemoteServer, type RemoteServer } from './server'
17
+ export { makeRemoteServer, type RemoteServer, renderSceneToWire } from './server'
18
18
  export {
19
19
  type ClientConfig,
20
20
  type RegisteredRemoteView,
@@ -14,6 +14,7 @@ import {
14
14
  OptionalUi,
15
15
  optionScene,
16
16
  slottedScene,
17
+ structureCounterScene,
17
18
  } from './fixtures'
18
19
 
19
20
  // The client renders views as React components, so a test runs them by RENDERING
@@ -65,6 +66,27 @@ test('invoking a trigger handle dispatches into the runtime and the next render
65
66
  }
66
67
  })
67
68
 
69
+ // ── structure-path counter: events ride ON the structure (plan 03b) ─────────────
70
+
71
+ test('a mount(...)-returning body registers its event from structure.events and invoking it dispatches', async () => {
72
+ const server = makeRemoteServer(structureCounterScene())
73
+ try {
74
+ const tree = await server.render()
75
+ const root = tree[0]!
76
+ expect(root.name).toBe('Counter')
77
+ // Props come from the structure value; the `bump` trigger rode on
78
+ // `structure.events` and was registered behind the standard handle.
79
+ expect(dataOf(root, 'count')).toEqual({ _tag: 'Data', name: 'count', value: 0 })
80
+ expect(eventOf(root, 'bump')).toEqual({ _tag: 'Event', name: 'bump', handle: '0:bump' })
81
+
82
+ await server.invoke('0:bump', { by: 4 })
83
+ const next = await server.render()
84
+ expect(dataOf(next[0]!, 'count')).toEqual({ _tag: 'Data', name: 'count', value: 4 })
85
+ } finally {
86
+ await server.dispose()
87
+ }
88
+ })
89
+
68
90
  test('the wire payload is validated at the seam — a bad payload rejects', async () => {
69
91
  const server = makeRemoteServer(counterScene())
70
92
  try {
package/src/server.ts CHANGED
@@ -1,24 +1,23 @@
1
- import { Effect, Layer, ManagedRuntime, type ParseResult, PubSub, Queue, Schema } from 'effect'
2
- import { isValidElement } from 'react'
1
+ import { Effect, Layer, Match, ManagedRuntime, type ParseResult, PubSub, Queue, Schema } from 'effect'
3
2
  import {
4
3
  Bus,
5
- CaptureSink,
6
- type CaptureSinkApi,
7
4
  Composition,
8
5
  type CompositionClass,
9
6
  type CompositionService,
10
7
  forceSync,
11
8
  isFeatureBinding,
12
- type Node,
9
+ isStructure,
13
10
  publish,
14
11
  type RenderEnv,
15
12
  type Scene,
16
13
  type SlotChild,
17
14
  type SlotClass,
18
- type SlotHost,
15
+ type SlotFill,
16
+ type Structure,
19
17
  Triggers,
18
+ type Trigger,
20
19
  type TriggerRegistryApi,
21
- type UiCapture,
20
+ type UiContract,
22
21
  type UiManifest,
23
22
  Wire,
24
23
  type WireNode,
@@ -30,11 +29,16 @@ import {
30
29
  /**
31
30
  * The server side of the remote transport (REMOTE_UI.md §5): render a closed
32
31
  * `Scene` to a serializable `WireTree`, registering each trigger behind a handle.
33
- * Built on the existing `CaptureSink` seamthe same one proofs use headlessly —
34
- * so nothing in core changes: production has no sink (renders to React), a host
35
- * provides one (here, to serialize). The render walk lifts proof's breadth-first
36
- * `collect`/`drive`, assigning stable ids and encoding props via each contract's
37
- * own wire schema (`WiredUiManifest`, Phase 1).
32
+ * Reads each composition's returned `Structure` value directly props, per-slot
33
+ * fills, and event triggers are all DATA so the server imports no React and never
34
+ * evaluates a view. The render walk is breadth-first over the structure tree,
35
+ * assigning stable ids and encoding props via each contract's own wire schema
36
+ * (`WiredUiManifest`, Phase 1).
37
+ *
38
+ * The render walk (`renderSceneToWire`) is a free-standing effect that requires only
39
+ * `CompositionService | SlotChild` — so any runtime that supplies a scene's services
40
+ * can render a frame, not just `makeRemoteServer`'s own. `@playfast/reform-driver-shot`
41
+ * runs it against `@playfast/reform-drive`'s runtime to screenshot a driven state.
38
42
  */
39
43
 
40
44
  // The runtime services a closed scene exposes (mirrors proof's erasure boundary:
@@ -45,24 +49,13 @@ type RuntimeServices = CompositionService | SlotChild | Bus
45
49
  const SETTLE_DRAIN = 30
46
50
  const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
47
51
 
48
- interface Sink {
49
- readonly api: CaptureSinkApi
50
- readonly captures: ReadonlyArray<UiCapture>
51
- reset(): void
52
- }
53
-
54
- const makeSink = (): Sink => {
55
- const state: { captures: UiCapture[] } = { captures: [] }
56
- return {
57
- api: { record: (capture) => state.captures.push(capture) },
58
- get captures() {
59
- return state.captures
60
- },
61
- reset: () => {
62
- state.captures = []
63
- },
64
- }
65
- }
52
+ // The event triggers a `Structure` frame carries (plan 03b). A composition's logic
53
+ // returns its acquired triggers ON the structure value (it never evaluates a view),
54
+ // so the server reads them straight from there. At the erased `UiContract` boundary the contract's event map
55
+ // is `Record<never, never>`, so each value is `never` — assignable to
56
+ // `Trigger<unknown>` without a cast — and an omitted `events` defaults to empty.
57
+ const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
58
+ Object.fromEntries(Object.entries(structure.events ?? {}))
66
59
 
67
60
  const closedSceneLayer = (scene: Scene): Layer.Layer<RuntimeServices, never, never> =>
68
61
  scene.provide.reduce((a, b) => Layer.merge(a, b)) as unknown as Layer.Layer<
@@ -86,35 +79,196 @@ interface Mounted {
86
79
  const childComposition = (child: SlotChild): CompositionClass<unknown> =>
87
80
  isFeatureBinding(child) ? child.composition : child
88
81
 
82
+ const handlesOf = (node: WireNode): ReadonlyArray<string> =>
83
+ node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []))
84
+
85
+ // A static render (a screenshot) has no client to receive trigger handles, so the
86
+ // default registrar is a no-op: the wire node still carries its `Event` props (with
87
+ // deterministic handles), they're just never registered for invocation.
88
+ const noRegister: TriggerRegistryApi['register'] = () => Effect.void
89
+
90
+ // Structure → wire encoding (plan 02 + 03b). Props come straight from the
91
+ // `Structure` value (data, never a view render). Event triggers ride ON the
92
+ // structure (`structure.events`) and are registered through `register` (the live
93
+ // registry for a streaming server, a no-op for a one-shot render). Produces the
94
+ // wire shape the transport and `Wire.diff` consume.
95
+ const toWireNodeFromStructure = (
96
+ m: Mounted,
97
+ structure: Structure<UiContract>,
98
+ register: TriggerRegistryApi['register'],
99
+ ): Effect.Effect<WireNode, ParseResult.ParseError> =>
100
+ Effect.gen(function* () {
101
+ const manifest = m.comp.manifest.ui.manifest as UiManifest
102
+ const name = manifest.name
103
+
104
+ const dataProps: WireProp[] = []
105
+ if (manifest.props !== undefined) {
106
+ const encoded = yield* Schema.encodeUnknown(manifest.props)(structure.props)
107
+ for (const [propName, value] of Object.entries(encoded as Record<string, unknown>)) {
108
+ dataProps.push({ _tag: 'Data', name: propName, value })
109
+ }
110
+ }
111
+
112
+ const eventSchemas = manifest.events ?? {}
113
+ const eventProps: WireProp[] = []
114
+ const events = eventsOf(structure)
115
+ for (const [eventName, trigger] of Object.entries(events)) {
116
+ const schema = eventSchemas[eventName]
117
+ if (schema === undefined) continue
118
+ const handle = `${m.id}:${eventName}`
119
+ yield* register(handle, trigger, schema)
120
+ eventProps.push({ _tag: 'Event', name: eventName, handle })
121
+ }
122
+
123
+ return {
124
+ id: m.id,
125
+ name,
126
+ parentId: m.parentId,
127
+ childIndex: m.childIndex,
128
+ slot: m.slot,
129
+ key: m.key,
130
+ props: [...dataProps, ...eventProps],
131
+ }
132
+ })
133
+
134
+ // Narrow an erased slot value to a `SlotFill` by its discriminant. `Structure<
135
+ // UiContract>` erases its per-slot fill types at this boundary (the strict typing
136
+ // lives on the concrete contract), so the runtime fills arrive as `unknown` and
137
+ // are recovered structurally — no `as`.
138
+ const isSlotFill = (u: unknown): u is SlotFill<unknown> =>
139
+ typeof u === 'object' &&
140
+ u !== null &&
141
+ '_tag' in u &&
142
+ (u._tag === 'Each' || u._tag === 'One' || u._tag === 'Absent')
143
+
144
+ // Read a structure's fills by slot name, recovering each erased value via the
145
+ // discriminant guard. Returns a plain record keyed by declared slot name.
146
+ const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
147
+ Object.fromEntries(
148
+ Object.entries(structure.slots).flatMap(([name, value]) =>
149
+ isSlotFill(value) ? [[name, value] as const] : [],
150
+ ),
151
+ )
152
+
153
+ // Enqueue the children a single declared slot is filled with, reading the fill
154
+ // (Each / One / Absent) from the returned `Structure`. Multiplicity, per-item
155
+ // `key`, and props are carried as data — no view walk, no reconstruction.
156
+ const enqueueStructureSlot = (
157
+ parent: Mounted,
158
+ slotName: string,
159
+ child: CompositionClass<unknown>,
160
+ fill: SlotFill<unknown>,
161
+ next: Mounted[],
162
+ ): void =>
163
+ Match.value(fill).pipe(
164
+ Match.tag('Each', (each) => {
165
+ each.items.forEach((item, index) => {
166
+ next.push({
167
+ comp: child,
168
+ props: item.props,
169
+ id: `${parent.id}.${slotName}.${index}`,
170
+ parentId: parent.id,
171
+ slot: slotName,
172
+ childIndex: index,
173
+ key: item.key,
174
+ })
175
+ })
176
+ }),
177
+ Match.tag('One', (single) => {
178
+ next.push({
179
+ comp: child,
180
+ props: single.props,
181
+ id: `${parent.id}.${slotName}.0`,
182
+ parentId: parent.id,
183
+ slot: slotName,
184
+ childIndex: 0,
185
+ key: null,
186
+ })
187
+ }),
188
+ Match.tag('Absent', () => {}),
189
+ Match.exhaustive,
190
+ )
191
+
192
+ const collect = (
193
+ root: CompositionClass<unknown>,
194
+ ): Effect.Effect<Map<SlotClass, CompositionClass<unknown>>, never, SlotChild> =>
195
+ Effect.gen(function* () {
196
+ const bindings = new Map<SlotClass, CompositionClass<unknown>>()
197
+ const walk = (comp: CompositionClass<unknown>): Effect.Effect<void, never, SlotChild> =>
198
+ Effect.gen(function* () {
199
+ for (const slotClass of Object.values(comp.manifest.slots ?? {})) {
200
+ if (bindings.has(slotClass)) continue
201
+ const child = childComposition(yield* slotClass.tag)
202
+ bindings.set(slotClass, child)
203
+ yield* walk(child)
204
+ }
205
+ })
206
+ yield* walk(root)
207
+ return bindings
208
+ })
209
+
210
+ const renderLevel = (
211
+ frontier: ReadonlyArray<Mounted>,
212
+ bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
213
+ register: TriggerRegistryApi['register'],
214
+ ): Effect.Effect<ReadonlyArray<WireNode>, ParseResult.ParseError, CompositionService> =>
215
+ Effect.gen(function* () {
216
+ if (frontier.length === 0) return []
217
+ const next: Mounted[] = []
218
+ const nodes: WireNode[] = []
219
+ for (const mounted of frontier) {
220
+ const service = yield* mounted.comp.tag
221
+ const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
222
+ const frame = yield* Composition.render(service, env)
223
+ // Every composition returns a `Structure` (the Node render path is gone): its
224
+ // wire node's props come from `frame.props`, event triggers from `frame.events`,
225
+ // and children are enqueued by reading each declared slot's fill — no view eval.
226
+ if (!isStructure(frame)) {
227
+ return yield* Effect.dieMessage(
228
+ `reform-remote server: composition ${mounted.comp.manifest.name} did not return a Structure`,
229
+ )
230
+ }
231
+ nodes.push(yield* toWireNodeFromStructure(mounted, frame, register))
232
+ const fills = structureFills(frame)
233
+ for (const [slotName, slotClass] of Object.entries(mounted.comp.manifest.slots ?? {})) {
234
+ const child = bindings.get(slotClass)
235
+ if (child === undefined) continue
236
+ const fill = fills[slotName]
237
+ if (fill === undefined) continue
238
+ enqueueStructureSlot(mounted, slotName, child, fill, next)
239
+ }
240
+ }
241
+ const rest = yield* renderLevel(next, bindings, register)
242
+ return [...nodes, ...rest]
243
+ })
244
+
89
245
  /**
90
- * Walk a rendered node and enqueue the child each slot placeholder stands for.
91
- * Slot components are matched by identity (the host defers them as JSX), so this
92
- * never invokes nor needs hooks from ordinary presentation components.
246
+ * Render the scene's CURRENT frame to a serializable `WireTree`, breadth-first from
247
+ * the root composition. Requires only the scene's render services
248
+ * (`CompositionService | SlotChild`), so it runs on any runtime built from the same
249
+ * closed scene — `makeRemoteServer`'s, or `@playfast/reform-drive`'s (the screenshot
250
+ * path). `register` defaults to a no-op: a one-shot render needs no live trigger
251
+ * registry, while `makeRemoteServer` passes its connection registry so the streamed
252
+ * client can invoke handles.
93
253
  */
94
- const drive = (
95
- node: Node,
96
- enqueueBy: Map<Function, (props: unknown, key: string | null) => void>,
97
- ): void => {
98
- if (Array.isArray(node)) {
99
- for (const child of node) drive(child, enqueueBy)
100
- return
101
- }
102
- if (!isValidElement<{ readonly children?: Node }>(node)) return
103
- if (node.type instanceof Function) {
104
- const enqueue = enqueueBy.get(node.type)
105
- if (enqueue !== undefined) {
106
- // `node.key` is the React key the parent set (`createElement(slots.Row, { key })`),
107
- // carried onto the wire node so a KEYED slot can select this child on the client
108
- // (see WireNode.key). React coerces a set key to a string; absent → null.
109
- enqueue(node.props, node.key)
110
- return
254
+ export const renderSceneToWire = (
255
+ scene: Scene,
256
+ options?: { readonly register?: TriggerRegistryApi['register'] },
257
+ ): Effect.Effect<WireTree, ParseResult.ParseError, CompositionService | SlotChild> =>
258
+ Effect.gen(function* () {
259
+ const register = options?.register ?? noRegister
260
+ const bindings = yield* collect(scene.composition)
261
+ const root: Mounted = {
262
+ comp: scene.composition,
263
+ props: {},
264
+ id: '0',
265
+ parentId: null,
266
+ slot: null,
267
+ childIndex: 0,
268
+ key: null,
111
269
  }
112
- }
113
- drive(node.props.children, enqueueBy)
114
- }
115
-
116
- const handlesOf = (node: WireNode): ReadonlyArray<string> =>
117
- node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []))
270
+ return yield* renderLevel([root], bindings, register)
271
+ })
118
272
 
119
273
  export interface RemoteServer {
120
274
  /** Render the current frame to a full wire tree; triggers are (re-)registered behind stable handles. */
@@ -141,126 +295,14 @@ export interface RemoteServer {
141
295
  }
142
296
 
143
297
  export const makeRemoteServer = (scene: Scene): RemoteServer => {
144
- const sink = makeSink()
145
- const runtime = ManagedRuntime.make(
146
- Layer.provideMerge(closedSceneLayer(scene), Layer.succeed(CaptureSink, sink.api)),
147
- )
298
+ const runtime = ManagedRuntime.make(closedSceneLayer(scene))
148
299
  const registry: TriggerRegistryApi = forceSync(() => runtime.runSync(Triggers.make))
149
300
 
150
- const collect = (
151
- root: CompositionClass<unknown>,
152
- ): Effect.Effect<Map<SlotClass, CompositionClass<unknown>>, never, SlotChild> =>
153
- Effect.gen(function* () {
154
- const bindings = new Map<SlotClass, CompositionClass<unknown>>()
155
- const walk = (comp: CompositionClass<unknown>): Effect.Effect<void, never, SlotChild> =>
156
- Effect.gen(function* () {
157
- for (const slotClass of Object.values(comp.manifest.slots ?? {})) {
158
- if (bindings.has(slotClass)) continue
159
- const child = childComposition(yield* slotClass.tag)
160
- bindings.set(slotClass, child)
161
- yield* walk(child)
162
- }
163
- })
164
- yield* walk(root)
165
- return bindings
166
- })
167
-
168
- const toWireNode = (m: Mounted, capture: UiCapture): Effect.Effect<WireNode, ParseResult.ParseError> =>
169
- Effect.gen(function* () {
170
- // The composition's contract carries the wire schemas at runtime (the type is
171
- // the erased `{ manifest }` carrier — `composition.ts:34`); read them as `UiManifest`.
172
- const manifest = m.comp.manifest.ui.manifest as UiManifest
173
-
174
- const dataProps: WireProp[] = []
175
- if (manifest.props !== undefined) {
176
- const encoded = yield* Schema.encodeUnknown(manifest.props)(capture.props)
177
- for (const [name, value] of Object.entries(encoded as Record<string, unknown>)) {
178
- dataProps.push({ _tag: 'Data', name, value })
179
- }
180
- }
181
-
182
- const eventSchemas = manifest.events ?? {}
183
- const eventProps: WireProp[] = []
184
- for (const [name, trigger] of Object.entries(capture.events)) {
185
- const schema = eventSchemas[name]
186
- if (schema === undefined) continue
187
- const handle = `${m.id}:${name}`
188
- yield* registry.register(handle, trigger, schema)
189
- eventProps.push({ _tag: 'Event', name, handle })
190
- }
191
-
192
- return {
193
- id: m.id,
194
- name: capture.name,
195
- parentId: m.parentId,
196
- childIndex: m.childIndex,
197
- slot: m.slot,
198
- key: m.key,
199
- props: [...dataProps, ...eventProps],
200
- }
201
- })
202
-
203
- const renderLevel = (
204
- frontier: ReadonlyArray<Mounted>,
205
- bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
206
- ): Effect.Effect<ReadonlyArray<WireNode>, ParseResult.ParseError, CompositionService> =>
207
- Effect.gen(function* () {
208
- if (frontier.length === 0) return []
209
- const next: Mounted[] = []
210
- const nodes: WireNode[] = []
211
- for (const mounted of frontier) {
212
- const service = yield* mounted.comp.tag
213
- const slotIndex = new Map<string, number>()
214
- const enqueueBy = new Map<Function, (childProps: unknown, key: string | null) => void>()
215
- const slots: SlotHost = {
216
- slot: (name) => {
217
- const slotClass = mounted.comp.manifest.slots?.[name]
218
- const child = slotClass ? bindings.get(slotClass) : undefined
219
- const component: (childProps: unknown) => Node = () => null
220
- if (child !== undefined) {
221
- enqueueBy.set(component, (childProps, key) => {
222
- const index = slotIndex.get(name) ?? 0
223
- slotIndex.set(name, index + 1)
224
- next.push({
225
- comp: child,
226
- props: childProps,
227
- id: `${mounted.id}.${name}.${index}`,
228
- parentId: mounted.id,
229
- slot: name,
230
- childIndex: index,
231
- key,
232
- })
233
- })
234
- }
235
- return component
236
- },
237
- }
238
- const before = sink.captures.length
239
- const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} }, slots }
240
- const node = yield* Composition.render(service, env)
241
- const capture = sink.captures[before]
242
- if (capture !== undefined) nodes.push(yield* toWireNode(mounted, capture))
243
- drive(node, enqueueBy)
244
- }
245
- const rest = yield* renderLevel(next, bindings)
246
- return [...nodes, ...rest]
247
- })
248
-
249
- const renderEffect = Effect.gen(function* () {
250
- yield* settleDrain
251
- sink.reset()
252
- const bindings = yield* collect(scene.composition)
253
- const root: Mounted = {
254
- comp: scene.composition,
255
- props: {},
256
- id: '0',
257
- parentId: null,
258
- slot: null,
259
- childIndex: 0,
260
- key: null,
261
- }
262
- return yield* renderLevel([root], bindings)
263
- })
301
+ // The shared render walk, registering triggers in this connection's registry so the
302
+ // client can invoke them. `settleDrain` first so an in-flight batch folds into state.
303
+ const renderEffect = settleDrain.pipe(
304
+ Effect.zipRight(renderSceneToWire(scene, { register: registry.register })),
305
+ )
264
306
 
265
307
  // Server-initiated change notification: a forked daemon subscribes to the engine bus
266
308
  // and, after each batch settles, fans out to registered listeners. This is what lets a
@@ -291,7 +333,7 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
291
333
  runtime.runSync(Effect.forEach(scene.boot ?? [], (event) => publish('High', event)))
292
334
 
293
335
  // The last emitted frame, to diff against. A const holder whose field we swap
294
- // (no reassigned binding), matching the sink's immutable-swap idiom.
336
+ // (no reassigned binding — house rule), not a `let`.
295
337
  const frame: { tree: WireTree } = { tree: [] }
296
338
 
297
339
  const render = (): Promise<WireTree> =>