@xmachines/play-dom 6.0.0 → 7.0.0

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.
@@ -1,657 +1,594 @@
1
- /**
2
- * PlayRenderer.ts — the XMachines wrapper of @xmachines/json-render-dom.
3
- *
4
- * It connects actor.currentView, an atom, to the DOM renderer below it, with
5
- * watchAtom. It gives you the connect() method and the disconnect() method, in
6
- * the same pattern as the PlayRenderer class of play-react, of play-vue, and of
7
- * play-solid.
8
- *
9
- * The state store: the renderer uses the external `store` option when the caller
10
- * gives one, and this is the controlled mode. Without that option, it makes a new
11
- * @xstate/store atom for each view transition, with the values of spec.state.
12
- */
13
- import { createAtom, watchAtom } from "@xmachines/play-atom";
1
+ import { createAtom } from "@xmachines/play-atom";
14
2
  import { xstateStoreStateStore } from "@xmachines/json-render-xstate";
15
- import { createReportGuard, createViewStoreLifecycle, refreshContextSubtree, } from "@xmachines/play-view";
3
+ import { bindStoreHandlers, createReportGuard, createViewSession, forwardRenderError } from "@xmachines/play-view";
16
4
  import { releaseQuietly } from "@xmachines/core/utils";
17
5
  import { createIncrementalRenderer } from "@xmachines/json-render-dom";
6
+ //#region packages/play-dom/src/PlayRenderer.ts
7
+ /**
8
+ * PlayRenderer.ts — the XMachines wrapper of @xmachines/json-render-dom.
9
+ *
10
+ * It connects actor.currentView, an atom, to the DOM renderer below it, through the
11
+ * view session of @xmachines/play-view. It gives you the connect() method and the disconnect() method, in
12
+ * the same pattern as the PlayRenderer class of play-react, of play-vue, and of
13
+ * play-solid.
14
+ *
15
+ * The state store: the renderer uses the external `store` option when the caller
16
+ * gives one, and this is the controlled mode. Without that option, it makes a new
17
+ * @xstate/store atom for each view transition, with the values of spec.state.
18
+ */
18
19
  /**
19
- * What the report guard writes, and what it lets through.
20
- *
21
- * It CONTAINS a handler of the host that throws, exactly as the four framework renderers
22
- * do. The five hold one rule, and a caller learns it one time: a handler of the host never
23
- * defeats the containment. A host that must escalate raises the failure from a task of its
24
- * own, which reaches the global handler of the page in every one of the five.
25
- *
26
- * It stands ABOVE the doc comment of the class, and so does every other declaration of
27
- * this module: a declaration takes the comment block that stands directly before it, so a
28
- * const or a function placed between that comment and the class takes the documentation
29
- * of `PlayRenderer` with it, and the class reaches the generated reference with none.
30
- */
31
- const GUARD_MESSAGES = {
32
- noHandler: "[@xmachines/play-dom] PlayRenderer contained a failed rebuild of the view. " +
33
- "Give an onError option to receive it.",
34
- handlerThrew: "[@xmachines/play-dom] the onError handler of the host threw. " +
35
- "PlayRenderer contained the failure of the view all the same.",
20
+ * What the report guard writes, and what it lets through.
21
+ *
22
+ * It CONTAINS a handler of the host that throws, exactly as the four framework renderers
23
+ * do. The five hold one rule, and a caller learns it one time: a handler of the host never
24
+ * defeats the containment. A host that must escalate raises the failure from a task of its
25
+ * own, which reaches the global handler of the page in every one of the five.
26
+ *
27
+ * It stands ABOVE the doc comment of the class, and so does every other declaration of
28
+ * this module: a declaration takes the comment block that stands directly before it, so a
29
+ * const or a function placed between that comment and the class takes the documentation
30
+ * of `PlayRenderer` with it, and the class reaches the generated reference with none.
31
+ */
32
+ var GUARD_MESSAGES = {
33
+ noHandler: "[@xmachines/play-dom] PlayRenderer contained a failed rebuild of the view. Give an onError option to receive it.",
34
+ handlerThrew: "[@xmachines/play-dom] the onError handler of the host threw. PlayRenderer contained the failure of the view all the same."
36
35
  };
37
36
  /**
38
- * Reports a failure of one release of a caller, for `releaseQuietly` of `@xmachines/core/utils`.
39
- *
40
- * Every release that `disconnect()`, `render()`, and `resetFailedRebuild()` run is CODE
41
- * OF A CALLER: the `unsubscribe` of a store that the host gave, and the watch cleanups
42
- * that the inner renderer registered. Each one can throw, and a throw must stop no path.
43
- * `render()` releases the registrations of the previous view on the path of a view that
44
- * works, and the comment at that call gives its reason.
45
- *
46
- * `resetFailedRebuild()` runs the releases of a FAILED rebuild, BEFORE the renderer
47
- * contains the failure of the view. A throw there takes the containment down with it: the
48
- * catch that called it reaches `contain()` never, so the failure of the view reaches no
49
- * handler, the dead tree stays on the screen, and the failure of the release escapes in
50
- * its place — which is both outcomes that the containment exists to prevent.
51
- *
52
- * `disconnect()` has no containment to defeat, and it has the same rule for a second
53
- * reason: a throw from the first release skips every release after it, it skips the clear
54
- * of the container, and it skips the reset of the identity. The renderer then holds a
55
- * half teardown that no caller can complete.
56
- *
57
- * Each release therefore stands alone, and a failure of one goes to `console.error`.
58
- */
37
+ * Reports a failure of one release of a caller, for `releaseQuietly` of `@xmachines/core/utils`.
38
+ *
39
+ * Every release that `disconnect()`, `render()`, and `resetFailedRebuild()` run is CODE
40
+ * OF A CALLER: the `unsubscribe` of a store that the host gave, and the watch cleanups
41
+ * that the inner renderer registered. Each one can throw, and a throw must stop no path.
42
+ * `render()` releases the registrations of the previous view on the path of a view that
43
+ * works, and the comment at that call gives its reason.
44
+ *
45
+ * `resetFailedRebuild()` runs the releases of a FAILED rebuild, BEFORE the renderer
46
+ * contains the failure of the view. A throw there takes the containment down with it: the
47
+ * catch that called it reaches `contain()` never, so the failure of the view reaches no
48
+ * handler, the dead tree stays on the screen, and the failure of the release escapes in
49
+ * its place — which is both outcomes that the containment exists to prevent.
50
+ *
51
+ * `disconnect()` has no containment to defeat, and it has the same rule for a second
52
+ * reason: a throw from the first release skips every release after it, it skips the clear
53
+ * of the container, and it skips the reset of the identity. The renderer then holds a
54
+ * half teardown that no caller can complete.
55
+ *
56
+ * Each release therefore stands alone, and a failure of one goes to `console.error`.
57
+ */
59
58
  function reportReleaseFailure(releaseFailure) {
60
- console.error("[@xmachines/play-dom] a release of PlayRenderer threw. " +
61
- "The renderer completed the teardown all the same.", releaseFailure);
59
+ console.error("[@xmachines/play-dom] a release of PlayRenderer threw. The renderer completed the teardown all the same.", releaseFailure);
62
60
  }
63
61
  /**
64
- * PlayRenderer connects the `currentView` atom of an actor to the DOM renderer.
65
- *
66
- * It watches `actor.currentView` through the atoms. It renders each
67
- * `DomComponentRenderer` function into `container` on every view transition.
68
- * `disconnect()` clears the container.
69
- *
70
- * Every option of `PlayDomOptions`, which extends `UIProviderOptions`, goes into
71
- * `DomRenderContext` on each render pass:
72
- * - `functions` — the named compute functions of a `{ $computed: "name" }` prop expression
73
- * - `directives` — your own dynamic values with a `$` prefix, from `defineDirective`. The renderer resolves them with the props
74
- * - `validationFunctions` — your own check functions. They are available at `ctx.ctx.validationFunctions`
75
- * - `navigate` — the navigation callback. The renderer calls it for an `onSuccess: { navigate: "..." }` action binding
76
- * - `onRenderError` — the renderer calls it with `(error, name)` for a component render error and for an action handler rejection
77
- *
78
- * **The preferred use, with `registryResult`:**
79
- * ```typescript
80
- * import { PlayRenderer, defineRegistry } from "@xmachines/play-dom";
81
- *
82
- * const registryResult = defineRegistry(catalog, { components, actions });
83
- * const renderer = new PlayRenderer(container, actor, registryResult.registry, {
84
- * registryResult, // it connects setState and getState of the xstate store for you
85
- * navigate: (path) => myRouter.push(path),
86
- * functions: { fullName: (args) => `${args.first} ${args.last}` },
87
- * });
88
- * renderer.connect();
89
- * // Later:
90
- * renderer.disconnect();
91
- * ```
92
- *
93
- * **The controlled store mode** — you give your own `StateStore`:
94
- * ```typescript
95
- * import { createAtom } from "@xmachines/play-atom";
96
- * import { xstateStoreStateStore } from "@xmachines/json-render-xstate";
97
- *
98
- * const atom = createAtom({ username: "" });
99
- * const store = xstateStoreStateStore({ atom });
100
- * const renderer = new PlayRenderer(container, actor, registry, { registryResult, store });
101
- * renderer.connect();
102
- * ```
103
- *
104
- * A second `connect()` is safe. A `connect()` call on a connected renderer
105
- * disconnects it first. Therefore the renderer holds no second render
106
- * subscription.
107
- */
108
- export class PlayRenderer {
109
- container;
110
- actor;
111
- registry;
112
- options;
113
- /**
114
- * True from the start of `connect()` to the end of `disconnect()`.
115
- *
116
- * The watcher cannot answer this question. `connect()` installs it AFTER the
117
- * synchronous first render, so a host that calls `connect()` again from the `onError`
118
- * of that render finds no watcher, and a test of the watcher alone would skip the
119
- * `disconnect()` and leave the guard of the abandoned connection alive for ever.
120
- */
121
- connected = false;
122
- unwatch = null;
123
- storeUnsubscribe = null;
124
- /**
125
- * The cleanup functions of the live watch subscriptions. This field is a `Set`,
126
- * and not an array, so that each registered cleanup function can delete ITSELF on
127
- * its call: the incremental renderer releases a watcher when its element unmounts,
128
- * and it calls the registered wrapper. The Set therefore shrinks with the live
129
- * subscriptions. It does not collect one spent closure for each historical mount of
130
- * a view with a long life.
131
- */
132
- watchCleanups = new Set();
133
- /**
134
- * The viewKey of the view on the screen now, and the store behind it, without the
135
- * guard. An emission with the same key means that only the /context projection
136
- * moved: the fast path for a slice in render() refreshes the store in place, and it
137
- * does not remove the tree. The ephemeral view state, such as a draft or a toggle,
138
- * therefore stays. A null emission, from a state with no meta.view, is a GAP, and
139
- * not a new view: the DOM goes away, but the identity and the store stay. A return
140
- * to the same view therefore builds again around the ephemeral state that survived,
141
- * in the same way as the framework providers. `disconnect()` resets both fields,
142
- * because a store that stays alive must not outlive the connection.
143
- */
144
- lastViewKey = undefined;
145
- currentStore = null;
146
- /** The fallback element, built at the first moment that the renderer shows it. */
147
- fallbackElement = null;
148
- /**
149
- * The shared coordinator of the store lifecycle, from @xmachines/play-view. It
150
- * seeds the store again on a change of the viewKey, it refreshes /context in place
151
- * in every other case, and it guards the identity cache. The two fields above,
152
- * lastViewKey and currentStore, stay as the DOM-side record for the fast path of a
153
- * slice. That path is about the live TREE, which is the spec and the subscription,
154
- * and not about the store.
155
- */
156
- storeLifecycle = createViewStoreLifecycle((seed) => xstateStoreStateStore({ atom: createAtom(seed) }));
157
- /**
158
- * `disconnect()` sets this field to `false` before it calls `storeUnsub()`.
159
- * The `rerender` closure reads the flag at its start. Therefore a synchronous
160
- * callback from the `StateStore` implementation inside its own `unsubscribe()`
161
- * call, which is rare but correct, changes no detached DOM tree.
162
- */
163
- alive = true;
164
- /**
165
- * The report guard, from @xmachines/play-view.
166
- *
167
- * It blocks a reset that the host calls from INSIDE the report, and every reset once
168
- * the renderer is disconnected. The five renderers share the one implementation.
169
- *
170
- * `alive` cannot answer the second half. A contained failure sets that flag to `false`
171
- * and the next emission sets it back, because it guards the callback of the store
172
- * subscription — a reset that a host keeps outlives both.
173
- */
174
- // It is NOT readonly: `disconnect()` disposes the guard, and `connect()` revives the
175
- // renderer, so a reconnected renderer needs a guard that is not spent.
176
- guard = createReportGuard(GUARD_MESSAGES);
177
- /**
178
- * The retry that every report of THIS connection hands the host.
179
- *
180
- * `connect()` builds one, beside the guard that it belongs to. The four framework
181
- * renderers hand the host one reset for the life of the provider, and this field holds
182
- * the same rule for the life of the connection.
183
- */
184
- retry = this.retryOf(this.guard);
185
- /**
186
- * @param container - The `HTMLElement` to render into. Each view transition clears it and fills it again.
187
- * @param actor - The actor with the `currentView` atom. It must implement `Viewable`.
188
- * @param registry - The map of the component renderers, usually `registryResult.registry` from `defineRegistry`.
189
- * @param options - The configuration. See {@link PlayDomOptions}:
190
- * - `registryResult` — it connects `setState` and `state` of the xstate store for you.
191
- * - `store` — an external `StateStore`, which is the controlled mode. It replaces the values of `spec.state`.
192
- * - `loading` — the flag of the streaming mode. It stops each warning about an absent child.
193
- * - `functions` — the named compute functions of a `$computed` prop expression.
194
- * - `directives` — your own dynamic values with a `$` prefix. The renderer resolves them with the props.
195
- * - `validationFunctions` — your own check functions. They are available at `ctx.ctx.validationFunctions`.
196
- * - `navigate` — the navigation callback. The renderer calls it for `onSuccess: { navigate: "..." }`.
197
- * - `onRenderError` — the `(error, name)` handler of a component render error and of an action handler rejection. It stops the `console.error` fallback.
198
- * - `onError` — the `(error, reset)` handler of a failure of a complete rebuild. The renderer contains such a failure always, and this option says where the report goes.
199
- * - `fallback` — the element to show for every null view, and after a contained failure.
200
- */
201
- constructor(container, actor, registry, options = {}) {
202
- this.container = container;
203
- this.actor = actor;
204
- this.registry = registry;
205
- this.options = options;
206
- }
207
- /**
208
- * Starts the watch of actor.currentView, and renders into the container.
209
- * It renders the first view synchronously, then it subscribes to the atom changes.
210
- *
211
- * A `connect()` call on a connected renderer, where a `disconnect()` call did not
212
- * follow the previous `connect()`, installs a second `watchAtom` subscription.
213
- * Each view change then renders two times. Therefore this method disconnects the
214
- * renderer first.
215
- */
216
- connect() {
217
- if (this.connected)
218
- this.disconnect();
219
- this.connected = true;
220
- const guard = createReportGuard(GUARD_MESSAGES);
221
- this.guard = guard;
222
- // ONE retry for the connection, as the four framework renderers hand the host one
223
- // reset for the life of the provider. A retry built for each report gives a host
224
- // that keys a Map on the callback one entry for each failure.
225
- this.retry = this.retryOf(guard);
226
- // The first render is SYNCHRONOUS, and it can reach `onError`. It needs no `try`: the
227
- // renderer contains a failed rebuild, and the guard contains a handler of the host
228
- // that throws, so this call returns for every view that a caller can give.
229
- const seeded = this.actor.currentView.get();
230
- this.render(seeded);
231
- // A host that tears the renderer down from the handler — a fatal view, and the host
232
- // gives up — calls `disconnect()` while this method still runs, and the line below
233
- // would then hand the renderer a live watcher again: it renders into a container
234
- // that the host released, and the watcher outlives the actor.
235
- //
236
- // A host that CONNECTS again from the handler reaches the same line. The `connected`
237
- // flag above makes that call disconnect this connection first, so both hosts dispose
238
- // the guard of THIS call. Ask that guard, and not the field: the field holds the
239
- // guard of the connection that took the place of this one.
240
- if (guard.blocked())
241
- return;
242
- this.unwatch = watchAtom(this.actor.currentView, (view) => {
243
- this.render(view);
244
- });
245
- // The actor can hold ANOTHER view already. The render above is synchronous, and a
246
- // host that REPAIRS the actor from the handler — a bad initial view, and the host
247
- // sends a recovery event — moves the atom between the read and the line above.
248
- // `watchAtom` seeds nothing, so the repaired view would wait for the next
249
- // emission of the actor, and a screen that shows the fallback of a failure that the
250
- // host repaired already would wait with it. Render the view that the actor holds
251
- // NOW.
252
- //
253
- // This runs one time, and it loops for no host: a repair from THIS render reaches
254
- // the watcher above, which is installed already.
255
- const current = this.actor.currentView.get();
256
- if (current !== seeded)
257
- this.render(current);
258
- }
259
- /**
260
- * Stops the watch and clears the container.
261
- */
262
- disconnect() {
263
- this.connected = false;
264
- this.guard.dispose();
265
- releaseQuietly(this.unwatch, reportReleaseFailure);
266
- this.unwatch = null;
267
- // Set the refs to null first. A rerender callback that is still in flight therefore
268
- // sees null, and it stops.
269
- // Capture each array and each function before the code clears them. Therefore the
270
- // code calls the correct cleanup functions, also when the cancellation of the store
271
- // subscription starts a synchronous rerender. Without the capture, that rerender
272
- // overwrites watchCleanups before the loop over them.
273
- const storeUnsub = this.storeUnsubscribe;
274
- const watchCleanups = [...this.watchCleanups];
275
- this.storeUnsubscribe = null;
276
- this.watchCleanups = new Set();
277
- // Set alive = false before the storeUnsub() call. A synchronous rerender callback
278
- // from inside unsubscribe() therefore returns at once.
279
- this.alive = false;
280
- // See reportReleaseFailure: a throw of one release must skip none of the work below it.
281
- releaseQuietly(storeUnsub, reportReleaseFailure);
282
- for (const cleanup of watchCleanups)
283
- releaseQuietly(cleanup, reportReleaseFailure);
284
- this.container.replaceChildren();
285
- // The rule of the lifetime: the store that stays alive dies with the connection.
286
- this.lastViewKey = undefined;
287
- this.currentStore = null;
288
- this.storeLifecycle.reset();
289
- }
290
- /**
291
- * The number of live watch-subscription cleanup functions now.
292
- *
293
- * @internal This accessor is for a test only. A test asserts with it that the
294
- * collection of the watch cleanups holds the live registrations, and that no mount
295
- * leaks. It is not part of the public API, and it can change without a notice.
296
- */
297
- get watchCleanupCount() {
298
- return this.watchCleanups.size;
299
- }
300
- render(view) {
301
- // ── The fast path of a slice ────────────────────────────────────────────
302
- // The viewKey of the live tree is the same → only the /context projection changed.
303
- // deriveCurrentView uses the selected meta entry as the key of each emission, and the
304
- // authored spec of a state is static. Refresh the subtree of the store in place: the
305
- // store subscription below calls incremental.update() already, and that function
306
- // compares the snapshots and patches the elements of the change only. Skip the
307
- // teardown completely, so that the ephemeral view state stays.
308
- // createIncrementalRenderer binds the SPEC at its construction. Therefore this path
309
- // is safe only because the spec is the same. A real view change, which means a
310
- // different viewKey or no viewKey for a comparison, still rebuilds the tree below, as
311
- // the discard-on-view-transition contract of the incremental renderer says.
312
- if (view !== null &&
313
- view.viewKey !== undefined &&
314
- this.lastViewKey === view.viewKey &&
315
- this.currentStore !== null &&
316
- this.storeUnsubscribe !== null) {
317
- // The fast path reads `spec.state` too, so it fails exactly as a rebuild does.
318
- // It sat OUTSIDE the try below, so a stream that keeps its viewKey and emits a
319
- // spec with an accessor that throws escaped the callback of watchAtom
320
- // uncontained, with the stale tree still on the screen.
321
- try {
322
- refreshContextSubtree(this.currentStore, view);
323
- }
324
- catch (error) {
325
- this.failView(error);
326
- }
327
- return;
328
- }
329
- this.alive = true;
330
- // Capture the values and clear them before the call. Some store implementations
331
- // fire a synchronous callback during their unsubscribe(). Without this order, that
332
- // callback sees a live storeUnsubscribe, and it enters the rerender path again with
333
- // an old view and an old store.
334
- // disconnect() uses the same capture-then-clear pattern.
335
- // See reportReleaseFailure. This release is code of a caller, and it runs on the path of a
336
- // view that WORKS: a throw here would abort the render of a good view, and it would
337
- // leave the renderer with the tree of the previous view on the screen and half of
338
- // its registrations released.
339
- const oldUnsub = this.storeUnsubscribe;
340
- this.storeUnsubscribe = null;
341
- releaseQuietly(oldUnsub, reportReleaseFailure);
342
- // Release the watch cleanups of the first render of the previous view.
343
- // Without this step, the watch subscriptions of the previous view stay active between
344
- // a view transition and the first store update of the new view. They then hold a
345
- // reference to a DOM element that is gone, and the garbage collector cannot take
346
- // it.
347
- const prevWatchCleanups = [...this.watchCleanups];
348
- this.watchCleanups = new Set();
349
- for (const cleanup of prevWatchCleanups)
350
- releaseQuietly(cleanup, reportReleaseFailure);
351
- this.container.replaceChildren();
352
- // A null emission is a gap between two views, and not a new view: keep lastViewKey
353
- // and currentStore. A return to the same identity therefore uses the store below
354
- // again. disconnect() stays the boundary of the lifetime.
355
- if (!view) {
356
- // Show the fallback for EVERY null view, and not for the first mount only. The
357
- // four framework providers hold the same rule: a null currentView renders the
358
- // placeholder content of the provider, at any moment of the life of the tree.
359
- this.showFallback();
360
- return;
361
- }
362
- // Resolve the store through the shared lifecycle. There are three results: the
363
- // external store, which is the controlled mode, and the coordinator refreshes its
364
- // /context, because the machinery owns /context in both modes; the store that the
365
- // coordinator kept, when this emission returns to the live identity (a view, then
366
- // null, then the same view — the fast path could not take this case, because the DOM
367
- // is gone, and the coordinator refreshes /context in place, because the context can
368
- // move during the gap); or a new store of this view, with the values of the composed
369
- // state.
370
- // The resolution stands INSIDE the try. It reads `spec.state`, and a spec can fail
371
- // there — the accessor of a projection throws, or a seed of the store does. Such a
372
- // failure aborts the rebuild exactly as a failure of renderView does, so the
373
- // containment must cover it. The ErrorBoundary of play-solid covers the same
374
- // accessor, and packages/play-solid/test/PlayRenderer-edge-error-boundary.test.tsx
375
- // holds that rule.
376
- try {
377
- const resolution = this.storeLifecycle.resolve(this.actor, view, this.options.store);
378
- this.lastViewKey = view.viewKey;
379
- // The code keeps the store without a guard, for the refresh of the fast path of a slice.
380
- this.currentStore = resolution.store;
381
- this.renderView(view, resolution.guardedStore);
382
- }
383
- catch (error) {
384
- // Containment: the renderer holds a failure of the complete rebuild ALWAYS. It
385
- // propagates none — not out of connect() for the initial view, and not out of
386
- // the callback of watchAtom for a later view. No option turns this on, and
387
- // none turns it off.
388
- // failView() clears the container and fills it with the fallback, exactly as on
389
- // a null view. The next view that renders clears the container again, which
390
- // removes the fallback.
391
- this.failView(error);
392
- }
393
- }
394
- /**
395
- * The end of a render that failed, in ONE place.
396
- *
397
- * Three paths render — the fast path of a slice, the complete rebuild, and the write
398
- * of the store — and each one ends here. The ORDER is load-bearing: the reset takes
399
- * the registrations of the dead tree down BEFORE the report, so a handler of the host
400
- * that renders again meets a renderer that holds nothing of the failed attempt. A
401
- * second copy of the pair is a second chance to put the two in the other order.
402
- *
403
- * @param error - The failure that the renderer contains.
404
- */
405
- failView(error) {
406
- this.resetFailedRebuild();
407
- this.contain(error);
408
- }
409
- /**
410
- * Clears the screen of the failed view, shows the fallback, and reports the failure.
411
- *
412
- * The renderer contains such a failure ALWAYS. No option turns the containment on,
413
- * and none turns it off: `onError` says where the report goes, and `fallback` says
414
- * what the empty container shows. The four framework renderers hold the same rule,
415
- * because an error boundary of a framework is not an option that a caller turns off.
416
- * A host that must escalate a failure raises it from a task of its own —
417
- * `rethrowLater(error)` of `@xmachines/core/utils` — because the guard contains a handler
418
- * that throws, exactly as the four framework renderers do.
419
- *
420
- * The container is cleared FIRST. The fast path of a slice holds the live tree of the
421
- * previous emission, and that tree is dead as soon as the refresh throws, so the
422
- * renderer must not keep it on the screen. A clear container also lets the fallback
423
- * stand alone.
424
- *
425
- * The SCREEN comes first, and the report second: a handler that throws — a reporter
426
- * that fails, or one that re-raises to escalate — must not defeat the containment
427
- * that it was given to observe. With the order reversed the container stays empty AND
428
- * the error escapes, which is both outcomes that the containment exists to prevent.
429
- */
430
- contain(error) {
431
- this.container.replaceChildren();
432
- this.showFallback();
433
- // Without `onError` the error reaches no handler and no caller. Write it to
434
- // `console.error`, in the same way as the inner renderer does for a component that
435
- // throws without an `onRenderError` handler. A silent crash is the one result that
436
- // no caller can debug.
437
- const { onError } = this.options;
438
- // The guard and the retry of THIS connection, read one time. A `connect()` that the
439
- // handler starts replaces both fields, and this report belongs to the connection
440
- // that reported.
441
- const guard = this.guard;
442
- const retry = this.retry;
443
- guard.report(error, onError && (() => onError(error, retry)));
444
- }
445
- /**
446
- * Builds the retry that one report hands the host.
447
- *
448
- * The read of the atom happens at the moment of the CALL: a retry that held the view
449
- * that failed would take a host off the healthy screen and back onto the one that
450
- * threw.
451
- *
452
- * The retry belongs to the guard of ITS connection, and it asks that guard alone. A
453
- * host may KEEP the callback — a "Retry" button of a toast outlives the route that
454
- * opened it — and `disconnect()` disposes the guard that was live then. `connect()`
455
- * disconnects a connected renderer first, so every guard that leaves the field is a
456
- * disposed guard, and a retry of an older connection stays dead. The rule of the prop
457
- * is one rule: a reset after `disconnect()` does nothing.
458
- *
459
- * @param guard - The guard of the connection that this retry belongs to.
460
- */
461
- retryOf(guard) {
462
- return () => {
463
- if (guard.blocked())
464
- return;
465
- this.render(this.actor.currentView.get());
466
- };
467
- }
468
- /**
469
- * Appends the fallback element to the container, which the caller cleared already.
470
- * The renderer calls it for a null view, and after a failure of a rebuild that it
471
- * contains. Both callers clear the container first. `appendChild` moves the node.
472
- * Therefore a second call adds no copy.
473
- */
474
- showFallback() {
475
- const fallback = this.options.fallback;
476
- if (fallback == null)
477
- return;
478
- // The producer of the fallback is CODE OF THE HOST, and it can throw: a template
479
- // lookup that finds nothing, a DOM API that the runtime lacks. This method runs on
480
- // the null path outside every try, and inside `contain()` after the containment
481
- // decided to hold an error — so a throw here escaped `connect()` and the callback
482
- // of watchAtom uncontained, and it turned a contained failure into an uncontained
483
- // one with the container empty.
484
- //
485
- // The report goes to `console.error` and NOT to `onError`: this method runs from
486
- // inside `contain()`, and a second trip through the containment would report the
487
- // failure of the fallback as a failure of the view.
488
- try {
489
- // A producer runs at the first moment that the renderer needs the element, and
490
- // the memo keeps it: a later null view of THIS renderer shows the same node, and
491
- // a renderer whose view is never null builds nothing at all.
492
- this.fallbackElement ??= typeof fallback === "function" ? fallback() : fallback;
493
- this.container.appendChild(this.fallbackElement);
494
- }
495
- catch (error) {
496
- console.error("[@xmachines/play-dom] the fallback of PlayRenderer failed to build. " +
497
- "The container stays empty.", error);
498
- }
499
- }
500
- /**
501
- * A rebuild that fails must not leave the guard of the fast path armed on a dead
502
- * tree, because the container is empty already. Therefore this code resets the view
503
- * identity. The next emission, also one with the same viewKey, then makes a
504
- * complete render again. It does not patch a store that nothing shows.
505
- *
506
- * It releases the watch subscriptions of the dead tree too. A complete rebuild
507
- * releases them before it builds again, and disconnect() releases them at the end of
508
- * the life of the renderer — but a failure takes the DOM down HERE, between those two
509
- * moments. Without this step one update of the store of the host runs every watch
510
- * action of a tree that nothing shows, against detached elements, with `send` still
511
- * bound to the actor. The fast path of a slice leaves a live tree of its own, and it
512
- * reaches this code only when it fails.
513
- */
514
- resetFailedRebuild() {
515
- // Capture both, clear both, and set alive = false BEFORE the unsubscribe — the
516
- // order of disconnect(). Some StateStore implementations fire a synchronous
517
- // callback from inside their own unsubscribe(). Without the flag that callback
518
- // passes the guard and runs incremental.update() on the tree that this method is
519
- // taking down, with `send` still bound to the actor; without the capture it also
520
- // overwrites watchCleanups before the loop below reaches it. render() sets the
521
- // flag to true again on the complete rebuild that the next emission makes, and the
522
- // fast path of a slice needs storeUnsubscribe, which is null from here.
523
- const failedUnsub = this.storeUnsubscribe;
524
- const failedWatchCleanups = [...this.watchCleanups];
525
- this.storeUnsubscribe = null;
526
- this.watchCleanups = new Set();
527
- this.alive = false;
528
- // See reportReleaseFailure. This method runs BEFORE contain(), so a throw here would take
529
- // the place of the failure of the view that the renderer contains.
530
- releaseQuietly(failedUnsub, reportReleaseFailure);
531
- for (const cleanup of failedWatchCleanups)
532
- releaseQuietly(cleanup, reportReleaseFailure);
533
- this.lastViewKey = undefined;
534
- this.currentStore = null;
535
- this.storeLifecycle.reset();
536
- }
537
- /**
538
- * The tail of a complete rebuild, which can throw. See the catch block of render().
539
- *
540
- * @param guardedStore - Everything that the spec can write through, which includes
541
- * $bindState, setState, and a chained set, receives the GUARDED store: /context is
542
- * read-only to the spec, because the machine context changes through an event
543
- * only.
544
- */
545
- renderView(view, guardedStore) {
546
- const send = this.actor.send.bind(this.actor);
547
- // Build a SetState function on the xstate store.
548
- // The code calls it late, at the moment of an action, and not at the moment of the
549
- // handlers() call. Therefore each action sees the newest state of the store.
550
- const setState = (updater) => {
551
- const prev = guardedStore.getSnapshot();
552
- const next = updater(prev);
553
- // The code calls store.update() with a complete snapshot of the state, and not with
554
- // a map of a patch. This works with createStoreAdapter and with createStateStore,
555
- // where update() merges the given top-level keys in a shallow way, and therefore
556
- // replaces the complete model of the state. Change this call to a patch with a path
557
- // as its key only after a change of the StateStore contract.
558
- // The store with the guard lets the context key of this read-modify-write round trip
559
- // pass, because the code touches that key not. A real mutation of /context
560
- // throws.
561
- guardedStore.update(next);
562
- };
563
- // Resolve the handlers from registryResult. This connects setState and getState of the store.
564
- const handlers = this.options.registryResult
565
- ? this.options.registryResult.handlers(() => setState, () => guardedStore.getSnapshot())
566
- : {};
567
- const loading = this.options.loading;
568
- const functions = this.options.functions;
569
- const directives = this.options.directives;
570
- const validationFunctions = this.options.validationFunctions;
571
- const navigate = this.options.navigate;
572
- const onConfirm = this.options.onConfirm;
573
- const onRenderError = this.options.onRenderError;
574
- // ── The lifecycle of a watch ─────────────────────────────────────────────
575
- // A watch subscription follows the MOUNT of an element, and the registry of each
576
- // instance in the incremental renderer manages it: an element of the first render
577
- // registers then, and an element that appears later, from a visibility that becomes
578
- // true or from a repeat item that the code appends, registers at that moment, with
579
- // the same skip-on-mount baseline. The renderer releases a subscription when its
580
- // element unmounts, from a visibility that becomes false or from a repeat item that
581
- // the code removes. It also releases each subscription on a view change, at the top of
582
- // this method, and on a disconnect().
583
- //
584
- // A stable element does NOT receive a new watch on each rerender. The code registers
585
- // the update subscription below with the store before the watch subscriptions of
586
- // renderSpec, and @xstate/store notifies in the order of the insertion. If a render
587
- // in place removed the watchers and made them again, each old watcher would die in
588
- // the queue, before it fires, and each new watcher would take the snapshot after the
589
- // update as its baseline. A watch action would then fire never. Therefore the registry
590
- // keeps the ORIGINAL subscription, with its baseline and its position in the
591
- // notification, whenever the code only renders an instance again. It registers a
592
- // real new mount only.
593
- //
594
- // Each registered cleanup is idempotent, and the Set watchCleanups also holds it.
595
- // Therefore a second call here, after the incremental renderer released it already, is
596
- // safe.
597
- const renderOptions = {
598
- send,
599
- handlers,
600
- ...(onRenderError !== undefined ? { onRenderError } : {}),
601
- ...(functions !== undefined ? { functions } : {}),
602
- ...(directives !== undefined ? { directives } : {}),
603
- ...(loading !== undefined ? { loading } : {}),
604
- onWatchSetup: (cleanup) => {
605
- // Wrap each cleanup. A call of the wrapper, from the disconnect() here or from the
606
- // view change here, OR from the incremental renderer when the element of the cleanup
607
- // unmounts, also removes the cleanup from the Set. The code returns the wrapper.
608
- // Therefore the incremental renderer registers THIS wrapper and calls it on a
609
- // release, and the Set stays in step with the live subscriptions.
610
- const entry = () => {
611
- this.watchCleanups.delete(entry);
612
- cleanup();
613
- };
614
- this.watchCleanups.add(entry);
615
- return entry;
616
- },
617
- ...(validationFunctions !== undefined ? { validationFunctions } : {}),
618
- ...(navigate !== undefined ? { navigate } : {}),
619
- ...(onConfirm !== undefined ? { onConfirm } : {}),
620
- };
621
- // ── The incremental updates ─────────────────────────────────────────────
622
- // A store update rebuilds the complete tree no longer: the incremental renderer
623
- // compares the previous snapshot and the next snapshot on the tracked $state paths of
624
- // each element, and it renders the elements of the change again, in place. An element
625
- // without a state binding keeps the identity of its DOM node. Therefore the focus, the
626
- // selection, and the scroll position survive an update of another element. A view
627
- // transition still rebuilds everything, because this method runs again.
628
- const incremental = createIncrementalRenderer(view, guardedStore, this.registry, this.container, renderOptions);
629
- // subscribe passes through the guard without a change, and it keeps the same function
630
- // reference. The guard wraps set and update only.
631
- this.storeUnsubscribe = guardedStore.subscribe(() => {
632
- // The guard: stop when a caller disconnected the PlayRenderer.
633
- // This handles the case of a StateStore implementation that fires its subscribed
634
- // callbacks synchronously inside its own unsubscribe() call.
635
- if (!this.alive)
636
- return;
637
- // The try CONTAINS a failed update, exactly as the try of render() contains a
638
- // failed rebuild. `incremental.update()` resolves the props of every element that
639
- // the write touched, and it rebuilds the COMPLETE tree when the store hands back
640
- // the snapshot that it held — so a `$computed` function that throws on the new
641
- // state aborts the render here. This callback runs from inside `store.update()`,
642
- // which an ACTION HANDLER of the host calls, so such a throw left the renderer
643
- // through a path that no option of this class covers: `onError` heard nothing,
644
- // the dead tree stayed on the screen, and the failure reached the caller of the
645
- // action instead. The containment of a failure of the view is one rule, and it
646
- // holds on every path that renders.
647
- try {
648
- incremental.update();
649
- }
650
- catch (error) {
651
- this.failView(error);
652
- }
653
- });
654
- incremental.render();
655
- }
656
- }
62
+ * PlayRenderer connects the `currentView` atom of an actor to the DOM renderer.
63
+ *
64
+ * It watches `actor.currentView` through the atoms. It renders each
65
+ * `DomComponentRenderer` function into `container` on every view transition.
66
+ * `disconnect()` clears the container.
67
+ *
68
+ * Every option of `PlayDomOptions`, which extends `UIProviderOptions`, goes into
69
+ * `DomRenderContext` on each render pass:
70
+ * - `functions` — the named compute functions of a `{ $computed: "name" }` prop expression
71
+ * - `directives` — your own dynamic values with a `$` prefix, from `defineDirective`. The renderer resolves them with the props
72
+ * - `validationFunctions` — your own check functions. They are available at `ctx.ctx.validationFunctions`
73
+ * - `navigate` — the navigation callback. The renderer calls it for an `onSuccess: { navigate: "..." }` action binding
74
+ * - `onRenderError` — the renderer calls it with `(error, name, info)` for each failure that the inner renderer reports
75
+ *
76
+ * **The preferred use, with `registryResult`:**
77
+ * ```typescript
78
+ * import { PlayRenderer, defineRegistry } from "@xmachines/play-dom";
79
+ *
80
+ * const registryResult = defineRegistry(catalog, { components, actions });
81
+ * const renderer = new PlayRenderer(container, actor, registryResult.registry, {
82
+ * registryResult, // it connects setState and getState of the xstate store for you
83
+ * navigate: (path) => myRouter.push(path),
84
+ * functions: { fullName: (args) => `${args.first} ${args.last}` },
85
+ * });
86
+ * renderer.connect();
87
+ * // Later:
88
+ * renderer.disconnect();
89
+ * ```
90
+ *
91
+ * **The controlled store mode** — you give your own `StateStore`:
92
+ * ```typescript
93
+ * import { createAtom } from "@xmachines/play-atom";
94
+ * import { xstateStoreStateStore } from "@xmachines/json-render-xstate";
95
+ *
96
+ * const atom = createAtom({ username: "" });
97
+ * const store = xstateStoreStateStore({ atom });
98
+ * const renderer = new PlayRenderer(container, actor, registry, { registryResult, store });
99
+ * renderer.connect();
100
+ * ```
101
+ *
102
+ * A second `connect()` is safe. A `connect()` call on a connected renderer
103
+ * disconnects it first. Therefore the renderer holds no second render
104
+ * subscription.
105
+ */
106
+ var PlayRenderer = class {
107
+ container;
108
+ actor;
109
+ registry;
110
+ options;
111
+ /**
112
+ * True from the start of `connect()` to the end of `disconnect()`.
113
+ *
114
+ * The watcher cannot answer this question. `connect()` installs it AFTER the
115
+ * synchronous first render, so a host that calls `connect()` again from the `onError`
116
+ * of that render finds no watcher, and a test of the watcher alone would skip the
117
+ * `disconnect()` and leave the guard of the abandoned connection alive for ever.
118
+ */
119
+ connected = false;
120
+ unwatch = null;
121
+ storeUnsubscribe = null;
122
+ /**
123
+ * The cleanup functions of the live watch subscriptions. This field is a `Set`,
124
+ * and not an array, so that each registered cleanup function can delete ITSELF on
125
+ * its call: the incremental renderer releases a watcher when its element unmounts,
126
+ * and it calls the registered wrapper. The Set therefore shrinks with the live
127
+ * subscriptions. It does not collect one spent closure for each historical mount of
128
+ * a view with a long life.
129
+ */
130
+ watchCleanups = /* @__PURE__ */ new Set();
131
+ /**
132
+ * The generation of the store behind the tree on the screen, from the lifecycle. A
133
+ * resolution with the same generation keeps the store: only the /context projection
134
+ * moved, or a composed view changed its page, so the renderer keeps the tree and the
135
+ * ephemeral view state, such as a draft or a toggle. A new generation is a new store,
136
+ * and the renderer builds the tree again. A null emission, from a state with no
137
+ * meta.view, is a GAP, and not a new view: the DOM goes away, but the lifecycle keeps
138
+ * the store. A return to the same view therefore builds again around the ephemeral
139
+ * state that survived, in the same way as the framework providers.
140
+ */
141
+ treeGeneration = void 0;
142
+ /**
143
+ * The incremental renderer of the live tree, and the `root` and `elements` of the
144
+ * spec that it renders. An emission with the same viewKey and the same two
145
+ * references moved the /context projection alone. An emission with the same
146
+ * viewKey and a new `elements` object is a new spec of the same view — a composed
147
+ * view whose page changed, or a streamed chunk — and the renderer takes it in place
148
+ * through `setSpec`.
149
+ */
150
+ incremental = null;
151
+ lastRoot = void 0;
152
+ lastElements = void 0;
153
+ /** The fallback element, built at the first moment that the renderer shows it. */
154
+ fallbackElement = null;
155
+ /**
156
+ * The view session of the connection, from @xmachines/play-view. It resolves the store
157
+ * of each view by the rules of the lifecycle — it seeds the store again on a change of
158
+ * the viewKey, and it refreshes /context in place in every other case — and it hands
159
+ * this renderer one snapshot for each attempt. Its generation decides whether the live
160
+ * tree stays. `connect()` makes one, and `disconnect()` drops it with its store.
161
+ */
162
+ session = null;
163
+ /**
164
+ * `disconnect()` sets this field to `false` before it calls `storeUnsub()`.
165
+ * The `rerender` closure reads the flag at its start. Therefore a synchronous
166
+ * callback from the `StateStore` implementation inside its own `unsubscribe()`
167
+ * call, which is rare but correct, changes no detached DOM tree.
168
+ */
169
+ alive = true;
170
+ /**
171
+ * The depth of the synchronous calls into the inner renderer: `render()`, `setSpec()`
172
+ * and `update()`. A failure of the root that the inner renderer reports inside such a
173
+ * call waits for the end of the call. See {@link PlayRenderer.failRoot}.
174
+ */
175
+ innerDepth = 0;
176
+ /**
177
+ * The failure of the root that the inner renderer reported, until the renderer
178
+ * contains it, and the inner renderer that reported it.
179
+ */
180
+ rootFailure = null;
181
+ /**
182
+ * The report guard, from @xmachines/play-view.
183
+ *
184
+ * It blocks a reset that the host calls from INSIDE the report, and every reset once
185
+ * the renderer is disconnected. The five renderers share the one implementation.
186
+ *
187
+ * `alive` cannot answer the second half. A contained failure sets that flag to `false`
188
+ * and the next emission sets it back, because it guards the callback of the store
189
+ * subscription — a reset that a host keeps outlives both.
190
+ */
191
+ guard = createReportGuard(GUARD_MESSAGES);
192
+ /**
193
+ * The retry that every report of THIS connection hands the host.
194
+ *
195
+ * `connect()` builds one, beside the guard that it belongs to. The four framework
196
+ * renderers hand the host one reset for the life of the provider, and this field holds
197
+ * the same rule for the life of the connection.
198
+ */
199
+ retry = this.retryOf(this.guard);
200
+ /**
201
+ * @param container - The `HTMLElement` to render into. Each view transition clears it and fills it again.
202
+ * @param actor - The actor with the `currentView` atom. It must implement `Viewable`.
203
+ * @param registry - The map of the component renderers, usually `registryResult.registry` from `defineRegistry`.
204
+ * @param options - The configuration. See {@link PlayDomOptions}:
205
+ * - `registryResult` — it connects `setState` and `state` of the xstate store for you.
206
+ * - `store` — an external `StateStore`, which is the controlled mode. It replaces the values of `spec.state`.
207
+ * - `loading` — the flag of the streaming mode. It stops each warning about an absent child.
208
+ * - `functions` — the named compute functions of a `$computed` prop expression.
209
+ * - `directives` — your own dynamic values with a `$` prefix. The renderer resolves them with the props.
210
+ * - `validationFunctions` — your own check functions. They are available at `ctx.ctx.validationFunctions`.
211
+ * - `navigate` — the navigation callback. The renderer calls it for `onSuccess: { navigate: "..." }`.
212
+ * - `onRenderError` — the `(error, name, info)` handler of each failure that the inner renderer reports. It stops the `[json-render]` console line.
213
+ * - `onError` — the `(error, reset)` handler of a failure of the view: a failure of the root, or of a complete rebuild. The renderer contains such a failure always, and this option says where the report goes.
214
+ * - `fallback` — the element to show for every null view, and after a contained failure.
215
+ */
216
+ constructor(container, actor, registry, options = {}) {
217
+ this.container = container;
218
+ this.actor = actor;
219
+ this.registry = registry;
220
+ this.options = options;
221
+ }
222
+ /**
223
+ * Starts the watch of actor.currentView, and renders into the container.
224
+ * It renders the first view synchronously, then it subscribes to the atom changes.
225
+ *
226
+ * A `connect()` call on a connected renderer, where a `disconnect()` call did not
227
+ * follow the previous `connect()`, installs a second subscription of the view.
228
+ * Each view change then renders two times. Therefore this method disconnects the
229
+ * renderer first.
230
+ */
231
+ connect() {
232
+ if (this.connected) this.disconnect();
233
+ this.connected = true;
234
+ const guard = createReportGuard(GUARD_MESSAGES);
235
+ this.guard = guard;
236
+ this.retry = this.retryOf(guard);
237
+ const session = createViewSession(this.actor, this.options.store, { createStore: (seed) => xstateStoreStateStore({ atom: createAtom(seed) }) });
238
+ this.session = session;
239
+ const release = session.subscribe(() => {
240
+ this.render(session.getSnapshot());
241
+ });
242
+ this.unwatch = release;
243
+ }
244
+ /**
245
+ * Stops the watch and clears the container.
246
+ */
247
+ disconnect() {
248
+ this.connected = false;
249
+ this.guard.dispose();
250
+ releaseQuietly(this.unwatch, reportReleaseFailure);
251
+ this.unwatch = null;
252
+ this.alive = false;
253
+ this.releaseTree();
254
+ this.container.replaceChildren();
255
+ this.forgetTree();
256
+ this.session?.dispose();
257
+ this.session = null;
258
+ }
259
+ /**
260
+ * Releases the store subscription and the watch subscriptions of the tree.
261
+ *
262
+ * It captures both and clears both BEFORE a release. Some store implementations fire a
263
+ * synchronous callback during their unsubscribe(). Without this order, that callback
264
+ * sees a live storeUnsubscribe, and it enters the rerender path again with an old view
265
+ * and an old store, or it overwrites watchCleanups before the loop below reaches it.
266
+ *
267
+ * See reportReleaseFailure: a throw of one release must skip none of the work below it.
268
+ * This is code of a caller, and it also runs on the path of a view that WORKS.
269
+ */
270
+ releaseTree() {
271
+ const storeUnsub = this.storeUnsubscribe;
272
+ const watchCleanups = [...this.watchCleanups];
273
+ const incremental = this.incremental;
274
+ this.storeUnsubscribe = null;
275
+ this.watchCleanups = /* @__PURE__ */ new Set();
276
+ this.rootFailure = null;
277
+ releaseQuietly(storeUnsub, reportReleaseFailure);
278
+ if (incremental !== null) releaseQuietly(() => incremental.dispose(), reportReleaseFailure);
279
+ for (const cleanup of watchCleanups) releaseQuietly(cleanup, reportReleaseFailure);
280
+ }
281
+ /** Forgets the tree on the screen, so that the next emission builds it again. */
282
+ forgetTree() {
283
+ this.treeGeneration = void 0;
284
+ this.incremental = null;
285
+ this.lastRoot = void 0;
286
+ this.lastElements = void 0;
287
+ }
288
+ /**
289
+ * The number of live watch-subscription cleanup functions now.
290
+ *
291
+ * @internal This accessor is for a test only. A test asserts with it that the
292
+ * collection of the watch cleanups holds the live registrations, and that no mount
293
+ * leaks. It is not part of the public API, and it can change without a notice.
294
+ */
295
+ get watchCleanupCount() {
296
+ return this.watchCleanups.size;
297
+ }
298
+ /**
299
+ * Brings the screen in line with one snapshot of the view session.
300
+ *
301
+ * The session resolved the store already: it refreshed /context in place, or it seeded
302
+ * a new store, or it recorded the failure of `spec.state`. This method decides the
303
+ * tree alone.
304
+ */
305
+ render(snapshot) {
306
+ const { view } = snapshot;
307
+ if (snapshot.failure) {
308
+ this.failView(snapshot.failure.error);
309
+ return;
310
+ }
311
+ if (view !== null && this.incremental !== null && this.storeUnsubscribe !== null) {
312
+ if (snapshot.generation === this.treeGeneration) {
313
+ const incremental = this.incremental;
314
+ try {
315
+ if (view.root !== this.lastRoot || view.elements !== this.lastElements) {
316
+ this.lastRoot = view.root;
317
+ this.lastElements = view.elements;
318
+ this.callInner(incremental, () => incremental.setSpec(view));
319
+ }
320
+ } catch (error) {
321
+ this.failView(error);
322
+ }
323
+ return;
324
+ }
325
+ }
326
+ this.clearTree();
327
+ if (view === null || snapshot.store === null) {
328
+ this.showFallback();
329
+ return;
330
+ }
331
+ try {
332
+ this.renderView(view, snapshot.store, snapshot.generation);
333
+ } catch (error) {
334
+ this.failView(error);
335
+ }
336
+ }
337
+ /**
338
+ * Takes the tree of the previous view off the screen, before a rebuild.
339
+ *
340
+ * The watch subscriptions of the previous view must go too. Without this step, they
341
+ * stay active between a view transition and the first store update of the new view.
342
+ * They then hold a reference to a DOM element that is gone, and the garbage collector
343
+ * cannot take it.
344
+ */
345
+ clearTree() {
346
+ this.alive = true;
347
+ this.releaseTree();
348
+ this.forgetTree();
349
+ this.container.replaceChildren();
350
+ }
351
+ /**
352
+ * The end of a render that failed, in ONE place.
353
+ *
354
+ * Three paths render — the fast path of a slice, the complete rebuild, and the write
355
+ * of the store — and each one ends here: through a throw, or through a failure of the
356
+ * root that the inner renderer reports (see {@link PlayRenderer.failRoot}). The ORDER
357
+ * is load-bearing: the reset takes the registrations of the dead tree down BEFORE the
358
+ * report, so a handler of the host that renders again meets a renderer that holds
359
+ * nothing of the failed attempt. A second copy of the pair is a second chance to put
360
+ * the two in the other order.
361
+ *
362
+ * @param error - The failure that the renderer contains.
363
+ */
364
+ failView(error) {
365
+ this.resetFailedRebuild();
366
+ this.contain(error);
367
+ }
368
+ /**
369
+ * Gives a failure that the inner renderer reports to the handler of the host, and
370
+ * contains a failure of the ROOT as a failure of the view.
371
+ *
372
+ * The inner renderer reports each failure one time, with `(error, name, info)`. This
373
+ * method gives each one to `forwardRenderError` of @xmachines/play-view, which holds
374
+ * the rule of the five renderers: the `onRenderError` of the host, or the handler of the
375
+ * registry, or the one `[json-render]` console line.
376
+ *
377
+ * A failure of the root — its component, a prop, or its `visible` condition — leaves
378
+ * no view on the screen. The inner renderer throws it to no caller, so the renderer
379
+ * contains it here, as it contains a complete rebuild that throws: it shows the
380
+ * fallback, and it reports the failure to `onError` one time. A handler of the host
381
+ * also gets it, with `info.kind` `"root"`. Without such a handler, the method writes
382
+ * no `[json-render]` line for it: the containment reports it to `onError`, or to its
383
+ * own console line, and a second line would report one failure two times.
384
+ *
385
+ * @param owner - The inner renderer that this handler belongs to, or `null` before it exists.
386
+ */
387
+ reportInner(owner, error, name, info) {
388
+ if (forwardRenderError(this.options.onRenderError, this.registry, error, name, info) && owner !== null) this.failRoot(owner, error);
389
+ }
390
+ /**
391
+ * Contains a failure of the root that the inner renderer reported.
392
+ *
393
+ * The report comes in the middle of the work of the inner renderer, which writes the
394
+ * container after it. The containment therefore waits for the end of that work. Inside
395
+ * a synchronous call, {@link PlayRenderer.callInner} contains the failure when the call
396
+ * returns. At the commit point of a store write, which no call of this class holds, a
397
+ * microtask contains it.
398
+ *
399
+ * @param owner - The inner renderer that reported the failure.
400
+ * @param error - The failure of the root.
401
+ */
402
+ failRoot(owner, error) {
403
+ if (this.incremental !== owner || this.rootFailure !== null) return;
404
+ this.rootFailure = {
405
+ error,
406
+ owner
407
+ };
408
+ if (this.innerDepth > 0) return;
409
+ queueMicrotask(() => this.settleRoot());
410
+ }
411
+ /**
412
+ * Hands the failure of the root that waits to the session, when it belongs to the
413
+ * live tree.
414
+ *
415
+ * The four framework providers take the same path: `failView` of the session records
416
+ * the failure in a new snapshot, and it gives the next attempt a new generation. The
417
+ * session then notifies this renderer, and `render()` contains the failed snapshot
418
+ * with {@link PlayRenderer.failView}: it clears the tree, shows the fallback, and
419
+ * reports to `onError` one time. The session has no `onFailure` here, so it reports
420
+ * nothing of its own.
421
+ */
422
+ settleRoot() {
423
+ const failure = this.rootFailure;
424
+ this.rootFailure = null;
425
+ if (failure === null || this.incremental !== failure.owner) return;
426
+ this.session?.failView(failure.error);
427
+ }
428
+ /**
429
+ * Runs one synchronous call into the inner renderer, and then contains a failure of the
430
+ * root that the call reported.
431
+ *
432
+ * @param owner - The inner renderer of the call.
433
+ * @param call - The call: `render()`, `setSpec()` or `update()`.
434
+ */
435
+ callInner(owner, call) {
436
+ this.innerDepth += 1;
437
+ try {
438
+ call();
439
+ } finally {
440
+ this.innerDepth -= 1;
441
+ }
442
+ if (this.innerDepth === 0 && this.rootFailure?.owner === owner) this.settleRoot();
443
+ }
444
+ /**
445
+ * Clears the screen of the failed view, shows the fallback, and reports the failure.
446
+ *
447
+ * The renderer contains such a failure ALWAYS. No option turns the containment on,
448
+ * and none turns it off: `onError` says where the report goes, and `fallback` says
449
+ * what the empty container shows. The four framework renderers hold the same rule,
450
+ * because an error boundary of a framework is not an option that a caller turns off.
451
+ * A host that must escalate a failure raises it from a task of its own —
452
+ * `rethrowLater(error)` of `@xmachines/core/utils` — because the guard contains a handler
453
+ * that throws, exactly as the four framework renderers do.
454
+ *
455
+ * The container is cleared FIRST. The fast path of a slice holds the live tree of the
456
+ * previous emission, and that tree is dead as soon as the refresh throws, so the
457
+ * renderer must not keep it on the screen. A clear container also lets the fallback
458
+ * stand alone.
459
+ *
460
+ * The SCREEN comes first, and the report second: a handler that throws — a reporter
461
+ * that fails, or one that re-raises to escalate — must not defeat the containment
462
+ * that it was given to observe. With the order reversed the container stays empty AND
463
+ * the error escapes, which is both outcomes that the containment exists to prevent.
464
+ */
465
+ contain(error) {
466
+ this.container.replaceChildren();
467
+ this.showFallback();
468
+ const { onError } = this.options;
469
+ const guard = this.guard;
470
+ const retry = this.retry;
471
+ guard.report(error, onError && (() => onError(error, retry)));
472
+ }
473
+ /**
474
+ * Builds the retry that one report hands the host.
475
+ *
476
+ * The read of the atom happens at the moment of the CALL: a retry that held the view
477
+ * that failed would take a host off the healthy screen and back onto the one that
478
+ * threw.
479
+ *
480
+ * The retry belongs to the guard of ITS connection, and it asks that guard alone. A
481
+ * host may KEEP the callback — a "Retry" button of a toast outlives the route that
482
+ * opened it — and `disconnect()` disposes the guard that was live then. `connect()`
483
+ * disconnects a connected renderer first, so every guard that leaves the field is a
484
+ * disposed guard, and a retry of an older connection stays dead. The rule of the prop
485
+ * is one rule: a reset after `disconnect()` does nothing.
486
+ *
487
+ * @param guard - The guard of the connection that this retry belongs to.
488
+ */
489
+ retryOf(guard) {
490
+ return () => {
491
+ if (guard.blocked()) return;
492
+ this.session?.retry();
493
+ };
494
+ }
495
+ /**
496
+ * Appends the fallback element to the container, which the caller cleared already.
497
+ * The renderer calls it for a null view, and after a failure of a rebuild that it
498
+ * contains. Both callers clear the container first. `appendChild` moves the node.
499
+ * Therefore a second call adds no copy.
500
+ */
501
+ showFallback() {
502
+ const fallback = this.options.fallback;
503
+ if (fallback == null) return;
504
+ try {
505
+ this.fallbackElement ??= typeof fallback === "function" ? fallback() : fallback;
506
+ this.container.appendChild(this.fallbackElement);
507
+ } catch (error) {
508
+ console.error("[@xmachines/play-dom] the fallback of PlayRenderer failed to build. The container stays empty.", error);
509
+ }
510
+ }
511
+ /**
512
+ * A rebuild that fails must not leave the guard of the fast path armed on a dead
513
+ * tree, because the container is empty already. Therefore this code resets the view
514
+ * identity. The next emission, also one with the same viewKey, then makes a
515
+ * complete render again. It does not patch a store that nothing shows.
516
+ *
517
+ * It releases the watch subscriptions of the dead tree too. A complete rebuild
518
+ * releases them before it builds again, and disconnect() releases them at the end of
519
+ * the life of the renderer — but a failure takes the DOM down HERE, between those two
520
+ * moments. Without this step one update of the store of the host runs every watch
521
+ * action of a tree that nothing shows, against detached elements, with `send` still
522
+ * bound to the actor. The fast path of a slice leaves a live tree of its own, and it
523
+ * reaches this code only when it fails.
524
+ */
525
+ resetFailedRebuild() {
526
+ this.alive = false;
527
+ this.releaseTree();
528
+ this.forgetTree();
529
+ }
530
+ /**
531
+ * The tail of a complete rebuild, which can throw. See the catch block of render().
532
+ *
533
+ * @param view - The view to render.
534
+ * @param guardedStore - The store of the view, from the session. Everything that the
535
+ * spec can write through, which includes $bindState, setState, and a chained set,
536
+ * receives the GUARDED store: /context is read-only to the spec, because the machine
537
+ * context changes through an event only.
538
+ * @param generation - The generation of that store, which the tree records.
539
+ */
540
+ renderView(view, guardedStore, generation) {
541
+ const send = this.actor.send.bind(this.actor);
542
+ const handlers = this.options.registryResult ? bindStoreHandlers(this.options.registryResult, () => guardedStore) : {};
543
+ const loading = this.options.loading;
544
+ const functions = this.options.functions;
545
+ const directives = this.options.directives;
546
+ const validationFunctions = this.options.validationFunctions;
547
+ const navigate = this.options.navigate;
548
+ const onConfirm = this.options.onConfirm;
549
+ const confirmDialog = this.options.confirmDialog;
550
+ let owner = null;
551
+ const onRenderError = (error, name, info) => {
552
+ this.reportInner(owner, error, name, info);
553
+ };
554
+ const renderOptions = {
555
+ send,
556
+ handlers,
557
+ onRenderError,
558
+ ...functions !== void 0 ? { functions } : {},
559
+ ...directives !== void 0 ? { directives } : {},
560
+ ...loading !== void 0 ? { loading } : {},
561
+ onWatchSetup: (cleanup) => {
562
+ const entry = () => {
563
+ this.watchCleanups.delete(entry);
564
+ cleanup();
565
+ };
566
+ this.watchCleanups.add(entry);
567
+ return entry;
568
+ },
569
+ ...validationFunctions !== void 0 ? { validationFunctions } : {},
570
+ ...navigate !== void 0 ? { navigate } : {},
571
+ ...onConfirm !== void 0 ? { onConfirm } : {},
572
+ ...confirmDialog !== void 0 ? { confirmDialog } : {}
573
+ };
574
+ const incremental = createIncrementalRenderer(view, guardedStore, this.registry, this.container, renderOptions);
575
+ owner = incremental;
576
+ this.incremental = incremental;
577
+ this.treeGeneration = generation;
578
+ this.lastRoot = view.root;
579
+ this.lastElements = view.elements;
580
+ this.storeUnsubscribe = guardedStore.subscribe(() => {
581
+ if (!this.alive) return;
582
+ try {
583
+ this.callInner(incremental, () => incremental.update());
584
+ } catch (error) {
585
+ this.failView(error);
586
+ }
587
+ });
588
+ this.callInner(incremental, () => incremental.render());
589
+ }
590
+ };
591
+ //#endregion
592
+ export { PlayRenderer };
593
+
657
594
  //# sourceMappingURL=PlayRenderer.js.map