@xmachines/play-react 1.0.0-beta.1

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 ADDED
@@ -0,0 +1,358 @@
1
+ # @xmachines/play-react
2
+
3
+ **React renderer consuming signals and UI schema with provider pattern**
4
+
5
+ Signal-driven React rendering layer observing actor state with zero React state for business logic.
6
+
7
+ ## Overview
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.
10
+
11
+ Per [RFC Play v1](https://gitlab.com/xmachin-es/rfc/-/blob/main/src/play-v1.md), this package implements:
12
+
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
15
+
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.
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ npm install react@^18.0.0 react-dom@^18.0.0
24
+ npm install @xmachines/play-react
25
+ ```
26
+
27
+ ## Current Exports
28
+
29
+ - `PlayRenderer`
30
+ - `useSignalEffect`
31
+ - `PlayRendererProps` (type)
32
+
33
+ **Peer dependencies:**
34
+
35
+ - `react` ^18.0.0 - React runtime
36
+ - `react-dom` ^18.0.0 - React DOM renderer
37
+
38
+ ## Quick Start
39
+
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";
45
+ import { z } from "zod";
46
+
47
+ // 1. Define catalog (business logic layer)
48
+ const catalog = defineCatalog({
49
+ LoginForm: z.object({ error: z.string().optional() }),
50
+ Dashboard: z.object({
51
+ userId: z.string(),
52
+ username: z.string(),
53
+ }),
54
+ });
55
+
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 });
87
+ const actor = createPlayer();
88
+ actor.start();
89
+
90
+ // 4. Render UI (actor via props)
91
+ const root = createRoot(document.getElementById("app")!);
92
+ root.render(<PlayRenderer actor={actor} components={components} />);
93
+ ```
94
+
95
+ ## API Reference
96
+
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
+ }
107
+ ```
108
+
109
+ **Props:**
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:**
116
+
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
120
+
121
+ **Example:**
122
+
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
+ ```
133
+
134
+ ### useSignalEffect()
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
+ ```
144
+
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`
151
+
152
+ **Canonical watcher lifecycle:**
153
+
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()`
159
+
160
+ Watcher notification is one-shot, so re-arm and explicit cleanup are both required.
161
+
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
+ });
175
+
176
+ return <div>Current Route: {route ?? "None"}</div>;
177
+ }
178
+ ```
179
+
180
+ **Complete API:** See [API Documentation](../../docs/api/@xmachines/play-react)
181
+
182
+ ## Examples
183
+
184
+ ### Component Receiving Props from Catalog
185
+
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
+ });
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
+ ```
231
+
232
+ ### useSignalEffect for Custom Rendering
233
+
234
+ ```typescript
235
+ import { useSignalEffect } from "@xmachines/play-react";
236
+ import { AbstractActor } from "@xmachines/play-actor";
237
+
238
+ function CustomRenderer({ actor }: { actor: AbstractActor<any> }) {
239
+ const [view, setView] = useState(null);
240
+
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>;
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} />;
256
+ }
257
+ ```
258
+
259
+ ### Provider Pattern
260
+
261
+ ```typescript
262
+ import { PlayTanStackRouterProvider } from "@xmachines/play-tanstack-react-router";
263
+ import { PlayRenderer } from "@xmachines/play-react";
264
+
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
+ }
284
+
285
+ // Header component also receives actor
286
+ function Header({ actor }: { actor: AbstractActor<any> }) {
287
+ const [route, setRoute] = useState<string | null>(null);
288
+
289
+ useSignalEffect(() => {
290
+ setRoute(actor.currentRoute.get());
291
+ });
292
+
293
+ return (
294
+ <header>
295
+ <nav>Current: {route}</nav>
296
+ </header>
297
+ );
298
+ }
299
+ ```
300
+
301
+ ## Architecture
302
+
303
+ This package implements **Signal-Only Reactivity (INV-05)** and **Passive Infrastructure (INV-04)**:
304
+
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
309
+
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`
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:**
336
+
337
+ - **Signal-Only Reactivity (INV-05):** No React state for business logic
338
+ - **Passive Infrastructure (INV-04):** Components reflect, never decide
339
+
340
+ ## Benefits
341
+
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 ()
347
+
348
+ ## Related Packages
349
+
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
355
+
356
+ ## License
357
+
358
+ MIT
@@ -0,0 +1,57 @@
1
+ /**
2
+ * PlayRenderer - Main React renderer component for XMachines Play architecture
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ import React from "react";
7
+ import type { PlayRendererProps } from "./types.js";
8
+ /**
9
+ * Main renderer component that subscribes to actor signals and renders UI
10
+ *
11
+ * Architecture (per RESEARCH.md Pattern 1):
12
+ * - Subscribes to actor.currentView signal via useSignalEffect
13
+ * - Dynamically renders catalog components based on view.component string
14
+ * - Forwards user events to actor via actor.send()
15
+ * - React state only for triggering renders, NOT business logic
16
+ *
17
+ * Invariant: Actor Authority - Actor decides all state transitions via guards.
18
+ * Invariant: Passive Infrastructure - Component observes signals and sends events.
19
+ * Invariant: Signal-Only Reactivity - Business logic state lives in actor signals.
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * import { PlayRenderer } from "@xmachines/play-react";
24
+ * import { definePlayer } from "@xmachines/play-xstate";
25
+ *
26
+ * const actor = definePlayer({ machine, catalog })();
27
+ * actor.start();
28
+ *
29
+ * const components = {
30
+ * Dashboard: ({ userId, send }) => <div>User: {userId}</div>,
31
+ * LoginForm: ({ error, send }) => <form onSubmit={(e) => {
32
+ * e.preventDefault();
33
+ * send({ type: "intent", name: "login.submit", payload: {...} });
34
+ * }}>...</form>
35
+ * };
36
+ *
37
+ * <PlayRenderer actor={actor} components={components} />
38
+ * ```
39
+ *
40
+ * @param props - Component props
41
+ * @returns React element rendering current view from actor
42
+ *
43
+ * @remarks
44
+ * **Component lookup:** Dynamically looks up component from `components` map
45
+ * using `view.component` string from actor.currentView signal.
46
+ *
47
+ * **Event forwarding:** Injects `send` function as prop to components. Components
48
+ * call `send(event)` to forward intents to actor. Actor guards decide validity.
49
+ *
50
+ * **Error handling:** If component not found in catalog, logs error and shows
51
+ * fallback. This indicates missing component registration, not runtime error.
52
+ *
53
+ * **CRITICAL:** Never call actor.send() during render - only in event handlers.
54
+ * Calling send during render causes infinite render loops.
55
+ */
56
+ export declare const PlayRenderer: React.FC<PlayRendererProps>;
57
+ //# sourceMappingURL=PlayRenderer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PlayRenderer.d.ts","sourceRoot":"","sources":["../src/PlayRenderer.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAmB,MAAM,OAAO,CAAC;AAExC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEpD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,eAAO,MAAM,YAAY,EAAE,KAAK,CAAC,EAAE,CAAC,iBAAiB,CA2CpD,CAAC"}
@@ -0,0 +1,87 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * PlayRenderer - Main React renderer component for XMachines Play architecture
4
+ *
5
+ * @packageDocumentation
6
+ */
7
+ import React, { useState } from "react";
8
+ import { useSignalEffect } from "./useSignalEffect.js";
9
+ /**
10
+ * Main renderer component that subscribes to actor signals and renders UI
11
+ *
12
+ * Architecture (per RESEARCH.md Pattern 1):
13
+ * - Subscribes to actor.currentView signal via useSignalEffect
14
+ * - Dynamically renders catalog components based on view.component string
15
+ * - Forwards user events to actor via actor.send()
16
+ * - React state only for triggering renders, NOT business logic
17
+ *
18
+ * Invariant: Actor Authority - Actor decides all state transitions via guards.
19
+ * Invariant: Passive Infrastructure - Component observes signals and sends events.
20
+ * Invariant: Signal-Only Reactivity - Business logic state lives in actor signals.
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * import { PlayRenderer } from "@xmachines/play-react";
25
+ * import { definePlayer } from "@xmachines/play-xstate";
26
+ *
27
+ * const actor = definePlayer({ machine, catalog })();
28
+ * actor.start();
29
+ *
30
+ * const components = {
31
+ * Dashboard: ({ userId, send }) => <div>User: {userId}</div>,
32
+ * LoginForm: ({ error, send }) => <form onSubmit={(e) => {
33
+ * e.preventDefault();
34
+ * send({ type: "intent", name: "login.submit", payload: {...} });
35
+ * }}>...</form>
36
+ * };
37
+ *
38
+ * <PlayRenderer actor={actor} components={components} />
39
+ * ```
40
+ *
41
+ * @param props - Component props
42
+ * @returns React element rendering current view from actor
43
+ *
44
+ * @remarks
45
+ * **Component lookup:** Dynamically looks up component from `components` map
46
+ * using `view.component` string from actor.currentView signal.
47
+ *
48
+ * **Event forwarding:** Injects `send` function as prop to components. Components
49
+ * call `send(event)` to forward intents to actor. Actor guards decide validity.
50
+ *
51
+ * **Error handling:** If component not found in catalog, logs error and shows
52
+ * fallback. This indicates missing component registration, not runtime error.
53
+ *
54
+ * **CRITICAL:** Never call actor.send() during render - only in event handlers.
55
+ * Calling send during render causes infinite render loops.
56
+ */
57
+ export const PlayRenderer = ({ actor, components, fallback = null, }) => {
58
+ // React state for triggering re-renders (NOT business logic state)
59
+ // Signal is source of truth, useState is just React's render trigger
60
+ const [view, setView] = useState(() => actor.currentView.get());
61
+ // Subscribe to signal changes
62
+ useSignalEffect(() => {
63
+ const currentView = actor.currentView.get();
64
+ setView(currentView);
65
+ });
66
+ // No view in current state
67
+ if (!view) {
68
+ return _jsx(_Fragment, { children: fallback });
69
+ }
70
+ // Handle null/undefined components catalog gracefully
71
+ if (!components) {
72
+ console.error(`Components catalog is ${components === null ? "null" : "undefined"}. ` +
73
+ `Cannot render component "${view.component}".`);
74
+ return _jsx(_Fragment, { children: fallback });
75
+ }
76
+ // Look up component from catalog
77
+ const Component = components[view.component];
78
+ if (!Component) {
79
+ console.error(`Component "${view.component}" not found in catalog. ` +
80
+ `Available components: ${Object.keys(components).join(", ")}`);
81
+ return _jsx(_Fragment, { children: fallback });
82
+ }
83
+ // Render with props from actor + send function
84
+ // bind(actor) ensures 'this' context is correct when components call send()
85
+ return _jsx(Component, { ...view.props, send: actor.send.bind(actor) });
86
+ };
87
+ //# sourceMappingURL=PlayRenderer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PlayRenderer.js","sourceRoot":"","sources":["../src/PlayRenderer.tsx"],"names":[],"mappings":";AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AACxC,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAGvD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,MAAM,CAAC,MAAM,YAAY,GAAgC,CAAC,EACzD,KAAK,EACL,UAAU,EACV,QAAQ,GAAG,IAAI,GACf,EAAE,EAAE;IACJ,mEAAmE;IACnE,qEAAqE;IACrE,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,WAAW,CAAC,GAAG,EAAE,CAAC,CAAC;IAEhE,8BAA8B;IAC9B,eAAe,CAAC,GAAG,EAAE;QACpB,MAAM,WAAW,GAAG,KAAK,CAAC,WAAW,CAAC,GAAG,EAAE,CAAC;QAC5C,OAAO,CAAC,WAAW,CAAC,CAAC;IACtB,CAAC,CAAC,CAAC;IAEH,2BAA2B;IAC3B,IAAI,CAAC,IAAI,EAAE,CAAC;QACX,OAAO,4BAAG,QAAQ,GAAI,CAAC;IACxB,CAAC;IAED,sDAAsD;IACtD,IAAI,CAAC,UAAU,EAAE,CAAC;QACjB,OAAO,CAAC,KAAK,CACZ,yBAAyB,UAAU,KAAK,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,WAAW,IAAI;YACtE,4BAA4B,IAAI,CAAC,SAAS,IAAI,CAC/C,CAAC;QACF,OAAO,4BAAG,QAAQ,GAAI,CAAC;IACxB,CAAC;IAED,iCAAiC;IACjC,MAAM,SAAS,GAAG,UAAU,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IAE7C,IAAI,CAAC,SAAS,EAAE,CAAC;QAChB,OAAO,CAAC,KAAK,CACZ,cAAc,IAAI,CAAC,SAAS,0BAA0B;YACrD,yBAAyB,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAC9D,CAAC;QACF,OAAO,4BAAG,QAAQ,GAAI,CAAC;IACxB,CAAC;IAED,+CAA+C;IAC/C,4EAA4E;IAC5E,OAAO,KAAC,SAAS,OAAK,IAAI,CAAC,KAAK,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,GAAI,CAAC;AACpE,CAAC,CAAC"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @xmachines/play-react - React renderer for XMachines Play architecture
3
+ *
4
+ * Provides a thin React rendering layer that passively observes actor signals
5
+ * and renders UI components from catalog definitions. This package enables
6
+ * framework-swappable architecture where React is just a rendering target
7
+ * that subscribes to signal changes.
8
+ *
9
+ * **Key principle:** React state is NEVER used for business logic—only for
10
+ * triggering React's render cycle. Signals are the source of truth.
11
+ *
12
+ * @packageDocumentation
13
+ * @module @xmachines/play-react
14
+ */
15
+ export { PlayRenderer } from "./PlayRenderer.js";
16
+ export { useSignalEffect } from "./useSignalEffect.js";
17
+ export type { PlayRendererProps } from "./types.js";
18
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAGvD,YAAY,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @xmachines/play-react - React renderer for XMachines Play architecture
3
+ *
4
+ * Provides a thin React rendering layer that passively observes actor signals
5
+ * and renders UI components from catalog definitions. This package enables
6
+ * framework-swappable architecture where React is just a rendering target
7
+ * that subscribes to signal changes.
8
+ *
9
+ * **Key principle:** React state is NEVER used for business logic—only for
10
+ * triggering React's render cycle. Signals are the source of truth.
11
+ *
12
+ * @packageDocumentation
13
+ * @module @xmachines/play-react
14
+ */
15
+ // Main exports
16
+ export { PlayRenderer } from "./PlayRenderer.js";
17
+ export { useSignalEffect } from "./useSignalEffect.js";
18
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,eAAe;AACf,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC"}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * TypeScript type definitions for play-react
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ import type { AbstractActor, Viewable } from "@xmachines/play-actor";
7
+ import type React from "react";
8
+ import type { AnyActorLogic } from "xstate";
9
+ /**
10
+ * Props for PlayRenderer component
11
+ *
12
+ * @property actor - Actor instance with currentView signal (requires Viewable capability)
13
+ * @property components - Map of component names to React components
14
+ * @property fallback - Optional component shown when currentView is null
15
+ */
16
+ export interface PlayRendererProps {
17
+ /** Actor instance with currentView signal (requires Viewable capability) */
18
+ actor: AbstractActor<AnyActorLogic> & Viewable;
19
+ /** Map of component names to React components */
20
+ components: Record<string, React.ElementType>;
21
+ /** Optional component shown when currentView is null */
22
+ fallback?: React.ReactNode;
23
+ }
24
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AACrE,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAC;AAE5C;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IACjC,4EAA4E;IAC5E,KAAK,EAAE,aAAa,CAAC,aAAa,CAAC,GAAG,QAAQ,CAAC;IAE/C,iDAAiD;IACjD,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,WAAW,CAAC,CAAC;IAE9C,wDAAwD;IACxD,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B"}
package/dist/types.js ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * TypeScript type definitions for play-react
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ export {};
7
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG"}
@@ -0,0 +1,56 @@
1
+ /**
2
+ * useSignalEffect - React hook for signal subscriptions with automatic cleanup
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ /**
7
+ * React hook that subscribes to signal changes and runs effect callback
8
+ *
9
+ * Wraps Signal.subtle.Watcher to automatically track signal dependencies
10
+ * accessed in the callback. When any tracked signal changes, the callback
11
+ * re-runs and React component re-renders.
12
+ *
13
+ * Architecture (per RESEARCH.md Pattern 3):
14
+ * - Uses Signal.Computed to wrap callback for automatic dependency tracking
15
+ * - Signal.subtle.Watcher monitors the Computed for changes
16
+ * - Microtask batching coalesces rapid signal updates
17
+ * - Forces React re-render via useReducer when signals change
18
+ * - Handles cleanup on unmount to prevent memory leaks
19
+ *
20
+ * Invariant: Signal-Only Reactivity - Signals accessed in callback are watched.
21
+ * Invariant: Passive Infrastructure - React observes signals and does not control them.
22
+ *
23
+ * @param callback - Effect function that accesses signals. Can return cleanup function.
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * const MyComponent = ({ actor }) => {
28
+ * const [view, setView] = useState(null);
29
+ *
30
+ * // Subscribe to actor.currentView signal
31
+ * useSignalEffect(() => {
32
+ * const currentView = actor.currentView.get();
33
+ * setView(currentView);
34
+ * });
35
+ *
36
+ * return <div>{view?.component}</div>;
37
+ * };
38
+ * ```
39
+ *
40
+ * @remarks
41
+ * **CRITICAL:** Signals must be accessed unconditionally (no if statements).
42
+ * Conditional signal access breaks automatic dependency tracking.
43
+ *
44
+ * **Performance:** Microtask batching (queueMicrotask) prevents React thrashing
45
+ * when multiple signals update rapidly. Per signal-polyfill README pattern.
46
+ *
47
+ * **Why forceUpdate:** React needs useState/useReducer to trigger re-render cycle.
48
+ * Signal changes won't automatically re-render React components without forcing
49
+ * update via state change.
50
+ *
51
+ * **Implementation note:** We wrap the callback in Signal.Computed because
52
+ * Signal.subtle.Watcher cannot automatically track arbitrary function calls.
53
+ * The Computed handles dependency tracking, and the Watcher monitors it.
54
+ */
55
+ export declare const useSignalEffect: (callback: () => void | (() => void)) => void;
56
+ //# sourceMappingURL=useSignalEffect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useSignalEffect.d.ts","sourceRoot":"","sources":["../src/useSignalEffect.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAWH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,eAAO,MAAM,eAAe,GAAI,UAAU,MAAM,IAAI,GAAG,CAAC,MAAM,IAAI,CAAC,KAAG,IAuDrE,CAAC"}
@@ -0,0 +1,107 @@
1
+ /**
2
+ * useSignalEffect - React hook for signal subscriptions with automatic cleanup
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ import { useEffect, useReducer } from "react";
7
+ import { Signal } from "@xmachines/play-signals";
8
+ /**
9
+ * Marker symbol returned by effect Computed to satisfy type requirements
10
+ * The actual value doesn't matter - we only care about side effects
11
+ */
12
+ const EFFECT_RUN_MARKER = Symbol("effect-run");
13
+ /**
14
+ * React hook that subscribes to signal changes and runs effect callback
15
+ *
16
+ * Wraps Signal.subtle.Watcher to automatically track signal dependencies
17
+ * accessed in the callback. When any tracked signal changes, the callback
18
+ * re-runs and React component re-renders.
19
+ *
20
+ * Architecture (per RESEARCH.md Pattern 3):
21
+ * - Uses Signal.Computed to wrap callback for automatic dependency tracking
22
+ * - Signal.subtle.Watcher monitors the Computed for changes
23
+ * - Microtask batching coalesces rapid signal updates
24
+ * - Forces React re-render via useReducer when signals change
25
+ * - Handles cleanup on unmount to prevent memory leaks
26
+ *
27
+ * Invariant: Signal-Only Reactivity - Signals accessed in callback are watched.
28
+ * Invariant: Passive Infrastructure - React observes signals and does not control them.
29
+ *
30
+ * @param callback - Effect function that accesses signals. Can return cleanup function.
31
+ *
32
+ * @example
33
+ * ```typescript
34
+ * const MyComponent = ({ actor }) => {
35
+ * const [view, setView] = useState(null);
36
+ *
37
+ * // Subscribe to actor.currentView signal
38
+ * useSignalEffect(() => {
39
+ * const currentView = actor.currentView.get();
40
+ * setView(currentView);
41
+ * });
42
+ *
43
+ * return <div>{view?.component}</div>;
44
+ * };
45
+ * ```
46
+ *
47
+ * @remarks
48
+ * **CRITICAL:** Signals must be accessed unconditionally (no if statements).
49
+ * Conditional signal access breaks automatic dependency tracking.
50
+ *
51
+ * **Performance:** Microtask batching (queueMicrotask) prevents React thrashing
52
+ * when multiple signals update rapidly. Per signal-polyfill README pattern.
53
+ *
54
+ * **Why forceUpdate:** React needs useState/useReducer to trigger re-render cycle.
55
+ * Signal changes won't automatically re-render React components without forcing
56
+ * update via state change.
57
+ *
58
+ * **Implementation note:** We wrap the callback in Signal.Computed because
59
+ * Signal.subtle.Watcher cannot automatically track arbitrary function calls.
60
+ * The Computed handles dependency tracking, and the Watcher monitors it.
61
+ */
62
+ export const useSignalEffect = (callback) => {
63
+ // Force re-render when signal changes (React needs state change to re-render)
64
+ const [, forceUpdate] = useReducer((x) => x + 1, 0);
65
+ useEffect(() => {
66
+ let cleanup;
67
+ let needsEnqueue = true;
68
+ // Wrap callback in a Computed to automatically track dependencies
69
+ // The Computed will re-evaluate when any accessed signal changes
70
+ const effect = new Signal.Computed(() => {
71
+ // Run cleanup from previous effect
72
+ cleanup?.();
73
+ // Run user callback (may access signals, return cleanup)
74
+ cleanup = callback();
75
+ // Return marker value (Computed requires a return value)
76
+ return EFFECT_RUN_MARKER;
77
+ });
78
+ // Create watcher to detect when Computed needs re-evaluation
79
+ const watcher = new Signal.subtle.Watcher(() => {
80
+ // Batching flag prevents multiple microtasks for rapid changes
81
+ if (needsEnqueue) {
82
+ needsEnqueue = false;
83
+ // Schedule microtask to batch updates (prevent React thrashing)
84
+ queueMicrotask(() => {
85
+ needsEnqueue = true;
86
+ // Re-evaluate the Computed (runs callback with new signal values)
87
+ effect.get();
88
+ // Force React re-render
89
+ forceUpdate();
90
+ // Re-watch for next change
91
+ watcher.watch();
92
+ });
93
+ }
94
+ });
95
+ // Watch the Computed signal
96
+ watcher.watch(effect);
97
+ // Initial run of callback (via Computed)
98
+ effect.get();
99
+ // Cleanup on unmount
100
+ return () => {
101
+ cleanup?.();
102
+ // Unwatch to stop receiving signal updates
103
+ watcher.unwatch(effect);
104
+ };
105
+ }, [callback]);
106
+ };
107
+ //# sourceMappingURL=useSignalEffect.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useSignalEffect.js","sourceRoot":"","sources":["../src/useSignalEffect.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,OAAO,CAAC;AAC9C,OAAO,EAAE,MAAM,EAAE,MAAM,yBAAyB,CAAC;AAEjD;;;GAGG;AACH,MAAM,iBAAiB,GAAG,MAAM,CAAC,YAAY,CAAC,CAAC;AAE/C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,QAAmC,EAAQ,EAAE;IAC5E,8EAA8E;IAC9E,MAAM,CAAC,EAAE,WAAW,CAAC,GAAG,UAAU,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IAEpD,SAAS,CAAC,GAAG,EAAE;QACd,IAAI,OAA4B,CAAC;QACjC,IAAI,YAAY,GAAG,IAAI,CAAC;QAExB,kEAAkE;QAClE,iEAAiE;QACjE,MAAM,MAAM,GAAG,IAAI,MAAM,CAAC,QAAQ,CAAC,GAAG,EAAE;YACvC,mCAAmC;YACnC,OAAO,EAAE,EAAE,CAAC;YAEZ,yDAAyD;YACzD,OAAO,GAAG,QAAQ,EAAE,CAAC;YAErB,yDAAyD;YACzD,OAAO,iBAAiB,CAAC;QAC1B,CAAC,CAAC,CAAC;QAEH,6DAA6D;QAC7D,MAAM,OAAO,GAAG,IAAI,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE;YAC9C,+DAA+D;YAC/D,IAAI,YAAY,EAAE,CAAC;gBAClB,YAAY,GAAG,KAAK,CAAC;gBACrB,gEAAgE;gBAChE,cAAc,CAAC,GAAG,EAAE;oBACnB,YAAY,GAAG,IAAI,CAAC;oBAEpB,kEAAkE;oBAClE,MAAM,CAAC,GAAG,EAAE,CAAC;oBAEb,wBAAwB;oBACxB,WAAW,EAAE,CAAC;oBAEd,2BAA2B;oBAC3B,OAAO,CAAC,KAAK,EAAE,CAAC;gBACjB,CAAC,CAAC,CAAC;YACJ,CAAC;QACF,CAAC,CAAC,CAAC;QAEH,4BAA4B;QAC5B,OAAO,CAAC,KAAK,CAAC,MAAa,CAAC,CAAC;QAE7B,yCAAyC;QACzC,MAAM,CAAC,GAAG,EAAE,CAAC;QAEb,qBAAqB;QACrB,OAAO,GAAG,EAAE;YACX,OAAO,EAAE,EAAE,CAAC;YACZ,2CAA2C;YAC3C,OAAO,CAAC,OAAO,CAAC,MAAa,CAAC,CAAC;QAChC,CAAC,CAAC;IACH,CAAC,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC;AAChB,CAAC,CAAC"}
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@xmachines/play-react",
3
+ "version": "1.0.0-beta.1",
4
+ "description": "React renderer for XMachines Play architecture with signal-driven rendering",
5
+ "keywords": [
6
+ "actor",
7
+ "react",
8
+ "renderer",
9
+ "signals",
10
+ "state-machine",
11
+ "ui",
12
+ "xmachines"
13
+ ],
14
+ "license": "MIT",
15
+ "author": "XMachines Contributors",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git@gitlab.com:xmachin-es/xmachines-js.git",
19
+ "directory": "packages/play-react"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "type": "module",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js"
31
+ }
32
+ },
33
+ "scripts": {
34
+ "build": "tsc --build",
35
+ "clean": "rm -rf dist *.tsbuildinfo",
36
+ "typecheck": "tsc --noEmit",
37
+ "test": "vitest run",
38
+ "test:vitest": "vitest run",
39
+ "test:browser": "vitest run --browser.enabled --browser.name=chromium test/browser",
40
+ "prepublishOnly": "npm run build"
41
+ },
42
+ "dependencies": {
43
+ "@xmachines/play-actor": "1.0.0-beta.1",
44
+ "@xmachines/play-catalog": "1.0.0-beta.1",
45
+ "@xmachines/play-signals": "1.0.0-beta.1"
46
+ },
47
+ "devDependencies": {
48
+ "@testing-library/react": "^16.3.2",
49
+ "@testing-library/user-event": "^14.6.1",
50
+ "@types/node": "^25.4.0",
51
+ "@types/react": "^19.2.14",
52
+ "@types/react-dom": "^19.2.3",
53
+ "jsdom": "^28.0.0",
54
+ "react": "^19.2.4",
55
+ "react-dom": "^19.2.4",
56
+ "typescript": "^5.7.0"
57
+ },
58
+ "peerDependencies": {
59
+ "react": "^18.2.0 || ^19.0.0",
60
+ "react-dom": "^18.2.0 || ^19.0.0"
61
+ },
62
+ "publishConfig": {
63
+ "access": "public"
64
+ }
65
+ }