@playfast/reform-remote 1.0.1 → 1.1.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.
@@ -2,8 +2,14 @@ import { createElement, Fragment, type ReactNode } from 'react'
2
2
  import { renderToStaticMarkup } from 'react-dom/server'
3
3
  import { expect, test } from 'vitest'
4
4
  import { Option } from 'effect'
5
- import { Ui, Wire } from '@playfast/reform'
6
- import type { WireNode, WirePatch, WireProp, WireTree } from '@playfast/reform'
5
+ import { Ui } from '@playfast/reform'
6
+ import {
7
+ Wire,
8
+ type WireNode,
9
+ type WirePatch,
10
+ type WireProp,
11
+ type WireTree,
12
+ } from '@playfast/reform/internal'
7
13
  import { makeRemoteServer } from './server'
8
14
  import { remoteViews, renderWireTree } from './client'
9
15
  import {
@@ -17,17 +23,20 @@ import {
17
23
  structureCounterScene,
18
24
  } from './fixtures'
19
25
 
20
- // The client renders views as React components, so a test runs them by RENDERING
21
- // `renderWireTree`'s output (node, no DOM needed) — the views capture as they render.
22
- const draw = (node: ReactNode): void => void renderToStaticMarkup(createElement(Fragment, null, node))
26
+ const draw = (node: ReactNode): void =>
27
+ void renderToStaticMarkup(createElement(Fragment, null, node))
23
28
 
24
29
  const dataOf = (node: WireNode, name: string): WireProp | undefined =>
25
30
  node.props.find((prop) => prop._tag === 'Data' && prop.name === name)
26
31
 
27
32
  const eventOf = (node: WireNode, name: string): Extract<WireProp, { _tag: 'Event' }> | undefined =>
28
- node.props.find((prop): prop is Extract<WireProp, { _tag: 'Event' }> => prop._tag === 'Event' && prop.name === name)
33
+ node.props.find(
34
+ (prop): prop is Extract<WireProp, { _tag: 'Event' }> =>
35
+ prop._tag === 'Event' && prop.name === name,
36
+ )
29
37
 
30
- const byId = (tree: WireTree, id: string): WireNode | undefined => tree.find((node) => node.id === id)
38
+ const byId = (tree: WireTree, id: string): WireNode | undefined =>
39
+ tree.find((node) => node.id === id)
31
40
 
32
41
  const upsertIds = (patches: ReadonlyArray<WirePatch>): ReadonlyArray<string> =>
33
42
  patches.flatMap((patch) => (patch._tag === 'Upsert' ? [patch.node.id] : []))
@@ -35,8 +44,6 @@ const upsertIds = (patches: ReadonlyArray<WirePatch>): ReadonlyArray<string> =>
35
44
  const deleteIds = (patches: ReadonlyArray<WirePatch>): ReadonlyArray<string> =>
36
45
  patches.flatMap((patch) => (patch._tag === 'Delete' ? [patch.id] : []))
37
46
 
38
- // ── flat counter ──────────────────────────────────────────────────────────────
39
-
40
47
  test('a scene renders to a wire tree with schema-encoded props and a trigger handle', async () => {
41
48
  const server = makeRemoteServer(counterScene())
42
49
  try {
@@ -66,16 +73,12 @@ test('invoking a trigger handle dispatches into the runtime and the next render
66
73
  }
67
74
  })
68
75
 
69
- // ── structure-path counter: events ride ON the structure (plan 03b) ─────────────
70
-
71
76
  test('a mount(...)-returning body registers its event from structure.events and invoking it dispatches', async () => {
72
77
  const server = makeRemoteServer(structureCounterScene())
73
78
  try {
74
79
  const tree = await server.render()
75
80
  const root = tree[0]!
76
81
  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
82
  expect(dataOf(root, 'count')).toEqual({ _tag: 'Data', name: 'count', value: 0 })
80
83
  expect(eventOf(root, 'bump')).toEqual({ _tag: 'Event', name: 'bump', handle: '0:bump' })
81
84
 
@@ -117,7 +120,10 @@ test('renderDiff streams patches a client folds back to the current tree', async
117
120
 
118
121
  test('end to end: client reconstructs props + event callbacks that drive the server', async () => {
119
122
  const server = makeRemoteServer(counterScene())
120
- const captured: { props?: Record<string, unknown>; events?: Record<string, (p: { by: number }) => void> } = {}
123
+ const captured: {
124
+ props?: Record<string, unknown>
125
+ events?: Record<string, (p: { by: number }) => void>
126
+ } = {}
121
127
  const sent: Array<[string, unknown]> = []
122
128
  try {
123
129
  const tree = await server.render()
@@ -156,8 +162,6 @@ test('boot events are applied before the first render', async () => {
156
162
  }
157
163
  })
158
164
 
159
- // ── nested slot: a child with its own state, two events ─────────────────────────
160
-
161
165
  test('a slotted child renders nested with its own encoded props and handle', async () => {
162
166
  const server = makeRemoteServer(slottedScene())
163
167
  try {
@@ -205,22 +209,23 @@ test('two events on one node register distinct handles and each fires independen
205
209
  expect(eventOf(before, 'clear')?.handle).toBe('0.Main.0:clear')
206
210
 
207
211
  await server.invoke('0.Main.0:greet', { text: 'hello' })
208
- expect(dataOf(byId(await server.render(), '0.Main.0')!, 'greeting')).toMatchObject({ value: 'hello' })
212
+ expect(dataOf(byId(await server.render(), '0.Main.0')!, 'greeting')).toMatchObject({
213
+ value: 'hello',
214
+ })
209
215
 
210
216
  await server.invoke('0.Main.0:clear', {})
211
- expect(dataOf(byId(await server.render(), '0.Main.0')!, 'greeting')).toMatchObject({ value: '' })
217
+ expect(dataOf(byId(await server.render(), '0.Main.0')!, 'greeting')).toMatchObject({
218
+ value: '',
219
+ })
212
220
  } finally {
213
221
  await server.dispose()
214
222
  }
215
223
  })
216
224
 
217
- // ── dynamic list: add (upsert), remove (delete + handle revocation) ─────────────
218
-
219
225
  test('a list renders one child per item under a single slot, in order', async () => {
220
226
  const server = makeRemoteServer(listScene())
221
227
  try {
222
228
  const tree = await server.render()
223
- // List + Bar + two items.
224
229
  expect(tree).toHaveLength(4)
225
230
  expect(byId(tree, '0')!.name).toBe('List')
226
231
  expect(byId(tree, '0.Bar.0')!.slot).toBe('Bar')
@@ -238,14 +243,13 @@ test('a list renders one child per item under a single slot, in order', async ()
238
243
  test('adding an item emits an Upsert for the new node; removing emits a Delete', async () => {
239
244
  const server = makeRemoteServer(listScene())
240
245
  try {
241
- await server.renderDiff() // first frame (snapshot baseline)
246
+ await server.renderDiff()
242
247
 
243
248
  await server.invoke('0.Bar.0:add', { id: 'c', label: 'C' })
244
249
  const added = await server.renderDiff()
245
250
  expect(deleteIds(added)).toEqual([])
246
251
  expect(upsertIds(added)).toContain('0.Item.2')
247
252
 
248
- // Remove the last item — exactly its node leaves the tree.
249
253
  await server.invoke('0.Item.2:remove', {})
250
254
  const removed = await server.renderDiff()
251
255
  expect(deleteIds(removed)).toEqual(['0.Item.2'])
@@ -258,25 +262,19 @@ test("a removed node's handle is revoked — a stale client invocation rejects",
258
262
  const server = makeRemoteServer(listScene())
259
263
  try {
260
264
  await server.renderDiff()
261
- // The handle works while the item is mounted…
262
265
  await server.invoke('0.Item.1:remove', {})
263
- await server.renderDiff() // item 'b' gone → its handle revoked
264
- // …and fails cleanly once the node has been unmounted.
266
+ await server.renderDiff()
265
267
  await expect(server.invoke('0.Item.1:remove', {})).rejects.toBeDefined()
266
268
  } finally {
267
269
  await server.dispose()
268
270
  }
269
271
  })
270
272
 
271
- // ── Option props: Effect-native values survive the wire as real instances ───────
272
-
273
273
  test('an Option prop round-trips through JSON as a real Option (symmetric schema decode)', async () => {
274
274
  const server = makeRemoteServer(optionScene(Option.some('hi')))
275
275
  const captured: { label?: unknown } = {}
276
276
  try {
277
277
  const tree = await server.render()
278
- // Round-trip the tree through JSON exactly as the real WebSocket transport does —
279
- // the Option's `toJSON` flattens it to a plain `{_tag,value}` shape on the way out.
280
278
  const wire: WireTree = JSON.parse(JSON.stringify(tree))
281
279
  draw(
282
280
  renderWireTree(wire, {
@@ -289,7 +287,6 @@ test('an Option prop round-trips through JSON as a real Option (symmetric schema
289
287
  invoke: () => undefined,
290
288
  }),
291
289
  )
292
- // The client decoded it back through the contract schema → a REAL Option instance.
293
290
  expect(Option.isOption(captured.label)).toBe(true)
294
291
  expect(captured.label).toStrictEqual(Option.some('hi'))
295
292
  } finally {
@@ -322,7 +319,6 @@ test('a data-only change upserts only the changed node, leaving siblings untouch
322
319
  await server.renderDiff()
323
320
  await server.invoke('0.Main.0:greet', { text: 'changed' })
324
321
  const patches = await server.renderDiff()
325
- // Only the panel changed; the shell is unchanged, so no patch for it.
326
322
  expect(upsertIds(patches)).toEqual(['0.Main.0'])
327
323
  expect(deleteIds(patches)).toEqual([])
328
324
  } finally {