@koordinates/xstate-tree 1.0.0-beta.1
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 +90 -0
- package/lib/builders.js +62 -0
- package/lib/index.js +8 -0
- package/lib/lazy.js +51 -0
- package/lib/routing/Link.js +27 -0
- package/lib/routing/createRoute/createRoute.js +185 -0
- package/lib/routing/createRoute/index.js +1 -0
- package/lib/routing/handleLocationChange/handleLocationChange.js +47 -0
- package/lib/routing/handleLocationChange/index.js +1 -0
- package/lib/routing/index.js +6 -0
- package/lib/routing/joinRoutes.js +5 -0
- package/lib/routing/matchRoute/index.js +1 -0
- package/lib/routing/matchRoute/matchRoute.js +35 -0
- package/lib/routing/providers.js +18 -0
- package/lib/routing/routingEvent.js +1 -0
- package/lib/routing/useHref.js +14 -0
- package/lib/setupScript.js +1 -0
- package/lib/slots/index.js +1 -0
- package/lib/slots/slots.js +25 -0
- package/lib/testingUtilities.js +196 -0
- package/lib/types.js +1 -0
- package/lib/useConstant.js +8 -0
- package/lib/useService.js +85 -0
- package/lib/utils.js +22 -0
- package/lib/xstate-tree.d.ts +580 -0
- package/lib/xstateTree.js +204 -0
- package/package.json +94 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// ignore file coverage
|
|
2
|
+
import { useMachine } from "@xstate/react";
|
|
3
|
+
import { set, transform, isEqual, isObject, isNil } from "lodash";
|
|
4
|
+
import React, { useEffect, useState } from "react";
|
|
5
|
+
import { TinyEmitter } from "tiny-emitter";
|
|
6
|
+
import { State, createMachine, } from "xstate";
|
|
7
|
+
import { initEvent } from "xstate/lib/actions";
|
|
8
|
+
import { buildXStateTreeMachine } from "./builders";
|
|
9
|
+
import { emitter, recursivelySend, XstateTreeView } from "./xstateTree";
|
|
10
|
+
/**
|
|
11
|
+
* @public
|
|
12
|
+
*/
|
|
13
|
+
export function slotTestingDummyFactory(name) {
|
|
14
|
+
return buildXStateTreeMachine(createMachine({
|
|
15
|
+
id: name,
|
|
16
|
+
initial: "idle",
|
|
17
|
+
states: {
|
|
18
|
+
idle: {},
|
|
19
|
+
},
|
|
20
|
+
}), {
|
|
21
|
+
actions: () => ({}),
|
|
22
|
+
selectors: () => ({}),
|
|
23
|
+
slots: [],
|
|
24
|
+
view: () => (React.createElement("div", null,
|
|
25
|
+
React.createElement("p", null, name))),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* @public
|
|
30
|
+
*/
|
|
31
|
+
export const genericSlotsTestingDummy = new Proxy({}, {
|
|
32
|
+
get(_target, prop) {
|
|
33
|
+
return () => (React.createElement("div", null,
|
|
34
|
+
React.createElement("p", null,
|
|
35
|
+
React.createElement(React.Fragment, null,
|
|
36
|
+
prop,
|
|
37
|
+
"-slot"))));
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
/**
|
|
41
|
+
* @public
|
|
42
|
+
*/
|
|
43
|
+
export function buildViewProps(_view, props) {
|
|
44
|
+
return {
|
|
45
|
+
...props,
|
|
46
|
+
inState: (testState) => (state) => state === testState,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* @public
|
|
51
|
+
*
|
|
52
|
+
* Sets up a root component for use in an \@xstate/test model backed by \@testing-library/react for the component
|
|
53
|
+
*
|
|
54
|
+
* The logger argument should just be a simple function which forwards the arguments to console.log,
|
|
55
|
+
* this is needed because Wallaby.js only displays console logs in tests that come from source code, not library code,
|
|
56
|
+
* so any logs from inside this file don't show up in the test explorer
|
|
57
|
+
*
|
|
58
|
+
* The returned object has a `rootComponent` property and a function, `awaitTransition`, that returns a Promise
|
|
59
|
+
* when called that is resolved the next time the underlying machine transitions. This can be used in the \@xstate/test
|
|
60
|
+
* model to ensure after an event action is executed the test in the next state doesn't run until after the machine transitions
|
|
61
|
+
*
|
|
62
|
+
* It also delays for 5ms to ensure any React re-rendering happens in response to the state transition
|
|
63
|
+
*/
|
|
64
|
+
export function buildTestRootComponent(machine, logger) {
|
|
65
|
+
if (!machine.meta) {
|
|
66
|
+
throw new Error("Root machine has no meta");
|
|
67
|
+
}
|
|
68
|
+
if (!machine.meta.view) {
|
|
69
|
+
throw new Error("Root machine has no associated view");
|
|
70
|
+
}
|
|
71
|
+
const onChangeEmitter = new TinyEmitter();
|
|
72
|
+
function addTransitionListener(listener) {
|
|
73
|
+
onChangeEmitter.once("transition", listener);
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
rootComponent: function XstateTreeRootComponent() {
|
|
77
|
+
const [_, __, interpreter] = useMachine(machine, { devTools: true });
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
function handler(event) {
|
|
80
|
+
recursivelySend(interpreter, event);
|
|
81
|
+
}
|
|
82
|
+
function changeHandler(ctx, oldCtx) {
|
|
83
|
+
logger("onChange: ", JSON.stringify(difference(ctx, oldCtx), null, 2));
|
|
84
|
+
onChangeEmitter.emit("changed", ctx);
|
|
85
|
+
}
|
|
86
|
+
function onEventHandler(e) {
|
|
87
|
+
logger("onEvent", e);
|
|
88
|
+
}
|
|
89
|
+
function onTransitionHandler(s) {
|
|
90
|
+
logger("State: ", s.value);
|
|
91
|
+
onChangeEmitter.emit("transition");
|
|
92
|
+
}
|
|
93
|
+
interpreter.onChange(changeHandler);
|
|
94
|
+
interpreter.onEvent(onEventHandler);
|
|
95
|
+
interpreter.onTransition(onTransitionHandler);
|
|
96
|
+
emitter.on("event", handler);
|
|
97
|
+
return () => {
|
|
98
|
+
emitter.off("event", handler);
|
|
99
|
+
interpreter.off(changeHandler);
|
|
100
|
+
interpreter.off(onEventHandler);
|
|
101
|
+
interpreter.off(onTransitionHandler);
|
|
102
|
+
};
|
|
103
|
+
}, [interpreter]);
|
|
104
|
+
if (!interpreter.initialized) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return React.createElement(XstateTreeView, { interpreter: interpreter });
|
|
108
|
+
},
|
|
109
|
+
addTransitionListener,
|
|
110
|
+
awaitTransition() {
|
|
111
|
+
return new Promise((res) => {
|
|
112
|
+
addTransitionListener(() => {
|
|
113
|
+
setTimeout(res, 50);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Deep diff between two object, using lodash
|
|
121
|
+
* @param {Object} object Object compared
|
|
122
|
+
* @param {Object} base Object to compare with
|
|
123
|
+
* @return {Object} Return a new object who represent the diff
|
|
124
|
+
*/
|
|
125
|
+
function difference(object, base) {
|
|
126
|
+
function changes(object, base) {
|
|
127
|
+
return transform(object, function (result, value, key) {
|
|
128
|
+
if (!isEqual(value, base[key])) {
|
|
129
|
+
result[key] =
|
|
130
|
+
isObject(value) && isObject(base[key])
|
|
131
|
+
? changes(value, base[key])
|
|
132
|
+
: value;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (isNil(base)) {
|
|
137
|
+
return object;
|
|
138
|
+
}
|
|
139
|
+
return changes(object, base);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* @internal
|
|
143
|
+
* Builds a root component for use in Storybook
|
|
144
|
+
*
|
|
145
|
+
* Pass in an initial state and context and the machine will start from that state
|
|
146
|
+
*
|
|
147
|
+
* This does _not_ work for any machines using slots, nothing will be invoked unless
|
|
148
|
+
* it would be invoked by the state you have chosen the machine to start in
|
|
149
|
+
*
|
|
150
|
+
* XState will not run any invoke handlers for parent states or sibling states that
|
|
151
|
+
* would be passed through if the machine was executing normally
|
|
152
|
+
*
|
|
153
|
+
* I have no solutions for this
|
|
154
|
+
*/
|
|
155
|
+
export function buildStorybookComponent(machine, state = machine.initial, context = machine.context ?? {}) {
|
|
156
|
+
// `set` converts a state.like.this to a {state: { like: this {} } }
|
|
157
|
+
const objectState = set({}, String(state), undefined);
|
|
158
|
+
const startingState = new State({
|
|
159
|
+
value: objectState,
|
|
160
|
+
context: context,
|
|
161
|
+
_event: initEvent,
|
|
162
|
+
_sessionid: null,
|
|
163
|
+
historyValue: undefined,
|
|
164
|
+
history: undefined,
|
|
165
|
+
actions: [],
|
|
166
|
+
activities: undefined,
|
|
167
|
+
meta: undefined,
|
|
168
|
+
events: [],
|
|
169
|
+
configuration: [],
|
|
170
|
+
transitions: [],
|
|
171
|
+
children: {},
|
|
172
|
+
});
|
|
173
|
+
return function XstateTreeStorybookComponent() {
|
|
174
|
+
const [_state, _send, interpreter] = useMachine(machine, {
|
|
175
|
+
devTools: true,
|
|
176
|
+
state: startingState,
|
|
177
|
+
});
|
|
178
|
+
const [_ignored, forceRender] = useState(0);
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
function handler(event) {
|
|
181
|
+
recursivelySend(interpreter, event);
|
|
182
|
+
}
|
|
183
|
+
emitter.on("event", handler);
|
|
184
|
+
// Hack to get around the fact I'm not seeing it re-render after the
|
|
185
|
+
// interpreter is initialized
|
|
186
|
+
setTimeout(() => forceRender(1), 250);
|
|
187
|
+
return () => {
|
|
188
|
+
emitter.off("event", handler);
|
|
189
|
+
};
|
|
190
|
+
}, [interpreter]);
|
|
191
|
+
if (!interpreter.initialized) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
return React.createElement(XstateTreeView, { interpreter: interpreter });
|
|
195
|
+
};
|
|
196
|
+
}
|
package/lib/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { omit, isEqual } from "lodash";
|
|
2
|
+
import { useState, useRef, useEffect } from "react";
|
|
3
|
+
/**
|
|
4
|
+
* @public
|
|
5
|
+
*/
|
|
6
|
+
export function loggingMetaOptions(ignoredEvents, ignoreContext = undefined) {
|
|
7
|
+
const ignoredEventMap = new Map();
|
|
8
|
+
ignoredEvents.forEach((event) => {
|
|
9
|
+
ignoredEventMap.set(event, true);
|
|
10
|
+
});
|
|
11
|
+
return {
|
|
12
|
+
xstateTree: {
|
|
13
|
+
ignoredEvents: ignoredEventMap,
|
|
14
|
+
ignoreContext,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
export function useService(service) {
|
|
22
|
+
const [current, setCurrent] = useState(service.state);
|
|
23
|
+
const [children, setChildren] = useState(service.children);
|
|
24
|
+
const childrenRef = useRef(new Map());
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
childrenRef.current = children;
|
|
27
|
+
}, [children]);
|
|
28
|
+
useEffect(function () {
|
|
29
|
+
// Set to current service state as there is a possibility
|
|
30
|
+
// of a transition occurring between the initial useState()
|
|
31
|
+
// initialization and useEffect() commit.
|
|
32
|
+
setCurrent(service.state);
|
|
33
|
+
setChildren(service.children);
|
|
34
|
+
const listener = function (state) {
|
|
35
|
+
if (state.changed) {
|
|
36
|
+
setCurrent(state);
|
|
37
|
+
if (!isEqual(childrenRef.current, service.children)) {
|
|
38
|
+
setChildren(new Map(service.children));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const sub = service.subscribe(listener);
|
|
43
|
+
return function () {
|
|
44
|
+
sub.unsubscribe();
|
|
45
|
+
};
|
|
46
|
+
}, [service, setChildren]);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
function handler(event) {
|
|
49
|
+
if (event.type.includes("done")) {
|
|
50
|
+
const idOfFinishedChild = event.type.split(".")[2];
|
|
51
|
+
childrenRef.current.delete(idOfFinishedChild);
|
|
52
|
+
setChildren(new Map(childrenRef.current));
|
|
53
|
+
}
|
|
54
|
+
console.debug(`[xstate-tree] ${service.id} handling event`, service.machine.meta?.xstateTree?.ignoredEvents?.has(event.type)
|
|
55
|
+
? event.type
|
|
56
|
+
: event);
|
|
57
|
+
}
|
|
58
|
+
let prevState = undefined;
|
|
59
|
+
function transitionHandler(state) {
|
|
60
|
+
const ignoreContext = service.machine.meta?.xstateTree?.ignoreContext;
|
|
61
|
+
const context = ignoreContext
|
|
62
|
+
? ignoreContext.length > 0
|
|
63
|
+
? omit(state.context, ignoreContext)
|
|
64
|
+
: "[context omitted]"
|
|
65
|
+
: state.context;
|
|
66
|
+
if (prevState) {
|
|
67
|
+
console.debug(`[xstate-tree] ${service.id} transitioning from`, prevState.value, "to", state.value, context);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
console.debug(`[xstate-tree] ${service.id} transitioning to ${state.value}`, context);
|
|
71
|
+
}
|
|
72
|
+
prevState = state;
|
|
73
|
+
}
|
|
74
|
+
service.onEvent(handler);
|
|
75
|
+
service.onTransition(transitionHandler);
|
|
76
|
+
return () => {
|
|
77
|
+
service.off(handler);
|
|
78
|
+
service.off(transitionHandler);
|
|
79
|
+
};
|
|
80
|
+
}, [service, setChildren]);
|
|
81
|
+
return [
|
|
82
|
+
current,
|
|
83
|
+
children,
|
|
84
|
+
];
|
|
85
|
+
}
|
package/lib/utils.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function delay(ms = 0) {
|
|
2
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
3
|
+
}
|
|
4
|
+
export function assertIsDefined(val, msg) {
|
|
5
|
+
if (val === undefined || val === null) {
|
|
6
|
+
throw new Error(`Expected 'val' to be defined, but received ${val} ${msg ? `(${msg})` : ""}`);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function assert(value, msg) {
|
|
10
|
+
if (typeof expect !== "undefined") {
|
|
11
|
+
if (value !== true && msg) {
|
|
12
|
+
console.error(msg);
|
|
13
|
+
}
|
|
14
|
+
expect(value).toEqual(true);
|
|
15
|
+
}
|
|
16
|
+
else if (value !== true) {
|
|
17
|
+
if (msg) {
|
|
18
|
+
console.error(msg);
|
|
19
|
+
}
|
|
20
|
+
throw new Error("assertion failed");
|
|
21
|
+
}
|
|
22
|
+
}
|