@playfast/reform-remote 1.0.1 → 1.1.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/README.md +16 -16
- package/package.json +18 -18
- package/src/client-keyed-slot.test.ts +19 -28
- package/src/client-remount.test.ts +14 -28
- package/src/client.ts +73 -182
- package/src/fixtures.ts +268 -92
- package/src/index.ts +0 -10
- package/src/memory.ts +0 -9
- package/src/react.ts +1 -32
- package/src/remote-view.typecheck.ts +3 -17
- package/src/server.test.ts +28 -32
- package/src/server.ts +269 -195
- package/src/transport.test.ts +3 -26
- package/src/transport.ts +18 -96
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ data — UI-tree patches one way, trigger invocations the other — so there is
|
|
|
19
19
|
This builds on reform's existing seams: the **`ui` contract** already separates logic from
|
|
20
20
|
presentation, the **`CaptureSink`** already serializes the rendered surface headlessly (the same
|
|
21
21
|
mechanism proofs use), and the **schema-first `ui`** form (`ui(name, { props, events })`) carries
|
|
22
|
-
the wire schemas that make props and trigger payloads typed
|
|
22
|
+
the wire schemas that make props and trigger payloads typed _and_ runtime-validated at the seam.
|
|
23
23
|
See [`REMOTE_UI.md`](../../REMOTE_UI.md) for the design.
|
|
24
24
|
|
|
25
25
|
## Install
|
|
@@ -35,15 +35,15 @@ sockets pair with [`@playfast/reform-remote-node`](https://www.npmjs.com/package
|
|
|
35
35
|
|
|
36
36
|
## Key concepts
|
|
37
37
|
|
|
38
|
-
| Concept
|
|
39
|
-
|
|
|
40
|
-
| `makeRemoteServer(scene)`
|
|
41
|
-
| `renderWireTree(tree, { views, invoke })`
|
|
42
|
-
| `remoteContract({...})` / `remoteViews<C>({...})`
|
|
43
|
-
| `serve({ scene, transport })` / `connect({ transport, views })` | Bind both ends to any `RemoteTransport`.
|
|
44
|
-
| `inMemoryTransportPair()`
|
|
45
|
-
| `<RemoteUI transport views />` / `useRemoteUI`
|
|
46
|
-
| `useConnectionStatus(reporter)`
|
|
38
|
+
| Concept | What it does |
|
|
39
|
+
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
40
|
+
| `makeRemoteServer(scene)` | Renders a `Scene` to a `WireTree`; `render()`/`renderDiff()` emit full tree/patches; `invoke(handle, payload)` fires a trigger. |
|
|
41
|
+
| `renderWireTree(tree, { views, invoke })` | Folds a `WireTree` back into React using local presentations. |
|
|
42
|
+
| `remoteContract({...})` / `remoteViews<C>({...})` | The trpc-style typesafe seam — server declares the contract, client implements exactly it. |
|
|
43
|
+
| `serve({ scene, transport })` / `connect({ transport, views })` | Bind both ends to any `RemoteTransport`. |
|
|
44
|
+
| `inMemoryTransportPair()` | In-process duplex `RemoteTransport` (the simplest concrete adapter). |
|
|
45
|
+
| `<RemoteUI transport views />` / `useRemoteUI` | React binding that owns connect, subscription, re-render, teardown. |
|
|
46
|
+
| `useConnectionStatus(reporter)` | Reads a transport's live `StatusReporter` status. |
|
|
47
47
|
|
|
48
48
|
## How it fits together
|
|
49
49
|
|
|
@@ -106,12 +106,12 @@ sockets pair with [`@playfast/reform-remote-node`](https://www.npmjs.com/package
|
|
|
106
106
|
|
|
107
107
|
## Transport adapters
|
|
108
108
|
|
|
109
|
-
| Package
|
|
110
|
-
|
|
|
111
|
-
| `inMemoryTransportPair` (here)
|
|
112
|
-
| [`@playfast/reform-remote-node`](https://www.npmjs.com/package/@playfast/reform-remote-node) | WebSocket server
|
|
113
|
-
| [`@playfast/reform-remote-bun`](https://www.npmjs.com/package/@playfast/reform-remote-bun)
|
|
114
|
-
| [`@playfast/reform-remote-web`](https://www.npmjs.com/package/@playfast/reform-remote-web)
|
|
109
|
+
| Package | Role | Built on |
|
|
110
|
+
| -------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------------ |
|
|
111
|
+
| `inMemoryTransportPair` (here) | in-process duplex | — |
|
|
112
|
+
| [`@playfast/reform-remote-node`](https://www.npmjs.com/package/@playfast/reform-remote-node) | WebSocket server | `ws` |
|
|
113
|
+
| [`@playfast/reform-remote-bun`](https://www.npmjs.com/package/@playfast/reform-remote-bun) | WebSocket server | `Bun.serve` |
|
|
114
|
+
| [`@playfast/reform-remote-web`](https://www.npmjs.com/package/@playfast/reform-remote-web) | WebSocket client (factory, auto-reconnect) | global `WebSocket` |
|
|
115
115
|
|
|
116
116
|
## License
|
|
117
117
|
|
package/package.json
CHANGED
|
@@ -1,36 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform-remote",
|
|
3
|
-
"
|
|
4
|
-
"version": "1.0.1",
|
|
5
|
-
"type": "module",
|
|
3
|
+
"version": "1.1.0",
|
|
6
4
|
"description": "Run a reform scene's logic on the server and stream its rendered UI to a thin client over any duplex transport.",
|
|
7
5
|
"keywords": [
|
|
8
|
-
"reform",
|
|
9
6
|
"effect",
|
|
7
|
+
"react",
|
|
8
|
+
"reform",
|
|
10
9
|
"remote",
|
|
11
10
|
"server-driven-ui",
|
|
12
|
-
"transport"
|
|
13
|
-
"react"
|
|
11
|
+
"transport"
|
|
14
12
|
],
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/playfast/reform/issues"
|
|
15
|
+
},
|
|
15
16
|
"license": "MIT",
|
|
16
17
|
"repository": {
|
|
17
18
|
"type": "git",
|
|
18
19
|
"url": "https://github.com/playfast/reform.git",
|
|
19
20
|
"directory": "packages/reform-remote"
|
|
20
21
|
},
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
|
|
22
|
+
"files": [
|
|
23
|
+
"src",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
24
27
|
"sideEffects": false,
|
|
25
28
|
"exports": {
|
|
26
29
|
"./package.json": "./package.json",
|
|
27
30
|
".": "./src/index.ts",
|
|
28
31
|
"./*": "./src/*.ts"
|
|
29
32
|
},
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
34
36
|
"scripts": {
|
|
35
37
|
"clean": "rm -rf dist .tsbuildinfo",
|
|
36
38
|
"check": "tsc --noEmit",
|
|
@@ -42,11 +44,9 @@
|
|
|
42
44
|
"lint:fix": "oxlint --fix src"
|
|
43
45
|
},
|
|
44
46
|
"peerDependencies": {
|
|
47
|
+
"@playfast/reform": "*",
|
|
45
48
|
"effect": "*",
|
|
46
|
-
"react": "^19.0.0"
|
|
47
|
-
"@playfast/reform": "*"
|
|
49
|
+
"react": "^19.0.0"
|
|
48
50
|
},
|
|
49
|
-
"
|
|
50
|
-
"access": "public"
|
|
51
|
-
}
|
|
51
|
+
"playbook": "./playbook"
|
|
52
52
|
}
|
|
@@ -4,27 +4,18 @@ import { createRoot } from 'react-dom/client'
|
|
|
4
4
|
import { expect, test } from 'vitest'
|
|
5
5
|
import { Schema as S } from 'effect'
|
|
6
6
|
import { Composition, slot, Ui, ui } from '@playfast/reform'
|
|
7
|
-
import type { WireNode } from '@playfast/reform'
|
|
7
|
+
import type { WireNode } from '@playfast/reform/internal'
|
|
8
8
|
import { remoteViews, renderWireTree, type RemoteViews } from './client'
|
|
9
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
10
|
class ItemUi extends ui('Item', { props: S.Struct({ label: S.String }) }) {}
|
|
25
|
-
|
|
26
|
-
class
|
|
27
|
-
|
|
11
|
+
|
|
12
|
+
class ItemComp extends Composition.make('Item', { title: 'Item', ui: ItemUi })<ItemComp>() {}
|
|
13
|
+
|
|
14
|
+
class ItemSlot extends slot('Item')<ItemSlot, typeof ItemComp>() {}
|
|
15
|
+
class ListUi extends ui('List', {
|
|
16
|
+
props: S.Struct({ mode: S.String }),
|
|
17
|
+
slots: { Item: ItemSlot },
|
|
18
|
+
}) {}
|
|
28
19
|
|
|
29
20
|
const node = (over: Partial<WireNode> & Pick<WireNode, 'id' | 'name'>): WireNode => ({
|
|
30
21
|
parentId: null,
|
|
@@ -39,8 +30,6 @@ const ItemView = Ui.make(ItemUi, ({ label }) =>
|
|
|
39
30
|
createElement('span', { 'data-item': label }, label),
|
|
40
31
|
)
|
|
41
32
|
|
|
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
33
|
const tree: ReadonlyArray<WireNode> = [
|
|
45
34
|
node({ id: '0', name: 'List', props: [{ _tag: 'Data', name: 'mode', value: 'keyed' }] }),
|
|
46
35
|
node({
|
|
@@ -89,32 +78,34 @@ const renderWith = async (
|
|
|
89
78
|
}
|
|
90
79
|
|
|
91
80
|
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
81
|
const ListView = Ui.make(ListUi, (_props, slots) =>
|
|
95
82
|
createElement(
|
|
96
83
|
'div',
|
|
97
84
|
null,
|
|
98
|
-
createElement(
|
|
99
|
-
|
|
85
|
+
createElement(
|
|
86
|
+
'section',
|
|
87
|
+
{ 'data-section': 'one' },
|
|
88
|
+
createElement(slots.Item, { slotKey: 'a' }),
|
|
89
|
+
),
|
|
90
|
+
createElement(
|
|
91
|
+
'section',
|
|
92
|
+
{ 'data-section': 'two' },
|
|
93
|
+
createElement(slots.Item, { slotKey: 'b' }),
|
|
94
|
+
),
|
|
100
95
|
),
|
|
101
96
|
)
|
|
102
97
|
const container = await renderWith(ListView, tree)
|
|
103
98
|
|
|
104
|
-
// Exactly two items rendered total — NOT 2 sections × 3 children = 6 (the bug).
|
|
105
99
|
expect(container.querySelectorAll('[data-item]').length).toBe(2)
|
|
106
|
-
// Each section shows ONLY its keyed child.
|
|
107
100
|
const one = container.querySelector('[data-section="one"]')
|
|
108
101
|
const two = container.querySelector('[data-section="two"]')
|
|
109
102
|
expect(one?.querySelectorAll('[data-item]').length).toBe(1)
|
|
110
103
|
expect(one?.querySelector('[data-item]')?.getAttribute('data-item')).toBe('a')
|
|
111
104
|
expect(two?.querySelector('[data-item]')?.getAttribute('data-item')).toBe('b')
|
|
112
|
-
// The unreferenced child 'c' is rendered nowhere.
|
|
113
105
|
expect(container.querySelector('[data-item="c"]')).toBeNull()
|
|
114
106
|
})
|
|
115
107
|
|
|
116
108
|
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
109
|
const ListView = Ui.make(ListUi, (_props, slots) =>
|
|
119
110
|
createElement('div', null, createElement(slots.Item)),
|
|
120
111
|
)
|
|
@@ -5,34 +5,21 @@ import { createRoot } from 'react-dom/client'
|
|
|
5
5
|
import { expect, test } from 'vitest'
|
|
6
6
|
import { Schema as S } from 'effect'
|
|
7
7
|
import { Composition, slot, Ui, ui } from '@playfast/reform'
|
|
8
|
-
import type { WireNode } from '@playfast/reform'
|
|
8
|
+
import type { WireNode } from '@playfast/reform/internal'
|
|
9
9
|
import type { ServerMessage } from './transport'
|
|
10
10
|
import { connect } from './transport'
|
|
11
11
|
import { inMemoryTransportPair } from './memory'
|
|
12
12
|
import { remoteViews } from './client'
|
|
13
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
14
|
class ChildUi extends ui('Child', { props: S.Struct({}) }) {}
|
|
31
|
-
|
|
32
|
-
class
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
class HostUi extends ui('Host'
|
|
15
|
+
|
|
16
|
+
class ChildComp extends Composition.make('Child', { title: 'Child', ui: ChildUi })<ChildComp>() {}
|
|
17
|
+
|
|
18
|
+
class MainSlot extends slot('Main')<MainSlot, typeof ChildComp>() {}
|
|
19
|
+
class HostUi extends ui('Host', {
|
|
20
|
+
props: S.Struct({ count: S.Number }),
|
|
21
|
+
slots: { Main: MainSlot },
|
|
22
|
+
}) {}
|
|
36
23
|
|
|
37
24
|
const node = (over: Partial<WireNode> & Pick<WireNode, 'id' | 'name'>): WireNode => ({
|
|
38
25
|
parentId: null,
|
|
@@ -82,7 +69,6 @@ test('a background patch on the PARENT keeps its slot child mounted (local useSt
|
|
|
82
69
|
root.render(createElement(Root))
|
|
83
70
|
})
|
|
84
71
|
|
|
85
|
-
// First frame: a host (count 0) holding one slot child.
|
|
86
72
|
await act(async () => {
|
|
87
73
|
serverTransport.send({
|
|
88
74
|
_tag: 'Snapshot',
|
|
@@ -97,23 +83,23 @@ test('a background patch on the PARENT keeps its slot child mounted (local useSt
|
|
|
97
83
|
const childIdBefore = container.querySelector('[data-child-id]')?.getAttribute('data-child-id')
|
|
98
84
|
expect(childIdBefore).toBe('mount-1')
|
|
99
85
|
|
|
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
86
|
await act(async () => {
|
|
103
87
|
serverTransport.send({
|
|
104
88
|
_tag: 'Patches',
|
|
105
89
|
patches: [
|
|
106
90
|
{
|
|
107
91
|
_tag: 'Upsert',
|
|
108
|
-
node: node({
|
|
92
|
+
node: node({
|
|
93
|
+
id: '0',
|
|
94
|
+
name: 'Host',
|
|
95
|
+
props: [{ _tag: 'Data', name: 'count', value: 7 }],
|
|
96
|
+
}),
|
|
109
97
|
},
|
|
110
98
|
],
|
|
111
99
|
})
|
|
112
100
|
})
|
|
113
101
|
|
|
114
|
-
// The parent re-rendered with the new count…
|
|
115
102
|
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
103
|
expect(container.querySelector('[data-child-id]')?.getAttribute('data-child-id')).toBe(
|
|
118
104
|
childIdBefore,
|
|
119
105
|
)
|
package/src/client.ts
CHANGED
|
@@ -1,198 +1,109 @@
|
|
|
1
1
|
import { createElement, Fragment, type ReactNode, useRef } from 'react'
|
|
2
|
-
import {
|
|
2
|
+
import { Effect, Record as Rec, Schema } from 'effect'
|
|
3
|
+
import { type MadeView, Ui, type UiContract, UiViewContract, type ViewImpl } from '@playfast/reform'
|
|
3
4
|
import {
|
|
4
|
-
type
|
|
5
|
-
|
|
6
|
-
type
|
|
7
|
-
|
|
5
|
+
type AnyMadeView,
|
|
6
|
+
type AnyUi,
|
|
7
|
+
type UiReflection,
|
|
8
|
+
type WiredUiManifest,
|
|
8
9
|
Wire,
|
|
9
10
|
type WireNode,
|
|
10
11
|
type WireTree,
|
|
11
|
-
} from '@playfast/reform'
|
|
12
|
+
} from '@playfast/reform/internal'
|
|
12
13
|
|
|
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
|
-
/** Props a slot thunk/component accepts — a React element-props boundary
|
|
46
|
-
* (`<slots.Row slotKey={id}/>`), so it stays a plain optional-field shape. */
|
|
47
14
|
export interface SlotPropsExternalApi {
|
|
48
15
|
readonly slotKey?: string
|
|
49
16
|
}
|
|
50
17
|
|
|
51
18
|
export type RemoteView = (
|
|
52
19
|
props: Record<string, unknown>,
|
|
53
|
-
// A slot thunk optionally takes `{ slotKey }` to select a single keyed child (see the keyed-
|
|
54
|
-
// slot handling in `WireNodeView`); omitting it renders all of the slot's children.
|
|
55
20
|
slots: Record<string, (slotProps?: SlotPropsExternalApi) => ReactNode>,
|
|
56
21
|
events: Record<string, (payload: unknown) => void>,
|
|
57
22
|
) => ReactNode
|
|
58
23
|
|
|
59
|
-
/** A contract-bound view paired with the contract name the renderer looks it up by, and
|
|
60
|
-
* (for a WIRED contract) the props schema used to DECODE wire props back into their
|
|
61
|
-
* decoded domain types — the symmetric inverse of the server's `Schema.encodeUnknown`.
|
|
62
|
-
* Without it, an `Option`/`Date`/branded prop would reach the view as its raw encoded
|
|
63
|
-
* shape (e.g. `{_tag:'Some',value}`) instead of a real `Option`. */
|
|
64
24
|
export interface RegisteredRemoteView {
|
|
65
25
|
readonly name: string
|
|
66
26
|
readonly view: RemoteView
|
|
67
|
-
readonly propsSchema: Option.Option<Schema.Schema<Record<string, unknown>, unknown>>
|
|
68
27
|
}
|
|
69
28
|
|
|
70
|
-
/** The by-name presentation record the renderer walks — the unbranded runtime shape behind
|
|
71
|
-
* a `RemoteViewSet`. Internal: it is NOT part of the public API, so a raw record of this
|
|
72
|
-
* shape can never be handed to `connect`/`renderWireTree`/`<RemoteUI>` (those demand the
|
|
73
|
-
* branded `RemoteViewSet<C>`). A `RemoteViewSet<C>` is structurally `ViewRegistry & brand`,
|
|
74
|
-
* so it always widens back to this when the renderer needs the plain lookup. */
|
|
75
29
|
type ViewRegistry = Readonly<Record<string, RegisteredRemoteView>>
|
|
76
30
|
|
|
77
|
-
/** Phantom brand carrying the server `RemoteContract` a view set was checked against. Typed
|
|
78
|
-
* as a function OF `C` (never called) so the contract appears in the type WITHOUT needing a
|
|
79
|
-
* runtime value of `C` — `remoteViews<C>(views)` can brand the set cast-free even though the
|
|
80
|
-
* client imports the contract as a TYPE only (trpc-style), never as a value. */
|
|
81
31
|
const ViewSetContract: unique symbol = Symbol.for('reform-remote/view-set-contract')
|
|
82
32
|
|
|
83
|
-
/**
|
|
84
|
-
* The presentation set the client renders by contract name — the `ViewRegistry` the renderer
|
|
85
|
-
* walks, BRANDED with the server `RemoteContract` `C` it was checked against. The brand is
|
|
86
|
-
* what makes the whole client chain typesafe: `connect`, `renderWireTree`, and `<RemoteUI>`
|
|
87
|
-
* accept only a `RemoteViewSet` *produced by* `remoteViews<C>(…)` — never an arbitrary
|
|
88
|
-
* `Record`, which lacks the brand — so a view set cannot reach the renderer without having
|
|
89
|
-
* been type-checked to implement exactly the server's shape. `C` is recovered by inference at
|
|
90
|
-
* each consumer, so passing a `RemoteViewSet<AppContract>` types the whole chain to that app.
|
|
91
|
-
*
|
|
92
|
-
* No default for `C`: a `RemoteViewSet` is ALWAYS bound to a concrete contract (inferred from
|
|
93
|
-
* `remoteViews<AppContract>(…)`), never silently widened to the `RemoteContract` bound — that
|
|
94
|
-
* is what keeps the safety end to end.
|
|
95
|
-
*/
|
|
96
33
|
export type RemoteViewSet<C extends RemoteContract> = ViewRegistry & {
|
|
97
|
-
readonly [ViewSetContract]: (contract: C) =>
|
|
34
|
+
readonly [ViewSetContract]: (contract: C) => C
|
|
98
35
|
}
|
|
99
36
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
* analog. It is a registry of the server's UI contracts (the `ui(...)` classes the scene's
|
|
103
|
-
* wire tree can emit) keyed by a label. The SERVER declares it ONCE (`remoteContract({…})`)
|
|
104
|
-
* and exports `typeof` it; the CLIENT imports THAT TYPE and `remoteViews<AppContract>(…)`
|
|
105
|
-
* checks its view set against it, so the client is forced to implement exactly the server's
|
|
106
|
-
* views. The wire name + props schema are read from each view's own contract at runtime, so
|
|
107
|
-
* the label is only the type-level join key — the contract is never needed as a value here.
|
|
108
|
-
*
|
|
109
|
-
* `UiClass<any>` is the registry's *upper bound*; the precise per-contract types are
|
|
110
|
-
* preserved by inferring `C` narrowly at the `remoteContract` declaration site (so
|
|
111
|
-
* `Ui.Contract<C[K]>` recovers the real contract, not `any`).
|
|
112
|
-
*/
|
|
113
|
-
export type RemoteContract = Readonly<Record<string, UiClass<any>>>
|
|
37
|
+
type RemoteUi = UiReflection<UiContract, string> & AnyUi & { readonly manifest: WiredUiManifest }
|
|
38
|
+
export type RemoteContract = Readonly<Record<string, RemoteUi>>
|
|
114
39
|
|
|
115
|
-
/**
|
|
116
|
-
* Declare a server's `RemoteContract` with its precise key/contract types inferred (the
|
|
117
|
-
* `const` type parameter keeps each value's specific `UiClass<…>` instead of widening to
|
|
118
|
-
* the `UiClass<any>` bound). Define it ONCE next to the scene and export `typeof` it as the
|
|
119
|
-
* UI requirements the client implements against:
|
|
120
|
-
*
|
|
121
|
-
* export const AppContract = remoteContract({ Shell: ShellUi, Sidebar: SidebarUi })
|
|
122
|
-
* export type AppContract = typeof AppContract
|
|
123
|
-
*
|
|
124
|
-
* The client then imports only the TYPE and implements it:
|
|
125
|
-
*
|
|
126
|
-
* import type { AppContract } from '…/app-contract'
|
|
127
|
-
* export const views = remoteViews<AppContract>({ Shell: ShellView, Sidebar: SidebarView })
|
|
128
|
-
*/
|
|
129
40
|
export const remoteContract = <const C extends RemoteContract>(contract: C): C => contract
|
|
130
41
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
* each typed to THAT contract (`MadeView<Ui.Contract<C[K]>>`). A missing key, an extra key,
|
|
134
|
-
* or a view authored for the wrong contract is a COMPILE error — the client cannot connect
|
|
135
|
-
* to the server without implementing precisely its shape, the same guarantee trpc gives a
|
|
136
|
-
* client built from `AppRouter`.
|
|
137
|
-
*/
|
|
42
|
+
type RemoteViewFor<U extends RemoteUi> =
|
|
43
|
+
U extends UiReflection<infer Contract, infer Name> ? MadeView<Contract, Name, U> : never
|
|
138
44
|
export type RemoteViews<C extends RemoteContract> = {
|
|
139
|
-
readonly [K in keyof C]:
|
|
45
|
+
readonly [K in keyof C]: RemoteViewFor<C[K]>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type RemoteSlots = Record<string, (slotProps?: SlotPropsExternalApi) => ReactNode>
|
|
49
|
+
type RemoteEvents = Record<string, (payload: unknown) => void>
|
|
50
|
+
|
|
51
|
+
function bindSlots<C extends Ui.UiContract>(slots: RemoteSlots): Parameters<ViewImpl<C>>[1]
|
|
52
|
+
function bindSlots(slots: RemoteSlots): unknown {
|
|
53
|
+
return slots
|
|
140
54
|
}
|
|
141
55
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
56
|
+
function bindEvents<C extends Ui.UiContract>(events: RemoteEvents): Parameters<ViewImpl<C>>[2]
|
|
57
|
+
function bindEvents(events: RemoteEvents): unknown {
|
|
58
|
+
return events
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const registerView = (view: AnyMadeView): RegisteredRemoteView =>
|
|
62
|
+
view.capture(
|
|
63
|
+
<C extends Ui.UiContract, N extends string, U extends Ui.AnyUi>(exact: MadeView<C, N, U>) => {
|
|
64
|
+
const contract = exact[UiViewContract]
|
|
65
|
+
const propsReflection = contract.manifest.props
|
|
66
|
+
if (propsReflection === undefined) {
|
|
67
|
+
return Effect.runSync(
|
|
68
|
+
Effect.dieMessage(
|
|
69
|
+
`reform-remote: view ${contract.manifest.name} needs a wire props schema`,
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
const propsSchema = Schema.make<C['props'], unknown>(propsReflection.ast)
|
|
74
|
+
return {
|
|
75
|
+
name: contract.manifest.name,
|
|
76
|
+
view: (encoded, slots, events) =>
|
|
77
|
+
exact(
|
|
78
|
+
Schema.decodeUnknownSync(propsSchema)(encoded),
|
|
79
|
+
bindSlots<C>(slots),
|
|
80
|
+
bindEvents<C>(events),
|
|
81
|
+
),
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
)
|
|
85
|
+
|
|
152
86
|
export const remoteViews = <C extends RemoteContract>(views: RemoteViews<C>): RemoteViewSet<C> => {
|
|
153
87
|
const madeViews: ReadonlyArray<AnyMadeView> = Object.values(views)
|
|
154
88
|
const byName: ViewRegistry = Rec.fromEntries(
|
|
155
89
|
madeViews.map((view): readonly [string, RegisteredRemoteView] => {
|
|
156
|
-
const
|
|
157
|
-
// `Node` is `ReactNode` and `ViewImpl`'s params widen to the dynamic shape the
|
|
158
|
-
// renderer calls, so the contract-typed view IS a `RemoteView` — no cast.
|
|
159
|
-
const entry: RegisteredRemoteView = {
|
|
160
|
-
name: viewContract.manifest.name,
|
|
161
|
-
view,
|
|
162
|
-
propsSchema: Option.fromNullable(viewContract.manifest.props),
|
|
163
|
-
}
|
|
90
|
+
const entry = registerView(view)
|
|
164
91
|
return [entry.name, entry]
|
|
165
92
|
}),
|
|
166
93
|
)
|
|
167
|
-
|
|
168
|
-
// function OF `C` that is never called, so it needs NO runtime value of `C` — the set is
|
|
169
|
-
// built cast-free even though the client only has the contract as a type. The renderer
|
|
170
|
-
// reads the set by string key; the brand is never read at runtime.
|
|
171
|
-
return { ...byName, [ViewSetContract]: (_contract: C): void => {} }
|
|
94
|
+
return { ...byName, [ViewSetContract]: (contract: C): C => contract }
|
|
172
95
|
}
|
|
173
96
|
|
|
174
97
|
export interface ClientConfig<C extends RemoteContract> {
|
|
175
|
-
/** Presentation (+ optional props schema) per contract name — a contract-checked set. */
|
|
176
98
|
readonly views: RemoteViewSet<C>
|
|
177
|
-
/** Deliver a trigger invocation to the server (the transport's send). */
|
|
178
99
|
readonly invoke: (handle: string, payload: unknown) => void
|
|
179
100
|
}
|
|
180
101
|
|
|
181
|
-
/** What the renderer actually walks: the unbranded `ViewRegistry` + `invoke`. Any
|
|
182
|
-
* `ClientConfig<C>` widens to this (its `RemoteViewSet<C>` views widen to `ViewRegistry`),
|
|
183
|
-
* so the recursive renderer is contract-agnostic — the contract check already happened at
|
|
184
|
-
* `remoteViews`. Internal: callers only ever supply a `ClientConfig<C>`. */
|
|
185
102
|
interface RenderConfig {
|
|
186
103
|
readonly views: ViewRegistry
|
|
187
104
|
readonly invoke: (handle: string, payload: unknown) => void
|
|
188
105
|
}
|
|
189
106
|
|
|
190
|
-
/**
|
|
191
|
-
* A stable top-level component for one wire node — stable identity so React keeps a
|
|
192
|
-
* view's local state across frames (the `node.id` keys it). It decodes props, builds
|
|
193
|
-
* slot components, and runs the registered view INSIDE this component so the view's
|
|
194
|
-
* own hooks get a fiber. A child slot renders its wire children as nested `WireNodeView`s.
|
|
195
|
-
*/
|
|
196
107
|
interface WireNodeViewProps {
|
|
197
108
|
readonly node: WireNode
|
|
198
109
|
readonly tree: WireTree
|
|
@@ -200,20 +111,10 @@ interface WireNodeViewProps {
|
|
|
200
111
|
}
|
|
201
112
|
|
|
202
113
|
const WireNodeView = ({ node, tree, config }: WireNodeViewProps): ReactNode => {
|
|
203
|
-
//
|
|
204
|
-
// render so a slot always renders against the current tree, even though its function
|
|
205
|
-
// identity never changes.
|
|
114
|
+
// Stable slot closures read current inputs through this ref.
|
|
206
115
|
const latest = useRef({ node, tree, config })
|
|
207
116
|
latest.current = { node, tree, config }
|
|
208
|
-
//
|
|
209
|
-
// view that renders a slot as `<slots.Foo/>` (the Ui.make convention) passes the slot value
|
|
210
|
-
// as the element TYPE — if that value were a fresh closure each render (as it was before),
|
|
211
|
-
// React would see a new component type every frame and UNMOUNT+REMOUNT the whole slot
|
|
212
|
-
// subtree, resetting every descendant view's local hook state (e.g. a modal's open/step
|
|
213
|
-
// `useState` would snap back, closing the modal on any unrelated background patch). Caching
|
|
214
|
-
// the closure by slot name gives `<slots.Foo/>` a stable type, so React reconciles the slot's
|
|
215
|
-
// children by id instead of remounting them. The closure reads `latest` so it still renders
|
|
216
|
-
// the current tree.
|
|
117
|
+
// Fresh slot component types remount descendants and reset their hooks.
|
|
217
118
|
const slotCache = useRef<Record<string, () => ReactNode>>({})
|
|
218
119
|
|
|
219
120
|
const registered = config.views[node.name]
|
|
@@ -222,31 +123,22 @@ const WireNodeView = ({ node, tree, config }: WireNodeViewProps): ReactNode => {
|
|
|
222
123
|
}
|
|
223
124
|
|
|
224
125
|
const encoded: Record<string, unknown> = Rec.fromEntries(
|
|
225
|
-
node.props.flatMap(
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
node.props.flatMap((prop) =>
|
|
229
|
-
prop._tag === 'Event'
|
|
230
|
-
? [[prop.name, (payload: unknown) => config.invoke(prop.handle, payload)] as const]
|
|
231
|
-
: [],
|
|
126
|
+
node.props.flatMap(
|
|
127
|
+
(prop): ReadonlyArray<readonly [string, unknown]> =>
|
|
128
|
+
prop._tag === 'Data' ? [[prop.name, prop.value]] : [],
|
|
232
129
|
),
|
|
233
130
|
)
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
131
|
+
const eventEntries = node.props.flatMap(
|
|
132
|
+
(prop): ReadonlyArray<readonly [string, (payload: unknown) => void]> => {
|
|
133
|
+
if (prop._tag !== 'Event') {
|
|
134
|
+
return []
|
|
135
|
+
}
|
|
136
|
+
return [[prop.name, (payload: unknown) => config.invoke(prop.handle, payload)]]
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
const events: Record<string, (payload: unknown) => void> = Rec.fromEntries(eventEntries)
|
|
238
140
|
|
|
239
|
-
//
|
|
240
|
-
// `<slots.Foo/>` (Ui.make convention) or `slots.Foo()` (thunk convention). The function is
|
|
241
|
-
// cached so its identity is STABLE across renders (see `slotCache` above); it reads
|
|
242
|
-
// `latest.current` so each invocation renders against the current tree/config.
|
|
243
|
-
//
|
|
244
|
-
// KEYED SLOTS: when the caller passes `slotKey` (`<slots.Row slotKey={id} />`), render only
|
|
245
|
-
// the ONE wire child whose `key` matches — the per-item identity the server captured from
|
|
246
|
-
// the parent's React `key` (see WireNode.key). This is what lets a LIST slot be invoked once
|
|
247
|
-
// per item without each call rendering the WHOLE list (the duplicate-rows bug). With NO
|
|
248
|
-
// `slotKey` the behaviour is unchanged: render every child of the slot (correct for a
|
|
249
|
-
// singleton slot rendered once, e.g. `<slots.Create/>`).
|
|
141
|
+
// slotKey selects one wire child; omitting it renders every child in the slot.
|
|
250
142
|
const slotFor = (slotName: string): ((slotProps?: SlotPropsExternalApi) => ReactNode) => {
|
|
251
143
|
const cached = slotCache.current[slotName]
|
|
252
144
|
if (cached !== undefined) {
|
|
@@ -275,16 +167,15 @@ const WireNodeView = ({ node, tree, config }: WireNodeViewProps): ReactNode => {
|
|
|
275
167
|
return stable
|
|
276
168
|
}
|
|
277
169
|
|
|
278
|
-
//
|
|
279
|
-
// wire children this frame (e.g. an empty list) gets a component that renders nothing,
|
|
280
|
-
// rather than `undefined` (which React rejects as an invalid element type). Mirrors the
|
|
281
|
-
// engine's total slot proxies — every declared slot is always callable.
|
|
170
|
+
// Empty slots must still produce a callable component, not an invalid undefined element type.
|
|
282
171
|
const slots: Record<string, (slotProps?: SlotPropsExternalApi) => ReactNode> = new Proxy(
|
|
283
172
|
Object.create(null),
|
|
284
|
-
{
|
|
173
|
+
{
|
|
174
|
+
get: (_target, key) => (typeof key === 'string' ? slotFor(key) : undefined),
|
|
175
|
+
},
|
|
285
176
|
)
|
|
286
177
|
|
|
287
|
-
return registered.view(
|
|
178
|
+
return registered.view(encoded, slots, events)
|
|
288
179
|
}
|
|
289
180
|
|
|
290
181
|
export const renderWireTree = <C extends RemoteContract>(
|