@xmachines/play-react 1.0.0-beta.4 → 1.0.0-beta.41
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/LICENSE +21 -0
- package/README.md +300 -260
- package/dist/ActorProvider.d.ts +82 -0
- package/dist/ActorProvider.d.ts.map +1 -0
- package/dist/ActorProvider.js +167 -0
- package/dist/ActorProvider.js.map +1 -0
- package/dist/PlayErrorBoundary.d.ts +55 -0
- package/dist/PlayErrorBoundary.d.ts.map +1 -0
- package/dist/PlayErrorBoundary.js +45 -0
- package/dist/PlayErrorBoundary.js.map +1 -0
- package/dist/PlayRenderer.d.ts +26 -47
- package/dist/PlayRenderer.d.ts.map +1 -1
- package/dist/PlayRenderer.js +31 -76
- package/dist/PlayRenderer.js.map +1 -1
- package/dist/PlayUIProvider.d.ts +67 -0
- package/dist/PlayUIProvider.d.ts.map +1 -0
- package/dist/PlayUIProvider.js +73 -0
- package/dist/PlayUIProvider.js.map +1 -0
- package/dist/errors.d.ts +23 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +26 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +31 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -2
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +6 -18
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -1
- package/dist/useActor.d.ts +26 -0
- package/dist/useActor.d.ts.map +1 -0
- package/dist/useActor.js +27 -0
- package/dist/useActor.js.map +1 -0
- package/dist/useSignalEffect.d.ts +6 -0
- package/dist/useSignalEffect.d.ts.map +1 -1
- package/dist/useSignalEffect.js +36 -19
- package/dist/useSignalEffect.js.map +1 -1
- package/package.json +32 -16
package/README.md
CHANGED
|
@@ -1,358 +1,398 @@
|
|
|
1
1
|
# @xmachines/play-react
|
|
2
2
|
|
|
3
|
-
**React renderer
|
|
3
|
+
**React renderer for XMachines Play Architecture**
|
|
4
4
|
|
|
5
|
-
Signal-driven React
|
|
5
|
+
Bridges TC39 Signal-driven actors to React's render cycle. Business logic stays in the actor; React is purely a rendering target.
|
|
6
6
|
|
|
7
7
|
## Overview
|
|
8
8
|
|
|
9
|
-
`@xmachines/play-react` provides `PlayRenderer
|
|
9
|
+
`@xmachines/play-react` provides `PlayRenderer`, a React component that:
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
- Subscribes to `actor.currentView` (TC39 Signal) and re-renders on every state transition
|
|
12
|
+
- Renders the current view's JSON spec via `@json-render/react`
|
|
13
|
+
- Routes action names from spec elements to `actor.send()` via the `actions` prop
|
|
14
|
+
- Manages per-view UI state in an `@xstate/store` atom (automatic or caller-supplied)
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
- **Passive Infrastructure (INV-04):** Components observe signals, send events to actor
|
|
16
|
+
Per [Play RFC](../docs/rfc/play.md):
|
|
15
17
|
|
|
16
|
-
**
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
- **Actor Authority (INV-01):** Guards in the machine decide all state transitions
|
|
19
|
+
- **Passive Infrastructure (INV-04):** React observes signals and dispatches events — never decides
|
|
20
|
+
- **Signal-Only Reactivity (INV-05):** `actor.currentView` signal is the sole render trigger
|
|
19
21
|
|
|
20
22
|
## Installation
|
|
21
23
|
|
|
22
24
|
```bash
|
|
23
|
-
npm install react@^18.0.0 react-dom@^18.0.0
|
|
24
25
|
npm install @xmachines/play-react
|
|
26
|
+
npm install @json-render/react @json-render/core # peer deps
|
|
27
|
+
npm install @json-render/xstate @xstate/store # store integration
|
|
25
28
|
```
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
- `useSignalEffect`
|
|
31
|
-
- `PlayRendererProps` (type)
|
|
30
|
+
In this monorepo, the root install applies a `patch-package` patch to `@json-render/react`
|
|
31
|
+
so `defineRegistry(..., { onRenderError })` can intercept inner element-boundary errors
|
|
32
|
+
without muting console output.
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
## Current Exports
|
|
34
35
|
|
|
35
|
-
- `
|
|
36
|
-
- `
|
|
36
|
+
- `PlayRenderer` — main renderer component
|
|
37
|
+
- `useActor` — hook for accessing the actor inside a `PlayRenderer` tree
|
|
38
|
+
- `useSignalEffect` — React hook for subscribing to TC39 Signals
|
|
39
|
+
- `PlayErrorBoundary` — error boundary wrapping renderer output
|
|
40
|
+
- `defineRegistry` — re-exported from `@json-render/react`
|
|
41
|
+
- `useBoundProp` — re-exported from `@json-render/react`
|
|
42
|
+
- `ComponentFn` (type) — re-exported from `@json-render/react`
|
|
43
|
+
- `ComponentContext` (type) — re-exported from `@json-render/react`
|
|
44
|
+
- `ActorProvider` — escape hatch primitive (owns actor bridging, signal bridge, store lifecycle)
|
|
45
|
+
- `PlayUIProvider` — batteries-included composite (wraps `ActorProvider` + `JSONUIProvider`)
|
|
46
|
+
- `usePlayView` — hook for accessing the current view spec inside a provider tree
|
|
47
|
+
- `RenderErrorHandler` (type) — inner per-element error callback signature
|
|
48
|
+
- `ActorProviderProps` (type)
|
|
49
|
+
- `ViewContextValue` (type)
|
|
50
|
+
- `PlayActor` (type)
|
|
37
51
|
|
|
38
52
|
## Quick Start
|
|
39
53
|
|
|
40
|
-
```
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
import { defineCatalog } from "@
|
|
44
|
-
import {
|
|
54
|
+
```tsx
|
|
55
|
+
import { definePlayer, formatPlayRouteTransitions } from "@xmachines/play-xstate";
|
|
56
|
+
import { PlayUIProvider, PlayRenderer } from "@xmachines/play-react";
|
|
57
|
+
import { defineCatalog } from "@json-render/core";
|
|
58
|
+
import { defineRegistry } from "@xmachines/play-react";
|
|
59
|
+
import type { ComponentFn } from "@xmachines/play-react";
|
|
60
|
+
import { setup, assign } from "xstate";
|
|
45
61
|
import { z } from "zod";
|
|
46
62
|
|
|
47
|
-
// 1. Define catalog
|
|
63
|
+
// 1. Define catalog — the contract between machine spec and UI components
|
|
48
64
|
const catalog = defineCatalog({
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
65
|
+
elements: {
|
|
66
|
+
Login: { props: z.object({ title: z.string() }), description: "Login form" },
|
|
67
|
+
Dashboard: { props: z.object({ username: z.string() }), description: "Dashboard" },
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// 2. Implement components using ComponentFn — typed against catalog entries
|
|
72
|
+
const Login: ComponentFn<typeof catalog, "Login"> = ({ props, emit }) => (
|
|
73
|
+
<form
|
|
74
|
+
onSubmit={(e) => {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
emit("submit");
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
<h2>{props.title}</h2>
|
|
80
|
+
<button type="submit">Log In</button>
|
|
81
|
+
</form>
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const Dashboard: ComponentFn<typeof catalog, "Dashboard"> = ({ props }) => (
|
|
85
|
+
<div>Welcome, {props.username}!</div>
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// 3. Build registry — wires components to catalog and declares action handlers
|
|
89
|
+
const registryResult = defineRegistry(catalog, {
|
|
90
|
+
components: { Login, Dashboard },
|
|
91
|
+
actions: {
|
|
92
|
+
login: async (params) => {
|
|
93
|
+
if (!params) return;
|
|
94
|
+
actor.send({ type: "auth.login", username: params.username });
|
|
95
|
+
},
|
|
96
|
+
logout: async () => {
|
|
97
|
+
actor.send({ type: "auth.logout" });
|
|
98
|
+
},
|
|
99
|
+
},
|
|
54
100
|
});
|
|
55
101
|
|
|
56
|
-
//
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
102
|
+
// 4. Define machine with view metadata
|
|
103
|
+
const machine = setup({
|
|
104
|
+
types: {
|
|
105
|
+
context: {} as {
|
|
106
|
+
isAuthenticated: boolean;
|
|
107
|
+
username: string | null;
|
|
108
|
+
params: Record<string, string>;
|
|
109
|
+
query: Record<string, string>;
|
|
110
|
+
},
|
|
111
|
+
events: {} as
|
|
112
|
+
| { type: "auth.login"; username: string }
|
|
113
|
+
| { type: "auth.logout" }
|
|
114
|
+
| { type: "play.route"; to: string; params?: Record<string, string> },
|
|
115
|
+
},
|
|
116
|
+
}).createMachine(
|
|
117
|
+
formatPlayRouteTransitions({
|
|
118
|
+
id: "app",
|
|
119
|
+
initial: "login",
|
|
120
|
+
context: { isAuthenticated: false, username: null, params: {}, query: {} },
|
|
121
|
+
states: {
|
|
122
|
+
login: {
|
|
123
|
+
id: "login",
|
|
124
|
+
meta: {
|
|
125
|
+
route: "/login",
|
|
126
|
+
view: {
|
|
127
|
+
root: "root",
|
|
128
|
+
elements: {
|
|
129
|
+
root: { type: "Login", props: { title: "Sign In" }, children: [] },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
dashboard: {
|
|
135
|
+
id: "dashboard",
|
|
136
|
+
meta: {
|
|
137
|
+
route: "/dashboard",
|
|
138
|
+
view: {
|
|
139
|
+
root: "root",
|
|
140
|
+
elements: {
|
|
141
|
+
root: { type: "Dashboard", props: { username: "" }, children: [] },
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
on: {
|
|
148
|
+
"auth.login": {
|
|
149
|
+
target: ".dashboard",
|
|
150
|
+
guard: ({ context }) => !context.isAuthenticated,
|
|
151
|
+
actions: assign({
|
|
152
|
+
isAuthenticated: true,
|
|
153
|
+
username: ({ event }) => event.username,
|
|
154
|
+
}),
|
|
155
|
+
},
|
|
156
|
+
"auth.logout": {
|
|
157
|
+
target: ".login",
|
|
158
|
+
guard: ({ context }) => context.isAuthenticated,
|
|
159
|
+
actions: assign({ isAuthenticated: false, username: null }),
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// 5. Create actor and render
|
|
166
|
+
const createPlayer = definePlayer({ machine });
|
|
87
167
|
const actor = createPlayer();
|
|
88
168
|
actor.start();
|
|
89
169
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
170
|
+
function App() {
|
|
171
|
+
return (
|
|
172
|
+
<PlayUIProvider actor={actor} registryResult={registryResult}>
|
|
173
|
+
<PlayRenderer />
|
|
174
|
+
</PlayUIProvider>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
93
177
|
```
|
|
94
178
|
|
|
95
179
|
## API Reference
|
|
96
180
|
|
|
97
|
-
###
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
actor
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
181
|
+
### `PlayUIProvider`
|
|
182
|
+
|
|
183
|
+
Batteries-included composite provider. Wraps `ActorProvider` + `JSONUIProvider`. Pass `actor` and `registryResult` here, then place `<PlayRenderer />` inside as a zero-prop child.
|
|
184
|
+
|
|
185
|
+
```tsx
|
|
186
|
+
<PlayUIProvider
|
|
187
|
+
actor={actor} // required
|
|
188
|
+
registryResult={registryResult} // required
|
|
189
|
+
store={myStore} // optional — controlled mode
|
|
190
|
+
fallback={<p>Loading…</p>} // optional
|
|
191
|
+
onError={(err, info) => Sentry.captureException(err, { extra: info })} // optional
|
|
192
|
+
onRenderError={(error, elementType) => console.warn(`<${elementType}> crashed:`, error)} // optional
|
|
193
|
+
>
|
|
194
|
+
<PlayRenderer />
|
|
195
|
+
</PlayUIProvider>
|
|
107
196
|
```
|
|
108
197
|
|
|
109
|
-
|
|
198
|
+
**`actor`** — A `PlayerActor` (or any `AbstractActor & Viewable`). Provides the `currentView` signal.
|
|
110
199
|
|
|
111
|
-
|
|
112
|
-
- `components` - Map of component names to React components
|
|
113
|
-
- `fallback` - Component shown when `currentView` is null (default: `null`)
|
|
200
|
+
**`registryResult`** — Full result from `defineRegistry(catalog, { components, actions })`. Contains both the component registry and action handlers.
|
|
114
201
|
|
|
115
|
-
|
|
202
|
+
**`store`** (optional) — Controls per-view UI state (form values, `$state` bindings):
|
|
116
203
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
3. Renders component with props from `view.props` + `send` function
|
|
204
|
+
- **Omitted (uncontrolled, default):** A fresh `@xstate/store` atom is created per view transition, seeded from `view.spec.state`. The atom resets automatically on each state transition.
|
|
205
|
+
- **Provided (controlled):** The caller owns the store lifecycle. `spec.state` is ignored.
|
|
120
206
|
|
|
121
|
-
|
|
207
|
+
```tsx
|
|
208
|
+
import { createAtom } from "@xstate/store";
|
|
209
|
+
import { xstateStoreStateStore } from "@json-render/xstate";
|
|
210
|
+
import type { StateStore } from "@json-render/core";
|
|
122
211
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
AboutPage: ({ send }) => <div>About</div>,
|
|
129
|
-
}}
|
|
130
|
-
fallback={<div>Loading...</div>}
|
|
131
|
-
/>
|
|
212
|
+
const store: StateStore = xstateStoreStateStore({ atom: createAtom({ username: "alice" }) });
|
|
213
|
+
|
|
214
|
+
<PlayUIProvider actor={actor} registryResult={registryResult} store={store}>
|
|
215
|
+
<PlayRenderer />
|
|
216
|
+
</PlayUIProvider>;
|
|
132
217
|
```
|
|
133
218
|
|
|
134
|
-
|
|
219
|
+
**`fallback`** — Shown when `actor.currentView.get()` is `null` (machine in a no-view state, or during initialisation).
|
|
135
220
|
|
|
136
|
-
|
|
221
|
+
**`onError`** — Called when the outer `PlayErrorBoundary` catches an error. Receives `(error: Error, info: React.ErrorInfo)`. Use for observability tools (Sentry, Datadog, etc.).
|
|
137
222
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
223
|
+
**`onRenderError`** — Called when an individual catalog component throws during render. Caught by `@json-render/react`'s inner per-element boundary — the failed component is silently removed while the rest of the spec continues rendering. `onError` / `fallback` are **not** triggered. When both `onRenderError` on `PlayUIProvider` and on `defineRegistry` are set, the prop wins.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
### `ActorProvider`
|
|
228
|
+
|
|
229
|
+
Escape hatch primitive. Owns actor bridging, signal bridge, and store lifecycle. Use this when you need direct control over the provider layer (e.g. custom `JSONUIProvider` configuration).
|
|
230
|
+
|
|
231
|
+
```tsx
|
|
232
|
+
import { ActorProvider } from "@xmachines/play-react";
|
|
233
|
+
|
|
234
|
+
<ActorProvider
|
|
235
|
+
actor={actor}
|
|
236
|
+
registryResult={registryResult}
|
|
237
|
+
onRenderError={(err, type) => reportError(err, type)}
|
|
238
|
+
>
|
|
239
|
+
{/* your own JSONUIProvider + PlayRenderer tree */}
|
|
240
|
+
</ActorProvider>;
|
|
143
241
|
```
|
|
144
242
|
|
|
145
|
-
|
|
243
|
+
---
|
|
146
244
|
|
|
147
|
-
|
|
148
|
-
- Uses `Signal.subtle.Watcher` with microtask batching
|
|
149
|
-
- Triggers React state update to force re-render
|
|
150
|
-
- Cleans up watcher on unmount with explicit `unwatch`
|
|
245
|
+
### `PlayRenderer`
|
|
151
246
|
|
|
152
|
-
|
|
247
|
+
Zero-prop leaf component. Must be rendered inside a `PlayUIProvider` (or `ActorProvider`) tree. Subscribes to `actor.currentView` via context and renders the current spec.
|
|
153
248
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
249
|
+
```tsx
|
|
250
|
+
<PlayUIProvider actor={actor} registryResult={registryResult}>
|
|
251
|
+
<PlayRenderer />
|
|
252
|
+
</PlayUIProvider>
|
|
253
|
+
```
|
|
159
254
|
|
|
160
|
-
|
|
255
|
+
`PlayRenderer` accepts no props — all configuration (`actor`, `registryResult`, `store`, `fallback`, `onError`, `onRenderError`) is provided by the enclosing `PlayUIProvider` or `ActorProvider`.
|
|
161
256
|
|
|
162
|
-
|
|
257
|
+
## Error handling
|
|
163
258
|
|
|
164
|
-
|
|
165
|
-
import { useSignalEffect } from "@xmachines/play-react";
|
|
166
|
-
import { useState } from "react";
|
|
259
|
+
The provider tree has two layers of error boundaries:
|
|
167
260
|
|
|
168
|
-
|
|
169
|
-
const [route, setRoute] = useState<string | null>(null);
|
|
261
|
+
### Outer boundary — `onError` and `fallback`
|
|
170
262
|
|
|
171
|
-
|
|
172
|
-
const currentRoute = actor.currentRoute.get();
|
|
173
|
-
setRoute(currentRoute);
|
|
174
|
-
});
|
|
263
|
+
Wraps the entire renderer output via `PlayErrorBoundary`. Triggered when the spec or store setup throws, or when the inner boundary is not present.
|
|
175
264
|
|
|
176
|
-
|
|
177
|
-
|
|
265
|
+
```tsx
|
|
266
|
+
<PlayUIProvider
|
|
267
|
+
actor={actor}
|
|
268
|
+
registryResult={registryResult}
|
|
269
|
+
fallback={<p>Something went wrong.</p>}
|
|
270
|
+
onError={(err, info) => Sentry.captureException(err, { extra: info })}
|
|
271
|
+
>
|
|
272
|
+
<PlayRenderer />
|
|
273
|
+
</PlayUIProvider>
|
|
178
274
|
```
|
|
179
275
|
|
|
180
|
-
|
|
276
|
+
### Inner boundary — `onRenderError`
|
|
181
277
|
|
|
182
|
-
|
|
278
|
+
Each catalog element is individually wrapped in an error boundary by `@json-render/react`. When a component throws, it is silently removed while the rest of the spec continues rendering. The outer boundary is **not** triggered.
|
|
183
279
|
|
|
184
|
-
|
|
280
|
+
Pass `onRenderError` to `PlayUIProvider` (or `ActorProvider`) — overrides any registry-level handler — or bake it into `defineRegistry`:
|
|
185
281
|
|
|
186
|
-
```
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
282
|
+
```tsx
|
|
283
|
+
// via PlayUIProvider prop
|
|
284
|
+
<PlayUIProvider
|
|
285
|
+
actor={actor}
|
|
286
|
+
registryResult={registryResult}
|
|
287
|
+
onRenderError={(error, elementType) => {
|
|
288
|
+
console.warn(`<${elementType}> crashed:`, error);
|
|
289
|
+
}}
|
|
290
|
+
>
|
|
291
|
+
<PlayRenderer />
|
|
292
|
+
</PlayUIProvider>
|
|
293
|
+
```
|
|
190
294
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
followers: z.number(),
|
|
200
|
-
}),
|
|
201
|
-
}),
|
|
295
|
+
```ts
|
|
296
|
+
// via defineRegistry — bakes the handler into the registry
|
|
297
|
+
const registryResult = defineRegistry(catalog, {
|
|
298
|
+
components: { Login, Dashboard },
|
|
299
|
+
actions: { login: async (params) => { ... }, logout: async () => { ... } },
|
|
300
|
+
onRenderError(error, elementType) {
|
|
301
|
+
reportExpectedRenderError(error, elementType);
|
|
302
|
+
},
|
|
202
303
|
});
|
|
203
|
-
|
|
204
|
-
// Component receives type-safe props + send
|
|
205
|
-
const components = {
|
|
206
|
-
UserProfile: ({ userId, name, avatar, stats, send }) => (
|
|
207
|
-
<div>
|
|
208
|
-
{avatar && <img src={avatar} alt={name} />}
|
|
209
|
-
<h1>{name}</h1>
|
|
210
|
-
<p>ID: {userId}</p>
|
|
211
|
-
<div>
|
|
212
|
-
<span>{stats.posts} posts</span>
|
|
213
|
-
<span>{stats.followers} followers</span>
|
|
214
|
-
</div>
|
|
215
|
-
<button
|
|
216
|
-
onClick={() =>
|
|
217
|
-
send({
|
|
218
|
-
type: "profile.edit",
|
|
219
|
-
userId,
|
|
220
|
-
})
|
|
221
|
-
}
|
|
222
|
-
>
|
|
223
|
-
Edit Profile
|
|
224
|
-
</button>
|
|
225
|
-
</div>
|
|
226
|
-
),
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
<PlayRenderer actor={actor} components={components} />;
|
|
230
304
|
```
|
|
231
305
|
|
|
232
|
-
|
|
306
|
+
`onRenderError` is typed as `RenderErrorHandler` and exported from `@xmachines/play-react`.
|
|
233
307
|
|
|
234
|
-
|
|
235
|
-
import { useSignalEffect } from "@xmachines/play-react";
|
|
236
|
-
import { AbstractActor } from "@xmachines/play-actor";
|
|
308
|
+
---
|
|
237
309
|
|
|
238
|
-
|
|
239
|
-
const [view, setView] = useState(null);
|
|
310
|
+
### `useActor`
|
|
240
311
|
|
|
241
|
-
|
|
242
|
-
useSignalEffect(() => {
|
|
243
|
-
const currentView = actor.currentView.get();
|
|
244
|
-
setView(currentView);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
if (!view) return <div>No view</div>;
|
|
312
|
+
Hook for accessing the actor from inside any component rendered by `PlayRenderer`. No prop drilling needed.
|
|
248
313
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
return <SpecialCaseComponent {...view.props} actor={actor} />;
|
|
252
|
-
}
|
|
314
|
+
```tsx
|
|
315
|
+
import { useActor } from "@xmachines/play-react";
|
|
253
316
|
|
|
254
|
-
|
|
255
|
-
|
|
317
|
+
// Works in any component rendered inside PlayRenderer:
|
|
318
|
+
function LogoutButton() {
|
|
319
|
+
const actor = useActor();
|
|
320
|
+
return <button onClick={() => actor.send({ type: "auth.logout" })}>Log Out</button>;
|
|
256
321
|
}
|
|
257
322
|
```
|
|
258
323
|
|
|
259
|
-
|
|
324
|
+
Throws `NonNullableError: "useActor() must be called inside <ActorProvider> (or <PlayUIProvider>)"` if called outside the tree.
|
|
260
325
|
|
|
261
|
-
|
|
262
|
-
import { PlayTanStackRouterProvider } from "@xmachines/play-tanstack-react-router";
|
|
263
|
-
import { PlayRenderer } from "@xmachines/play-react";
|
|
326
|
+
---
|
|
264
327
|
|
|
265
|
-
|
|
266
|
-
function App() {
|
|
267
|
-
return (
|
|
268
|
-
<PlayTanStackRouterProvider
|
|
269
|
-
actor={actor}
|
|
270
|
-
router={router}
|
|
271
|
-
renderer={(currentActor, currentRouter) => {
|
|
272
|
-
void currentRouter;
|
|
273
|
-
return (
|
|
274
|
-
<div>
|
|
275
|
-
<Header actor={currentActor} />
|
|
276
|
-
<PlayRenderer actor={currentActor} components={components} />
|
|
277
|
-
<Footer />
|
|
278
|
-
</div>
|
|
279
|
-
);
|
|
280
|
-
}}
|
|
281
|
-
/>
|
|
282
|
-
);
|
|
283
|
-
}
|
|
328
|
+
### `useSignalEffect`
|
|
284
329
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
330
|
+
Hook for subscribing to TC39 Signals in React components that live outside a `PlayRenderer` tree (e.g. nav bars, status indicators driven by actor state).
|
|
331
|
+
|
|
332
|
+
```tsx
|
|
333
|
+
import { useSignalEffect } from "@xmachines/play-react";
|
|
334
|
+
|
|
335
|
+
function NavBar({ actor }: { actor: ReturnType<typeof createPlayer> }) {
|
|
336
|
+
const [isAuth, setIsAuth] = useState(false);
|
|
288
337
|
|
|
289
338
|
useSignalEffect(() => {
|
|
290
|
-
|
|
339
|
+
const snap = actor.state.get();
|
|
340
|
+
setIsAuth((snap.context as { isAuthenticated: boolean }).isAuthenticated);
|
|
291
341
|
});
|
|
292
342
|
|
|
293
|
-
return
|
|
294
|
-
<header>
|
|
295
|
-
<nav>Current: {route}</nav>
|
|
296
|
-
</header>
|
|
297
|
-
);
|
|
343
|
+
return <nav>{isAuth ? <LogoutBtn /> : <LoginBtn />}</nav>;
|
|
298
344
|
}
|
|
299
345
|
```
|
|
300
346
|
|
|
301
|
-
|
|
347
|
+
---
|
|
302
348
|
|
|
303
|
-
|
|
349
|
+
### `PlayErrorBoundary`
|
|
304
350
|
|
|
305
|
-
|
|
306
|
-
- No useState/useReducer for business state
|
|
307
|
-
- No useEffect for side effects
|
|
308
|
-
- React only triggers renders, doesn't control state
|
|
351
|
+
Class error boundary that wraps the rendered output. `PlayRenderer` wraps its own output in this boundary automatically — use this directly only if you need to wrap other content or nest boundaries manually.
|
|
309
352
|
|
|
310
|
-
|
|
311
|
-
- `actor.currentView.get()` provides UI structure
|
|
312
|
-
- `actor.currentRoute.get()` provides navigation state
|
|
313
|
-
- Components observe signals via `useSignalEffect`
|
|
353
|
+
`componentDidCatch` invokes the `onError` prop (for observability tools) but does **not** re-throw — re-throwing from `componentDidCatch` can unmount the entire React 19 root. `getDerivedStateFromError` handles fallback state transition instead.
|
|
314
354
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
- User actions send events to actor (e.g., `{ type: "auth.login" }`)
|
|
318
|
-
- Actor guards validate and process events
|
|
355
|
+
```tsx
|
|
356
|
+
import { PlayErrorBoundary } from "@xmachines/play-react";
|
|
319
357
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
358
|
+
<PlayErrorBoundary
|
|
359
|
+
fallback={<p>Something went wrong.</p>}
|
|
360
|
+
onError={(err, info) => Sentry.captureException(err, { extra: info })}
|
|
361
|
+
>
|
|
362
|
+
<PlayUIProvider actor={actor} registryResult={registryResult}>
|
|
363
|
+
<PlayRenderer />
|
|
364
|
+
</PlayUIProvider>
|
|
365
|
+
</PlayErrorBoundary>;
|
|
366
|
+
```
|
|
324
367
|
|
|
325
|
-
|
|
326
|
-
- Component teardown must call watcher `unwatch` in cleanup
|
|
327
|
-
- Do not rely on GC-only cleanup
|
|
368
|
+
---
|
|
328
369
|
|
|
329
|
-
|
|
370
|
+
## Route Parameters in Props
|
|
330
371
|
|
|
331
|
-
|
|
332
|
-
- Enables composition with navigation, headers, footers
|
|
333
|
-
- Supports multiple renderers in same app
|
|
372
|
+
When using `formatPlayRouteTransitions`, URL path parameters flow automatically into component props. Declare an `undefined` slot in the spec to opt in:
|
|
334
373
|
|
|
335
|
-
|
|
374
|
+
```ts
|
|
375
|
+
// spec: { section: undefined, user: "alice" }
|
|
376
|
+
// After play.route to /settings/profile → context.params = { section: "profile" }
|
|
377
|
+
// Component receives: { section: "profile", user: "alice" }
|
|
378
|
+
```
|
|
336
379
|
|
|
337
|
-
|
|
338
|
-
- **Passive Infrastructure (INV-04):** Components reflect, never decide
|
|
380
|
+
Priority: **route param fills `undefined` slots; explicit non-`undefined` spec props always win.**
|
|
339
381
|
|
|
340
|
-
|
|
382
|
+
---
|
|
341
383
|
|
|
342
|
-
|
|
343
|
-
- **Type Safety:** Props validated against catalog schemas
|
|
344
|
-
- **Simple Testing:** Test actors without React renderer
|
|
345
|
-
- **Performance:** Microtask batching reduces unnecessary renders
|
|
346
|
-
- **Composability:** Renderer prop enables complex layouts ()
|
|
384
|
+
## Error Handling
|
|
347
385
|
|
|
348
|
-
|
|
386
|
+
| Error | Cause | Fix |
|
|
387
|
+
| ------------------------------------------------------------------------ | ---------------------------- | ---------------------------------------------------------------- |
|
|
388
|
+
| `useActor() must be called inside <ActorProvider> (or <PlayUIProvider>)` | Hook called outside the tree | Wrap with `<PlayUIProvider>` or `<ActorProvider>` |
|
|
389
|
+
| Component render error | Component throws | `PlayErrorBoundary` catches it; inspect component implementation |
|
|
349
390
|
|
|
350
|
-
|
|
351
|
-
- **[@xmachines/play-catalog](../play-catalog)** - UI schema validation
|
|
352
|
-
- **[@xmachines/play-tanstack-react-router](../play-tanstack-react-router)** - TanStack Router integration
|
|
353
|
-
- **[@xmachines/play-actor](../play-actor)** - Actor base
|
|
354
|
-
- **[@xmachines/play-signals](../play-signals)** - TC39 Signals primitives
|
|
391
|
+
---
|
|
355
392
|
|
|
356
|
-
##
|
|
393
|
+
## Architecture Notes
|
|
357
394
|
|
|
358
|
-
|
|
395
|
+
- React `useState` is **only** used to trigger re-renders — never for business logic
|
|
396
|
+
- `actor.currentView` (TC39 Signal) is the sole render trigger; `PlayRenderer` is a passive observer
|
|
397
|
+
- Per-view UI state lives in an `@xstate/store` atom, not in React state
|
|
398
|
+
- `@json-render/react` drives rendering; `PlayRenderer` is the signal bridge — import `defineRegistry`, `ComponentFn`, `ComponentContext`, and `useBoundProp` from `@xmachines/play-react`
|