@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,434 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// Two root causes, three proofs. BUG 1 and BUG 2 are the same defect seen from
|
|
4
|
+
// two ends; BUG 3 is independent.
|
|
5
|
+
//
|
|
6
|
+
// ---- BUGS 1 & 2 ----
|
|
7
|
+
//
|
|
8
|
+
// A wire node's id is minted from its POSITION —
|
|
9
|
+
// `${parent.id}.${slotName}.${index}` (wireRender.ts:104 / :116) — while its
|
|
10
|
+
// semantic identity travels beside it in `WireNode.key`, which `each({ key })`
|
|
11
|
+
// fills in and `Wire.diff` already compares (wire/tree.ts:79). Everything
|
|
12
|
+
// downstream is then keyed off the positional id rather than off that identity:
|
|
13
|
+
//
|
|
14
|
+
// * a trigger handle is `${mounted.id}:${eventName}` (wireNode.ts:102), so a
|
|
15
|
+
// handle survives the item it was minted for and re-points at whoever slid
|
|
16
|
+
// into that slot position;
|
|
17
|
+
// * the remote client reconciles React by `child.key ?? child.id`
|
|
18
|
+
// (client.ts:160), so a child with no key of its own — every `one(...)`
|
|
19
|
+
// fill — inherits its ancestors' positions and remounts when they move.
|
|
20
|
+
//
|
|
21
|
+
// Nothing here fixes anything. Every test asserts behaviour the package already
|
|
22
|
+
// promises elsewhere, and every one currently FAILS.
|
|
23
|
+
import { act, createElement, useState } from 'react'
|
|
24
|
+
import { createRoot } from 'react-dom/client'
|
|
25
|
+
import { expect, test } from 'vitest'
|
|
26
|
+
import { Data, Layer, Schema as S } from 'effect'
|
|
27
|
+
import {
|
|
28
|
+
Composition,
|
|
29
|
+
Engine,
|
|
30
|
+
Event,
|
|
31
|
+
Reducer,
|
|
32
|
+
State,
|
|
33
|
+
Ui,
|
|
34
|
+
each,
|
|
35
|
+
mount,
|
|
36
|
+
one,
|
|
37
|
+
provide,
|
|
38
|
+
scene,
|
|
39
|
+
slot,
|
|
40
|
+
ui,
|
|
41
|
+
} from '@playfast/reform'
|
|
42
|
+
import { Wire, type WireNode, type WireTree } from '@playfast/reform/internal'
|
|
43
|
+
import { makeRemoteServer } from './server'
|
|
44
|
+
import { remoteViews, renderWireTree } from './client'
|
|
45
|
+
import { listScene } from './fixtures'
|
|
46
|
+
|
|
47
|
+
// act() only flushes effects when the environment opts in; without this the two
|
|
48
|
+
// renders below can report a tree React has not finished committing.
|
|
49
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
50
|
+
|
|
51
|
+
const MissingBase: new (args: { readonly detail: string }) => Error & {
|
|
52
|
+
readonly _tag: 'hunt/Missing'
|
|
53
|
+
} & Readonly<{ readonly detail: string }> = Data.TaggedError('hunt/Missing')<{
|
|
54
|
+
readonly detail: string
|
|
55
|
+
}>
|
|
56
|
+
|
|
57
|
+
class Missing extends MissingBase {
|
|
58
|
+
override get message(): string {
|
|
59
|
+
return `wire-hunt: ${this.detail}`
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// BUG 1 — a trigger handle outlives the item it was minted for.
|
|
65
|
+
//
|
|
66
|
+
// `renderDiff` revokes the handles of nodes that VANISH (server.ts:144), which
|
|
67
|
+
// is what makes the playbook's "deleted nodes' handles are revoked, so a stale
|
|
68
|
+
// client invoke fails cleanly" true for the tail of a list. An interior removal
|
|
69
|
+
// deletes only the LAST position: every row after the removed one slides down
|
|
70
|
+
// one slot, so its node id — and therefore its handle — is silently reissued to
|
|
71
|
+
// a different item. `WireNode.key` records that the occupant changed, and
|
|
72
|
+
// `Wire.diff` even ships that change to the client, but the registry entry is
|
|
73
|
+
// just overwritten in place (wire/triggers.ts:52).
|
|
74
|
+
//
|
|
75
|
+
// The window is one round trip wide and opens on every background change: a
|
|
76
|
+
// procedure completing, another session on a `serveShared` runtime, a push. The
|
|
77
|
+
// invoke that loses the race does not fail — it deletes somebody else's row.
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
const rowsOf = (tree: WireTree): WireTree => tree.filter((node) => node.slot === 'Item')
|
|
81
|
+
|
|
82
|
+
const labelsOf = (tree: WireTree): ReadonlyArray<unknown> =>
|
|
83
|
+
rowsOf(tree).map((node) => {
|
|
84
|
+
const prop = node.props.find(
|
|
85
|
+
(candidate) => candidate._tag === 'Data' && candidate.name === 'label',
|
|
86
|
+
)
|
|
87
|
+
return prop !== undefined && 'value' in prop ? prop.value : undefined
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const rowWithKey = (tree: WireTree, key: string): WireNode => {
|
|
91
|
+
const found = rowsOf(tree).find((node) => node.key === key)
|
|
92
|
+
if (found === undefined) {
|
|
93
|
+
throw new Missing({ detail: `no row keyed '${key}'` })
|
|
94
|
+
}
|
|
95
|
+
return found
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const handleFor = (node: WireNode, event: string): string => {
|
|
99
|
+
const prop = node.props.find(
|
|
100
|
+
(candidate) => candidate._tag === 'Event' && candidate.name === event,
|
|
101
|
+
)
|
|
102
|
+
if (prop === undefined || !('handle' in prop)) {
|
|
103
|
+
throw new Missing({ detail: `node ${node.id} exposes no '${event}' handle` })
|
|
104
|
+
}
|
|
105
|
+
return prop.handle
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const seed = [
|
|
109
|
+
{ id: 'a', label: 'A' },
|
|
110
|
+
{ id: 'b', label: 'B' },
|
|
111
|
+
{ id: 'c', label: 'C' },
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
test('a trigger handle keeps firing the item it was minted for', async () => {
|
|
115
|
+
// ---- CONTROL — the handle really is B's, and against a synced tree it removes B.
|
|
116
|
+
const control = makeRemoteServer(listScene(seed))
|
|
117
|
+
try {
|
|
118
|
+
const controlFrame = await control.render()
|
|
119
|
+
await control.invoke(handleFor(rowWithKey(controlFrame, 'b'), 'remove'), {})
|
|
120
|
+
expect(labelsOf(await control.render())).toEqual(['A', 'C'])
|
|
121
|
+
} finally {
|
|
122
|
+
await control.dispose()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---- DEFECT
|
|
126
|
+
const server = makeRemoteServer(listScene(seed))
|
|
127
|
+
try {
|
|
128
|
+
const frame0 = await server.render()
|
|
129
|
+
// The handle a connected client is holding for row 'b' once this frame paints.
|
|
130
|
+
const clickB = handleFor(rowWithKey(frame0, 'b'), 'remove')
|
|
131
|
+
|
|
132
|
+
// A change this client did not cause drops row 'a' while its click is in flight.
|
|
133
|
+
// Rows 'b' and 'c' are untouched — they only slide down one slot position.
|
|
134
|
+
await server.invoke(handleFor(rowWithKey(frame0, 'a'), 'remove'), {})
|
|
135
|
+
const frame1 = Wire.apply(frame0, await server.renderDiff())
|
|
136
|
+
expect(rowsOf(frame1).map((node) => node.key)).toEqual(['b', 'c'])
|
|
137
|
+
|
|
138
|
+
// Now the in-flight click on 'b' lands. It must remove 'b' — or fail cleanly, the
|
|
139
|
+
// way the playbook says a stale handle does. What it must never do is delete a row
|
|
140
|
+
// the click was not aimed at.
|
|
141
|
+
// Actual today: 'C' is gone and 'B', the row that was clicked, survives — the
|
|
142
|
+
// handle was silently reissued to whoever slid into slot position 1.
|
|
143
|
+
await server.invoke(clickB, {})
|
|
144
|
+
expect(labelsOf(await server.render())).toContain('C')
|
|
145
|
+
} finally {
|
|
146
|
+
await server.dispose()
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// BUG 2 — reordering a keyed list remounts every un-keyed descendant.
|
|
152
|
+
//
|
|
153
|
+
// The client keys React by `child.key ?? child.id` (client.ts:160) so that a
|
|
154
|
+
// keyed row survives a reorder. A `one(...)` fill carries no key
|
|
155
|
+
// (wireRender.ts:120 `key: Option.none()`), so its React key falls back to the
|
|
156
|
+
// node id — which embeds the id of every ancestor, and therefore their
|
|
157
|
+
// POSITIONS. Move a row and its child's key changes from `0.Row.0.Detail.0` to
|
|
158
|
+
// `0.Row.1.Detail.0`, so React tears the whole subtree down and rebuilds it:
|
|
159
|
+
// local state, DOM focus, scroll offsets, uncontrolled input values.
|
|
160
|
+
//
|
|
161
|
+
// `@playfast/reform-react` renders the identical scene without that loss — it
|
|
162
|
+
// keys a `One` fill `${slotName}.0` (packages/react/src/structure.ts:89), a key
|
|
163
|
+
// relative to the parent that a reorder cannot disturb.
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
const RowValue = S.Struct({ id: S.String, label: S.String })
|
|
167
|
+
|
|
168
|
+
class Rows extends State.make('hunt.rows', S.Array(RowValue)) {}
|
|
169
|
+
class Reversed extends Event.make('hunt.Reversed', S.Struct({})) {}
|
|
170
|
+
class ReverseRows extends Reducer.make('hunt.ReverseRows', {
|
|
171
|
+
states: [Rows],
|
|
172
|
+
events: [Reversed],
|
|
173
|
+
}) {}
|
|
174
|
+
|
|
175
|
+
class DetailUi extends ui('HuntDetail', { props: S.Struct({ label: S.String }) }) {}
|
|
176
|
+
class DetailComp extends Composition.make('HuntDetail', {
|
|
177
|
+
title: 'HuntDetail',
|
|
178
|
+
ui: DetailUi,
|
|
179
|
+
props: S.Struct({ label: S.String }),
|
|
180
|
+
})<DetailComp>() {}
|
|
181
|
+
class DetailSlot extends slot('Detail')<DetailSlot, typeof DetailComp>() {}
|
|
182
|
+
|
|
183
|
+
class RowUi extends ui('HuntRow', {
|
|
184
|
+
props: S.Struct({ label: S.String }),
|
|
185
|
+
slots: { Detail: DetailSlot },
|
|
186
|
+
}) {}
|
|
187
|
+
class RowComp extends Composition.make('HuntRow', {
|
|
188
|
+
title: 'HuntRow',
|
|
189
|
+
ui: RowUi,
|
|
190
|
+
props: RowValue,
|
|
191
|
+
slots: { Detail: DetailSlot },
|
|
192
|
+
})<RowComp>() {}
|
|
193
|
+
class RowSlot extends slot('Row')<RowSlot, typeof RowComp>() {}
|
|
194
|
+
|
|
195
|
+
class ListUi extends ui('HuntList', {
|
|
196
|
+
props: S.Struct({}),
|
|
197
|
+
events: { reverse: S.Struct({}) },
|
|
198
|
+
slots: { Row: RowSlot },
|
|
199
|
+
}) {}
|
|
200
|
+
class ListComp extends Composition.make('HuntList', {
|
|
201
|
+
title: 'HuntList',
|
|
202
|
+
ui: ListUi,
|
|
203
|
+
slots: { Row: RowSlot },
|
|
204
|
+
states: [Rows],
|
|
205
|
+
})<ListComp>() {}
|
|
206
|
+
|
|
207
|
+
const nestedScene = (initial: ReadonlyArray<{ readonly id: string; readonly label: string }>) => {
|
|
208
|
+
const presentation = Layer.mergeAll(
|
|
209
|
+
provide(
|
|
210
|
+
ListUi,
|
|
211
|
+
Ui.make(ListUi, () => null),
|
|
212
|
+
),
|
|
213
|
+
provide(
|
|
214
|
+
RowUi,
|
|
215
|
+
Ui.make(RowUi, () => null),
|
|
216
|
+
),
|
|
217
|
+
provide(
|
|
218
|
+
DetailUi,
|
|
219
|
+
Ui.make(DetailUi, () => null),
|
|
220
|
+
),
|
|
221
|
+
provide(RowSlot, RowComp),
|
|
222
|
+
provide(DetailSlot, DetailComp),
|
|
223
|
+
State.live(Rows, initial),
|
|
224
|
+
)
|
|
225
|
+
const app = Layer.mergeAll(
|
|
226
|
+
Composition.live(ListComp, function* () {
|
|
227
|
+
const rows = yield* Rows
|
|
228
|
+
const reverse = yield* Event.trigger(Reversed)
|
|
229
|
+
return mount({
|
|
230
|
+
props: {},
|
|
231
|
+
events: { reverse },
|
|
232
|
+
slots: {
|
|
233
|
+
Row: each(rows, {
|
|
234
|
+
key: (entry) => entry.id,
|
|
235
|
+
props: (entry) => ({ id: entry.id, label: entry.label }),
|
|
236
|
+
}),
|
|
237
|
+
},
|
|
238
|
+
})
|
|
239
|
+
}),
|
|
240
|
+
Composition.live(RowComp, function* () {
|
|
241
|
+
const rowProps = yield* RowComp.props
|
|
242
|
+
return mount({
|
|
243
|
+
props: { label: rowProps.label },
|
|
244
|
+
slots: { Detail: one({ label: rowProps.label }) },
|
|
245
|
+
})
|
|
246
|
+
}),
|
|
247
|
+
Composition.live(DetailComp, function* () {
|
|
248
|
+
const detailProps = yield* DetailComp.props
|
|
249
|
+
return mount({ props: { label: detailProps.label }, slots: {} })
|
|
250
|
+
}),
|
|
251
|
+
Reducer.live(ReverseRows, (current) => [...current].reverse()),
|
|
252
|
+
).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
|
|
253
|
+
return scene(ListComp, { provide: [app] })
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
test('reordering a keyed list keeps every descendant mounted', async () => {
|
|
257
|
+
const server = makeRemoteServer(nestedScene(seed))
|
|
258
|
+
const rowMounts = { n: 0 }
|
|
259
|
+
const detailMounts = { n: 0 }
|
|
260
|
+
const views = remoteViews<{
|
|
261
|
+
HuntList: typeof ListUi
|
|
262
|
+
HuntRow: typeof RowUi
|
|
263
|
+
HuntDetail: typeof DetailUi
|
|
264
|
+
}>({
|
|
265
|
+
HuntList: Ui.make(ListUi, (_props, slots) =>
|
|
266
|
+
createElement('div', null, createElement(slots.Row)),
|
|
267
|
+
),
|
|
268
|
+
// Per-row client state: which instance is rendering this row?
|
|
269
|
+
HuntRow: Ui.make(RowUi, ({ label }, slots) => {
|
|
270
|
+
const [instance] = useState(() => {
|
|
271
|
+
rowMounts.n += 1
|
|
272
|
+
return `row-${rowMounts.n}`
|
|
273
|
+
})
|
|
274
|
+
return createElement(
|
|
275
|
+
'div',
|
|
276
|
+
{ 'data-row': label, 'data-row-instance': instance },
|
|
277
|
+
createElement(slots.Detail),
|
|
278
|
+
)
|
|
279
|
+
}),
|
|
280
|
+
// …and the same question one level below, in the row's un-keyed `one(...)` child.
|
|
281
|
+
HuntDetail: Ui.make(DetailUi, ({ label }) => {
|
|
282
|
+
const [instance] = useState(() => {
|
|
283
|
+
detailMounts.n += 1
|
|
284
|
+
return `detail-${detailMounts.n}`
|
|
285
|
+
})
|
|
286
|
+
return createElement('span', { 'data-detail': label, 'data-detail-instance': instance })
|
|
287
|
+
}),
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
const container = document.createElement('div')
|
|
291
|
+
document.body.appendChild(container)
|
|
292
|
+
const root = createRoot(container)
|
|
293
|
+
const ownersOf = (attribute: string, instance: string): Record<string, string> =>
|
|
294
|
+
Object.fromEntries(
|
|
295
|
+
[...container.querySelectorAll(`[${attribute}]`)].map((element) => [
|
|
296
|
+
element.getAttribute(attribute) ?? '',
|
|
297
|
+
element.getAttribute(instance) ?? '',
|
|
298
|
+
]),
|
|
299
|
+
)
|
|
300
|
+
const rowOwners = (): Record<string, string> => ownersOf('data-row', 'data-row-instance')
|
|
301
|
+
const detailOwners = (): Record<string, string> => ownersOf('data-detail', 'data-detail-instance')
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const before = await server.render()
|
|
305
|
+
await act(async () => {
|
|
306
|
+
root.render(renderWireTree(before, { views, invoke: () => undefined }))
|
|
307
|
+
})
|
|
308
|
+
const rowsBefore = rowOwners()
|
|
309
|
+
const detailsBefore = detailOwners()
|
|
310
|
+
expect(rowsBefore).toEqual({ A: 'row-1', B: 'row-2', C: 'row-3' })
|
|
311
|
+
expect(detailsBefore).toEqual({ A: 'detail-1', B: 'detail-2', C: 'detail-3' })
|
|
312
|
+
|
|
313
|
+
// Same three rows, reversed. Every row keeps its key; only positions move.
|
|
314
|
+
await server.invoke('0:reverse', {})
|
|
315
|
+
const after = await server.render()
|
|
316
|
+
// The server kept every row's identity; only the positional ids moved.
|
|
317
|
+
expect(after.filter((node) => node.slot === 'Row').map((node) => node.key)).toEqual([
|
|
318
|
+
'c',
|
|
319
|
+
'b',
|
|
320
|
+
'a',
|
|
321
|
+
])
|
|
322
|
+
expect(after.filter((node) => node.slot === 'Detail').map((node) => node.key)).toEqual([
|
|
323
|
+
null,
|
|
324
|
+
null,
|
|
325
|
+
null,
|
|
326
|
+
])
|
|
327
|
+
await act(async () => {
|
|
328
|
+
root.render(renderWireTree(after, { views, invoke: () => undefined }))
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
// The reorder landed…
|
|
332
|
+
expect(Object.keys(rowOwners())).toEqual(['C', 'B', 'A'])
|
|
333
|
+
// ---- CONTROL — the keyed layer behaves: every row is still its own instance,
|
|
334
|
+
// so React really did reconcile by key and this harness can see it.
|
|
335
|
+
expect(rowMounts.n).toBe(3)
|
|
336
|
+
expect(rowOwners()).toEqual(rowsBefore)
|
|
337
|
+
// ---- DEFECT — one level down, nothing moved and nothing changed, yet the two
|
|
338
|
+
// rows that changed position had their children destroyed and rebuilt.
|
|
339
|
+
// Actual today: A→detail-5, C→detail-4, and detailMounts.n === 5.
|
|
340
|
+
expect(detailOwners()).toEqual(detailsBefore)
|
|
341
|
+
expect(detailMounts.n).toBe(3)
|
|
342
|
+
} finally {
|
|
343
|
+
await act(async () => {
|
|
344
|
+
root.unmount()
|
|
345
|
+
})
|
|
346
|
+
await server.dispose()
|
|
347
|
+
}
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// BUG 3 — a view's contract binding lives ON the function, so two contracts
|
|
352
|
+
// sharing one implementation collapse onto whichever was made last.
|
|
353
|
+
//
|
|
354
|
+
// `Ui.make` attaches its reflection statics with `Object.assign(view, …)`
|
|
355
|
+
// (compose/ui.ts:230) — it MUTATES the function it was handed and returns that
|
|
356
|
+
// same object. Make two contracts from one function value and the second
|
|
357
|
+
// `Ui.make` overwrites `[UiViewContract]` on the object the first one also
|
|
358
|
+
// returned, so both `MadeView`s now report the second contract.
|
|
359
|
+
//
|
|
360
|
+
// `remoteViews` builds its registry by asking each view which contract it
|
|
361
|
+
// implements and keying by that contract's name (client.ts:64, :88). Two views
|
|
362
|
+
// that both answer "Footer" produce ONE entry, and `renderWireTree` resolves a
|
|
363
|
+
// node by `config.views[node.name]` and returns `null` when it misses
|
|
364
|
+
// (client.ts:120-122) — so the view that lost its identity renders nothing at all,
|
|
365
|
+
// with no error anywhere.
|
|
366
|
+
//
|
|
367
|
+
// Sharing a trivial view function is not exotic: the repo's own house rule asks
|
|
368
|
+
// for hook-free view shells, and hoisting one `const shell = () => …` out of two
|
|
369
|
+
// `Ui.make` calls is the obvious next edit.
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
class HeaderUi extends ui('HuntHeader', { props: S.Struct({ label: S.String }) }) {}
|
|
373
|
+
class FooterUi extends ui('HuntFooter', { props: S.Struct({ label: S.String }) }) {}
|
|
374
|
+
|
|
375
|
+
type ChromeContract = { HuntHeader: typeof HeaderUi; HuntFooter: typeof FooterUi }
|
|
376
|
+
|
|
377
|
+
const chromeNode = (name: string, label: string): WireNode => ({
|
|
378
|
+
id: name,
|
|
379
|
+
name,
|
|
380
|
+
parentId: null,
|
|
381
|
+
childIndex: name === 'HuntHeader' ? 0 : 1,
|
|
382
|
+
slot: null,
|
|
383
|
+
key: null,
|
|
384
|
+
props: [{ _tag: 'Data', name: 'label', value: label }],
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
const chromeTree: WireTree = [chromeNode('HuntHeader', 'top'), chromeNode('HuntFooter', 'bottom')]
|
|
388
|
+
|
|
389
|
+
const paint = async (views: ReturnType<typeof remoteViews<ChromeContract>>): Promise<string> => {
|
|
390
|
+
const container = document.createElement('div')
|
|
391
|
+
document.body.appendChild(container)
|
|
392
|
+
const root = createRoot(container)
|
|
393
|
+
await act(async () => {
|
|
394
|
+
root.render(renderWireTree(chromeTree, { views, invoke: () => undefined }))
|
|
395
|
+
})
|
|
396
|
+
const painted = [...container.querySelectorAll('[data-shell]')]
|
|
397
|
+
.map((element) => element.getAttribute('data-shell') ?? '')
|
|
398
|
+
.join(',')
|
|
399
|
+
await act(async () => {
|
|
400
|
+
root.unmount()
|
|
401
|
+
})
|
|
402
|
+
return painted
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
test('two contracts can share one view function without erasing each other', async () => {
|
|
406
|
+
const renderShell = ({ label }: { readonly label: string }): ReturnType<typeof createElement> =>
|
|
407
|
+
createElement('i', { 'data-shell': label })
|
|
408
|
+
|
|
409
|
+
// ---- CONTROL — two separate function values with identical bodies. Both
|
|
410
|
+
// contracts keep their own identity and both nodes paint.
|
|
411
|
+
expect(
|
|
412
|
+
await paint(
|
|
413
|
+
remoteViews<ChromeContract>({
|
|
414
|
+
HuntHeader: Ui.make(HeaderUi, (props) => renderShell(props)),
|
|
415
|
+
HuntFooter: Ui.make(FooterUi, (props) => renderShell(props)),
|
|
416
|
+
}),
|
|
417
|
+
),
|
|
418
|
+
).toBe('top,bottom')
|
|
419
|
+
|
|
420
|
+
// ---- DEFECT — the same body, hoisted to one shared value.
|
|
421
|
+
// Actual today: 'bottom' — `Ui.make(FooterUi, …)` rebranded the object
|
|
422
|
+
// `Ui.make(HeaderUi, …)` had already returned, the registry holds a single
|
|
423
|
+
// 'HuntFooter' entry, and the header node silently resolves to no view.
|
|
424
|
+
const shell = (props: { readonly label: string }): ReturnType<typeof createElement> =>
|
|
425
|
+
renderShell(props)
|
|
426
|
+
expect(
|
|
427
|
+
await paint(
|
|
428
|
+
remoteViews<ChromeContract>({
|
|
429
|
+
HuntHeader: Ui.make(HeaderUi, shell),
|
|
430
|
+
HuntFooter: Ui.make(FooterUi, shell),
|
|
431
|
+
}),
|
|
432
|
+
),
|
|
433
|
+
).toBe('top,bottom')
|
|
434
|
+
})
|
package/src/wireRender.ts
CHANGED
|
@@ -96,19 +96,36 @@ export const enqueueStructureSlot = (
|
|
|
96
96
|
fill: SlotFill<unknown>,
|
|
97
97
|
): ReadonlyArray<Mounted> =>
|
|
98
98
|
Match.value(fill).pipe(
|
|
99
|
-
Match.tag('Each', (each) =>
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
Match.tag('Each', (each) => {
|
|
100
|
+
// Keyed, not positional: a trigger handle embeds the node id, so an id built from
|
|
101
|
+
// the index silently re-points a handle the client already holds at whatever row
|
|
102
|
+
// slid into that slot. An accidental duplicate key falls back to the index rather
|
|
103
|
+
// than minting two nodes with one id.
|
|
104
|
+
const used = new Set<string>()
|
|
105
|
+
// The disambiguator is retried rather than assumed free: a key that literally ends
|
|
106
|
+
// in `#<n>` can already hold the string a later repeat would mint, and two nodes
|
|
107
|
+
// sharing an id costs a whole row on the wire.
|
|
108
|
+
const unusedId = (base: string): string => {
|
|
109
|
+
const attempt = (round: number): string => {
|
|
110
|
+
const candidate = round === 0 ? base : `${base}#${round}`
|
|
111
|
+
return used.has(candidate) ? attempt(round + 1) : candidate
|
|
112
|
+
}
|
|
113
|
+
return attempt(0)
|
|
114
|
+
}
|
|
115
|
+
return each.items.map((entry, index): Mounted => {
|
|
116
|
+
const id = unusedId(`${parent.id}.${slotName}.${entry.key ?? index}`)
|
|
117
|
+
used.add(id)
|
|
118
|
+
return {
|
|
102
119
|
comp: child,
|
|
103
120
|
props: entry.props,
|
|
104
|
-
id
|
|
121
|
+
id,
|
|
105
122
|
parentId: Option.some(parent.id),
|
|
106
123
|
slot: Option.some(slotName),
|
|
107
124
|
childIndex: index,
|
|
108
125
|
key: Option.fromNullable(entry.key),
|
|
109
|
-
}
|
|
110
|
-
)
|
|
111
|
-
),
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
}),
|
|
112
129
|
Match.tag('One', (single) => [
|
|
113
130
|
{
|
|
114
131
|
comp: child,
|
|
@@ -131,8 +148,9 @@ export type SlotProvider<Services> = (
|
|
|
131
148
|
export const collect: <Services>(
|
|
132
149
|
root: AnyComposition,
|
|
133
150
|
provideSlot: SlotProvider<Services>,
|
|
134
|
-
) => Effect.Effect<Map<AnySlot, AnyComposition>, never, Services> = Effect.fn('collect')(
|
|
135
|
-
|
|
151
|
+
) => Effect.Effect<Map<AnySlot, AnyComposition>, never, Services> = Effect.fn('collect')(function* <
|
|
152
|
+
Services,
|
|
153
|
+
>(
|
|
136
154
|
root: AnyComposition,
|
|
137
155
|
provideSlot: SlotProvider<Services>,
|
|
138
156
|
): Effect.fn.Return<Map<AnySlot, AnyComposition>, never, Services> {
|
|
@@ -209,27 +227,23 @@ export const renderSceneWith: <SlotServices, CompositionServices>(
|
|
|
209
227
|
options: RenderSceneToWireOptions | undefined,
|
|
210
228
|
provideSlot: SlotProvider<SlotServices>,
|
|
211
229
|
render: CompositionRenderer<CompositionServices>,
|
|
212
|
-
) => Effect.Effect<
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
key: Option.none(),
|
|
233
|
-
}
|
|
234
|
-
return yield* renderLevel([root], bindings, register, render)
|
|
235
|
-
})
|
|
230
|
+
) => Effect.Effect<WireTree, ParseResult.ParseError, SlotServices | CompositionServices> =
|
|
231
|
+
Effect.fn('renderSceneWith')(function* <SlotServices, CompositionServices>(
|
|
232
|
+
scene: AnyScene,
|
|
233
|
+
options: RenderSceneToWireOptions | undefined,
|
|
234
|
+
provideSlot: SlotProvider<SlotServices>,
|
|
235
|
+
render: CompositionRenderer<CompositionServices>,
|
|
236
|
+
): Effect.fn.Return<WireTree, ParseResult.ParseError, SlotServices | CompositionServices> {
|
|
237
|
+
const register = options?.register ?? noRegister
|
|
238
|
+
const bindings = yield* collect(scene.composition, provideSlot)
|
|
239
|
+
const root: Mounted = {
|
|
240
|
+
comp: scene.composition,
|
|
241
|
+
props: {},
|
|
242
|
+
id: '0',
|
|
243
|
+
parentId: Option.none(),
|
|
244
|
+
slot: Option.none(),
|
|
245
|
+
childIndex: 0,
|
|
246
|
+
key: Option.none(),
|
|
247
|
+
}
|
|
248
|
+
return yield* renderLevel([root], bindings, register, render)
|
|
249
|
+
})
|