@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.
- package/dist/client.d.ts +33 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +97 -0
- package/dist/client.js.map +1 -0
- 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/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/memory.d.ts +7 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +21 -0
- package/dist/memory.js.map +1 -0
- package/dist/react.d.ts +15 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +16 -0
- package/dist/react.js.map +1 -0
- package/dist/server.d.ts +16 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +56 -0
- package/dist/server.js.map +1 -0
- package/dist/transport.d.ts +41 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +157 -0
- package/dist/transport.js.map +1 -0
- package/dist/wireNode.d.ts +24 -0
- package/dist/wireNode.d.ts.map +1 -0
- package/dist/wireNode.js +80 -0
- package/dist/wireNode.js.map +1 -0
- package/dist/wireRender.d.ts +20 -0
- package/dist/wireRender.d.ts.map +1 -0
- package/dist/wireRender.js +118 -0
- package/dist/wireRender.js.map +1 -0
- package/package.json +17 -5
- package/src/client-prop-decode.test.ts +98 -0
- package/src/client.ts +25 -9
- package/src/clientBinding.ts +103 -0
- package/src/counterFixtures.ts +142 -0
- package/src/fixtures.ts +10 -453
- package/src/listFixtures.ts +174 -0
- package/src/optionalFixtures.ts +64 -0
- package/src/react.ts +14 -3
- package/src/server.test.ts +6 -6
- package/src/server.ts +11 -349
- package/src/slottedFixtures.ts +120 -0
- 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/wireNode.ts +153 -0
- 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
|
+
})
|