@koordinates/xstate-tree
Version:
Build UIs with Actors using xstate and React
382 lines (381 loc) • 16.8 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildRootComponent = exports.recursivelySend = exports.XstateTreeView = exports.getMultiSlotViewForChildren = exports.onBroadcast = exports.broadcast = exports.emitter = void 0;
const react_1 = require("@xstate/react");
const fast_memoize_1 = __importDefault(require("fast-memoize"));
const react_2 = __importStar(require("react"));
const tiny_emitter_1 = require("tiny-emitter");
const routing_1 = require("./routing");
const useConstant_1 = require("./useConstant");
const useService_1 = require("./useService");
const utils_1 = require("./utils");
exports.emitter = new tiny_emitter_1.TinyEmitter();
/**
* @public
*
* Broadcasts a global event to all xstate-tree machines
*
* @param event - the event to broadcast
*/
function broadcast(event) {
console.debug("[xstate-tree] broadcasting event ", event.type);
exports.emitter.emit("event", event);
}
exports.broadcast = broadcast;
/**
* @public
*
* Allows hooking in to the global events sent between machines
*
* @param handler - the handler to call when an event is broadcast
*/
function onBroadcast(handler) {
exports.emitter.on("event", handler);
return () => {
exports.emitter.off("event", handler);
};
}
exports.onBroadcast = onBroadcast;
function cacheKeyForInterpreter(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
interpreter) {
return interpreter.sessionId;
}
const getViewForInterpreter = (0, fast_memoize_1.default)((interpreter) => {
return react_2.default.memo(function InterpreterView({ children, }) {
const activeRouteEvents = (0, routing_1.useActiveRouteEvents)();
(0, react_2.useEffect)(() => {
if (activeRouteEvents) {
activeRouteEvents.forEach((event) => {
if (interpreter.getSnapshot().can(event)) {
interpreter.send(event);
}
});
}
}, []);
return react_2.default.createElement(XstateTreeView, { actor: interpreter }, children);
});
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ serializer: cacheKeyForInterpreter });
/**
* @private
*/
exports.getMultiSlotViewForChildren = (0, fast_memoize_1.default)((parent, slot) => {
return react_2.default.memo(function MultiSlotView() {
const [_, children] = (0, useService_1.useService)(parent);
const interpreters = Object.values(children);
// Once the interpreter is stopped, initialized gets set to false
// We don't want to render stopped interpreters
const interpretersWeCareAbout = interpreters.filter((i) => i.id.includes(slot));
return (react_2.default.createElement(XstateTreeMultiSlotView, { childInterpreters: interpretersWeCareAbout }));
});
}, {
serializer: (args) => `${cacheKeyForInterpreter(args[0])}-${args[1]}`,
});
function useSlots(interpreter, slots) {
return (0, useConstant_1.useConstant)(() => {
return slots.reduce((views, slot) => {
return {
...views,
[slot]: (({ children: reactChildren }) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [__, children] = (0, useService_1.useService)(interpreter);
if (slot.toString().endsWith("Multi")) {
const MultiView = (0, exports.getMultiSlotViewForChildren)(interpreter, slot.toLowerCase());
return react_2.default.createElement(MultiView, null);
}
else {
const interpreterForSlot = children[`${slot.toLowerCase()}-slot`];
if (interpreterForSlot) {
const View = getViewForInterpreter(interpreterForSlot);
return react_2.default.createElement(View, null, reactChildren);
}
else {
// Waiting for the interpreter for this slot to be invoked
return null;
}
}
}),
};
}, {});
});
}
function XstateTreeMultiSlotView({ childInterpreters, }) {
return (react_2.default.createElement(react_2.default.Fragment, null, childInterpreters.map((i) => (react_2.default.createElement(XstateTreeView, { key: i.id, actor: i })))));
}
/**
* @internal
*/
function XstateTreeView({ actor, children }) {
const [current] = (0, useService_1.useService)(actor);
const currentRef = (0, react_2.useRef)(current);
currentRef.current = current;
const selectorsRef = (0, react_2.useRef)(undefined);
const { slots: interpreterSlots, View, actions: actionsFactory, selectors: selectorsFactory, } = actor.logic._xstateTree;
const slots = useSlots(actor, interpreterSlots.map((x) => x.name));
const canHandleEvent = (0, react_2.useCallback)((e) => {
return actor.getSnapshot().can(e);
}, [actor]);
const inState = (0, react_2.useCallback)((state) => {
return currentRef.current?.matches(state) ?? false;
},
// This is needed because the inState function needs to be recreated if the
// current state the machine is in changes. But _only_ then
// eslint-disable-next-line react-hooks/exhaustive-deps
[current?.value]);
const selectorsProxy = (0, useConstant_1.useConstant)(() => {
return new Proxy({}, {
get: (_target, prop) => {
return selectorsRef.current?.[prop];
},
});
});
const actions = (0, useConstant_1.useConstant)(() => {
return actionsFactory({
send: actor.send,
selectors: selectorsProxy,
});
});
if (!current) {
return null;
}
selectorsRef.current = selectorsFactory({
ctx: current.context,
canHandleEvent,
// Workaround for type instantiation possibly infinite error
inState: inState,
meta: (0, utils_1.mergeMeta)(current.getMeta()),
});
return (react_2.default.createElement(View, { selectors: selectorsRef.current, actions: actions, slots: slots }, children));
}
exports.XstateTreeView = XstateTreeView;
/**
* @internal
*/
function recursivelySend(service, event) {
const children = Object.values(service.getSnapshot().children).filter((s) => s.id.includes("-slot"));
// If the service can't handle the event, don't send it
if (service.getSnapshot().can(event)) {
try {
service.send(event);
}
catch (e) {
console.error("Error sending event ", event, " to machine ", service.id, e);
}
}
children.forEach((child) => recursivelySend(child, event));
}
exports.recursivelySend = recursivelySend;
/**
* @public
*
* Builds a React host component for the root machine of an xstate-tree
*
* @param machine - The root machine of the tree
* @param routing - The routing configuration for the tree
*/
function buildRootComponent(options) {
const { input, machine, routing } = options;
if (!machine._xstateTree) {
throw new Error("Root machine is not an xstate-tree machine, missing metadata");
}
if (!machine._xstateTree.View) {
throw new Error("Root machine has no associated view");
}
const RootComponent = function XstateTreeRootComponent({ children, }) {
const lastSnapshotsRef = (0, react_2.useRef)({});
const [_, __, interpreter] = (0, react_1.useActor)(machine, {
input,
inspect(event) {
switch (event.type) {
case "@xstate.actor":
console.log(`[xstate-tree] actor spawned: ${event.actorRef.id}`);
break;
case "@xstate.event":
// Ignore internal events
if (event.event.type.includes("xstate.")) {
return;
}
console.log(`[xstate-tree] event: ${event.sourceRef ? event.sourceRef.id : "UNKNOWN"} -> ${event.event.type} -> ${event.actorRef.id}`, event.event);
break;
case "@xstate.snapshot":
const lastSnapshot = lastSnapshotsRef.current[event.actorRef.sessionId];
const strippedKeys = ["_subscription"];
if (!lastSnapshot) {
console.log(`[xstate-tree] initial snapshot: ${event.actorRef.id}`, (0, utils_1.toJSON)(event.snapshot, strippedKeys));
}
else {
console.log(`[xstate-tree] snapshot: ${event.actorRef.id} transitioning to`, (0, utils_1.toJSON)(event.snapshot, strippedKeys), "from", (0, utils_1.toJSON)(lastSnapshot, strippedKeys));
}
lastSnapshotsRef.current[event.actorRef.sessionId] = event.snapshot;
break;
}
},
id: machine.config.id,
});
const [activeRoute, setActiveRoute] = (0, react_2.useState)(undefined);
const activeRouteEventsRef = (0, react_2.useRef)([]);
const setActiveRouteEvents = (events) => {
activeRouteEventsRef.current = events;
};
const insideRoutingContext = (0, routing_1.useInRoutingContext)();
const inTestRoutingContext = (0, routing_1.useInTestRoutingContext)();
if (!inTestRoutingContext &&
insideRoutingContext &&
typeof routing !== "undefined") {
const m = "Routing root rendered inside routing context, this implies a bug";
if (process.env.NODE_ENV !== "production") {
throw new Error(m);
}
console.error(m);
}
(0, react_2.useEffect)(() => {
function handler(event) {
recursivelySend(interpreter, event);
}
exports.emitter.on("event", handler);
return () => {
exports.emitter.off("event", handler);
};
}, [interpreter]);
(0, react_2.useEffect)(() => {
if (activeRoute === undefined) {
return;
}
const controller = new AbortController();
const routes = [activeRoute];
let route = activeRoute;
while (route.parent) {
routes.unshift(route.parent);
route = route.parent;
}
const routeEventPairs = [];
const activeRoutesEvent = activeRouteEventsRef.current.find((e) => e.type === activeRoute.event);
(0, utils_1.assertIsDefined)(activeRoutesEvent);
for (let i = 0; i < routes.length; i++) {
const route = routes[i];
const routeEvent = activeRouteEventsRef.current[i];
routeEventPairs.push([route, routeEvent]);
}
const routePairsWithRedirects = routeEventPairs.filter(([route]) => {
return route.redirect !== undefined;
});
const redirectPromises = routePairsWithRedirects.map(([route, event]) => {
(0, utils_1.assertIsDefined)(route.redirect);
return route.redirect({
signal: controller.signal,
query: event.query,
params: event.params,
meta: event.meta,
});
});
void Promise.all(redirectPromises).then((redirects) => {
const didAnyRedirect = redirects.some((x) => x !== undefined);
if (!didAnyRedirect || controller.signal.aborted) {
return;
}
const routeArguments = redirects.reduce((args, redirect) => {
if (redirect) {
args.query = { ...args.query, ...redirect.query };
args.params = { ...args.params, ...redirect.params };
args.meta = { ...args.meta, ...redirect.meta };
}
return args;
}, {
// since the redirect results are partials, need to merge them with the original event
// params/query to ensure that all params/query are present
query: { ...(activeRoutesEvent.query ?? {}) },
params: { ...(activeRoutesEvent.params ?? {}) },
meta: { replace: true, ...(activeRoutesEvent.meta ?? {}) },
});
activeRoute.navigate(routeArguments);
});
return () => {
controller.abort();
};
}, [activeRoute]);
(0, react_2.useEffect)(() => {
if (routing) {
const { getPathName = () => routing.history.location.pathname, getQueryString = () => routing.history.location.search, } = routing;
const initialMeta = {
...(routing.history.location.state?.meta ?? {}),
onloadEvent: true,
};
const queryString = getQueryString();
const result = (0, routing_1.handleLocationChange)(routing.routes, routing.basePath, getPathName(), getQueryString(), initialMeta);
if (result) {
setActiveRouteEvents(result.events);
setActiveRoute({ ...result.matchedRoute });
}
// Hack to ensure the initial location doesn't have undefined state
// It's not supposed to, but it does for some reason
// And the history library ignores popstate events with undefined state
routing.history.replace(`${getPathName()}${queryString}`, {
meta: initialMeta,
});
}
}, []);
(0, react_2.useEffect)(() => {
if (routing) {
const unsub = routing.history.listen((location) => {
const result = (0, routing_1.handleLocationChange)(routing.routes, routing.basePath, location.pathname, location.search, location.state?.meta);
if (result) {
setActiveRouteEvents(result.events);
setActiveRoute({ ...result.matchedRoute });
}
});
return () => {
unsub();
};
}
return undefined;
}, []);
const routingProviderValue = (0, react_2.useMemo)(() => {
// Just to satisfy linter, need this memo to be re-calculated on route changes
activeRoute;
if (!routing) {
return null;
}
return {
activeRouteEvents: activeRouteEventsRef,
};
}, [activeRoute]);
if (routingProviderValue) {
return (react_2.default.createElement(routing_1.RoutingContext.Provider, { value: routingProviderValue },
react_2.default.createElement(XstateTreeView, { actor: interpreter }, children)));
}
else {
return react_2.default.createElement(XstateTreeView, { actor: interpreter }, children);
}
};
RootComponent.rootMachine = machine;
return RootComponent;
}
exports.buildRootComponent = buildRootComponent;