@playfast/reform-remote 1.2.0 → 1.3.0

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,434 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Two root causes, three proofs. BUG 1 and BUG 2 are the same defect seen from
4
+ // two ends; BUG 3 is independent.
5
+ //
6
+ // ---- BUGS 1 & 2 ----
7
+ //
8
+ // A wire node's id is minted from its POSITION —
9
+ // `${parent.id}.${slotName}.${index}` (wireRender.ts:104 / :116) — while its
10
+ // semantic identity travels beside it in `WireNode.key`, which `each({ key })`
11
+ // fills in and `Wire.diff` already compares (wire/tree.ts:79). Everything
12
+ // downstream is then keyed off the positional id rather than off that identity:
13
+ //
14
+ // * a trigger handle is `${mounted.id}:${eventName}` (wireNode.ts:102), so a
15
+ // handle survives the item it was minted for and re-points at whoever slid
16
+ // into that slot position;
17
+ // * the remote client reconciles React by `child.key ?? child.id`
18
+ // (client.ts:160), so a child with no key of its own — every `one(...)`
19
+ // fill — inherits its ancestors' positions and remounts when they move.
20
+ //
21
+ // Nothing here fixes anything. Every test asserts behaviour the package already
22
+ // promises elsewhere, and every one currently FAILS.
23
+ import { act, createElement, useState } from 'react'
24
+ import { createRoot } from 'react-dom/client'
25
+ import { expect, test } from 'vitest'
26
+ import { Data, Layer, Schema as S } from 'effect'
27
+ import {
28
+ Composition,
29
+ Engine,
30
+ Event,
31
+ Reducer,
32
+ State,
33
+ Ui,
34
+ each,
35
+ mount,
36
+ one,
37
+ provide,
38
+ scene,
39
+ slot,
40
+ ui,
41
+ } from '@playfast/reform'
42
+ import { Wire, type WireNode, type WireTree } from '@playfast/reform/internal'
43
+ import { makeRemoteServer } from './server'
44
+ import { remoteViews, renderWireTree } from './client'
45
+ import { listScene } from './fixtures'
46
+
47
+ // act() only flushes effects when the environment opts in; without this the two
48
+ // renders below can report a tree React has not finished committing.
49
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
50
+
51
+ const MissingBase: new (args: { readonly detail: string }) => Error & {
52
+ readonly _tag: 'hunt/Missing'
53
+ } & Readonly<{ readonly detail: string }> = Data.TaggedError('hunt/Missing')<{
54
+ readonly detail: string
55
+ }>
56
+
57
+ class Missing extends MissingBase {
58
+ override get message(): string {
59
+ return `wire-hunt: ${this.detail}`
60
+ }
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // BUG 1 — a trigger handle outlives the item it was minted for.
65
+ //
66
+ // `renderDiff` revokes the handles of nodes that VANISH (server.ts:144), which
67
+ // is what makes the playbook's "deleted nodes' handles are revoked, so a stale
68
+ // client invoke fails cleanly" true for the tail of a list. An interior removal
69
+ // deletes only the LAST position: every row after the removed one slides down
70
+ // one slot, so its node id — and therefore its handle — is silently reissued to
71
+ // a different item. `WireNode.key` records that the occupant changed, and
72
+ // `Wire.diff` even ships that change to the client, but the registry entry is
73
+ // just overwritten in place (wire/triggers.ts:52).
74
+ //
75
+ // The window is one round trip wide and opens on every background change: a
76
+ // procedure completing, another session on a `serveShared` runtime, a push. The
77
+ // invoke that loses the race does not fail — it deletes somebody else's row.
78
+ // ---------------------------------------------------------------------------
79
+
80
+ const rowsOf = (tree: WireTree): WireTree => tree.filter((node) => node.slot === 'Item')
81
+
82
+ const labelsOf = (tree: WireTree): ReadonlyArray<unknown> =>
83
+ rowsOf(tree).map((node) => {
84
+ const prop = node.props.find(
85
+ (candidate) => candidate._tag === 'Data' && candidate.name === 'label',
86
+ )
87
+ return prop !== undefined && 'value' in prop ? prop.value : undefined
88
+ })
89
+
90
+ const rowWithKey = (tree: WireTree, key: string): WireNode => {
91
+ const found = rowsOf(tree).find((node) => node.key === key)
92
+ if (found === undefined) {
93
+ throw new Missing({ detail: `no row keyed '${key}'` })
94
+ }
95
+ return found
96
+ }
97
+
98
+ const handleFor = (node: WireNode, event: string): string => {
99
+ const prop = node.props.find(
100
+ (candidate) => candidate._tag === 'Event' && candidate.name === event,
101
+ )
102
+ if (prop === undefined || !('handle' in prop)) {
103
+ throw new Missing({ detail: `node ${node.id} exposes no '${event}' handle` })
104
+ }
105
+ return prop.handle
106
+ }
107
+
108
+ const seed = [
109
+ { id: 'a', label: 'A' },
110
+ { id: 'b', label: 'B' },
111
+ { id: 'c', label: 'C' },
112
+ ]
113
+
114
+ test('a trigger handle keeps firing the item it was minted for', async () => {
115
+ // ---- CONTROL — the handle really is B's, and against a synced tree it removes B.
116
+ const control = makeRemoteServer(listScene(seed))
117
+ try {
118
+ const controlFrame = await control.render()
119
+ await control.invoke(handleFor(rowWithKey(controlFrame, 'b'), 'remove'), {})
120
+ expect(labelsOf(await control.render())).toEqual(['A', 'C'])
121
+ } finally {
122
+ await control.dispose()
123
+ }
124
+
125
+ // ---- DEFECT
126
+ const server = makeRemoteServer(listScene(seed))
127
+ try {
128
+ const frame0 = await server.render()
129
+ // The handle a connected client is holding for row 'b' once this frame paints.
130
+ const clickB = handleFor(rowWithKey(frame0, 'b'), 'remove')
131
+
132
+ // A change this client did not cause drops row 'a' while its click is in flight.
133
+ // Rows 'b' and 'c' are untouched — they only slide down one slot position.
134
+ await server.invoke(handleFor(rowWithKey(frame0, 'a'), 'remove'), {})
135
+ const frame1 = Wire.apply(frame0, await server.renderDiff())
136
+ expect(rowsOf(frame1).map((node) => node.key)).toEqual(['b', 'c'])
137
+
138
+ // Now the in-flight click on 'b' lands. It must remove 'b' — or fail cleanly, the
139
+ // way the playbook says a stale handle does. What it must never do is delete a row
140
+ // the click was not aimed at.
141
+ // Actual today: 'C' is gone and 'B', the row that was clicked, survives — the
142
+ // handle was silently reissued to whoever slid into slot position 1.
143
+ await server.invoke(clickB, {})
144
+ expect(labelsOf(await server.render())).toContain('C')
145
+ } finally {
146
+ await server.dispose()
147
+ }
148
+ })
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // BUG 2 — reordering a keyed list remounts every un-keyed descendant.
152
+ //
153
+ // The client keys React by `child.key ?? child.id` (client.ts:160) so that a
154
+ // keyed row survives a reorder. A `one(...)` fill carries no key
155
+ // (wireRender.ts:120 `key: Option.none()`), so its React key falls back to the
156
+ // node id — which embeds the id of every ancestor, and therefore their
157
+ // POSITIONS. Move a row and its child's key changes from `0.Row.0.Detail.0` to
158
+ // `0.Row.1.Detail.0`, so React tears the whole subtree down and rebuilds it:
159
+ // local state, DOM focus, scroll offsets, uncontrolled input values.
160
+ //
161
+ // `@playfast/reform-react` renders the identical scene without that loss — it
162
+ // keys a `One` fill `${slotName}.0` (packages/react/src/structure.ts:89), a key
163
+ // relative to the parent that a reorder cannot disturb.
164
+ // ---------------------------------------------------------------------------
165
+
166
+ const RowValue = S.Struct({ id: S.String, label: S.String })
167
+
168
+ class Rows extends State.make('hunt.rows', S.Array(RowValue)) {}
169
+ class Reversed extends Event.make('hunt.Reversed', S.Struct({})) {}
170
+ class ReverseRows extends Reducer.make('hunt.ReverseRows', {
171
+ states: [Rows],
172
+ events: [Reversed],
173
+ }) {}
174
+
175
+ class DetailUi extends ui('HuntDetail', { props: S.Struct({ label: S.String }) }) {}
176
+ class DetailComp extends Composition.make('HuntDetail', {
177
+ title: 'HuntDetail',
178
+ ui: DetailUi,
179
+ props: S.Struct({ label: S.String }),
180
+ })<DetailComp>() {}
181
+ class DetailSlot extends slot('Detail')<DetailSlot, typeof DetailComp>() {}
182
+
183
+ class RowUi extends ui('HuntRow', {
184
+ props: S.Struct({ label: S.String }),
185
+ slots: { Detail: DetailSlot },
186
+ }) {}
187
+ class RowComp extends Composition.make('HuntRow', {
188
+ title: 'HuntRow',
189
+ ui: RowUi,
190
+ props: RowValue,
191
+ slots: { Detail: DetailSlot },
192
+ })<RowComp>() {}
193
+ class RowSlot extends slot('Row')<RowSlot, typeof RowComp>() {}
194
+
195
+ class ListUi extends ui('HuntList', {
196
+ props: S.Struct({}),
197
+ events: { reverse: S.Struct({}) },
198
+ slots: { Row: RowSlot },
199
+ }) {}
200
+ class ListComp extends Composition.make('HuntList', {
201
+ title: 'HuntList',
202
+ ui: ListUi,
203
+ slots: { Row: RowSlot },
204
+ states: [Rows],
205
+ })<ListComp>() {}
206
+
207
+ const nestedScene = (initial: ReadonlyArray<{ readonly id: string; readonly label: string }>) => {
208
+ const presentation = Layer.mergeAll(
209
+ provide(
210
+ ListUi,
211
+ Ui.make(ListUi, () => null),
212
+ ),
213
+ provide(
214
+ RowUi,
215
+ Ui.make(RowUi, () => null),
216
+ ),
217
+ provide(
218
+ DetailUi,
219
+ Ui.make(DetailUi, () => null),
220
+ ),
221
+ provide(RowSlot, RowComp),
222
+ provide(DetailSlot, DetailComp),
223
+ State.live(Rows, initial),
224
+ )
225
+ const app = Layer.mergeAll(
226
+ Composition.live(ListComp, function* () {
227
+ const rows = yield* Rows
228
+ const reverse = yield* Event.trigger(Reversed)
229
+ return mount({
230
+ props: {},
231
+ events: { reverse },
232
+ slots: {
233
+ Row: each(rows, {
234
+ key: (entry) => entry.id,
235
+ props: (entry) => ({ id: entry.id, label: entry.label }),
236
+ }),
237
+ },
238
+ })
239
+ }),
240
+ Composition.live(RowComp, function* () {
241
+ const rowProps = yield* RowComp.props
242
+ return mount({
243
+ props: { label: rowProps.label },
244
+ slots: { Detail: one({ label: rowProps.label }) },
245
+ })
246
+ }),
247
+ Composition.live(DetailComp, function* () {
248
+ const detailProps = yield* DetailComp.props
249
+ return mount({ props: { label: detailProps.label }, slots: {} })
250
+ }),
251
+ Reducer.live(ReverseRows, (current) => [...current].reverse()),
252
+ ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
253
+ return scene(ListComp, { provide: [app] })
254
+ }
255
+
256
+ test('reordering a keyed list keeps every descendant mounted', async () => {
257
+ const server = makeRemoteServer(nestedScene(seed))
258
+ const rowMounts = { n: 0 }
259
+ const detailMounts = { n: 0 }
260
+ const views = remoteViews<{
261
+ HuntList: typeof ListUi
262
+ HuntRow: typeof RowUi
263
+ HuntDetail: typeof DetailUi
264
+ }>({
265
+ HuntList: Ui.make(ListUi, (_props, slots) =>
266
+ createElement('div', null, createElement(slots.Row)),
267
+ ),
268
+ // Per-row client state: which instance is rendering this row?
269
+ HuntRow: Ui.make(RowUi, ({ label }, slots) => {
270
+ const [instance] = useState(() => {
271
+ rowMounts.n += 1
272
+ return `row-${rowMounts.n}`
273
+ })
274
+ return createElement(
275
+ 'div',
276
+ { 'data-row': label, 'data-row-instance': instance },
277
+ createElement(slots.Detail),
278
+ )
279
+ }),
280
+ // …and the same question one level below, in the row's un-keyed `one(...)` child.
281
+ HuntDetail: Ui.make(DetailUi, ({ label }) => {
282
+ const [instance] = useState(() => {
283
+ detailMounts.n += 1
284
+ return `detail-${detailMounts.n}`
285
+ })
286
+ return createElement('span', { 'data-detail': label, 'data-detail-instance': instance })
287
+ }),
288
+ })
289
+
290
+ const container = document.createElement('div')
291
+ document.body.appendChild(container)
292
+ const root = createRoot(container)
293
+ const ownersOf = (attribute: string, instance: string): Record<string, string> =>
294
+ Object.fromEntries(
295
+ [...container.querySelectorAll(`[${attribute}]`)].map((element) => [
296
+ element.getAttribute(attribute) ?? '',
297
+ element.getAttribute(instance) ?? '',
298
+ ]),
299
+ )
300
+ const rowOwners = (): Record<string, string> => ownersOf('data-row', 'data-row-instance')
301
+ const detailOwners = (): Record<string, string> => ownersOf('data-detail', 'data-detail-instance')
302
+
303
+ try {
304
+ const before = await server.render()
305
+ await act(async () => {
306
+ root.render(renderWireTree(before, { views, invoke: () => undefined }))
307
+ })
308
+ const rowsBefore = rowOwners()
309
+ const detailsBefore = detailOwners()
310
+ expect(rowsBefore).toEqual({ A: 'row-1', B: 'row-2', C: 'row-3' })
311
+ expect(detailsBefore).toEqual({ A: 'detail-1', B: 'detail-2', C: 'detail-3' })
312
+
313
+ // Same three rows, reversed. Every row keeps its key; only positions move.
314
+ await server.invoke('0:reverse', {})
315
+ const after = await server.render()
316
+ // The server kept every row's identity; only the positional ids moved.
317
+ expect(after.filter((node) => node.slot === 'Row').map((node) => node.key)).toEqual([
318
+ 'c',
319
+ 'b',
320
+ 'a',
321
+ ])
322
+ expect(after.filter((node) => node.slot === 'Detail').map((node) => node.key)).toEqual([
323
+ null,
324
+ null,
325
+ null,
326
+ ])
327
+ await act(async () => {
328
+ root.render(renderWireTree(after, { views, invoke: () => undefined }))
329
+ })
330
+
331
+ // The reorder landed…
332
+ expect(Object.keys(rowOwners())).toEqual(['C', 'B', 'A'])
333
+ // ---- CONTROL — the keyed layer behaves: every row is still its own instance,
334
+ // so React really did reconcile by key and this harness can see it.
335
+ expect(rowMounts.n).toBe(3)
336
+ expect(rowOwners()).toEqual(rowsBefore)
337
+ // ---- DEFECT — one level down, nothing moved and nothing changed, yet the two
338
+ // rows that changed position had their children destroyed and rebuilt.
339
+ // Actual today: A→detail-5, C→detail-4, and detailMounts.n === 5.
340
+ expect(detailOwners()).toEqual(detailsBefore)
341
+ expect(detailMounts.n).toBe(3)
342
+ } finally {
343
+ await act(async () => {
344
+ root.unmount()
345
+ })
346
+ await server.dispose()
347
+ }
348
+ })
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // BUG 3 — a view's contract binding lives ON the function, so two contracts
352
+ // sharing one implementation collapse onto whichever was made last.
353
+ //
354
+ // `Ui.make` attaches its reflection statics with `Object.assign(view, …)`
355
+ // (compose/ui.ts:230) — it MUTATES the function it was handed and returns that
356
+ // same object. Make two contracts from one function value and the second
357
+ // `Ui.make` overwrites `[UiViewContract]` on the object the first one also
358
+ // returned, so both `MadeView`s now report the second contract.
359
+ //
360
+ // `remoteViews` builds its registry by asking each view which contract it
361
+ // implements and keying by that contract's name (client.ts:64, :88). Two views
362
+ // that both answer "Footer" produce ONE entry, and `renderWireTree` resolves a
363
+ // node by `config.views[node.name]` and returns `null` when it misses
364
+ // (client.ts:120-122) — so the view that lost its identity renders nothing at all,
365
+ // with no error anywhere.
366
+ //
367
+ // Sharing a trivial view function is not exotic: the repo's own house rule asks
368
+ // for hook-free view shells, and hoisting one `const shell = () => …` out of two
369
+ // `Ui.make` calls is the obvious next edit.
370
+ // ---------------------------------------------------------------------------
371
+
372
+ class HeaderUi extends ui('HuntHeader', { props: S.Struct({ label: S.String }) }) {}
373
+ class FooterUi extends ui('HuntFooter', { props: S.Struct({ label: S.String }) }) {}
374
+
375
+ type ChromeContract = { HuntHeader: typeof HeaderUi; HuntFooter: typeof FooterUi }
376
+
377
+ const chromeNode = (name: string, label: string): WireNode => ({
378
+ id: name,
379
+ name,
380
+ parentId: null,
381
+ childIndex: name === 'HuntHeader' ? 0 : 1,
382
+ slot: null,
383
+ key: null,
384
+ props: [{ _tag: 'Data', name: 'label', value: label }],
385
+ })
386
+
387
+ const chromeTree: WireTree = [chromeNode('HuntHeader', 'top'), chromeNode('HuntFooter', 'bottom')]
388
+
389
+ const paint = async (views: ReturnType<typeof remoteViews<ChromeContract>>): Promise<string> => {
390
+ const container = document.createElement('div')
391
+ document.body.appendChild(container)
392
+ const root = createRoot(container)
393
+ await act(async () => {
394
+ root.render(renderWireTree(chromeTree, { views, invoke: () => undefined }))
395
+ })
396
+ const painted = [...container.querySelectorAll('[data-shell]')]
397
+ .map((element) => element.getAttribute('data-shell') ?? '')
398
+ .join(',')
399
+ await act(async () => {
400
+ root.unmount()
401
+ })
402
+ return painted
403
+ }
404
+
405
+ test('two contracts can share one view function without erasing each other', async () => {
406
+ const renderShell = ({ label }: { readonly label: string }): ReturnType<typeof createElement> =>
407
+ createElement('i', { 'data-shell': label })
408
+
409
+ // ---- CONTROL — two separate function values with identical bodies. Both
410
+ // contracts keep their own identity and both nodes paint.
411
+ expect(
412
+ await paint(
413
+ remoteViews<ChromeContract>({
414
+ HuntHeader: Ui.make(HeaderUi, (props) => renderShell(props)),
415
+ HuntFooter: Ui.make(FooterUi, (props) => renderShell(props)),
416
+ }),
417
+ ),
418
+ ).toBe('top,bottom')
419
+
420
+ // ---- DEFECT — the same body, hoisted to one shared value.
421
+ // Actual today: 'bottom' — `Ui.make(FooterUi, …)` rebranded the object
422
+ // `Ui.make(HeaderUi, …)` had already returned, the registry holds a single
423
+ // 'HuntFooter' entry, and the header node silently resolves to no view.
424
+ const shell = (props: { readonly label: string }): ReturnType<typeof createElement> =>
425
+ renderShell(props)
426
+ expect(
427
+ await paint(
428
+ remoteViews<ChromeContract>({
429
+ HuntHeader: Ui.make(HeaderUi, shell),
430
+ HuntFooter: Ui.make(FooterUi, shell),
431
+ }),
432
+ ),
433
+ ).toBe('top,bottom')
434
+ })
package/src/wireRender.ts CHANGED
@@ -96,19 +96,36 @@ export const enqueueStructureSlot = (
96
96
  fill: SlotFill<unknown>,
97
97
  ): ReadonlyArray<Mounted> =>
98
98
  Match.value(fill).pipe(
99
- Match.tag('Each', (each) =>
100
- each.items.map(
101
- (entry, index): Mounted => ({
99
+ Match.tag('Each', (each) => {
100
+ // Keyed, not positional: a trigger handle embeds the node id, so an id built from
101
+ // the index silently re-points a handle the client already holds at whatever row
102
+ // slid into that slot. An accidental duplicate key falls back to the index rather
103
+ // than minting two nodes with one id.
104
+ const used = new Set<string>()
105
+ // The disambiguator is retried rather than assumed free: a key that literally ends
106
+ // in `#<n>` can already hold the string a later repeat would mint, and two nodes
107
+ // sharing an id costs a whole row on the wire.
108
+ const unusedId = (base: string): string => {
109
+ const attempt = (round: number): string => {
110
+ const candidate = round === 0 ? base : `${base}#${round}`
111
+ return used.has(candidate) ? attempt(round + 1) : candidate
112
+ }
113
+ return attempt(0)
114
+ }
115
+ return each.items.map((entry, index): Mounted => {
116
+ const id = unusedId(`${parent.id}.${slotName}.${entry.key ?? index}`)
117
+ used.add(id)
118
+ return {
102
119
  comp: child,
103
120
  props: entry.props,
104
- id: `${parent.id}.${slotName}.${index}`,
121
+ id,
105
122
  parentId: Option.some(parent.id),
106
123
  slot: Option.some(slotName),
107
124
  childIndex: index,
108
125
  key: Option.fromNullable(entry.key),
109
- }),
110
- ),
111
- ),
126
+ }
127
+ })
128
+ }),
112
129
  Match.tag('One', (single) => [
113
130
  {
114
131
  comp: child,
@@ -131,8 +148,9 @@ export type SlotProvider<Services> = (
131
148
  export const collect: <Services>(
132
149
  root: AnyComposition,
133
150
  provideSlot: SlotProvider<Services>,
134
- ) => Effect.Effect<Map<AnySlot, AnyComposition>, never, Services> = Effect.fn('collect')(
135
- function* <Services>(
151
+ ) => Effect.Effect<Map<AnySlot, AnyComposition>, never, Services> = Effect.fn('collect')(function* <
152
+ Services,
153
+ >(
136
154
  root: AnyComposition,
137
155
  provideSlot: SlotProvider<Services>,
138
156
  ): Effect.fn.Return<Map<AnySlot, AnyComposition>, never, Services> {
@@ -209,27 +227,23 @@ export const renderSceneWith: <SlotServices, CompositionServices>(
209
227
  options: RenderSceneToWireOptions | undefined,
210
228
  provideSlot: SlotProvider<SlotServices>,
211
229
  render: CompositionRenderer<CompositionServices>,
212
- ) => Effect.Effect<
213
- WireTree,
214
- ParseResult.ParseError,
215
- SlotServices | CompositionServices
216
- > = Effect.fn('renderSceneWith')(
217
- function* <SlotServices, CompositionServices>(
218
- scene: AnyScene,
219
- options: RenderSceneToWireOptions | undefined,
220
- provideSlot: SlotProvider<SlotServices>,
221
- render: CompositionRenderer<CompositionServices>,
222
- ): Effect.fn.Return<WireTree, ParseResult.ParseError, SlotServices | CompositionServices> {
223
- const register = options?.register ?? noRegister
224
- const bindings = yield* collect(scene.composition, provideSlot)
225
- const root: Mounted = {
226
- comp: scene.composition,
227
- props: {},
228
- id: '0',
229
- parentId: Option.none(),
230
- slot: Option.none(),
231
- childIndex: 0,
232
- key: Option.none(),
233
- }
234
- return yield* renderLevel([root], bindings, register, render)
235
- })
230
+ ) => Effect.Effect<WireTree, ParseResult.ParseError, SlotServices | CompositionServices> =
231
+ Effect.fn('renderSceneWith')(function* <SlotServices, CompositionServices>(
232
+ scene: AnyScene,
233
+ options: RenderSceneToWireOptions | undefined,
234
+ provideSlot: SlotProvider<SlotServices>,
235
+ render: CompositionRenderer<CompositionServices>,
236
+ ): Effect.fn.Return<WireTree, ParseResult.ParseError, SlotServices | CompositionServices> {
237
+ const register = options?.register ?? noRegister
238
+ const bindings = yield* collect(scene.composition, provideSlot)
239
+ const root: Mounted = {
240
+ comp: scene.composition,
241
+ props: {},
242
+ id: '0',
243
+ parentId: Option.none(),
244
+ slot: Option.none(),
245
+ childIndex: 0,
246
+ key: Option.none(),
247
+ }
248
+ return yield* renderLevel([root], bindings, register, render)
249
+ })