@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,103 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Match, MutableRef, Option, Predicate } from 'effect'
3
+ import { Wire, type WireTree } from '@playfast/reform/internal'
4
+ import {
5
+ type ClientConfig,
6
+ type RemoteContract,
7
+ type RemoteViewSet,
8
+ renderWireTree,
9
+ } from './client'
10
+ import type { InvokeMessage, RemoteTransport, ServerMessage } from './transport'
11
+
12
+ export interface ClientBinding {
13
+ readonly node: () => ReactNode
14
+ readonly subscribe: (listener: () => void) => () => void
15
+ readonly snapshot: () => WireTree
16
+ // Stop notifying React. The wire stays in sync — see `connect`.
17
+ readonly dispose: () => void
18
+ // Done with the connection: drop the transport subscription too. A binding that
19
+ // has been closed never updates again.
20
+ readonly close: () => void
21
+ }
22
+
23
+ export interface ConnectOptions<C extends RemoteContract> {
24
+ readonly transport: RemoteTransport<InvokeMessage, ServerMessage>
25
+ readonly views: RemoteViewSet<C>
26
+ // How long the transport subscription outlives a `dispose()`. See `dispose` below.
27
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- presence-optional public config; absent falls back to the built-in default
28
+ readonly releaseWindowMs?: number
29
+ }
30
+
31
+ // oxlint-disable-next-line reform-rules/no-magic-numbers -- the window's length is the value being named
32
+ const defaultReleaseWindowMs = 10_000
33
+
34
+ // Browsers hand back a number, which has no `unref`.
35
+ const detachTimer = (handle: unknown): void => {
36
+ if (Predicate.hasProperty(handle, 'unref') && Predicate.isFunction(handle.unref)) {
37
+ handle.unref()
38
+ }
39
+ }
40
+
41
+ export const connect = <C extends RemoteContract>(options: ConnectOptions<C>): ClientBinding => {
42
+ const { transport, views } = options
43
+ const state: { tree: WireTree } = { tree: [] }
44
+ const listeners = new Set<() => void>()
45
+ const config: ClientConfig<C> = {
46
+ views,
47
+ invoke: (handle, payload) => transport.send({ _tag: 'Invoke', handle, payload }),
48
+ }
49
+ const receive = (message: ServerMessage): void => {
50
+ state.tree = Match.value(message).pipe(
51
+ Match.tag('Snapshot', ({ tree }) => tree),
52
+ Match.tag('Patches', ({ patches }) => Wire.apply(state.tree, patches)),
53
+ Match.exhaustive,
54
+ )
55
+ listeners.forEach((listener) => listener())
56
+ }
57
+ // Subscribed for the binding's whole life, not for the span React happens to be
58
+ // listening. Patches are deltas against the server's own baseline and a delete is
59
+ // never re-sent, so a frame missed while detached is a permanent phantom node —
60
+ // and an effect cleanup is not an unmount (StrictMode, <Suspense>, <Activity> all
61
+ // tear effects down and put them back). `dispose` therefore only goes quiet; the
62
+ // transport subscription is released by `close`, which the connection's owner calls.
63
+ const off = transport.onMessage(receive)
64
+ const pending = MutableRef.make(Option.none<ReturnType<typeof setTimeout>>())
65
+ const close = (): void => {
66
+ listeners.clear()
67
+ MutableRef.set(pending, Option.none())
68
+ off()
69
+ }
70
+ // `dispose()` cannot know whether React is unmounting the host or merely hiding it,
71
+ // so it goes quiet and starts a clock. A `subscribe` inside the window is the host
72
+ // coming back and stops it; nothing inside the window means the host is gone, and
73
+ // the transport subscription is released rather than left to accumulate one dead
74
+ // handler per mount/unmount cycle.
75
+ const cancelClose = (): void =>
76
+ Option.match(MutableRef.get(pending), {
77
+ onNone: () => undefined,
78
+ onSome: (handle) => {
79
+ MutableRef.set(pending, Option.none())
80
+ clearTimeout(handle)
81
+ },
82
+ })
83
+ return {
84
+ node: () => renderWireTree(state.tree, config),
85
+ subscribe: (listener) => {
86
+ cancelClose()
87
+ listeners.add(listener)
88
+ return () => void listeners.delete(listener)
89
+ },
90
+ snapshot: () => state.tree,
91
+ dispose: () => {
92
+ listeners.clear()
93
+ if (Option.isSome(MutableRef.get(pending))) {
94
+ return
95
+ }
96
+ // oxlint-disable-next-line reform-rules/no-set-timeout-interval -- must be unref-able; a binding waiting to be collected must not hold the host process open
97
+ const handle = setTimeout(close, options.releaseWindowMs ?? defaultReleaseWindowMs)
98
+ detachTimer(handle)
99
+ MutableRef.set(pending, Option.some(handle))
100
+ },
101
+ close,
102
+ }
103
+ }
package/src/react.ts CHANGED
@@ -1,12 +1,23 @@
1
- import { type ReactNode, useEffect, useState, useSyncExternalStore } from 'react'
2
- import { connect, type InvokeMessage, type RemoteTransport, type ServerMessage } from './transport'
1
+ import { Option } from 'effect'
2
+ import { type ReactNode, useEffect, useRef, useSyncExternalStore } from 'react'
3
+ import {
4
+ type ClientBinding,
5
+ connect,
6
+ type InvokeMessage,
7
+ type RemoteTransport,
8
+ type ServerMessage,
9
+ } from './transport'
3
10
  import type { RemoteContract, RemoteViewSet } from './client'
4
11
 
5
12
  export const useRemoteUI = <C extends RemoteContract>(
6
13
  transport: RemoteTransport<InvokeMessage, ServerMessage>,
7
14
  views: RemoteViewSet<C>,
8
15
  ): ReactNode => {
9
- const [binding] = useState(() => connect({ transport, views }))
16
+ // A ref, not `useState`: StrictMode double-invokes a state initializer, and the
17
+ // binding React discards would keep its transport subscription for good.
18
+ const held = useRef<Option.Option<ClientBinding>>(Option.none())
19
+ const binding = Option.getOrElse(held.current, () => connect({ transport, views }))
20
+ held.current = Option.some(binding)
10
21
  useEffect(() => () => binding.dispose(), [binding])
11
22
  useSyncExternalStore(binding.subscribe, binding.snapshot, binding.snapshot)
12
23
  return binding.node()
@@ -231,7 +231,7 @@ test('a list renders one child per item under a single slot, in order', async ()
231
231
  expect(byId(tree, '0.Bar.0')!.slot).toBe('Bar')
232
232
 
233
233
  const items = Wire.childrenOf(tree, '0').filter((node) => node.slot === 'Item')
234
- expect(items.map((node) => node.id)).toEqual(['0.Item.0', '0.Item.1'])
234
+ expect(items.map((node) => node.id)).toEqual(['0.Item.a', '0.Item.b'])
235
235
  expect(items.map((node) => node.childIndex)).toEqual([0, 1])
236
236
  expect(dataOf(items[0]!, 'label')).toMatchObject({ value: 'A' })
237
237
  expect(dataOf(items[1]!, 'label')).toMatchObject({ value: 'B' })
@@ -248,11 +248,11 @@ test('adding an item emits an Upsert for the new node; removing emits a Delete',
248
248
  await server.invoke('0.Bar.0:add', { id: 'c', label: 'C' })
249
249
  const added = await server.renderDiff()
250
250
  expect(deleteIds(added)).toEqual([])
251
- expect(upsertIds(added)).toContain('0.Item.2')
251
+ expect(upsertIds(added)).toContain('0.Item.c')
252
252
 
253
- await server.invoke('0.Item.2:remove', {})
253
+ await server.invoke('0.Item.c:remove', {})
254
254
  const removed = await server.renderDiff()
255
- expect(deleteIds(removed)).toEqual(['0.Item.2'])
255
+ expect(deleteIds(removed)).toEqual(['0.Item.c'])
256
256
  } finally {
257
257
  await server.dispose()
258
258
  }
@@ -262,9 +262,9 @@ test("a removed node's handle is revoked — a stale client invocation rejects",
262
262
  const server = makeRemoteServer(listScene())
263
263
  try {
264
264
  await server.renderDiff()
265
- await server.invoke('0.Item.1:remove', {})
265
+ await server.invoke('0.Item.b:remove', {})
266
266
  await server.renderDiff()
267
- await expect(server.invoke('0.Item.1:remove', {})).rejects.toBeDefined()
267
+ await expect(server.invoke('0.Item.b:remove', {})).rejects.toBeDefined()
268
268
  } finally {
269
269
  await server.dispose()
270
270
  }
package/src/transport.ts CHANGED
@@ -1,13 +1,6 @@
1
- import { Effect, Fiber, Match, Option } from 'effect'
2
- import type { ReactNode } from 'react'
3
- import { type AnyScene, Wire, type WirePatch, type WireTree } from '@playfast/reform/internal'
1
+ import { Effect, Fiber, Option } from 'effect'
2
+ import { type AnyScene, type WirePatch, type WireTree } from '@playfast/reform/internal'
4
3
  import { makeRemoteServer } from './server'
5
- import {
6
- renderWireTree,
7
- type ClientConfig,
8
- type RemoteContract,
9
- type RemoteViewSet,
10
- } from './client'
11
4
 
12
5
  export interface SnapshotMessage {
13
6
  readonly _tag: 'Snapshot'
@@ -108,6 +101,12 @@ export const serve = (options: ServeOptions): ServerBinding => {
108
101
  void Effect.runPromise(
109
102
  Effect.promise(() => server.invoke(message.handle, message.payload)).pipe(
110
103
  Effect.zipRight(Effect.promise(push)),
104
+ // A stale handle (its node was deleted by a patch still in flight) fails by
105
+ // design. Left unhandled that escapes as a rejected promise and, under Node's
106
+ // default --unhandled-rejections=throw, takes the server down for every client.
107
+ Effect.catchAllCause((cause) =>
108
+ Effect.logError(`reform-remote: invoke '${message.handle}' failed`, cause),
109
+ ),
111
110
  ),
112
111
  )
113
112
  })
@@ -215,6 +214,12 @@ export const serveShared = (options: ServeSharedOptions): SharedServerBinding =>
215
214
  void Effect.runPromise(
216
215
  Effect.promise(() => server.invoke(message.handle, message.payload)).pipe(
217
216
  Effect.zipRight(Effect.promise(push)),
217
+ // A stale handle (its node was deleted by a patch still in flight) fails by
218
+ // design. Left unhandled that escapes as a rejected promise and, under Node's
219
+ // default --unhandled-rejections=throw, takes the server down for every client.
220
+ Effect.catchAllCause((cause) =>
221
+ Effect.logError(`reform-remote: invoke '${message.handle}' failed`, cause),
222
+ ),
218
223
  ),
219
224
  )
220
225
  })
@@ -250,41 +255,4 @@ export const serveShared = (options: ServeSharedOptions): SharedServerBinding =>
250
255
  }
251
256
  }
252
257
 
253
- export interface ClientBinding {
254
- readonly node: () => ReactNode
255
- readonly subscribe: (listener: () => void) => () => void
256
- readonly snapshot: () => WireTree
257
- readonly dispose: () => void
258
- }
259
-
260
- export interface ConnectOptions<C extends RemoteContract> {
261
- readonly transport: RemoteTransport<InvokeMessage, ServerMessage>
262
- readonly views: RemoteViewSet<C>
263
- }
264
-
265
- export const connect = <C extends RemoteContract>(options: ConnectOptions<C>): ClientBinding => {
266
- const { transport, views } = options
267
- const state: { tree: WireTree } = { tree: [] }
268
- const listeners = new Set<() => void>()
269
- const config: ClientConfig<C> = {
270
- views,
271
- invoke: (handle, payload) => transport.send({ _tag: 'Invoke', handle, payload }),
272
- }
273
- const off = transport.onMessage((message) => {
274
- state.tree = Match.value(message).pipe(
275
- Match.tag('Snapshot', ({ tree }) => tree),
276
- Match.tag('Patches', ({ patches }) => Wire.apply(state.tree, patches)),
277
- Match.exhaustive,
278
- )
279
- listeners.forEach((listener) => listener())
280
- })
281
- return {
282
- node: () => renderWireTree(state.tree, config),
283
- subscribe: (listener) => {
284
- listeners.add(listener)
285
- return () => void listeners.delete(listener)
286
- },
287
- snapshot: () => state.tree,
288
- dispose: off,
289
- }
290
- }
258
+ export { type ClientBinding, connect, type ConnectOptions } from './clientBinding'
@@ -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
+ })