@koordinates/xstate-tree 2.0.9 → 2.0.11
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 +76 -44
- package/lib/builders.js +3 -16
- package/lib/xstate-tree.d.ts +2 -2
- package/lib/xstateTree.js +13 -1
- package/package.json +13 -3
package/README.md
CHANGED
|
@@ -15,82 +15,113 @@ While xstate-tree manages your application state, it does not have a mechanism f
|
|
|
15
15
|
|
|
16
16
|
At Koordinates we use xstate-tree for all new UI development. Our desktop application, built on top of [Kart](https://kartproject.org/) our Geospatial version control system, is built entirely with xstate-tree using GraphQL for global state.
|
|
17
17
|
|
|
18
|
-
A minimal example of a single machine tree
|
|
18
|
+
A minimal example of a single machine tree ([CodeSandbox](https://codesandbox.io/s/xstate-tree-b0el6e-forked-4i6txh?file=/src/index.tsx)):
|
|
19
19
|
|
|
20
20
|
```tsx
|
|
21
21
|
import React from "react";
|
|
22
22
|
import { createRoot } from "react-dom/client";
|
|
23
23
|
import { createMachine } from "xstate";
|
|
24
24
|
import { assign } from "@xstate/immer";
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
import {
|
|
26
|
+
buildSelectors,
|
|
27
|
+
buildActions,
|
|
28
|
+
buildView,
|
|
29
|
+
buildXStateTreeMachine,
|
|
30
|
+
buildRootComponent
|
|
31
|
+
} from "@koordinates/xstate-tree";
|
|
32
|
+
|
|
33
|
+
type Events =
|
|
34
|
+
| { type: "SWITCH_CLICKED" }
|
|
35
|
+
| { type: "INCREMENT"; amount: number };
|
|
28
36
|
type Context = { incremented: number };
|
|
29
37
|
|
|
30
38
|
// If this tree had more than a single machine the slots to render child machines into would be defined here
|
|
31
39
|
const slots = [];
|
|
32
40
|
|
|
33
41
|
// A standard xstate machine, nothing extra is needed for xstate-tree
|
|
34
|
-
const machine = createMachine<Context, Events>(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
states: {
|
|
41
|
-
inactive: {
|
|
42
|
-
on: {
|
|
43
|
-
SWITCH_CLICKED: "active",
|
|
44
|
-
},
|
|
42
|
+
const machine = createMachine<Context, Events>(
|
|
43
|
+
{
|
|
44
|
+
id: "root",
|
|
45
|
+
initial: "inactive",
|
|
46
|
+
context: {
|
|
47
|
+
incremented: 0
|
|
45
48
|
},
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
states: {
|
|
50
|
+
inactive: {
|
|
51
|
+
on: {
|
|
52
|
+
SWITCH_CLICKED: "active"
|
|
53
|
+
}
|
|
50
54
|
},
|
|
51
|
-
|
|
55
|
+
active: {
|
|
56
|
+
on: {
|
|
57
|
+
SWITCH_CLICKED: "inactive",
|
|
58
|
+
INCREMENT: { actions: "increment" }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
52
62
|
},
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
63
|
+
{
|
|
64
|
+
actions: {
|
|
65
|
+
increment: assign((context, event) => {
|
|
66
|
+
if (event.type !== "INCREMENT") {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
context.incremented += event.amount;
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
);
|
|
60
75
|
|
|
61
76
|
// Selectors to transform the machines state into a representation useful for the view
|
|
62
77
|
const selectors = buildSelectors(machine, (ctx, canHandleEvent) => ({
|
|
63
|
-
canIncrement: canHandleEvent({type: "INCREMENT",
|
|
78
|
+
canIncrement: canHandleEvent({ type: "INCREMENT", amount: 1 }),
|
|
64
79
|
showSecret: ctx.incremented > 10,
|
|
65
|
-
count: ctx.incremented
|
|
80
|
+
count: ctx.incremented
|
|
66
81
|
}));
|
|
67
82
|
|
|
68
83
|
// Actions to abstract away the details of sending events to the machine
|
|
69
|
-
const actions = buildActions(machine,
|
|
84
|
+
const actions = buildActions(machine, selectors, (send, selectors) => ({
|
|
70
85
|
increment(amount: number) {
|
|
71
|
-
|
|
86
|
+
send({
|
|
87
|
+
type: "INCREMENT",
|
|
88
|
+
amount: selectors.count > 4 ? amount * 2 : amount
|
|
89
|
+
});
|
|
72
90
|
},
|
|
73
91
|
switch() {
|
|
74
92
|
send({ type: "SWITCH_CLICKED" });
|
|
75
|
-
}
|
|
93
|
+
}
|
|
76
94
|
}));
|
|
77
95
|
|
|
78
96
|
// A view to bring it all together
|
|
79
97
|
// the return value is a plain React view that can be rendered anywhere by passing in the needed props
|
|
80
98
|
// the view has no knowledge of the machine it's bound to
|
|
81
|
-
const view = buildView(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
99
|
+
const view = buildView(
|
|
100
|
+
machine,
|
|
101
|
+
selectors,
|
|
102
|
+
actions,
|
|
103
|
+
slots,
|
|
104
|
+
({ actions, selectors, inState }) => {
|
|
105
|
+
return (
|
|
106
|
+
<div>
|
|
107
|
+
<button onClick={() => actions.switch()}>
|
|
108
|
+
{inState("active") ? "Deactivate" : "Activate"}
|
|
109
|
+
</button>
|
|
110
|
+
<p>Count: {selectors.count}</p>
|
|
111
|
+
<button
|
|
112
|
+
onClick={() => actions.increment(1)}
|
|
113
|
+
disabled={!selectors.canIncrement}
|
|
114
|
+
>
|
|
115
|
+
Increment
|
|
116
|
+
</button>
|
|
117
|
+
{selectors.showSecret && <p>The secret password is hunter2</p>}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
);
|
|
91
122
|
|
|
92
123
|
// Stapling the machine, selectors, actions, view, and slots together
|
|
93
|
-
const RootMachine =
|
|
124
|
+
const RootMachine = buildXStateTreeMachine(machine, {
|
|
94
125
|
selectors,
|
|
95
126
|
actions,
|
|
96
127
|
view,
|
|
@@ -100,12 +131,13 @@ const RootMachine = buildXstateTreeMachine(machine, {
|
|
|
100
131
|
// Build the React host for the tree
|
|
101
132
|
const XstateTreeRoot = buildRootComponent(RootMachine);
|
|
102
133
|
|
|
103
|
-
|
|
104
134
|
// Rendering it with React
|
|
105
135
|
const ReactRoot = createRoot(document.getElementById("root"));
|
|
106
136
|
ReactRoot.render(<XstateTreeRoot />);
|
|
107
137
|
```
|
|
108
138
|
|
|
139
|
+
A more complicated todomvc [example](https://github.com/koordinates/xstate-tree/tree/master/examples/todomvc)
|
|
140
|
+
|
|
109
141
|
## Overview
|
|
110
142
|
|
|
111
143
|
Each machine that forms the tree representing your UI has an associated set of selector, action, view functions, and "slots"
|
package/lib/builders.js
CHANGED
|
@@ -45,8 +45,8 @@ export function buildSelectors(__machine, selectors) {
|
|
|
45
45
|
* - `send` - the interpreters send function, which can be used to send events to the machine
|
|
46
46
|
* - `selectors` - the output of the selectors function from {@link buildSelectors}
|
|
47
47
|
*
|
|
48
|
-
* The resulting action function
|
|
49
|
-
* selectors
|
|
48
|
+
* The resulting action function will only be called once per invocation of a machine.
|
|
49
|
+
* The selectors are passed in as a proxy to always read the latest selector value
|
|
50
50
|
*
|
|
51
51
|
* @param machine - The machine to create the actions for
|
|
52
52
|
* @param selectors - The selectors function
|
|
@@ -54,20 +54,7 @@ export function buildSelectors(__machine, selectors) {
|
|
|
54
54
|
* @returns The actions function - ready to be passed to {@link buildView}
|
|
55
55
|
* */
|
|
56
56
|
export function buildActions(__machine, __selectors, actions) {
|
|
57
|
-
|
|
58
|
-
let lastCachedResult = undefined;
|
|
59
|
-
let lastSendReference = undefined;
|
|
60
|
-
return (send, selectors) => {
|
|
61
|
-
if (lastSelectorResult === selectors &&
|
|
62
|
-
lastCachedResult !== undefined &&
|
|
63
|
-
lastSendReference === send) {
|
|
64
|
-
return lastCachedResult;
|
|
65
|
-
}
|
|
66
|
-
lastCachedResult = actions(send, selectors);
|
|
67
|
-
lastSelectorResult = selectors;
|
|
68
|
-
lastSendReference = send;
|
|
69
|
-
return lastCachedResult;
|
|
70
|
-
};
|
|
57
|
+
return actions;
|
|
71
58
|
}
|
|
72
59
|
/**
|
|
73
60
|
* @public
|
package/lib/xstate-tree.d.ts
CHANGED
|
@@ -71,8 +71,8 @@ export declare function broadcast(event: GlobalEvents): void;
|
|
|
71
71
|
* - `send` - the interpreters send function, which can be used to send events to the machine
|
|
72
72
|
* - `selectors` - the output of the selectors function from {@link buildSelectors}
|
|
73
73
|
*
|
|
74
|
-
* The resulting action function
|
|
75
|
-
* selectors
|
|
74
|
+
* The resulting action function will only be called once per invocation of a machine.
|
|
75
|
+
* The selectors are passed in as a proxy to always read the latest selector value
|
|
76
76
|
*
|
|
77
77
|
* @param machine - The machine to create the actions for
|
|
78
78
|
* @param selectors - The selectors function
|
package/lib/xstateTree.js
CHANGED
|
@@ -107,6 +107,7 @@ export function XstateTreeView({ interpreter }) {
|
|
|
107
107
|
const [current] = useService(interpreter);
|
|
108
108
|
const currentRef = useRef(current);
|
|
109
109
|
currentRef.current = current;
|
|
110
|
+
const selectorsRef = useRef(undefined);
|
|
110
111
|
const { view: View, actions: actionsFactory, selectors: selectorsFactory, slots: interpreterSlots, } = interpreter.machine.meta;
|
|
111
112
|
const slots = useSlots(interpreter, interpreterSlots.map((x) => x.name));
|
|
112
113
|
const canHandleEvent = useCallback((e) => {
|
|
@@ -121,11 +122,22 @@ export function XstateTreeView({ interpreter }) {
|
|
|
121
122
|
// current state the machine is in changes. But _only_ then
|
|
122
123
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
123
124
|
[current.value]);
|
|
125
|
+
const selectorsProxy = useConstant(() => {
|
|
126
|
+
return new Proxy({}, {
|
|
127
|
+
get: (_target, prop) => {
|
|
128
|
+
var _a;
|
|
129
|
+
return (_a = selectorsRef.current) === null || _a === void 0 ? void 0 : _a[prop];
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
const actions = useConstant(() => {
|
|
134
|
+
return actionsFactory(interpreter.send, selectorsProxy);
|
|
135
|
+
});
|
|
124
136
|
if (!current) {
|
|
125
137
|
return null;
|
|
126
138
|
}
|
|
127
139
|
const selectors = selectorsFactory(current.context, canHandleEvent, inState, current.value);
|
|
128
|
-
|
|
140
|
+
selectorsRef.current = selectors;
|
|
129
141
|
return (React.createElement(View, { selectors: selectors, actions: actions, slots: slots, inState: inState }));
|
|
130
142
|
}
|
|
131
143
|
/**
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@koordinates/xstate-tree",
|
|
3
3
|
"main": "lib/index.js",
|
|
4
4
|
"types": "lib/xstate-tree.d.ts",
|
|
5
|
-
"version": "2.0.
|
|
5
|
+
"version": "2.0.11",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"description": "Build UIs with Actors using xstate and React",
|
|
8
8
|
"keywords": [
|
|
@@ -41,6 +41,8 @@
|
|
|
41
41
|
"@types/react-dom": "^18.0.6",
|
|
42
42
|
"@types/testing-library__jest-dom": "^5.14.1",
|
|
43
43
|
"@typescript-eslint/eslint-plugin": "^5.30.5",
|
|
44
|
+
"@vitejs/plugin-react": "^2.1.0",
|
|
45
|
+
"@xstate/immer": "^0.3.1",
|
|
44
46
|
"@xstate/react": "^3.0.0",
|
|
45
47
|
"classnames": "^2.3.1",
|
|
46
48
|
"cz-conventional-changelog": "^3.3.0",
|
|
@@ -51,16 +53,22 @@
|
|
|
51
53
|
"eslint-plugin-react-hooks": "^4.3.0",
|
|
52
54
|
"history": "^4.10.1",
|
|
53
55
|
"husky": "^8.0.1",
|
|
56
|
+
"immer": "^9.0.15",
|
|
54
57
|
"jest": "^28.0.3",
|
|
55
58
|
"jest-environment-jsdom": "^28.0.1",
|
|
56
59
|
"react": "^18.1.0",
|
|
57
60
|
"react-dom": "^18.1.0",
|
|
58
61
|
"rimraf": "^3.0.2",
|
|
62
|
+
"rxjs": "^7.5.6",
|
|
59
63
|
"semantic-release": "^19.0.3",
|
|
60
64
|
"semantic-release-npm-github-publish": "^1.5.1",
|
|
65
|
+
"todomvc-app-css": "^2.4.2",
|
|
66
|
+
"todomvc-common": "^1.0.5",
|
|
61
67
|
"ts-jest": "^28.0.5",
|
|
62
68
|
"typescript": "^4.7.3",
|
|
63
|
-
"
|
|
69
|
+
"vite": "^3.1.3",
|
|
70
|
+
"vite-tsconfig-paths": "^3.5.0",
|
|
71
|
+
"xstate": "^4.33.0"
|
|
64
72
|
},
|
|
65
73
|
"peerDependencies": {
|
|
66
74
|
"@xstate/react": "^3.x",
|
|
@@ -70,7 +78,9 @@
|
|
|
70
78
|
"scripts": {
|
|
71
79
|
"lint": "eslint 'src/**/*'",
|
|
72
80
|
"test": "jest",
|
|
73
|
-
"
|
|
81
|
+
"test-examples": "tsc --noEmit",
|
|
82
|
+
"todomvc": "vite dev",
|
|
83
|
+
"build": "rimraf lib && rimraf out && tsc -p tsconfig.build.json",
|
|
74
84
|
"build:watch": "tsc -p tsconfig.build.json -w",
|
|
75
85
|
"api-extractor": "api-extractor run",
|
|
76
86
|
"release": "semantic-release",
|