@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,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
+ })