@playfast/reform-remote 0.0.3 → 0.0.4
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/fixtures.ts +46 -16
- package/src/server.test.ts +22 -0
- package/src/server.ts +114 -104
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform-remote",
|
|
3
3
|
"playbook": "./playbook",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.4",
|
|
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/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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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, (
|
|
196
|
+
Ui.make(ListUi, (_props, slots) =>
|
|
175
197
|
createElement(
|
|
176
198
|
Fragment,
|
|
177
199
|
null,
|
|
178
200
|
createElement(slots.Bar, {}),
|
|
179
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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/server.test.ts
CHANGED
|
@@ -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
|
-
|
|
9
|
+
isStructure,
|
|
13
10
|
publish,
|
|
14
11
|
type RenderEnv,
|
|
15
12
|
type Scene,
|
|
16
13
|
type SlotChild,
|
|
17
14
|
type SlotClass,
|
|
18
|
-
type
|
|
15
|
+
type SlotFill,
|
|
16
|
+
type Structure,
|
|
19
17
|
Triggers,
|
|
18
|
+
type Trigger,
|
|
20
19
|
type TriggerRegistryApi,
|
|
21
|
-
type
|
|
20
|
+
type UiContract,
|
|
22
21
|
type UiManifest,
|
|
23
22
|
Wire,
|
|
24
23
|
type WireNode,
|
|
@@ -30,11 +29,11 @@ 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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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).
|
|
38
37
|
*/
|
|
39
38
|
|
|
40
39
|
// The runtime services a closed scene exposes (mirrors proof's erasure boundary:
|
|
@@ -45,24 +44,13 @@ type RuntimeServices = CompositionService | SlotChild | Bus
|
|
|
45
44
|
const SETTLE_DRAIN = 30
|
|
46
45
|
const settleDrain = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
|
|
47
46
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
}
|
|
47
|
+
// The event triggers a `Structure` frame carries (plan 03b). A composition's logic
|
|
48
|
+
// returns its acquired triggers ON the structure value (it never evaluates a view),
|
|
49
|
+
// so the server reads them straight from there. At the erased `UiContract` boundary the contract's event map
|
|
50
|
+
// is `Record<never, never>`, so each value is `never` — assignable to
|
|
51
|
+
// `Trigger<unknown>` without a cast — and an omitted `events` defaults to empty.
|
|
52
|
+
const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
|
|
53
|
+
Object.fromEntries(Object.entries(structure.events ?? {}))
|
|
66
54
|
|
|
67
55
|
const closedSceneLayer = (scene: Scene): Layer.Layer<RuntimeServices, never, never> =>
|
|
68
56
|
scene.provide.reduce((a, b) => Layer.merge(a, b)) as unknown as Layer.Layer<
|
|
@@ -86,33 +74,6 @@ interface Mounted {
|
|
|
86
74
|
const childComposition = (child: SlotChild): CompositionClass<unknown> =>
|
|
87
75
|
isFeatureBinding(child) ? child.composition : child
|
|
88
76
|
|
|
89
|
-
/**
|
|
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.
|
|
93
|
-
*/
|
|
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
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
drive(node.props.children, enqueueBy)
|
|
114
|
-
}
|
|
115
|
-
|
|
116
77
|
const handlesOf = (node: WireNode): ReadonlyArray<string> =>
|
|
117
78
|
node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []))
|
|
118
79
|
|
|
@@ -141,10 +102,7 @@ export interface RemoteServer {
|
|
|
141
102
|
}
|
|
142
103
|
|
|
143
104
|
export const makeRemoteServer = (scene: Scene): RemoteServer => {
|
|
144
|
-
const
|
|
145
|
-
const runtime = ManagedRuntime.make(
|
|
146
|
-
Layer.provideMerge(closedSceneLayer(scene), Layer.succeed(CaptureSink, sink.api)),
|
|
147
|
-
)
|
|
105
|
+
const runtime = ManagedRuntime.make(closedSceneLayer(scene))
|
|
148
106
|
const registry: TriggerRegistryApi = forceSync(() => runtime.runSync(Triggers.make))
|
|
149
107
|
|
|
150
108
|
const collect = (
|
|
@@ -165,33 +123,40 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
|
|
|
165
123
|
return bindings
|
|
166
124
|
})
|
|
167
125
|
|
|
168
|
-
|
|
126
|
+
// Structure → wire encoding (plan 02 + 03b). Props come straight from the
|
|
127
|
+
// `Structure` value (data, never a view render). Event triggers ride ON the
|
|
128
|
+
// structure (`structure.events`) and are registered from there. Produces the wire
|
|
129
|
+
// shape the transport and `Wire.diff` consume.
|
|
130
|
+
const toWireNodeFromStructure = (
|
|
131
|
+
m: Mounted,
|
|
132
|
+
structure: Structure<UiContract>,
|
|
133
|
+
): Effect.Effect<WireNode, ParseResult.ParseError> =>
|
|
169
134
|
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
135
|
const manifest = m.comp.manifest.ui.manifest as UiManifest
|
|
136
|
+
const name = manifest.name
|
|
173
137
|
|
|
174
138
|
const dataProps: WireProp[] = []
|
|
175
139
|
if (manifest.props !== undefined) {
|
|
176
|
-
const encoded = yield* Schema.encodeUnknown(manifest.props)(
|
|
177
|
-
for (const [
|
|
178
|
-
dataProps.push({ _tag: 'Data', name, value })
|
|
140
|
+
const encoded = yield* Schema.encodeUnknown(manifest.props)(structure.props)
|
|
141
|
+
for (const [propName, value] of Object.entries(encoded as Record<string, unknown>)) {
|
|
142
|
+
dataProps.push({ _tag: 'Data', name: propName, value })
|
|
179
143
|
}
|
|
180
144
|
}
|
|
181
145
|
|
|
182
146
|
const eventSchemas = manifest.events ?? {}
|
|
183
147
|
const eventProps: WireProp[] = []
|
|
184
|
-
|
|
185
|
-
|
|
148
|
+
const events = eventsOf(structure)
|
|
149
|
+
for (const [eventName, trigger] of Object.entries(events)) {
|
|
150
|
+
const schema = eventSchemas[eventName]
|
|
186
151
|
if (schema === undefined) continue
|
|
187
|
-
const handle = `${m.id}:${
|
|
152
|
+
const handle = `${m.id}:${eventName}`
|
|
188
153
|
yield* registry.register(handle, trigger, schema)
|
|
189
|
-
eventProps.push({ _tag: 'Event', name, handle })
|
|
154
|
+
eventProps.push({ _tag: 'Event', name: eventName, handle })
|
|
190
155
|
}
|
|
191
156
|
|
|
192
157
|
return {
|
|
193
158
|
id: m.id,
|
|
194
|
-
name
|
|
159
|
+
name,
|
|
195
160
|
parentId: m.parentId,
|
|
196
161
|
childIndex: m.childIndex,
|
|
197
162
|
slot: m.slot,
|
|
@@ -200,6 +165,64 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
|
|
|
200
165
|
}
|
|
201
166
|
})
|
|
202
167
|
|
|
168
|
+
// Narrow an erased slot value to a `SlotFill` by its discriminant. `Structure<
|
|
169
|
+
// UiContract>` erases its per-slot fill types at this boundary (the strict typing
|
|
170
|
+
// lives on the concrete contract), so the runtime fills arrive as `unknown` and
|
|
171
|
+
// are recovered structurally — no `as`.
|
|
172
|
+
const isSlotFill = (u: unknown): u is SlotFill<unknown> =>
|
|
173
|
+
typeof u === 'object' &&
|
|
174
|
+
u !== null &&
|
|
175
|
+
'_tag' in u &&
|
|
176
|
+
(u._tag === 'Each' || u._tag === 'One' || u._tag === 'Absent')
|
|
177
|
+
|
|
178
|
+
// Read a structure's fills by slot name, recovering each erased value via the
|
|
179
|
+
// discriminant guard. Returns a plain record keyed by declared slot name.
|
|
180
|
+
const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
|
|
181
|
+
Object.fromEntries(
|
|
182
|
+
Object.entries(structure.slots).flatMap(([name, value]) =>
|
|
183
|
+
isSlotFill(value) ? [[name, value] as const] : [],
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
// Enqueue the children a single declared slot is filled with, reading the fill
|
|
188
|
+
// (Each / One / Absent) from the returned `Structure`. Multiplicity, per-item
|
|
189
|
+
// `key`, and props are carried as data — no view walk, no reconstruction.
|
|
190
|
+
const enqueueStructureSlot = (
|
|
191
|
+
parent: Mounted,
|
|
192
|
+
slotName: string,
|
|
193
|
+
child: CompositionClass<unknown>,
|
|
194
|
+
fill: SlotFill<unknown>,
|
|
195
|
+
next: Mounted[],
|
|
196
|
+
): void =>
|
|
197
|
+
Match.value(fill).pipe(
|
|
198
|
+
Match.tag('Each', (each) => {
|
|
199
|
+
each.items.forEach((item, index) => {
|
|
200
|
+
next.push({
|
|
201
|
+
comp: child,
|
|
202
|
+
props: item.props,
|
|
203
|
+
id: `${parent.id}.${slotName}.${index}`,
|
|
204
|
+
parentId: parent.id,
|
|
205
|
+
slot: slotName,
|
|
206
|
+
childIndex: index,
|
|
207
|
+
key: item.key,
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
}),
|
|
211
|
+
Match.tag('One', (single) => {
|
|
212
|
+
next.push({
|
|
213
|
+
comp: child,
|
|
214
|
+
props: single.props,
|
|
215
|
+
id: `${parent.id}.${slotName}.0`,
|
|
216
|
+
parentId: parent.id,
|
|
217
|
+
slot: slotName,
|
|
218
|
+
childIndex: 0,
|
|
219
|
+
key: null,
|
|
220
|
+
})
|
|
221
|
+
}),
|
|
222
|
+
Match.tag('Absent', () => {}),
|
|
223
|
+
Match.exhaustive,
|
|
224
|
+
)
|
|
225
|
+
|
|
203
226
|
const renderLevel = (
|
|
204
227
|
frontier: ReadonlyArray<Mounted>,
|
|
205
228
|
bindings: ReadonlyMap<SlotClass, CompositionClass<unknown>>,
|
|
@@ -210,37 +233,25 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
|
|
|
210
233
|
const nodes: WireNode[] = []
|
|
211
234
|
for (const mounted of frontier) {
|
|
212
235
|
const service = yield* mounted.comp.tag
|
|
213
|
-
const
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
key,
|
|
232
|
-
})
|
|
233
|
-
})
|
|
234
|
-
}
|
|
235
|
-
return component
|
|
236
|
-
},
|
|
236
|
+
const env: RenderEnv = { props: mounted.props, tracker: { add: () => {} } }
|
|
237
|
+
const frame = yield* Composition.render(service, env)
|
|
238
|
+
// Every composition returns a `Structure` (the Node render path is gone): its
|
|
239
|
+
// wire node's props come from `frame.props`, event triggers from `frame.events`,
|
|
240
|
+
// and children are enqueued by reading each declared slot's fill — no view eval.
|
|
241
|
+
if (!isStructure(frame)) {
|
|
242
|
+
return yield* Effect.dieMessage(
|
|
243
|
+
`reform-remote server: composition ${mounted.comp.manifest.name} did not return a Structure`,
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
nodes.push(yield* toWireNodeFromStructure(mounted, frame))
|
|
247
|
+
const fills = structureFills(frame)
|
|
248
|
+
for (const [slotName, slotClass] of Object.entries(mounted.comp.manifest.slots ?? {})) {
|
|
249
|
+
const child = bindings.get(slotClass)
|
|
250
|
+
if (child === undefined) continue
|
|
251
|
+
const fill = fills[slotName]
|
|
252
|
+
if (fill === undefined) continue
|
|
253
|
+
enqueueStructureSlot(mounted, slotName, child, fill, next)
|
|
237
254
|
}
|
|
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
255
|
}
|
|
245
256
|
const rest = yield* renderLevel(next, bindings)
|
|
246
257
|
return [...nodes, ...rest]
|
|
@@ -248,7 +259,6 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
|
|
|
248
259
|
|
|
249
260
|
const renderEffect = Effect.gen(function* () {
|
|
250
261
|
yield* settleDrain
|
|
251
|
-
sink.reset()
|
|
252
262
|
const bindings = yield* collect(scene.composition)
|
|
253
263
|
const root: Mounted = {
|
|
254
264
|
comp: scene.composition,
|
|
@@ -291,7 +301,7 @@ export const makeRemoteServer = (scene: Scene): RemoteServer => {
|
|
|
291
301
|
runtime.runSync(Effect.forEach(scene.boot ?? [], (event) => publish('High', event)))
|
|
292
302
|
|
|
293
303
|
// The last emitted frame, to diff against. A const holder whose field we swap
|
|
294
|
-
// (no reassigned binding),
|
|
304
|
+
// (no reassigned binding — house rule), not a `let`.
|
|
295
305
|
const frame: { tree: WireTree } = { tree: [] }
|
|
296
306
|
|
|
297
307
|
const render = (): Promise<WireTree> =>
|