@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,287 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Round-5 adversarial audit — `reform-remote`'s half of the keyed-identity fix set.
4
+ //
5
+ // Under audit:
6
+ // * `wireRender.ts` `enqueueStructureSlot` — an `Each` child's id is now
7
+ // `${parent.id}.${slotName}.${entry.key}`, with a `used` Set that appends
8
+ // `#${index}` when a key repeats.
9
+ // * `client.ts` `WireNodeView` — a slot child is now reconciled by React on
10
+ // `child.key ?? child.id` instead of `child.id`.
11
+ //
12
+ // Both findings below survived an attempt to refute them. Each test states the
13
+ // behaviour the FIXED code is supposed to have, fails on the current working
14
+ // tree, and carries a passing CONTROL in the same body that proves the harness
15
+ // works and isolates the defect. Every listener/observer is attached before
16
+ // anything is awaited.
17
+ import { act, createElement, useState } from 'react'
18
+ import { createRoot } from 'react-dom/client'
19
+ import { expect, test } from 'vitest'
20
+ import { Layer, Schema as S } from 'effect'
21
+ import {
22
+ Composition,
23
+ Engine,
24
+ State,
25
+ Ui,
26
+ each,
27
+ mount,
28
+ provide,
29
+ scene,
30
+ slot,
31
+ ui,
32
+ } from '@playfast/reform'
33
+ import { Wire, type WireNode, type WireTree } from '@playfast/reform/internal'
34
+ import { makeRemoteServer } from './server'
35
+ import { remoteViews, renderWireTree } from './client'
36
+
37
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // One scene, seeded per server: a list whose rows are keyed by `entry.id`, the
41
+ // shape every `each(...)` call in the repo uses. A "reorder" is modelled as a
42
+ // second server over the same rows in a different order — which is exactly the
43
+ // frame a reordering server emits now that ids are minted from keys rather than
44
+ // positions.
45
+ // ---------------------------------------------------------------------------
46
+
47
+ const RowValue = S.Struct({ id: S.String, label: S.String })
48
+
49
+ class Rows extends State.make('audit5.rows', S.Array(RowValue)) {}
50
+
51
+ class RowUi extends ui('audit5.Row', { props: S.Struct({ label: S.String }) }) {}
52
+ class RowComp extends Composition.make('audit5.Row', {
53
+ title: 'Row',
54
+ ui: RowUi,
55
+ props: RowValue,
56
+ })<RowComp>() {}
57
+ class RowSlot extends slot('audit5.Row')<RowSlot, typeof RowComp>() {}
58
+
59
+ class ListUi extends ui('audit5.List', { props: S.Struct({}), slots: { Row: RowSlot } }) {}
60
+ class ListComp extends Composition.make('audit5.List', {
61
+ title: 'List',
62
+ ui: ListUi,
63
+ slots: { Row: RowSlot },
64
+ states: [Rows],
65
+ })<ListComp>() {}
66
+
67
+ interface Row {
68
+ readonly id: string
69
+ readonly label: string
70
+ }
71
+
72
+ const listScene = (seed: ReadonlyArray<Row>) => {
73
+ const presentation = Layer.mergeAll(
74
+ provide(
75
+ ListUi,
76
+ Ui.make(ListUi, () => null),
77
+ ),
78
+ provide(
79
+ RowUi,
80
+ Ui.make(RowUi, () => null),
81
+ ),
82
+ provide(RowSlot, RowComp),
83
+ State.live(Rows, seed),
84
+ )
85
+ const app = Layer.mergeAll(
86
+ Composition.live(ListComp, function* () {
87
+ const rows = yield* Rows
88
+ return mount({
89
+ props: {},
90
+ slots: {
91
+ Row: each(rows, {
92
+ key: (entry) => entry.id,
93
+ props: (entry) => ({ id: entry.id, label: entry.label }),
94
+ }),
95
+ },
96
+ })
97
+ }),
98
+ Composition.live(RowComp, function* () {
99
+ const rowProps = yield* RowComp.props
100
+ return mount({ props: { label: rowProps.label }, slots: {} })
101
+ }),
102
+ ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
103
+ return scene(ListComp, { provide: [app] })
104
+ }
105
+
106
+ const frameFor = async (seed: ReadonlyArray<Row>): Promise<WireTree> => {
107
+ const server = makeRemoteServer(listScene(seed))
108
+ try {
109
+ return await server.render()
110
+ } finally {
111
+ await server.dispose()
112
+ }
113
+ }
114
+
115
+ const rowsOf = (tree: WireTree): WireTree => tree.filter((node) => node.slot === 'Row')
116
+
117
+ const labelOf = (node: WireNode): string => {
118
+ const prop = node.props.find(
119
+ (candidate) => candidate._tag === 'Data' && candidate.name === 'label',
120
+ )
121
+ return prop !== undefined && 'value' in prop ? String(prop.value) : '<none>'
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // FINDING 1 — `wireRender.ts:104-108`. The `used` dedupe is not collision-safe.
126
+ //
127
+ // const preferred = `${parent.id}.${slotName}.${entry.key ?? index}`
128
+ // const id = used.has(preferred) ? `${preferred}#${index}` : preferred
129
+ // used.add(id)
130
+ //
131
+ // The repeat branch mints `${preferred}#${index}` and never asks whether THAT
132
+ // string is already taken. It can be: an earlier row whose own key literally
133
+ // ends in `#<n>` already claimed it, where `n` is the index of a later repeat.
134
+ // Keys carrying a `#` are ordinary — an issue ref (`PROJ#12`), a channel or
135
+ // thread name, a URL fragment, a label with a discriminator — and the whole
136
+ // point of the `used` branch is that the framework tolerates a duplicate key
137
+ // instead of trusting the app to produce unique ones.
138
+ //
139
+ // The result is two wire NODES sharing one id. Node id is the wire's primary
140
+ // key everywhere downstream: `Wire.diff` builds `previousById`/`nextById` from
141
+ // it, `Wire.apply`'s `upsertNode` finds-and-replaces by it, and a trigger
142
+ // handle is `${id}:${eventName}`. So the row that loses the tie is not merely
143
+ // mis-reconciled, it is DELETED from the client's tree by the very first frame,
144
+ // and its `remove` handle points at the row that overwrote it.
145
+ // ---------------------------------------------------------------------------
146
+
147
+ test('every wire node in a frame has its own id, whatever the app keys rows by', async () => {
148
+ // ---- CONTROL — three rows, two of them sharing the key `dup`. The `used`
149
+ // branch does its job: three rows, three ids, and a client that applies the
150
+ // opening diff sees all three. This is the harness, the scene and the whole
151
+ // duplicate-key path working.
152
+ const control = await frameFor([
153
+ { id: 'dup', label: 'A' },
154
+ { id: 'dup', label: 'B' },
155
+ { id: 'plain', label: 'C' },
156
+ ])
157
+ const controlRows = rowsOf(control)
158
+ expect(controlRows.map(labelOf)).toEqual(['A', 'B', 'C'])
159
+ expect(new Set(controlRows.map((node) => node.id)).size).toBe(3)
160
+ expect(rowsOf(Wire.apply([], Wire.diff([], control))).map(labelOf)).toEqual(['A', 'B', 'C'])
161
+
162
+ // ---- DEFECT — the same three rows. The only change is that the first row's
163
+ // key already spells the collision the dedupe is about to mint for index 2.
164
+ // index 0 key 'tag#2' -> '0.audit5.Row.tag#2' (taken)
165
+ // index 1 key 'tag' -> '0.audit5.Row.tag'
166
+ // index 2 key 'tag' -> taken, so '0.audit5.Row.tag#2' <- same id
167
+ const defect = await frameFor([
168
+ { id: 'tag#2', label: 'A' },
169
+ { id: 'tag', label: 'B' },
170
+ { id: 'tag', label: 'C' },
171
+ ])
172
+ const defectRows = rowsOf(defect)
173
+ expect(defectRows.map(labelOf)).toEqual(['A', 'B', 'C'])
174
+
175
+ // Two nodes, one id. Actual today: 2 — `0.audit5.Row.tag#2` is minted twice.
176
+ expect(new Set(defectRows.map((node) => node.id)).size).toBe(3)
177
+
178
+ // ...and the consequence a connected client actually sees: `Wire.apply` keys
179
+ // by id, so the opening frame loses a row outright.
180
+ // Actual today: ['C', 'B'] — row A was overwritten in place by row C.
181
+ expect(rowsOf(Wire.apply([], Wire.diff([], defect))).map(labelOf)).toEqual(['A', 'B', 'C'])
182
+ })
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // FINDING 2 — `client.ts:172`. `key: child.key ?? child.id`.
186
+ //
187
+ // `child.id` is unique across a frame by construction (that is what FINDING 1
188
+ // is about). `child.key` is whatever the app's `key:` callback returned and
189
+ // carries no uniqueness guarantee at all — `enqueueStructureSlot` was hardened
190
+ // in this same change set precisely BECAUSE a duplicate key happens.
191
+ //
192
+ // Handing React a duplicate key is documented-unsupported: "Non-unique keys may
193
+ // cause children to be duplicated and/or omitted". It does. A reorder across a
194
+ // duplicate key makes React emit a row the frame does not contain and keep an
195
+ // element the frame deleted, so the painted list stops matching the wire tree.
196
+ //
197
+ // The change is also unnecessary. Its stated reason — "node ids are positional,
198
+ // so a reorder would otherwise slide item state between rows" — was true of the
199
+ // OLD id scheme and is no longer true of the one shipped in the same commit:
200
+ // `${parent.id}.${slotName}.${key}` is already reorder-stable, and stable for
201
+ // un-keyed `one(...)` descendants too, which `child.key` is not (it is null for
202
+ // them). `key: child.id` satisfies both round-4 bugs and stays unique.
203
+ // ---------------------------------------------------------------------------
204
+
205
+ const paint = async (
206
+ before: WireTree,
207
+ after: WireTree,
208
+ ): Promise<{
209
+ readonly painted: ReadonlyArray<string>
210
+ readonly expected: ReadonlyArray<string>
211
+ }> => {
212
+ const seat = { n: 0 }
213
+ const views = remoteViews<{ 'audit5.List': typeof ListUi; 'audit5.Row': typeof RowUi }>({
214
+ 'audit5.List': Ui.make(ListUi, (_props, slots) =>
215
+ createElement('div', null, createElement(slots.Row)),
216
+ ),
217
+ 'audit5.Row': Ui.make(RowUi, ({ label }) => {
218
+ const [seatId] = useState(() => {
219
+ seat.n += 1
220
+ return `seat-${seat.n}`
221
+ })
222
+ return createElement('span', { 'data-row': label, 'data-seat': seatId })
223
+ }),
224
+ })
225
+ const container = document.createElement('div')
226
+ document.body.appendChild(container)
227
+ const root = createRoot(container)
228
+ const config = { views, invoke: () => undefined }
229
+ try {
230
+ await act(async () => {
231
+ root.render(renderWireTree(before, config))
232
+ })
233
+ await act(async () => {
234
+ root.render(renderWireTree(after, config))
235
+ })
236
+ return {
237
+ painted: [...container.querySelectorAll('[data-row]')].map(
238
+ (element) => element.getAttribute('data-row') ?? '<none>',
239
+ ),
240
+ expected: Wire.childrenOf(after, '0').map(labelOf),
241
+ }
242
+ } finally {
243
+ await act(async () => {
244
+ root.unmount()
245
+ })
246
+ container.remove()
247
+ }
248
+ }
249
+
250
+ test('a reordered frame paints exactly the rows the frame contains', async () => {
251
+ // ---- CONTROL — three rows with distinct keys, reordered. The client keys
252
+ // React by `child.key`, every key is unique, and the painted list is the
253
+ // frame's list. Same scene, same two renders, same assertion as the arm below.
254
+ const uniqueBefore = await frameFor([
255
+ { id: 'one', label: 'A' },
256
+ { id: 'two', label: 'B' },
257
+ { id: 'three', label: 'C' },
258
+ ])
259
+ const uniqueAfter = await frameFor([
260
+ { id: 'two', label: 'B' },
261
+ { id: 'one', label: 'A' },
262
+ { id: 'three', label: 'C' },
263
+ ])
264
+ const control = await paint(uniqueBefore, uniqueAfter)
265
+ expect(control.expected).toEqual(['B', 'A', 'C'])
266
+ expect(control.painted).toEqual(control.expected)
267
+
268
+ // ---- DEFECT — the same reorder over a list where two rows share a key. The
269
+ // server handled it: three nodes, three distinct ids, correct order.
270
+ const dupBefore = await frameFor([
271
+ { id: 'dup', label: 'A' },
272
+ { id: 'other', label: 'B' },
273
+ { id: 'dup', label: 'C' },
274
+ ])
275
+ const dupAfter = await frameFor([
276
+ { id: 'other', label: 'B' },
277
+ { id: 'dup', label: 'A' },
278
+ { id: 'dup', label: 'C' },
279
+ ])
280
+ expect(new Set(rowsOf(dupAfter).map((node) => node.id)).size).toBe(3)
281
+
282
+ const defect = await paint(dupBefore, dupAfter)
283
+ expect(defect.expected).toEqual(['B', 'A', 'C'])
284
+ // Actual today: ['A', 'B', 'A', 'C'] — React duplicated the row it could not
285
+ // tell apart, so the browser shows four rows for a three-row frame.
286
+ expect(defect.painted).toEqual(defect.expected)
287
+ })
@@ -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
+ })