Skip to content

XMachines 3.0.0: one release type, and contained failures

XMachines 3.0.0 has two themes. Every subscription now hands back one release type, and a using scope can end it. All five renderers now contain a failed view with the same rule, and each one hands the host a reset.

Why the version is a major

The release was planned as 2.3.0, and it removes no API. The release tool read a sentence in a commit message as a breaking-change note, and it made the release a major.

One behavior changes: @xmachines/play-dom now contains a failed rebuild always. Read play-dom contains every failure if your host catches what mount() throws. For every other package, 3.0.0 is a minor upgrade.

One release type: Cleanup

Before 3.0.0, a subscription handed back a bare () => void. Nothing in the type told the caller that the function was a release. Nothing ran it when a scope ended or when an exception left the scope.

Cleanup of @xmachines/play is that type: (() => void) & Disposable. One value serves the explicit form and the scoped form:

const stop = watchSignal(count, render); // the explicit form, unchanged
stop();
{
using stop = watchSignal(count, render);
// the scope releases it, and an exception releases it too
}

asCleanup builds a Cleanup. It gives back the same function with Symbol.dispose added, so a caller that compares two releases sees no change. Cleanup is a () => void, so every call site of 2.2.0 compiles and runs as before.

These values now carry the release protocol:

  • watchSignal of @xmachines/play-signals.
  • The mount of @xmachines/play-dom, from createPlayUI and createRenderer.
  • history.subscribe() of @xmachines/play-dom-router. The history that createBrowserHistory builds also releases with destroy() at the end of a scope.
  • RouterConnection, the release of connectRouter in all eight router adapters.
  • PlayerActor of @xmachines/play-xstate. A scope that owns the actor calls stop() at its end. dispose() still works, and it is deprecated.
{
using actor = definePlayer({ machine })();
actor.start();
actor.send({ type: "go" });
// stop() runs here, and it runs when send() throws too
}

The using form needs Node 24, or a browser with Explicit Resource Management. A runtime without Symbol.dispose still calls the value as a function.

One rule for a failed view

Before 3.0.0, the five renderers handled a failed view in different ways. Svelte gave the host a reset, and the other four did not. Vue had no outer boundary. The DOM renderer let the error out, unless the caller passed an option.

Now all five renderers contain the failure, and each onError handler receives a reset:

Every reset obeys the same three rules:

  1. The reset renders the view that the actor holds at the time of the call. A host that keeps the callback cannot go back to the view that failed.
  2. The reset tries the render again, and the host receives the result.
  3. A reset from inside the report does nothing. A reset after the provider goes away does nothing.

A handler that throws is contained too, and the renderer writes the throw to console.error. The next view emission clears a stored failure in every renderer.

@xmachines/play-vue now catches the errors of its descendants in ActorProvider, calls the new onError prop, and shows the fallback slot. PlayErrorBoundary of @xmachines/play-react takes a new resetKeyEquals option, so a key of several inputs needs no stable reference.

play-dom contains every failure

In 2.2.0, a view that failed the rebuild threw out of mount() and out of the signal watcher. A try of the host, or the global handler of the page, received it.

In 3.0.0, the renderer contains such a failure always. No option turns the containment on, and no option turns it off. The renderer clears the container, shows the fallback, and mount() returns normally. onError tells where the report goes. Without onError, the renderer writes the failure to console.error.

The renderer now also shows the fallback for every null view, and not only on the first mount.

Do this if you catch what mount() throws. Move the report into onError, and raise the error from a task of your own:

const mount = createPlayUI(registryResult, {
onError: (err) => {
reportToSentry(err);
queueMicrotask(() => {
throw err; // the global handler of the page receives it
});
},
});

Other changes

  • @xmachines/play-signals arms the watcher before the callback, and it keeps the watch when the callback throws.
  • @xmachines/play-solid keeps the DOM nodes and the focus when a new view emission arrives.
  • To develop in the workspace, you need Node 24. The published packages still support Node 22.

Upgrade

  1. If your host catches what the mount() of @xmachines/play-dom throws, move that code into onError.
  2. Optionally, replace manual teardown with using where your runtime supports it.
  3. Optionally, add a “Retry” button that calls the reset of onError.

Later releases change the release protocol. 4.0.0 puts it on every interface, and 6.0.0 replaces Cleanup with asDisposable of @xmachines/core.

The changelog of 3.0.0 lists every change of the release.