@playfast/reform-remote 1.1.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.
Files changed (54) hide show
  1. package/dist/client.d.ts +33 -0
  2. package/dist/client.d.ts.map +1 -0
  3. package/dist/client.js +97 -0
  4. package/dist/client.js.map +1 -0
  5. package/dist/clientBinding.d.ts +18 -0
  6. package/dist/clientBinding.d.ts.map +1 -0
  7. package/dist/clientBinding.js +70 -0
  8. package/dist/clientBinding.js.map +1 -0
  9. package/dist/index.d.ts +11 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +11 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/memory.d.ts +7 -0
  14. package/dist/memory.d.ts.map +1 -0
  15. package/dist/memory.js +21 -0
  16. package/dist/memory.js.map +1 -0
  17. package/dist/react.d.ts +15 -0
  18. package/dist/react.d.ts.map +1 -0
  19. package/dist/react.js +16 -0
  20. package/dist/react.js.map +1 -0
  21. package/dist/server.d.ts +16 -0
  22. package/dist/server.d.ts.map +1 -0
  23. package/dist/server.js +56 -0
  24. package/dist/server.js.map +1 -0
  25. package/dist/transport.d.ts +41 -0
  26. package/dist/transport.d.ts.map +1 -0
  27. package/dist/transport.js +157 -0
  28. package/dist/transport.js.map +1 -0
  29. package/dist/wireNode.d.ts +24 -0
  30. package/dist/wireNode.d.ts.map +1 -0
  31. package/dist/wireNode.js +80 -0
  32. package/dist/wireNode.js.map +1 -0
  33. package/dist/wireRender.d.ts +20 -0
  34. package/dist/wireRender.d.ts.map +1 -0
  35. package/dist/wireRender.js +118 -0
  36. package/dist/wireRender.js.map +1 -0
  37. package/package.json +17 -5
  38. package/src/client-prop-decode.test.ts +98 -0
  39. package/src/client.ts +25 -9
  40. package/src/clientBinding.ts +103 -0
  41. package/src/counterFixtures.ts +142 -0
  42. package/src/fixtures.ts +10 -453
  43. package/src/listFixtures.ts +174 -0
  44. package/src/optionalFixtures.ts +64 -0
  45. package/src/react.ts +14 -3
  46. package/src/server.test.ts +6 -6
  47. package/src/server.ts +11 -349
  48. package/src/slottedFixtures.ts +120 -0
  49. package/src/transport.ts +15 -47
  50. package/src/wire-id-uniqueness.test.ts +287 -0
  51. package/src/wire-identity.test.ts +369 -0
  52. package/src/wire-keyed-identity.test.ts +434 -0
  53. package/src/wireNode.ts +153 -0
  54. package/src/wireRender.ts +249 -0
@@ -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
+ })
@@ -0,0 +1,153 @@
1
+ import { Array as Arr, Effect, Option, type ParseResult, Record as Rec, Schema } from 'effect'
2
+ import {
3
+ isFeatureBinding,
4
+ isSlot,
5
+ type SlotFill,
6
+ type Structure,
7
+ type Trigger,
8
+ type UiContract,
9
+ } from '@playfast/reform'
10
+ import {
11
+ type AnyComposition,
12
+ type AnySlot,
13
+ type AnySlotChild,
14
+ type TriggerRegistryApi,
15
+ type UiManifest,
16
+ type WireNode,
17
+ type WireProp,
18
+ } from '@playfast/reform/internal'
19
+
20
+ export const SETTLE_DRAIN = 30
21
+ export const settleDrain: Effect.Effect<void> = Effect.yieldNow().pipe(Effect.repeatN(SETTLE_DRAIN))
22
+
23
+ // The erased event map's never values remain assignable to Trigger<unknown> without a cast.
24
+ export const eventsOf = (structure: Structure<UiContract>): Record<string, Trigger<unknown>> =>
25
+ Option.match(Option.fromNullable(structure.events), {
26
+ onNone: () => ({}),
27
+ onSome: (events) => events,
28
+ })
29
+
30
+ export interface Mounted {
31
+ readonly comp: AnyComposition
32
+ readonly props: unknown
33
+ readonly id: string
34
+ readonly parentId: Option.Option<string>
35
+ readonly slot: Option.Option<string>
36
+ readonly childIndex: number
37
+ readonly key: Option.Option<string>
38
+ }
39
+
40
+ export const childComposition = (child: AnySlotChild): AnyComposition => {
41
+ if (!isFeatureBinding(child)) {
42
+ return child
43
+ }
44
+ return child.capture<AnyComposition>((binding): AnyComposition => binding.composition)
45
+ }
46
+
47
+ export const isRecord = (candidate: unknown): candidate is Record<string, unknown> =>
48
+ typeof candidate === 'object' && candidate !== null && !Array.isArray(candidate)
49
+
50
+ export const handlesOf = (node: WireNode): ReadonlyArray<string> =>
51
+ node.props.flatMap((prop) => (prop._tag === 'Event' ? [prop.handle] : []))
52
+
53
+ // One-shot renders still emit deterministic handles but have no client registry.
54
+ export const noRegister: TriggerRegistryApi['register'] = () => Effect.void
55
+
56
+ export const toWireNodeFromStructure: (
57
+ mounted: Mounted,
58
+ structure: Structure<UiContract>,
59
+ register: TriggerRegistryApi['register'],
60
+ ) => Effect.Effect<WireNode, ParseResult.ParseError> = Effect.fn('toWireNodeFromStructure')(
61
+ function* (
62
+ mounted: Mounted,
63
+ structure: Structure<UiContract>,
64
+ register: TriggerRegistryApi['register'],
65
+ ): Effect.fn.Return<WireNode, ParseResult.ParseError> {
66
+ const manifest: UiManifest = mounted.comp.manifest.ui.manifest
67
+ const name = manifest.name
68
+
69
+ // The manifest stores schema AST only; reconstruct an unknown-safe schema at this wire seam.
70
+ const encodedProps = yield* Option.match(Option.fromNullable(manifest.props), {
71
+ onNone: () => Effect.succeed({}),
72
+ onSome: (reflection) =>
73
+ Schema.encodeUnknown(Schema.make<unknown, unknown>(reflection.ast))(structure.props).pipe(
74
+ Effect.flatMap((encoded) => {
75
+ if (isRecord(encoded)) {
76
+ return Effect.succeed(encoded)
77
+ }
78
+ return Effect.dieMessage(
79
+ `reform-remote: ${manifest.name} props did not encode to a record`,
80
+ )
81
+ }),
82
+ ),
83
+ })
84
+ const dataProps: ReadonlyArray<WireProp> = Rec.toEntries(encodedProps).map(
85
+ ([propName, propValue]): WireProp => ({
86
+ _tag: 'Data',
87
+ name: propName,
88
+ value: propValue,
89
+ }),
90
+ )
91
+
92
+ const eventSchemas = Option.fromNullable(manifest.events)
93
+ const events = eventsOf(structure)
94
+ const eventProps: ReadonlyArray<WireProp> = yield* Effect.forEach(
95
+ Rec.toEntries(events),
96
+ ([eventName, trigger]) =>
97
+ Effect.gen(function* () {
98
+ const schema = Option.flatMap(eventSchemas, (schemas) => Rec.get(schemas, eventName))
99
+ if (Option.isNone(schema)) {
100
+ return Option.none<WireProp>()
101
+ }
102
+ const handle = `${mounted.id}:${eventName}`
103
+ yield* register(handle, trigger, Schema.make<unknown, unknown>(schema.value.ast))
104
+ return Option.some<WireProp>({
105
+ _tag: 'Event',
106
+ name: eventName,
107
+ handle,
108
+ })
109
+ }),
110
+ ).pipe(Effect.map(Arr.getSomes))
111
+
112
+ return {
113
+ id: mounted.id,
114
+ name,
115
+ parentId: Option.getOrNull(mounted.parentId),
116
+ childIndex: mounted.childIndex,
117
+ slot: Option.getOrNull(mounted.slot),
118
+ key: Option.getOrNull(mounted.key),
119
+ props: [...dataProps, ...eventProps],
120
+ }
121
+ })
122
+
123
+ // Structure erases per-slot fill types; recover SlotFill structurally without `as`.
124
+ export const isSlotFill = (candidate: unknown): candidate is SlotFill<unknown> =>
125
+ typeof candidate === 'object' &&
126
+ candidate !== null &&
127
+ '_tag' in candidate &&
128
+ (candidate._tag === 'Each' || candidate._tag === 'One' || candidate._tag === 'Absent')
129
+
130
+ export const structureFills = (structure: Structure<UiContract>): Record<string, SlotFill<unknown>> =>
131
+ Rec.fromEntries(
132
+ Rec.toEntries(structure.slots).flatMap(
133
+ ([slotName, fillValue]): ReadonlyArray<readonly [string, SlotFill<unknown>]> =>
134
+ isSlotFill(fillValue) ? [[slotName, fillValue]] : [],
135
+ ),
136
+ )
137
+
138
+ export const slotsOf = (comp: AnyComposition): Record<string, AnySlot> =>
139
+ comp.capture<Record<string, AnySlot>>((exact) => {
140
+ if (!('slots' in exact.manifest)) {
141
+ return {}
142
+ }
143
+ const candidate: unknown = exact.manifest.slots
144
+ if (!isRecord(candidate)) {
145
+ return {}
146
+ }
147
+ return Rec.fromEntries(
148
+ Rec.toEntries(candidate).flatMap(
149
+ ([name, slotDefinition]): ReadonlyArray<readonly [string, AnySlot]> =>
150
+ isSlot(slotDefinition) ? [[name, slotDefinition]] : [],
151
+ ),
152
+ )
153
+ })