@xmachines/play-dom 1.0.0-beta.45 → 1.0.0-beta.47

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.
Files changed (2) hide show
  1. package/README.md +140 -232
  2. package/package.json +5 -5
package/README.md CHANGED
@@ -1,106 +1,24 @@
1
+ <!-- generated-by: gsd-doc-writer -->
2
+
1
3
  # @xmachines/play-dom
2
4
 
3
- **Vanilla DOM renderer for XMachines**
5
+ Vanilla DOM renderer for XMachines Play architecture with signal-driven rendering.
4
6
 
5
- Framework-free view rendering driven by an XState v5 actor's `currentView` TC39 Signal. Implements the same catalog-typed `defineRegistry` / `ComponentFn` / `ActionFn` API surface as `@json-render/react`, `/solid`, `/vue`, and `/svelte`.
7
+ Part of the [xmachines-js monorepo](../../README.md).
6
8
 
7
9
  ## Installation
8
10
 
9
11
  ```bash
10
- npm install @xmachines/play-dom @json-render/core zod
12
+ npm install @xmachines/play-dom
11
13
  ```
12
14
 
13
- ## Key Exports
14
-
15
- | Export | Description |
16
- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
17
- | `createPlayUI(registryResult, options?)` | Batteries-included factory: returns `mount(actor, container) → disconnect` — parallel to `PlayUIProvider` in framework packages |
18
- | `createRenderer(catalog, componentMap)` | One-call factory: returns `mount(actor, container, options?) → disconnect` |
19
- | `connectRenderer(options)` | Functional API: connect actor → DOM with full options |
20
- | `defineRegistry(catalog, { components, actions })` | Build a catalog-typed `DomRegistry` with typed action handlers |
21
- | `UIProviderOptions` | Standard options interface: `validationFunctions`, `navigate`, `functions`, `onRenderError`, `fallback` |
22
- | `PlayRenderer` | Class-based renderer — `connect()` / `disconnect()` lifecycle |
23
- | `schema` | DOM schema for `defineCatalog` (mirrors `@json-render/react/schema`) |
24
- | `ComponentFn<C, K>` | Catalog-typed DOM component function type |
25
- | `ComponentContext<C, K>` | Context passed to each `ComponentFn` — `props`, `on`, `emit`, `children`, `bindings`, `ctx` |
26
- | `ActionFn<C, K>` | `(params, setState, state) => Promise<void>` — catalog-typed action handler |
27
- | `SetState` | `(updater: prev => next) => void` — write to the local state store |
28
- | `BaseComponentProps<P>` | Base type for catalog component prop definitions |
29
- | `CatalogHasActions<C>` | Conditional type: `true` when catalog declares actions |
30
- | `renderSpec(spec, store, registry, send, handlers)` | Pure Spec → DOM renderer (advanced use) |
31
-
32
- ## Quick Start — `createPlayUI`
33
-
34
- The standard entry point — mirrors `PlayUIProvider` in framework packages. Pass a `registryResult` (from `defineRegistry`) plus optional `UIProviderOptions`, and get back a `mount` function:
35
-
36
- ```typescript
37
- import { defineRegistry, createPlayUI, schema } from "@xmachines/play-dom";
38
- import { defineCatalog } from "@json-render/core";
39
- import { z } from "zod";
40
- import type { ComponentFn } from "@xmachines/play-dom";
15
+ **Peer dependencies:**
41
16
 
42
- // 1. Define catalog
43
- const catalog = defineCatalog(schema, {
44
- components: {
45
- Home: { props: z.object({ title: z.string() }) },
46
- Login: { props: z.object({ title: z.string(), username: z.string().optional() }) },
47
- },
48
- actions: {
49
- login: { params: z.object({ username: z.string() }) },
50
- logout: {},
51
- },
52
- });
53
-
54
- // 2. Build registry with typed action handlers
55
- const registryResult = defineRegistry(catalog, {
56
- components: {
57
- Home: ({ props }) => {
58
- const el = document.createElement("section");
59
- el.textContent = props.title;
60
- return el;
61
- },
62
- Login: ({ props, on }) => {
63
- const section = document.createElement("section");
64
- const button = document.createElement("button");
65
- button.textContent = "Log In";
66
- const submit = on("submit");
67
- button.addEventListener("click", () => submit.emit());
68
- section.append(button);
69
- return section;
70
- },
71
- },
72
- actions: {
73
- login: async (params) => {
74
- if (!params) return;
75
- actor.send({ type: "auth.login", username: params.username });
76
- },
77
- logout: async () => actor.send({ type: "auth.logout" }),
78
- },
79
- });
80
-
81
- // 3. Create the UI factory (once, at module level)
82
- const ui = createPlayUI(registryResult, {
83
- onRenderError: (error, elementType) => console.warn(`<${elementType}> crashed:`, error),
84
- fallback: (() => {
85
- const el = document.createElement("p");
86
- el.textContent = "Loading…";
87
- return el;
88
- })(),
89
- });
90
-
91
- // 4. Mount when actor and container are ready
92
- const actor = createPlayer()();
93
- actor.start();
94
-
95
- const disconnect = ui.mount(actor, document.getElementById("app")!);
96
-
97
- // Cleanup:
98
- disconnect();
17
+ ```bash
18
+ npm install xstate @xstate/store @json-render/core @json-render/xstate
99
19
  ```
100
20
 
101
- ## Quick Start — `createRenderer`
102
-
103
- The lower-level one-call pattern — takes a catalog and component map directly:
21
+ ## Quick Start
104
22
 
105
23
  ```typescript
106
24
  import { createRenderer, schema } from "@xmachines/play-dom";
@@ -108,7 +26,7 @@ import { defineCatalog } from "@json-render/core";
108
26
  import { z } from "zod";
109
27
  import type { ComponentFn } from "@xmachines/play-dom";
110
28
 
111
- // 1. Define catalog
29
+ // 1. Define a catalog
112
30
  const catalog = defineCatalog(schema, {
113
31
  components: {
114
32
  Home: { props: z.object({ title: z.string() }) },
@@ -119,210 +37,200 @@ const catalog = defineCatalog(schema, {
119
37
  logout: {},
120
38
  },
121
39
  });
122
- type AppCatalog = typeof catalog;
123
40
 
124
41
  // 2. Implement components
125
- const Home: ComponentFn<AppCatalog, "Home"> = ({ props }) => {
42
+ const Home: ComponentFn<typeof catalog, "Home"> = ({ props }) => {
126
43
  const el = document.createElement("section");
127
44
  el.textContent = props.title;
128
45
  return el;
129
46
  };
130
47
 
131
- const Login: ComponentFn<AppCatalog, "Login"> = ({ props, on }) => {
132
- const section = document.createElement("section");
133
- const input = document.createElement("input");
134
- input.value = props.username ?? "";
135
- input.addEventListener("input", () =>
136
- ctx.store.update((s) => ({ ...s, username: input.value })),
137
- );
138
-
139
- const button = document.createElement("button");
140
- button.textContent = "Log In";
48
+ const Login: ComponentFn<typeof catalog, "Login"> = ({ props, on }) => {
49
+ const el = document.createElement("section");
50
+ const btn = document.createElement("button");
141
51
  const submit = on("submit");
142
- button.addEventListener("click", () => submit.emit());
143
-
144
- section.append(input, button);
145
- return section;
52
+ btn.addEventListener("click", () => submit.emit());
53
+ el.append(btn);
54
+ return el;
146
55
  };
147
56
 
148
- // 3. Create the renderer factory (once, at module level)
57
+ // 3. Build the factory once (module scope)
149
58
  const mount = createRenderer(catalog, { Home, Login });
150
59
 
151
60
  // 4. Mount when actor and container are ready
152
- const actor = createPlayer()();
153
- actor.start();
154
-
155
61
  const disconnect = mount(actor, document.getElementById("app")!);
156
62
 
157
- // Cleanup:
63
+ // 5. Cleanup on teardown
158
64
  disconnect();
159
65
  ```
160
66
 
161
- ## `defineRegistry` — Full Control
67
+ ## Usage
162
68
 
163
- When you need `registryResult.executeAction()` or want to share the registry with `connectRenderer`:
69
+ ### `createRenderer` — one-call factory (recommended)
70
+
71
+ `createRenderer` is the simplest integration path. Call it once at module scope with your catalog and component map, then call the returned `mount` function for each actor/container pair.
164
72
 
165
73
  ```typescript
166
- import { defineRegistry, connectRenderer, schema } from "@xmachines/play-dom";
74
+ import { createRenderer, schema } from "@xmachines/play-dom";
167
75
  import { defineCatalog } from "@json-render/core";
168
- import { z } from "zod";
169
76
 
170
77
  const catalog = defineCatalog(schema, {
171
- components: {
172
- Home: { props: z.object({ title: z.string() }) },
173
- },
174
- actions: {
175
- login: { params: z.object({ username: z.string() }) },
176
- logout: {},
177
- },
78
+ /* ... */
79
+ });
80
+
81
+ const mount = createRenderer(catalog, { MyComponent });
82
+
83
+ const disconnect = mount(actor, document.getElementById("app")!);
84
+ // Returns a cleanup function — call it to stop rendering and clear the container.
85
+ disconnect();
86
+ ```
87
+
88
+ ### `createPlayUI` — batteries-included factory with full options
89
+
90
+ Use `createPlayUI` when you need render error handling, a fallback element, or a shared `registryResult` that you also need to reference programmatically (e.g. for `executeAction`).
91
+
92
+ ```typescript
93
+ import { defineRegistry, createPlayUI, schema } from "@xmachines/play-dom";
94
+ import { defineCatalog } from "@json-render/core";
95
+
96
+ const catalog = defineCatalog(schema, {
97
+ /* ... */
178
98
  });
179
99
 
180
- // Action handlers receive (params, setState, state)
181
- // - params: resolved from the spec's on.submit.params (e.g. { $state: "/username" })
182
- // - setState: write to the local state store (e.g. to clear a form)
183
- // - state: current local state store snapshot
184
100
  const registryResult = defineRegistry(catalog, {
185
- components: {
186
- Home: ({ props }) => {
187
- const el = document.createElement("section");
188
- el.textContent = props.title;
189
- return el;
190
- },
191
- },
101
+ components: { Home, Login },
192
102
  actions: {
193
103
  login: async (params, setState) => {
194
- if (!params) return;
195
- actor.send({ type: "auth.login", username: params.username });
196
- setState((prev) => ({ ...prev, username: "" })); // clear the form
104
+ /* ... */
197
105
  },
198
106
  logout: async () => actor.send({ type: "auth.logout" }),
199
107
  },
200
108
  });
201
109
 
202
- const disconnect = connectRenderer({
203
- actor,
204
- registry: registryResult.registry,
205
- registryResult, // wires setState/state from xstate store automatically
206
- container: document.getElementById("app")!,
110
+ const mount = createPlayUI(registryResult, {
111
+ onRenderError: console.error,
112
+ fallback: document.getElementById("loading")!,
207
113
  });
114
+
115
+ const disconnect = mount(actor, document.getElementById("app")!);
116
+ disconnect();
208
117
  ```
209
118
 
210
- ## Component API
119
+ ### `PlayRenderer` — class-based lifecycle control
211
120
 
212
- ### `ComponentFn<C, K>` — component function signature
121
+ Use `PlayRenderer` directly when you need explicit `connect()` / `disconnect()` lifecycle control, or when integrating into a system that manages the renderer's lifetime externally.
213
122
 
214
123
  ```typescript
215
- const MyCard: ComponentFn<AppCatalog, "Card"> = ({
216
- props, // catalog-typed props for this component
217
- children, // Node[] — rendered child nodes
218
- on, // (eventName) => EventHandle — get emit() for catalog-declared events
219
- emit, // (eventName) => void — fire an event directly
220
- bindings, // Record<string, string> — $bindState paths for two-way bindings
221
- ctx, // DomRenderContext — store, send, handlers, loading, functions
222
- }) => {
223
- const el = document.createElement("div");
224
- el.append(...children);
225
- return el;
226
- };
227
- ```
124
+ import { PlayRenderer, defineRegistry, schema } from "@xmachines/play-dom";
228
125
 
229
- ### Two-way binding with `$bindState`
126
+ const registryResult = defineRegistry(catalog, { components, actions });
230
127
 
231
- In the view spec:
128
+ const renderer = new PlayRenderer(container, actor, registryResult.registry, { registryResult });
232
129
 
233
- ```json
234
- { "username": { "$bindState": "/username" } }
130
+ renderer.connect();
131
+ // Later:
132
+ renderer.disconnect();
235
133
  ```
236
134
 
237
- In the component:
135
+ **Controlled store mode** — supply an external `StateStore` so the renderer shares state with other parts of your app:
238
136
 
239
137
  ```typescript
240
- const Login: ComponentFn<AppCatalog, "Login"> = ({ props, ctx }) => {
241
- const input = document.createElement("input");
242
- input.value = props.username ?? "";
243
- // Write back to the store on user input
244
- input.addEventListener("input", () => {
245
- ctx.store.update((s) => ({ ...s, username: input.value }));
246
- });
247
- return input;
248
- };
249
- ```
138
+ import { createAtom } from "@xstate/store";
139
+ import { xstateStoreStateStore } from "@json-render/xstate";
250
140
 
251
- ### `on()` — event handle
141
+ const atom = createAtom({ username: "" });
142
+ const store = xstateStoreStateStore({ atom });
252
143
 
253
- ```typescript
254
- const submit = on("submit"); // EventHandle
255
- if (submit.bound) {
256
- button.addEventListener("click", (e) => {
257
- if (submit.shouldPreventDefault) e.preventDefault();
258
- submit.emit(); // resolves params from store, calls action handler
259
- });
260
- }
144
+ const renderer = new PlayRenderer(container, actor, registryResult.registry, {
145
+ registryResult,
146
+ store,
147
+ });
148
+ renderer.connect();
261
149
  ```
262
150
 
263
- ### `ActionFn` — action handler signature
151
+ ### `connectRenderer` — functional API (backward-compatible)
152
+
153
+ `connectRenderer` is the original functional API, equivalent to creating a `PlayRenderer` and calling `connect()` in one step. Prefer `createRenderer` or `createPlayUI` for new code.
264
154
 
265
155
  ```typescript
266
- // Full signature — all three params are available
267
- login: async (params, setState, state) => {
268
- actor.send({ type: "auth.login", username: params!.username });
269
- setState(prev => ({ ...prev, username: "" }));
270
- console.log("previous state was:", state);
271
- },
272
-
273
- // Params-only — setState/state can be omitted if unused
274
- logout: async () => actor.send({ type: "auth.logout" }),
275
- route: async (params) => actor.send({ type: "play.route", to: params!.to }),
276
- ```
156
+ import { connectRenderer, defineRegistry, schema } from "@xmachines/play-dom";
157
+
158
+ const registryResult = defineRegistry(catalog, {
159
+ components: {
160
+ Home: ({ props }) => {
161
+ const el = document.createElement("section");
162
+ el.textContent = props.title;
163
+ return el;
164
+ },
165
+ },
166
+ actions: {
167
+ logout: async () => actor.send({ type: "auth.logout" }),
168
+ },
169
+ });
277
170
 
278
- ## Spec Features
171
+ const disconnect = connectRenderer({
172
+ actor,
173
+ registry: registryResult.registry,
174
+ registryResult,
175
+ container: document.getElementById("app")!,
176
+ });
279
177
 
280
- `renderSpec` / `renderElement` supports these spec directives:
178
+ // Later:
179
+ disconnect();
180
+ ```
281
181
 
282
- | Directive | Description |
283
- | ------------------------------------------------------ | ------------------------------------------------------------------------------------- |
284
- | `visible` | Boolean or `{ $state: "/path" }` — hide element when false |
285
- | `on.submit` / `on.click` | Action binding — `{ action: "login", params: { username: { $state: "/username" } } }` |
286
- | `repeat: { statePath, key? }` | Render children once per item in the state array at `statePath` |
287
- | `watch: { "/path": actionBinding }` | Fire action when store path changes after mount |
288
- | `props.username: { $bindState: "/username" }` | Two-way binding — read from store, write back via `ctx.store.update()` |
289
- | `props.value: { $state: "/value" }` | Read-only store reference |
290
- | `props.label: { $computed: "computeFn", args: [...] }` | Computed prop via `functions` map |
182
+ ## API Summary
291
183
 
292
- ## `PlayRenderer` — class API
184
+ ### XMachines Layer
293
185
 
294
- ```typescript
295
- import { PlayRenderer, defineRegistry } from "@xmachines/play-dom";
186
+ | Export | Kind | Description |
187
+ | ---------------------------------------- | -------- | --------------------------------------------------------------------------- |
188
+ | `createRenderer(catalog, components)` | function | One-call factory — returns `mount(actor, container, options?) → disconnect` |
189
+ | `createPlayUI(registryResult, options?)` | function | Batteries-included factory — returns `MountFn` |
190
+ | `PlayRenderer` | class | Class-based renderer with `connect()` / `disconnect()` lifecycle |
191
+ | `connectRenderer(options)` | function | Functional API; backward-compatible alternative to `PlayRenderer` |
192
+ | `defineRegistry(catalog, options)` | function | Build a catalog-typed `DomRegistry` with typed handlers |
193
+ | `renderSpec(...)` | function | Pure Spec → DOM renderer (low-level) |
194
+ | `schema` | const | The `@json-render/dom` schema — pass to `defineCatalog()` |
296
195
 
297
- const { registry, registryResult } = defineRegistry(catalog, { components, actions });
196
+ ### Key Types
298
197
 
299
- const renderer = new PlayRenderer(document.getElementById("app")!, actor, registry, {
300
- registryResult,
301
- });
198
+ | Type | Description |
199
+ | ------------------------ | -------------------------------------------------------------------------------------- |
200
+ | `ComponentFn<C, K>` | Catalog-typed component function — returns `HTMLElement \| Text \| null` |
201
+ | `ComponentContext<C, K>` | Context passed to each component: `props`, `children`, `emit`, `on`, `bindings`, `ctx` |
202
+ | `ActionFn<C, K>` | Catalog-typed action function — receives `(params, setState, state)` |
203
+ | `EventHandle` | Handle returned by `on(eventName)` — has `emit()`, `shouldPreventDefault`, `bound` |
204
+ | `SetState` | State updater: `(prev => next) => void` |
205
+ | `DefineRegistryResult` | Result from `defineRegistry` — has `registry`, `handlers`, `executeAction` |
206
+ | `PlayDomOptions` | Options for `PlayRenderer` and `connectRenderer` |
207
+ | `BaseComponentProps<P>` | Catalog-agnostic component props for shared component libraries |
208
+ | `DomRegistry` | Raw registry type: `Record<string, DomComponentRenderer>` |
209
+ | `DomSchema` | Type of the `schema` export |
302
210
 
303
- renderer.connect(); // starts watching actor.currentView
304
- renderer.disconnect(); // stops watching, clears container
211
+ ### Error Classes
305
212
 
306
- // double-connect is safe — connect() calls disconnect() internally if already connected
307
- ```
213
+ | Class | Error Code | Description |
214
+ | ----------------------- | --------------------------------- | ---------------------------------------- |
215
+ | `MissingCatalogError` | `PLAY_RENDERER_MISSING_CATALOG` | Components map was `null` or `undefined` |
216
+ | `MissingComponentError` | `PLAY_RENDERER_MISSING_COMPONENT` | Component name not found in catalog |
308
217
 
309
- ## Options Reference
218
+ ## Rendering Behaviour
310
219
 
311
- ### `ConnectRendererOptions` / `PlayDomOptions`
220
+ - **Initial render is synchronous** — the container is populated before `connect()` or `connectRenderer()` returns.
221
+ - **Signal-driven re-renders are microtask-deferred** — `watchSignal` schedules updates on the next microtask queue tick.
222
+ - **Null view** clears the container. A `fallback` element can be shown on initial mount when the view is `null`; it is **not** re-appended if the view later transitions back to `null` after a non-null view.
223
+ - **Double `connect()` is safe** — calling `connect()` on an already-connected renderer auto-disconnects first.
224
+ - **`disconnect()` clears the container** and unsubscribes all signal and store watchers.
312
225
 
313
- | Option | Type | Description |
314
- | ---------------- | ------------------------------- | ----------------------------------------------------------------------------- |
315
- | `actor` | `AbstractActor & Viewable` | Actor providing `currentView` signal |
316
- | `registry` | `DomRegistry` | Component renderer map from `defineRegistry` |
317
- | `registryResult` | `DefineRegistryResult` | Preferred — auto-wires `setState`/`state` from xstate store |
318
- | `handlers` | `Record<string, ActionHandler>` | Pre-resolved handlers (legacy / advanced) |
319
- | `container` | `HTMLElement` | DOM element to render into |
320
- | `fallback` | `HTMLElement \| null` | Shown on initial mount when view is `null` (initial mount only) |
321
- | `store` | `StateStore` | External store — controlled mode, overrides `spec.state` |
322
- | `loading` | `boolean` | Streaming mode — suppresses missing-child warnings, exposes `ctx.ctx.loading` |
226
+ ## Testing
323
227
 
324
- ## Learn More
228
+ ```bash
229
+ # Run all tests (jsdom environment)
230
+ npm test
231
+
232
+ # Run with coverage
233
+ npm run test:coverage
234
+ ```
325
235
 
326
- - [Demo](examples/demo/README.md)
327
- - [DOM Router adapter](../play-dom-router/README.md)
328
- - [Play RFC](../../packages/docs/rfc/play.md)
236
+ Tests live in `test/` and use [Vitest](https://vitest.dev/) with a jsdom environment. Coverage thresholds: 80% lines, functions, branches, and statements.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmachines/play-dom",
3
- "version": "1.0.0-beta.45",
3
+ "version": "1.0.0-beta.47",
4
4
  "description": "Vanilla DOM renderer for XMachines Play architecture with signal-driven rendering",
5
5
  "keywords": [
6
6
  "actor",
@@ -43,15 +43,15 @@
43
43
  "prepublishOnly": "npm run build"
44
44
  },
45
45
  "dependencies": {
46
- "@xmachines/play": "1.0.0-beta.45",
47
- "@xmachines/play-actor": "1.0.0-beta.45",
48
- "@xmachines/play-signals": "1.0.0-beta.45"
46
+ "@xmachines/play": "1.0.0-beta.47",
47
+ "@xmachines/play-actor": "1.0.0-beta.47",
48
+ "@xmachines/play-signals": "1.0.0-beta.47"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@json-render/core": "^0.18.0",
52
52
  "@json-render/xstate": "^0.18.0",
53
53
  "@types/node": "^25.6.0",
54
- "@xmachines/shared": "1.0.0-beta.45",
54
+ "@xmachines/shared": "1.0.0-beta.47",
55
55
  "@xstate/store": "^3.17.0",
56
56
  "oxfmt": "^0.45.0",
57
57
  "oxlint": "^1.60.0",