@xmachines/play-react 1.0.0-beta.3 → 1.0.0-beta.31
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 +236 -270
- 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 +29 -31
- package/dist/PlayRenderer.d.ts.map +1 -1
- package/dist/PlayRenderer.js +80 -46
- 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 -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 +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,324 @@
|
|
|
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
|
+
- `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`
|
|
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 action handlers
|
|
84
|
+
const registryResult = defineRegistry(catalog, {
|
|
85
|
+
components: { Login, Dashboard },
|
|
86
|
+
actions: {
|
|
87
|
+
login: async (params) => {
|
|
88
|
+
if (!params) return;
|
|
89
|
+
actor.send({ type: "auth.login", username: params.username });
|
|
90
|
+
},
|
|
91
|
+
logout: async () => {
|
|
92
|
+
actor.send({ type: "auth.logout" });
|
|
93
|
+
},
|
|
94
|
+
},
|
|
54
95
|
});
|
|
55
96
|
|
|
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
|
-
|
|
97
|
+
// 4. Define machine with view metadata
|
|
98
|
+
const machine = setup({
|
|
99
|
+
types: {
|
|
100
|
+
context: {} as {
|
|
101
|
+
isAuthenticated: boolean;
|
|
102
|
+
username: string | null;
|
|
103
|
+
params: Record<string, string>;
|
|
104
|
+
query: Record<string, string>;
|
|
105
|
+
},
|
|
106
|
+
events: {} as
|
|
107
|
+
| { type: "auth.login"; username: string }
|
|
108
|
+
| { type: "auth.logout" }
|
|
109
|
+
| { type: "play.route"; to: string; params?: Record<string, string> },
|
|
110
|
+
},
|
|
111
|
+
}).createMachine(
|
|
112
|
+
formatPlayRouteTransitions({
|
|
113
|
+
id: "app",
|
|
114
|
+
initial: "login",
|
|
115
|
+
context: { isAuthenticated: false, username: null, params: {}, query: {} },
|
|
116
|
+
states: {
|
|
117
|
+
login: {
|
|
118
|
+
id: "login",
|
|
119
|
+
meta: {
|
|
120
|
+
route: "/login",
|
|
121
|
+
view: {
|
|
122
|
+
component: "Login",
|
|
123
|
+
spec: {
|
|
124
|
+
root: "root",
|
|
125
|
+
elements: {
|
|
126
|
+
root: { type: "Login", props: { title: "Sign In" }, children: [] },
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
dashboard: {
|
|
133
|
+
id: "dashboard",
|
|
134
|
+
meta: {
|
|
135
|
+
route: "/dashboard",
|
|
136
|
+
view: {
|
|
137
|
+
component: "Dashboard",
|
|
138
|
+
spec: {
|
|
139
|
+
root: "root",
|
|
140
|
+
elements: {
|
|
141
|
+
root: { type: "Dashboard", props: { username: "" }, children: [] },
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
on: {
|
|
149
|
+
"auth.login": {
|
|
150
|
+
target: ".dashboard",
|
|
151
|
+
guard: ({ context }) => !context.isAuthenticated,
|
|
152
|
+
actions: assign({
|
|
153
|
+
isAuthenticated: true,
|
|
154
|
+
username: ({ event }) => event.username,
|
|
155
|
+
}),
|
|
156
|
+
},
|
|
157
|
+
"auth.logout": {
|
|
158
|
+
target: ".login",
|
|
159
|
+
guard: ({ context }) => context.isAuthenticated,
|
|
160
|
+
actions: assign({ isAuthenticated: false, username: null }),
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// 5. Create actor and render
|
|
167
|
+
const createPlayer = definePlayer({ machine });
|
|
87
168
|
const actor = createPlayer();
|
|
88
169
|
actor.start();
|
|
89
170
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
171
|
+
function App() {
|
|
172
|
+
return <PlayRenderer actor={actor} registryResult={registryResult} />;
|
|
173
|
+
}
|
|
93
174
|
```
|
|
94
175
|
|
|
95
176
|
## API Reference
|
|
96
177
|
|
|
97
|
-
### PlayRenderer
|
|
178
|
+
### `PlayRenderer`
|
|
98
179
|
|
|
99
|
-
Main
|
|
180
|
+
Main component. Subscribes to `actor.currentView` and renders the spec.
|
|
100
181
|
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
actor
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
182
|
+
```tsx
|
|
183
|
+
<PlayRenderer
|
|
184
|
+
actor={actor} // required
|
|
185
|
+
registryResult={registryResult} // required
|
|
186
|
+
store={myStore} // optional — controlled mode
|
|
187
|
+
fallback={<p>Loading…</p>} // optional
|
|
188
|
+
/>
|
|
107
189
|
```
|
|
108
190
|
|
|
109
|
-
|
|
191
|
+
**`actor`** — A `PlayerActor` (or any `AbstractActor & Viewable`). Provides the `currentView` signal.
|
|
110
192
|
|
|
111
|
-
|
|
112
|
-
- `components` - Map of component names to React components
|
|
113
|
-
- `fallback` - Component shown when `currentView` is null (default: `null`)
|
|
193
|
+
**`registryResult`** — Full result from `defineRegistry(catalog, { components, actions })`. Pass the entire return value — contains both the component registry and action handlers. Action handlers are real async functions dispatching to the actor, not string event-type maps.
|
|
114
194
|
|
|
115
|
-
|
|
195
|
+
`defineRegistry` also accepts `onRenderError(error, elementType)`, which receives errors
|
|
196
|
+
caught by `@json-render/react`'s inner element boundary before the default logger is used.
|
|
116
197
|
|
|
117
|
-
|
|
118
|
-
2. Looks up component from `components` map using `view.component` string
|
|
119
|
-
3. Renders component with props from `view.props` + `send` function
|
|
198
|
+
**`store`** (optional) — Controls per-view UI state (form values, `$state` bindings):
|
|
120
199
|
|
|
121
|
-
**
|
|
200
|
+
- **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.
|
|
201
|
+
- **Provided (controlled):** The caller owns the store lifecycle. `spec.state` is ignored.
|
|
122
202
|
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
HomePage: ({ send }) => <div>Home</div>,
|
|
128
|
-
AboutPage: ({ send }) => <div>About</div>,
|
|
129
|
-
}}
|
|
130
|
-
fallback={<div>Loading...</div>}
|
|
131
|
-
/>
|
|
132
|
-
```
|
|
203
|
+
```tsx
|
|
204
|
+
import { createAtom } from "@xstate/store";
|
|
205
|
+
import { xstateStoreStateStore } from "@json-render/xstate";
|
|
206
|
+
import type { StateStore } from "@json-render/core";
|
|
133
207
|
|
|
134
|
-
|
|
208
|
+
const store: StateStore = xstateStoreStateStore({ atom: createAtom({ username: "alice" }) });
|
|
135
209
|
|
|
136
|
-
|
|
210
|
+
<PlayRenderer actor={actor} registryResult={registryResult} store={store} />;
|
|
211
|
+
```
|
|
137
212
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
213
|
+
**Inner render errors** — You can intercept catalog component render failures without
|
|
214
|
+
overriding the outer `PlayErrorBoundary`:
|
|
215
|
+
|
|
216
|
+
```tsx
|
|
217
|
+
const registryResult = defineRegistry(catalog, {
|
|
218
|
+
components: { Login, Dashboard },
|
|
219
|
+
actions: {
|
|
220
|
+
login: async (params) => {
|
|
221
|
+
if (!params) return;
|
|
222
|
+
actor.send({ type: "auth.login", username: params.username });
|
|
223
|
+
},
|
|
224
|
+
logout: async () => {
|
|
225
|
+
actor.send({ type: "auth.logout" });
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
onRenderError(error, elementType) {
|
|
229
|
+
reportExpectedRenderError(error, elementType);
|
|
230
|
+
},
|
|
142
231
|
});
|
|
143
232
|
```
|
|
144
233
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
- Tracks signal dependencies automatically via `Signal.Computed` wrapper
|
|
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`
|
|
234
|
+
**`fallback`** — Shown when `actor.currentView.get()` is `null` (machine in a no-view state, or during initialisation).
|
|
151
235
|
|
|
152
|
-
|
|
236
|
+
---
|
|
153
237
|
|
|
154
|
-
|
|
155
|
-
2. `queueMicrotask`
|
|
156
|
-
3. drain pending work (`getPending` and/or `Computed.get`)
|
|
157
|
-
4. run effect + trigger render
|
|
158
|
-
5. re-arm watcher via `watch()`
|
|
238
|
+
### `useActor`
|
|
159
239
|
|
|
160
|
-
|
|
240
|
+
Hook for accessing the actor from inside any component rendered by `PlayRenderer`. No prop drilling needed.
|
|
161
241
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
```typescript
|
|
165
|
-
import { useSignalEffect } from "@xmachines/play-react";
|
|
166
|
-
import { useState } from "react";
|
|
167
|
-
|
|
168
|
-
function RouteDisplay({ actor }: { actor: AbstractActor<any> }) {
|
|
169
|
-
const [route, setRoute] = useState<string | null>(null);
|
|
170
|
-
|
|
171
|
-
useSignalEffect(() => {
|
|
172
|
-
const currentRoute = actor.currentRoute.get();
|
|
173
|
-
setRoute(currentRoute);
|
|
174
|
-
});
|
|
242
|
+
```tsx
|
|
243
|
+
import { useActor } from "@xmachines/play-react";
|
|
175
244
|
|
|
176
|
-
|
|
245
|
+
// Works in any component rendered inside PlayRenderer:
|
|
246
|
+
function LogoutButton() {
|
|
247
|
+
const actor = useActor();
|
|
248
|
+
return <button onClick={() => actor.send({ type: "auth.logout" })}>Log Out</button>;
|
|
177
249
|
}
|
|
178
250
|
```
|
|
179
251
|
|
|
180
|
-
|
|
252
|
+
Throws `"useActor() must be called inside <PlayRenderer>"` if called outside the tree.
|
|
181
253
|
|
|
182
|
-
|
|
254
|
+
---
|
|
183
255
|
|
|
184
|
-
###
|
|
256
|
+
### `useSignalEffect`
|
|
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
|
-
});
|
|
258
|
+
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).
|
|
203
259
|
|
|
204
|
-
|
|
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
|
-
```
|
|
231
|
-
|
|
232
|
-
### useSignalEffect for Custom Rendering
|
|
233
|
-
|
|
234
|
-
```typescript
|
|
260
|
+
```tsx
|
|
235
261
|
import { useSignalEffect } from "@xmachines/play-react";
|
|
236
|
-
import { AbstractActor } from "@xmachines/play-actor";
|
|
237
262
|
|
|
238
|
-
function
|
|
239
|
-
const [
|
|
263
|
+
function NavBar({ actor }: { actor: ReturnType<typeof createPlayer> }) {
|
|
264
|
+
const [isAuth, setIsAuth] = useState(false);
|
|
240
265
|
|
|
241
|
-
// Subscribe to currentView signal
|
|
242
266
|
useSignalEffect(() => {
|
|
243
|
-
const
|
|
244
|
-
|
|
267
|
+
const snap = actor.state.get();
|
|
268
|
+
setIsAuth((snap.context as { isAuthenticated: boolean }).isAuthenticated);
|
|
245
269
|
});
|
|
246
270
|
|
|
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} />;
|
|
271
|
+
return <nav>{isAuth ? <LogoutBtn /> : <LoginBtn />}</nav>;
|
|
256
272
|
}
|
|
257
273
|
```
|
|
258
274
|
|
|
259
|
-
|
|
275
|
+
---
|
|
260
276
|
|
|
261
|
-
|
|
262
|
-
import { PlayTanStackRouterProvider } from "@xmachines/play-tanstack-react-router";
|
|
263
|
-
import { PlayRenderer } from "@xmachines/play-react";
|
|
277
|
+
### `PlayErrorBoundary`
|
|
264
278
|
|
|
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
|
-
}
|
|
279
|
+
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
280
|
|
|
285
|
-
|
|
286
|
-
function Header({ actor }: { actor: AbstractActor<any> }) {
|
|
287
|
-
const [route, setRoute] = useState<string | null>(null);
|
|
281
|
+
`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
282
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
});
|
|
283
|
+
```tsx
|
|
284
|
+
import { PlayErrorBoundary } from "@xmachines/play-react";
|
|
292
285
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
286
|
+
<PlayErrorBoundary
|
|
287
|
+
fallback={<p>Something went wrong.</p>}
|
|
288
|
+
onError={(err, info) => Sentry.captureException(err, { extra: info })}
|
|
289
|
+
>
|
|
290
|
+
<PlayRenderer actor={actor} registryResult={registryResult} />
|
|
291
|
+
</PlayErrorBoundary>;
|
|
299
292
|
```
|
|
300
293
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
This package implements **Signal-Only Reactivity (INV-05)** and **Passive Infrastructure (INV-04)**:
|
|
294
|
+
---
|
|
304
295
|
|
|
305
|
-
|
|
306
|
-
- No useState/useReducer for business state
|
|
307
|
-
- No useEffect for side effects
|
|
308
|
-
- React only triggers renders, doesn't control state
|
|
296
|
+
## Route Parameters in Props
|
|
309
297
|
|
|
310
|
-
|
|
311
|
-
- `actor.currentView.get()` provides UI structure
|
|
312
|
-
- `actor.currentRoute.get()` provides navigation state
|
|
313
|
-
- Components observe signals via `useSignalEffect`
|
|
298
|
+
When using `formatPlayRouteTransitions`, URL path parameters flow automatically into component props. Declare an `undefined` slot in the spec to opt in:
|
|
314
299
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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:**
|
|
300
|
+
```ts
|
|
301
|
+
// spec: { section: undefined, user: "alice" }
|
|
302
|
+
// After play.route to /settings/profile → context.params = { section: "profile" }
|
|
303
|
+
// Component receives: { section: "profile", user: "alice" }
|
|
304
|
+
```
|
|
336
305
|
|
|
337
|
-
|
|
338
|
-
- **Passive Infrastructure (INV-04):** Components reflect, never decide
|
|
306
|
+
Priority: **route param fills `undefined` slots; explicit non-`undefined` spec props always win.**
|
|
339
307
|
|
|
340
|
-
|
|
308
|
+
---
|
|
341
309
|
|
|
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 ()
|
|
310
|
+
## Error Handling
|
|
347
311
|
|
|
348
|
-
|
|
312
|
+
| Error | Cause | Fix |
|
|
313
|
+
| ------------------------------------------------- | ---------------------------- | ---------------------------------------------------------------- |
|
|
314
|
+
| `useActor() must be called inside <PlayRenderer>` | Hook called outside the tree | Move inside a component rendered by `PlayRenderer` |
|
|
315
|
+
| Component render error | Component throws | `PlayErrorBoundary` catches it; inspect component implementation |
|
|
349
316
|
|
|
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
|
|
317
|
+
---
|
|
355
318
|
|
|
356
|
-
##
|
|
319
|
+
## Architecture Notes
|
|
357
320
|
|
|
358
|
-
|
|
321
|
+
- React `useState` is **only** used to trigger re-renders — never for business logic
|
|
322
|
+
- `actor.currentView` (TC39 Signal) is the sole render trigger; `PlayRenderer` is a passive observer
|
|
323
|
+
- Per-view UI state lives in an `@xstate/store` atom, not in React state
|
|
324
|
+
- `@json-render/react` drives rendering; `PlayRenderer` is the signal bridge — import `defineRegistry`, `ComponentFn`, `ComponentContext`, and `useBoundProp` 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
|