@playfast/reform-remote 1.0.1 → 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 +1 -1
- package/src/client-keyed-slot.test.ts +0 -22
- package/src/client-remount.test.ts +0 -23
- package/src/client.ts +10 -152
- package/src/connect.ts +44 -0
- package/src/contract.ts +3 -0
- package/src/fixtures.ts +4 -34
- package/src/index.ts +0 -10
- package/src/memory.ts +0 -9
- package/src/react.ts +0 -26
- package/src/remote-server.ts +109 -0
- package/src/remote-view.typecheck.ts +0 -15
- package/src/server.test.ts +2 -20
- package/src/server.ts +6 -209
- package/src/transport.test.ts +0 -24
- package/src/transport.ts +20 -162
- package/src/transport.types.ts +29 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform-remote",
|
|
3
3
|
"playbook": "./playbook",
|
|
4
|
-
"version": "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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
+
}
|
package/src/contract.ts
ADDED
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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)
|