UNPKG

@koordinates/xstate-tree

Version:

Build UIs with Actors using xstate and React

382 lines (381 loc) 16.8 kB
"use strict"; 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;