@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.
@@ -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,8 @@
1
+ import { useRef } from "react";
2
+ export function useConstant(fn) {
3
+ const ref = useRef();
4
+ if (!ref.current) {
5
+ ref.current = { v: fn() };
6
+ }
7
+ return ref.current.v;
8
+ }
@@ -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
+ }