@xmachines/play-react 1.0.0-beta.3 → 1.0.0-beta.30
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 +234 -266
- 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 +19 -28
- package/dist/PlayRenderer.d.ts.map +1 -1
- package/dist/PlayRenderer.js +61 -45
- package/dist/PlayRenderer.js.map +1 -1
- 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 +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +50 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/useActor.d.ts +26 -0
- package/dist/useActor.d.ts.map +1 -0
- package/dist/useActor.js +29 -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 +29 -12
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mikael Karon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,358 +1,326 @@
|
|
|
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
|
|
|
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.
|
|
33
|
+
|
|
27
34
|
## Current Exports
|
|
28
35
|
|
|
29
|
-
- `PlayRenderer`
|
|
30
|
-
- `
|
|
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
|
+
- `useStateBinding` — re-exported from `@json-render/react`
|
|
42
|
+
- `ComponentFn` (type) — re-exported from `@json-render/react`
|
|
43
|
+
- `ComponentContext` (type) — re-exported from `@json-render/react`
|
|
31
44
|
- `PlayRendererProps` (type)
|
|
32
|
-
|
|
33
|
-
**Peer dependencies:**
|
|
34
|
-
|
|
35
|
-
- `react` ^18.0.0 - React runtime
|
|
36
|
-
- `react-dom` ^18.0.0 - React DOM renderer
|
|
45
|
+
- `PlayActor` (type)
|
|
37
46
|
|
|
38
47
|
## Quick Start
|
|
39
48
|
|
|
40
|
-
```
|
|
41
|
-
import {
|
|
42
|
-
import { definePlayer } from "@xmachines/play-xstate";
|
|
43
|
-
import { defineCatalog } from "@xmachines/play-catalog";
|
|
49
|
+
```tsx
|
|
50
|
+
import { definePlayer, formatPlayRouteTransitions } from "@xmachines/play-xstate";
|
|
44
51
|
import { PlayRenderer } from "@xmachines/play-react";
|
|
52
|
+
import { defineCatalog } from "@json-render/core";
|
|
53
|
+
import { defineRegistry } from "@xmachines/play-react";
|
|
54
|
+
import type { ComponentFn } from "@xmachines/play-react";
|
|
55
|
+
import { setup, assign } from "xstate";
|
|
45
56
|
import { z } from "zod";
|
|
46
57
|
|
|
47
|
-
// 1. Define catalog
|
|
58
|
+
// 1. Define catalog — the contract between machine spec and UI components
|
|
48
59
|
const catalog = defineCatalog({
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
60
|
+
elements: {
|
|
61
|
+
Login: { props: z.object({ title: z.string() }), description: "Login form" },
|
|
62
|
+
Dashboard: { props: z.object({ username: z.string() }), description: "Dashboard" },
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// 2. Implement components using ComponentFn — typed against catalog entries
|
|
67
|
+
const Login: ComponentFn<typeof catalog, "Login"> = ({ props, emit }) => (
|
|
68
|
+
<form
|
|
69
|
+
onSubmit={(e) => {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
emit("submit");
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
<h2>{props.title}</h2>
|
|
75
|
+
<button type="submit">Log In</button>
|
|
76
|
+
</form>
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const Dashboard: ComponentFn<typeof catalog, "Dashboard"> = ({ props }) => (
|
|
80
|
+
<div>Welcome, {props.username}!</div>
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// 3. Build registry — wires components to catalog and declares no-op action stubs
|
|
84
|
+
const { registry } = defineRegistry(catalog, {
|
|
85
|
+
components: { Login, Dashboard },
|
|
86
|
+
actions: { login: async () => {}, logout: async () => {} },
|
|
54
87
|
});
|
|
55
88
|
|
|
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
|
-
|
|
89
|
+
// 4. Define machine with view metadata
|
|
90
|
+
const machine = setup({
|
|
91
|
+
types: {
|
|
92
|
+
context: {} as {
|
|
93
|
+
isAuthenticated: boolean;
|
|
94
|
+
username: string | null;
|
|
95
|
+
params: Record<string, string>;
|
|
96
|
+
query: Record<string, string>;
|
|
97
|
+
},
|
|
98
|
+
events: {} as
|
|
99
|
+
| { type: "auth.login"; username: string }
|
|
100
|
+
| { type: "auth.logout" }
|
|
101
|
+
| { type: "play.route"; to: string; params?: Record<string, string> },
|
|
102
|
+
},
|
|
103
|
+
}).createMachine(
|
|
104
|
+
formatPlayRouteTransitions({
|
|
105
|
+
id: "app",
|
|
106
|
+
initial: "login",
|
|
107
|
+
context: { isAuthenticated: false, username: null, params: {}, query: {} },
|
|
108
|
+
states: {
|
|
109
|
+
login: {
|
|
110
|
+
id: "login",
|
|
111
|
+
meta: {
|
|
112
|
+
route: "/login",
|
|
113
|
+
view: {
|
|
114
|
+
component: "Login",
|
|
115
|
+
spec: {
|
|
116
|
+
root: "root",
|
|
117
|
+
elements: {
|
|
118
|
+
root: { type: "Login", props: { title: "Sign In" }, children: [] },
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
dashboard: {
|
|
125
|
+
id: "dashboard",
|
|
126
|
+
meta: {
|
|
127
|
+
route: "/dashboard",
|
|
128
|
+
view: {
|
|
129
|
+
component: "Dashboard",
|
|
130
|
+
spec: {
|
|
131
|
+
root: "root",
|
|
132
|
+
elements: {
|
|
133
|
+
root: { type: "Dashboard", props: { username: "" }, children: [] },
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
on: {
|
|
141
|
+
"auth.login": {
|
|
142
|
+
target: ".dashboard",
|
|
143
|
+
guard: ({ context }) => !context.isAuthenticated,
|
|
144
|
+
actions: assign({
|
|
145
|
+
isAuthenticated: true,
|
|
146
|
+
username: ({ event }) => event.username,
|
|
147
|
+
}),
|
|
148
|
+
},
|
|
149
|
+
"auth.logout": {
|
|
150
|
+
target: ".login",
|
|
151
|
+
guard: ({ context }) => context.isAuthenticated,
|
|
152
|
+
actions: assign({ isAuthenticated: false, username: null }),
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// 5. Create actor and render
|
|
159
|
+
const createPlayer = definePlayer({ machine });
|
|
87
160
|
const actor = createPlayer();
|
|
88
161
|
actor.start();
|
|
89
162
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
163
|
+
function App() {
|
|
164
|
+
return (
|
|
165
|
+
<PlayRenderer
|
|
166
|
+
actor={actor}
|
|
167
|
+
registry={registry}
|
|
168
|
+
actions={{ login: "auth.login", logout: "auth.logout" }}
|
|
169
|
+
/>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
93
172
|
```
|
|
94
173
|
|
|
95
174
|
## API Reference
|
|
96
175
|
|
|
97
|
-
### PlayRenderer
|
|
176
|
+
### `PlayRenderer`
|
|
98
177
|
|
|
99
|
-
Main
|
|
178
|
+
Main component. Subscribes to `actor.currentView` and renders the spec.
|
|
100
179
|
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
actor
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
180
|
+
```tsx
|
|
181
|
+
<PlayRenderer
|
|
182
|
+
actor={actor} // required
|
|
183
|
+
registry={registry} // required
|
|
184
|
+
actions={{ login: "auth.login" }} // optional
|
|
185
|
+
store={myStore} // optional — controlled mode
|
|
186
|
+
fallback={<p>Loading…</p>} // optional
|
|
187
|
+
/>
|
|
107
188
|
```
|
|
108
189
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
- `actor` - Actor instance with `currentView` signal
|
|
112
|
-
- `components` - Map of component names to React components
|
|
113
|
-
- `fallback` - Component shown when `currentView` is null (default: `null`)
|
|
190
|
+
**`actor`** — A `PlayerActor` (or any `AbstractActor & Viewable`). Provides the `currentView` signal.
|
|
114
191
|
|
|
115
|
-
|
|
192
|
+
**`registry`** — Built with `defineRegistry(catalog, { components, actions })` from `@xmachines/play-react`.
|
|
116
193
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
3. Renders component with props from `view.props` + `send` function
|
|
194
|
+
`defineRegistry` also accepts `onRenderError(error, elementType)`, which receives errors
|
|
195
|
+
caught by `@json-render/react`'s inner element boundary before the default logger is used.
|
|
120
196
|
|
|
121
|
-
|
|
197
|
+
**`actions`** — Maps json-render action names (from spec `on` bindings) to XState event type strings. Values are type-checked against `EventFromLogic<TLogic>["type"]` when `TLogic` is specified:
|
|
122
198
|
|
|
123
|
-
```
|
|
124
|
-
|
|
199
|
+
```tsx
|
|
200
|
+
// Typed: "bad.event" causes a compile error if it is not in the machine's event union
|
|
201
|
+
<PlayRenderer<typeof machine>
|
|
125
202
|
actor={actor}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
AboutPage: ({ send }) => <div>About</div>,
|
|
129
|
-
}}
|
|
130
|
-
fallback={<div>Loading...</div>}
|
|
203
|
+
registry={registry}
|
|
204
|
+
actions={{ login: "auth.login", logout: "auth.logout" }}
|
|
131
205
|
/>
|
|
132
206
|
```
|
|
133
207
|
|
|
134
|
-
|
|
208
|
+
**`store`** (optional) — Controls per-view UI state (form values, `$state` bindings):
|
|
135
209
|
|
|
136
|
-
|
|
210
|
+
- **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.
|
|
211
|
+
- **Provided (controlled):** The caller owns the store lifecycle. `spec.state` is ignored.
|
|
137
212
|
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
});
|
|
143
|
-
```
|
|
213
|
+
```tsx
|
|
214
|
+
import { createAtom } from "@xstate/store";
|
|
215
|
+
import { xstateStoreStateStore } from "@json-render/xstate";
|
|
216
|
+
import type { StateStore } from "@json-render/core";
|
|
144
217
|
|
|
145
|
-
|
|
218
|
+
const store: StateStore = xstateStoreStateStore({ atom: createAtom({ username: "alice" }) });
|
|
146
219
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
- Triggers React state update to force re-render
|
|
150
|
-
- Cleans up watcher on unmount with explicit `unwatch`
|
|
220
|
+
<PlayRenderer actor={actor} registry={registry} store={store} actions={{ login: "auth.login" }} />;
|
|
221
|
+
```
|
|
151
222
|
|
|
152
|
-
**
|
|
223
|
+
**Inner render errors** — You can intercept catalog component render failures without
|
|
224
|
+
overriding the outer `PlayErrorBoundary`:
|
|
153
225
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
226
|
+
```tsx
|
|
227
|
+
const { registry } = defineRegistry(catalog, {
|
|
228
|
+
components: { Login, Dashboard },
|
|
229
|
+
actions: { login: async () => {}, logout: async () => {} },
|
|
230
|
+
onRenderError(error, elementType) {
|
|
231
|
+
reportExpectedRenderError(error, elementType);
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
```
|
|
159
235
|
|
|
160
|
-
|
|
236
|
+
**`fallback`** — Shown when `actor.currentView.get()` is `null` (machine in a no-view state, or during initialisation).
|
|
161
237
|
|
|
162
|
-
|
|
238
|
+
---
|
|
163
239
|
|
|
164
|
-
|
|
165
|
-
import { useSignalEffect } from "@xmachines/play-react";
|
|
166
|
-
import { useState } from "react";
|
|
240
|
+
### `useActor`
|
|
167
241
|
|
|
168
|
-
|
|
169
|
-
const [route, setRoute] = useState<string | null>(null);
|
|
242
|
+
Hook for accessing the actor from inside any component rendered by `PlayRenderer`. No prop drilling needed.
|
|
170
243
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
setRoute(currentRoute);
|
|
174
|
-
});
|
|
244
|
+
```tsx
|
|
245
|
+
import { useActor } from "@xmachines/play-react";
|
|
175
246
|
|
|
176
|
-
|
|
247
|
+
// Works in any component rendered inside PlayRenderer:
|
|
248
|
+
function LogoutButton() {
|
|
249
|
+
const actor = useActor();
|
|
250
|
+
return <button onClick={() => actor.send({ type: "auth.logout" })}>Log Out</button>;
|
|
177
251
|
}
|
|
178
252
|
```
|
|
179
253
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
## Examples
|
|
254
|
+
Throws `"useActor() must be called inside <PlayRenderer>"` if called outside the tree.
|
|
183
255
|
|
|
184
|
-
|
|
256
|
+
---
|
|
185
257
|
|
|
186
|
-
|
|
187
|
-
import { PlayRenderer } from "@xmachines/play-react";
|
|
188
|
-
import { defineCatalog } from "@xmachines/play-catalog";
|
|
189
|
-
import { z } from "zod";
|
|
190
|
-
|
|
191
|
-
// Define schema in catalog
|
|
192
|
-
const catalog = defineCatalog({
|
|
193
|
-
UserProfile: z.object({
|
|
194
|
-
userId: z.string(),
|
|
195
|
-
name: z.string(),
|
|
196
|
-
avatar: z.string().url().optional(),
|
|
197
|
-
stats: z.object({
|
|
198
|
-
posts: z.number(),
|
|
199
|
-
followers: z.number(),
|
|
200
|
-
}),
|
|
201
|
-
}),
|
|
202
|
-
});
|
|
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
|
-
```
|
|
258
|
+
### `useSignalEffect`
|
|
231
259
|
|
|
232
|
-
|
|
260
|
+
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).
|
|
233
261
|
|
|
234
|
-
```
|
|
262
|
+
```tsx
|
|
235
263
|
import { useSignalEffect } from "@xmachines/play-react";
|
|
236
|
-
import { AbstractActor } from "@xmachines/play-actor";
|
|
237
264
|
|
|
238
|
-
function
|
|
239
|
-
const [
|
|
265
|
+
function NavBar({ actor }: { actor: ReturnType<typeof createPlayer> }) {
|
|
266
|
+
const [isAuth, setIsAuth] = useState(false);
|
|
240
267
|
|
|
241
|
-
// Subscribe to currentView signal
|
|
242
268
|
useSignalEffect(() => {
|
|
243
|
-
const
|
|
244
|
-
|
|
269
|
+
const snap = actor.state.get();
|
|
270
|
+
setIsAuth((snap.context as { isAuthenticated: boolean }).isAuthenticated);
|
|
245
271
|
});
|
|
246
272
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
// Custom rendering logic
|
|
250
|
-
if (view.component === "SpecialCase") {
|
|
251
|
-
return <SpecialCaseComponent {...view.props} actor={actor} />;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Fallback to standard rendering
|
|
255
|
-
return <DefaultComponent view={view} actor={actor} />;
|
|
273
|
+
return <nav>{isAuth ? <LogoutBtn /> : <LoginBtn />}</nav>;
|
|
256
274
|
}
|
|
257
275
|
```
|
|
258
276
|
|
|
259
|
-
|
|
277
|
+
---
|
|
260
278
|
|
|
261
|
-
|
|
262
|
-
import { PlayTanStackRouterProvider } from "@xmachines/play-tanstack-react-router";
|
|
263
|
-
import { PlayRenderer } from "@xmachines/play-react";
|
|
279
|
+
### `PlayErrorBoundary`
|
|
264
280
|
|
|
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
|
-
}
|
|
281
|
+
Class error boundary that wraps the rendered output. Catches errors thrown during component render and logs them without crashing the full page. `PlayRenderer` wraps its own output in this boundary automatically.
|
|
284
282
|
|
|
285
|
-
|
|
286
|
-
function Header({ actor }: { actor: AbstractActor<any> }) {
|
|
287
|
-
const [route, setRoute] = useState<string | null>(null);
|
|
283
|
+
`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.
|
|
288
284
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
});
|
|
285
|
+
```tsx
|
|
286
|
+
import { PlayErrorBoundary } from "@xmachines/play-react";
|
|
292
287
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
288
|
+
<PlayErrorBoundary
|
|
289
|
+
fallback={<p>Something went wrong.</p>}
|
|
290
|
+
onError={(err, info) => Sentry.captureException(err, { extra: info })}
|
|
291
|
+
>
|
|
292
|
+
<PlayRenderer actor={actor} registry={registry} />
|
|
293
|
+
</PlayErrorBoundary>;
|
|
299
294
|
```
|
|
300
295
|
|
|
301
|
-
|
|
296
|
+
---
|
|
302
297
|
|
|
303
|
-
|
|
298
|
+
## Route Parameters in Props
|
|
304
299
|
|
|
305
|
-
|
|
306
|
-
- No useState/useReducer for business state
|
|
307
|
-
- No useEffect for side effects
|
|
308
|
-
- React only triggers renders, doesn't control state
|
|
300
|
+
When using `formatPlayRouteTransitions`, URL path parameters flow automatically into component props. Declare an `undefined` slot in the spec to opt in:
|
|
309
301
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
3. **Event Forwarding:**
|
|
316
|
-
- Components receive `send` function via props
|
|
317
|
-
- User actions send events to actor (e.g., `{ type: "auth.login" }`)
|
|
318
|
-
- Actor guards validate and process events
|
|
319
|
-
|
|
320
|
-
4. **Microtask Batching:**
|
|
321
|
-
- `Signal.subtle.Watcher` coalesces rapid signal changes
|
|
322
|
-
- Prevents React thrashing from multiple signal updates
|
|
323
|
-
- Single React render per microtask batch
|
|
324
|
-
|
|
325
|
-
5. **Explicit Disposal Contract:**
|
|
326
|
-
- Component teardown must call watcher `unwatch` in cleanup
|
|
327
|
-
- Do not rely on GC-only cleanup
|
|
328
|
-
|
|
329
|
-
**Pattern:**
|
|
330
|
-
|
|
331
|
-
- Renderer receives actor via props (provider pattern)
|
|
332
|
-
- Enables composition with navigation, headers, footers
|
|
333
|
-
- Supports multiple renderers in same app
|
|
334
|
-
|
|
335
|
-
**Architectural Invariants:**
|
|
302
|
+
```ts
|
|
303
|
+
// spec: { section: undefined, user: "alice" }
|
|
304
|
+
// After play.route to /settings/profile → context.params = { section: "profile" }
|
|
305
|
+
// Component receives: { section: "profile", user: "alice" }
|
|
306
|
+
```
|
|
336
307
|
|
|
337
|
-
|
|
338
|
-
- **Passive Infrastructure (INV-04):** Components reflect, never decide
|
|
308
|
+
Priority: **route param fills `undefined` slots; explicit non-`undefined` spec props always win.**
|
|
339
309
|
|
|
340
|
-
|
|
310
|
+
---
|
|
341
311
|
|
|
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 ()
|
|
312
|
+
## Error Handling
|
|
347
313
|
|
|
348
|
-
|
|
314
|
+
| Error | Cause | Fix |
|
|
315
|
+
| ------------------------------------------------- | ---------------------------- | ---------------------------------------------------------------- |
|
|
316
|
+
| `useActor() must be called inside <PlayRenderer>` | Hook called outside the tree | Move inside a component rendered by `PlayRenderer` |
|
|
317
|
+
| Component render error | Component throws | `PlayErrorBoundary` catches it; inspect component implementation |
|
|
349
318
|
|
|
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
|
|
319
|
+
---
|
|
355
320
|
|
|
356
|
-
##
|
|
321
|
+
## Architecture Notes
|
|
357
322
|
|
|
358
|
-
|
|
323
|
+
- React `useState` is **only** used to trigger re-renders — never for business logic
|
|
324
|
+
- `actor.currentView` (TC39 Signal) is the sole render trigger; `PlayRenderer` is a passive observer
|
|
325
|
+
- Per-view UI state lives in an `@xstate/store` atom, not in React state
|
|
326
|
+
- `@json-render/react` drives rendering; `PlayRenderer` is the signal bridge — import `defineRegistry`, `ComponentFn`, `ComponentContext`, and `useStateBinding` from `@xmachines/play-react`
|