@xmachines/play-dom 1.0.0-beta.32 → 1.0.0-beta.33

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
@@ -2,73 +2,256 @@
2
2
 
3
3
  **Vanilla DOM renderer for XMachines**
4
4
 
5
- `connectRenderer` — a framework-free `PlayRenderer` equivalent that wires an XState v5 actor's `currentView` TC39 Signal to pure DOM rendering via a catalog-typed `DomRegistry`.
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`.
6
6
 
7
7
  ## Installation
8
8
 
9
9
  ```bash
10
- npm install @xmachines/play-dom
10
+ npm install @xmachines/play-dom @json-render/core zod
11
11
  ```
12
12
 
13
13
  ## Key Exports
14
14
 
15
- - `connectRenderer({ actor, registry, container, handlers })` — connect actor view signal to DOM
16
- - `defineRegistry(catalog, { components, actions })` — build a catalog-typed `DomRegistry` with real async action handlers
17
- - `schema` — DOM schema for use with `defineCatalog` (mirrors `@json-render/react/schema` shape)
18
- - `PlayRenderer` — class-based renderer (connect/disconnect lifecycle)
19
- - `ComponentFn<C, K>` — catalog-typed DOM component function type
20
- - `ComponentContext<C, K>` — context passed to each `ComponentFn` (props, emit, on, children, bindings)
21
- - `renderSpec(spec, store, registry, send, handlers)` — pure Spec → DOM renderer
15
+ | Export | Description |
16
+ | --------------------------------------------------- | ------------------------------------------------------------------------------------------- |
17
+ | `createRenderer(catalog, componentMap)` | One-call factory: returns `mount(actor, container, options?) → disconnect` |
18
+ | `connectRenderer(options)` | Functional API: connect actor → DOM with full options |
19
+ | `defineRegistry(catalog, { components, actions })` | Build a catalog-typed `DomRegistry` with typed action handlers |
20
+ | `PlayRenderer` | Class-based renderer — `connect()` / `disconnect()` lifecycle |
21
+ | `schema` | DOM schema for `defineCatalog` (mirrors `@json-render/react/schema`) |
22
+ | `ComponentFn<C, K>` | Catalog-typed DOM component function type |
23
+ | `ComponentContext<C, K>` | Context passed to each `ComponentFn` — `props`, `on`, `emit`, `children`, `bindings`, `ctx` |
24
+ | `ActionFn<C, K>` | `(params, setState, state) => Promise<void>` — catalog-typed action handler |
25
+ | `SetState` | `(updater: prev => next) => void` — write to the local state store |
26
+ | `BaseComponentProps<P>` | Base type for catalog component prop definitions |
27
+ | `CatalogHasActions<C>` | Conditional type: `true` when catalog declares actions |
28
+ | `renderSpec(spec, store, registry, send, handlers)` | Pure Spec → DOM renderer (advanced use) |
22
29
 
23
- ## Usage
30
+ ## Quick Start — `createRenderer`
24
31
 
25
- ```ts
26
- import { definePlayer } from "@xmachines/play-xstate";
27
- import { connectRenderer, defineRegistry, schema } from "@xmachines/play-dom";
32
+ The preferred one-call pattern — mirrors all framework renderers:
33
+
34
+ ```typescript
35
+ import { createRenderer, schema } from "@xmachines/play-dom";
28
36
  import { defineCatalog } from "@json-render/core";
29
- import { authCatalogDef, type AuthActor } from "@xmachines/play-actor-shared";
37
+ import { z } from "zod";
30
38
  import type { ComponentFn } from "@xmachines/play-dom";
31
39
 
32
- // 1. Build catalog with DOM schema
33
- const authCatalog = defineCatalog(schema, authCatalogDef);
34
- type AuthCatalog = typeof authCatalog;
40
+ // 1. Define catalog
41
+ const catalog = defineCatalog(schema, {
42
+ components: {
43
+ Home: { props: z.object({ title: z.string() }) },
44
+ Login: { props: z.object({ title: z.string(), username: z.string().optional() }) },
45
+ },
46
+ actions: {
47
+ login: { params: z.object({ username: z.string() }) },
48
+ logout: {},
49
+ },
50
+ });
51
+ type AppCatalog = typeof catalog;
35
52
 
36
- // 2. Implement catalog components
37
- const Home: ComponentFn<AuthCatalog, "Home"> = ({ props }) => {
53
+ // 2. Implement components
54
+ const Home: ComponentFn<AppCatalog, "Home"> = ({ props }) => {
38
55
  const el = document.createElement("section");
39
56
  el.textContent = props.title;
40
57
  return el;
41
58
  };
42
59
 
43
- // 3. Build registry with real async action handlers
44
- const registryResult = defineRegistry(authCatalog, {
45
- components: { Home /* ... */ },
60
+ const Login: ComponentFn<AppCatalog, "Login"> = ({ props, on }) => {
61
+ const section = document.createElement("section");
62
+ const input = document.createElement("input");
63
+ input.value = props.username ?? "";
64
+ input.addEventListener("input", () =>
65
+ ctx.store.update((s) => ({ ...s, username: input.value })),
66
+ );
67
+
68
+ const button = document.createElement("button");
69
+ button.textContent = "Log In";
70
+ const submit = on("submit");
71
+ button.addEventListener("click", () => submit.emit());
72
+
73
+ section.append(input, button);
74
+ return section;
75
+ };
76
+
77
+ // 3. Create the renderer factory (once, at module level)
78
+ const mount = createRenderer(catalog, { Home, Login });
79
+
80
+ // 4. Mount when actor and container are ready
81
+ const actor = createPlayer()();
82
+ actor.start();
83
+
84
+ const disconnect = mount(actor, document.getElementById("app")!);
85
+
86
+ // Cleanup:
87
+ disconnect();
88
+ ```
89
+
90
+ ## `defineRegistry` — Full Control
91
+
92
+ When you need `registryResult.executeAction()` or want to share the registry with `connectRenderer`:
93
+
94
+ ```typescript
95
+ import { defineRegistry, connectRenderer, schema } from "@xmachines/play-dom";
96
+ import { defineCatalog } from "@json-render/core";
97
+ import { z } from "zod";
98
+
99
+ const catalog = defineCatalog(schema, {
100
+ components: {
101
+ Home: { props: z.object({ title: z.string() }) },
102
+ },
103
+ actions: {
104
+ login: { params: z.object({ username: z.string() }) },
105
+ logout: {},
106
+ },
107
+ });
108
+
109
+ // Action handlers receive (params, setState, state)
110
+ // - params: resolved from the spec's on.submit.params (e.g. { $state: "/username" })
111
+ // - setState: write to the local state store (e.g. to clear a form)
112
+ // - state: current local state store snapshot
113
+ const registryResult = defineRegistry(catalog, {
114
+ components: {
115
+ Home: ({ props }) => {
116
+ const el = document.createElement("section");
117
+ el.textContent = props.title;
118
+ return el;
119
+ },
120
+ },
46
121
  actions: {
47
- login: async (params) => {
122
+ login: async (params, setState) => {
48
123
  if (!params) return;
49
124
  actor.send({ type: "auth.login", username: params.username });
125
+ setState((prev) => ({ ...prev, username: "" })); // clear the form
50
126
  },
51
127
  logout: async () => actor.send({ type: "auth.logout" }),
52
128
  },
53
129
  });
54
130
 
55
- // 4. Resolve handlers and connect
56
- const actor = definePlayer({ machine: authMachine })();
57
- actor.start();
58
-
59
- const handlers = registryResult.handlers(
60
- () => undefined,
61
- () => ({}),
62
- );
63
- const container = document.getElementById("app")!;
64
131
  const disconnect = connectRenderer({
65
132
  actor,
66
133
  registry: registryResult.registry,
67
- container,
68
- handlers,
134
+ registryResult, // wires setState/state from xstate store automatically
135
+ container: document.getElementById("app")!,
69
136
  });
70
137
  ```
71
138
 
139
+ ## Component API
140
+
141
+ ### `ComponentFn<C, K>` — component function signature
142
+
143
+ ```typescript
144
+ const MyCard: ComponentFn<AppCatalog, "Card"> = ({
145
+ props, // catalog-typed props for this component
146
+ children, // Node[] — rendered child nodes
147
+ on, // (eventName) => EventHandle — get emit() for catalog-declared events
148
+ emit, // (eventName) => void — fire an event directly
149
+ bindings, // Record<string, string> — $bindState paths for two-way bindings
150
+ ctx, // DomRenderContext — store, send, handlers, loading, functions
151
+ }) => {
152
+ const el = document.createElement("div");
153
+ el.append(...children);
154
+ return el;
155
+ };
156
+ ```
157
+
158
+ ### Two-way binding with `$bindState`
159
+
160
+ In the view spec:
161
+
162
+ ```json
163
+ { "username": { "$bindState": "/username" } }
164
+ ```
165
+
166
+ In the component:
167
+
168
+ ```typescript
169
+ const Login: ComponentFn<AppCatalog, "Login"> = ({ props, ctx }) => {
170
+ const input = document.createElement("input");
171
+ input.value = props.username ?? "";
172
+ // Write back to the store on user input
173
+ input.addEventListener("input", () => {
174
+ ctx.store.update((s) => ({ ...s, username: input.value }));
175
+ });
176
+ return input;
177
+ };
178
+ ```
179
+
180
+ ### `on()` — event handle
181
+
182
+ ```typescript
183
+ const submit = on("submit"); // EventHandle
184
+ if (submit.bound) {
185
+ button.addEventListener("click", (e) => {
186
+ if (submit.shouldPreventDefault) e.preventDefault();
187
+ submit.emit(); // resolves params from store, calls action handler
188
+ });
189
+ }
190
+ ```
191
+
192
+ ### `ActionFn` — action handler signature
193
+
194
+ ```typescript
195
+ // Full signature — all three params are available
196
+ login: async (params, setState, state) => {
197
+ actor.send({ type: "auth.login", username: params!.username });
198
+ setState(prev => ({ ...prev, username: "" }));
199
+ console.log("previous state was:", state);
200
+ },
201
+
202
+ // Params-only — setState/state can be omitted if unused
203
+ logout: async () => actor.send({ type: "auth.logout" }),
204
+ route: async (params) => actor.send({ type: "play.route", to: params!.to }),
205
+ ```
206
+
207
+ ## Spec Features
208
+
209
+ `renderSpec` / `renderElement` supports these spec directives:
210
+
211
+ | Directive | Description |
212
+ | ------------------------------------------------------ | ------------------------------------------------------------------------------------- |
213
+ | `visible` | Boolean or `{ $state: "/path" }` — hide element when false |
214
+ | `on.submit` / `on.click` | Action binding — `{ action: "login", params: { username: { $state: "/username" } } }` |
215
+ | `repeat: { statePath, key? }` | Render children once per item in the state array at `statePath` |
216
+ | `watch: { "/path": actionBinding }` | Fire action when store path changes after mount |
217
+ | `props.username: { $bindState: "/username" }` | Two-way binding — read from store, write back via `ctx.store.update()` |
218
+ | `props.value: { $state: "/value" }` | Read-only store reference |
219
+ | `props.label: { $computed: "computeFn", args: [...] }` | Computed prop via `functions` map |
220
+
221
+ ## `PlayRenderer` — class API
222
+
223
+ ```typescript
224
+ import { PlayRenderer, defineRegistry } from "@xmachines/play-dom";
225
+
226
+ const { registry, registryResult } = defineRegistry(catalog, { components, actions });
227
+
228
+ const renderer = new PlayRenderer(document.getElementById("app")!, actor, registry, {
229
+ registryResult,
230
+ });
231
+
232
+ renderer.connect(); // starts watching actor.currentView
233
+ renderer.disconnect(); // stops watching, clears container
234
+
235
+ // double-connect is safe — connect() calls disconnect() internally if already connected
236
+ ```
237
+
238
+ ## Options Reference
239
+
240
+ ### `ConnectRendererOptions` / `PlayDomOptions`
241
+
242
+ | Option | Type | Description |
243
+ | ---------------- | ------------------------------- | ----------------------------------------------------------------------------- |
244
+ | `actor` | `AbstractActor & Viewable` | Actor providing `currentView` signal |
245
+ | `registry` | `DomRegistry` | Component renderer map from `defineRegistry` |
246
+ | `registryResult` | `DefineRegistryResult` | Preferred — auto-wires `setState`/`state` from xstate store |
247
+ | `handlers` | `Record<string, ActionHandler>` | Pre-resolved handlers (legacy / advanced) |
248
+ | `container` | `HTMLElement` | DOM element to render into |
249
+ | `fallback` | `HTMLElement \| null` | Shown on initial mount when view is `null` (initial mount only) |
250
+ | `store` | `StateStore` | External store — controlled mode, overrides `spec.state` |
251
+ | `loading` | `boolean` | Streaming mode — suppresses missing-child warnings, exposes `ctx.ctx.loading` |
252
+
72
253
  ## Learn More
73
254
 
74
255
  - [DOM Router adapter `@xmachines/play-dom-router`](../play-dom-router/README.md)
256
+ - [API reference](../../packages/docs/api/@xmachines/play-dom/README.md)
257
+ - [Play RFC](../../packages/docs/rfc/play.md)
@@ -13,24 +13,37 @@ import type { AnyActorLogic } from "xstate";
13
13
  import type { DomRegistry } from "./json-render/types.js";
14
14
  import type { PlayDomOptions } from "./xm-types.js";
15
15
  /**
16
- * PlayRenderer connects an actor's currentView signal to the DOM renderer.
16
+ * PlayRenderer connects an actor's `currentView` signal to the DOM renderer.
17
17
  *
18
- * Usage:
18
+ * Watches `actor.currentView` via TC39 Signals and renders `DomComponentRenderer`
19
+ * functions into `container` on every view transition. Cleared on `disconnect()`.
20
+ *
21
+ * **Preferred usage — via `registryResult`:**
19
22
  * ```typescript
20
- * const { registry, handlers } = defineRegistry(catalog, { components, actions });
21
- * const resolvedHandlers = handlers(() => undefined, () => ({}));
22
- * const renderer = new PlayRenderer(container, actor, registry, { handlers: resolvedHandlers });
23
+ * import { PlayRenderer, defineRegistry } from "@xmachines/play-dom";
24
+ *
25
+ * const registryResult = defineRegistry(catalog, { components, actions });
26
+ * const renderer = new PlayRenderer(container, actor, registryResult.registry, {
27
+ * registryResult, // wires setState/getState from xstate store automatically
28
+ * });
23
29
  * renderer.connect();
30
+ * // Later:
31
+ * renderer.disconnect();
32
+ * ```
24
33
  *
25
- * // Controlled mode — bring your own store:
34
+ * **Controlled store mode** — bring your own `StateStore`:
35
+ * ```typescript
26
36
  * import { createAtom } from "@xstate/store";
27
37
  * import { xstateStoreStateStore } from "@json-render/xstate";
28
- * const store = xstateStoreStateStore({ atom: createAtom({ username: "" }) });
29
- * const renderer = new PlayRenderer(container, actor, registry, { store, handlers: resolvedHandlers });
30
38
  *
31
- * // Later:
32
- * renderer.disconnect();
39
+ * const atom = createAtom({ username: "" });
40
+ * const store = xstateStoreStateStore({ atom });
41
+ * const renderer = new PlayRenderer(container, actor, registry, { registryResult, store });
42
+ * renderer.connect();
33
43
  * ```
44
+ *
45
+ * Double `connect()` is safe — calling `connect()` while already connected
46
+ * automatically disconnects first, preventing double-render subscriptions.
34
47
  */
35
48
  export declare class PlayRenderer {
36
49
  private readonly container;
@@ -39,18 +52,34 @@ export declare class PlayRenderer {
39
52
  private readonly options;
40
53
  private _unwatch;
41
54
  private _storeUnsubscribe;
55
+ private _watchCleanups;
56
+ /**
57
+ * Set to `false` in `disconnect()` before calling `storeUnsub()`.
58
+ * The `rerender` closure checks this flag at entry so that any synchronous
59
+ * callback fired by the `StateStore` implementation within its own
60
+ * `unsubscribe()` call (an edge-case but valid contract) does not mutate a
61
+ * detached DOM tree.
62
+ */
63
+ private _alive;
42
64
  /**
43
- * @param container - The `HTMLElement` to render into. Cleared and repopulated on every view transition.
44
- * @param actor - Actor instance providing the `currentView` signal (must implement `Viewable`).
45
- * @param registry - Map of component type names to `DomComponentRenderer` functions.
46
- * @param options - Optional configuration: `handlers` map (action name → async handler) and
47
- * optional external `store` (controlled mode — when omitted, a fresh `@xstate/store` atom is
48
- * created per view transition seeded from `spec.state`).
65
+ * @param container - `HTMLElement` to render into. Cleared and repopulated on every view transition.
66
+ * @param actor - Actor providing the `currentView` signal (must implement `Viewable`).
67
+ * @param registry - Component renderer map — typically `registryResult.registry` from `defineRegistry`.
68
+ * @param options - Configuration:
69
+ * - `registryResult` — preferred; auto-wires `setState`/`state` from the xstate store.
70
+ * - `handlers` — pre-resolved handler map (legacy; used when `registryResult` is absent).
71
+ * - `store` — external `StateStore` (controlled mode; overrides `spec.state` seeding).
72
+ * - `loading` — streaming mode flag; suppresses missing-child warnings.
49
73
  */
50
74
  constructor(container: HTMLElement, actor: AbstractActor<AnyActorLogic> & Viewable, registry: DomRegistry, options?: PlayDomOptions);
51
75
  /**
52
76
  * Start watching actor.currentView and render to container.
53
77
  * Renders the initial view synchronously, then subscribes to signal changes.
78
+ *
79
+ * Calling `connect()` on an already-connected renderer (where a previous
80
+ * `connect()` was never followed by `disconnect()`) would silently install a
81
+ * second `watchSignal` subscription, causing double-renders on every view
82
+ * change. Guard against this by auto-disconnecting first.
54
83
  */
55
84
  connect(): void;
56
85
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"PlayRenderer.d.ts","sourceRoot":"","sources":["../src/PlayRenderer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAMH,OAAO,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAgB,MAAM,uBAAuB,CAAC;AACnF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAC;AAE5C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAC1D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAoBpD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,qBAAa,YAAY;IAavB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAfzB,OAAO,CAAC,QAAQ,CAA6B;IAC7C,OAAO,CAAC,iBAAiB,CAA6B;IAEtD;;;;;;;OAOG;gBAEe,SAAS,EAAE,WAAW,EACtB,KAAK,EAAE,aAAa,CAAC,aAAa,CAAC,GAAG,QAAQ,EAC9C,QAAQ,EAAE,WAAW,EACrB,OAAO,GAAE,cAAmB;IAG9C;;;OAGG;IACH,OAAO,IAAI,IAAI;IAKf;;OAEG;IACH,UAAU,IAAI,IAAI;IAQlB,OAAO,CAAC,OAAO;CA8Bf"}
1
+ {"version":3,"file":"PlayRenderer.d.ts","sourceRoot":"","sources":["../src/PlayRenderer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAMH,OAAO,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAgB,MAAM,uBAAuB,CAAC;AACnF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAC;AAE5C,OAAO,KAAK,EAAE,WAAW,EAAY,MAAM,wBAAwB,CAAC;AACpE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAyBpD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,qBAAa,YAAY;IAwBvB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,OAAO;IA1BzB,OAAO,CAAC,QAAQ,CAA6B;IAC7C,OAAO,CAAC,iBAAiB,CAA6B;IACtD,OAAO,CAAC,cAAc,CAAsB;IAC5C;;;;;;OAMG;IACH,OAAO,CAAC,MAAM,CAAQ;IAEtB;;;;;;;;;OASG;gBAEe,SAAS,EAAE,WAAW,EACtB,KAAK,EAAE,aAAa,CAAC,aAAa,CAAC,GAAG,QAAQ,EAC9C,QAAQ,EAAE,WAAW,EACrB,OAAO,GAAE,cAAmB;IAG9C;;;;;;;;OAQG;IACH,OAAO,IAAI,IAAI;IAMf;;OAEG;IACH,UAAU,IAAI,IAAI;IAmBlB,OAAO,CAAC,OAAO;CAuFf"}
@@ -20,34 +20,52 @@ import { renderSpec } from "./json-render/renderer.js";
20
20
  * machine author put in the view spec. `createAtom` requires a plain object as
21
21
  * its initial value — anything else produces a broken store at runtime.
22
22
  *
23
- * Returns the value unchanged when it is a non-null object; falls back to `{}`
24
- * for all other cases (null, undefined, string, number, array, etc.).
23
+ * Only plain objects (prototype is `Object.prototype` or `null`) are accepted.
24
+ * Class instances and built-in objects (Date, Map, Set, etc.) are rejected and
25
+ * fall back to `{}` — this prevents silent broken-store bugs at runtime.
26
+ * Arrays are also rejected (they have `Array.prototype`).
25
27
  */
26
28
  function toAtomState(state) {
27
29
  if (state !== null && typeof state === "object" && !Array.isArray(state)) {
28
- return state;
30
+ const proto = Object.getPrototypeOf(state);
31
+ if (proto === Object.prototype || proto === null) {
32
+ return state;
33
+ }
29
34
  }
30
35
  return {};
31
36
  }
32
37
  /**
33
- * PlayRenderer connects an actor's currentView signal to the DOM renderer.
38
+ * PlayRenderer connects an actor's `currentView` signal to the DOM renderer.
34
39
  *
35
- * Usage:
40
+ * Watches `actor.currentView` via TC39 Signals and renders `DomComponentRenderer`
41
+ * functions into `container` on every view transition. Cleared on `disconnect()`.
42
+ *
43
+ * **Preferred usage — via `registryResult`:**
36
44
  * ```typescript
37
- * const { registry, handlers } = defineRegistry(catalog, { components, actions });
38
- * const resolvedHandlers = handlers(() => undefined, () => ({}));
39
- * const renderer = new PlayRenderer(container, actor, registry, { handlers: resolvedHandlers });
45
+ * import { PlayRenderer, defineRegistry } from "@xmachines/play-dom";
46
+ *
47
+ * const registryResult = defineRegistry(catalog, { components, actions });
48
+ * const renderer = new PlayRenderer(container, actor, registryResult.registry, {
49
+ * registryResult, // wires setState/getState from xstate store automatically
50
+ * });
40
51
  * renderer.connect();
52
+ * // Later:
53
+ * renderer.disconnect();
54
+ * ```
41
55
  *
42
- * // Controlled mode — bring your own store:
56
+ * **Controlled store mode** — bring your own `StateStore`:
57
+ * ```typescript
43
58
  * import { createAtom } from "@xstate/store";
44
59
  * import { xstateStoreStateStore } from "@json-render/xstate";
45
- * const store = xstateStoreStateStore({ atom: createAtom({ username: "" }) });
46
- * const renderer = new PlayRenderer(container, actor, registry, { store, handlers: resolvedHandlers });
47
60
  *
48
- * // Later:
49
- * renderer.disconnect();
61
+ * const atom = createAtom({ username: "" });
62
+ * const store = xstateStoreStateStore({ atom });
63
+ * const renderer = new PlayRenderer(container, actor, registry, { registryResult, store });
64
+ * renderer.connect();
50
65
  * ```
66
+ *
67
+ * Double `connect()` is safe — calling `connect()` while already connected
68
+ * automatically disconnects first, preventing double-render subscriptions.
51
69
  */
52
70
  export class PlayRenderer {
53
71
  container;
@@ -56,13 +74,24 @@ export class PlayRenderer {
56
74
  options;
57
75
  _unwatch = null;
58
76
  _storeUnsubscribe = null;
77
+ _watchCleanups = [];
78
+ /**
79
+ * Set to `false` in `disconnect()` before calling `storeUnsub()`.
80
+ * The `rerender` closure checks this flag at entry so that any synchronous
81
+ * callback fired by the `StateStore` implementation within its own
82
+ * `unsubscribe()` call (an edge-case but valid contract) does not mutate a
83
+ * detached DOM tree.
84
+ */
85
+ _alive = true;
59
86
  /**
60
- * @param container - The `HTMLElement` to render into. Cleared and repopulated on every view transition.
61
- * @param actor - Actor instance providing the `currentView` signal (must implement `Viewable`).
62
- * @param registry - Map of component type names to `DomComponentRenderer` functions.
63
- * @param options - Optional configuration: `handlers` map (action name → async handler) and
64
- * optional external `store` (controlled mode — when omitted, a fresh `@xstate/store` atom is
65
- * created per view transition seeded from `spec.state`).
87
+ * @param container - `HTMLElement` to render into. Cleared and repopulated on every view transition.
88
+ * @param actor - Actor providing the `currentView` signal (must implement `Viewable`).
89
+ * @param registry - Component renderer map — typically `registryResult.registry` from `defineRegistry`.
90
+ * @param options - Configuration:
91
+ * - `registryResult` — preferred; auto-wires `setState`/`state` from the xstate store.
92
+ * - `handlers` — pre-resolved handler map (legacy; used when `registryResult` is absent).
93
+ * - `store` — external `StateStore` (controlled mode; overrides `spec.state` seeding).
94
+ * - `loading` — streaming mode flag; suppresses missing-child warnings.
66
95
  */
67
96
  constructor(container, actor, registry, options = {}) {
68
97
  this.container = container;
@@ -73,8 +102,15 @@ export class PlayRenderer {
73
102
  /**
74
103
  * Start watching actor.currentView and render to container.
75
104
  * Renders the initial view synchronously, then subscribes to signal changes.
105
+ *
106
+ * Calling `connect()` on an already-connected renderer (where a previous
107
+ * `connect()` was never followed by `disconnect()`) would silently install a
108
+ * second `watchSignal` subscription, causing double-renders on every view
109
+ * change. Guard against this by auto-disconnecting first.
76
110
  */
77
111
  connect() {
112
+ if (this._unwatch !== null)
113
+ this.disconnect();
78
114
  this._render(this.actor.currentView.get());
79
115
  this._unwatch = watchSignal(this.actor.currentView, (view) => this._render(view));
80
116
  }
@@ -84,14 +120,38 @@ export class PlayRenderer {
84
120
  disconnect() {
85
121
  this._unwatch?.();
86
122
  this._unwatch = null;
87
- this._storeUnsubscribe?.();
123
+ // Null the refs first so any in-flight rerender callback sees null and skips.
124
+ // Capture the arrays/functions before clearing so we call the correct cleanups
125
+ // even if unsubscribing the store triggers a synchronous rerender that would
126
+ // otherwise overwrite _watchCleanups before we iterate them.
127
+ const storeUnsub = this._storeUnsubscribe;
128
+ const watchCleanups = [...this._watchCleanups];
88
129
  this._storeUnsubscribe = null;
130
+ this._watchCleanups = [];
131
+ // Set alive = false before calling storeUnsub() so that any synchronous
132
+ // rerender callback triggered within unsubscribe() exits immediately.
133
+ this._alive = false;
134
+ storeUnsub?.();
135
+ for (const cleanup of watchCleanups)
136
+ cleanup();
89
137
  this.container.replaceChildren();
90
138
  }
91
139
  _render(view) {
140
+ // Reset alive before releasing old subscriptions — any synchronous callback
141
+ // from the old store's unsubscribe sees alive=true and will be accepted.
142
+ // This differs from disconnect() which sets alive=false BEFORE calling storeUnsub.
143
+ this._alive = true;
92
144
  // Clean up previous store subscription before re-rendering
93
145
  this._storeUnsubscribe?.();
94
146
  this._storeUnsubscribe = null;
147
+ // Release watch cleanups from the previous view's initial render.
148
+ // Without this, watch subscriptions from the prior view remain active
149
+ // between a view transition and the first store update in the new view,
150
+ // holding references to stale DOM elements and preventing GC.
151
+ const prevWatchCleanups = this._watchCleanups;
152
+ this._watchCleanups = [];
153
+ for (const cleanup of prevWatchCleanups)
154
+ cleanup();
95
155
  this.container.replaceChildren();
96
156
  if (!view)
97
157
  return;
@@ -105,10 +165,39 @@ export class PlayRenderer {
105
165
  atom: createAtom(toAtomState(view.spec.state)),
106
166
  });
107
167
  const send = this.actor.send.bind(this.actor);
108
- const handlers = this.options.handlers ?? {};
168
+ // Build a SetState function backed by the xstate store.
169
+ // Called lazily at action-invocation time (not at handlers() call time)
170
+ // so actions always see the latest store state.
171
+ const setState = (updater) => {
172
+ const prev = store.getSnapshot();
173
+ const next = updater(prev);
174
+ // store.update() is called with a full state snapshot (not a patch map).
175
+ // This is compatible with createStoreAdapter/createStateStore where update()
176
+ // performs a shallow merge of the provided top-level keys, effectively
177
+ // replacing the entire state model. Do not change this to a path-keyed
178
+ // patch unless the StateStore contract is updated to match.
179
+ store.update(next);
180
+ };
181
+ // Resolve handlers: prefer registryResult (wires setState/getState from store),
182
+ // fall back to pre-resolved handlers map for backward compatibility.
183
+ const handlers = this.options.registryResult
184
+ ? this.options.registryResult.handlers(() => setState, () => store.getSnapshot())
185
+ : (this.options.handlers ?? {});
186
+ const loading = this.options.loading;
109
187
  const rerender = () => {
188
+ // Guard: skip if PlayRenderer has been disconnected.
189
+ // Handles the edge case where a StateStore implementation fires its
190
+ // subscribed callbacks synchronously within its own unsubscribe() call.
191
+ if (!this._alive)
192
+ return;
193
+ // Clear watch subscriptions from previous render pass before re-rendering.
194
+ for (const cleanup of this._watchCleanups)
195
+ cleanup();
196
+ this._watchCleanups = [];
110
197
  this.container.replaceChildren();
111
- const node = renderSpec(view.spec, store, this.registry, send, handlers);
198
+ const node = renderSpec(view.spec, store, this.registry, send, handlers, undefined, undefined, undefined, loading, (cleanup) => {
199
+ this._watchCleanups.push(cleanup);
200
+ });
112
201
  if (node)
113
202
  this.container.appendChild(node);
114
203
  };
@@ -1 +1 @@
1
- {"version":3,"file":"PlayRenderer.js","sourceRoot":"","sources":["../src/PlayRenderer.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"PlayRenderer.js","sourceRoot":"","sources":["../src/PlayRenderer.ts"],"names":[],"mappings":"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"}