Skip to content

@xmachines/play-view

API / @xmachines/play-view

The shared view half of every renderer of the XMachines Play Architecture.

License: MIT Version

Installation

Terminal window
pnpm add @xmachines/play-view

Peer dependencies. Install them with the package:

Terminal window
pnpm add @xmachines/core @xmachines/json-render-core@0.21.0-xm.6 @xmachines/play @xmachines/play-atom

The json-render packages are exact peers: install the version that this release names, 0.21.0-xm.6.

  • @xmachines/core — the base package. This package reads the helpers of its utils subpath.
  • @xmachines/play — the core protocol. This package reads the error classes from its errors subpath.
  • @xmachines/play-atom — the atom primitives. The view store holds the spec in a ReadonlyAtom.
  • @xmachines/json-render-core — the spec type that PlaySpec extends.

A renderer of a framework depends on this package already. Install it directly when you write a renderer of your own.

Overview

A renderer of a framework is two things: the view lifecycle, and the code that binds that lifecycle to the effects of the framework. This package holds the first one. @xmachines/play-react, @xmachines/play-vue, @xmachines/play-solid, @xmachines/play-svelte and @xmachines/play-dom hold the second one.

It also holds Viewable and PlaySpec. Every type here names a type of @xmachines/json-render-core, and that is the reason they are here and not in @xmachines/play-actor: an application that routes and renders no view installs this package never, and json-render never.

ExportDescription
ViewableThe capability of an actor that publishes a currentView atom
ViewMeta, typedSpecThe view that an author writes in meta.view, and its identity helper
PlaySpecThe spec that an actor publishes, and that a renderer receives
ViewOutlet, composeViewsThe composition of a parent view and the views of its substates
BaseActorProviderProps, BaseViewContextValueThe framework-free half of the props and of the context of a provider
createViewSessionThe resolution of the view store that every provider reads
createViewStoreLifecycleThe lifecycle of the state store of one view
createReportGuardThe report of a contained failure, with a reset that the host holds
toAtomState, attachRenderErrorHandlerThe guards that a provider puts around a registry
createRenderErrorBindingOne registry copy for each registry, which calls the newest handler
createActorProxyThe stand-in for the actor that a provider hands to its descendants
bindStoreHandlersThe action handlers of a registry, against a state store
composePlayState, refreshContextSubtree, …The projection of the machine context into the store

API Summary

typedSpec(spec)

This identity helper gives a view-spec literal the type ViewMeta at the definition site. The XState meta field has the type Record<string, unknown>. Therefore this helper is the place where the spec shape receives the compile-time check and the IDE autocomplete. The helper has no cost at run time.

import { typedSpec } from "@xmachines/play-view";
// In an XState machine meta block:
const meta = {
view: typedSpec({
root: "root",
elements: {
root: {
type: "Dashboard",
props: { username: { $state: "/context/username" } },
children: [],
},
},
}),
};

ViewMeta and PlaySpec

ViewMeta is the view that you write in meta.view: the Spec type of @xmachines/json-render-core, and the outlets of a parent view. PlaySpec is the view that the actor derives from it, and that a renderer receives: it carries viewKey, the /context projection, and viewParts for a composed view, and no outlets.

PlaySpec extends the Spec type of @xmachines/json-render-core. Each derived view receives the complete machine context in its state store, under the read-only /context subtree. A spec therefore reads the context through the ordinary { $state: "/context/…" } grammar: in a prop, in a visible condition, and in repeat.statePath.

/context is read-only by design. Nothing can write to it. The machine context changes through an event only. A $bindState write or a setState write under /context throws an error, and the error names the event to send. This is the model: the bindable ephemeral state is at the root of the store, from spec.state; the domain state is in the machine, and it changes through events that are meaningful and easy to inspect.

The path shows the origin of each value. /context/params/username comes from the URL. /context/username belongs to the machine. One value can never hide the other.

The model projects everything, and this has two consequences. The first consequence is exposure. The complete context is visible to the client in the view store, which includes a debug panel, an inspector, and a validator. The context is a client-side value in each case, so keep a secret out of it.

The second consequence is emission granularity. The emit gate compares the context field by field, at the top level only. Therefore an event that changes any field emits the view again with the same viewKey. A provider refreshes /context in the live store, and it does not seed the store again. The component does not remount, and the ephemeral view state and the focus stay. However, a new emission is still a render pass in the framework layer. Keep high-frequency ephemeral data, such as a draft for each keystroke or a timer, in the view store (spec.state with $bindState) or in a child actor. The domain state belongs in the context. A keystroke does not.

import type { ViewMeta } from "@xmachines/play-view";
const spec: ViewMeta = {
root: "root",
elements: {
root: {
type: "Profile",
props: { username: { $state: "/context/username" } },
children: [],
},
},
};

Always validate the derived view (actor.currentView.get()), not the raw meta.view. The state of the derived spec carries the projection, so a tool such as validateSpec sees a spec that is consistent with itself.

outlets and composeViews(tree)

A meta.view that declares outlets shares the screen with the views of its active substates. Each outlet is a ViewOutlet: the element of the parent view that receives the view of a child, and an optional named slot of that element. Without slot, the child view goes into the children of the element.

outlets: {
menu: { element: "shell", slot: "sidebar" }, // the child state "menu"
"*": { element: "shell" }, // every other active child
}

The view derivation of @xmachines/play-xstate resolves the active views into a ViewTree, and composeViews grafts that tree into one flat spec. The keys of the outer view stay as the author wrote them, and each key of a child view gets the id of its state as a prefix ("app.page/root"). The function makes a new object on each call. A caller that emits the composed view on each snapshot keeps one result for each tree, so that the emit gate sees no change on a context assign: play-xstate keeps one for each tree of each machine. An outlet that names an absent element throws InvalidViewOutletError from @xmachines/play-view/errors, and two views that put the same key into the composition throw ViewElementKeyCollisionError.

A composed spec carries viewParts, the ids of the composed states, and its viewKey is the id of the outermost view. createViewStoreLifecycle therefore keeps the store when a page changes, and it writes the authored keys of the new page that the store does not hold yet. In the controlled mode, the caller owns the seed, so the lifecycle writes no authored key. It refreshes /context only.

The State Machines guide shows a complete machine.

createViewSession(actor, store, options)

The view session resolves the store of the view for a provider, and the five renderers share it. It resolves when the actor emits, when the host hands over a new actor or controlled store (setInputs), and for a retry (retry). It never resolves in a render.

A provider reads one immutable snapshot: view, the guarded store, the generation of the store, and a failure. It binds subscribe and getSnapshot to the primitive of its framework. The first listener connects the session, and the first read before it resolves without a write of a controlled store, so a render may call getSnapshot. A new snapshot is a new attempt, so a provider keys its subtree on generation and resets its error boundary on a new snapshot.

A failure of the resolution reaches options.onFailure, outside a render. A provider whose error boundary reports a failure leaves that option out, and it throws snapshot.failure.error under the boundary.

Viewable

Interface for actors that expose a renderable view atom.

currentView is a ReadonlyAtom, because an actor DERIVES the view from its state. Only the actor decides what the view is, and that rule is in the type rather than in prose. A writable Atom still satisfies the interface structurally, so a test double drives a renderer by holding its own writable reference:

import type { Viewable } from "@xmachines/play-view";
import type { PlaySpec } from "@xmachines/play-view";
import { createAtom } from "@xmachines/play-atom";
declare const spec: PlaySpec; // the view that the double shows
// The real actor derives this atom. A double writes its own.
const atom = createAtom<PlaySpec | null>(null);
const viewable: Viewable = { currentView: atom };
atom.set(spec); // the double drives the renderer through ITS reference

BaseActorProviderProps<TRegistry>

The framework-agnostic base props. Every ActorProvider implementation shares them: React, Vue, Solid, and Svelte. Each framework renderer package extends this interface.

import type { BaseActorProviderProps } from "@xmachines/play-view";
import type { DefineRegistryResult } from "@xmachines/json-render-react";
interface ActorProviderProps extends BaseActorProviderProps<DefineRegistryResult> {
fallback?: React.ReactNode;
children: React.ReactNode;
}

BaseViewContextValue<TRegistry>

The framework-agnostic base of the ViewContextValue type in each framework. It holds the spec, handlers, registry, and store fields. These fields are identical in React, Vue, Solid, and Svelte.

Documentation

License

MIT

Modules