@xmachines/play-react 1.0.0-beta.4 → 1.0.0-beta.41

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