@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.
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +19 -2
- package/dist/client.js.map +1 -1
- package/dist/clientBinding.d.ts +18 -0
- package/dist/clientBinding.d.ts.map +1 -0
- package/dist/clientBinding.js +70 -0
- package/dist/clientBinding.js.map +1 -0
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +8 -3
- package/dist/react.js.map +1 -1
- package/dist/transport.d.ts +1 -13
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +13 -27
- package/dist/transport.js.map +1 -1
- package/dist/wireRender.d.ts.map +1 -1
- package/dist/wireRender.js +30 -9
- package/dist/wireRender.js.map +1 -1
- package/package.json +1 -1
- package/src/client-prop-decode.test.ts +98 -0
- package/src/client.ts +25 -9
- package/src/clientBinding.ts +103 -0
- package/src/react.ts +14 -3
- package/src/server.test.ts +6 -6
- package/src/transport.ts +15 -47
- package/src/wire-id-uniqueness.test.ts +287 -0
- package/src/wire-identity.test.ts +369 -0
- package/src/wire-keyed-identity.test.ts +434 -0
- package/src/wireRender.ts +47 -33
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// Wire-level identity/attribution defects in @playfast/reform-remote.
|
|
4
|
+
// Every test here asserts the behaviour the package is *supposed* to have; each
|
|
5
|
+
// one currently FAILS. Nothing in this file fixes anything.
|
|
6
|
+
import { act, createElement, StrictMode, useState } from 'react'
|
|
7
|
+
import { createRoot } from 'react-dom/client'
|
|
8
|
+
import { expect, test } from 'vitest'
|
|
9
|
+
import { Data, Layer, Schema as S } from 'effect'
|
|
10
|
+
import {
|
|
11
|
+
Composition,
|
|
12
|
+
Engine,
|
|
13
|
+
Event,
|
|
14
|
+
Reducer,
|
|
15
|
+
State,
|
|
16
|
+
Ui,
|
|
17
|
+
each,
|
|
18
|
+
mount,
|
|
19
|
+
provide,
|
|
20
|
+
scene,
|
|
21
|
+
slot,
|
|
22
|
+
ui,
|
|
23
|
+
} from '@playfast/reform'
|
|
24
|
+
import type { WireNode, WireTree } from '@playfast/reform/internal'
|
|
25
|
+
import { makeRemoteServer } from './server'
|
|
26
|
+
import { remoteViews, renderWireTree } from './client'
|
|
27
|
+
import { RemoteUI, type RemoteUIProps } from './react'
|
|
28
|
+
import { connect, serve, type InvokeMessage, type ServerMessage } from './transport'
|
|
29
|
+
import { inMemoryTransportPair } from './memory'
|
|
30
|
+
import { CounterUi, counterScene } from './fixtures'
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// polling helper — no fixed waits, every poll stops the moment it is satisfied
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
const PollTimeoutBase: new (args: { readonly detail: string }) => Error & {
|
|
37
|
+
readonly _tag: 'regress/PollTimeout'
|
|
38
|
+
} & Readonly<{ readonly detail: string }> = Data.TaggedError('regress/PollTimeout')<{
|
|
39
|
+
readonly detail: string
|
|
40
|
+
}>
|
|
41
|
+
|
|
42
|
+
class PollTimeout extends PollTimeoutBase {
|
|
43
|
+
override get message(): string {
|
|
44
|
+
return `condition never became true — ${this.detail}`
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const until = async (detail: string, predicate: () => boolean, rounds = 200): Promise<void> => {
|
|
49
|
+
if (predicate()) {
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
if (rounds <= 0) {
|
|
53
|
+
throw new PollTimeout({ detail })
|
|
54
|
+
}
|
|
55
|
+
await new Promise<void>((resolve) => {
|
|
56
|
+
// oxlint-disable-next-line reform-rules/no-wall-clock-wait-in-test -- turn hop inside a bounded structural poll, not a fixed wait
|
|
57
|
+
setTimeout(resolve, 0)
|
|
58
|
+
})
|
|
59
|
+
return until(detail, predicate, rounds - 1)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// a keyed, reorderable list scene
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
const Row = S.Struct({ id: S.String, label: S.String })
|
|
67
|
+
|
|
68
|
+
class Rows extends State.make('identity.rows', S.Array(Row)) {}
|
|
69
|
+
class Reversed extends Event.make('identity.Reversed', S.Struct({})) {}
|
|
70
|
+
class ReverseRows extends Reducer.make('identity.ReverseRows', {
|
|
71
|
+
states: [Rows],
|
|
72
|
+
events: [Reversed],
|
|
73
|
+
}) {}
|
|
74
|
+
|
|
75
|
+
class RowUi extends ui('RegressRow', {
|
|
76
|
+
props: S.Struct({ label: S.String }),
|
|
77
|
+
events: { ping: S.Struct({}) },
|
|
78
|
+
}) {}
|
|
79
|
+
class RowComp extends Composition.make('RegressRow', {
|
|
80
|
+
title: 'RegressRow',
|
|
81
|
+
ui: RowUi,
|
|
82
|
+
props: Row,
|
|
83
|
+
})<RowComp>() {}
|
|
84
|
+
class RowSlot extends slot('RegressRow')<RowSlot, typeof RowComp>() {}
|
|
85
|
+
|
|
86
|
+
class ListUi extends ui('RegressList', {
|
|
87
|
+
props: S.Struct({}),
|
|
88
|
+
events: { reverse: S.Struct({}) },
|
|
89
|
+
slots: { Row: RowSlot },
|
|
90
|
+
}) {}
|
|
91
|
+
class ListComp extends Composition.make('RegressList', {
|
|
92
|
+
title: 'RegressList',
|
|
93
|
+
ui: ListUi,
|
|
94
|
+
slots: { Row: RowSlot },
|
|
95
|
+
states: [Rows],
|
|
96
|
+
})<ListComp>() {}
|
|
97
|
+
|
|
98
|
+
const identityScene = (initial: ReadonlyArray<{ readonly id: string; readonly label: string }>) => {
|
|
99
|
+
const presentation = Layer.mergeAll(
|
|
100
|
+
provide(
|
|
101
|
+
ListUi,
|
|
102
|
+
Ui.make(ListUi, () => null),
|
|
103
|
+
),
|
|
104
|
+
provide(
|
|
105
|
+
RowUi,
|
|
106
|
+
Ui.make(RowUi, ({ label }) => label),
|
|
107
|
+
),
|
|
108
|
+
provide(RowSlot, RowComp),
|
|
109
|
+
State.live(Rows, initial),
|
|
110
|
+
)
|
|
111
|
+
const app = Layer.mergeAll(
|
|
112
|
+
Composition.live(ListComp, function* () {
|
|
113
|
+
const rows = yield* Rows
|
|
114
|
+
const reverse = yield* Event.trigger(Reversed)
|
|
115
|
+
return mount({
|
|
116
|
+
props: {},
|
|
117
|
+
events: { reverse },
|
|
118
|
+
slots: {
|
|
119
|
+
Row: each(rows, {
|
|
120
|
+
key: (entry) => entry.id,
|
|
121
|
+
props: (entry) => ({ id: entry.id, label: entry.label }),
|
|
122
|
+
}),
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
}),
|
|
126
|
+
Composition.live(RowComp, function* () {
|
|
127
|
+
const rowProps = yield* RowComp.props
|
|
128
|
+
const pingTrigger = yield* Event.trigger(Reversed)
|
|
129
|
+
const ping = (): void => pingTrigger({})
|
|
130
|
+
return mount({ props: { label: rowProps.label }, slots: {}, events: { ping } })
|
|
131
|
+
}),
|
|
132
|
+
Reducer.live(ReverseRows, (current) => [...current].reverse()),
|
|
133
|
+
).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
|
|
134
|
+
return scene(ListComp, { provide: [app] })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const rowsOf = (tree: WireTree): ReadonlyArray<WireNode> =>
|
|
138
|
+
tree.filter((node) => node.slot === 'Row')
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// BUG 1 — a keyed slot child's client identity follows its POSITION, not its key.
|
|
142
|
+
//
|
|
143
|
+
// `each({ key: … })` labels every child, the server ships that label as
|
|
144
|
+
// `WireNode.key`, and `@playfast/reform-react` (the local host for the same
|
|
145
|
+
// scene) reconciles React by exactly that key —
|
|
146
|
+
// packages/react/src/structure.ts:106 `key: placement.key`.
|
|
147
|
+
// The remote client instead keys React by the wire node id
|
|
148
|
+
// (packages/reform-remote/src/client.ts:158 `key: child.id`), and that id is
|
|
149
|
+
// minted positionally as `${parent}.${slot}.${index}`
|
|
150
|
+
// (packages/reform-remote/src/wireRender.ts:104). Reorder a keyed list and every
|
|
151
|
+
// node id keeps its slot position while its `key` slides underneath it, so each
|
|
152
|
+
// client component instance — and all of its local state — is silently
|
|
153
|
+
// re-attached to a different item.
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
test('a reordered keyed slot keeps each item on its own client instance', async () => {
|
|
157
|
+
const server = makeRemoteServer(
|
|
158
|
+
identityScene([
|
|
159
|
+
{ id: 'a', label: 'A' },
|
|
160
|
+
{ id: 'b', label: 'B' },
|
|
161
|
+
{ id: 'c', label: 'C' },
|
|
162
|
+
]),
|
|
163
|
+
)
|
|
164
|
+
const mounts = { n: 0 }
|
|
165
|
+
const views = remoteViews<{ RegressList: typeof ListUi; RegressRow: typeof RowUi }>({
|
|
166
|
+
RegressList: Ui.make(ListUi, (_props, slots) =>
|
|
167
|
+
createElement('div', null, createElement(slots.Row)),
|
|
168
|
+
),
|
|
169
|
+
// Per-row client state: which instance is rendering this row?
|
|
170
|
+
RegressRow: Ui.make(RowUi, ({ label }) => {
|
|
171
|
+
const [instance] = useState(() => {
|
|
172
|
+
mounts.n += 1
|
|
173
|
+
return `instance-${mounts.n}`
|
|
174
|
+
})
|
|
175
|
+
return createElement('span', { 'data-label': label, 'data-instance': instance }, label)
|
|
176
|
+
}),
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
const container = document.createElement('div')
|
|
180
|
+
document.body.appendChild(container)
|
|
181
|
+
const root = createRoot(container)
|
|
182
|
+
|
|
183
|
+
const rendered = (): ReadonlyArray<readonly [string, string]> =>
|
|
184
|
+
[...container.querySelectorAll('[data-label]')].map(
|
|
185
|
+
(element) =>
|
|
186
|
+
[
|
|
187
|
+
element.getAttribute('data-label') ?? '',
|
|
188
|
+
element.getAttribute('data-instance') ?? '',
|
|
189
|
+
] as const,
|
|
190
|
+
)
|
|
191
|
+
const instanceByLabel = (): Record<string, string> => Object.fromEntries(rendered())
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const before = await server.render()
|
|
195
|
+
expect(rowsOf(before).map((node) => node.key)).toEqual(['a', 'b', 'c'])
|
|
196
|
+
await act(async () => {
|
|
197
|
+
root.render(renderWireTree(before, { views, invoke: () => undefined }))
|
|
198
|
+
})
|
|
199
|
+
const ownerBefore = instanceByLabel()
|
|
200
|
+
expect(ownerBefore).toEqual({ A: 'instance-1', B: 'instance-2', C: 'instance-3' })
|
|
201
|
+
|
|
202
|
+
// Reverse the list server-side: same three keys, new order.
|
|
203
|
+
await server.invoke('0:reverse', {})
|
|
204
|
+
const after = await server.render()
|
|
205
|
+
expect(rowsOf(after).map((node) => node.key)).toEqual(['c', 'b', 'a'])
|
|
206
|
+
await act(async () => {
|
|
207
|
+
root.render(renderWireTree(after, { views, invoke: () => undefined }))
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
// The reorder itself did land in the DOM…
|
|
211
|
+
expect(rendered().map(([label]) => label)).toEqual(['C', 'B', 'A'])
|
|
212
|
+
// …and nothing remounted, so every instance is still alive.
|
|
213
|
+
expect(mounts.n).toBe(3)
|
|
214
|
+
// Each key must therefore still be rendered by the instance that owned it.
|
|
215
|
+
// Actual today: A→instance-3, C→instance-1 — the state stayed at the slot
|
|
216
|
+
// position and the items swapped underneath it.
|
|
217
|
+
expect(instanceByLabel()).toEqual(ownerBefore)
|
|
218
|
+
} finally {
|
|
219
|
+
await act(async () => {
|
|
220
|
+
root.unmount()
|
|
221
|
+
})
|
|
222
|
+
await server.dispose()
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// BUG 2 — a failed invoke escapes serve()'s message pump as an unhandled
|
|
228
|
+
// promise rejection.
|
|
229
|
+
//
|
|
230
|
+
// packages/reform-remote/src/transport.ts:108-113 (and the identical
|
|
231
|
+
// `serveShared` pump at :215-220) wrap `server.invoke` in `Effect.promise`,
|
|
232
|
+
// which turns a rejection into a defect, then drop the resulting promise with
|
|
233
|
+
// `void Effect.runPromise(…)`. `registry.invoke` legitimately FAILS for a
|
|
234
|
+
// revoked handle (`UnknownTrigger`) or a payload that misses its schema —
|
|
235
|
+
// exactly the "stale client invoke fails cleanly" path the playbook documents —
|
|
236
|
+
// so any client frame that loses that race detonates in the server process.
|
|
237
|
+
// Under Node's default `--unhandled-rejections=throw` that takes down the
|
|
238
|
+
// WebSocket server for every other connected client.
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
test('a stale invoke fails cleanly instead of escaping serve() unhandled', async () => {
|
|
242
|
+
const rejections: Array<unknown> = []
|
|
243
|
+
const onRejection = (reason: unknown): void => void rejections.push(reason)
|
|
244
|
+
process.on('unhandledRejection', onRejection)
|
|
245
|
+
|
|
246
|
+
const { server: serverTransport, client: clientTransport } = inMemoryTransportPair<
|
|
247
|
+
ServerMessage,
|
|
248
|
+
InvokeMessage
|
|
249
|
+
>()
|
|
250
|
+
const client = connect({
|
|
251
|
+
transport: clientTransport,
|
|
252
|
+
views: remoteViews<{ Counter: typeof CounterUi }>({
|
|
253
|
+
Counter: Ui.make(CounterUi, () => null),
|
|
254
|
+
}),
|
|
255
|
+
})
|
|
256
|
+
const server = serve({ scene: counterScene(), transport: serverTransport })
|
|
257
|
+
|
|
258
|
+
const countOnClient = (): unknown => {
|
|
259
|
+
const prop = client
|
|
260
|
+
.snapshot()
|
|
261
|
+
.find((node) => node.id === '0')
|
|
262
|
+
?.props.find((candidate) => candidate._tag === 'Data' && candidate.name === 'count')
|
|
263
|
+
return prop !== undefined && 'value' in prop ? prop.value : undefined
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
await server.start()
|
|
268
|
+
await until('the client received the initial snapshot', () => countOnClient() === 0)
|
|
269
|
+
|
|
270
|
+
// A handle the server does not know: the everyday outcome of clicking a row
|
|
271
|
+
// whose node was deleted by a background patch still in flight.
|
|
272
|
+
clientTransport.send({ _tag: 'Invoke', handle: '0:gone', payload: {} })
|
|
273
|
+
// A well-formed invoke behind it, used purely as the landing signal.
|
|
274
|
+
clientTransport.send({ _tag: 'Invoke', handle: '0:bump', payload: { by: 3 } })
|
|
275
|
+
await until('the following invoke round-tripped', () => countOnClient() === 3)
|
|
276
|
+
|
|
277
|
+
expect(rejections).toEqual([])
|
|
278
|
+
} finally {
|
|
279
|
+
process.off('unhandledRejection', onRejection)
|
|
280
|
+
client.dispose()
|
|
281
|
+
await server.dispose()
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// BUG 3 — <RemoteUI> renders nothing under <StrictMode>.
|
|
287
|
+
//
|
|
288
|
+
// packages/reform-remote/src/react.ts:9-10 creates the transport subscription
|
|
289
|
+
// inside a `useState` initializer — `connect()` calls `transport.onMessage`
|
|
290
|
+
// eagerly — but tears it down from an effect cleanup. StrictMode double-invokes
|
|
291
|
+
// the initializer (two bindings, one orphaned but still subscribed) and then
|
|
292
|
+
// double-invokes the effect: setup → cleanup → setup. The cleanup calls
|
|
293
|
+
// `binding.dispose()` on the binding React kept, and the second setup does
|
|
294
|
+
// nothing to re-subscribe it, so the binding that is actually rendered is deaf
|
|
295
|
+
// for the rest of its life. `@playfast/reform-react` deliberately avoids this
|
|
296
|
+
// shape ("Capture once: committed construction avoids leaking runtimes from
|
|
297
|
+
// abandoned renders", packages/react/src/index.ts:116).
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
const counterNode: WireNode = {
|
|
301
|
+
id: '0',
|
|
302
|
+
name: 'Counter',
|
|
303
|
+
parentId: null,
|
|
304
|
+
childIndex: 0,
|
|
305
|
+
slot: null,
|
|
306
|
+
key: null,
|
|
307
|
+
props: [{ _tag: 'Data', name: 'count', value: 41 }],
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
type CounterContract = { Counter: typeof CounterUi }
|
|
311
|
+
|
|
312
|
+
test('RemoteUI stays subscribed to its transport under StrictMode', async () => {
|
|
313
|
+
const views = remoteViews<CounterContract>({
|
|
314
|
+
Counter: Ui.make(CounterUi, ({ count }) =>
|
|
315
|
+
createElement('i', { 'data-count': String(count) }, String(count)),
|
|
316
|
+
),
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
const control = inMemoryTransportPair<ServerMessage, InvokeMessage>()
|
|
320
|
+
const controlContainer = document.createElement('div')
|
|
321
|
+
document.body.appendChild(controlContainer)
|
|
322
|
+
const controlRoot = createRoot(controlContainer)
|
|
323
|
+
|
|
324
|
+
const strict = inMemoryTransportPair<ServerMessage, InvokeMessage>()
|
|
325
|
+
const strictContainer = document.createElement('div')
|
|
326
|
+
document.body.appendChild(strictContainer)
|
|
327
|
+
const strictRoot = createRoot(strictContainer)
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
// Control: the very same mount without StrictMode, so a failure below can
|
|
331
|
+
// only be StrictMode's double-invocation and not a broken harness.
|
|
332
|
+
await act(async () => {
|
|
333
|
+
controlRoot.render(
|
|
334
|
+
createElement<RemoteUIProps<CounterContract>>(RemoteUI, {
|
|
335
|
+
transport: control.client,
|
|
336
|
+
views,
|
|
337
|
+
}),
|
|
338
|
+
)
|
|
339
|
+
})
|
|
340
|
+
await act(async () => {
|
|
341
|
+
control.server.send({ _tag: 'Snapshot', tree: [counterNode] })
|
|
342
|
+
})
|
|
343
|
+
expect(controlContainer.querySelector('[data-count]')?.getAttribute('data-count')).toBe('41')
|
|
344
|
+
|
|
345
|
+
await act(async () => {
|
|
346
|
+
strictRoot.render(
|
|
347
|
+
createElement(
|
|
348
|
+
StrictMode,
|
|
349
|
+
null,
|
|
350
|
+
createElement<RemoteUIProps<CounterContract>>(RemoteUI, {
|
|
351
|
+
transport: strict.client,
|
|
352
|
+
views,
|
|
353
|
+
}),
|
|
354
|
+
),
|
|
355
|
+
)
|
|
356
|
+
})
|
|
357
|
+
await act(async () => {
|
|
358
|
+
strict.server.send({ _tag: 'Snapshot', tree: [counterNode] })
|
|
359
|
+
})
|
|
360
|
+
// Actual today: null — the snapshot reached an orphaned binding, and the
|
|
361
|
+
// rendered one had already been unsubscribed by StrictMode's effect cleanup.
|
|
362
|
+
expect(strictContainer.querySelector('[data-count]')?.getAttribute('data-count')).toBe('41')
|
|
363
|
+
} finally {
|
|
364
|
+
await act(async () => {
|
|
365
|
+
controlRoot.unmount()
|
|
366
|
+
strictRoot.unmount()
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
})
|