@playfast/reform-remote 0.0.2 → 0.0.3
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/package.json +2 -2
- package/src/client-keyed-slot.test.ts +136 -0
- package/src/client-remount.test.ts +127 -0
- package/src/client.ts +284 -0
- package/src/fixtures.ts +246 -0
- package/src/index.ts +49 -0
- package/src/memory.ts +36 -0
- package/src/react.ts +62 -0
- package/src/remote-view.typecheck.ts +75 -0
- package/src/server.test.ts +309 -0
- package/src/server.ts +333 -0
- package/src/transport.test.ts +205 -0
- package/src/transport.ts +192 -0
- package/dist/client.d.ts +0 -127
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js +0 -127
- package/dist/client.js.map +0 -1
- package/dist/index.d.ts +0 -11
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -20
- package/dist/index.js.map +0 -1
- package/dist/memory.d.ts +0 -15
- package/dist/memory.d.ts.map +0 -1
- package/dist/memory.js +0 -21
- package/dist/memory.js.map +0 -1
- package/dist/react.d.ts +0 -38
- package/dist/react.d.ts.map +0 -1
- package/dist/react.js +0 -29
- package/dist/react.js.map +0 -1
- package/dist/server.d.ts +0 -26
- package/dist/server.d.ts.map +0 -1
- package/dist/server.js +0 -206
- package/dist/server.js.map +0 -1
- package/dist/transport.d.ts +0 -69
- package/dist/transport.d.ts.map +0 -1
- package/dist/transport.js +0 -116
- package/dist/transport.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform-remote",
|
|
3
3
|
"playbook": "./playbook",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Run a reform scene's logic on the server and stream its rendered UI to a thin client over any duplex transport.",
|
|
7
7
|
"keywords": [
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"./*": "./src/*.ts"
|
|
29
29
|
},
|
|
30
30
|
"files": [
|
|
31
|
-
"
|
|
31
|
+
"src",
|
|
32
32
|
"README.md"
|
|
33
33
|
],
|
|
34
34
|
"scripts": {
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act, createElement } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { expect, test } from 'vitest'
|
|
5
|
+
import { Schema as S } from 'effect'
|
|
6
|
+
import { Composition, slot, Ui, ui } from '@playfast/reform'
|
|
7
|
+
import type { WireNode } from '@playfast/reform'
|
|
8
|
+
import { remoteViews, renderWireTree, type RemoteViews } from './client'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Regression for the KEYED-SLOT bug ("the sidebar showed every branch row under every repo
|
|
12
|
+
* section"). The client slot thunk renders ALL of a slot's wire children on EACH invocation and
|
|
13
|
+
* ignores call-site props — correct for a singleton slot rendered once, but wrong for a LIST
|
|
14
|
+
* slot a view invokes once PER ITEM (each section did `<slots.Row .../>` per branch), so every
|
|
15
|
+
* call rendered the whole list: N sections × M rows.
|
|
16
|
+
*
|
|
17
|
+
* The fix: a view passes `<slots.Row slotKey={id} />` and the thunk renders only the ONE wire
|
|
18
|
+
* child whose `key` matches (the per-item identity captured server-side from the React `key`).
|
|
19
|
+
* Omitting `slotKey` keeps the render-all-children behaviour (the singleton case). These tests
|
|
20
|
+
* pin both halves directly at the framework boundary, with NO app wiring.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// A list item carrying a `label` so each rendered child is identifiable in the DOM.
|
|
24
|
+
class ItemUi extends ui('Item', { props: S.Struct({ label: S.String }) }) {}
|
|
25
|
+
class ItemComp extends Composition.make('Item', { title: 'Item', ui: ItemUi }) {}
|
|
26
|
+
class ItemSlot extends slot('Item')<typeof ItemComp>() {}
|
|
27
|
+
class ListUi extends ui('List')<{ props: { mode: string }; slots: { Item: ItemSlot } }>() {}
|
|
28
|
+
|
|
29
|
+
const node = (over: Partial<WireNode> & Pick<WireNode, 'id' | 'name'>): WireNode => ({
|
|
30
|
+
parentId: null,
|
|
31
|
+
childIndex: 0,
|
|
32
|
+
slot: null,
|
|
33
|
+
key: null,
|
|
34
|
+
props: [],
|
|
35
|
+
...over,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const ItemView = Ui.make(ItemUi, ({ label }) =>
|
|
39
|
+
createElement('span', { 'data-item': label }, label),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
// Three keyed children (a/b/c) under one `Item` slot — the wire shape the server emits for a
|
|
43
|
+
// list. `mode` switches the parent view between keyed selection and the legacy render-all.
|
|
44
|
+
const tree: ReadonlyArray<WireNode> = [
|
|
45
|
+
node({ id: '0', name: 'List', props: [{ _tag: 'Data', name: 'mode', value: 'keyed' }] }),
|
|
46
|
+
node({
|
|
47
|
+
id: '0.Item.0',
|
|
48
|
+
name: 'Item',
|
|
49
|
+
parentId: '0',
|
|
50
|
+
slot: 'Item',
|
|
51
|
+
key: 'a',
|
|
52
|
+
props: [{ _tag: 'Data', name: 'label', value: 'a' }],
|
|
53
|
+
}),
|
|
54
|
+
node({
|
|
55
|
+
id: '0.Item.1',
|
|
56
|
+
name: 'Item',
|
|
57
|
+
parentId: '0',
|
|
58
|
+
slot: 'Item',
|
|
59
|
+
key: 'b',
|
|
60
|
+
props: [{ _tag: 'Data', name: 'label', value: 'b' }],
|
|
61
|
+
}),
|
|
62
|
+
node({
|
|
63
|
+
id: '0.Item.2',
|
|
64
|
+
name: 'Item',
|
|
65
|
+
parentId: '0',
|
|
66
|
+
slot: 'Item',
|
|
67
|
+
key: 'c',
|
|
68
|
+
props: [{ _tag: 'Data', name: 'label', value: 'c' }],
|
|
69
|
+
}),
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
type Contract = { List: typeof ListUi; Item: typeof ItemUi }
|
|
73
|
+
|
|
74
|
+
const renderWith = async (
|
|
75
|
+
ListView: RemoteViews<Contract>['List'],
|
|
76
|
+
wire: ReadonlyArray<WireNode>,
|
|
77
|
+
): Promise<HTMLDivElement> => {
|
|
78
|
+
const config = {
|
|
79
|
+
invoke: () => Promise.resolve(),
|
|
80
|
+
views: remoteViews<Contract>({ List: ListView, Item: ItemView }),
|
|
81
|
+
}
|
|
82
|
+
const container = document.createElement('div')
|
|
83
|
+
document.body.appendChild(container)
|
|
84
|
+
const root = createRoot(container)
|
|
85
|
+
await act(async () => {
|
|
86
|
+
root.render(renderWireTree(wire, config))
|
|
87
|
+
})
|
|
88
|
+
return container
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
test('a keyed slot renders ONE matching child per call site, not the whole list', async () => {
|
|
92
|
+
// Two "sections", each invoking the SAME `Item` slot once with a different `slotKey` — the
|
|
93
|
+
// exact shape that duplicated rows. Each must render only its own child.
|
|
94
|
+
const ListView = Ui.make(ListUi, (_props, slots) =>
|
|
95
|
+
createElement(
|
|
96
|
+
'div',
|
|
97
|
+
null,
|
|
98
|
+
createElement('section', { 'data-section': 'one' }, createElement(slots.Item, { slotKey: 'a' })),
|
|
99
|
+
createElement('section', { 'data-section': 'two' }, createElement(slots.Item, { slotKey: 'b' })),
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
const container = await renderWith(ListView, tree)
|
|
103
|
+
|
|
104
|
+
// Exactly two items rendered total — NOT 2 sections × 3 children = 6 (the bug).
|
|
105
|
+
expect(container.querySelectorAll('[data-item]').length).toBe(2)
|
|
106
|
+
// Each section shows ONLY its keyed child.
|
|
107
|
+
const one = container.querySelector('[data-section="one"]')
|
|
108
|
+
const two = container.querySelector('[data-section="two"]')
|
|
109
|
+
expect(one?.querySelectorAll('[data-item]').length).toBe(1)
|
|
110
|
+
expect(one?.querySelector('[data-item]')?.getAttribute('data-item')).toBe('a')
|
|
111
|
+
expect(two?.querySelector('[data-item]')?.getAttribute('data-item')).toBe('b')
|
|
112
|
+
// The unreferenced child 'c' is rendered nowhere.
|
|
113
|
+
expect(container.querySelector('[data-item="c"]')).toBeNull()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('an un-keyed slot still renders ALL its children (singleton/back-compat path)', async () => {
|
|
117
|
+
// No `slotKey` → render every child of the slot, unchanged from before the keyed API.
|
|
118
|
+
const ListView = Ui.make(ListUi, (_props, slots) =>
|
|
119
|
+
createElement('div', null, createElement(slots.Item)),
|
|
120
|
+
)
|
|
121
|
+
const container = await renderWith(ListView, tree)
|
|
122
|
+
|
|
123
|
+
expect(container.querySelectorAll('[data-item]').length).toBe(3)
|
|
124
|
+
expect(
|
|
125
|
+
[...container.querySelectorAll('[data-item]')].map((el) => el.getAttribute('data-item')),
|
|
126
|
+
).toEqual(['a', 'b', 'c'])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('a keyed call with no matching child renders nothing (no fallback to the full list)', async () => {
|
|
130
|
+
const ListView = Ui.make(ListUi, (_props, slots) =>
|
|
131
|
+
createElement('div', null, createElement(slots.Item, { slotKey: 'missing' })),
|
|
132
|
+
)
|
|
133
|
+
const container = await renderWith(ListView, tree)
|
|
134
|
+
|
|
135
|
+
expect(container.querySelectorAll('[data-item]').length).toBe(0)
|
|
136
|
+
})
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act, createElement, useState } from 'react'
|
|
3
|
+
import { useSyncExternalStore } from 'react'
|
|
4
|
+
import { createRoot } from 'react-dom/client'
|
|
5
|
+
import { expect, test } from 'vitest'
|
|
6
|
+
import { Schema as S } from 'effect'
|
|
7
|
+
import { Composition, slot, Ui, ui } from '@playfast/reform'
|
|
8
|
+
import type { WireNode } from '@playfast/reform'
|
|
9
|
+
import type { ServerMessage } from './transport'
|
|
10
|
+
import { connect } from './transport'
|
|
11
|
+
import { inMemoryTransportPair } from './memory'
|
|
12
|
+
import { remoteViews } from './client'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Regression for the slot-remount bug: a background patch that re-renders ONLY a parent node
|
|
16
|
+
* must NOT unmount + remount the parent's slot children. Before the fix, `renderWireTree`
|
|
17
|
+
* rebuilt each slot's component closure every render, so a parent rendering a child as
|
|
18
|
+
* `<slots.Child/>` handed React a NEW component type each frame — React then tore down the
|
|
19
|
+
* whole child subtree and rebuilt it, wiping every descendant view's local `useState` (in the
|
|
20
|
+
* app: the onboarding modal's open/step state snapped back, closing the modal on any unrelated
|
|
21
|
+
* background patch once server-initiated streaming started delivering them).
|
|
22
|
+
*
|
|
23
|
+
* This test renders the client into a REAL React root (static markup can't observe fiber
|
|
24
|
+
* preservation), captures the child view's mount-time `useState` id, sends a patch touching
|
|
25
|
+
* only the parent, and asserts the child id is unchanged — i.e. the child fiber survived.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// A child whose CLIENT view owns local state. Each fresh mount picks the next mount id, so a
|
|
29
|
+
// remount is observable as a changed id.
|
|
30
|
+
class ChildUi extends ui('Child', { props: S.Struct({}) }) {}
|
|
31
|
+
class ChildComp extends Composition.make('Child', { title: 'Child', ui: ChildUi }) {}
|
|
32
|
+
class MainSlot extends slot('Main')<typeof ChildComp>() {}
|
|
33
|
+
// A parent carrying a `count` prop (the thing a background patch bumps) and one slot child it
|
|
34
|
+
// renders as `<slots.Main/>` — the component form that triggered the remount.
|
|
35
|
+
class HostUi extends ui('Host')<{ props: { count: number }; slots: { Main: MainSlot } }>() {}
|
|
36
|
+
|
|
37
|
+
const node = (over: Partial<WireNode> & Pick<WireNode, 'id' | 'name'>): WireNode => ({
|
|
38
|
+
parentId: null,
|
|
39
|
+
childIndex: 0,
|
|
40
|
+
slot: null,
|
|
41
|
+
key: null,
|
|
42
|
+
props: [],
|
|
43
|
+
...over,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('a background patch on the PARENT keeps its slot child mounted (local useState survives)', async () => {
|
|
47
|
+
const { server: serverTransport, client: clientTransport } = inMemoryTransportPair<
|
|
48
|
+
ServerMessage,
|
|
49
|
+
unknown
|
|
50
|
+
>()
|
|
51
|
+
|
|
52
|
+
const mounts = { n: 0 }
|
|
53
|
+
const ChildView = Ui.make(ChildUi, () => {
|
|
54
|
+
const [id] = useState(() => {
|
|
55
|
+
mounts.n += 1
|
|
56
|
+
return `mount-${mounts.n}`
|
|
57
|
+
})
|
|
58
|
+
return createElement('span', { 'data-child-id': id }, id)
|
|
59
|
+
})
|
|
60
|
+
const HostView = Ui.make(HostUi, ({ count }, slots) =>
|
|
61
|
+
createElement('div', { 'data-count': String(count) }, createElement(slots.Main)),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
const client = connect({
|
|
65
|
+
transport: clientTransport,
|
|
66
|
+
views: remoteViews<{ Host: typeof HostUi; Child: typeof ChildUi }>({
|
|
67
|
+
Host: HostView,
|
|
68
|
+
Child: ChildView,
|
|
69
|
+
}),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const Root = (): ReturnType<typeof client.node> => {
|
|
73
|
+
useSyncExternalStore(client.subscribe, client.snapshot, client.snapshot)
|
|
74
|
+
return client.node()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const container = document.createElement('div')
|
|
78
|
+
document.body.appendChild(container)
|
|
79
|
+
const root = createRoot(container)
|
|
80
|
+
try {
|
|
81
|
+
await act(async () => {
|
|
82
|
+
root.render(createElement(Root))
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// First frame: a host (count 0) holding one slot child.
|
|
86
|
+
await act(async () => {
|
|
87
|
+
serverTransport.send({
|
|
88
|
+
_tag: 'Snapshot',
|
|
89
|
+
tree: [
|
|
90
|
+
node({ id: '0', name: 'Host', props: [{ _tag: 'Data', name: 'count', value: 0 }] }),
|
|
91
|
+
node({ id: '0.Main.0', name: 'Child', parentId: '0', slot: 'Main' }),
|
|
92
|
+
],
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
expect(container.querySelector('[data-count]')?.getAttribute('data-count')).toBe('0')
|
|
97
|
+
const childIdBefore = container.querySelector('[data-child-id]')?.getAttribute('data-child-id')
|
|
98
|
+
expect(childIdBefore).toBe('mount-1')
|
|
99
|
+
|
|
100
|
+
// A background patch that touches ONLY the host (count 0 → 7). The child node is unchanged
|
|
101
|
+
// and not in the patch at all.
|
|
102
|
+
await act(async () => {
|
|
103
|
+
serverTransport.send({
|
|
104
|
+
_tag: 'Patches',
|
|
105
|
+
patches: [
|
|
106
|
+
{
|
|
107
|
+
_tag: 'Upsert',
|
|
108
|
+
node: node({ id: '0', name: 'Host', props: [{ _tag: 'Data', name: 'count', value: 7 }] }),
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// The parent re-rendered with the new count…
|
|
115
|
+
expect(container.querySelector('[data-count]')?.getAttribute('data-count')).toBe('7')
|
|
116
|
+
// …but the child was NOT remounted: same fiber, same mount id, and no second mount ran.
|
|
117
|
+
expect(container.querySelector('[data-child-id]')?.getAttribute('data-child-id')).toBe(
|
|
118
|
+
childIdBefore,
|
|
119
|
+
)
|
|
120
|
+
expect(mounts.n).toBe(1)
|
|
121
|
+
} finally {
|
|
122
|
+
await act(async () => {
|
|
123
|
+
root.unmount()
|
|
124
|
+
})
|
|
125
|
+
client.dispose()
|
|
126
|
+
}
|
|
127
|
+
})
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { createElement, Fragment, type ReactNode, useRef } from 'react'
|
|
2
|
+
import { Schema } from 'effect'
|
|
3
|
+
import {
|
|
4
|
+
type MadeView,
|
|
5
|
+
Ui,
|
|
6
|
+
type UiClass,
|
|
7
|
+
UiViewContract,
|
|
8
|
+
Wire,
|
|
9
|
+
type WireNode,
|
|
10
|
+
type WireTree,
|
|
11
|
+
} from '@playfast/reform'
|
|
12
|
+
|
|
13
|
+
// A `Ui.make` view of any contract. The `any` is the same variance escape the core
|
|
14
|
+
// uses for `Schema<any, any>`: a heterogeneous list of contract-typed views cannot
|
|
15
|
+
// share one element type (function params are contravariant in the contract), and the
|
|
16
|
+
// real contract is recovered at runtime from the view's carried brand.
|
|
17
|
+
type AnyMadeView = MadeView<any>
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The client side of the remote transport: fold the server's patches with
|
|
21
|
+
* `Wire.apply`, then render the resulting `WireTree` with locally registered
|
|
22
|
+
* presentations. The presentations are the SAME `Ui.make` views the local
|
|
23
|
+
* `@playfast/react` renderer uses — one presentation, many consumers (REMOTE_UI.md):
|
|
24
|
+
*
|
|
25
|
+
* - Each wire node's view runs as a stable React COMPONENT, so a view body's own
|
|
26
|
+
* hooks (`useState`, effects) work exactly as under `@playfast/react`.
|
|
27
|
+
* - Slots are COMPONENTS that render that slot's wire children, so a view's
|
|
28
|
+
* `<slots.Foo/>` / `createElement(slots.Foo, props)` works unchanged (the
|
|
29
|
+
* per-slot props were captured server-side; the client component ignores them).
|
|
30
|
+
* - Data props are DECODED through the contract's own props schema — the inverse of
|
|
31
|
+
* the server's `Schema.encodeUnknown` — so `Option`/`Date`/branded props arrive as
|
|
32
|
+
* real instances, not their `{_tag,value}` wire shape.
|
|
33
|
+
*
|
|
34
|
+
* A client view is authored with `Ui.make(SomeUi, …)` — exactly like a local view —
|
|
35
|
+
* and registered with `remoteViews<AppContract>({ Some: SomeView, … })`. There is no
|
|
36
|
+
* separate `remoteView` call: a `Ui.make` view carries its own contract, so `remoteViews`
|
|
37
|
+
* recovers each view's wire name + props schema straight from it.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The dynamic presentation shape the renderer calls — slots and events erased to
|
|
42
|
+
* their runtime form. Views are authored with `Ui.make` (contract-typed); this is
|
|
43
|
+
* only the internal shape `renderWireTree` invokes them through.
|
|
44
|
+
*/
|
|
45
|
+
export type RemoteView = (
|
|
46
|
+
props: Record<string, unknown>,
|
|
47
|
+
// A slot thunk optionally takes `{ slotKey }` to select a single keyed child (see the keyed-
|
|
48
|
+
// slot handling in `WireNodeView`); omitting it renders all of the slot's children.
|
|
49
|
+
slots: Record<string, (slotProps?: { readonly slotKey?: string }) => ReactNode>,
|
|
50
|
+
events: Record<string, (payload: unknown) => void>,
|
|
51
|
+
) => ReactNode
|
|
52
|
+
|
|
53
|
+
/** A contract-bound view paired with the contract name the renderer looks it up by, and
|
|
54
|
+
* (for a WIRED contract) the props schema used to DECODE wire props back into their
|
|
55
|
+
* decoded domain types — the symmetric inverse of the server's `Schema.encodeUnknown`.
|
|
56
|
+
* Without it, an `Option`/`Date`/branded prop would reach the view as its raw encoded
|
|
57
|
+
* shape (e.g. `{_tag:'Some',value}`) instead of a real `Option`. */
|
|
58
|
+
export interface RegisteredRemoteView {
|
|
59
|
+
readonly name: string
|
|
60
|
+
readonly view: RemoteView
|
|
61
|
+
readonly propsSchema?: Schema.Schema<Record<string, unknown>, unknown>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The by-name presentation record the renderer walks — the unbranded runtime shape behind
|
|
65
|
+
* a `RemoteViewSet`. Internal: it is NOT part of the public API, so a raw record of this
|
|
66
|
+
* shape can never be handed to `connect`/`renderWireTree`/`<RemoteUI>` (those demand the
|
|
67
|
+
* branded `RemoteViewSet<C>`). A `RemoteViewSet<C>` is structurally `ViewRegistry & brand`,
|
|
68
|
+
* so it always widens back to this when the renderer needs the plain lookup. */
|
|
69
|
+
type ViewRegistry = Readonly<Record<string, RegisteredRemoteView>>
|
|
70
|
+
|
|
71
|
+
/** Phantom brand carrying the server `RemoteContract` a view set was checked against. Typed
|
|
72
|
+
* as a function OF `C` (never called) so the contract appears in the type WITHOUT needing a
|
|
73
|
+
* runtime value of `C` — `remoteViews<C>(views)` can brand the set cast-free even though the
|
|
74
|
+
* client imports the contract as a TYPE only (trpc-style), never as a value. */
|
|
75
|
+
const ViewSetContract: unique symbol = Symbol.for('reform-remote/view-set-contract')
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The presentation set the client renders by contract name — the `ViewRegistry` the renderer
|
|
79
|
+
* walks, BRANDED with the server `RemoteContract` `C` it was checked against. The brand is
|
|
80
|
+
* what makes the whole client chain typesafe: `connect`, `renderWireTree`, and `<RemoteUI>`
|
|
81
|
+
* accept only a `RemoteViewSet` *produced by* `remoteViews<C>(…)` — never an arbitrary
|
|
82
|
+
* `Record`, which lacks the brand — so a view set cannot reach the renderer without having
|
|
83
|
+
* been type-checked to implement exactly the server's shape. `C` is recovered by inference at
|
|
84
|
+
* each consumer, so passing a `RemoteViewSet<AppContract>` types the whole chain to that app.
|
|
85
|
+
*
|
|
86
|
+
* No default for `C`: a `RemoteViewSet` is ALWAYS bound to a concrete contract (inferred from
|
|
87
|
+
* `remoteViews<AppContract>(…)`), never silently widened to the `RemoteContract` bound — that
|
|
88
|
+
* is what keeps the safety end to end.
|
|
89
|
+
*/
|
|
90
|
+
export type RemoteViewSet<C extends RemoteContract> = ViewRegistry & {
|
|
91
|
+
readonly [ViewSetContract]: (contract: C) => void
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The shared "shape" the server publishes and the client implements — the trpc `AppRouter`
|
|
96
|
+
* analog. It is a registry of the server's UI contracts (the `ui(...)` classes the scene's
|
|
97
|
+
* wire tree can emit) keyed by a label. The SERVER declares it ONCE (`remoteContract({…})`)
|
|
98
|
+
* and exports `typeof` it; the CLIENT imports THAT TYPE and `remoteViews<AppContract>(…)`
|
|
99
|
+
* checks its view set against it, so the client is forced to implement exactly the server's
|
|
100
|
+
* views. The wire name + props schema are read from each view's own contract at runtime, so
|
|
101
|
+
* the label is only the type-level join key — the contract is never needed as a value here.
|
|
102
|
+
*
|
|
103
|
+
* `UiClass<any>` is the registry's *upper bound*; the precise per-contract types are
|
|
104
|
+
* preserved by inferring `C` narrowly at the `remoteContract` declaration site (so
|
|
105
|
+
* `Ui.Contract<C[K]>` recovers the real contract, not `any`).
|
|
106
|
+
*/
|
|
107
|
+
export type RemoteContract = Readonly<Record<string, UiClass<any>>>
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Declare a server's `RemoteContract` with its precise key/contract types inferred (the
|
|
111
|
+
* `const` type parameter keeps each value's specific `UiClass<…>` instead of widening to
|
|
112
|
+
* the `UiClass<any>` bound). Define it ONCE next to the scene and export `typeof` it as the
|
|
113
|
+
* UI requirements the client implements against:
|
|
114
|
+
*
|
|
115
|
+
* export const AppContract = remoteContract({ Shell: ShellUi, Sidebar: SidebarUi })
|
|
116
|
+
* export type AppContract = typeof AppContract
|
|
117
|
+
*
|
|
118
|
+
* The client then imports only the TYPE and implements it:
|
|
119
|
+
*
|
|
120
|
+
* import type { AppContract } from '…/app-contract'
|
|
121
|
+
* export const views = remoteViews<AppContract>({ Shell: ShellView, Sidebar: SidebarView })
|
|
122
|
+
*/
|
|
123
|
+
export const remoteContract = <const C extends RemoteContract>(contract: C): C => contract
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The client view set a `RemoteContract` demands: exactly one `Ui.make` view per contract,
|
|
127
|
+
* each typed to THAT contract (`MadeView<Ui.Contract<C[K]>>`). A missing key, an extra key,
|
|
128
|
+
* or a view authored for the wrong contract is a COMPILE error — the client cannot connect
|
|
129
|
+
* to the server without implementing precisely its shape, the same guarantee trpc gives a
|
|
130
|
+
* client built from `AppRouter`.
|
|
131
|
+
*/
|
|
132
|
+
export type RemoteViews<C extends RemoteContract> = {
|
|
133
|
+
readonly [K in keyof C]: MadeView<Ui.Contract<C[K]>>
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Implement a server's UI shape: `remoteViews<AppContract>({ … })` takes the server's
|
|
138
|
+
* `RemoteContract` as a TYPE PARAMETER (the client imports only `typeof AppContract`, never a
|
|
139
|
+
* value — trpc-style) and a set of `Ui.make` views type-checked to implement EXACTLY that
|
|
140
|
+
* contract. Each view carries its own contract (`Ui.make(SomeUi, …)`), so the wire name and
|
|
141
|
+
* props schema are recovered from the view itself — a renamed/retyped contract can never
|
|
142
|
+
* drift from its presentation, and the SAME view authored for the local renderer is reused.
|
|
143
|
+
* The result is a `RemoteViewSet<C>` (the branded set `connect`/`renderWireTree`/`<RemoteUI>`
|
|
144
|
+
* accept); an unbranded `Record` can never be substituted.
|
|
145
|
+
*/
|
|
146
|
+
export const remoteViews = <C extends RemoteContract>(views: RemoteViews<C>): RemoteViewSet<C> => {
|
|
147
|
+
const list: ReadonlyArray<AnyMadeView> = Object.values(views)
|
|
148
|
+
const byName: ViewRegistry = Object.fromEntries(
|
|
149
|
+
list.map((view): readonly [string, RegisteredRemoteView] => {
|
|
150
|
+
const viewContract = view[UiViewContract]
|
|
151
|
+
const propsSchema = viewContract.manifest.props
|
|
152
|
+
// `Node` is `ReactNode` and `ViewImpl`'s params widen to the dynamic shape the
|
|
153
|
+
// renderer calls, so the contract-typed view IS a `RemoteView` — no cast.
|
|
154
|
+
const entry: RegisteredRemoteView = {
|
|
155
|
+
name: viewContract.manifest.name,
|
|
156
|
+
view,
|
|
157
|
+
...(propsSchema !== undefined ? { propsSchema } : {}),
|
|
158
|
+
}
|
|
159
|
+
return [entry.name, entry]
|
|
160
|
+
}),
|
|
161
|
+
)
|
|
162
|
+
// Brand the record with the contract `C` it was checked against. The brand is a phantom
|
|
163
|
+
// function OF `C` that is never called, so it needs NO runtime value of `C` — the set is
|
|
164
|
+
// built cast-free even though the client only has the contract as a type. The renderer
|
|
165
|
+
// reads the set by string key; the brand is never read at runtime.
|
|
166
|
+
return Object.assign(byName, { [ViewSetContract]: (_contract: C): void => {} })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface ClientConfig<C extends RemoteContract> {
|
|
170
|
+
/** Presentation (+ optional props schema) per contract name — a contract-checked set. */
|
|
171
|
+
readonly views: RemoteViewSet<C>
|
|
172
|
+
/** Deliver a trigger invocation to the server (the transport's send). */
|
|
173
|
+
readonly invoke: (handle: string, payload: unknown) => void
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** What the renderer actually walks: the unbranded `ViewRegistry` + `invoke`. Any
|
|
177
|
+
* `ClientConfig<C>` widens to this (its `RemoteViewSet<C>` views widen to `ViewRegistry`),
|
|
178
|
+
* so the recursive renderer is contract-agnostic — the contract check already happened at
|
|
179
|
+
* `remoteViews`. Internal: callers only ever supply a `ClientConfig<C>`. */
|
|
180
|
+
interface RenderConfig {
|
|
181
|
+
readonly views: ViewRegistry
|
|
182
|
+
readonly invoke: (handle: string, payload: unknown) => void
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A stable top-level component for one wire node — stable identity so React keeps a
|
|
187
|
+
* view's local state across frames (the `node.id` keys it). It decodes props, builds
|
|
188
|
+
* slot components, and runs the registered view INSIDE this component so the view's
|
|
189
|
+
* own hooks get a fiber. A child slot renders its wire children as nested `WireNodeView`s.
|
|
190
|
+
*/
|
|
191
|
+
const WireNodeView = ({
|
|
192
|
+
node,
|
|
193
|
+
tree,
|
|
194
|
+
config,
|
|
195
|
+
}: {
|
|
196
|
+
readonly node: WireNode
|
|
197
|
+
readonly tree: WireTree
|
|
198
|
+
readonly config: RenderConfig
|
|
199
|
+
}): ReactNode => {
|
|
200
|
+
// The latest render inputs, for the stable slot closures below to read. Mutated every
|
|
201
|
+
// render so a slot always renders against the current tree, even though its function
|
|
202
|
+
// identity never changes.
|
|
203
|
+
const latest = useRef({ node, tree, config })
|
|
204
|
+
latest.current = { node, tree, config }
|
|
205
|
+
// Slot components cached by name across renders. CRITICAL for state preservation: a parent
|
|
206
|
+
// view that renders a slot as `<slots.Foo/>` (the Ui.make convention) passes the slot value
|
|
207
|
+
// as the element TYPE — if that value were a fresh closure each render (as it was before),
|
|
208
|
+
// React would see a new component type every frame and UNMOUNT+REMOUNT the whole slot
|
|
209
|
+
// subtree, resetting every descendant view's local hook state (e.g. a modal's open/step
|
|
210
|
+
// `useState` would snap back, closing the modal on any unrelated background patch). Caching
|
|
211
|
+
// the closure by slot name gives `<slots.Foo/>` a stable type, so React reconciles the slot's
|
|
212
|
+
// children by id instead of remounting them. The closure reads `latest` so it still renders
|
|
213
|
+
// the current tree.
|
|
214
|
+
const slotCache = useRef<Record<string, () => ReactNode>>({})
|
|
215
|
+
|
|
216
|
+
const registered = config.views[node.name]
|
|
217
|
+
if (registered === undefined) return null
|
|
218
|
+
|
|
219
|
+
const encoded: Record<string, unknown> = {}
|
|
220
|
+
const events: Record<string, (payload: unknown) => void> = {}
|
|
221
|
+
for (const prop of node.props) {
|
|
222
|
+
if (prop._tag === 'Data') encoded[prop.name] = prop.value
|
|
223
|
+
else events[prop.name] = (payload) => config.invoke(prop.handle, payload)
|
|
224
|
+
}
|
|
225
|
+
const props: Record<string, unknown> =
|
|
226
|
+
registered.propsSchema === undefined
|
|
227
|
+
? encoded
|
|
228
|
+
: Schema.decodeUnknownSync(registered.propsSchema)(encoded)
|
|
229
|
+
|
|
230
|
+
const children = Wire.childrenOf(tree, node.id)
|
|
231
|
+
const slots: Record<string, (slotProps?: { readonly slotKey?: string }) => ReactNode> = {}
|
|
232
|
+
for (const slotName of new Set(children.map((child) => child.slot))) {
|
|
233
|
+
if (slotName === null) continue
|
|
234
|
+
// A slot is a COMPONENT rendering that slot's wire children — usable as `<slots.Foo/>`
|
|
235
|
+
// (Ui.make convention) or `slots.Foo()` (thunk convention). The function is cached so its
|
|
236
|
+
// identity is STABLE across renders (see `slotCache` above); it reads `latest.current` so
|
|
237
|
+
// each invocation renders against the current tree/config.
|
|
238
|
+
//
|
|
239
|
+
// KEYED SLOTS: when the caller passes `slotKey` (`<slots.Row slotKey={id} />`), render only
|
|
240
|
+
// the ONE wire child whose `key` matches — the per-item identity the server captured from
|
|
241
|
+
// the parent's React `key` (see WireNode.key). This is what lets a LIST slot be invoked once
|
|
242
|
+
// per item without each call rendering the WHOLE list (the duplicate-rows bug). With NO
|
|
243
|
+
// `slotKey` the behaviour is unchanged: render every child of the slot (correct for a
|
|
244
|
+
// singleton slot rendered once, e.g. `<slots.Create/>`).
|
|
245
|
+
const cached = slotCache.current[slotName]
|
|
246
|
+
const stable =
|
|
247
|
+
cached ??
|
|
248
|
+
((slotProps?: { readonly slotKey?: string }): ReactNode => {
|
|
249
|
+
const { node: currentNode, tree: currentTree, config: currentConfig } = latest.current
|
|
250
|
+
const requestedKey = slotProps?.slotKey
|
|
251
|
+
return createElement(
|
|
252
|
+
Fragment,
|
|
253
|
+
null,
|
|
254
|
+
...Wire.childrenOf(currentTree, currentNode.id)
|
|
255
|
+
.filter((child) => child.slot === slotName)
|
|
256
|
+
.filter((child) => requestedKey === undefined || child.key === requestedKey)
|
|
257
|
+
.map((child) =>
|
|
258
|
+
createElement(WireNodeView, {
|
|
259
|
+
key: child.id,
|
|
260
|
+
node: child,
|
|
261
|
+
tree: currentTree,
|
|
262
|
+
config: currentConfig,
|
|
263
|
+
}),
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
})
|
|
267
|
+
slotCache.current[slotName] = stable
|
|
268
|
+
slots[slotName] = stable
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return registered.view(props, slots, events)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export const renderWireTree = <C extends RemoteContract>(
|
|
275
|
+
tree: WireTree,
|
|
276
|
+
config: ClientConfig<C>,
|
|
277
|
+
): ReactNode =>
|
|
278
|
+
createElement(
|
|
279
|
+
Fragment,
|
|
280
|
+
null,
|
|
281
|
+
...Wire.roots(tree).map((node) =>
|
|
282
|
+
createElement(WireNodeView, { key: node.id, node, tree, config }),
|
|
283
|
+
),
|
|
284
|
+
)
|