@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 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 *and* runtime-validated at the seam.
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 | 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. |
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 | 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` |
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
- "playbook": "./playbook",
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
- "bugs": {
22
- "url": "https://github.com/playfast/reform/issues"
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
- "files": [
31
- "src",
32
- "README.md"
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
- "publishConfig": {
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
- 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 } }>() {}
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('section', { 'data-section': 'one' }, createElement(slots.Item, { slotKey: 'a' })),
99
- createElement('section', { 'data-section': 'two' }, createElement(slots.Item, { slotKey: 'b' })),
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
- 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 } }>() {}
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({ id: '0', name: 'Host', props: [{ _tag: 'Data', name: 'count', value: 7 }] }),
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 { Option, Record as Rec, Schema } from 'effect'
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 MadeView,
5
- Ui,
6
- type UiClass,
7
- UiViewContract,
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) => void
34
+ readonly [ViewSetContract]: (contract: C) => C
98
35
  }
99
36
 
100
- /**
101
- * The shared "shape" the server publishes and the client implements — the trpc `AppRouter`
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
- * The client view set a `RemoteContract` demands: exactly one `Ui.make` view per contract,
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]: MadeView<Ui.Contract<C[K]>>
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
- * Implement a server's UI shape: `remoteViews<AppContract>({ … })` takes the server's
144
- * `RemoteContract` as a TYPE PARAMETER (the client imports only `typeof AppContract`, never a
145
- * value — trpc-style) and a set of `Ui.make` views type-checked to implement EXACTLY that
146
- * contract. Each view carries its own contract (`Ui.make(SomeUi, …)`), so the wire name and
147
- * props schema are recovered from the view itself a renamed/retyped contract can never
148
- * drift from its presentation, and the SAME view authored for the local renderer is reused.
149
- * The result is a `RemoteViewSet<C>` (the branded set `connect`/`renderWireTree`/`<RemoteUI>`
150
- * accept); an unbranded `Record` can never be substituted.
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 viewContract = view[UiViewContract]
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
- // Brand the record with the contract `C` it was checked against. The brand is a phantom
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
- // The latest render inputs, for the stable slot closures below to read. Mutated every
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
- // Slot components cached by name across renders. CRITICAL for state preservation: a parent
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((prop) => (prop._tag === 'Data' ? [[prop.name, prop.value] as const] : [])),
226
- )
227
- const events: Record<string, (payload: unknown) => void> = Rec.fromEntries(
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 props: Record<string, unknown> = Option.match(registered.propsSchema, {
235
- onNone: () => encoded,
236
- onSome: (schema) => Schema.decodeUnknownSync(schema)(encoded),
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
- // A slot resolves to a COMPONENT rendering that slot's wire children usable as
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
- // Resolve slots LAZILY by name: a view referencing `<slots.Item/>` when that slot has no
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
- { get: (_target, key) => (typeof key === 'string' ? slotFor(key) : undefined) },
173
+ {
174
+ get: (_target, key) => (typeof key === 'string' ? slotFor(key) : undefined),
175
+ },
285
176
  )
286
177
 
287
- return registered.view(props, slots, events)
178
+ return registered.view(encoded, slots, events)
288
179
  }
289
180
 
290
181
  export const renderWireTree = <C extends RemoteContract>(