@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,204 @@
|
|
|
1
|
+
import { useMachine } from "@xstate/react";
|
|
2
|
+
import memoize from "fast-memoize";
|
|
3
|
+
import { reduce } from "lodash";
|
|
4
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react";
|
|
5
|
+
import { TinyEmitter } from "tiny-emitter";
|
|
6
|
+
import { handleLocationChange, RoutingContext, } from "./routing";
|
|
7
|
+
import { useActiveRouteEvents } from "./routing/providers";
|
|
8
|
+
import { useConstant } from "./useConstant";
|
|
9
|
+
import { useService } from "./useService";
|
|
10
|
+
export const emitter = new TinyEmitter();
|
|
11
|
+
/**
|
|
12
|
+
* @public
|
|
13
|
+
*/
|
|
14
|
+
export function broadcast(event) {
|
|
15
|
+
console.debug("[xstate-tree] broadcasting event ", event.type);
|
|
16
|
+
emitter.emit("event", event);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* @public
|
|
20
|
+
*/
|
|
21
|
+
export function onBroadcast(handler) {
|
|
22
|
+
emitter.on("event", handler);
|
|
23
|
+
return () => {
|
|
24
|
+
emitter.off("event", handler);
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function cacheKeyForInterpreter(
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
29
|
+
interpreter) {
|
|
30
|
+
return interpreter.sessionId;
|
|
31
|
+
}
|
|
32
|
+
const getViewForInterpreter = memoize((interpreter) => {
|
|
33
|
+
return React.memo(function InterpreterView() {
|
|
34
|
+
const activeRouteEvents = useActiveRouteEvents();
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (activeRouteEvents) {
|
|
37
|
+
activeRouteEvents.forEach((event) => {
|
|
38
|
+
if (interpreter.state.nextEvents.includes(event.type)) {
|
|
39
|
+
interpreter.send(event);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}, []);
|
|
44
|
+
return React.createElement(XstateTreeView, { interpreter: interpreter });
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
48
|
+
{ serializer: cacheKeyForInterpreter });
|
|
49
|
+
const getMultiSlotViewForChildren = memoize((parent, slot) => {
|
|
50
|
+
return React.memo(function MultiSlotView() {
|
|
51
|
+
const [_, children] = useService(parent);
|
|
52
|
+
const interpreters = [...children.values()];
|
|
53
|
+
// Once the interpreter is stopped, initialized gets set to false
|
|
54
|
+
// We don't want to render stopped interpreters
|
|
55
|
+
const interpretersWeCareAbout = interpreters.filter((i) => i.id.includes(slot) && i.initialized);
|
|
56
|
+
return (React.createElement(XstateTreeMultiSlotView, { childInterpreters: interpretersWeCareAbout }));
|
|
57
|
+
});
|
|
58
|
+
}, {
|
|
59
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
60
|
+
serializer: ((interpreter, slot) =>
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
62
|
+
`${cacheKeyForInterpreter(interpreter)}-${slot}`),
|
|
63
|
+
});
|
|
64
|
+
function useSlots(interpreter, slots) {
|
|
65
|
+
return useConstant(() => {
|
|
66
|
+
return reduce(slots, (views, slot) => {
|
|
67
|
+
return {
|
|
68
|
+
...views,
|
|
69
|
+
[slot]: () => {
|
|
70
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
71
|
+
const [__, children] = useService(interpreter);
|
|
72
|
+
if (slot.toString().endsWith("s")) {
|
|
73
|
+
const MultiView = getMultiSlotViewForChildren(interpreter, slot.toLowerCase());
|
|
74
|
+
return React.createElement(MultiView, null);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
const interpreterForSlot = children.get(`${slot.toLowerCase()}-slot`);
|
|
78
|
+
if (interpreterForSlot) {
|
|
79
|
+
const View = getViewForInterpreter(interpreterForSlot);
|
|
80
|
+
return React.createElement(View, null);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
// Waiting for the interpreter for this slot to be invoked
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}, {});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function XstateTreeMultiSlotView({ childInterpreters, }) {
|
|
93
|
+
return (React.createElement(React.Fragment, null, childInterpreters.map((i) => (React.createElement(XstateTreeView, { key: i.id, interpreter: i })))));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* @internal
|
|
97
|
+
*/
|
|
98
|
+
export function XstateTreeView({ interpreter }) {
|
|
99
|
+
const [current] = useService(interpreter);
|
|
100
|
+
const currentRef = useRef(current);
|
|
101
|
+
currentRef.current = current;
|
|
102
|
+
const { view: View, actions: actionsFactory, selectors: selectorsFactory, slots: interpreterSlots, } = interpreter.machine.meta;
|
|
103
|
+
const slots = useSlots(interpreter, interpreterSlots.map((x) => x.name));
|
|
104
|
+
const canHandleEvent = useCallback((e) => {
|
|
105
|
+
return interpreter.nextState(e).changed ?? false;
|
|
106
|
+
}, [interpreter]);
|
|
107
|
+
const inState = useCallback((state) => {
|
|
108
|
+
return currentRef.current?.matches(state) ?? false;
|
|
109
|
+
},
|
|
110
|
+
// This is needed because the inState function needs to be recreated if the
|
|
111
|
+
// current state the machine is in changes. But _only_ then
|
|
112
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
113
|
+
[current.value]);
|
|
114
|
+
if (!current) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const selectors = selectorsFactory(current.context, canHandleEvent, inState, current.value);
|
|
118
|
+
const actions = actionsFactory(interpreter.send, selectors);
|
|
119
|
+
return (React.createElement(View, { selectors: selectors, actions: actions, slots: slots, inState: inState }));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* @internal
|
|
123
|
+
*/
|
|
124
|
+
export function recursivelySend(service, event) {
|
|
125
|
+
const children = ([...service.children.values()] || []).filter((s) => s.id.includes("-slot"));
|
|
126
|
+
// If the service can't handle the event, don't send it
|
|
127
|
+
if (service.state.nextEvents.includes(event.type)) {
|
|
128
|
+
try {
|
|
129
|
+
service.send(event);
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
console.error("Error sending event ", event, " to machine ", service.machine.id, e);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
children.forEach((child) => recursivelySend(child, event));
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* @public
|
|
139
|
+
*/
|
|
140
|
+
export function buildRootComponent(machine, routing) {
|
|
141
|
+
if (!machine.meta) {
|
|
142
|
+
throw new Error("Root machine has no meta");
|
|
143
|
+
}
|
|
144
|
+
if (!machine.meta.view) {
|
|
145
|
+
throw new Error("Root machine has no associated view");
|
|
146
|
+
}
|
|
147
|
+
const RootComponent = function XstateTreeRootComponent() {
|
|
148
|
+
const [_, __, interpreter] = useMachine(machine, { devTools: true });
|
|
149
|
+
const [activeRouteEvents, setActiveRouteEvents] = useState(undefined);
|
|
150
|
+
const [forceRenderValue, forceRender] = useState(false);
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
function handler(event) {
|
|
153
|
+
recursivelySend(interpreter, event);
|
|
154
|
+
}
|
|
155
|
+
emitter.on("event", handler);
|
|
156
|
+
return () => {
|
|
157
|
+
emitter.off("event", handler);
|
|
158
|
+
};
|
|
159
|
+
}, [interpreter]);
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
if (routing) {
|
|
162
|
+
const { getPathName = () => window.location.pathname, getQueryString = () => window.location.search, } = routing;
|
|
163
|
+
const queryString = getQueryString();
|
|
164
|
+
handleLocationChange(routing.routes, routing.basePath, getPathName(), getQueryString(), setActiveRouteEvents);
|
|
165
|
+
// Hack to ensure the initial location doesn't have undefined state
|
|
166
|
+
// It's not supposed to, but it does for some reason
|
|
167
|
+
// And the history library ignores popstate events with undefined state
|
|
168
|
+
routing.history.replace(`${getPathName()}${queryString}`, {});
|
|
169
|
+
}
|
|
170
|
+
}, []);
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (routing) {
|
|
173
|
+
const unsub = routing.history.listen((location) => {
|
|
174
|
+
handleLocationChange(routing.routes, routing.basePath, location.pathname, location.search, setActiveRouteEvents, location.state?.meta);
|
|
175
|
+
});
|
|
176
|
+
return () => {
|
|
177
|
+
unsub();
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}, []);
|
|
182
|
+
const routingProviderValue = useMemo(() => {
|
|
183
|
+
if (!routing) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
activeRouteEvents,
|
|
188
|
+
};
|
|
189
|
+
}, [activeRouteEvents]);
|
|
190
|
+
if (!interpreter.initialized) {
|
|
191
|
+
setTimeout(() => forceRender(!forceRenderValue), 0);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
if (routingProviderValue) {
|
|
195
|
+
return (React.createElement(RoutingContext.Provider, { value: routingProviderValue },
|
|
196
|
+
React.createElement(XstateTreeView, { interpreter: interpreter })));
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
return React.createElement(XstateTreeView, { interpreter: interpreter });
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
RootComponent.rootMachine = machine;
|
|
203
|
+
return RootComponent;
|
|
204
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@koordinates/xstate-tree",
|
|
3
|
+
"main": "lib/index.js",
|
|
4
|
+
"types": "lib/xstate-tree.d.ts",
|
|
5
|
+
"version": "1.0.0-beta.1",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@xstate/react": "3.0.0",
|
|
8
|
+
"fast-memoize": "2.5.2",
|
|
9
|
+
"history": "4.10.1",
|
|
10
|
+
"lodash": "4.17.21",
|
|
11
|
+
"path-to-regexp": "6.2.0",
|
|
12
|
+
"query-string": "6.12.1",
|
|
13
|
+
"react": "18.1.0",
|
|
14
|
+
"react-dom": "18.1.0",
|
|
15
|
+
"rxjs": "6.6.2",
|
|
16
|
+
"tiny-emitter": "2.1.0",
|
|
17
|
+
"typescript": "4.7.3",
|
|
18
|
+
"xstate": "4.32.0",
|
|
19
|
+
"zod": "3.17.3"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@microsoft/api-extractor": "7.28.4",
|
|
23
|
+
"@rushstack/eslint-config": "2.6.0",
|
|
24
|
+
"@testing-library/dom": "8.14.0",
|
|
25
|
+
"@testing-library/jest-dom": "5.16.1",
|
|
26
|
+
"@testing-library/react": "10.4.8",
|
|
27
|
+
"@testing-library/user-event": "13.5.0",
|
|
28
|
+
"@types/history": "4.7.7",
|
|
29
|
+
"@types/jest": "28.1.4",
|
|
30
|
+
"@types/lodash": "4.14.180",
|
|
31
|
+
"@types/react": "17.0.29",
|
|
32
|
+
"@types/react-dom": "18.0.6",
|
|
33
|
+
"@types/testing-library__jest-dom": "5.14.1",
|
|
34
|
+
"@typescript-eslint/eslint-plugin": "5.30.5",
|
|
35
|
+
"classnames": "2.3.1",
|
|
36
|
+
"cz-conventional-changelog": "^3.3.0",
|
|
37
|
+
"eslint": "7.32.0",
|
|
38
|
+
"eslint-import-resolver-typescript": "2.7.1",
|
|
39
|
+
"eslint-plugin-import": "2.26.0",
|
|
40
|
+
"eslint-plugin-prettier": "4.2.1",
|
|
41
|
+
"eslint-plugin-react-hooks": "4.3.0",
|
|
42
|
+
"jest": "28.0.3",
|
|
43
|
+
"jest-environment-jsdom": "28.0.1",
|
|
44
|
+
"rimraf": "3.0.2",
|
|
45
|
+
"semantic-release": "19.0.3",
|
|
46
|
+
"ts-jest": "28.0.5"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"lint": "eslint 'src/**/*'",
|
|
50
|
+
"test": "jest",
|
|
51
|
+
"build": "rimraf dist && tsc -p tsconfig.build.json",
|
|
52
|
+
"build:watch": "tsc -p tsconfig.build.json -w",
|
|
53
|
+
"api-extractor": "api-extractor run"
|
|
54
|
+
},
|
|
55
|
+
"files": [
|
|
56
|
+
"lib/**/*.js",
|
|
57
|
+
"lib/xstate-tree.d.ts",
|
|
58
|
+
"!lib/**/*.spec.js"
|
|
59
|
+
],
|
|
60
|
+
"config": {
|
|
61
|
+
"commitizen": {
|
|
62
|
+
"path": "./node_modules/cz-conventional-changelog",
|
|
63
|
+
"types": {
|
|
64
|
+
"build": {
|
|
65
|
+
"description": "Changes that affect the build system or external dependencies (example scopes: rollup, npm)"
|
|
66
|
+
},
|
|
67
|
+
"ci": {
|
|
68
|
+
"description": "Changes to our CI configuration files and scripts"
|
|
69
|
+
},
|
|
70
|
+
"docs": {
|
|
71
|
+
"description": "Changes to documentation"
|
|
72
|
+
},
|
|
73
|
+
"feat": {
|
|
74
|
+
"description": "A new feature"
|
|
75
|
+
},
|
|
76
|
+
"fix": {
|
|
77
|
+
"description": "Bug fixes"
|
|
78
|
+
},
|
|
79
|
+
"perf": {
|
|
80
|
+
"description": "A performance improvement"
|
|
81
|
+
},
|
|
82
|
+
"refactor": {
|
|
83
|
+
"description": "A code change that neither fixes a bug nor adds a feature"
|
|
84
|
+
},
|
|
85
|
+
"test": {
|
|
86
|
+
"description": "Adding or correcting tests"
|
|
87
|
+
},
|
|
88
|
+
"chore": {
|
|
89
|
+
"description": "Other changes that don't modify src or test files"
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|