@xmachines/play-react 1.0.0-beta.2 → 1.0.0-beta.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +217 -269
- 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 +36 -6
- 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.map +1 -1
- package/dist/useSignalEffect.js +13 -6
- package/dist/useSignalEffect.js.map +1 -1
- package/package.json +27 -16
package/README.md
CHANGED
|
@@ -1,358 +1,306 @@
|
|
|
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
30
|
## Current Exports
|
|
28
31
|
|
|
29
|
-
- `PlayRenderer`
|
|
30
|
-
- `
|
|
32
|
+
- `PlayRenderer` — main renderer component
|
|
33
|
+
- `useActor` — hook for accessing the actor inside a `PlayRenderer` tree
|
|
34
|
+
- `useSignalEffect` — React hook for subscribing to TC39 Signals
|
|
35
|
+
- `PlayErrorBoundary` — error boundary wrapping renderer output
|
|
36
|
+
- `defineRegistry` — re-exported from `@json-render/react`
|
|
37
|
+
- `useStateBinding` — re-exported from `@json-render/react`
|
|
38
|
+
- `ComponentFn` (type) — re-exported from `@json-render/react`
|
|
39
|
+
- `ComponentContext` (type) — re-exported from `@json-render/react`
|
|
31
40
|
- `PlayRendererProps` (type)
|
|
32
|
-
|
|
33
|
-
**Peer dependencies:**
|
|
34
|
-
|
|
35
|
-
- `react` ^18.0.0 - React runtime
|
|
36
|
-
- `react-dom` ^18.0.0 - React DOM renderer
|
|
41
|
+
- `PlayActor` (type)
|
|
37
42
|
|
|
38
43
|
## Quick Start
|
|
39
44
|
|
|
40
|
-
```
|
|
41
|
-
import {
|
|
42
|
-
import { definePlayer } from "@xmachines/play-xstate";
|
|
43
|
-
import { defineCatalog } from "@xmachines/play-catalog";
|
|
45
|
+
```tsx
|
|
46
|
+
import { definePlayer, formatPlayRouteTransitions } from "@xmachines/play-xstate";
|
|
44
47
|
import { PlayRenderer } from "@xmachines/play-react";
|
|
48
|
+
import { defineCatalog } from "@json-render/core";
|
|
49
|
+
import { defineRegistry } from "@xmachines/play-react";
|
|
50
|
+
import type { ComponentFn } from "@xmachines/play-react";
|
|
51
|
+
import { setup, assign } from "xstate";
|
|
45
52
|
import { z } from "zod";
|
|
46
53
|
|
|
47
|
-
// 1. Define catalog
|
|
54
|
+
// 1. Define catalog — the contract between machine spec and UI components
|
|
48
55
|
const catalog = defineCatalog({
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}),
|
|
56
|
+
elements: {
|
|
57
|
+
Login: { props: z.object({ title: z.string() }), description: "Login form" },
|
|
58
|
+
Dashboard: { props: z.object({ username: z.string() }), description: "Dashboard" },
|
|
59
|
+
},
|
|
54
60
|
});
|
|
55
61
|
|
|
56
|
-
// 2.
|
|
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
|
-
|
|
62
|
+
// 2. Implement components using ComponentFn — typed against catalog entries
|
|
63
|
+
const Login: ComponentFn<typeof catalog, "Login"> = ({ props, emit }) => (
|
|
64
|
+
<form
|
|
65
|
+
onSubmit={(e) => {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
emit("submit");
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
<h2>{props.title}</h2>
|
|
71
|
+
<button type="submit">Log In</button>
|
|
72
|
+
</form>
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const Dashboard: ComponentFn<typeof catalog, "Dashboard"> = ({ props }) => (
|
|
76
|
+
<div>Welcome, {props.username}!</div>
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// 3. Build registry — wires components to catalog and declares no-op action stubs
|
|
80
|
+
const { registry } = defineRegistry(catalog, {
|
|
81
|
+
components: { Login, Dashboard },
|
|
82
|
+
actions: { login: async () => {}, logout: async () => {} },
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// 4. Define machine with view metadata
|
|
86
|
+
const machine = setup({
|
|
87
|
+
types: {
|
|
88
|
+
context: {} as {
|
|
89
|
+
isAuthenticated: boolean;
|
|
90
|
+
username: string | null;
|
|
91
|
+
routeParams: Record<string, string>;
|
|
92
|
+
queryParams: Record<string, string>;
|
|
93
|
+
},
|
|
94
|
+
events: {} as
|
|
95
|
+
| { type: "auth.login"; username: string }
|
|
96
|
+
| { type: "auth.logout" }
|
|
97
|
+
| { type: "play.route"; to: string; params?: Record<string, string> },
|
|
98
|
+
},
|
|
99
|
+
}).createMachine(
|
|
100
|
+
formatPlayRouteTransitions({
|
|
101
|
+
id: "app",
|
|
102
|
+
initial: "login",
|
|
103
|
+
context: { isAuthenticated: false, username: null, routeParams: {}, queryParams: {} },
|
|
104
|
+
states: {
|
|
105
|
+
login: {
|
|
106
|
+
id: "login",
|
|
107
|
+
meta: {
|
|
108
|
+
route: "/login",
|
|
109
|
+
view: {
|
|
110
|
+
component: "Login",
|
|
111
|
+
spec: {
|
|
112
|
+
root: "root",
|
|
113
|
+
elements: {
|
|
114
|
+
root: { type: "Login", props: { title: "Sign In" }, children: [] },
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
dashboard: {
|
|
121
|
+
id: "dashboard",
|
|
122
|
+
meta: {
|
|
123
|
+
route: "/dashboard",
|
|
124
|
+
view: {
|
|
125
|
+
component: "Dashboard",
|
|
126
|
+
spec: {
|
|
127
|
+
root: "root",
|
|
128
|
+
elements: {
|
|
129
|
+
root: { type: "Dashboard", props: { username: "" }, children: [] },
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
on: {
|
|
137
|
+
"auth.login": {
|
|
138
|
+
target: ".dashboard",
|
|
139
|
+
guard: ({ context }) => !context.isAuthenticated,
|
|
140
|
+
actions: assign({
|
|
141
|
+
isAuthenticated: true,
|
|
142
|
+
username: ({ event }) => event.username,
|
|
143
|
+
}),
|
|
144
|
+
},
|
|
145
|
+
"auth.logout": {
|
|
146
|
+
target: ".login",
|
|
147
|
+
guard: ({ context }) => context.isAuthenticated,
|
|
148
|
+
actions: assign({ isAuthenticated: false, username: null }),
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// 5. Create actor and render
|
|
155
|
+
const createPlayer = definePlayer({ machine });
|
|
87
156
|
const actor = createPlayer();
|
|
88
157
|
actor.start();
|
|
89
158
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
159
|
+
function App() {
|
|
160
|
+
return (
|
|
161
|
+
<PlayRenderer
|
|
162
|
+
actor={actor}
|
|
163
|
+
registry={registry}
|
|
164
|
+
actions={{ login: "auth.login", logout: "auth.logout" }}
|
|
165
|
+
/>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
93
168
|
```
|
|
94
169
|
|
|
95
170
|
## API Reference
|
|
96
171
|
|
|
97
|
-
### PlayRenderer
|
|
172
|
+
### `PlayRenderer`
|
|
98
173
|
|
|
99
|
-
Main
|
|
174
|
+
Main component. Subscribes to `actor.currentView` and renders the spec.
|
|
100
175
|
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
actor
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
176
|
+
```tsx
|
|
177
|
+
<PlayRenderer
|
|
178
|
+
actor={actor} // required
|
|
179
|
+
registry={registry} // required
|
|
180
|
+
actions={{ login: "auth.login" }} // optional
|
|
181
|
+
store={myStore} // optional — controlled mode
|
|
182
|
+
fallback={<p>Loading…</p>} // optional
|
|
183
|
+
/>
|
|
107
184
|
```
|
|
108
185
|
|
|
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`)
|
|
114
|
-
|
|
115
|
-
**Behavior:**
|
|
186
|
+
**`actor`** — A `PlayerActor` (or any `AbstractActor & Viewable`). Provides the `currentView` signal.
|
|
116
187
|
|
|
117
|
-
|
|
118
|
-
2. Looks up component from `components` map using `view.component` string
|
|
119
|
-
3. Renders component with props from `view.props` + `send` function
|
|
188
|
+
**`registry`** — Built with `defineRegistry(catalog, { components, actions })` from `@xmachines/play-react`.
|
|
120
189
|
|
|
121
|
-
|
|
190
|
+
**`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
191
|
|
|
123
|
-
```
|
|
124
|
-
|
|
192
|
+
```tsx
|
|
193
|
+
// Typed: "bad.event" causes a compile error if it is not in the machine's event union
|
|
194
|
+
<PlayRenderer<typeof machine>
|
|
125
195
|
actor={actor}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
AboutPage: ({ send }) => <div>About</div>,
|
|
129
|
-
}}
|
|
130
|
-
fallback={<div>Loading...</div>}
|
|
196
|
+
registry={registry}
|
|
197
|
+
actions={{ login: "auth.login", logout: "auth.logout" }}
|
|
131
198
|
/>
|
|
132
199
|
```
|
|
133
200
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
Hook for subscribing to signal changes with automatic cleanup:
|
|
137
|
-
|
|
138
|
-
```typescript
|
|
139
|
-
useSignalEffect(() => {
|
|
140
|
-
const value = signal.get();
|
|
141
|
-
// React re-renders when signal changes
|
|
142
|
-
});
|
|
143
|
-
```
|
|
201
|
+
**`store`** (optional) — Controls per-view UI state (form values, `$state` bindings):
|
|
144
202
|
|
|
145
|
-
**
|
|
203
|
+
- **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.
|
|
204
|
+
- **Provided (controlled):** The caller owns the store lifecycle. `spec.state` is ignored.
|
|
146
205
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
206
|
+
```tsx
|
|
207
|
+
import { createAtom } from "@xstate/store";
|
|
208
|
+
import { xstateStoreStateStore } from "@json-render/xstate";
|
|
209
|
+
import type { StateStore } from "@json-render/core";
|
|
151
210
|
|
|
152
|
-
|
|
211
|
+
const store: StateStore = xstateStoreStateStore({ atom: createAtom({ username: "alice" }) });
|
|
153
212
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
3. drain pending work (`getPending` and/or `Computed.get`)
|
|
157
|
-
4. run effect + trigger render
|
|
158
|
-
5. re-arm watcher via `watch()`
|
|
213
|
+
<PlayRenderer actor={actor} registry={registry} store={store} actions={{ login: "auth.login" }} />;
|
|
214
|
+
```
|
|
159
215
|
|
|
160
|
-
|
|
216
|
+
**`fallback`** — Shown when `actor.currentView.get()` is `null` (machine in a no-view state, or during initialisation).
|
|
161
217
|
|
|
162
|
-
|
|
218
|
+
---
|
|
163
219
|
|
|
164
|
-
|
|
165
|
-
import { useSignalEffect } from "@xmachines/play-react";
|
|
166
|
-
import { useState } from "react";
|
|
220
|
+
### `useActor`
|
|
167
221
|
|
|
168
|
-
|
|
169
|
-
const [route, setRoute] = useState<string | null>(null);
|
|
222
|
+
Hook for accessing the actor from inside any component rendered by `PlayRenderer`. No prop drilling needed.
|
|
170
223
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
setRoute(currentRoute);
|
|
174
|
-
});
|
|
224
|
+
```tsx
|
|
225
|
+
import { useActor } from "@xmachines/play-react";
|
|
175
226
|
|
|
176
|
-
|
|
227
|
+
// Works in any component rendered inside PlayRenderer:
|
|
228
|
+
function LogoutButton() {
|
|
229
|
+
const actor = useActor();
|
|
230
|
+
return <button onClick={() => actor.send({ type: "auth.logout" })}>Log Out</button>;
|
|
177
231
|
}
|
|
178
232
|
```
|
|
179
233
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
## Examples
|
|
234
|
+
Throws `"useActor() must be called inside <PlayRenderer>"` if called outside the tree.
|
|
183
235
|
|
|
184
|
-
|
|
236
|
+
---
|
|
185
237
|
|
|
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
|
-
```
|
|
238
|
+
### `useSignalEffect`
|
|
231
239
|
|
|
232
|
-
|
|
240
|
+
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
241
|
|
|
234
|
-
```
|
|
242
|
+
```tsx
|
|
235
243
|
import { useSignalEffect } from "@xmachines/play-react";
|
|
236
|
-
import { AbstractActor } from "@xmachines/play-actor";
|
|
237
244
|
|
|
238
|
-
function
|
|
239
|
-
const [
|
|
245
|
+
function NavBar({ actor }: { actor: ReturnType<typeof createPlayer> }) {
|
|
246
|
+
const [isAuth, setIsAuth] = useState(false);
|
|
240
247
|
|
|
241
|
-
// Subscribe to currentView signal
|
|
242
248
|
useSignalEffect(() => {
|
|
243
|
-
const
|
|
244
|
-
|
|
249
|
+
const snap = actor.state.get();
|
|
250
|
+
setIsAuth((snap.context as { isAuthenticated: boolean }).isAuthenticated);
|
|
245
251
|
});
|
|
246
252
|
|
|
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} />;
|
|
253
|
+
return <nav>{isAuth ? <LogoutBtn /> : <LoginBtn />}</nav>;
|
|
256
254
|
}
|
|
257
255
|
```
|
|
258
256
|
|
|
259
|
-
|
|
257
|
+
---
|
|
260
258
|
|
|
261
|
-
|
|
262
|
-
import { PlayTanStackRouterProvider } from "@xmachines/play-tanstack-react-router";
|
|
263
|
-
import { PlayRenderer } from "@xmachines/play-react";
|
|
259
|
+
### `PlayErrorBoundary`
|
|
264
260
|
|
|
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
|
-
}
|
|
261
|
+
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
262
|
|
|
285
|
-
|
|
286
|
-
function Header({ actor }: { actor: AbstractActor<any> }) {
|
|
287
|
-
const [route, setRoute] = useState<string | null>(null);
|
|
263
|
+
`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
264
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
});
|
|
265
|
+
```tsx
|
|
266
|
+
import { PlayErrorBoundary } from "@xmachines/play-react";
|
|
292
267
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
268
|
+
<PlayErrorBoundary
|
|
269
|
+
fallback={<p>Something went wrong.</p>}
|
|
270
|
+
onError={(err, info) => Sentry.captureException(err, { extra: info })}
|
|
271
|
+
>
|
|
272
|
+
<PlayRenderer actor={actor} registry={registry} />
|
|
273
|
+
</PlayErrorBoundary>;
|
|
299
274
|
```
|
|
300
275
|
|
|
301
|
-
|
|
276
|
+
---
|
|
302
277
|
|
|
303
|
-
|
|
278
|
+
## Route Parameters in Props
|
|
304
279
|
|
|
305
|
-
|
|
306
|
-
- No useState/useReducer for business state
|
|
307
|
-
- No useEffect for side effects
|
|
308
|
-
- React only triggers renders, doesn't control state
|
|
280
|
+
When using `formatPlayRouteTransitions`, URL path parameters flow automatically into component props. Declare an `undefined` slot in the spec to opt in:
|
|
309
281
|
|
|
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:**
|
|
282
|
+
```ts
|
|
283
|
+
// spec: { section: undefined, user: "alice" }
|
|
284
|
+
// After play.route to /settings/profile → context.routeParams = { section: "profile" }
|
|
285
|
+
// Component receives: { section: "profile", user: "alice" }
|
|
286
|
+
```
|
|
336
287
|
|
|
337
|
-
|
|
338
|
-
- **Passive Infrastructure (INV-04):** Components reflect, never decide
|
|
288
|
+
Priority: **route param fills `undefined` slots; explicit non-`undefined` spec props always win.**
|
|
339
289
|
|
|
340
|
-
|
|
290
|
+
---
|
|
341
291
|
|
|
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 ()
|
|
292
|
+
## Error Handling
|
|
347
293
|
|
|
348
|
-
|
|
294
|
+
| Error | Cause | Fix |
|
|
295
|
+
| ------------------------------------------------- | ---------------------------- | ---------------------------------------------------------------- |
|
|
296
|
+
| `useActor() must be called inside <PlayRenderer>` | Hook called outside the tree | Move inside a component rendered by `PlayRenderer` |
|
|
297
|
+
| Component render error | Component throws | `PlayErrorBoundary` catches it; inspect component implementation |
|
|
349
298
|
|
|
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
|
|
299
|
+
---
|
|
355
300
|
|
|
356
|
-
##
|
|
301
|
+
## Architecture Notes
|
|
357
302
|
|
|
358
|
-
|
|
303
|
+
- React `useState` is **only** used to trigger re-renders — never for business logic
|
|
304
|
+
- `actor.currentView` (TC39 Signal) is the sole render trigger; `PlayRenderer` is a passive observer
|
|
305
|
+
- Per-view UI state lives in an `@xstate/store` atom, not in React state
|
|
306
|
+
- `@json-render/react` drives rendering; `PlayRenderer` is the signal bridge — import `defineRegistry`, `ComponentFn`, `ComponentContext`, and `useStateBinding` from `@xmachines/play-react`
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PlayErrorBoundary - React error boundary for catching catalog component render errors
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
import React from "react";
|
|
7
|
+
/**
|
|
8
|
+
* Props for PlayErrorBoundary
|
|
9
|
+
*
|
|
10
|
+
* @public
|
|
11
|
+
*/
|
|
12
|
+
export interface PlayErrorBoundaryProps {
|
|
13
|
+
/** Fallback UI to render when a child component throws. Defaults to null. */
|
|
14
|
+
fallback?: React.ReactNode;
|
|
15
|
+
/** Child components to render */
|
|
16
|
+
children: React.ReactNode;
|
|
17
|
+
/** Optional error handler callback — forwards errors to observability tools (Sentry, etc.) */
|
|
18
|
+
onError?: (error: Error, info: React.ErrorInfo) => void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Internal state shape for PlayErrorBoundary
|
|
22
|
+
*
|
|
23
|
+
* @public
|
|
24
|
+
*/
|
|
25
|
+
export interface PlayErrorBoundaryState {
|
|
26
|
+
hasError: boolean;
|
|
27
|
+
error: Error | null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* React class component error boundary for catching catalog component render errors.
|
|
31
|
+
*
|
|
32
|
+
* Wraps catalog component renders so failures are caught and forwarded to standard
|
|
33
|
+
* React error boundary protocol. Consumers can attach the `onError` prop to forward
|
|
34
|
+
* errors to production observability tools (Sentry, Datadog, etc.).
|
|
35
|
+
*
|
|
36
|
+
* **React 19 safety (Phase 29):** `componentDidCatch` calls `onError` for observability
|
|
37
|
+
* but does NOT re-throw. `getDerivedStateFromError` already sets the fallback state —
|
|
38
|
+
* re-throwing from `componentDidCatch` can unmount the entire React 19 root.
|
|
39
|
+
*
|
|
40
|
+
* Per CONS-14: Class component pattern works with all React versions (18 and 19).
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```tsx
|
|
44
|
+
* <PlayErrorBoundary fallback={<div>Something went wrong</div>} onError={Sentry.captureException}>
|
|
45
|
+
* <CatalogComponent {...props} />
|
|
46
|
+
* </PlayErrorBoundary>
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export declare class PlayErrorBoundary extends React.Component<PlayErrorBoundaryProps, PlayErrorBoundaryState> {
|
|
50
|
+
constructor(props: PlayErrorBoundaryProps);
|
|
51
|
+
static getDerivedStateFromError(error: Error): PlayErrorBoundaryState;
|
|
52
|
+
componentDidCatch(error: Error, info: React.ErrorInfo): void;
|
|
53
|
+
render(): React.ReactNode;
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=PlayErrorBoundary.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PlayErrorBoundary.d.ts","sourceRoot":"","sources":["../src/PlayErrorBoundary.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B;;;;GAIG;AACH,MAAM,WAAW,sBAAsB;IACtC,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IAC3B,iCAAiC;IACjC,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,8FAA8F;IAC9F,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,CAAC,SAAS,KAAK,IAAI,CAAC;CACxD;AAED;;;;GAIG;AACH,MAAM,WAAW,sBAAsB;IACtC,QAAQ,EAAE,OAAO,CAAC;IAClB,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,qBAAa,iBAAkB,SAAQ,KAAK,CAAC,SAAS,CACrD,sBAAsB,EACtB,sBAAsB,CACtB;gBACY,KAAK,EAAE,sBAAsB;IAKzC,MAAM,CAAC,wBAAwB,CAAC,KAAK,EAAE,KAAK,GAAG,sBAAsB;IAI5D,iBAAiB,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,CAAC,SAAS,GAAG,IAAI;IAI5D,MAAM,IAAI,KAAK,CAAC,SAAS;CAMlC"}
|