@playfast/reform-remote 1.2.0 → 1.3.1
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,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 {
|
|
2
|
-
import {
|
|
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
|
-
|
|
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()
|
package/src/server.test.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
251
|
+
expect(upsertIds(added)).toContain('0.Item.c')
|
|
252
252
|
|
|
253
|
-
await server.invoke('0.Item.
|
|
253
|
+
await server.invoke('0.Item.c:remove', {})
|
|
254
254
|
const removed = await server.renderDiff()
|
|
255
|
-
expect(deleteIds(removed)).toEqual(['0.Item.
|
|
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.
|
|
265
|
+
await server.invoke('0.Item.b:remove', {})
|
|
266
266
|
await server.renderDiff()
|
|
267
|
-
await expect(server.invoke('0.Item.
|
|
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,
|
|
2
|
-
import type
|
|
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
|
|
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
|
+
})
|