@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 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 consuming signals and UI schema with provider pattern**
3
+ **React renderer for XMachines Play Architecture**
4
4
 
5
- Signal-driven React rendering layer observing actor state with zero React state for business logic.
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` and `useSignalEffect` for building React UIs that passively observe actor signals. This package enables framework-swappable architecture where React is just a rendering target subscribing to signal changes — business logic lives entirely in the actor.
9
+ `@xmachines/play-react` provides `PlayRenderer`, a React component that:
10
10
 
11
- Per [RFC Play v1](https://gitlab.com/xmachin-es/rfc/-/blob/main/src/play-v1.md), this package implements:
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
- - **Signal-Only Reactivity (INV-05):** No useState/useReducer for business logic, signals only
14
- - **Passive Infrastructure (INV-04):** Components observe signals, send events to actor
16
+ Per [Play RFC](../docs/rfc/play.md):
15
17
 
16
- **Key Principle:** React state is never used for business logic. Signals are the source of truth.
17
-
18
- Renderer receives actor via props (provider pattern), not children.
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
- - `useSignalEffect`
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
- ```typescript
41
- import { createRoot } from "react-dom/client";
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 (business logic layer)
58
+ // 1. Define catalog the contract between machine spec and UI components
48
59
  const catalog = defineCatalog({
49
- LoginForm: z.object({ error: z.string().optional() }),
50
- Dashboard: z.object({
51
- userId: z.string(),
52
- username: z.string(),
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
- // 2. Create React components (view layer)
57
- const components = {
58
- LoginForm: ({ error, send }) => (
59
- <form
60
- onSubmit={(e) => {
61
- e.preventDefault();
62
- const data = new FormData(e.currentTarget);
63
- send({
64
- type: "auth.login",
65
- username: data.get("username"),
66
- password: data.get("password"),
67
- });
68
- }}
69
- >
70
- {error && <p style={{ color: "red" }}>{error}</p>}
71
- <input name="username" required placeholder="Username" />
72
- <input name="password" type="password" required placeholder="Password" />
73
- <button type="submit">Log In</button>
74
- </form>
75
- ),
76
- Dashboard: ({ userId, username, send }) => (
77
- <div>
78
- <h1>Welcome, {username}!</h1>
79
- <p>User ID: {userId}</p>
80
- <button onClick={() => send({ type: "auth.logout" })}>Log Out</button>
81
- </div>
82
- ),
83
- };
84
-
85
- // 3. Create player actor (business logic runtime)
86
- const createPlayer = definePlayer({ machine: authMachine, catalog });
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
- // 4. Render UI (actor via props)
91
- const root = createRoot(document.getElementById("app")!);
92
- root.render(<PlayRenderer actor={actor} components={components} />);
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 renderer component subscribing to actor signals and dynamically rendering catalog components:
180
+ Main component. Subscribes to `actor.currentView` and renders the spec.
100
181
 
101
- ```typescript
102
- interface PlayRendererProps {
103
- actor: AbstractActor<any>;
104
- components: Record<string, React.ComponentType<any>>;
105
- fallback?: React.ReactNode;
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
- **Props:**
191
+ **`actor`** — A `PlayerActor` (or any `AbstractActor & Viewable`). Provides the `currentView` signal.
110
192
 
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`)
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
- **Behavior:**
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
- 1. Subscribes to `actor.currentView` signal via `useSignalEffect`
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
- **Example:**
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
- ```typescript
124
- <PlayRenderer
125
- actor={actor}
126
- components={{
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
- ### useSignalEffect()
208
+ const store: StateStore = xstateStoreStateStore({ atom: createAtom({ username: "alice" }) });
135
209
 
136
- Hook for subscribing to signal changes with automatic cleanup:
210
+ <PlayRenderer actor={actor} registryResult={registryResult} store={store} />;
211
+ ```
137
212
 
138
- ```typescript
139
- useSignalEffect(() => {
140
- const value = signal.get();
141
- // React re-renders when signal changes
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
- **Behavior:**
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
- **Canonical watcher lifecycle:**
236
+ ---
153
237
 
154
- 1. `notify`
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
- Watcher notification is one-shot, so re-arm and explicit cleanup are both required.
240
+ Hook for accessing the actor from inside any component rendered by `PlayRenderer`. No prop drilling needed.
161
241
 
162
- **Example:**
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
- return <div>Current Route: {route ?? "None"}</div>;
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
- **Complete API:** See [API Documentation](../../docs/api/@xmachines/play-react)
252
+ Throws `"useActor() must be called inside <PlayRenderer>"` if called outside the tree.
181
253
 
182
- ## Examples
254
+ ---
183
255
 
184
- ### Component Receiving Props from Catalog
256
+ ### `useSignalEffect`
185
257
 
186
- ```typescript
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
- // 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
- ```
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 CustomRenderer({ actor }: { actor: AbstractActor<any> }) {
239
- const [view, setView] = useState(null);
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 currentView = actor.currentView.get();
244
- setView(currentView);
267
+ const snap = actor.state.get();
268
+ setIsAuth((snap.context as { isAuthenticated: boolean }).isAuthenticated);
245
269
  });
246
270
 
247
- if (!view) return <div>No view</div>;
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
- ### Provider Pattern
275
+ ---
260
276
 
261
- ```typescript
262
- import { PlayTanStackRouterProvider } from "@xmachines/play-tanstack-react-router";
263
- import { PlayRenderer } from "@xmachines/play-react";
277
+ ### `PlayErrorBoundary`
264
278
 
265
- // Renderer receives actor via props (not children)
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
- // Header component also receives actor
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
- useSignalEffect(() => {
290
- setRoute(actor.currentRoute.get());
291
- });
283
+ ```tsx
284
+ import { PlayErrorBoundary } from "@xmachines/play-react";
292
285
 
293
- return (
294
- <header>
295
- <nav>Current: {route}</nav>
296
- </header>
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
- ## Architecture
302
-
303
- This package implements **Signal-Only Reactivity (INV-05)** and **Passive Infrastructure (INV-04)**:
294
+ ---
304
295
 
305
- 1. **No Business Logic in React:**
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
- 2. **Signals as Source of Truth:**
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
- 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:**
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
- - **Signal-Only Reactivity (INV-05):** No React state for business logic
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
- ## Benefits
308
+ ---
341
309
 
342
- - **Framework Swappable:** Business logic has zero React imports
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
- ## Related Packages
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
- - **[@xmachines/play-xstate](../play-xstate)** - XState adapter providing actors
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
- ## License
319
+ ## Architecture Notes
357
320
 
358
- MIT
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