Skip to content

Instantly share code, notes, and snippets.

@fiddlerwoaroof
Last active October 1, 2021 04:08
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save fiddlerwoaroof/41ed84d44236138ff860f5e6ff5c5295 to your computer and use it in GitHub Desktop.
Save fiddlerwoaroof/41ed84d44236138ff860f5e6ff5c5295 to your computer and use it in GitHub Desktop.
Dynamic state transition graphs from an XState machine

Some code for generating graphs from unit tests runs or other contexts where you want to visualize the dynamic behavior of an XState machine.

node src/address-book.js config |
  unflatten |
  dot -Gsize=15,15\! -Gdpi=100 -Tpng > graph.png

graph

export class Graph {
constructor({
edgeSimplifier,
nodeFormatter,
edgeFormatter,
makeGraphContext,
}) {
this.edges = [];
this.edgeSimplifier = edgeSimplifier;
this.edgeFormatter = edgeFormatter;
this.nodeFormatter = nodeFormatter;
this.makeGraphContext = makeGraphContext;
this.nodeMap = [];
this.nodes = new Set();
}
defineNode(node, config) {
this.nodeMap[node] = config;
return this;
}
recordEdge(start, edge, stop) {
const simplifiedEdge = this.edgeSimplifier(start, edge, stop);
this.edges.push(simplifiedEdge);
this.nodes.add(start);
this.nodes.add(stop);
return this;
}
dump() {
return this.makeGraphContext({
nodes: Array.from(this.nodes)
.map((v) => this.nodeFormatter(v, this.nodeMap[v]))
.filter((v) => !!v),
edges: this.edges.map((v) => this.edgeFormatter(...v)),
});
}
}
import { interpret, Machine } from "xstate";
import { Graph } from "./graph.js";
// basic machine: one state, invokes a service.
const basicMachine = Machine({
id: "root",
states: {
"Search Bar": {
id: "Search Bar",
states: {
Inactive: {
id: "Inactive",
states: {},
on: {
focused: "#Active",
},
},
Active: {
id: "Active",
states: {
Empty: { id: "Empty", states: {} },
"Text Entry": {
id: "Text Entry",
states: {},
on: { submitted: "#Results" },
},
Results: { id: "Results", states: {} },
},
initial: "Empty",
on: { canceled: "#Inactive", typed: "#Text Entry" },
},
},
initial: "Inactive",
on: {},
},
},
initial: "Search Bar",
on: {},
});
const graph = new Graph({
nodeFormatter(node, config) {
if (config) {
return `"${node}"[${config}]`;
}
},
edgeSimplifier(start, label, end) {
return [start, label, end];
},
edgeFormatter(a, b, c) {
if (b) {
return `"${a}" -> "${c}" [label="${b}"]`;
} else {
return `"${a}" -> "${c}"`;
}
},
makeGraphContext({ nodes, edges }) {
const nodeBody = nodes.length > 0 ? `${nodes.join(";\n ")};` : "";
return `digraph {
${nodeBody}
${edges.join(";\n ")};
}`;
},
})
.defineNode("<start>", "shape=box,color=green")
.defineNode("<stop>", "shape=box,color=red");
export const service = interpret(basicMachine);
let transitions = 0;
service.onTransition((state, e) => {
transitions++;
const { length: l1, [l1 - 1]: target } = state.toStrings() ?? ["<start>"];
const { length: l2, [l2 - 1]: source } = state.history?.toStrings?.() ?? [
"<start>",
];
graph.defineNode(e.type, "shape=diamond,rank=0");
graph.recordEdge(source, e.type, target);
});
service.start();
service.send("focused");
service.send("typed");
service.send("typed");
service.send("canceled");
service.stop();
transitions++;
const finalState = service.state.toStrings();
graph.recordEdge(finalState[finalState.length - 1], `${transitions}`, "<stop>");
console.log(graph.dump());
{
"name": "demos",
"version": "1.0.0",
"main": "src/index.js",
"type": "module",
"license": "MIT",
"dependencies": {
"xstate": "^4.23.4"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment