@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.
- package/README.md +16 -16
- package/package.json +18 -18
- package/src/client-keyed-slot.test.ts +19 -28
- package/src/client-remount.test.ts +14 -28
- package/src/client.ts +73 -182
- package/src/fixtures.ts +268 -92
- package/src/index.ts +0 -10
- package/src/memory.ts +0 -9
- package/src/react.ts +1 -32
- package/src/remote-view.typecheck.ts +3 -17
- package/src/server.test.ts +28 -32
- package/src/server.ts +269 -195
- package/src/transport.test.ts +3 -26
- package/src/transport.ts +18 -96
package/src/server.test.ts
CHANGED
|
@@ -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
|
|
6
|
-
import
|
|
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
|
-
|
|
21
|
-
|
|
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(
|
|
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 =>
|
|
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: {
|
|
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({
|
|
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({
|
|
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()
|
|
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()
|
|
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 {
|