@playfast/reform-remote 0.1.0 → 1.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@playfast/reform-remote",
3
3
  "playbook": "./playbook",
4
- "version": "0.1.0",
4
+ "version": "1.0.2",
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": [
@@ -7,20 +7,6 @@ import { Composition, slot, Ui, ui } from '@playfast/reform'
7
7
  import type { WireNode } from '@playfast/reform'
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
11
  class ItemComp extends Composition.make('Item', { title: 'Item', ui: ItemUi }) {}
26
12
  class ItemSlot extends slot('Item')<typeof ItemComp>() {}
@@ -39,8 +25,6 @@ const ItemView = Ui.make(ItemUi, ({ label }) =>
39
25
  createElement('span', { 'data-item': label }, label),
40
26
  )
41
27
 
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
28
  const tree: ReadonlyArray<WireNode> = [
45
29
  node({ id: '0', name: 'List', props: [{ _tag: 'Data', name: 'mode', value: 'keyed' }] }),
46
30
  node({
@@ -89,8 +73,6 @@ const renderWith = async (
89
73
  }
90
74
 
91
75
  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
76
  const ListView = Ui.make(ListUi, (_props, slots) =>
95
77
  createElement(
96
78
  'div',
@@ -101,20 +83,16 @@ test('a keyed slot renders ONE matching child per call site, not the whole list'
101
83
  )
102
84
  const container = await renderWith(ListView, tree)
103
85
 
104
- // Exactly two items rendered total — NOT 2 sections × 3 children = 6 (the bug).
105
86
  expect(container.querySelectorAll('[data-item]').length).toBe(2)
106
- // Each section shows ONLY its keyed child.
107
87
  const one = container.querySelector('[data-section="one"]')
108
88
  const two = container.querySelector('[data-section="two"]')
109
89
  expect(one?.querySelectorAll('[data-item]').length).toBe(1)
110
90
  expect(one?.querySelector('[data-item]')?.getAttribute('data-item')).toBe('a')
111
91
  expect(two?.querySelector('[data-item]')?.getAttribute('data-item')).toBe('b')
112
- // The unreferenced child 'c' is rendered nowhere.
113
92
  expect(container.querySelector('[data-item="c"]')).toBeNull()
114
93
  })
115
94
 
116
95
  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
96
  const ListView = Ui.make(ListUi, (_props, slots) =>
119
97
  createElement('div', null, createElement(slots.Item)),
120
98
  )
@@ -11,27 +11,9 @@ 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
15
  class ChildComp extends Composition.make('Child', { title: 'Child', ui: ChildUi }) {}
32
16
  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
17
  class HostUi extends ui('Host')<{ props: { count: number }; slots: { Main: MainSlot } }>() {}
36
18
 
37
19
  const node = (over: Partial<WireNode> & Pick<WireNode, 'id' | 'name'>): WireNode => ({
@@ -82,7 +64,6 @@ test('a background patch on the PARENT keeps its slot child mounted (local useSt
82
64
  root.render(createElement(Root))
83
65
  })
84
66
 
85
- // First frame: a host (count 0) holding one slot child.
86
67
  await act(async () => {
87
68
  serverTransport.send({
88
69
  _tag: 'Snapshot',
@@ -97,8 +78,6 @@ test('a background patch on the PARENT keeps its slot child mounted (local useSt
97
78
  const childIdBefore = container.querySelector('[data-child-id]')?.getAttribute('data-child-id')
98
79
  expect(childIdBefore).toBe('mount-1')
99
80
 
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
81
  await act(async () => {
103
82
  serverTransport.send({
104
83
  _tag: 'Patches',
@@ -111,9 +90,7 @@ test('a background patch on the PARENT keeps its slot child mounted (local useSt
111
90
  })
112
91
  })
113
92
 
114
- // The parent re-rendered with the new count…
115
93
  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
94
  expect(container.querySelector('[data-child-id]')?.getAttribute('data-child-id')).toBe(
118
95
  childIdBefore,
119
96
  )
package/src/client.ts CHANGED
@@ -10,152 +10,49 @@ import {
10
10
  type WireTree,
11
11
  } from '@playfast/reform'
12
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>
13
+ type AnyValue = Schema.Schema.Type<Schema.Schema.Any>
18
14
 
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
- */
15
+ // Variance escape (like Schema<any, any>): heterogeneous contract-typed views can't share one element type.
16
+ type AnyMadeView = MadeView<AnyValue>
39
17
 
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
18
  export interface SlotPropsExternalApi {
48
19
  readonly slotKey?: string
49
20
  }
50
21
 
51
22
  export type RemoteView = (
52
23
  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
24
  slots: Record<string, (slotProps?: SlotPropsExternalApi) => ReactNode>,
56
25
  events: Record<string, (payload: unknown) => void>,
57
26
  ) => ReactNode
58
27
 
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
28
  export interface RegisteredRemoteView {
65
29
  readonly name: string
66
30
  readonly view: RemoteView
67
31
  readonly propsSchema: Option.Option<Schema.Schema<Record<string, unknown>, unknown>>
68
32
  }
69
33
 
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
34
  type ViewRegistry = Readonly<Record<string, RegisteredRemoteView>>
76
35
 
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. */
36
+ // Phantom brand of C (function never called) so remoteViews can brand cast-free with type-only C.
81
37
  const ViewSetContract: unique symbol = Symbol.for('reform-remote/view-set-contract')
82
38
 
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
39
  export type RemoteViewSet<C extends RemoteContract> = ViewRegistry & {
97
40
  readonly [ViewSetContract]: (contract: C) => void
98
41
  }
99
42
 
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>>>
43
+ export type RemoteContract = Readonly<Record<string, UiClass<AnyValue>>>
114
44
 
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
- export const remoteContract = <const C extends RemoteContract>(contract: C): C => contract
45
+ export { remoteContract } from './contract'
130
46
 
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
- */
138
47
  export type RemoteViews<C extends RemoteContract> = {
139
48
  readonly [K in keyof C]: MadeView<Ui.Contract<C[K]>>
140
49
  }
141
50
 
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
- */
152
51
  export const remoteViews = <C extends RemoteContract>(views: RemoteViews<C>): RemoteViewSet<C> => {
153
52
  const madeViews: ReadonlyArray<AnyMadeView> = Object.values(views)
154
53
  const byName: ViewRegistry = Rec.fromEntries(
155
54
  madeViews.map((view): readonly [string, RegisteredRemoteView] => {
156
55
  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
56
  const entry: RegisteredRemoteView = {
160
57
  name: viewContract.manifest.name,
161
58
  view,
@@ -164,35 +61,19 @@ export const remoteViews = <C extends RemoteContract>(views: RemoteViews<C>): Re
164
61
  return [entry.name, entry]
165
62
  }),
166
63
  )
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
64
  return { ...byName, [ViewSetContract]: (_contract: C): void => {} }
172
65
  }
173
66
 
174
67
  export interface ClientConfig<C extends RemoteContract> {
175
- /** Presentation (+ optional props schema) per contract name — a contract-checked set. */
176
68
  readonly views: RemoteViewSet<C>
177
- /** Deliver a trigger invocation to the server (the transport's send). */
178
69
  readonly invoke: (handle: string, payload: unknown) => void
179
70
  }
180
71
 
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
72
  interface RenderConfig {
186
73
  readonly views: ViewRegistry
187
74
  readonly invoke: (handle: string, payload: unknown) => void
188
75
  }
189
76
 
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
77
  interface WireNodeViewProps {
197
78
  readonly node: WireNode
198
79
  readonly tree: WireTree
@@ -200,20 +81,10 @@ interface WireNodeViewProps {
200
81
  }
201
82
 
202
83
  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.
84
+ // latest mutates each render so stable slot closures always see current tree.
206
85
  const latest = useRef({ node, tree, config })
207
86
  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.
87
+ // Cache slot components by name: fresh closures remount the slot subtree and reset descendant hooks.
217
88
  const slotCache = useRef<Record<string, () => ReactNode>>({})
218
89
 
219
90
  const registered = config.views[node.name]
@@ -236,17 +107,7 @@ const WireNodeView = ({ node, tree, config }: WireNodeViewProps): ReactNode => {
236
107
  onSome: (schema) => Schema.decodeUnknownSync(schema)(encoded),
237
108
  })
238
109
 
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/>`).
110
+ // slotKey one child by wire key (list-per-item); omit all children of the slot.
250
111
  const slotFor = (slotName: string): ((slotProps?: SlotPropsExternalApi) => ReactNode) => {
251
112
  const cached = slotCache.current[slotName]
252
113
  if (cached !== undefined) {
@@ -275,10 +136,7 @@ const WireNodeView = ({ node, tree, config }: WireNodeViewProps): ReactNode => {
275
136
  return stable
276
137
  }
277
138
 
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.
139
+ // Lazy by name: empty slots yield a no-op component, never undefined (invalid element type).
282
140
  const slots: Record<string, (slotProps?: SlotPropsExternalApi) => ReactNode> = new Proxy(
283
141
  Object.create(null),
284
142
  { get: (_target, key) => (typeof key === 'string' ? slotFor(key) : undefined) },
package/src/connect.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { Match } from 'effect'
2
+ import type { ReactNode } from 'react'
3
+ import { Wire, type WireTree } from '@playfast/reform'
4
+ import { renderWireTree, type ClientConfig, type RemoteContract, type RemoteViewSet } from './client'
5
+ import type { InvokeMessage, RemoteTransport, ServerMessage } from './transport'
6
+
7
+ export interface ClientBinding {
8
+ readonly node: () => ReactNode
9
+ readonly subscribe: (listener: () => void) => () => void
10
+ readonly snapshot: () => WireTree
11
+ readonly dispose: () => void
12
+ }
13
+
14
+ export interface ConnectOptions<C extends RemoteContract> {
15
+ readonly transport: RemoteTransport<InvokeMessage, ServerMessage>
16
+ readonly views: RemoteViewSet<C>
17
+ }
18
+
19
+ export const connect = <C extends RemoteContract>(options: ConnectOptions<C>): ClientBinding => {
20
+ const { transport, views } = options
21
+ const state: { tree: WireTree } = { tree: [] }
22
+ const listeners = new Set<() => void>()
23
+ const config: ClientConfig<C> = {
24
+ views,
25
+ invoke: (handle, payload) => transport.send({ _tag: 'Invoke', handle, payload }),
26
+ }
27
+ const off = transport.onMessage((message) => {
28
+ state.tree = Match.value(message).pipe(
29
+ Match.tag('Snapshot', ({ tree }) => tree),
30
+ Match.tag('Patches', ({ patches }) => Wire.apply(state.tree, patches)),
31
+ Match.exhaustive,
32
+ )
33
+ listeners.forEach((listener) => listener())
34
+ })
35
+ return {
36
+ node: () => renderWireTree(state.tree, config),
37
+ subscribe: (listener) => {
38
+ listeners.add(listener)
39
+ return () => void listeners.delete(listener)
40
+ },
41
+ snapshot: () => state.tree,
42
+ dispose: off,
43
+ }
44
+ }
@@ -0,0 +1,3 @@
1
+ import type { RemoteContract } from './client'
2
+
3
+ export const remoteContract = <const C extends RemoteContract>(contract: C): C => contract
package/src/fixtures.ts CHANGED
@@ -24,17 +24,6 @@ import {
24
24
  ui,
25
25
  } from '@playfast/reform'
26
26
 
27
- /**
28
- * Shared scenes the remote-transport tests render server-side. Each is a closed
29
- * `Scene` wired the canonical way — compositions + reducers in `Layer.mergeAll`,
30
- * then `provideMerge(presentation)` (views + seeded state) and `provideMerge(Engine)`
31
- * — so they exercise the real runtime, not a stub. The compositions return
32
- * headless structures; the server expands slot fills from those structures and the
33
- * client renders the resulting wire tree with its own `RemoteView`s (thunk slots),
34
- * which the transport tests supply separately.
35
- */
36
-
37
- // ── counter: a flat composition with own state + one event ────────────────────
38
27
  class Count extends State.make('count', S.Number) {}
39
28
  class Counters extends StateGroup.make(Count) {}
40
29
  class Bumped extends Event.make('Bumped', S.Struct({ by: S.Number })) {}
@@ -51,7 +40,6 @@ const CounterUiBase: WiredUi<
51
40
  export class CounterUi extends CounterUiBase {}
52
41
  class Counter extends Composition.make('Counter', { title: 'Counter', ui: CounterUi, states: [Count] }) {}
53
42
 
54
- /** A boot event the counter scene dispatches before its first render. */
55
43
  export const bumpedBy = (amount: number): EventOf<'Bumped', { by: number }> =>
56
44
  Event.construct(Bumped, { by: amount })
57
45
 
@@ -71,10 +59,6 @@ export const counterScene = (boot?: ReadonlyArray<EventOf<'Bumped', { by: number
71
59
  return scene(Counter, boot === undefined ? { provide: [app] } : { provide: [app], boot })
72
60
  }
73
61
 
74
- // ── structure counter: kept as explicit structure-event coverage. The `bump`
75
- // trigger rides ON the structure, so the server reads it from `structure.events`,
76
- // registers the handle, and produces the same wire shape as a legacy view body.
77
- // ───────────────────────────────────────────────────────────────────────────────
78
62
  export const structureCounterScene = (): Scene => {
79
63
  const presentation = Layer.mergeAll(
80
64
  provide(CounterUi, Ui.make(CounterUi, ({ count }) => `count:${count}`)),
@@ -91,9 +75,6 @@ export const structureCounterScene = (): Scene => {
91
75
  return scene(Counter, { provide: [app] })
92
76
  }
93
77
 
94
- // ── option: a flat composition whose prop is an `Option` (an Effect-native, non-JSON
95
- // value) — exercises the symmetric schema encode/decode so the client receives a REAL
96
- // `Option`, not its raw `{_tag,value}` wire shape. ──────────────────────────────────
97
78
  class Label extends State.make('label', S.OptionFromSelf(S.String)) {}
98
79
  class Labels extends StateGroup.make(Label) {}
99
80
  const OptionalUiBase: WiredUi<
@@ -116,7 +97,6 @@ export const optionScene = (initial: Option.Option<string> = Option.some('hi')):
116
97
  return scene(Optional, { provide: [app] })
117
98
  }
118
99
 
119
- // ── slotted: a shell whose one slot holds a child with its OWN state + event ───
120
100
  class Greeting extends State.make('greeting', S.String) {}
121
101
  class Greeted extends Event.make('Greeted', S.Struct({ text: S.String })) {}
122
102
  class Cleared extends Event.make('Cleared', S.Struct({})) {}
@@ -124,7 +104,6 @@ class SetGreeting extends Reducer.make('SetGreeting', { states: [Greeting], even
124
104
  class ClearGreeting extends Reducer.make('ClearGreeting', { states: [Greeting], events: [Cleared] }) {}
125
105
  class PanelUi extends ui('Panel', {
126
106
  props: S.Struct({ greeting: S.String }),
127
- // Two events on one node — each registers behind its own `${id}:${name}` handle.
128
107
  events: { greet: S.Struct({ text: S.String }), clear: S.Struct({}) },
129
108
  }) {}
130
109
  class Panel extends Composition.make('Panel', { title: 'Panel', ui: PanelUi, states: [Greeting] }) {}
@@ -141,6 +120,7 @@ export const slottedScene = (): Scene => {
141
120
  )
142
121
  const app = Layer.mergeAll(
143
122
  Composition.live(Shell, function* () {
123
+ yield* Effect.void
144
124
  return mount({ props: {}, slots: { Main: one({}) } })
145
125
  }),
146
126
  Composition.live(Panel, function* () {
@@ -156,7 +136,6 @@ export const slottedScene = (): Scene => {
156
136
  return scene(Shell, { provide: [app] })
157
137
  }
158
138
 
159
- // ── list: a parent with two slots — a single-child toolbar and a dynamic list ──
160
139
  const ListItem = S.Struct({ id: S.String, label: S.String })
161
140
  class Items extends State.make('items', S.Array(ListItem)) {}
162
141
  class Added extends Event.make('Added', ListItem) {}
@@ -230,8 +209,7 @@ export const listScene = (
230
209
  Composition.live(ItemComp, function* () {
231
210
  const itemProps = S.decodeUnknownSync(ListItem)(yield* Props)
232
211
  const removeTrigger = yield* Event.trigger(Removed)
233
- // The item binds its own id, so the wire `remove` carries no payload — the
234
- // client fires it knowing only the handle.
212
+ // Item binds its own id into remove so the wire event has empty payload.
235
213
  const remove = (): void => removeTrigger({ id: itemProps.id })
236
214
  return mount({ props: { label: itemProps.label }, slots: {}, events: { remove } })
237
215
  }),
@@ -241,11 +219,7 @@ export const listScene = (
241
219
  return scene(ListComp, { provide: [app] })
242
220
  }
243
221
 
244
- // ── async: a counter whose state changes AFTER the opening snapshot, with no client
245
- // invoke. A boot event kicks a procedure that sleeps, then dispatches `Bumped` — modelling
246
- // a background load (a repo list, a reconcile sweep) that resolves long after connect. This
247
- // is the ONLY fixture that exercises the server-initiated streaming push: every other scene
248
- // settles before its first render, so its full state is already in the snapshot. ──────────
222
+ // Boot Kick DelayedBump sleeps past opening snapshot, then Bumped — only path for server-pushed diffs.
249
223
  class Kick extends Event.make('Kick', S.Struct({ to: S.Number })) {}
250
224
  class Loader extends Channel.make('Loader', { policy: { _tag: 'latest' } }) {}
251
225
  class DelayedBump extends Procedure.make('DelayedBump', { channel: Loader, events: [Kick] }) {}
@@ -262,15 +236,11 @@ export const asyncCounterScene = (delay: Duration.DurationInput = '40 millis'):
262
236
  return mount({ props: { count }, slots: {}, events: { bump } })
263
237
  }),
264
238
  Reducer.live(Bump, (count, event) => count + event.by),
265
- // The procedure runs on the bus AFTER the scene boots: it sleeps past the opening
266
- // snapshot, then dispatches `Bumped` — so the only way the client learns the new count
267
- // is the server pushing a diff nobody asked for.
268
239
  Procedure.live(DelayedBump, function* (event) {
269
240
  yield* Effect.sleep(delay)
270
241
  yield* Event.dispatch(Bumped, { by: event.to })
271
242
  }),
272
- // The procedure runs on a named channel; without its queue+fiber (`Channel.live`)
273
- // the boot `Kick` is dropped and nothing ever bumps.
243
+ // Without Channel.live the boot Kick is dropped.
274
244
  Channel.live(Loader),
275
245
  ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine))
276
246
  return scene(Counter, { provide: [app], boot: [Event.construct(Kick, { to: 7 })] })
package/src/index.ts CHANGED
@@ -1,13 +1,3 @@
1
- // @playfast/reform-remote — run a reform scene's logic on the server and stream
2
- // its rendered UI to a thin client over a transport. A fourth consumer of a
3
- // `Scene`, alongside @playfast/react, @playfast/react-native, and @playfast/proof.
4
- // See REMOTE_UI.md.
5
- //
6
- // Public API is exposed two ways (Effect-style): namespace barrels for discovery
7
- // (`import { Transport } from "@playfast/reform-remote"`) and per-path subpaths
8
- // for direct use (`import { connect } from "@playfast/reform-remote/transport"`).
9
- // The select re-exports below keep the common entry points nameable from the root.
10
-
11
1
  export * as Server from './server'
12
2
  export * as Client from './client'
13
3
  export * as Transport from './transport'
package/src/memory.ts CHANGED
@@ -1,14 +1,5 @@
1
1
  import type { RemoteTransport } from './transport'
2
2
 
3
- /**
4
- * The in-memory transport adapter: a duplex pair where what one end sends, the
5
- * other receives — synchronously, in process. It is the simplest concrete
6
- * `RemoteTransport`, and the one the test suite drives `serve`/`connect` over
7
- * without a socket. A WebSocket adapter is the same shape with JSON framing
8
- * across a wire; proving the loop here proves it everywhere.
9
- */
10
-
11
- /** A linked `server`/`client` transport: each receives exactly what the other sends. */
12
3
  export interface TransportPair<Server, Client> {
13
4
  readonly server: RemoteTransport<Server, Client>
14
5
  readonly client: RemoteTransport<Client, Server>
package/src/react.ts CHANGED
@@ -7,56 +7,30 @@ import {
7
7
  } from './transport'
8
8
  import type { RemoteContract, RemoteViewSet } from './client'
9
9
 
10
- /**
11
- * The React binding for the remote client. A consumer hands it a transport and a
12
- * `views` map and gets a live React subtree — the framework owns the transport
13
- * wiring (connect, the patch subscription, re-render on each frame, teardown on
14
- * unmount). No `useSyncExternalStore`, `binding.subscribe`, or `binding.node()`
15
- * at the call site.
16
- */
17
-
18
- /**
19
- * Render the remote scene as React. Pass a stable `transport` (created once,
20
- * e.g. by `createWebSocketClientTransport`) — the binding is established on mount
21
- * and disposed on unmount.
22
- */
23
10
  export const useRemoteUI = <C extends RemoteContract>(
24
11
  transport: RemoteTransport<InvokeMessage, ServerMessage>,
25
12
  views: RemoteViewSet<C>,
26
13
  ): ReactNode => {
27
- // One binding for the life of the component (the transport is stable).
28
14
  const [binding] = useState(() => connect({ transport, views }))
29
15
  useEffect(() => () => binding.dispose(), [binding])
30
- // Re-render whenever a frame changes the tree; `snapshot` is a stable ref.
31
16
  useSyncExternalStore(binding.subscribe, binding.snapshot, binding.snapshot)
32
17
  return binding.node()
33
18
  }
34
19
 
35
20
  export interface RemoteUIProps<C extends RemoteContract> {
36
21
  readonly transport: RemoteTransport<InvokeMessage, ServerMessage>
37
- /** A contract-checked view set from `remoteViews<C>(…)` — an unbranded `Record` is rejected,
38
- * so `<RemoteUI>` can only render views proven to implement the server's shape. */
39
22
  readonly views: RemoteViewSet<C>
40
23
  }
41
24
 
42
- /** Component form of {@link useRemoteUI}: `<RemoteUI transport={…} views={…} />`. `C` is
43
- * inferred from `views` (the `remoteViews<AppContract>(…)` result), so the whole subtree is
44
- * typed to that app's contract. */
45
25
  export const RemoteUI = <C extends RemoteContract>({
46
26
  transport,
47
27
  views,
48
28
  }: RemoteUIProps<C>): ReactNode => useRemoteUI(transport, views)
49
29
 
50
- /**
51
- * Anything that reports an observable connection status — implemented by the
52
- * WebSocket client transport. Kept structural so the React binding needs no
53
- * dependency on a specific adapter.
54
- */
55
30
  export interface StatusReporter<S> {
56
31
  readonly status: () => S
57
32
  readonly onStatusChange: (listener: () => void) => () => void
58
33
  }
59
34
 
60
- /** Subscribe to a transport's connection status (e.g. to show a reconnecting badge). */
61
35
  export const useConnectionStatus = <S>(reporter: StatusReporter<S>): S =>
62
36
  useSyncExternalStore(reporter.onStatusChange, reporter.status, reporter.status)