├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── xstate-tree.yml ├── .gitignore ├── .husky └── commit-msg ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── api-extractor.json ├── commitlint.config.js ├── examples └── todomvc │ ├── App.tsx │ ├── App.typegen.ts │ ├── Todo.tsx │ ├── Todo.typegen.ts │ ├── index.tsx │ ├── models.ts │ └── routes.ts ├── index.html ├── jest.config.js ├── package-lock.json ├── package.json ├── release.config.js ├── src ├── builders.spec.tsx ├── builders.tsx ├── index.ts ├── lazy.spec.tsx ├── lazy.tsx ├── routing │ ├── Link.spec.tsx │ ├── Link.tsx │ ├── README.md │ ├── createRoute │ │ ├── createRoute.spec.ts │ │ ├── createRoute.ts │ │ └── index.ts │ ├── handleLocationChange │ │ ├── handleLocationChange.spec.ts │ │ ├── handleLocationChange.ts │ │ └── index.ts │ ├── index.ts │ ├── joinRoutes.spec.ts │ ├── joinRoutes.ts │ ├── matchRoute │ │ ├── .prettierrc │ │ ├── index.ts │ │ ├── matchRoute.spec.ts │ │ └── matchRoute.ts │ ├── providers.tsx │ ├── routingEvent.ts │ ├── useHref.spec.ts │ ├── useHref.ts │ ├── useIsRouteActive.spec.tsx │ ├── useIsRouteActive.tsx │ ├── useOnRoute.spec.tsx │ ├── useOnRoute.tsx │ ├── useRouteArgsIfActive.spec.tsx │ └── useRouteArgsIfActive.tsx ├── setupScript.ts ├── slots │ ├── index.ts │ ├── slots.spec.ts │ └── slots.ts ├── test-app │ ├── AppMachine.tsx │ ├── AppMachine.typegen.ts │ ├── OtherMachine.tsx │ ├── TodoMachine.tsx │ ├── TodosMachine.tsx │ ├── routes.ts │ ├── tests │ │ ├── __snapshots__ │ │ │ └── itWorks.integration.tsx.snap │ │ ├── changingInvokedMachineForSlot.integration.tsx │ │ ├── interpreterViewsNotUnmountedNeedlessly.integration.tsx │ │ ├── itWorks.integration.tsx │ │ ├── itWorksWithoutRouting.integration.tsx │ │ ├── removingChildActor.integration.tsx │ │ ├── routing.integration.tsx │ │ ├── selectorsStaleCanHandleEvent.integration.tsx │ │ ├── spawningChildActor.integration.tsx │ │ └── updatingChildActorViaBroadcast.integration.tsx │ └── unmountingTestFixture │ │ ├── index.tsx │ │ └── unmountCb.ts ├── testingUtilities.tsx ├── tests │ ├── actionsGetUpdatedSelectors.spec.tsx │ └── asyncRouteRedirects.spec.tsx ├── types.ts ├── useConstant.ts ├── useService.ts ├── utils.ts ├── xstateTree.spec.tsx └── xstateTree.tsx ├── tsconfig.build.json ├── tsconfig.json ├── tsconfig.tsbuildinfo ├── vite.config.ts └── xstate-tree.api.md /.eslintignore: -------------------------------------------------------------------------------- 1 | *.snap 2 | *.typegen.ts 3 | README.md -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | tsconfigRootDir: __dirname, 4 | ecmaFeatures: { 5 | jsx: true, 6 | }, 7 | }, 8 | overrides: [ 9 | { 10 | files: ["*.tsx", "*.ts", "*.js", "*.jsx"], 11 | extends: [ "@rushstack/eslint-config/profile/web-app", "plugin:react-hooks/recommended", "@rushstack/eslint-config/mixins/react"], 12 | plugins: ["import", "prettier"], 13 | rules: { 14 | "@rushstack/typedef-var": "off", 15 | "@typescript-eslint/typedef": "off", 16 | "@typescript-eslint/consistent-type-definitions": "off", 17 | "no-void": "off", 18 | "promise/param-names": "off", 19 | "import/no-unresolved": "off", 20 | "import/named": "off", 21 | "prettier/prettier": "error", 22 | "import/default": "error", 23 | "import/no-self-import": "error", 24 | "import/export": "error", 25 | "import/no-named-as-default": "error", 26 | "import/no-named-as-default-member": "error", 27 | "import/no-deprecated": "warn", 28 | "import/no-mutable-exports": "error", 29 | "import/first": "error", 30 | "no-unused-expressions": "off", 31 | "react/jsx-no-bind": "off", 32 | "import/order": [ 33 | "error", 34 | { 35 | "newlines-between": "always", 36 | "groups": ["builtin", "external", "internal", "parent", "sibling", "index"], 37 | "pathGroups": [ 38 | { 39 | "pattern": "~**/**", 40 | "group": "internal" 41 | }, 42 | { 43 | "pattern": "~*", 44 | "group": "internal" 45 | }, 46 | ], 47 | "alphabetize": { 48 | "order": "asc" 49 | } 50 | } 51 | ], 52 | "import/newline-after-import": "error", 53 | "@typescript-eslint/explicit-member-accessibility": "off", 54 | "@typescript-eslint/explicit-function-return-type": "off", 55 | "@typescript-eslint/prefer-interface": "off", 56 | "@typescript-eslint/no-angle-bracket-type-assertion": "off", 57 | "@typescript-eslint/camelcase": "off", 58 | "@typescript-eslint/no-unused-vars": [ 59 | "error", 60 | { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" } 61 | ], 62 | "@typescript-eslint/explicit-module-boundary-types": "off", 63 | "@typescript-eslint/no-use-before-define": "off", 64 | "@typescript-eslint/no-empty-function": "off", 65 | "@typescript-eslint/ban-ts-comment": ["error", { 66 | "ts-expect-error": "allow-with-description", 67 | "ts-ignore": "allow-with-description", 68 | "ts-nocheck": "allow-with-description" 69 | }], 70 | "@typescript-eslint/naming-convention": [ 71 | "error", 72 | { 73 | "selector": "interface", 74 | "format": ["PascalCase"], 75 | } 76 | ] 77 | }, 78 | "settings": { 79 | "import/extensions": [".ts", ".tsx"], 80 | "import/ignore": ["node_modules"], 81 | "import/internal-regex": "^@kx/", 82 | "import/resolver": "typescript", 83 | "import/external-module-folders": ["node_modules", "node_modules/@types"] 84 | }, 85 | }, 86 | { 87 | "files": ["*.spec.ts", "*.spec.tsx"], 88 | "rules": { 89 | "@typescript-eslint/ban-ts-comment": "off", 90 | "@typescript-eslint/no-var-requires ": "off", 91 | "@typescript-eslint/no-non-null-assertion": "off", 92 | "@typescript-eslint/no-explicit-any": "off", 93 | "@typescript-eslint/no-floating-promises": "off", 94 | } 95 | }, 96 | ] 97 | } -------------------------------------------------------------------------------- /.github/workflows/xstate-tree.yml: -------------------------------------------------------------------------------- 1 | name: xstate-tree 2 | on: 3 | push: 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-20.04 8 | steps: 9 | - name: Check out 10 | uses: actions/checkout@v2 11 | - name: Install dependencies 12 | run: npm install --ci 13 | - name: Run ESLint 14 | run: npm run lint 15 | - name: Run Tests 16 | run: npm run test 17 | - name: Test examples 18 | run: npm run test-examples 19 | - name: Build 20 | run: npm run build 21 | - name: API Extractor 22 | run: npm run api-extractor 23 | - name: semantic-release 24 | run: npx semantic-release 25 | env: 26 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | out 4 | temp -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run commitlint 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest in contributing to xstate-tree! This project is made possible by contributors like you, and we welcome any contributions to the code base and the documentation. 4 | 5 | ## Environment 6 | 7 | - Ensure you have the latest version of Node and NPM. 8 | - Run `npm install` to install all needed dev dependencies. 9 | 10 | ## Making Changes 11 | 12 | Pull requests are encouraged. If you want to add a feature or fix a bug: 13 | 14 | 1. [Fork](https://docs.github.com/en/github/getting-started-with-github/fork-a-repo) and [clone](https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/cloning-a-repository) the [repository](https://github.com/koordinates/xstate-tree) 15 | 2. [Create a separate branch](https://docs.github.com/en/desktop/contributing-and-collaborating-using-github-desktop/managing-branches) for your changes 16 | 3. Make your changes, and ensure that it is formatted by [Prettier](https://prettier.io) and type-checks without errors in [TypeScript](https://www.typescriptlang.org/) 17 | 4. Write tests that validate your change and/or fix. 18 | 5. Run `npm run build` and then run tests with `npm run test` 19 | 6. Run api-extractor and update xstate-tree.api.md if it says the document has changed. 20 | 7. Commit your changes following conventional commit format, use `git cz` to help you with this. 21 | 8. Push your branch and open a PR 🚀 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright © 2022 Koordinates Limited 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the 5 | Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, 6 | and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 11 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF 12 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # xstate-tree 2 | 3 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/koordinates/xstate-tree/blob/master/LICENSE) 4 | [![npm version](https://img.shields.io/npm/v/@koordinates/xstate-tree.svg)](https://www.npmjs.com/package/@koordinates/xstate-tree) 5 | [![Downloads](https://img.shields.io/npm/dm/@koordinates/xstate-tree.svg)](https://www.npmjs.com/package/@koordinates/xstate-tree) 6 | [![Build Status](https://github.com/koordinates/xstate-tree/workflows/xstate-tree/badge.svg)](https://github.com/koordinates/xstate-tree/actions?query=workflow%3Axstate-tree) 7 | 8 | xstate-tree was born as an answer to the question "What would a UI framework that uses [Actors](https://en.wikipedia.org/wiki/Actor_model) as the building block look like?". Inspired by Thomas Weber's [Master Thesis](https://links-lang.org/papers/mscs/Master_Thesis_Thomas_Weber_1450761.pdf) on the topic. [XState](https://xstate.js.org/) was chosen to power the actors with [React](https://reactjs.org) powering the UI derived from the actors. 9 | 10 | xstate-tree was designed to enable modeling large applications as a single tree of xstate machines, with each machine being responsible for smaller and smaller sub sections of the UI. This allows modeling the entire UI structure in state machines, but without having to worry about the complexity of managing the state of the entire application in a single large machine. 11 | 12 | Each machine has an associated, but loosely coupled, React view associated with it. The loose coupling allows the view to have no knowledge of the state machine for ease of testing and re-use in tools like [Storybook](https://storybook.js.org). Actors views are composed together via "slots", which can be rendered in the view to provide child actors a place to render their views in the parent's view. 13 | 14 | While xstate-tree manages your application state, it does not have a mechanism for providing global application state accessible by multiple actors, this must be provided with another library like [Redux](https://redux.js.org/) or [GraphQL](https://graphql.org/). It does provide a routing solution, outlined [here](https://github.com/koordinates/xstate-tree/blob/master/src/routing/README.md). 15 | 16 | At Koordinates we use xstate-tree for all new UI development. Our desktop application, built on top of [Kart](https://kartproject.org/) our Geospatial version control system, is built entirely with xstate-tree using GraphQL for global state. 17 | 18 | A minimal example of a single machine tree: 19 | 20 | ```tsx 21 | import React from "react"; 22 | import { createRoot } from "react-dom/client"; 23 | import { createMachine } from "xstate"; 24 | import { assign } from "@xstate/immer"; 25 | import { 26 | createXStateTreeMachine 27 | buildRootComponent 28 | } from "@koordinates/xstate-tree"; 29 | 30 | type Events = 31 | | { type: "SWITCH_CLICKED" } 32 | | { type: "INCREMENT"; amount: number }; 33 | type Context = { incremented: number }; 34 | 35 | // A standard xstate machine, nothing extra is needed for xstate-tree 36 | const machine = createMachine( 37 | { 38 | id: "root", 39 | initial: "inactive", 40 | context: { 41 | incremented: 0 42 | }, 43 | states: { 44 | inactive: { 45 | on: { 46 | SWITCH_CLICKED: "active" 47 | } 48 | }, 49 | active: { 50 | on: { 51 | SWITCH_CLICKED: "inactive", 52 | INCREMENT: { actions: "increment" } 53 | } 54 | } 55 | } 56 | }, 57 | { 58 | actions: { 59 | increment: assign((context, event) => { 60 | if (event.type !== "INCREMENT") { 61 | return; 62 | } 63 | 64 | context.incremented += event.amount; 65 | }) 66 | } 67 | } 68 | ); 69 | 70 | const RootMachine = createXStateTreeMachine(machine, { 71 | // Selectors to transform the machines state into a representation useful for the view 72 | selectors({ ctx, canHandleEvent, inState }) { 73 | return { 74 | canIncrement: canHandleEvent({ type: "INCREMENT", amount: 1 }), 75 | showSecret: ctx.incremented > 10, 76 | count: ctx.incremented, 77 | active: inState("active") 78 | } 79 | }, 80 | // Actions to abstract away the details of sending events to the machine 81 | actions({ send, selectors }) { 82 | return { 83 | increment(amount: number) { 84 | send({ 85 | type: "INCREMENT", 86 | amount: selectors.count > 4 ? amount * 2 : amount 87 | }); 88 | }, 89 | switch() { 90 | send({ type: "SWITCH_CLICKED" }); 91 | } 92 | } 93 | }, 94 | 95 | // If this tree had more than a single machine the slots to render child machines into would be defined here 96 | // see the codesandbox example for an expanded demonstration that uses slots 97 | slots: [], 98 | // A view to bring it all together 99 | // the return value is a plain React view that can be rendered anywhere by passing in the needed props 100 | // the view has no knowledge of the machine it's bound to 101 | view({ actions, selectors }) { 102 | return ( 103 |
104 | 107 |

Count: {selectors.count}

108 | 114 | {selectors.showSecret &&

The secret password is hunter2

} 115 |
116 | ); 117 | }, 118 | }); 119 | 120 | // Build the React host for the tree 121 | const XstateTreeRoot = buildRootComponent(RootMachine); 122 | 123 | // Rendering it with React 124 | const ReactRoot = createRoot(document.getElementById("root")); 125 | ReactRoot.render(); 126 | ``` 127 | 128 | A more complicated todomvc [example](https://github.com/koordinates/xstate-tree/tree/master/examples/todomvc) 129 | 130 | ## Overview 131 | 132 | Each machine that forms the tree representing your UI has an associated set of selector, action, view functions, and "slots" 133 | - Selector functions are provided with the current context of the machine, a function to determine if it can handle a given event and a function to determine if it is in a given state, and expose the returned result to the view. 134 | - Action functions are provided with the `send` method bound to the machines interpreter and the result of calling the selector function 135 | - Slots are how children of the machine are exposed to the view. They can be either single slot for a single actor, or multi slot for when you have a list of actors. 136 | - View functions are React views provided with the output of the selector and action functions, and the currently active slots 137 | 138 | ## API 139 | 140 | To assist in making xstate-tree easy to use with TypeScript there is the `createXStateTreeMachine` function for typing selectors, actions and view arguments and stapling the resulting functions to the xstate machine 141 | 142 | `createXStateTreeMachine` accepts the xstate machine as the first argument and takes an options argument with the following fields, it is important the fields are defined in this order or TypeScript will infer the wrong types: 143 | * `selectors`, receives an object with `ctx`, `inState`, `canHandleEvent`, and `meta` fields. `ctx` is the machines current context, `inState` is the xstate `state.matches` function to allow determining if the machine is in a given state, and `canHandleEvent` accepts an event object and returns whether the machine will do anything in response to that event in it's current state. `meta` is the xstate `state.meta` object with all the per state meta flattened into an object 144 | * `actions`, receives an object with `send` and `selectors` fields. `send` is the xstate `send` function bound to the machine, and `selectors` is the result of calling the selector function 145 | * `view`, is a React component that receives `actions`, `selectors`, and `slots` as props. `actions` and `selectors` being the result of the action/selector functions and `slots` being an object with keys as the slot names and the values the slots React component 146 | 147 | Full API docs coming soon, see [#20](https://github.com/koordinates/xstate-tree/issues/20) 148 | 149 | ### Slots 150 | 151 | Slots are how invoked/spawned children of the machine are supplied to the Machines view. The child machines get wrapped into a React component responsible for rendering the machine itself. Since the view is provided with these components it is responsible for determining where in the view output they show up. This leaves the view in complete control of where the child views are placed. 152 | 153 | Slotted machines are determined based on the id of the invoked/spawned machine. There are two types of slots, single and multi. Single slots are for invoked machines, where there will only be a single machine per slot. Multi slots are for spawned machines where there are multiple children per slot, rendered as a group; think lists. There is a set of helper functions for creating slots which in turn can be used to get the id for the slot. 154 | 155 | `singleSlot` accepts the name of the slot as the first argument and returns an object with a method `getId()` that returns the id of the slot. 156 | `multiSlot` accepts the name of the slot and returns an object with a method `getId(id: string)` that returns the id of the slot 157 | 158 | You should always use the `getId` methods when invoking/spawning something into a slot. Each slot the machine has must be represented by a call to `singleSlot` or `multiSlot` and stored into an array of slots. These slots must be passed to the `createXStateTreeMachine` function. 159 | 160 | ### Inter-machine communication 161 | 162 | Communicating between multiple independent xstate machines is done via the `broadcast` function. 163 | Any event broadcast via this function is sent to every machine that has the event in its `nextEvents` array, so it won't get sent to machines that have no handler for the event. 164 | 165 | To get access to the type information for these events in a machine listening for it, use the `PickEvent` type to extract the events you are interested in 166 | 167 | ie `PickEvent<"FOO" | "BAR">` will return `{type: "FOO" } | { type: "BAR" }` which can be added to your machines event union. 168 | 169 | To provide the type information on what events are available you add them to the global XstateTreeEvents interface. This is done using `declare global` 170 | 171 | ``` 172 | declare global { 173 | interface XstateTreeEvents { 174 | BASIC: string; 175 | WITH_PAYLOAD: { a: "payload" } 176 | } 177 | } 178 | ``` 179 | 180 | That adds two events to the system, a no payload event (`{ type: "BASIC" }`) and event with payload (`{ type: "WITH_PAYLOAD"; a: "payload" }`). These events will now be visible in the typings for `broadcast` and `PickEvent`. The property name is the `type` of the event and the type of the property is the payload of the event. If the event has no payload, use `string`. 181 | 182 | These events can be added anywhere, either next to a component for component specific events or in a module for events that are for multiple machines. One thing that it is important to keep in mind is that these `declare global` declarations must be loaded by the `.d.ts` files when importing the component, otherwise the events will be missing. Which means 183 | 184 | 1. If they are in their own file, say for a module level declaration, that file will need to be imported somewhere. Somewhere that using a component will trigger the import 185 | 2. If they are tied to a component they need to be in the index.ts file that imports the view/selectors/actions etc and calls `createXStateTreeMachine`. If they are in the file containing those functions the index.d.ts file will not end up importing them. 186 | 187 | 188 | ### Utilities 189 | 190 | #### `viewToMachine` 191 | 192 | This utility accepts a React view that does not take any props and wraps it with an xstate-tree machine so you can easily invoke arbitrary React views in your xstate machines 193 | 194 | ``` 195 | function MyView() { 196 | return
My View
; 197 | } 198 | 199 | const MyViewMachine = viewToMachine(MyView); 200 | ``` 201 | 202 | #### `buildRoutingMachine` 203 | 204 | This utility aims to reduce boilerplate by generating a common type of state machine, a routing machine. This is a machine that solely consists of routing events that transition to states that invoke xstate-tree machines. 205 | 206 | The first argument is the array of routes you wish to handle, and the second is an object mapping from those event types to the xstate-tree machine that will be invoked for that routing event 207 | 208 | ``` 209 | const routeA = createRoute.simpleRoute()({ 210 | url: "/a", 211 | event: "GO_TO_A", 212 | }); 213 | const routeB = createRoute.simpleRoute()({ 214 | url: "/b", 215 | event: "GO_TO_B", 216 | }); 217 | 218 | const RoutingMachine = buildRoutingMachine([routeA, routeB], { 219 | GO_TO_A: MachineA, 220 | GO_TO_B: MachineB, 221 | }); 222 | ``` 223 | 224 | ### Type helpers 225 | 226 | There are some exported type helpers for use with xstate-tree 227 | 228 | * `SelectorsFrom`: Takes a machine and returns the type of the selectors object 229 | * `ActionsFrom`: Takes a machine and returns the type of the actions object 230 | 231 | 232 | ### [Storybook](https://storybook.js.org) 233 | 234 | It is relatively simple to display xstate-tree views directly in Storybook. Since the views are plain React components that accept selectors/actions/slots/inState as props you can just import the view and render it in a Story 235 | 236 | There are a few utilities in xstate-tree to make this easier 237 | 238 | #### `genericSlotsTestingDummy` 239 | 240 | This is a simple Proxy object that renders a
containing the name of the slot whenever rendering 241 | a slot is attempted in the view. This will suffice as an argument for the slots prop in most views 242 | when rendering them in a Story 243 | 244 | #### `slotTestingDummyFactory` 245 | 246 | This is not relevant if using the render-view-component approach. But useful if you 247 | are planning on rendering the view using the xstate-tree machine itself, or testing the machine 248 | via the view. 249 | 250 | It's a simple function that takes a name argument and returns a basic xstate-tree machine that you 251 | can replace slot services with. It just renders a div containing the name supplied 252 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'body-max-line-length': [0], 5 | 'footer-max-line-length': [0] 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /examples/todomvc/App.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | broadcast, 3 | multiSlot, 4 | Link, 5 | RoutingEvent, 6 | createXStateTreeMachine, 7 | } from "@koordinates/xstate-tree"; 8 | import { assign } from "@xstate/immer"; 9 | import React from "react"; 10 | import { map } from "rxjs/operators"; 11 | import { createMachine, type ActorRefFrom, spawn } from "xstate"; 12 | 13 | import { TodoMachine } from "./Todo"; 14 | import { todos$, type Todo } from "./models"; 15 | import { activeTodos, allTodos, completedTodos } from "./routes"; 16 | 17 | type Context = { 18 | todos: Todo[]; 19 | actors: Record>; 20 | filter: "all" | "active" | "completed"; 21 | }; 22 | type Events = 23 | | { type: "SYNC_TODOS"; todos: Todo[] } 24 | | RoutingEvent 25 | | RoutingEvent 26 | | RoutingEvent; 27 | 28 | const TodosSlot = multiSlot("Todos"); 29 | const machine = 30 | /** @xstate-layout N4IgpgJg5mDOIC5QBcD2FUFoCGAHXAdAMaoB2EArkWgE4DEiouqsAlsq2YyAB6ICMAFgBMBAJwTBANgkBWAOwAOWQGYpwgDQgAngMEEp-RYNn8FABmHmxMlQF87WtBhz46AZQCaAOQDCAfQAVAHkAEWD3bmY2Di4kXkRMeSkCEWTzRUUbYUV5c1ktXQRMflKCMyUxQXk1Y2FShyd0LDxcDwAJYIB1fwBBX0CASQA1AFEgsIiolnZOUm4+BBUagitzFWFZasVzczMpQsThFeVFFV35fjzLQUaQZxa3d06e3oAZN4nwyPjo2bjQIt+FJFKsxNYxLIbLJNoIxIpDsUVFDUsJBGcVGIrPxjrdHPdmq42s9uv5fMEALIABTeo0Co1CXymvxmsXm8UWJWsBB2ljUVThe2EBx0iWRYlR6JUmOxuIc+NI6Dg3AeROIZEo1FQNGmMTmC0SslBVRqIJE-HywsEgkRVwIlWqan40tM-DuqtaBEVgWa8BZeoBCQQV1EUhUFSyjrNNtFwZs4kjpysKkUwjU7sJnoAFthYD6MH6mKz9RzDeYCDCwxGTfzbfH4VUk+tU+n8R78Lr-uzAYkLeW0lIMll1Ll8ojMGiUhGzkIU2JWw4gA */ 31 | createMachine( 32 | { 33 | context: { todos: [], actors: {}, filter: "all" }, 34 | tsTypes: {} as import("./App.typegen").Typegen0, 35 | schema: { context: {} as Context, events: {} as Events }, 36 | predictableActionArguments: true, 37 | invoke: { 38 | src: "syncTodos", 39 | id: "syncTodos", 40 | }, 41 | id: "todo-app", 42 | initial: "conductor", 43 | on: { 44 | SYNC_TODOS: { 45 | actions: "syncTodos", 46 | target: ".conductor", 47 | }, 48 | SHOW_ACTIVE_TODOS: { 49 | actions: "setFilter", 50 | }, 51 | SHOW_ALL_TODOS: { 52 | actions: "setFilter", 53 | }, 54 | SHOW_COMPLETED_TODOS: { 55 | actions: "setFilter", 56 | }, 57 | }, 58 | states: { 59 | conductor: { 60 | always: [ 61 | { 62 | cond: "hasTodos", 63 | target: "hasTodos", 64 | }, 65 | { 66 | target: "noTodos", 67 | }, 68 | ], 69 | }, 70 | noTodos: {}, 71 | hasTodos: {}, 72 | }, 73 | }, 74 | { 75 | actions: { 76 | syncTodos: assign((ctx, e) => { 77 | ctx.todos = e.todos; 78 | 79 | ctx.todos.forEach((todo) => { 80 | if (!ctx.actors[todo.id]) { 81 | ctx.actors[todo.id] = spawn( 82 | TodoMachine.withContext({ ...TodoMachine.context, todo }), 83 | TodosSlot.getId(todo.id) 84 | ); 85 | } 86 | }); 87 | }), 88 | setFilter: assign((ctx, e) => { 89 | ctx.filter = 90 | e.type === "SHOW_ACTIVE_TODOS" 91 | ? "active" 92 | : e.type === "SHOW_COMPLETED_TODOS" 93 | ? "completed" 94 | : "all"; 95 | }), 96 | }, 97 | guards: { 98 | hasTodos: (ctx) => ctx.todos.length > 0, 99 | }, 100 | services: { 101 | syncTodos: () => { 102 | return todos$.pipe( 103 | map((todos): Events => ({ type: "SYNC_TODOS", todos })) 104 | ); 105 | }, 106 | }, 107 | } 108 | ); 109 | 110 | export const TodoApp = createXStateTreeMachine(machine, { 111 | selectors({ ctx, inState }) { 112 | return { 113 | get count() { 114 | const completedCount = ctx.todos.filter((t) => t.completed).length; 115 | const activeCount = ctx.todos.length - completedCount; 116 | 117 | return ctx.filter === "completed" ? completedCount : activeCount; 118 | }, 119 | get countText() { 120 | const count = this.count; 121 | const plural = count === 1 ? "" : "s"; 122 | 123 | return `item${plural} ${ 124 | ctx.filter === "completed" ? "completed" : "left" 125 | }`; 126 | }, 127 | allCompleted: ctx.todos.every((t) => t.completed), 128 | haveCompleted: ctx.todos.some((t) => t.completed), 129 | allTodosClass: ctx.filter === "all" ? "selected" : undefined, 130 | activeTodosClass: ctx.filter === "active" ? "selected" : undefined, 131 | completedTodosClass: ctx.filter === "completed" ? "selected" : undefined, 132 | hasTodos: inState("hasTodos"), 133 | }; 134 | }, 135 | actions() { 136 | return { 137 | addTodo(title: string) { 138 | const trimmed = title.trim(); 139 | 140 | if (trimmed.length > 0) { 141 | broadcast({ type: "TODO_CREATED", text: trimmed }); 142 | } 143 | }, 144 | completeAll(completed: boolean) { 145 | broadcast({ type: "TODO_ALL_COMPLETED", completed }); 146 | }, 147 | clearCompleted() { 148 | broadcast({ type: "TODO_COMPLETED_CLEARED" }); 149 | }, 150 | }; 151 | }, 152 | slots: [TodosSlot], 153 | View({ actions, selectors, slots }) { 154 | return ( 155 | <> 156 |
157 |
158 |

todos

159 | { 164 | if (e.key === "Enter") { 165 | actions.addTodo(e.currentTarget.value); 166 | } 167 | }} 168 | /> 169 |
170 | 171 | {selectors.hasTodos && ( 172 | <> 173 |
174 | actions.completeAll(!selectors.allCompleted)} 179 | checked={selectors.allCompleted} 180 | /> 181 | 182 | 183 |
    184 | 185 |
186 |
187 | 188 |
189 | 190 | {selectors.count} {selectors.countText} 191 | 192 |
    193 |
  • 194 | 195 | All 196 | 197 |
  • 198 |
  • 199 | 203 | Active 204 | 205 |
  • 206 |
  • 207 | 211 | Completed 212 | 213 |
  • 214 |
215 | 216 | {selectors.haveCompleted && ( 217 | 223 | )} 224 |
225 | 226 | )} 227 |
228 | 229 |
230 |

Double-click to edit a todo

231 |

Created by Taylor Lodge

232 |
233 | 234 | ); 235 | }, 236 | }); 237 | -------------------------------------------------------------------------------- /examples/todomvc/App.typegen.ts: -------------------------------------------------------------------------------- 1 | // This file was automatically generated. Edits will be overwritten 2 | 3 | export interface Typegen0 { 4 | "@@xstate/typegen": true; 5 | internalEvents: { 6 | "": { type: "" }; 7 | "done.invoke.syncTodos": { 8 | type: "done.invoke.syncTodos"; 9 | data: unknown; 10 | __tip: "See the XState TS docs to learn how to strongly type this."; 11 | }; 12 | "error.platform.syncTodos": { 13 | type: "error.platform.syncTodos"; 14 | data: unknown; 15 | }; 16 | "xstate.init": { type: "xstate.init" }; 17 | }; 18 | invokeSrcNameMap: { 19 | syncTodos: "done.invoke.syncTodos"; 20 | }; 21 | missingImplementations: { 22 | actions: never; 23 | services: never; 24 | guards: never; 25 | delays: never; 26 | }; 27 | eventsCausingActions: { 28 | setFilter: "SHOW_ACTIVE_TODOS" | "SHOW_ALL_TODOS" | "SHOW_COMPLETED_TODOS"; 29 | syncTodos: "SYNC_TODOS"; 30 | }; 31 | eventsCausingServices: { 32 | syncTodos: "xstate.init"; 33 | }; 34 | eventsCausingGuards: { 35 | hasTodos: ""; 36 | }; 37 | eventsCausingDelays: {}; 38 | matchesStates: "conductor" | "hasTodos" | "noTodos"; 39 | tags: never; 40 | } 41 | -------------------------------------------------------------------------------- /examples/todomvc/Todo.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | broadcast, 3 | createXStateTreeMachine, 4 | RoutingEvent, 5 | type PickEvent, 6 | } from "@koordinates/xstate-tree"; 7 | import { assign } from "@xstate/immer"; 8 | import React from "react"; 9 | import { map, filter } from "rxjs/operators"; 10 | import { createMachine } from "xstate"; 11 | 12 | import { Todo, todos$ } from "./models"; 13 | import { activeTodos, allTodos, completedTodos } from "./routes"; 14 | 15 | type Context = { todo: Todo; editedText: string }; 16 | type Events = 17 | | { type: "EDIT" } 18 | | { type: "SYNC_TODO"; todo: Todo } 19 | | { type: "EDIT_SUBMITTED" } 20 | | { type: "EDIT_CHANGED"; text: string } 21 | | RoutingEvent 22 | | RoutingEvent 23 | | RoutingEvent 24 | | PickEvent<"TODO_DELETED" | "TODO_EDITED" | "TODO_COMPLETED_CLEARED">; 25 | 26 | const machine = 27 | /** @xstate-layout N4IgpgJg5mDOIC5QBcD2FUDoAWBLCEYAdgMQDKAEgPIDqA+gIIAyTdAKlQCJVmKgAOqWLmS5URPiAAeiABwBmACyZFigIwAmNQFYNATgDsANkPaANCACeibQAZZmWRtu3tag2r0aji2-IC+-hZoGDj4hKSUtIwAwmwAkgBqAKLsXDySgsKi4pIyCCbKXmqysmqKGooG2vLyFtYIBgYOGna2Ggbyrs46gcHoWHgExOTU9DFUALIACkzJbMmcady8SCBZImISa-lG8mqYGkeybraKJ54G9YhKRn0gIVgAbrjCAEYANmCYL2AA7iRFvE2JkhJtcjtECU9CoFPJtHo9p49O1rghtEYNIdPDo9PITh1DPdHj9XrhPt9ICISBxuHQgQtOKDsls8lDSipyvI9G4jC49IiNGimrZDqY1EjSp1FMSBqT3l9MFTkIDOMC6GQAKoAIUmwMZzPB21A+XOWL58g03J5LiM5isNnOmBc5Q0siMzVkKLuQQecpeCspEGpDLoMQoDAAcgBxRaGnLG6SIc4HWTGIx7DO2TS2HxovZYvFKI57M48vSy0IB8lfUbRBhxJKpWkZNYbBNsxpeRxp5wotPuNRqfNaTBKaoVeR8moCyvPMkUuvjKazeaLZatgRgjuQrvyTB2eQedSKPRpj3C1SHDMGdSyXOtIyyOfkACakZiG-jrN3dhh8J0c4zkULo3DRTQXxbOhOGSOYDTbbcfxNGwrgdBBbkg9IwxXOD1xiOYGAAJTjBCWQhZD0VQhojgMTABQFAc7FPJQZXuIh0DgSQSSGCJv3IpMEAFIwxxA294WqSonzRfEsQlD0j1qL1dCaOd5Rrb5fj+PjE3yAxRX2ExnCMHRbwUIxhXxa8PQ9bRmk0U9VOrCklWDZBtM7CoYXogU-ExNNswsg4XBdLxb1aWdfRJJyvnc3dFGEgyvFzEzzinNEqmEl0KlvTxT20bRVMIL5kEgWKKOLFQH0MsKET0NFvG0Oj6OMVRWg8AJIoGMqBKOMcJSS4zbNS8y0O7bzqjTeL3U6QJAiAA */ 28 | createMachine( 29 | { 30 | context: { editedText: "" } as Context, 31 | tsTypes: {} as import("./Todo.typegen").Typegen0, 32 | schema: { context: {} as Context, events: {} as Events }, 33 | predictableActionArguments: true, 34 | invoke: { 35 | src: "syncTodo", 36 | id: "syncTodo", 37 | }, 38 | id: "todo", 39 | on: { 40 | SYNC_TODO: { 41 | actions: "syncTodo", 42 | }, 43 | TODO_DELETED: { 44 | cond: "isThisTodo", 45 | target: ".deleted", 46 | }, 47 | TODO_COMPLETED_CLEARED: { 48 | cond: "isCompleted", 49 | target: ".deleted", 50 | }, 51 | }, 52 | initial: "visible", 53 | states: { 54 | hidden: { 55 | on: { 56 | SHOW_ALL_TODOS: { 57 | target: "visible", 58 | }, 59 | SHOW_ACTIVE_TODOS: { 60 | cond: "isNotCompleted", 61 | target: "visible", 62 | }, 63 | SHOW_COMPLETED_TODOS: { 64 | cond: "isCompleted", 65 | target: "visible", 66 | }, 67 | }, 68 | }, 69 | visible: { 70 | initial: "view", 71 | states: { 72 | view: { 73 | on: { 74 | EDIT: { 75 | cond: "isNotCompleted", 76 | target: "edit", 77 | }, 78 | }, 79 | }, 80 | edit: { 81 | entry: "setEditedText", 82 | on: { 83 | TODO_EDITED: { 84 | cond: "isThisTodo", 85 | target: "view", 86 | }, 87 | EDIT_SUBMITTED: { 88 | actions: "submitTodo", 89 | }, 90 | EDIT_CHANGED: { 91 | actions: "updateEditedText", 92 | }, 93 | }, 94 | }, 95 | }, 96 | on: { 97 | SHOW_ACTIVE_TODOS: { 98 | cond: "isCompleted", 99 | target: "hidden", 100 | }, 101 | SHOW_COMPLETED_TODOS: { 102 | cond: "isNotCompleted", 103 | target: "hidden", 104 | }, 105 | }, 106 | }, 107 | deleted: { 108 | type: "final", 109 | }, 110 | }, 111 | }, 112 | { 113 | actions: { 114 | syncTodo: assign((ctx, e) => { 115 | ctx.todo = e.todo; 116 | }), 117 | submitTodo: (ctx) => { 118 | broadcast({ 119 | type: "TODO_EDITED", 120 | id: ctx.todo.id, 121 | text: ctx.editedText, 122 | }); 123 | }, 124 | setEditedText: assign((ctx) => { 125 | ctx.editedText = ctx.todo.text; 126 | }), 127 | updateEditedText: assign((ctx, e) => { 128 | ctx.editedText = e.text; 129 | }), 130 | }, 131 | guards: { 132 | isThisTodo: (ctx, e) => ctx.todo.id === e.id, 133 | isNotCompleted: (ctx) => !ctx.todo.completed, 134 | isCompleted: (ctx) => ctx.todo.completed, 135 | }, 136 | services: { 137 | syncTodo: (ctx) => 138 | todos$.pipe( 139 | map((todos) => ({ 140 | type: "SYNC_TODO", 141 | todo: todos.find((todo) => todo.id === ctx.todo.id), 142 | })), 143 | filter((e) => e.todo !== undefined) 144 | ), 145 | }, 146 | } 147 | ); 148 | 149 | export const TodoMachine = createXStateTreeMachine(machine, { 150 | selectors({ ctx, inState }) { 151 | return { 152 | text: ctx.todo.text, 153 | completed: ctx.todo.completed, 154 | id: ctx.todo.id, 155 | editedText: ctx.editedText, 156 | editing: inState("visible.edit"), 157 | viewing: inState("visible.view"), 158 | }; 159 | }, 160 | actions({ selectors, send }) { 161 | return { 162 | complete() { 163 | broadcast({ 164 | type: "TODO_COMPLETED", 165 | id: selectors.id, 166 | }); 167 | }, 168 | delete() { 169 | broadcast({ 170 | type: "TODO_DELETED", 171 | id: selectors.id, 172 | }); 173 | }, 174 | textChange(text: string) { 175 | send({ type: "EDIT_CHANGED", text }); 176 | }, 177 | submitEdit() { 178 | send({ type: "EDIT_SUBMITTED" }); 179 | }, 180 | startEditing() { 181 | send({ type: "EDIT" }); 182 | }, 183 | }; 184 | }, 185 | View({ 186 | selectors: { completed, editedText, text, editing, viewing }, 187 | actions, 188 | }) { 189 | return ( 190 |
  • actions.startEditing()} 193 | > 194 | {viewing && ( 195 |
    196 | 202 | 203 |
    205 | )} 206 | 207 | {editing && ( 208 | actions.submitEdit()} 213 | onKeyDown={(e) => e.key === "Enter" && actions.submitEdit()} 214 | onChange={(e) => actions.textChange(e.target.value)} 215 | /> 216 | )} 217 |
  • 218 | ); 219 | }, 220 | }); 221 | -------------------------------------------------------------------------------- /examples/todomvc/Todo.typegen.ts: -------------------------------------------------------------------------------- 1 | // This file was automatically generated. Edits will be overwritten 2 | 3 | export interface Typegen0 { 4 | "@@xstate/typegen": true; 5 | internalEvents: { 6 | "done.invoke.syncTodo": { 7 | type: "done.invoke.syncTodo"; 8 | data: unknown; 9 | __tip: "See the XState TS docs to learn how to strongly type this."; 10 | }; 11 | "error.platform.syncTodo": { 12 | type: "error.platform.syncTodo"; 13 | data: unknown; 14 | }; 15 | "xstate.init": { type: "xstate.init" }; 16 | }; 17 | invokeSrcNameMap: { 18 | syncTodo: "done.invoke.syncTodo"; 19 | }; 20 | missingImplementations: { 21 | actions: never; 22 | services: never; 23 | guards: never; 24 | delays: never; 25 | }; 26 | eventsCausingActions: { 27 | setEditedText: "EDIT"; 28 | submitTodo: "EDIT_SUBMITTED"; 29 | syncTodo: "SYNC_TODO"; 30 | updateEditedText: "EDIT_CHANGED"; 31 | }; 32 | eventsCausingServices: { 33 | syncTodo: "xstate.init"; 34 | }; 35 | eventsCausingGuards: { 36 | isCompleted: 37 | | "SHOW_ACTIVE_TODOS" 38 | | "SHOW_COMPLETED_TODOS" 39 | | "TODO_COMPLETED_CLEARED"; 40 | isNotCompleted: "EDIT" | "SHOW_ACTIVE_TODOS" | "SHOW_COMPLETED_TODOS"; 41 | isThisTodo: "TODO_DELETED" | "TODO_EDITED"; 42 | }; 43 | eventsCausingDelays: {}; 44 | matchesStates: 45 | | "deleted" 46 | | "hidden" 47 | | "visible" 48 | | "visible.edit" 49 | | "visible.view" 50 | | { visible?: "edit" | "view" }; 51 | tags: never; 52 | } 53 | -------------------------------------------------------------------------------- /examples/todomvc/index.tsx: -------------------------------------------------------------------------------- 1 | import { buildRootComponent } from "@koordinates/xstate-tree"; 2 | import React from "react"; 3 | import { createRoot } from "react-dom/client"; 4 | 5 | import { TodoApp } from "./App"; 6 | import { routes, history } from "./routes"; 7 | 8 | const appRoot = document.getElementById("root"); 9 | const root = createRoot(appRoot!); 10 | const App = buildRootComponent(TodoApp, { 11 | basePath: "/", 12 | history, 13 | routes, 14 | }); 15 | 16 | root.render(); 17 | -------------------------------------------------------------------------------- /examples/todomvc/models.ts: -------------------------------------------------------------------------------- 1 | import { onBroadcast } from "@koordinates/xstate-tree"; 2 | import { produce } from "immer"; 3 | import { Observable, share } from "rxjs"; 4 | 5 | export type Todo = { 6 | id: string; 7 | text: string; 8 | completed: boolean; 9 | }; 10 | 11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 12 | export function isTodo(obj: any): obj is Todo { 13 | return ( 14 | obj.id !== undefined && 15 | typeof obj.id === "string" && 16 | obj.text !== undefined && 17 | typeof obj.text === "string" && 18 | obj.completed !== undefined && 19 | typeof obj.completed === "boolean" 20 | ); 21 | } 22 | 23 | declare global { 24 | interface XstateTreeEvents { 25 | TODO_CREATED: { text: string }; 26 | TODO_EDITED: { id: string; text: string }; 27 | TODO_COMPLETED: { id: string }; 28 | TODO_DELETED: { id: string }; 29 | TODO_ALL_COMPLETED: { completed: boolean }; 30 | TODO_COMPLETED_CLEARED: string; 31 | } 32 | } 33 | 34 | export const todos$ = new Observable((subscriber) => { 35 | let todos = getTodos(); 36 | 37 | subscriber.next(todos); 38 | 39 | return onBroadcast((event) => { 40 | switch (event.type) { 41 | case "TODO_CREATED": 42 | todos = produce(todos, (draft) => { 43 | draft.push({ 44 | id: Math.random().toString(36).substring(2, 9), 45 | text: event.text, 46 | completed: false, 47 | }); 48 | }); 49 | break; 50 | case "TODO_EDITED": 51 | todos = produce(todos, (draft) => { 52 | const todo = getTodo(event.id, draft); 53 | 54 | if (todo) { 55 | todo.text = event.text; 56 | } 57 | }); 58 | break; 59 | case "TODO_COMPLETED": 60 | todos = produce(todos, (draft) => { 61 | const todo = getTodo(event.id, draft); 62 | 63 | if (todo) { 64 | todo.completed = !todo.completed; 65 | } 66 | }); 67 | break; 68 | case "TODO_DELETED": 69 | todos = produce(todos, (draft) => { 70 | const index = draft.findIndex((todo) => todo.id === event.id); 71 | 72 | if (index !== -1) { 73 | draft.splice(index, 1); 74 | } 75 | }); 76 | break; 77 | case "TODO_ALL_COMPLETED": 78 | todos = produce(todos, (draft) => { 79 | draft.forEach((todo) => { 80 | todo.completed = event.completed; 81 | }); 82 | }); 83 | break; 84 | case "TODO_COMPLETED_CLEARED": 85 | todos = produce(todos, (draft) => { 86 | draft.forEach((todo, index) => { 87 | if (todo.completed) { 88 | draft.splice(index, 1); 89 | } 90 | }); 91 | }); 92 | } 93 | 94 | subscriber.next(todos); 95 | }); 96 | }).pipe(share()); 97 | 98 | todos$.subscribe(saveTodos); 99 | 100 | function getTodo(id: string, todos: Todo[]): Todo | undefined { 101 | return todos.find((todo) => todo.id === id); 102 | } 103 | 104 | function getTodos(): Todo[] { 105 | try { 106 | const todos: unknown = JSON.parse( 107 | localStorage.getItem("xstate-todos") ?? "[]" 108 | ); 109 | 110 | if (Array.isArray(todos)) { 111 | return todos.filter(isTodo); 112 | } else { 113 | return []; 114 | } 115 | } catch (e) { 116 | console.error("Error loading from localStorage", e); 117 | return []; 118 | } 119 | } 120 | 121 | function saveTodos(todos: Todo[]): void { 122 | try { 123 | localStorage.setItem("xstate-todos", JSON.stringify(todos)); 124 | } catch (e) { 125 | console.error("Error saving to localStorage", e); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /examples/todomvc/routes.ts: -------------------------------------------------------------------------------- 1 | import { buildCreateRoute, XstateTreeHistory } from "@koordinates/xstate-tree"; 2 | import { createBrowserHistory } from "history"; 3 | 4 | export const history: XstateTreeHistory = createBrowserHistory(); 5 | const createRoute = buildCreateRoute(() => history, "/"); 6 | 7 | export const allTodos = createRoute.simpleRoute()({ 8 | url: "/", 9 | event: "SHOW_ALL_TODOS", 10 | }); 11 | export const activeTodos = createRoute.simpleRoute()({ 12 | url: "/active", 13 | event: "SHOW_ACTIVE_TODOS", 14 | }); 15 | export const completedTodos = createRoute.simpleRoute()({ 16 | url: "/completed", 17 | event: "SHOW_COMPLETED_TODOS", 18 | }); 19 | 20 | export const routes = [allTodos, activeTodos, completedTodos]; 21 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | xstate-tree • TodoMVC 7 | 8 | 9 | 10 | 11 |
    12 | 13 | 14 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: "xstate-tree", 3 | testMatch: ["./**/?(*.)(spec|test|integration).ts?(x)"], 4 | preset: "ts-jest", 5 | testEnvironment: "jsdom", 6 | testEnvironmentOptions: { 7 | url: "http://localhost", 8 | }, 9 | setupFilesAfterEnv: ["./src/setupScript.ts"], 10 | globals: { 11 | "ts-jest": { 12 | tsconfig: "./tsconfig.json", 13 | isolatedModules: true, 14 | }, 15 | }, 16 | verbose: true, 17 | collectCoverage: Boolean(process.env.CI), 18 | }; 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@koordinates/xstate-tree", 3 | "main": "lib/index.js", 4 | "types": "lib/xstate-tree.d.ts", 5 | "version": "4.7.0", 6 | "license": "MIT", 7 | "description": "Build UIs with Actors using xstate and React", 8 | "keywords": [ 9 | "react", 10 | "javascript", 11 | "typescript", 12 | "ui", 13 | "actors", 14 | "state-machine", 15 | "actor-model", 16 | "xstate" 17 | ], 18 | "homepage": "https://github.com/koordinates/xstate-tree/", 19 | "bugs": { 20 | "url": "https://github.com/koordinates/xstate-tree/issues/" 21 | }, 22 | "dependencies": { 23 | "fast-memoize": "^2.5.2", 24 | "path-to-regexp": "^6.2.0", 25 | "query-string": "^6.12.1", 26 | "tiny-emitter": "^2.1.0" 27 | }, 28 | "devDependencies": { 29 | "@commitlint/cli": "^17.1.2", 30 | "@commitlint/config-conventional": "^17.1.0", 31 | "@microsoft/api-extractor": "^7.33.6", 32 | "@rushstack/eslint-config": "^2.6.0", 33 | "@saithodev/semantic-release-backmerge": "^2.1.2", 34 | "@testing-library/dom": "^8.14.0", 35 | "@testing-library/jest-dom": "^5.16.1", 36 | "@testing-library/react": "^13.4.0", 37 | "@testing-library/user-event": "^14.4.3", 38 | "@types/history": "^4.7.7", 39 | "@types/jest": "^28.1.4", 40 | "@types/node": "^20.4.9", 41 | "@types/react": "^17.0.29", 42 | "@types/react-dom": "^18.0.6", 43 | "@types/testing-library__jest-dom": "^5.14.1", 44 | "@typescript-eslint/eslint-plugin": "^5.30.5", 45 | "@vitejs/plugin-react": "^2.1.0", 46 | "@xstate/immer": "^0.3.1", 47 | "@xstate/react": "^3.0.0", 48 | "classnames": "^2.3.1", 49 | "cz-conventional-changelog": "^3.3.0", 50 | "eslint": "^7.32.0", 51 | "eslint-import-resolver-typescript": "^2.7.1", 52 | "eslint-plugin-import": "^2.26.0", 53 | "eslint-plugin-prettier": "^4.2.1", 54 | "eslint-plugin-react-hooks": "^4.3.0", 55 | "history": "^4.10.1", 56 | "husky": "^8.0.1", 57 | "immer": "^9.0.15", 58 | "jest": "^28.0.3", 59 | "jest-environment-jsdom": "^28.0.1", 60 | "react": "^18.1.0", 61 | "react-dom": "^18.1.0", 62 | "rimraf": "^3.0.2", 63 | "rxjs": "^7.5.6", 64 | "semantic-release": "^19.0.3", 65 | "semantic-release-npm-github-publish": "^1.5.1", 66 | "todomvc-app-css": "^2.4.2", 67 | "todomvc-common": "^1.0.5", 68 | "ts-jest": "^28.0.5", 69 | "typescript": "^4.7.3", 70 | "vite": "^3.1.3", 71 | "vite-tsconfig-paths": "^3.5.0", 72 | "xstate": "^4.33.0" 73 | }, 74 | "peerDependencies": { 75 | "@xstate/react": "^3.x", 76 | "react": ">= 16.8.0 < 19.0.0", 77 | "xstate": ">= 4.20 < 5.0.0", 78 | "zod": "^3.x" 79 | }, 80 | "scripts": { 81 | "lint": "eslint 'src/**/*'", 82 | "test": "jest", 83 | "test-examples": "tsc --noEmit", 84 | "todomvc": "vite dev", 85 | "build": "rimraf lib && rimraf out && tsc -p tsconfig.build.json", 86 | "build:watch": "tsc -p tsconfig.json -w", 87 | "api-extractor": "api-extractor run", 88 | "release": "semantic-release", 89 | "commitlint": "commitlint --edit" 90 | }, 91 | "files": [ 92 | "lib/**/*.js", 93 | "lib/xstate-tree.d.ts", 94 | "!lib/**/*.spec.js" 95 | ], 96 | "config": { 97 | "commitizen": { 98 | "path": "./node_modules/cz-conventional-changelog", 99 | "types": { 100 | "build": { 101 | "description": "Changes that affect the build system or external dependencies (example scopes: rollup, npm)" 102 | }, 103 | "ci": { 104 | "description": "Changes to our CI configuration files and scripts" 105 | }, 106 | "docs": { 107 | "description": "Changes to documentation" 108 | }, 109 | "feat": { 110 | "description": "A new feature" 111 | }, 112 | "fix": { 113 | "description": "Bug fixes" 114 | }, 115 | "perf": { 116 | "description": "A performance improvement" 117 | }, 118 | "refactor": { 119 | "description": "A code change that neither fixes a bug nor adds a feature" 120 | }, 121 | "test": { 122 | "description": "Adding or correcting tests" 123 | }, 124 | "chore": { 125 | "description": "Other changes that don't modify src or test files" 126 | } 127 | } 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | const base = require("semantic-release-npm-github-publish"); 2 | 3 | module.exports = { 4 | ...base, 5 | branches: ['+([0-9])?(.{+([0-9]),x}).x', 'master', {name: 'next', prerelease: true}, {name: 'beta', prerelease: true}, {name: 'alpha', prerelease: true}], 6 | releaseRules: [], 7 | plugins: [ 8 | ...base.plugins.slice(1), 9 | [ 10 | "@saithodev/semantic-release-backmerge", 11 | { 12 | "branches": [{"from": "master", "to": "beta"}] 13 | } 14 | ] 15 | ] 16 | } -------------------------------------------------------------------------------- /src/builders.spec.tsx: -------------------------------------------------------------------------------- 1 | import { act, render, waitFor } from "@testing-library/react"; 2 | import { createMemoryHistory } from "history"; 3 | import React from "react"; 4 | 5 | import { buildRoutingMachine, viewToMachine } from "./builders"; 6 | import { buildCreateRoute } from "./routing"; 7 | import { XstateTreeHistory } from "./types"; 8 | import { buildRootComponent } from "./xstateTree"; 9 | 10 | describe("xstate-tree builders", () => { 11 | describe("viewToMachine", () => { 12 | it("takes a React view and wraps it in an xstate-tree machine that renders that view", async () => { 13 | const ViewMachine = viewToMachine(() =>
    hello world
    ); 14 | const Root = buildRootComponent(ViewMachine); 15 | 16 | const { getByText } = render(); 17 | 18 | await waitFor(() => getByText("hello world")); 19 | }); 20 | 21 | it("works for Root components", async () => { 22 | const ViewMachine = viewToMachine(() =>
    hello world
    ); 23 | const Root = buildRootComponent(ViewMachine); 24 | const RootMachine = viewToMachine(Root); 25 | const RootView = buildRootComponent(RootMachine); 26 | 27 | const { getByText } = render(); 28 | 29 | await waitFor(() => getByText("hello world")); 30 | }); 31 | }); 32 | 33 | describe("buildRoutingMachine", () => { 34 | const hist: XstateTreeHistory = createMemoryHistory(); 35 | const createRoute = buildCreateRoute(() => hist, "/"); 36 | 37 | it("takes a mapping of routes to machines and returns a machine that invokes those machines when those routes events are broadcast", async () => { 38 | const fooRoute = createRoute.simpleRoute()({ 39 | url: "/foo/", 40 | event: "GO_TO_FOO", 41 | }); 42 | const barRoute = createRoute.simpleRoute()({ 43 | url: "/bar/", 44 | event: "GO_TO_BAR", 45 | }); 46 | 47 | const FooMachine = viewToMachine(() =>
    foo
    ); 48 | const BarMachine = viewToMachine(() =>
    bar
    ); 49 | 50 | const routingMachine = buildRoutingMachine([fooRoute, barRoute], { 51 | GO_TO_FOO: FooMachine, 52 | GO_TO_BAR: BarMachine, 53 | }); 54 | 55 | const Root = buildRootComponent(routingMachine, { 56 | history: hist, 57 | basePath: "/", 58 | routes: [fooRoute, barRoute], 59 | }); 60 | 61 | const { getByText } = render(); 62 | 63 | act(() => fooRoute.navigate()); 64 | await waitFor(() => getByText("foo")); 65 | 66 | act(() => barRoute.navigate()); 67 | await waitFor(() => getByText("bar")); 68 | }); 69 | 70 | it("handles routing events that contain . in them", async () => { 71 | const fooRoute = createRoute.simpleRoute()({ 72 | url: "/foo/", 73 | event: "routing.foo", 74 | }); 75 | const barRoute = createRoute.simpleRoute()({ 76 | url: "/bar/", 77 | event: "routing.bar", 78 | }); 79 | 80 | const FooMachine = viewToMachine(() =>
    foo
    ); 81 | const BarMachine = viewToMachine(() =>
    bar
    ); 82 | 83 | const routingMachine = buildRoutingMachine([fooRoute, barRoute], { 84 | "routing.foo": FooMachine, 85 | "routing.bar": BarMachine, 86 | }); 87 | 88 | const Root = buildRootComponent(routingMachine, { 89 | history: hist, 90 | basePath: "/", 91 | routes: [fooRoute, barRoute], 92 | }); 93 | 94 | const { getByText } = render(); 95 | 96 | act(() => fooRoute.navigate()); 97 | await waitFor(() => getByText("foo")); 98 | 99 | act(() => barRoute.navigate()); 100 | await waitFor(() => getByText("bar")); 101 | }); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /src/builders.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | type EventObject, 4 | type StateMachine, 5 | type AnyStateMachine, 6 | type ContextFrom, 7 | type EventFrom, 8 | type InterpreterFrom, 9 | type AnyFunction, 10 | createMachine, 11 | StateNodeConfig, 12 | } from "xstate"; 13 | 14 | import { AnyRoute } from "./routing"; 15 | import { Slot, singleSlot } from "./slots"; 16 | import { 17 | AnyActions, 18 | AnySelector, 19 | CanHandleEvent, 20 | MatchesFrom, 21 | OutputFromSelector, 22 | V1Selectors as LegacySelectors, 23 | V2BuilderMeta, 24 | ViewProps, 25 | XStateTreeMachineMetaV1, 26 | XstateTreeMachineStateSchemaV1, 27 | XstateTreeMachineStateSchemaV2, 28 | AnyXstateTreeMachine, 29 | } from "./types"; 30 | 31 | /** 32 | * @public 33 | * 34 | * Factory function for selectors. The selectors function is passed three arguments: 35 | * - `ctx` - the current context of the machines state 36 | * - `canHandleEvent` - a function that can be used to determine if the machine can handle a 37 | * given event, by simulating sending the event and seeing if a stat change would happen. 38 | * Handles guards 39 | * - `inState` - equivalent to xstates `state.matches`, allows checking if the machine is in a given state 40 | * 41 | * The resulting selector function has memoization. It will return the same value until the 42 | * machine's state changes or the machine's context changes 43 | * @param machine - The machine to create the selectors for 44 | * @param selectors - The selector function 45 | * @returns The selectors - ready to be passed to {@link buildActions} 46 | * @deprecated use {@link createXStateTreeMachine} instead 47 | */ 48 | export function buildSelectors< 49 | TMachine extends AnyStateMachine, 50 | TSelectors, 51 | TContext = ContextFrom 52 | >( 53 | __machine: TMachine, 54 | selectors: ( 55 | ctx: TContext, 56 | canHandleEvent: CanHandleEvent, 57 | inState: MatchesFrom, 58 | __currentState: never 59 | ) => TSelectors 60 | ): LegacySelectors< 61 | TContext, 62 | EventFrom, 63 | TSelectors, 64 | MatchesFrom 65 | > { 66 | let lastState: never | undefined = undefined; 67 | let lastCachedResult: TSelectors | undefined = undefined; 68 | let lastCtxRef: TContext | undefined = undefined; 69 | 70 | return ( 71 | ctx: TContext, 72 | canHandleEvent: CanHandleEvent, 73 | inState: MatchesFrom, 74 | currentState 75 | ) => { 76 | // Handles caching to ensure stable references to selector results 77 | // Only re-run the selector if 78 | // * The reference to the context object has changed (the context object should never be mutated) 79 | // * The last state we ran the selectors in has changed. This is to ensure `canHandleEvent` and `inState` calls aren't stale 80 | if ( 81 | lastCtxRef === ctx && 82 | lastState === currentState && 83 | lastCachedResult !== undefined 84 | ) { 85 | return lastCachedResult; 86 | } else { 87 | const result = selectors(ctx, canHandleEvent, inState, currentState); 88 | lastCtxRef = ctx; 89 | lastCachedResult = result; 90 | lastState = currentState; 91 | 92 | return result; 93 | } 94 | }; 95 | } 96 | 97 | /** 98 | * @public 99 | * 100 | * Factory function for actions. The actions function is passed two arguments: 101 | * - `send` - the interpreters send function, which can be used to send events to the machine 102 | * - `selectors` - the output of the selectors function from {@link buildSelectors} 103 | * 104 | * The resulting action function will only be called once per invocation of a machine. 105 | * The selectors are passed in as a proxy to always read the latest selector value 106 | * 107 | * @param machine - The machine to create the actions for 108 | * @param selectors - The selectors function 109 | * @param actions - The action function 110 | * @returns The actions function - ready to be passed to {@link buildView} 111 | * @deprecated use {@link createXStateTreeMachine} instead 112 | * */ 113 | export function buildActions< 114 | TMachine extends AnyStateMachine, 115 | TActions, 116 | TSelectors, 117 | TSend = InterpreterFrom["send"] 118 | >( 119 | __machine: TMachine, 120 | __selectors: TSelectors, 121 | actions: (send: TSend, selectors: OutputFromSelector) => TActions 122 | ): (send: TSend, selectors: OutputFromSelector) => TActions { 123 | return actions; 124 | } 125 | 126 | /** 127 | * @public 128 | * 129 | * Factory function for views. The view is passed four props: 130 | * - `slots` - the slots object, which can be used to render the children of the view invoked by the machine 131 | * - `actions` - the output of the actions function from {@link buildActions} 132 | * - `selectors` - the output of the selectors function from {@link buildSelectors} 133 | * - `inState` - equivalent to xstates `state.matches`, allows checking if the machine is in a given state 134 | * 135 | * The resulting view is wrapped in React.memo, it will re-render when the actions or selectors reference changes 136 | * 137 | * @param machine - The machine to create the view for 138 | * @param selectors - The selectors function from {@link buildSelectors} 139 | * @param actions - The actions function from {@link buildActions} 140 | * @param slots - The array of slots that can be rendered by the view 141 | * @param view - The view function 142 | * @returns The React view 143 | * @deprecated use {@link createXStateTreeMachine} instead 144 | */ 145 | export function buildView< 146 | TMachine extends AnyStateMachine, 147 | TEvent extends EventObject, 148 | TActions, 149 | TSelectors extends AnySelector, 150 | TSlots extends readonly Slot[] = [], 151 | TMatches extends AnyFunction = MatchesFrom, 152 | TViewProps = ViewProps< 153 | OutputFromSelector, 154 | TActions, 155 | TSlots, 156 | TMatches 157 | >, 158 | TSend = (send: TEvent) => void 159 | >( 160 | __machine: TMachine, 161 | __selectors: TSelectors, 162 | __actions: ( 163 | send: TSend, 164 | selectors: OutputFromSelector 165 | ) => TActions, 166 | __slots: TSlots, 167 | view: React.ComponentType 168 | ): React.ComponentType { 169 | return React.memo(view) as unknown as React.ComponentType; 170 | } 171 | 172 | /** 173 | * @public 174 | * 175 | * staples xstate machine and xstate-tree metadata together into an xstate-tree machine 176 | * 177 | * @param machine - The machine to staple the selectors/actions/slots/view to 178 | * @param metadata - The xstate-tree metadata to staple to the machine 179 | * @returns The xstate-tree machine, ready to be invoked by other xstate-machines or used with `buildRootComponent` 180 | * @deprecated use {@link createXStateTreeMachine} instead 181 | */ 182 | export function buildXStateTreeMachine< 183 | TMachine extends AnyStateMachine, 184 | TSelectors extends AnySelector, 185 | TActions extends AnyActions 186 | >( 187 | machine: TMachine, 188 | meta: XStateTreeMachineMetaV1 189 | ): StateMachine< 190 | ContextFrom, 191 | XstateTreeMachineStateSchemaV1, 192 | EventFrom, 193 | any, 194 | any, 195 | any, 196 | any 197 | > { 198 | const copiedMeta = { ...meta }; 199 | copiedMeta.xstateTreeMachine = true; 200 | machine.config.meta = { 201 | ...machine.config.meta, 202 | ...copiedMeta, 203 | builderVersion: 1, 204 | }; 205 | machine.meta = { ...machine.meta, ...copiedMeta, builderVersion: 1 }; 206 | 207 | return machine; 208 | } 209 | 210 | /** 211 | * @public 212 | * Creates an xstate-tree machine from an xstate-machine 213 | * 214 | * Accepts an options object defining the selectors/actions/slots and view for the xstate-tree machine 215 | * 216 | * Selectors/slots/actions can be omitted from the options object and will default to 217 | * - actions: an empty object 218 | * - selectors: the context of the machine 219 | * - slots: an empty array 220 | * 221 | * @param machine - The xstate machine to create the xstate-tree machine from 222 | * @param options - the xstate-tree options 223 | */ 224 | export function createXStateTreeMachine< 225 | TMachine extends AnyStateMachine, 226 | TSelectorsOutput = ContextFrom, 227 | TActionsOutput = Record, 228 | TSlots extends readonly Slot[] = [] 229 | >( 230 | machine: TMachine, 231 | options: V2BuilderMeta 232 | ): StateMachine< 233 | ContextFrom, 234 | XstateTreeMachineStateSchemaV2< 235 | TMachine, 236 | TSelectorsOutput, 237 | TActionsOutput, 238 | TSlots 239 | >, 240 | EventFrom, 241 | any, 242 | any, 243 | any, 244 | any 245 | > { 246 | const selectors = options.selectors ?? (({ ctx }) => ctx); 247 | const actions = options.actions ?? (() => ({})); 248 | 249 | const xstateTreeMeta = { 250 | selectors, 251 | actions, 252 | View: options.View, 253 | slots: options.slots ?? [], 254 | }; 255 | machine.meta = { 256 | ...machine.meta, 257 | ...xstateTreeMeta, 258 | builderVersion: 2, 259 | }; 260 | machine.config.meta = { 261 | ...machine.config.meta, 262 | ...xstateTreeMeta, 263 | builderVersion: 2, 264 | }; 265 | 266 | return machine; 267 | } 268 | 269 | /** 270 | * @public 271 | * 272 | * Simple utility builder to aid in integrating existing React views with xstate-tree 273 | * 274 | * @param view - the React view you want to invoke in an xstate machine 275 | * @returns The view wrapped into an xstate-tree machine, ready to be invoked by other xstate machines or used with `buildRootComponent` 276 | */ 277 | export function viewToMachine( 278 | view: (args?: any) => JSX.Element | null 279 | ): AnyXstateTreeMachine { 280 | return createXStateTreeMachine( 281 | createMachine({ 282 | initial: "idle", 283 | states: { idle: {} }, 284 | }), 285 | { 286 | View: view, 287 | } 288 | ); 289 | } 290 | 291 | /** 292 | * @public 293 | * 294 | * Utility to aid in reducing boilerplate of mapping route events to xstate-tree machines 295 | * 296 | * Takes a list of routes and a mapping of route events to xstate-tree machines and returns an xstate-tree machine 297 | * that renders the machines based on the routing events 298 | * 299 | * @param _routes - the array of routes you wish to map to machines 300 | * @param mappings - an object mapping the route events to the machine to invoke 301 | * @returns an xstate-tree machine that will render the right machines based on the routing events 302 | */ 303 | export function buildRoutingMachine( 304 | _routes: TRoutes, 305 | mappings: Record 306 | ): AnyXstateTreeMachine { 307 | /** 308 | * States in xstate can't contain dots, since the states are named after the routing events 309 | * if the routing event contains a dot that will make a state with a dot in it 310 | * this function sanitizes the event name to remove dots and is used for the state names and targets 311 | */ 312 | function sanitizeEventName(event: string) { 313 | return event.replace(/\.([a-zA-Z])/g, (_, letter) => letter.toUpperCase()); 314 | } 315 | 316 | const contentSlot = singleSlot("Content"); 317 | const mappingsToStates = Object.entries( 318 | mappings 319 | ).reduce((acc, [event, _machine]) => { 320 | return { 321 | ...acc, 322 | [sanitizeEventName(event)]: { 323 | invoke: { 324 | src: (_ctx, e) => { 325 | return mappings[e.type as TRoutes[number]["event"]]; 326 | }, 327 | id: contentSlot.getId(), 328 | }, 329 | }, 330 | }; 331 | }, {} as Record>); 332 | 333 | const mappingsToEvents = Object.keys(mappings).reduce( 334 | (acc, event) => ({ 335 | ...acc, 336 | [event]: { 337 | target: `.${sanitizeEventName(event)}`, 338 | }, 339 | }), 340 | {} 341 | ); 342 | const machine = createMachine({ 343 | on: { 344 | ...mappingsToEvents, 345 | }, 346 | initial: "idle", 347 | states: { 348 | idle: {}, 349 | ...mappingsToStates, 350 | }, 351 | }); 352 | 353 | return createXStateTreeMachine(machine, { 354 | slots: [contentSlot], 355 | View: ({ slots }) => { 356 | return ; 357 | }, 358 | }); 359 | } 360 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./builders"; 2 | export * from "./slots"; 3 | export { broadcast, buildRootComponent, onBroadcast } from "./xstateTree"; 4 | export * from "./types"; 5 | export { 6 | buildTestRootComponent, 7 | buildViewProps, 8 | genericSlotsTestingDummy, 9 | slotTestingDummyFactory, 10 | } from "./testingUtilities"; 11 | export { 12 | Link, 13 | type RoutingEvent, 14 | type LinkProps, 15 | type AnyRoute, 16 | type RouteParams, 17 | type RouteQuery, 18 | type RouteArguments, 19 | type Route, 20 | type RouteMeta, 21 | type Routing404Event, 22 | type StyledLink, 23 | type ArgumentsForRoute, 24 | type Params, 25 | type Query, 26 | type Meta, 27 | type SharedMeta, 28 | type RouteArgumentFunctions, 29 | buildCreateRoute, 30 | matchRoute, 31 | useIsRouteActive, 32 | useRouteArgsIfActive, 33 | useActiveRouteEvents, 34 | TestRoutingContext, 35 | useOnRoute, 36 | } from "./routing"; 37 | export { loggingMetaOptions } from "./useService"; 38 | export { lazy } from "./lazy"; 39 | -------------------------------------------------------------------------------- /src/lazy.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, waitFor } from "@testing-library/react"; 2 | import React from "react"; 3 | import { createMachine } from "xstate"; 4 | import "@testing-library/jest-dom"; 5 | 6 | import { 7 | buildActions, 8 | buildSelectors, 9 | buildView, 10 | buildXStateTreeMachine, 11 | } from "./builders"; 12 | import { lazy } from "./lazy"; 13 | import { singleSlot } from "./slots"; 14 | import { buildRootComponent } from "./xstateTree"; 15 | 16 | describe("lazy", () => { 17 | it("wraps a promise in an xstate-tree machine", () => { 18 | const promiseFactory = () => new Promise(() => void 0); 19 | const lazyMachine = lazy(promiseFactory); 20 | 21 | expect(lazyMachine.meta.xstateTreeMachine).toBe(true); 22 | }); 23 | 24 | it("renders null by default when loading", () => { 25 | const promiseFactory = () => new Promise(() => void 0); 26 | const lazyMachine = lazy(promiseFactory); 27 | const Root = buildRootComponent(lazyMachine); 28 | 29 | const { container, rerender } = render(); 30 | rerender(); 31 | 32 | expect(container).toBeEmptyDOMElement(); 33 | }); 34 | 35 | it("renders the loader component specified in the options object", () => { 36 | const promiseFactory = () => new Promise(() => void 0); 37 | const lazyMachine = lazy(promiseFactory, { 38 | Loader: () =>

    loading

    , 39 | }); 40 | const Root = buildRootComponent(lazyMachine); 41 | 42 | const { container, rerender } = render(); 43 | rerender(); 44 | 45 | expect(container.firstChild).toHaveTextContent("loading"); 46 | }); 47 | 48 | it("invokes the xstate-tree machine returned by the promise", async () => { 49 | const machine = createMachine({ 50 | id: "lazy-test", 51 | initial: "idle", 52 | states: { 53 | idle: {}, 54 | }, 55 | }); 56 | const lazySelectors = buildSelectors(machine, (ctx) => ctx); 57 | const lazyActions = buildActions(machine, lazySelectors, () => ({})); 58 | const lazyView = buildView(machine, lazySelectors, lazyActions, [], () => { 59 | return

    loaded

    ; 60 | }); 61 | const lazyMachine = buildXStateTreeMachine(machine, { 62 | actions: lazyActions, 63 | selectors: lazySelectors, 64 | slots: [], 65 | view: lazyView, 66 | }); 67 | const lazyMachinePromise = lazy(() => { 68 | return new Promise((res) => { 69 | setTimeout(() => { 70 | res(lazyMachine); 71 | }); 72 | }); 73 | }); 74 | 75 | const lazyMachineSlot = singleSlot("lazy"); 76 | const rootMachine = createMachine({ 77 | initial: "idle", 78 | states: { 79 | idle: { 80 | invoke: { 81 | id: lazyMachineSlot.getId(), 82 | src: () => { 83 | console.log("root"); 84 | return lazyMachinePromise; 85 | }, 86 | }, 87 | }, 88 | }, 89 | }); 90 | const slots = [lazyMachineSlot]; 91 | const selectors = buildSelectors(rootMachine, (ctx) => ctx); 92 | const actions = buildActions(rootMachine, selectors, () => ({})); 93 | const view = buildView( 94 | rootMachine, 95 | selectors, 96 | actions, 97 | slots, 98 | ({ slots }) => { 99 | return ; 100 | } 101 | ); 102 | 103 | const Root = buildRootComponent( 104 | buildXStateTreeMachine(rootMachine, { 105 | actions, 106 | selectors, 107 | slots, 108 | view, 109 | }) 110 | ); 111 | 112 | const { container } = render(); 113 | 114 | await waitFor(() => expect(container).toHaveTextContent("loaded")); 115 | }); 116 | 117 | it("invokes the xstate-tree machine returned by the promise with the context specified in withContext", async () => { 118 | const machine = createMachine<{ foo: string; baz: string }>({ 119 | id: "lazy-test", 120 | initial: "idle", 121 | context: { 122 | foo: "bar", 123 | baz: "floople", 124 | }, 125 | states: { 126 | idle: {}, 127 | }, 128 | }); 129 | const lazySelectors = buildSelectors(machine, (ctx) => ctx); 130 | const lazyActions = buildActions(machine, lazySelectors, () => ({})); 131 | const lazyView = buildView( 132 | machine, 133 | lazySelectors, 134 | lazyActions, 135 | [], 136 | ({ selectors }) => { 137 | return ( 138 |

    139 | {selectors.foo} 140 | {selectors.baz} 141 |

    142 | ); 143 | } 144 | ); 145 | const lazyMachine = buildXStateTreeMachine(machine, { 146 | actions: lazyActions, 147 | selectors: lazySelectors, 148 | slots: [], 149 | view: lazyView, 150 | }); 151 | const lazyMachinePromise = lazy( 152 | () => { 153 | return new Promise((res) => { 154 | setTimeout(() => { 155 | res(lazyMachine); 156 | }); 157 | }); 158 | }, 159 | { 160 | withContext: () => ({ 161 | foo: "qux", 162 | }), 163 | } 164 | ); 165 | 166 | const lazyMachineSlot = singleSlot("lazy"); 167 | const rootMachine = createMachine({ 168 | initial: "idle", 169 | states: { 170 | idle: { 171 | invoke: { 172 | id: lazyMachineSlot.getId(), 173 | src: () => { 174 | return lazyMachinePromise; 175 | }, 176 | }, 177 | }, 178 | }, 179 | }); 180 | const slots = [lazyMachineSlot]; 181 | const selectors = buildSelectors(rootMachine, (ctx) => ctx); 182 | const actions = buildActions(rootMachine, selectors, () => ({})); 183 | const view = buildView( 184 | rootMachine, 185 | selectors, 186 | actions, 187 | slots, 188 | ({ slots }) => { 189 | return ; 190 | } 191 | ); 192 | 193 | const Root = buildRootComponent( 194 | buildXStateTreeMachine(rootMachine, { 195 | actions, 196 | selectors, 197 | slots, 198 | view, 199 | }) 200 | ); 201 | 202 | const { container } = render(); 203 | 204 | await waitFor(() => expect(container).toHaveTextContent("quxfloople")); 205 | }); 206 | }); 207 | -------------------------------------------------------------------------------- /src/lazy.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | AnyStateMachine, 4 | createMachine, 5 | DoneEvent, 6 | StateMachine, 7 | } from "xstate"; 8 | 9 | import { 10 | buildActions, 11 | buildSelectors, 12 | buildView, 13 | buildXStateTreeMachine, 14 | } from "./builders"; 15 | import { singleSlot } from "./slots"; 16 | 17 | type Context = {}; 18 | type Events = any; 19 | type States = 20 | | { value: "loading"; context: Context } 21 | | { value: "rendering"; context: Context }; 22 | 23 | type Options = { 24 | /** 25 | * Displayed while the promise is resolving, defaults to returning null 26 | */ 27 | Loader?: React.ComponentType; 28 | /** 29 | * Allows you to specify an overriden context when the machine is invoked 30 | * Automatically supplies the machines default context so only requires a partial of overrides 31 | */ 32 | withContext?: () => Partial; 33 | }; 34 | /** 35 | * @public 36 | * 37 | * Wraps an xstate-tree returning Promise (generated by `import()` in an xstate-tree machine responsible for 38 | * booting up the machine upon resolution 39 | * 40 | * @param factory - the factory function that returns the promise that resolves to the machine 41 | * @param options - configure loading component and context to invoke machine with 42 | * @returns an xstate-tree machine that wraps the promise, invoking the resulting machine when it resolves 43 | */ 44 | export function lazy( 45 | factory: () => Promise, 46 | { 47 | Loader = () => null, 48 | withContext = () => ({}), 49 | }: Options = {} 50 | ): StateMachine { 51 | const loadedMachineSlot = singleSlot("loadedMachine"); 52 | const slots = [loadedMachineSlot]; 53 | const machine = createMachine({ 54 | initial: "loading", 55 | states: { 56 | loading: { 57 | invoke: { 58 | src: () => factory, 59 | onDone: "rendering", 60 | }, 61 | }, 62 | rendering: { 63 | invoke: { 64 | id: loadedMachineSlot.getId(), 65 | src: (_ctx, e: DoneEvent) => { 66 | return e.data.withContext({ ...e.data.context, ...withContext() }); 67 | }, 68 | }, 69 | }, 70 | }, 71 | }); 72 | 73 | const selectors = buildSelectors(machine, (ctx) => ctx); 74 | const actions = buildActions(machine, selectors, (_send, _selectors) => {}); 75 | const view = buildView( 76 | machine, 77 | selectors, 78 | actions, 79 | slots, 80 | ({ slots, inState }) => { 81 | if (inState("loading")) { 82 | return ; 83 | } 84 | 85 | return ; 86 | } 87 | ); 88 | 89 | return buildXStateTreeMachine(machine, { 90 | actions, 91 | selectors, 92 | slots, 93 | view, 94 | }); 95 | } 96 | -------------------------------------------------------------------------------- /src/routing/Link.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import { createMemoryHistory } from "history"; 4 | import React from "react"; 5 | import { z } from "zod"; 6 | 7 | import { delay } from "../utils"; 8 | 9 | import { Link } from "./Link"; 10 | import { buildCreateRoute } from "./createRoute"; 11 | 12 | const hist = createMemoryHistory<{ meta?: unknown }>(); 13 | const createRoute = buildCreateRoute(() => hist, "/"); 14 | 15 | const route = createRoute.simpleRoute()({ 16 | event: "event", 17 | url: "/url/:param", 18 | paramsSchema: z.object({ param: z.string() }), 19 | }); 20 | 21 | describe("Link", () => { 22 | describe("preloading", () => { 23 | it("calls the route preload on mouseDown if preloadOnInteraction is true", async () => { 24 | route.preload = jest.fn(); 25 | 26 | const { getByText, rerender } = render( 27 | 28 | Link 29 | 30 | ); 31 | 32 | await userEvent.click(getByText("Link")); 33 | expect(route.preload).not.toHaveBeenCalled(); 34 | 35 | rerender( 36 | 37 | Link 38 | 39 | ); 40 | 41 | await userEvent.click(getByText("Link")); 42 | expect(route.preload).toHaveBeenCalledWith({ params: { param: "test" } }); 43 | }); 44 | 45 | it("calls the route preload on hover if preloadOnHoverMs is set", async () => { 46 | route.preload = jest.fn(); 47 | 48 | const { getByText, rerender } = render( 49 | 50 | Link 51 | 52 | ); 53 | 54 | await userEvent.hover(getByText("Link")); 55 | expect(route.preload).not.toHaveBeenCalled(); 56 | 57 | rerender( 58 | 59 | Link 60 | 61 | ); 62 | 63 | await userEvent.hover(getByText("Link")); 64 | await delay(0); 65 | expect(route.preload).toHaveBeenCalledWith({ params: { param: "test" } }); 66 | }); 67 | 68 | it("does not call the preload if the element isn't hovered for long enough", async () => { 69 | route.preload = jest.fn(); 70 | 71 | const { getByText } = render( 72 | 73 | Link 74 | 75 | ); 76 | 77 | await userEvent.hover(getByText("Link")); 78 | await delay(2); 79 | await userEvent.unhover(getByText("Link")); 80 | 81 | await delay(50); 82 | expect(route.preload).not.toHaveBeenCalled(); 83 | }); 84 | 85 | it("calls user supplied onMouse Down/Enter/Leave when preloading is not active", async () => { 86 | const onMouseDown = jest.fn(); 87 | const onMouseEnter = jest.fn(); 88 | const onMouseLeave = jest.fn(); 89 | 90 | const { getByText } = render( 91 | 98 | Link 99 | 100 | ); 101 | 102 | await userEvent.hover(getByText("Link")); 103 | await userEvent.click(getByText("Link")); 104 | await userEvent.unhover(getByText("Link")); 105 | 106 | expect(onMouseDown).toHaveBeenCalledTimes(1); 107 | expect(onMouseEnter).toHaveBeenCalledTimes(2); 108 | expect(onMouseLeave).toHaveBeenCalledTimes(1); 109 | }); 110 | 111 | it("calls user supplied onMouse Down/Enter/Leave when preloading is active", async () => { 112 | const onMouseDown = jest.fn(); 113 | const onMouseEnter = jest.fn(); 114 | const onMouseLeave = jest.fn(); 115 | route.preload = jest.fn(); 116 | 117 | const { getByText } = render( 118 | 127 | Link 128 | 129 | ); 130 | 131 | await userEvent.hover(getByText("Link")); 132 | await userEvent.click(getByText("Link")); 133 | await userEvent.unhover(getByText("Link")); 134 | 135 | expect(onMouseDown).toHaveBeenCalledTimes(1); 136 | expect(onMouseEnter).toHaveBeenCalledTimes(2); 137 | expect(onMouseLeave).toHaveBeenCalledTimes(1); 138 | expect(route.preload).toHaveBeenCalledTimes(3); 139 | }); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /src/routing/Link.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from "react"; 2 | 3 | import { AnyRoute, Route, RouteArguments } from "./createRoute"; 4 | import { useHref } from "./useHref"; 5 | 6 | /** 7 | * @public 8 | */ 9 | export type StyledLink = ( 10 | props: LinkProps & TStyleProps 11 | ) => JSX.Element; 12 | 13 | /** 14 | * @public 15 | */ 16 | export type LinkProps< 17 | TRoute extends AnyRoute, 18 | TRouteParams = TRoute extends Route 19 | ? TParams 20 | : undefined, 21 | TRouteQuery = TRoute extends Route 22 | ? TQuery 23 | : undefined, 24 | TRouteMeta = TRoute extends Route 25 | ? TMeta 26 | : undefined 27 | > = { 28 | to: TRoute; 29 | children: React.ReactNode; 30 | testId?: string; 31 | 32 | /** 33 | * onClick works as normal, but if you return false from it the navigation will not happen 34 | */ 35 | onClick?: (e: React.MouseEvent) => boolean | void; 36 | preloadOnInteraction?: boolean; 37 | preloadOnHoverMs?: number; 38 | } & RouteArguments & 39 | Omit, "href" | "onClick">; 40 | 41 | function LinkInner( 42 | { 43 | to, 44 | children, 45 | testId, 46 | preloadOnHoverMs, 47 | preloadOnInteraction, 48 | onMouseDown: _onMouseDown, 49 | onMouseEnter: _onMouseEnter, 50 | onMouseLeave: _onMouseLeave, 51 | ...rest 52 | }: LinkProps, 53 | ref: React.ForwardedRef 54 | ) { 55 | // @ts-ignore, these fields _might_ exist, so typechecking doesn't believe they exist 56 | // and everything that consumes params/query already checks for undefined 57 | const { params, query, meta, ...props } = rest; 58 | 59 | let timeout: ReturnType | undefined; 60 | const href = useHref(to, params, query); 61 | const onMouseDown: React.MouseEventHandler | undefined = 62 | preloadOnInteraction 63 | ? (e) => { 64 | _onMouseDown?.(e); 65 | 66 | to.preload({ params, query, meta }); 67 | } 68 | : undefined; 69 | const onMouseEnter: React.MouseEventHandler | undefined = 70 | preloadOnHoverMs !== undefined 71 | ? (e) => { 72 | _onMouseEnter?.(e); 73 | 74 | timeout = setTimeout(() => { 75 | to.preload({ params, query, meta }); 76 | }, preloadOnHoverMs); 77 | } 78 | : undefined; 79 | const onMouseLeave: React.MouseEventHandler | undefined = 80 | preloadOnHoverMs !== undefined 81 | ? (e) => { 82 | _onMouseLeave?.(e); 83 | 84 | if (timeout !== undefined) { 85 | clearTimeout(timeout); 86 | } 87 | } 88 | : undefined; 89 | 90 | return ( 91 | { 101 | if (props.onClick?.(e) === false) { 102 | return; 103 | } 104 | 105 | // Holding the Command key on Mac or the Control Key on Windows while clicking the link will open a new tab/window 106 | // TODO: add global callback to prevent this 107 | if (e.metaKey || e.ctrlKey) { 108 | return; 109 | } 110 | 111 | e.preventDefault(); 112 | to.navigate({ params, query, meta }); 113 | }} 114 | > 115 | {children} 116 | 117 | ); 118 | } 119 | 120 | /** 121 | * @public 122 | * 123 | * Renders an anchor tag pointing at the provided Route 124 | * 125 | * The query/params/meta props are conditionally required based on the 126 | * route passed as the To parameter 127 | */ 128 | export const Link = forwardRef(LinkInner) as ( 129 | props: LinkProps & { ref?: React.ForwardedRef } 130 | ) => ReturnType; 131 | -------------------------------------------------------------------------------- /src/routing/README.md: -------------------------------------------------------------------------------- 1 | # xstate-tree routing, how does it work? 2 | 3 | Since xstate-tree is designed around a hierarchical tree of machines, routing can't function similar to how react-router works 4 | 5 | Instead, routing is based around the construction of route objects representing specific urls. Route objects can be composed together to create hierarchies, designed to mimic the xstate-tree machine hierarchy, but not required to match the actual hierarchy of machines 6 | 7 | ## Constructing routes 8 | 9 | First you must build a `createRoute` function, this can be done by calling `buildCreateRoute` exported from xstate-tree. `buildCreateRoute` takes two arguments, a history object and a basePath. These arguments are then stapled to any routes created by the returned `createRoute` function from `buildCreateRoute` so the routes can make use of them. These arguments must match the history and basePath arguments provided to `buildRootComponent` 10 | 11 | To construct a Route object you can use the `route` function or the `simpleRoute` function. Both are "curried" functions, meaning they return a function when called which requires more arguments. The argument to both is an optional parent route, the argument to the second function is the route options object. 12 | 13 | ### route 14 | 15 | A route gives you full control over the matching and reversing behavior of a route. It's up to you to supply a `matcher` and `reverser` function which control this. The matcher function is supplied the url to match as well as the query string parsed into an object. It then returns either false to indicate no match, or an object containing the extracted params/query data as well as a `matchLength` property which indicates how much of the URL was consumed by this matcher. The passed in URL will always be normalized to start with `/` and end with `/` 16 | 17 | The match length is required because matching nested routes start from the highest parent route and matches from parent -> child until either a route doesn't match, or the entire URL is consumed, the route does not match if there is any URL left unconsumed. This also means that routes can't attempt to match the full URL if they are a parent route, ie no using regexes with `$` anchors. If matching the URL with a regex the `matchLength` will be `match[0].length` where `match` is the result of `regex.exec(url)` 18 | 19 | The `reverser` function is supplied an object containing `params` if the route defines them, and `query` if the route defines them. `query` can be undefined even if the route provides them because they are only passed to the reverser function for the actual route being reversed, not for any parent routes. The reverser function returns a url representing the given params/query combination. 20 | 21 | The other arguments are the event, paramsSchema, querySchema and meta type. 22 | 23 | ### simpleRoute 24 | 25 | Simple route is built on top of `route`, for when you aren't interested in full control of the matcher and reverser functions. It takes the same arguments as `route`, without `matcher`/`reverser` and with an additional `url`. The `url` is a string parsed by [path-to-regexp](https://www.npmjs.com/package/path-to-regexp) to generate the `matcher`/`reverser` functions automatically. Simple routes can be composed with normal routes. 26 | 27 | 28 | Examples 29 | 30 | ```typescript 31 | // In practice you would always use a simpleRoute for this, this is just to show how `route` works 32 | const parentRoute = createRoute.route()({ 33 | event: "GO_FOO", 34 | matcher: (url) => { 35 | if (url === "/foo/") { 36 | return { 37 | matchLength: 5, 38 | }; 39 | } 40 | 41 | return false; 42 | }, 43 | reverser: () => "/foo/", 44 | }); 45 | const childRoute = createRoute.simpleRoute(parentRoute)({ 46 | url: "/bar/:barId", 47 | event: "GO_BAR", 48 | paramsSchema: Z.object({ 49 | barId: Z.string() 50 | }) 51 | }); 52 | 53 | const routeWithMeta = createRoute.simpleRoute()({ 54 | url: "/whatever", 55 | event: "GO_WHATEVER", 56 | meta: {} as { metaField: string } 57 | }); 58 | ``` 59 | 60 | The parent route does not extend another route so the first function call takes no arguments, it does not define any params as part of the URL or consume any query string arguments so it does not require any arguments 61 | 62 | The child route extends the parent route, adding it as an argument to the first function call, and defines params as part of the URL so has a params schema defined with Zod 63 | 64 | Since the child route composes with the parent route the resulting URL that it will match against is actually /foo/bar/123. If the parent route had defined a params schema or a meta type, those would also have been composed with the the routes params schema/meta type 65 | 66 | 67 | ### Redirects 68 | 69 | Routes (both route and simpleRoute) can define an async redirect function. This function is called whenever a route is matched, for all routes in the routing chain. The function is called with the params/query/meta object that the route was originally matched with and you can return a new set of params and/or query objects to perform a redirect. If you return `undefined` no redirect will be performed. You may also navigate to a different route inside this function. 70 | 71 | The results of calling the redirect functions is merged with the original params/query/meta objects, from top to bottom (so if two routes override the same param, the one from the parent route will be overwritten). 72 | 73 | If the URL is updated while the async redirect function is running then the redirect will be aborted and the redirect will be ignored. An AbortSignal is passed to the redirect functions to enable hooking this into any async processes you may be running. 74 | 75 | ```typescript 76 | const parentRoute = createRoute.simpleRoute()({ 77 | url: "/foo/:bar", 78 | event: "GO_FOO", 79 | paramsSchema: Z.object({ 80 | bar: Z.string() 81 | }), 82 | redirect: async ({ params }) => { 83 | if (params.bar === "123") { 84 | return { 85 | params: { 86 | bar: "456" 87 | } 88 | }; 89 | } 90 | } 91 | }); 92 | const childRoute = createRoute.simpleRoute(parentRoute)({ 93 | url: "/baz/:qux", 94 | event: "GO_BAR", 95 | paramsSchema: Z.object({ 96 | qux: Z.string() 97 | }), 98 | redirect: async ({ params }) => { 99 | if (params.qux === "789") { 100 | return { 101 | params: { 102 | bar: "123", 103 | qux: "012" 104 | } 105 | }; 106 | } 107 | } 108 | }); 109 | ``` 110 | 111 | So if the URL is /foo/123/baz/789, the redirect functions will be called in the following order: 112 | 1. parentRoute with { params: { bar: "123" } } 113 | 2. childRoute with { params: { qux: "789" } } 114 | 115 | Since parentRoute returns a redirect to { bar: "456" } but the child route returns a redirect to { bar: "123", qux: "012" } the final params will be { bar: "123", qux: "012" } because the child route overrode the parent route's redirect 116 | 117 | ### What is the "meta" type? 118 | 119 | When you call history.pushState you can also supply "state" data, this is stored in the history stack. When a popstate event is fired it contains the "state" of that history entry that was stored with pushState, but this state is not actually part of the URL. It's just additional data we can attach to a history entry 120 | 121 | What this allows for is attaching "enriched" data to a routing event that isn't required for the route to function, since it won't be present during the initial page load, but can be used by handlers of the route to access extra data for some purpose. 122 | 123 | The meta field on a routing event (if it has a meta type defined) is optional, but will be defined if the route event was broadcast with meta (ie if you attached meta to a Link route) or from a popstate event where it extracts the state associated with the history and attaches it to the meta property of the event 124 | 125 | This is used in browse-data when opening a datasheet to pass the dataset the sheet is opening along with the routing event so it doesn't require the datasheet to load the dataset again. On the initial page load where meta won't be set for that route the dataset sheet fetches the dataset from the API 126 | 127 | Because the meta information is attached from popstate events it means that if the user closes the datasheet and then presses the back button, opening the datasheet again, the dataset is already loaded from the history state instead of having to fetch it from the API again. 128 | 129 | ## Using routes 130 | 131 | The two most common usages of routes are using it with the `Link` component or creating a navigation function with `useRouteNavigator` 132 | 133 | The `Link` component accepts a Route in the `to` prop and then requires query/params/meta props as per that route. It renders an `a` tag pointing at the full URL of the route (relative to the configured base path). Any props an `a` tag accepts can be used, barring `onClick` and `href` 134 | 135 | `useRouteNavigator` takes a Route as the only argument and returns a function that can be called with params/query/meta objects and navigates to the URL for the route when called 136 | 137 | There are a couple other functions on them like `reverse`, `navigate`, `getEvent` and `matches` but those are primarily for internal use 138 | 139 | ## What happens when navigating to a route? 140 | 141 | When the page loads or when the url is updated xstate-tree takes the URL and query string (if it exists) and iterates through the list of routes it knows about (how it knows we will get to after this) looking for a route that matches the current URL 142 | 143 | ### A matching route is found 144 | 145 | 1. Collect all of the routes parent routes into an array 146 | 2. Iterates through that array generating events from those routes based on the current URL params/query 147 | 3. broadcasts the events for those routes in reverse order, ie the topmost route -> its child -> its child -> the route that was matched 148 | 4. Stores the routing events that were just broadcast 149 | 5. When a new child machine is invoked it gets sent every routing event (in the same order) that it has a handler for 150 | 151 | It is done this way so that you don't need to have handlers at every layer of the machine tree handling every routing event that a sub machine might route to. 152 | 153 | How this works in practice is like so, given the following routes 154 | 155 | ```typescript 156 | const topRoute = createRoute.simpleRoute()({ url: "/foo", event: "GO_FOO" }); 157 | const middleRoute = createRoute.staticRoute(topRoute)({ url: "/bar", event: "GO_BAR" }); 158 | const bottomRoute = createRoute.staticRoute(middleRoute)({ url: "/qux", event: "GO_QUX" }); 159 | ``` 160 | 161 | if you were to load up the URL `/foo/bar/qux` which is matched by the `bottomRoute` the following happens 162 | 163 | broadcast GO_FOO 164 | broadcast GO_BAR 165 | broadcast GO_QUX 166 | 167 | Assuming you have a hierarchy of four machines, with the root invoking the top, which invokes the middle which invokes the bottom, you design it as so 168 | 169 | root machine -> GO_FOO -> invokes top machine 170 | top machine -> GO_BAR -> invokes middle machine 171 | middle machine -> GO_QUX -> invokes bottom machine 172 | 173 | That way the root machine doesn't need to have a handler for all three of GO_FOO/GO_BAR/GO_QUX events causing it to invoke the top machine 174 | 175 | If you were already on the `/foo/bar/qux` url and navigated to the `/foo/bar` url then GO_FOO and GO_BAR would be broadcast 176 | 177 | ### No matching route is found 178 | 179 | If it does not find a matching route, either because no routes matched, or because the matching route threw an error parsing the query/params schema it logs an error message currently 180 | 181 | 404 and routing "errors" don't currently have any way to handle them, this will be worked on when it is needed (soon?) 182 | 183 | ## Adding routes to an xstate-tree root machine 184 | 185 | `buildRootComponent` takes a 2nd optional routing configuration object. This object requires you to specify an array of routes (routes are matched in the order they are in the array), a history object, and a basePath. 186 | 187 | The routes should be fairly self explanatory, export a routes array from the routes definition file and ensure the routes are in the right order for matching. 188 | 189 | The history object must be a shared history object for the project, in Matais case that is the `~matai-history` import. It is important that the same history object is used in Matai as it is also used in React router 190 | 191 | The basePath is prepended to route links and stripped from routing events when they come in. This should be set to the URL that the root machine is going to be rendered at. In Matai React router will handle navigation to that URL and any sub URLs are handled by xstate-tree routing 192 | 193 | There are two optional arguments that won't be needed in Matai but will be needed in Rimu, `getPathName` and `getQueryString` which are functions that return pathname/query string respectively. 194 | 195 | These by default return window.location.pathname and window.location.search which is fine for Matai but won't work in Rimu 196 | 197 | ### A full example 198 | 199 | ```typescript 200 | const home = createRoute.simpleRoute()({ url: "/", event: "GO_HOME" }); 201 | const products = createRoute.simpleRoute()({ url: "/products", event: "GO_PRODUCTS" }); 202 | const product = createRoute.simpleRoute(products)({ 203 | url: "/:productId(\\d+)", 204 | event: "GO_PRODUCT", 205 | paramsSchema: Z.object({ 206 | // All params come in as strings, but this actually represents a number so transform it into one 207 | productId: Z.string().transform((id) => parseInt(id, 10)) 208 | }) 209 | }); 210 | 211 | // Routes only match exact URLs, so '/' won't match '/products', unlike react-router 212 | const routes = [home, products, product]; 213 | 214 | const machine = createMachine({ 215 | initial: "home", 216 | on: { 217 | GO_HOME: "home", 218 | GO_PRODUCTS: "products", 219 | }, 220 | states: { 221 | home: {}, 222 | products: { 223 | on: { 224 | GO_PRODUCTS: ".product" 225 | }, 226 | initial: "index", 227 | states: { 228 | index: {}, 229 | product: {} 230 | } 231 | } 232 | } 233 | }); 234 | 235 | const App = buildXstateTreeMachine(machine, { 236 | selectors: ..., 237 | actions: ..., 238 | view: ..., 239 | }); 240 | 241 | const history = createBrowserHistory(); 242 | export const Root = buildRootComponent(App, { 243 | routes, 244 | history, 245 | basePath: "/" 246 | }); 247 | ``` -------------------------------------------------------------------------------- /src/routing/createRoute/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | type Route, 3 | type AnyRoute, 4 | buildCreateRoute, 5 | type RouteArguments, 6 | type RouteParams, 7 | type RouteMeta, 8 | type RouteQuery, 9 | type ArgumentsForRoute, 10 | type Params, 11 | type Query, 12 | type Meta, 13 | type SharedMeta, 14 | type RouteArgumentFunctions, 15 | } from "./createRoute"; 16 | -------------------------------------------------------------------------------- /src/routing/handleLocationChange/handleLocationChange.spec.ts: -------------------------------------------------------------------------------- 1 | import { createMemoryHistory } from "history"; 2 | import { z } from "zod"; 3 | 4 | import { delay } from "../../utils"; 5 | import { onBroadcast } from "../../xstateTree"; 6 | import { buildCreateRoute } from "../createRoute"; 7 | import { RoutingEvent } from "../routingEvent"; 8 | 9 | import { handleLocationChange, Routing404Event } from "./handleLocationChange"; 10 | 11 | const hist = createMemoryHistory<{ meta?: unknown }>(); 12 | const createRoute = buildCreateRoute(() => hist, "/"); 13 | const foo = createRoute.simpleRoute()({ url: "/foo", event: "GO_FOO" }); 14 | const bar = createRoute.simpleRoute(foo)({ url: "/bar", event: "GO_BAR" }); 15 | const routes = [foo, bar]; 16 | describe("handleLocationChange", () => { 17 | const broadcastEvents: any[] = []; 18 | let unsub: () => void = () => void 0; 19 | 20 | beforeEach(() => { 21 | broadcastEvents.splice(0); 22 | unsub = onBroadcast((e) => broadcastEvents.push(e)); 23 | }); 24 | afterEach(() => { 25 | unsub(); 26 | }); 27 | 28 | it("broadcasts a 404 routing event if no matching route is found", () => { 29 | handleLocationChange(routes, "/", "/qux", "", () => void 0); 30 | const fourOhFour: Routing404Event = { 31 | type: "ROUTING_404", 32 | url: "/qux", 33 | }; 34 | 35 | expect(broadcastEvents).toEqual([fourOhFour]); 36 | }); 37 | 38 | it("broadcasts the matched routes event for a route with no parents", async () => { 39 | handleLocationChange(routes, "/", "/foo", "", () => void 0); 40 | const fooEvent: RoutingEvent = { 41 | type: "GO_FOO", 42 | meta: { indexEvent: true }, 43 | originalUrl: "/foo/", 44 | // The typings say this is undefined, when in actuality it's an empty object 45 | // too annoying to fix, only relevant in tests like this 46 | params: {} as any, 47 | query: {} as any, 48 | }; 49 | 50 | await delay(1); 51 | expect(broadcastEvents).toEqual([fooEvent]); 52 | }); 53 | 54 | it("calls the route preload function if present", () => { 55 | const preload = jest.fn(); 56 | const foo = createRoute.simpleRoute()({ 57 | url: "/foo/:bar", 58 | event: "GO_FOO", 59 | paramsSchema: z.object({ bar: z.string() }), 60 | preload, 61 | }); 62 | 63 | handleLocationChange([foo], "/", "/foo/bar", "", () => void 0); 64 | 65 | expect(preload).toHaveBeenCalledWith({ 66 | params: { bar: "bar" }, 67 | query: {}, 68 | meta: { 69 | indexEvent: true, 70 | }, 71 | }); 72 | }); 73 | 74 | describe("route with parent route", () => { 75 | it("broadcasts an event for both the child and parent routes, in parent -> child order, with only the child event marked as an index event", async () => { 76 | handleLocationChange(routes, "/", "/foo/bar", "", () => void 0); 77 | const barEvent: RoutingEvent = { 78 | type: "GO_BAR", 79 | meta: { indexEvent: true }, 80 | originalUrl: "/foo/bar/", 81 | // The typings say this is undefined, when in actuality it's an empty object 82 | // too annoying to fix, only relevant in tests like this 83 | params: {} as any, 84 | query: {} as any, 85 | }; 86 | const fooEvent: RoutingEvent = { 87 | type: "GO_FOO", 88 | meta: {}, 89 | originalUrl: "/foo/bar/", 90 | params: {} as any, 91 | query: {} as any, 92 | }; 93 | 94 | await delay(1); 95 | expect(broadcastEvents).toEqual([fooEvent, barEvent]); 96 | }); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /src/routing/handleLocationChange/handleLocationChange.ts: -------------------------------------------------------------------------------- 1 | import { broadcast } from "../../xstateTree"; 2 | import { AnyRoute } from "../createRoute"; 3 | import { matchRoute } from "../matchRoute"; 4 | import { RoutingEvent } from "../routingEvent"; 5 | 6 | /** 7 | * @public 8 | */ 9 | export type Routing404Event = { 10 | type: "ROUTING_404"; 11 | url: string; 12 | }; 13 | 14 | /** 15 | * @internal 16 | */ 17 | export function handleLocationChange( 18 | routes: AnyRoute[], 19 | basePath: string, 20 | path: string, 21 | search: string, 22 | meta?: Record 23 | ): { events: RoutingEvent[]; matchedRoute: AnyRoute } | undefined { 24 | console.debug("[xstate-tree] Matching routes", basePath, path, search, meta); 25 | const match = matchRoute(routes, basePath, path, search); 26 | 27 | if (match.type === "no-matches") { 28 | const fourOhFour: Routing404Event = { 29 | type: "ROUTING_404", 30 | url: path, 31 | }; 32 | 33 | // @ts-ignore the event won't match GlobalEvents 34 | broadcast(fourOhFour); 35 | return; 36 | } else if (match.type === "match-error") { 37 | console.error("Error matching route for", location.pathname, match.error); 38 | return; 39 | } else { 40 | console.log("[xstate-tree] matched route", match.event); 41 | const matchedEvent = match.event; 42 | matchedEvent.meta = { ...(meta ?? {}) }; 43 | (matchedEvent.meta as Record).indexEvent = true; 44 | const { params, query } = match.event; 45 | 46 | const routingEvents: any[] = []; 47 | 48 | let route: AnyRoute = match.route; 49 | route.preload({ params, query, meta: matchedEvent.meta }); 50 | while (route.parent) { 51 | routingEvents.push( 52 | route.parent.getEvent({ params, query: {}, meta: { ...(meta ?? {}) } }) 53 | ); 54 | route = route.parent; 55 | } 56 | 57 | const clonedRoutingEvents = [...routingEvents]; 58 | while (routingEvents.length > 0) { 59 | const event = routingEvents.pop()!; 60 | // copy the originalUrl to all parent events 61 | event.originalUrl = match.event.originalUrl; 62 | 63 | // @ts-ignore the event won't match GlobalEvents 64 | broadcast(event); 65 | } 66 | 67 | // @ts-ignore the event won't match GlobalEvents 68 | broadcast(matchedEvent); 69 | 70 | return { 71 | events: [...clonedRoutingEvents, match.event], 72 | matchedRoute: match.route, 73 | }; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/routing/handleLocationChange/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | handleLocationChange, 3 | type Routing404Event, 4 | } from "./handleLocationChange"; 5 | -------------------------------------------------------------------------------- /src/routing/index.ts: -------------------------------------------------------------------------------- 1 | export type { RoutingEvent } from "./routingEvent"; 2 | export { 3 | type Route, 4 | type AnyRoute, 5 | type RouteParams, 6 | type RouteMeta, 7 | type RouteQuery, 8 | type RouteArguments, 9 | type ArgumentsForRoute, 10 | type Params, 11 | type Query, 12 | type Meta, 13 | type SharedMeta, 14 | type RouteArgumentFunctions, 15 | buildCreateRoute, 16 | } from "./createRoute"; 17 | export { joinRoutes } from "./joinRoutes"; 18 | export { Link, type LinkProps, type StyledLink } from "./Link"; 19 | export { matchRoute } from "./matchRoute"; 20 | export { 21 | handleLocationChange, 22 | type Routing404Event, 23 | } from "./handleLocationChange"; 24 | export { useIsRouteActive } from "./useIsRouteActive"; 25 | export { useRouteArgsIfActive } from "./useRouteArgsIfActive"; 26 | export { useOnRoute } from "./useOnRoute"; 27 | 28 | export { 29 | RoutingContext, 30 | TestRoutingContext, 31 | useInRoutingContext, 32 | useInTestRoutingContext, 33 | useActiveRouteEvents, 34 | } from "./providers"; 35 | -------------------------------------------------------------------------------- /src/routing/joinRoutes.spec.ts: -------------------------------------------------------------------------------- 1 | import { joinRoutes } from "./joinRoutes"; 2 | 3 | describe("joinRoutes", () => { 4 | it("joins the two routes together", () => { 5 | expect(joinRoutes("/foo", "/bar")).toBe("/foo/bar/"); 6 | }); 7 | 8 | it("handles base routes that end with a slash", () => { 9 | expect(joinRoutes("/foo/", "/bar")).toBe("/foo/bar/"); 10 | }); 11 | 12 | it("handles routes that do not begin with a slash", () => { 13 | expect(joinRoutes("/foo", "bar")).toBe("/foo/bar/"); 14 | }); 15 | 16 | it("handles baseRoutes that end with slash and routes that start with slash", () => { 17 | expect(joinRoutes("/foo/", "/bar")).toBe("/foo/bar/"); 18 | }); 19 | 20 | it("does not append a / if the url has a query string", () => { 21 | expect(joinRoutes("/foo", "/bar?baz=1")).toBe("/foo/bar/?baz=1"); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/routing/joinRoutes.ts: -------------------------------------------------------------------------------- 1 | export function joinRoutes(base: string, route: string): string { 2 | const realBase = base.endsWith("/") ? base.slice(0, -1) : base; 3 | const realRoute = route.startsWith("/") ? route : `/${route}`; 4 | 5 | const joinedUrl = realBase + realRoute; 6 | if (!joinedUrl.endsWith("/")) { 7 | if (!joinedUrl.includes("?")) { 8 | return `${joinedUrl}/`; 9 | } 10 | 11 | if (!joinedUrl.includes("/?")) { 12 | return joinedUrl.replace("?", "/?"); 13 | } 14 | 15 | return joinedUrl; 16 | } 17 | 18 | return joinedUrl; 19 | } 20 | -------------------------------------------------------------------------------- /src/routing/matchRoute/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2 4 | } -------------------------------------------------------------------------------- /src/routing/matchRoute/index.ts: -------------------------------------------------------------------------------- 1 | export { matchRoute } from "./matchRoute"; 2 | -------------------------------------------------------------------------------- /src/routing/matchRoute/matchRoute.spec.ts: -------------------------------------------------------------------------------- 1 | import { createMemoryHistory } from "history"; 2 | import * as Z from "zod"; 3 | 4 | import { buildCreateRoute } from "../createRoute"; 5 | 6 | import { matchRoute } from "./matchRoute"; 7 | 8 | const hist = createMemoryHistory<{ meta?: unknown }>(); 9 | const createRoute = buildCreateRoute(() => hist, "/"); 10 | describe("matchRoute", () => { 11 | const route1 = createRoute.simpleRoute()({ url: "/route1", event: "ROUTE_1" }); 12 | const route2 = createRoute.simpleRoute()({ 13 | url: "/route2", 14 | event: "ROUTE_2", 15 | querySchema: Z.object({ foo: Z.number() }), 16 | }); 17 | const route3 = createRoute.simpleRoute()({ 18 | url: "/route3/:foo", 19 | event: "ROUTE_3", 20 | paramsSchema: Z.object({ 21 | foo: Z.string(), 22 | }), 23 | }); 24 | const routes = [route1, route2, route3]; 25 | 26 | it("returns a matched type if it finds a matching route", () => { 27 | expect(matchRoute(routes, "", "/route1", "")).toMatchObject({ 28 | type: "matched", 29 | route: route1, 30 | }); 31 | }); 32 | 33 | it("returns a no-matches type if it finds no matching routes", () => { 34 | expect(matchRoute(routes, "", "/foo", "")).toEqual({ type: "no-matches" }); 35 | }); 36 | 37 | it("trims the basePath if present before matching", () => { 38 | expect(matchRoute(routes, "/base/", "/base/route1/", "")).toMatchObject({ 39 | type: "matched", 40 | route: route1, 41 | }); 42 | }); 43 | 44 | it("returns a match-error type if there is a problem parsing the query/params schema", () => { 45 | expect(matchRoute(routes, "", "/route2", "?foo=123")).toMatchObject({ 46 | type: "match-error", 47 | error: expect.any(Error), 48 | }); 49 | }); 50 | 51 | it("provides type safe route/event matches", () => { 52 | const match = matchRoute(routes, "", "/route1", ""); 53 | 54 | if (match.type === "matched") { 55 | expect(match.route).toBe(route1); 56 | expect(match.event.type).toBe("ROUTE_1"); 57 | 58 | const _test: "ROUTE_1" | "ROUTE_2" | "ROUTE_3" = match.route.event; 59 | 60 | if (match.event.type === "ROUTE_3") { 61 | const _test2: string = match.event.params.foo; 62 | } 63 | } 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/routing/matchRoute/matchRoute.ts: -------------------------------------------------------------------------------- 1 | import { Route } from "../createRoute"; 2 | import { RoutingEvent } from "../routingEvent"; 3 | 4 | type Return[]> = 5 | | { 6 | type: "matched"; 7 | route: TRoutes[number]; 8 | event: RoutingEvent; 9 | } 10 | | { type: "no-matches" } 11 | | { type: "match-error"; error: unknown }; 12 | 13 | /** 14 | * @public 15 | */ 16 | export function matchRoute[]>( 17 | routes: TRoutes, 18 | basePath: string, 19 | path: string, 20 | search: string 21 | ): Return { 22 | const realBase = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath; 23 | const realPath = (() => { 24 | if (path.startsWith(realBase) && realBase.length > 0) { 25 | return path.substring(realBase.length); 26 | } 27 | 28 | return path; 29 | })(); 30 | 31 | const [matchingRoute, event] = routes 32 | .map((route): [Route | Error | undefined, undefined | RoutingEvent] => { 33 | try { 34 | const match = route.matches(realPath, search); 35 | if (match) { 36 | return [route, match as any]; 37 | } 38 | } catch (e) { 39 | if (e instanceof Error) { 40 | return [e, undefined]; 41 | } 42 | } 43 | 44 | return [undefined, undefined]; 45 | }) 46 | .find(([match]) => Boolean(match)) ?? [undefined, undefined]; 47 | 48 | if (matchingRoute === undefined) { 49 | return { type: "no-matches" }; 50 | } else if (matchingRoute instanceof Error) { 51 | return { type: "match-error", error: matchingRoute }; 52 | } 53 | 54 | return { type: "matched", route: matchingRoute, event: event as any }; 55 | } 56 | -------------------------------------------------------------------------------- /src/routing/providers.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createContext, MutableRefObject, useContext } from "react"; 3 | 4 | import { RoutingEvent } from "./routingEvent"; 5 | 6 | type Context = { 7 | activeRouteEvents?: MutableRefObject[]>; 8 | isTestRoutingContext?: boolean; 9 | }; 10 | 11 | export const RoutingContext = createContext(undefined); 12 | 13 | function useRoutingContext() { 14 | const context = useContext(RoutingContext); 15 | 16 | if (context === undefined) { 17 | throw new Error( 18 | "useRoutingContext must be used within a RoutingContext provider" 19 | ); 20 | } 21 | 22 | return context; 23 | } 24 | 25 | /** 26 | * @private 27 | */ 28 | export function useInRoutingContext(): boolean { 29 | const context = useContext(RoutingContext); 30 | 31 | return context !== undefined; 32 | } 33 | 34 | /** 35 | * @private 36 | */ 37 | export function useInTestRoutingContext(): boolean { 38 | const context = useContext(RoutingContext); 39 | 40 | return context?.isTestRoutingContext ?? false; 41 | } 42 | 43 | /** 44 | * @public 45 | * 46 | * Returns the list of active routing events, or undefined if there are none / used outside of an xstate-tree routing context 47 | */ 48 | export function useActiveRouteEvents() { 49 | try { 50 | const context = useRoutingContext(); 51 | 52 | return context.activeRouteEvents?.current; 53 | } catch { 54 | return undefined; 55 | } 56 | } 57 | 58 | /** 59 | * @public 60 | * 61 | * Renders the xstate-tree routing context. Designed for use in tests/storybook 62 | * for components that make use of routing hooks but aren't part of an xstate-tree view 63 | * 64 | * @param activeRouteEvents - The active route events to use in the context 65 | */ 66 | export function TestRoutingContext({ 67 | activeRouteEvents, 68 | children, 69 | }: { 70 | activeRouteEvents: RoutingEvent[]; 71 | children: React.ReactNode; 72 | }) { 73 | return ( 74 | 80 | {children} 81 | 82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /src/routing/routingEvent.ts: -------------------------------------------------------------------------------- 1 | import type { Route } from "./createRoute"; 2 | 3 | /** 4 | * @public 5 | * 6 | * Converts a Route type into the Event that will be broadcast for that route 7 | * 8 | * All routes a machine should handle should be added to the machines event union using this type 9 | */ 10 | export type RoutingEvent = T extends Route< 11 | infer TParams, 12 | infer TQuery, 13 | infer TEvent, 14 | infer TMeta 15 | > 16 | ? { 17 | type: TEvent; 18 | originalUrl: string; 19 | params: TParams; 20 | query: TQuery; 21 | meta: TMeta; 22 | } 23 | : never; 24 | -------------------------------------------------------------------------------- /src/routing/useHref.spec.ts: -------------------------------------------------------------------------------- 1 | import { createMemoryHistory } from "history"; 2 | import * as Z from "zod"; 3 | 4 | import { buildCreateRoute } from "./createRoute"; 5 | import { useHref } from "./useHref"; 6 | 7 | const hist = createMemoryHistory<{ meta?: unknown }>(); 8 | const createRoute = buildCreateRoute(() => hist, "/foo"); 9 | const route = createRoute.simpleRoute()({ 10 | url: "/bar/:type(valid)", 11 | event: "GO_BAR", 12 | paramsSchema: Z.object({ 13 | type: Z.literal("valid"), 14 | }), 15 | }); 16 | 17 | describe("useHref", () => { 18 | it("reverses the supplied route and joins the basePath onto it", () => { 19 | expect(useHref(route, { type: "valid" }, undefined)).toBe( 20 | "/foo/bar/valid/" 21 | ); 22 | }); 23 | 24 | it("fallsback to an empty string if there is an error", () => { 25 | expect(useHref(route, { type: "invalid" }, undefined)).toBe(""); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/routing/useHref.ts: -------------------------------------------------------------------------------- 1 | import { AnyRoute, Route } from "./createRoute"; 2 | import { joinRoutes } from "./joinRoutes"; 3 | 4 | /** 5 | * Returns a string created by joining the base path and routes URL 6 | */ 7 | export function useHref< 8 | TRoute extends AnyRoute, 9 | TRouteParams = TRoute extends Route 10 | ? TParams 11 | : never, 12 | TRouteQuery = TRoute extends Route 13 | ? TQuery 14 | : never 15 | >(to: TRoute, params: TRouteParams, query: TRouteQuery): string { 16 | try { 17 | const routePath = to.reverse({ params, query }); 18 | 19 | return joinRoutes(to.basePath, routePath); 20 | } catch (e) { 21 | console.error(e); 22 | } 23 | 24 | return ""; 25 | } 26 | -------------------------------------------------------------------------------- /src/routing/useIsRouteActive.spec.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook } from "@testing-library/react"; 2 | import { createMemoryHistory } from "history"; 3 | import React from "react"; 4 | 5 | import { buildCreateRoute } from "./createRoute"; 6 | import { RoutingContext } from "./providers"; 7 | import { useIsRouteActive } from "./useIsRouteActive"; 8 | 9 | const createRoute = buildCreateRoute(() => createMemoryHistory(), "/"); 10 | const fooRoute = createRoute.simpleRoute()({ 11 | event: "foo", 12 | url: "/", 13 | }); 14 | const barRoute = createRoute.simpleRoute()({ 15 | event: "bar", 16 | url: "/", 17 | }); 18 | describe("useIsRouteActive", () => { 19 | it("returns false if the supplied route is not part of the activeRouteEvents in the routing context", () => { 20 | const { result } = renderHook(() => useIsRouteActive(fooRoute), { 21 | wrapper: ({ children }) => ( 22 | 23 | {children} 24 | 25 | ), 26 | }); 27 | 28 | expect(result.current).toBe(false); 29 | }); 30 | 31 | it("throws an error if not called within the RoutingContext", () => { 32 | expect(() => renderHook(() => useIsRouteActive(fooRoute))).toThrow(); 33 | }); 34 | 35 | it("returns true if the supplied route is part of the activeRouteEvents in the routing context", () => { 36 | const { result } = renderHook(() => useIsRouteActive(fooRoute), { 37 | wrapper: ({ children }) => ( 38 | 53 | {children} 54 | 55 | ), 56 | }); 57 | 58 | expect(result.current).toBe(true); 59 | }); 60 | 61 | it("handles multiple routes where one is active", () => { 62 | const { result } = renderHook(() => useIsRouteActive(fooRoute, barRoute), { 63 | wrapper: ({ children }) => ( 64 | 79 | {children} 80 | 81 | ), 82 | }); 83 | 84 | expect(result.current).toBe(true); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /src/routing/useIsRouteActive.tsx: -------------------------------------------------------------------------------- 1 | import { AnyRoute } from "./createRoute"; 2 | import { useActiveRouteEvents } from "./providers"; 3 | 4 | /** 5 | * @public 6 | * Accepts Routes and returns true if any route is currently active. False if not. 7 | * 8 | * If used outside of a RoutingContext, an error will be thrown. 9 | * @param routes - the routes to check 10 | * @returns true if any route is active, false if not 11 | * @throws if used outside of an xstate-tree root 12 | */ 13 | export function useIsRouteActive(...routes: AnyRoute[]): boolean { 14 | const activeRouteEvents = useActiveRouteEvents(); 15 | 16 | if (!activeRouteEvents) { 17 | throw new Error( 18 | "useIsRouteActive must be used within a RoutingContext. Are you using it outside of an xstate-tree Root?" 19 | ); 20 | } 21 | 22 | return activeRouteEvents.some((activeRouteEvent) => { 23 | return routes.some((route) => activeRouteEvent.type === route.event); 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /src/routing/useOnRoute.spec.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook } from "@testing-library/react"; 2 | import { createMemoryHistory } from "history"; 3 | import React from "react"; 4 | 5 | import { buildCreateRoute } from "./createRoute"; 6 | import { RoutingContext } from "./providers"; 7 | import { useOnRoute } from "./useOnRoute"; 8 | 9 | const createRoute = buildCreateRoute(() => createMemoryHistory(), "/"); 10 | const fooRoute = createRoute.simpleRoute()({ 11 | event: "foo", 12 | url: "/", 13 | }); 14 | 15 | describe("useOnRoute", () => { 16 | it("returns false if the supplied route is not part of the activeRouteEvents in the routing context", () => { 17 | const { result } = renderHook(() => useOnRoute(fooRoute), { 18 | wrapper: ({ children }) => ( 19 | 20 | {children} 21 | 22 | ), 23 | }); 24 | 25 | expect(result.current).toBe(false); 26 | }); 27 | 28 | it("throws an error if not called within the RoutingContext", () => { 29 | expect(() => renderHook(() => useOnRoute(fooRoute))).toThrow(); 30 | }); 31 | 32 | it("returns false if the supplied route is part of the activeRouteEvents but not marked as an index event", () => { 33 | const { result } = renderHook(() => useOnRoute(fooRoute), { 34 | wrapper: ({ children }) => ( 35 | 50 | {children} 51 | 52 | ), 53 | }); 54 | 55 | expect(result.current).toBe(false); 56 | }); 57 | 58 | it("returns true if the supplied route is part of the activeRouteEvents and marked as an index event", () => { 59 | const { result } = renderHook(() => useOnRoute(fooRoute), { 60 | wrapper: ({ children }) => ( 61 | 76 | {children} 77 | 78 | ), 79 | }); 80 | 81 | expect(result.current).toBe(true); 82 | }); 83 | 84 | it("returns false if a different route is active and marked as an index event", () => { 85 | const { result } = renderHook(() => useOnRoute(fooRoute), { 86 | wrapper: ({ children }) => ( 87 | 102 | {children} 103 | 104 | ), 105 | }); 106 | 107 | expect(result.current).toBe(false); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/routing/useOnRoute.tsx: -------------------------------------------------------------------------------- 1 | import { AnyRoute, type SharedMeta } from "./createRoute"; 2 | import { useActiveRouteEvents } from "./providers"; 3 | 4 | /** 5 | * @public 6 | * Accepts a single Route and returns true if the route is currently active and marked as an index route. 7 | * False if not. 8 | * 9 | * If used outside of a RoutingContext, an error will be thrown. 10 | * @param route - the route to check 11 | * @returns true if the route is active and an index route, false if not 12 | * @throws if used outside of an xstate-tree root 13 | */ 14 | export function useOnRoute(route: AnyRoute): boolean { 15 | const activeRouteEvents = useActiveRouteEvents(); 16 | 17 | if (!activeRouteEvents) { 18 | throw new Error( 19 | "useOnRoute must be used within a RoutingContext. Are you using it outside of an xstate-tree Root?" 20 | ); 21 | } 22 | 23 | return activeRouteEvents.some( 24 | (activeRouteEvent) => 25 | activeRouteEvent.type === route.event && 26 | (activeRouteEvent.meta as SharedMeta)?.indexEvent === true 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/routing/useRouteArgsIfActive.spec.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook } from "@testing-library/react"; 2 | import { createMemoryHistory } from "history"; 3 | import React from "react"; 4 | import { z } from "zod"; 5 | 6 | import { buildCreateRoute } from "./createRoute"; 7 | import { RoutingContext } from "./providers"; 8 | import { useRouteArgsIfActive } from "./useRouteArgsIfActive"; 9 | 10 | const createRoute = buildCreateRoute(() => createMemoryHistory(), "/"); 11 | const fooRoute = createRoute.simpleRoute()({ 12 | event: "foo", 13 | url: "/:foo", 14 | paramsSchema: z.object({ foo: z.string() }), 15 | querySchema: z.object({ bar: z.string() }), 16 | }); 17 | describe("useRouteArgsIfActive", () => { 18 | it("returns undefined if the route is not active", () => { 19 | const { result } = renderHook(() => useRouteArgsIfActive(fooRoute), { 20 | wrapper: ({ children }) => ( 21 | 22 | {children} 23 | 24 | ), 25 | }); 26 | 27 | expect(result.current).toBe(undefined); 28 | }); 29 | 30 | it("returns the routes arguments if the route is active", () => { 31 | const { result } = renderHook(() => useRouteArgsIfActive(fooRoute), { 32 | wrapper: ({ children }) => ( 33 | 48 | {children} 49 | 50 | ), 51 | }); 52 | 53 | expect(result.current).toEqual({ 54 | params: { foo: "bar" }, 55 | query: { bar: "baz" }, 56 | meta: {}, 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/routing/useRouteArgsIfActive.tsx: -------------------------------------------------------------------------------- 1 | import { assertIsDefined } from "../utils"; 2 | 3 | import { AnyRoute, ArgumentsForRoute } from "./createRoute"; 4 | import { useActiveRouteEvents } from "./providers"; 5 | import { useIsRouteActive } from "./useIsRouteActive"; 6 | 7 | /** 8 | * @public 9 | * Returns the arguments for the given route if the route is active. 10 | * Returns undefined if the route is not active. 11 | * 12 | * @param route - the route to get the arguments for 13 | * @returns the arguments for the given route if the route is active, undefined otherwise 14 | * @throws if used outside of an xstate-tree root 15 | */ 16 | export function useRouteArgsIfActive( 17 | route: TRoute 18 | ): ArgumentsForRoute | undefined { 19 | const isActive = useIsRouteActive(route); 20 | const activeRoutes = useActiveRouteEvents(); 21 | 22 | if (!isActive) { 23 | return undefined; 24 | } 25 | 26 | const activeRoute = activeRoutes?.find( 27 | (activeRoute) => activeRoute.type === route.event 28 | ); 29 | assertIsDefined( 30 | activeRoute, 31 | "active route is not defined, but the route is active??" 32 | ); 33 | 34 | return { 35 | params: activeRoute.params, 36 | query: activeRoute.query, 37 | meta: activeRoute.meta, 38 | } as ArgumentsForRoute; 39 | } 40 | -------------------------------------------------------------------------------- /src/setupScript.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom/extend-expect"; 2 | -------------------------------------------------------------------------------- /src/slots/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./slots"; 2 | -------------------------------------------------------------------------------- /src/slots/slots.spec.ts: -------------------------------------------------------------------------------- 1 | import { multiSlot, singleSlot, GetSlotNames } from "./slots"; 2 | 3 | describe("slot utilities", () => { 4 | describe("multiSlot", () => { 5 | it("returns an object that allows you to generate slot ids given an id", () => { 6 | const slot = multiSlot("Names"); 7 | 8 | expect(slot.getId("id")).toEqual("id-namesmulti-slots"); 9 | }); 10 | }); 11 | 12 | describe("singleSlot", () => { 13 | it("returns an object that allows you to generate a slot id", () => { 14 | const slot = singleSlot("Name"); 15 | 16 | expect(slot.getId()).toEqual("name-slot"); 17 | }); 18 | }); 19 | 20 | describe("GetSlotNames", () => { 21 | it("converts an array of Slot types to a union of the Slot names", () => { 22 | const slots = [multiSlot("foo"), singleSlot("bar")] as const; 23 | 24 | expect(slots.length).toEqual(2); 25 | 26 | // should be "foo" | "bar" 27 | type Foo = GetSlotNames; 28 | 29 | const fooTest = "fooMulti" as const; 30 | const barTest = "bar" as const; 31 | const invalidTest: "invalid" = "invalid" as const; 32 | const _fooOtherTest: Foo = fooTest; 33 | const _barOtherTest: Foo = barTest; 34 | // @ts-expect-error 35 | const _invalidOtherTest: Foo = invalidTest; 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/slots/slots.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @public 3 | */ 4 | export enum SlotType { 5 | SingleSlot, 6 | MultiSlot, 7 | } 8 | 9 | /** 10 | * @public 11 | */ 12 | export type SingleSlot = { 13 | type: SlotType.SingleSlot; 14 | name: T; 15 | getId(): string; 16 | }; 17 | 18 | /** 19 | * @public 20 | */ 21 | export type MultiSlot = { 22 | type: SlotType.MultiSlot; 23 | name: `${T}Multi`; 24 | getId(id: string): string; 25 | }; 26 | 27 | /** 28 | * @public 29 | */ 30 | export type Slot = SingleSlot | MultiSlot; 31 | 32 | /** 33 | * @public 34 | */ 35 | export function singleSlot(name: T): SingleSlot { 36 | return { 37 | type: SlotType.SingleSlot, 38 | name, 39 | getId: () => `${name.toLowerCase()}-slot`, 40 | }; 41 | } 42 | 43 | /** 44 | * @public 45 | */ 46 | export function multiSlot(name: T): MultiSlot { 47 | return { 48 | type: SlotType.MultiSlot, 49 | name: `${name}Multi`, 50 | getId: (id: string) => `${id}-${name.toLowerCase()}multi-slots`, 51 | }; 52 | } 53 | 54 | /** 55 | * @public 56 | */ 57 | export type GetSlotNames = 58 | TSlots[number]["name"]; 59 | -------------------------------------------------------------------------------- /src/test-app/AppMachine.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createMachine } from "xstate"; 3 | 4 | import { 5 | buildRootComponent, 6 | singleSlot, 7 | lazy, 8 | createXStateTreeMachine, 9 | } from "../"; 10 | import { Link, RoutingEvent, useIsRouteActive } from "../routing"; 11 | 12 | import { TodosMachine } from "./TodosMachine"; 13 | import { homeRoute, settingsRoute, history } from "./routes"; 14 | 15 | type Context = {}; 16 | type Events = 17 | | RoutingEvent 18 | | RoutingEvent; 19 | const ScreenSlot = singleSlot("ScreenGoesHere"); 20 | const slots = [ScreenSlot]; 21 | const OtherMachine = () => 22 | import("./OtherMachine").then(({ OtherMachine }) => OtherMachine); 23 | const AppMachine = 24 | /** @xstate-layout N4IgpgJg5mDOIC5QEMAOqDEBxA8gfQAkcBZAUUVFQHtYBLAF1qoDsKQAPRAFgCYAaEAE9EPLgEYAdAE4ZUgBxcAzFxkBWGQDYAvloFpMuPAGVSAFVMBJAHJYjbanUYs2nBLwHCEY1QHZpsnxV1KUV1HV0QZioIODZ9CQB3ZAZaZigAMSoAJwAlKgBXejB7GhTnJA5uP1VFKQAGDVUNRTrWuQ9EOSl-GTlAn00B7Qj4+miaEscmVgrXHh4fDoRRVR6ZBrqZH3U5HT10CSp6AAswLKMAYyywMBnKUqc7yuWFpbEeDTXGppkxMTlhvtUJMyk9XP8lsMdEA */ 25 | createMachine( 26 | { 27 | tsTypes: {} as import("./AppMachine.typegen").Typegen0, 28 | schema: { context: {} as Context, events: {} as Events }, 29 | id: "app", 30 | initial: "waitingForRoute", 31 | on: { 32 | GO_HOME: { 33 | cond: (_ctx, e) => e.meta.indexEvent ?? false, 34 | target: ".todos", 35 | }, 36 | GO_SETTINGS: { 37 | target: ".otherScreen", 38 | }, 39 | }, 40 | states: { 41 | waitingForRoute: {}, 42 | todos: { 43 | invoke: { 44 | src: "TodosMachine", 45 | id: ScreenSlot.getId(), 46 | }, 47 | }, 48 | otherScreen: { 49 | invoke: { 50 | src: "OtherMachine", 51 | id: ScreenSlot.getId(), 52 | }, 53 | }, 54 | }, 55 | }, 56 | { 57 | services: { 58 | TodosMachine: TodosMachine, 59 | OtherMachine: lazy(OtherMachine), 60 | }, 61 | } 62 | ); 63 | 64 | export const BuiltAppMachine = createXStateTreeMachine(AppMachine, { 65 | slots, 66 | selectors({ inState }) { 67 | return { 68 | showingTodos: inState("todos"), 69 | showingOtherScreen: inState("otherScreen"), 70 | }; 71 | }, 72 | View({ slots, selectors }) { 73 | const isHomeActive = useIsRouteActive(homeRoute); 74 | 75 | return ( 76 | <> 77 |

    {isHomeActive ? "true" : "false"}

    78 | {selectors.showingTodos && ( 79 | <> 80 |

    On home

    81 | 82 | Swap to settings 83 | 84 | 85 | )} 86 | {selectors.showingOtherScreen && ( 87 | <> 88 |

    On settings

    89 | 90 | Swap to home 91 | 92 | 93 | )} 94 | 95 | 96 | ); 97 | }, 98 | }); 99 | 100 | export const App = buildRootComponent(BuiltAppMachine as any, { 101 | history, 102 | basePath: "", 103 | routes: [homeRoute, settingsRoute], 104 | getPathName: () => "/", 105 | getQueryString: () => "", 106 | }); 107 | -------------------------------------------------------------------------------- /src/test-app/AppMachine.typegen.ts: -------------------------------------------------------------------------------- 1 | 2 | // This file was automatically generated. Edits will be overwritten 3 | 4 | export interface Typegen0 { 5 | '@@xstate/typegen': true; 6 | internalEvents: { 7 | "xstate.init": { type: "xstate.init" }; 8 | }; 9 | invokeSrcNameMap: { 10 | "OtherMachine": "done.invoke.app.otherScreen:invocation[0]"; 11 | "TodosMachine": "done.invoke.app.todos:invocation[0]"; 12 | }; 13 | missingImplementations: { 14 | actions: never; 15 | delays: never; 16 | guards: never; 17 | services: never; 18 | }; 19 | eventsCausingActions: { 20 | 21 | }; 22 | eventsCausingDelays: { 23 | 24 | }; 25 | eventsCausingGuards: { 26 | 27 | }; 28 | eventsCausingServices: { 29 | "OtherMachine": "GO_SETTINGS"; 30 | "TodosMachine": "GO_HOME"; 31 | }; 32 | matchesStates: "otherScreen" | "todos" | "waitingForRoute"; 33 | tags: never; 34 | } 35 | -------------------------------------------------------------------------------- /src/test-app/OtherMachine.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createMachine } from "xstate"; 3 | 4 | import { createXStateTreeMachine, PickEvent } from "../"; 5 | import { RoutingEvent } from "../routing"; 6 | 7 | import { settingsRoute } from "./routes"; 8 | 9 | declare global { 10 | interface XstateTreeEvents { 11 | DO_THE_THING: { type: "DO_THE_THING" }; 12 | GO_TO_DO_THE_THING_STATE: { type: "GO_TO_DO_THE_THING_STATE" }; 13 | } 14 | } 15 | type Events = 16 | | PickEvent<"DO_THE_THING" | "GO_TO_DO_THE_THING_STATE"> 17 | | RoutingEvent; 18 | type States = { 19 | value: "awaitingRoute"; 20 | context: any; 21 | }; 22 | const machine = createMachine({ 23 | id: "other", 24 | initial: "awaitingRoute", 25 | states: { 26 | awaitingRoute: { 27 | on: { 28 | GO_SETTINGS: "idle", 29 | }, 30 | }, 31 | idle: { 32 | on: { 33 | GO_TO_DO_THE_THING_STATE: "doTheThing", 34 | }, 35 | }, 36 | doTheThing: { 37 | on: { 38 | DO_THE_THING: "idle", 39 | }, 40 | }, 41 | }, 42 | }); 43 | 44 | export const OtherMachine = createXStateTreeMachine(machine, { 45 | selectors({ canHandleEvent }) { 46 | return { 47 | canDoTheThing: canHandleEvent({ type: "DO_THE_THING" }), 48 | }; 49 | }, 50 | View({ selectors }) { 51 | return ( 52 | <> 53 |

    54 | {selectors.canDoTheThing ? "true" : "false"} 55 |

    56 |

    Other

    57 | 58 | ); 59 | }, 60 | }); 61 | -------------------------------------------------------------------------------- /src/test-app/TodoMachine.tsx: -------------------------------------------------------------------------------- 1 | // ignore file coverage 2 | import cx from "classnames"; 3 | import React from "react"; 4 | import { createMachine, assign, sendParent } from "xstate"; 5 | 6 | import { Slot, PickEvent, createXStateTreeMachine } from ".."; 7 | 8 | import { UnmountingTest } from "./unmountingTestFixture"; 9 | 10 | declare global { 11 | interface XstateTreeEvents { 12 | UPDATE_ALL_TODOS: { completed: boolean }; 13 | VIEW_COMPLETED_TODOS: string; 14 | VIEW_ACTIVE_TODOS: string; 15 | VIEW_ALL_TODOS: string; 16 | CLEAR_COMPLETED: string; 17 | } 18 | } 19 | 20 | type Context = { 21 | todo: string; 22 | completed: boolean; 23 | edittedTodo: string; 24 | id: string; 25 | }; 26 | type Events = 27 | | { type: "TOGGLE_CLICKED" } 28 | | { type: "REMOVE" } 29 | | { type: "START_EDITING" } 30 | | { type: "EDITTING_FINISHED" } 31 | | { type: "EDITTING_CANCELLED" } 32 | | { type: "EDITTED_TODO_UPDATED"; updatedText: string } 33 | | { type: "UPDATE_ALL_TODOS"; completed: boolean } 34 | | { type: "CLEAR_COMPLETED" } 35 | | PickEvent< 36 | | "VIEW_ACTIVE_TODOS" 37 | | "VIEW_COMPLETED_TODOS" 38 | | "CLEAR_COMPLETED" 39 | | "VIEW_ALL_TODOS" 40 | | "UPDATE_ALL_TODOS" 41 | >; 42 | type State = 43 | | { value: "idle"; context: Context } 44 | | { value: "editing"; context: Context } 45 | | { value: "hidden"; context: Context } 46 | | { value: "removed"; context: Context }; 47 | 48 | const slots: Slot[] = []; 49 | const TodoMachine = createMachine({ 50 | context: { 51 | todo: "", 52 | completed: false, 53 | edittedTodo: "", 54 | id: "", 55 | }, 56 | initial: "idle", 57 | on: { 58 | UPDATE_ALL_TODOS: { 59 | actions: assign({ completed: (_ctx, e) => e.completed }), 60 | }, 61 | CLEAR_COMPLETED: { 62 | cond: (ctx) => ctx.completed, 63 | target: "removed", 64 | }, 65 | }, 66 | states: { 67 | idle: { 68 | entry: assign({ 69 | edittedTodo: (ctx) => { 70 | return ctx.todo; 71 | }, 72 | }), 73 | on: { 74 | TOGGLE_CLICKED: { 75 | actions: assign({ completed: (ctx) => !ctx.completed }), 76 | }, 77 | REMOVE: "removed", 78 | START_EDITING: "editing", 79 | VIEW_COMPLETED_TODOS: { 80 | target: "hidden", 81 | cond: (ctx) => !ctx.completed, 82 | }, 83 | VIEW_ACTIVE_TODOS: { 84 | target: "hidden", 85 | cond: (ctx) => ctx.completed, 86 | }, 87 | }, 88 | }, 89 | editing: { 90 | on: { 91 | EDITTING_CANCELLED: "idle", 92 | EDITTED_TODO_UPDATED: { 93 | actions: assign({ 94 | edittedTodo: (_ctx, e) => e.updatedText, 95 | }), 96 | }, 97 | EDITTING_FINISHED: [ 98 | { 99 | target: "idle", 100 | cond: (ctx) => ctx.edittedTodo.trim().length > 0, 101 | actions: assign({ 102 | todo: (ctx) => ctx.edittedTodo.trim(), 103 | }), 104 | }, 105 | { 106 | target: "removed", 107 | }, 108 | ], 109 | }, 110 | }, 111 | hidden: { 112 | on: { 113 | VIEW_ALL_TODOS: "idle", 114 | VIEW_COMPLETED_TODOS: { 115 | target: "idle", 116 | cond: (ctx) => ctx.completed, 117 | }, 118 | VIEW_ACTIVE_TODOS: { 119 | target: "idle", 120 | cond: (ctx) => !ctx.completed, 121 | }, 122 | }, 123 | }, 124 | removed: { 125 | entry: sendParent((ctx) => ({ type: "REMOVE_TODO", id: ctx.id })), 126 | type: "final", 127 | }, 128 | }, 129 | }); 130 | 131 | const BoundTodoMachine = createXStateTreeMachine(TodoMachine, { 132 | selectors({ ctx, inState }) { 133 | return { 134 | todo: ctx.todo, 135 | edittedTodoText: ctx.edittedTodo, 136 | completed: ctx.completed, 137 | editing: inState("editing"), 138 | hidden: inState("hidden"), 139 | }; 140 | }, 141 | actions({ send }) { 142 | return { 143 | toggle() { 144 | send({ type: "TOGGLE_CLICKED" }); 145 | }, 146 | remove() { 147 | send({ type: "REMOVE" }); 148 | }, 149 | startEditing() { 150 | send({ type: "START_EDITING" }); 151 | }, 152 | finishEditing() { 153 | send({ type: "EDITTING_FINISHED" }); 154 | }, 155 | cancelEditing() { 156 | send({ type: "EDITTING_CANCELLED" }); 157 | }, 158 | updateEdittedTodoText(text: string) { 159 | send({ type: "EDITTED_TODO_UPDATED", updatedText: text }); 160 | }, 161 | }; 162 | }, 163 | View({ selectors, actions }) { 164 | if (selectors.hidden) { 165 | return null; 166 | } 167 | 168 | return ( 169 |
  • 176 |
    177 | 178 | 185 | 186 |
    192 | actions.updateEdittedTodoText(e.currentTarget.value)} 196 | onKeyDown={(e) => { 197 | if (e.key === "Enter") { 198 | actions.finishEditing(); 199 | } else if (e.key === "Escape") { 200 | actions.cancelEditing(); 201 | } 202 | }} 203 | autoFocus={selectors.editing} 204 | /> 205 |
  • 206 | ); 207 | }, 208 | slots, 209 | }); 210 | 211 | export { BoundTodoMachine as TodoMachine }; 212 | -------------------------------------------------------------------------------- /src/test-app/TodosMachine.tsx: -------------------------------------------------------------------------------- 1 | import cx from "classnames"; 2 | import React from "react"; 3 | import { 4 | createMachine, 5 | assign, 6 | spawn, 7 | DoneInvokeEvent, 8 | ActorRefFrom, 9 | } from "xstate"; 10 | 11 | import { broadcast, multiSlot, createXStateTreeMachine } from "../"; 12 | import { assert } from "../utils"; 13 | 14 | import { TodoMachine } from "./TodoMachine"; 15 | 16 | enum Actions { 17 | inputChanged = "inputChanged", 18 | } 19 | type Context = { 20 | todos: ActorRefFrom[]; 21 | newTodo: string; 22 | }; 23 | type Events = 24 | | { type: "TODO_INPUT_CHANGED"; val: string } 25 | | { type: "CREATE_TODO" } 26 | | { type: "REMOVE_TODO"; id: string } 27 | | { type: "CLEAR_COMPLETED" } 28 | | { type: "ALL_SELECTED" } 29 | | { type: "ACTIVE_SELECTED" } 30 | | { type: "COMPLETED_SELECTED" }; 31 | type State = 32 | | { value: "loadingTodos"; context: Context } 33 | | { value: "noTodos"; context: Context } 34 | | { value: "haveTodos"; context: Context } 35 | | { value: "haveTodos.all"; context: Context } 36 | | { value: "haveTodos.active"; context: Context } 37 | | { value: "haveTodos.completed"; context: Context }; 38 | const TodosSlot = multiSlot("Todos"); 39 | const slots = [TodosSlot]; 40 | const lastId = 1; 41 | const TodosMachine = createMachine( 42 | { 43 | id: "todos", 44 | context: { 45 | todos: [], 46 | newTodo: "", 47 | }, 48 | initial: "loadingTodos", 49 | on: { 50 | TODO_INPUT_CHANGED: { 51 | actions: Actions.inputChanged, 52 | }, 53 | CREATE_TODO: { 54 | actions: assign({ 55 | newTodo: () => "", 56 | todos: (ctx) => { 57 | const id = String(lastId + 1); 58 | 59 | return [ 60 | ...ctx.todos, 61 | spawn( 62 | TodoMachine.withContext({ 63 | todo: ctx.newTodo.trim(), 64 | completed: false, 65 | id, 66 | edittedTodo: "", 67 | }), 68 | TodosSlot.getId(id) 69 | ), 70 | ]; 71 | }, 72 | }) as any, 73 | cond: (ctx) => ctx.newTodo.trim().length > 0, 74 | target: "chooseCorrectState", 75 | }, 76 | }, 77 | states: { 78 | loadingTodos: { 79 | invoke: { 80 | src: () => 81 | new Promise((res) => { 82 | res([ 83 | { todo: "foo", id: "100", completed: false }, 84 | { todo: "bar", id: "200", completed: true }, 85 | ]); 86 | }), 87 | onDone: { 88 | actions: assign({ 89 | todos: ( 90 | ctx: Context, 91 | e: DoneInvokeEvent< 92 | { todo: string; id: string; completed: boolean }[] 93 | > 94 | ) => { 95 | const foo = [ 96 | ...ctx.todos, 97 | ...e.data.map((todo) => 98 | spawn( 99 | TodoMachine.withContext({ ...todo, edittedTodo: "" }), 100 | TodosSlot.getId(String(todo.id)) 101 | ) 102 | ), 103 | ]; 104 | 105 | return foo; 106 | }, 107 | }) as any, 108 | target: "chooseCorrectState", 109 | }, 110 | }, 111 | }, 112 | chooseCorrectState: { 113 | always: [ 114 | { target: "haveTodos.hist", cond: (ctx) => ctx.todos.length > 0 }, 115 | { target: "noTodos" }, 116 | ], 117 | }, 118 | haveTodos: { 119 | on: { 120 | REMOVE_TODO: { 121 | actions: assign({ 122 | todos: (ctx, e) => { 123 | return ctx.todos.filter( 124 | (todoActor) => todoActor.state.context.id !== e.id 125 | ); 126 | }, 127 | }), 128 | target: "chooseCorrectState", 129 | }, 130 | CLEAR_COMPLETED: "chooseCorrectState", 131 | ALL_SELECTED: ".all", 132 | ACTIVE_SELECTED: ".active", 133 | COMPLETED_SELECTED: ".completed", 134 | }, 135 | initial: "all", 136 | states: { 137 | hist: { 138 | type: "history", 139 | }, 140 | all: { 141 | entry: () => broadcast({ type: "VIEW_ALL_TODOS" }), 142 | }, 143 | active: { 144 | entry: () => broadcast({ type: "VIEW_ACTIVE_TODOS" }), 145 | }, 146 | completed: { 147 | entry: () => broadcast({ type: "VIEW_COMPLETED_TODOS" }), 148 | }, 149 | }, 150 | }, 151 | noTodos: {}, 152 | }, 153 | }, 154 | { 155 | actions: { 156 | [Actions.inputChanged]: assign({ 157 | newTodo: (_ctx, e) => { 158 | assert(e.type === "TODO_INPUT_CHANGED"); 159 | 160 | return e.val; 161 | }, 162 | }), 163 | }, 164 | } 165 | ); 166 | 167 | const BuiltTodosMachine = createXStateTreeMachine(TodosMachine, { 168 | selectors({ ctx, inState }) { 169 | return { 170 | todoInput: ctx.newTodo, 171 | allCompleted: ctx.todos.every( 172 | (todoActor) => todoActor.state.context.completed 173 | ), 174 | uncompletedCount: ctx.todos.filter( 175 | (todoActor) => !todoActor.state.context.completed 176 | ).length, 177 | loading: inState("loadingTodos"), 178 | haveTodos: inState("haveTodos"), 179 | onActive: inState("haveTodos.active"), 180 | onCompleted: inState("haveTodos.completed"), 181 | onAll: inState("haveTodos.all"), 182 | }; 183 | }, 184 | actions({ send, selectors }) { 185 | return { 186 | todoInputChanged(newVal: string) { 187 | send({ type: "TODO_INPUT_CHANGED", val: newVal }); 188 | }, 189 | createTodo() { 190 | send({ type: "CREATE_TODO" }); 191 | }, 192 | updateAllTodos() { 193 | broadcast({ 194 | type: "UPDATE_ALL_TODOS", 195 | completed: !selectors.allCompleted, 196 | }); 197 | }, 198 | clearCompleted() { 199 | broadcast({ type: "CLEAR_COMPLETED" }); 200 | }, 201 | viewAllTodos() { 202 | send({ type: "ALL_SELECTED" }); 203 | }, 204 | viewActiveTodos() { 205 | send({ type: "ACTIVE_SELECTED" }); 206 | }, 207 | viewCompletedTodos() { 208 | send({ type: "COMPLETED_SELECTED" }); 209 | }, 210 | }; 211 | }, 212 | View({ slots, actions, selectors }) { 213 | if (selectors.loading) { 214 | return

    Loading

    ; 215 | } 216 | 217 | return ( 218 | <> 219 |
    220 |

    todos

    221 | actions.todoInputChanged(e.currentTarget.value)} 226 | value={selectors.todoInput} 227 | onKeyPress={(e) => e.key === "Enter" && actions.createTodo()} 228 | data-testid="todo-input" 229 | /> 230 |
    231 | {selectors.haveTodos && ( 232 | <> 233 |
    234 | 241 | 242 |
      243 | 244 |
    245 |
    246 | 292 | 293 | )} 294 | 295 | ); 296 | }, 297 | slots, 298 | }); 299 | 300 | export { BuiltTodosMachine as TodosMachine }; 301 | -------------------------------------------------------------------------------- /src/test-app/routes.ts: -------------------------------------------------------------------------------- 1 | import { createMemoryHistory } from "history"; 2 | 3 | import { buildCreateRoute } from "../routing"; 4 | 5 | export const history = createMemoryHistory(); 6 | const createRoute = buildCreateRoute(() => history, "/"); 7 | export const homeRoute = createRoute.route()({ 8 | matcher(url, _query) { 9 | if (url === "/") { 10 | return { matchLength: 1 }; 11 | } 12 | 13 | return false; 14 | }, 15 | reverser: () => "/", 16 | event: "GO_HOME", 17 | }); 18 | export const settingsRoute = createRoute.simpleRoute()({ 19 | url: "/settings", 20 | event: "GO_SETTINGS", 21 | }); 22 | -------------------------------------------------------------------------------- /src/test-app/tests/__snapshots__/itWorks.integration.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Test app renders the initial app 1`] = ` 4 |
    5 |

    8 | true 9 |

    10 |

    13 | On home 14 |

    15 | 20 | Swap to settings 21 | 22 |
    25 |

    26 | todos 27 |

    28 | 34 |
    35 |
    38 | 44 | 49 |
      52 |
    • 56 |
      59 | 64 | 67 |
      72 | 76 |
    • 77 |
    • 81 |
      84 | 90 | 93 |
      98 | 102 |
    • 103 |
    104 |
    105 | 156 |
    157 | `; 158 | -------------------------------------------------------------------------------- /src/test-app/tests/changingInvokedMachineForSlot.integration.tsx: -------------------------------------------------------------------------------- 1 | import { render, act } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import React from "react"; 4 | import "@testing-library/jest-dom"; 5 | 6 | import { delay } from "../../utils"; 7 | import { App } from "../AppMachine"; 8 | 9 | describe("changing the machine invoked into a slot", () => { 10 | it("correctly updates the view to point to the new machine", async () => { 11 | const { getByTestId, queryByTestId } = render(); 12 | 13 | await delay(50); 14 | await act(() => userEvent.click(getByTestId("swap-to-other-machine"))); 15 | 16 | await delay(50); 17 | expect(queryByTestId("other-text")).toBeInTheDocument(); 18 | expect(getByTestId("header")).toHaveTextContent("On settings"); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/test-app/tests/interpreterViewsNotUnmountedNeedlessly.integration.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/first */ 2 | const unmountingMock = jest.fn(); 3 | jest.mock("../unmountingTestFixture/unmountCb", () => { 4 | return { 5 | calledOnUnmount: unmountingMock, 6 | }; 7 | }); 8 | import { render, act, cleanup } from "@testing-library/react"; 9 | import userEvent from "@testing-library/user-event"; 10 | import React from "react"; 11 | 12 | import { delay } from "../../utils"; 13 | import { App } from "../AppMachine"; 14 | 15 | describe("Rendering behaviour", () => { 16 | it("Child components are not unmounted when re-rendering root view", async () => { 17 | await cleanup(); 18 | const { getByTestId, getAllByTestId } = render(); 19 | 20 | await delay(50); 21 | await act(() => userEvent.type(getByTestId("todo-input"), "test{enter}")); 22 | 23 | await delay(300); 24 | expect(getAllByTestId("todo")).toHaveLength(3); 25 | expect(unmountingMock).not.toHaveBeenCalled(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/test-app/tests/itWorks.integration.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import React from "react"; 3 | 4 | import { delay } from "../../utils"; 5 | import { App } from "../AppMachine"; 6 | 7 | describe("Test app", () => { 8 | it("renders the initial app", async () => { 9 | const { container } = render(); 10 | 11 | await delay(50); 12 | expect(container).toMatchSnapshot(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/test-app/tests/itWorksWithoutRouting.integration.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import React from "react"; 3 | import { createMachine } from "xstate"; 4 | 5 | import { 6 | buildRootComponent, 7 | singleSlot, 8 | createXStateTreeMachine, 9 | } from "../../"; 10 | 11 | const childMachine = createMachine({ 12 | initial: "idle", 13 | states: { 14 | idle: {}, 15 | }, 16 | }); 17 | 18 | const child = createXStateTreeMachine(childMachine, { 19 | View: () =>

    child

    , 20 | }); 21 | 22 | const childSlot = singleSlot("Child"); 23 | const rootMachine = createMachine({ 24 | initial: "idle", 25 | invoke: { 26 | src: () => child, 27 | id: childSlot.getId(), 28 | }, 29 | states: { 30 | idle: {}, 31 | }, 32 | }); 33 | 34 | const root = createXStateTreeMachine(rootMachine, { 35 | slots: [childSlot], 36 | View({ slots }) { 37 | return ( 38 | <> 39 |

    root

    40 | 41 | 42 | ); 43 | }, 44 | }); 45 | 46 | const RootView = buildRootComponent(root); 47 | 48 | describe("Environment without routing", () => { 49 | it("still works without error", () => { 50 | const { getByTestId } = render(); 51 | 52 | expect(getByTestId("child")).toHaveTextContent("child"); 53 | expect(getByTestId("root")).toHaveTextContent("root"); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/test-app/tests/removingChildActor.integration.tsx: -------------------------------------------------------------------------------- 1 | import { render, act } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import React from "react"; 4 | 5 | import { delay } from "../../utils"; 6 | import { App } from "../AppMachine"; 7 | 8 | describe("removing an existing child actor", () => { 9 | it("remotes the child actor from the existing multi-slot view when it is stopped", async () => { 10 | const { getAllByTestId } = render(); 11 | 12 | await delay(50); 13 | await act(() => userEvent.click(getAllByTestId("remove-todo")[0])); 14 | 15 | await delay(300); 16 | expect(getAllByTestId("todo")).toHaveLength(1); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/test-app/tests/routing.integration.tsx: -------------------------------------------------------------------------------- 1 | import { act, render } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import React from "react"; 4 | 5 | import { broadcast } from "../../"; 6 | import { delay } from "../../utils"; 7 | import { App } from "../AppMachine"; 8 | 9 | describe("Routing", () => { 10 | describe("spawning child machines after entering a route", () => { 11 | it("sends the latest matched routing event to the newly spawned machine", async () => { 12 | const { getByTestId } = render(); 13 | 14 | await delay(50); 15 | await act(() => userEvent.click(getByTestId("swap-to-other-machine"))); 16 | 17 | await delay(50); 18 | broadcast({ type: "GO_TO_DO_THE_THING_STATE" }); 19 | 20 | await delay(10); 21 | expect(getByTestId("can-do-the-thing")).toHaveTextContent("true"); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/test-app/tests/selectorsStaleCanHandleEvent.integration.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import { createMemoryHistory } from "history"; 3 | import React from "react"; 4 | 5 | import { buildRootComponent, broadcast } from "../../"; 6 | import { delay } from "../../utils"; 7 | import { OtherMachine } from "../OtherMachine"; 8 | import { settingsRoute } from "../routes"; 9 | 10 | const history = createMemoryHistory(); 11 | const App = buildRootComponent(OtherMachine, { 12 | history, 13 | basePath: "", 14 | routes: [settingsRoute], 15 | getPathName: () => "/settings", 16 | getQueryString: () => "", 17 | }); 18 | 19 | describe("Selectors & canHandleEvent", () => { 20 | it("Re-runs the selectors when canHandleEvent needs to be re-run", async () => { 21 | const { getByTestId, rerender } = render(); 22 | 23 | // Eh? Why the fuck don't you just re-render when the useMachine hook updates... 24 | await delay(10); 25 | rerender(); 26 | 27 | const canDoTheThing = getByTestId("can-do-the-thing"); 28 | expect(canDoTheThing.textContent).toEqual("false"); 29 | 30 | broadcast({ type: "GO_TO_DO_THE_THING_STATE" }); 31 | 32 | await delay(10); 33 | 34 | const canDoTheThingUpdated = getByTestId("can-do-the-thing"); 35 | expect(canDoTheThingUpdated.textContent).toEqual("true"); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/test-app/tests/spawningChildActor.integration.tsx: -------------------------------------------------------------------------------- 1 | import { render, act, cleanup } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import React from "react"; 4 | 5 | import { delay } from "../../utils"; 6 | import { App } from "../AppMachine"; 7 | 8 | describe("creating a new child actor", () => { 9 | it("adds the new child actor into the existing multi-slot view when it is spawned", async () => { 10 | await cleanup(); 11 | const { getByTestId, getAllByTestId } = render(); 12 | 13 | await delay(50); 14 | await act(() => userEvent.type(getByTestId("todo-input"), "test{enter}")); 15 | 16 | await delay(300); 17 | expect(getAllByTestId("todo")).toHaveLength(3); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/test-app/tests/updatingChildActorViaBroadcast.integration.tsx: -------------------------------------------------------------------------------- 1 | import { render, act, cleanup } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import React from "react"; 4 | 5 | import { delay } from "../../utils"; 6 | import { App } from "../AppMachine"; 7 | 8 | describe("updating child actors via broadcast", () => { 9 | it("re-renders the views for the child actors when they change", async () => { 10 | await cleanup(); 11 | const { getByTestId, getAllByTestId } = render(); 12 | 13 | await delay(50); 14 | await act(() => userEvent.click(getByTestId("update-all"))); 15 | 16 | await delay(300); 17 | const todoInputs = getAllByTestId("toggle-todo"); 18 | for (const input of todoInputs) { 19 | expect(input).toBeChecked(); 20 | } 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/test-app/unmountingTestFixture/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { calledOnUnmount } from "./unmountCb"; 4 | 5 | export function UnmountingTest() { 6 | React.useEffect(() => { 7 | return () => { 8 | calledOnUnmount(); 9 | }; 10 | }, []); 11 | 12 | return null; 13 | } 14 | -------------------------------------------------------------------------------- /src/test-app/unmountingTestFixture/unmountCb.ts: -------------------------------------------------------------------------------- 1 | export function calledOnUnmount() {} 2 | -------------------------------------------------------------------------------- /src/testingUtilities.tsx: -------------------------------------------------------------------------------- 1 | // ignore file coverage 2 | import { useMachine } from "@xstate/react"; 3 | import React, { JSXElementConstructor, useEffect } from "react"; 4 | import { TinyEmitter } from "tiny-emitter"; 5 | import { 6 | StateMachine, 7 | createMachine, 8 | AnyStateMachine, 9 | ContextFrom, 10 | EventFrom, 11 | } from "xstate"; 12 | 13 | import { buildXStateTreeMachine } from "./builders"; 14 | import { 15 | XstateTreeMachineStateSchemaV1, 16 | GlobalEvents, 17 | ViewProps, 18 | AnySelector, 19 | AnyActions, 20 | XstateTreeMachineStateSchemaV2, 21 | } from "./types"; 22 | import { difference, PropsOf } from "./utils"; 23 | import { emitter, recursivelySend, XstateTreeView } from "./xstateTree"; 24 | 25 | /** 26 | * @public 27 | * 28 | * Creates a dummy machine that just renders the supplied string - useful for rendering xstate-tree views in isolation 29 | * 30 | * @param name - the string to render in the machines view 31 | * @returns a dummy machine that renders a div containing the supplied string 32 | */ 33 | export function slotTestingDummyFactory(name: string) { 34 | return buildXStateTreeMachine( 35 | createMachine({ 36 | id: name, 37 | initial: "idle", 38 | states: { 39 | idle: {}, 40 | }, 41 | }), 42 | { 43 | actions: () => ({}), 44 | selectors: () => ({}), 45 | slots: [], 46 | view: () => ( 47 |
    48 |

    {name}

    49 |
    50 | ), 51 | } 52 | ); 53 | } 54 | 55 | /** 56 | * @public 57 | * 58 | * Can be used as the slots prop for an xstate-tree view, will render a div containing a

    slotName-slot

    for each slot 59 | */ 60 | export const genericSlotsTestingDummy = new Proxy( 61 | {}, 62 | { 63 | get(_target, prop) { 64 | return () => ( 65 |

    66 |

    67 | <>{prop}-slot 68 |

    69 |
    70 | ); 71 | }, 72 | } 73 | ) as any; 74 | 75 | type InferViewProps = T extends ViewProps< 76 | infer TSelectors, 77 | infer TActions, 78 | never, 79 | infer TMatches 80 | > 81 | ? { 82 | selectors: TSelectors; 83 | actions: TActions; 84 | inState: (state: Parameters[0]) => TMatches; 85 | } 86 | : never; 87 | 88 | /** 89 | * @public 90 | * 91 | * Aids in type inference for creating props objects for xstate-tree views. 92 | * 93 | * @param view - The view to create props for 94 | * @param props - The actions/selectors props to pass to the view 95 | * @returns An object with the view's selectors, actions, and inState function props 96 | */ 97 | export function buildViewProps< 98 | C extends keyof JSX.IntrinsicElements | JSXElementConstructor 99 | >( 100 | _view: C, 101 | props: Pick>, "actions" | "selectors"> 102 | ): InferViewProps> { 103 | return { 104 | ...props, 105 | inState: (testState: any) => (state: any) => 106 | state === testState || testState.startsWith(state), 107 | } as InferViewProps>; 108 | } 109 | 110 | /** 111 | * @public 112 | * 113 | * Sets up a root component for use in an \@xstate/test model backed by \@testing-library/react for the component 114 | * 115 | * The logger argument should just be a simple function which forwards the arguments to console.log, 116 | * this is needed because Wallaby.js only displays console logs in tests that come from source code, not library code, 117 | * so any logs from inside this file don't show up in the test explorer 118 | * 119 | * The returned object has a `rootComponent` property and a function, `awaitTransition`, that returns a Promise 120 | * when called that is resolved the next time the underlying machine transitions. This can be used in the \@xstate/test 121 | * model to ensure after an event action is executed the test in the next state doesn't run until after the machine transitions 122 | * 123 | * It also delays for 5ms to ensure any React re-rendering happens in response to the state transition 124 | */ 125 | export function buildTestRootComponent< 126 | TMachine extends AnyStateMachine, 127 | TSelectors extends AnySelector, 128 | TActions extends AnyActions, 129 | TContext = ContextFrom 130 | >( 131 | machine: StateMachine< 132 | TContext, 133 | | XstateTreeMachineStateSchemaV1 134 | | XstateTreeMachineStateSchemaV2, 135 | EventFrom 136 | >, 137 | logger: typeof console.log 138 | ) { 139 | if (!machine.meta) { 140 | throw new Error("Root machine has no meta"); 141 | } 142 | if ( 143 | (machine.meta.builderVersion === 1 && !machine.meta.view) || 144 | (machine.meta.builderVersion === 2 && !machine.meta.View) 145 | ) { 146 | throw new Error("Root machine has no associated view"); 147 | } 148 | const onChangeEmitter = new TinyEmitter(); 149 | 150 | function addTransitionListener(listener: () => void) { 151 | onChangeEmitter.once("transition", listener); 152 | } 153 | 154 | return { 155 | rootComponent: function XstateTreeRootComponent() { 156 | const [_, __, interpreter] = useMachine(machine, { devTools: true }); 157 | 158 | useEffect(() => { 159 | function handler(event: GlobalEvents) { 160 | recursivelySend(interpreter, event); 161 | } 162 | function changeHandler(ctx: TContext, oldCtx: TContext | undefined) { 163 | logger("onChange: ", difference(oldCtx, ctx)); 164 | onChangeEmitter.emit("changed", ctx); 165 | } 166 | function onEventHandler(e: any) { 167 | logger("onEvent", e); 168 | } 169 | function onTransitionHandler(s: any) { 170 | logger("State: ", s.value); 171 | onChangeEmitter.emit("transition"); 172 | } 173 | 174 | interpreter.onChange(changeHandler); 175 | interpreter.onEvent(onEventHandler); 176 | interpreter.onTransition(onTransitionHandler); 177 | 178 | emitter.on("event", handler); 179 | 180 | return () => { 181 | emitter.off("event", handler); 182 | interpreter.off(changeHandler); 183 | interpreter.off(onEventHandler); 184 | interpreter.off(onTransitionHandler); 185 | }; 186 | }, [interpreter]); 187 | 188 | if (!interpreter.initialized) { 189 | return null; 190 | } 191 | 192 | return ; 193 | }, 194 | addTransitionListener, 195 | awaitTransition() { 196 | return new Promise((res) => { 197 | addTransitionListener(() => { 198 | setTimeout(res, 50); 199 | }); 200 | }); 201 | }, 202 | }; 203 | } 204 | -------------------------------------------------------------------------------- /src/tests/actionsGetUpdatedSelectors.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import React from "react"; 4 | import { assign, createMachine } from "xstate"; 5 | 6 | import { 7 | buildActions, 8 | buildSelectors, 9 | buildView, 10 | buildXStateTreeMachine, 11 | } from "../builders"; 12 | import { delay } from "../utils"; 13 | import { buildRootComponent } from "../xstateTree"; 14 | 15 | describe("actions accessing selectors", () => { 16 | let actionsCallCount = 0; 17 | type Events = { type: "SET_COUNT"; count: number }; 18 | type Context = { count: number }; 19 | const machine = createMachine({ 20 | context: { 21 | count: 0, 22 | }, 23 | on: { 24 | SET_COUNT: { 25 | actions: assign({ count: (_, event) => event.count }), 26 | }, 27 | }, 28 | initial: "foo", 29 | states: { 30 | foo: {}, 31 | }, 32 | }); 33 | 34 | const selectors = buildSelectors(machine, (ctx) => ({ count: ctx.count })); 35 | const actions = buildActions(machine, selectors, (send, selectors) => { 36 | actionsCallCount++; 37 | return { 38 | incrementCount() { 39 | send({ type: "SET_COUNT", count: selectors.count + 1 }); 40 | }, 41 | }; 42 | }); 43 | 44 | const view = buildView( 45 | machine, 46 | selectors, 47 | actions, 48 | [], 49 | ({ selectors, actions }) => { 50 | return ( 51 | 52 | ); 53 | } 54 | ); 55 | 56 | const Root = buildRootComponent( 57 | buildXStateTreeMachine(machine, { 58 | actions, 59 | selectors, 60 | slots: [], 61 | view, 62 | }) 63 | ); 64 | 65 | it("gets the most up to date selectors value without re-creating the action functions", async () => { 66 | const { getByRole, rerender } = render(); 67 | 68 | await delay(10); 69 | rerender(); 70 | 71 | const button = getByRole("button"); 72 | 73 | await userEvent.click(button); 74 | await delay(); 75 | expect(button).toHaveTextContent("1"); 76 | 77 | await userEvent.click(button); 78 | await delay(); 79 | expect(button).toHaveTextContent("2"); 80 | expect(actionsCallCount).toBe(1); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/tests/asyncRouteRedirects.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import { assign } from "@xstate/immer"; 3 | import { createMemoryHistory } from "history"; 4 | import React from "react"; 5 | import { createMachine } from "xstate"; 6 | import { z } from "zod"; 7 | 8 | import { 9 | buildRootComponent, 10 | buildActions, 11 | buildCreateRoute, 12 | buildSelectors, 13 | buildView, 14 | buildXStateTreeMachine, 15 | XstateTreeHistory, 16 | } from "../"; 17 | import { delay } from "../utils"; 18 | 19 | describe("async route redirects", () => { 20 | const hist: XstateTreeHistory = createMemoryHistory(); 21 | const createRoute = buildCreateRoute(() => hist, "/"); 22 | 23 | const parentRoute = createRoute.simpleRoute()({ 24 | url: "/:notFoo/", 25 | event: "GO_TO_PARENT", 26 | paramsSchema: z.object({ notFoo: z.string() }), 27 | redirect: async ({ params }) => { 28 | await delay(100); 29 | 30 | if (params.notFoo === "foo") { 31 | return { params: { notFoo: "notFoo" } }; 32 | } 33 | 34 | return; 35 | }, 36 | }); 37 | const redirectRoute = createRoute.simpleRoute(parentRoute)({ 38 | url: "/foo/:bar/", 39 | event: "GO_TO_REDIRECT", 40 | paramsSchema: z.object({ bar: z.string() }), 41 | redirect: async ({ params }) => { 42 | if (params.bar === "redirect") { 43 | return { 44 | params: { 45 | bar: "redirected", 46 | }, 47 | }; 48 | } 49 | 50 | return; 51 | }, 52 | }); 53 | const childRoute = createRoute.simpleRoute(redirectRoute)({ 54 | url: "/child/", 55 | event: "GO_TO_CHILD", 56 | }); 57 | 58 | const machine = createMachine({ 59 | context: {}, 60 | initial: "idle", 61 | on: { 62 | GO_TO_REDIRECT: { 63 | actions: assign((ctx, e) => { 64 | ctx.bar = e.params.bar; 65 | }), 66 | }, 67 | }, 68 | states: { 69 | idle: {}, 70 | }, 71 | }); 72 | const selectors = buildSelectors(machine, (ctx) => ctx); 73 | const actions = buildActions(machine, selectors, () => ({})); 74 | const view = buildView(machine, selectors, actions, [], ({ selectors }) => ( 75 |

    {selectors.bar}

    76 | )); 77 | 78 | const Root = buildRootComponent( 79 | buildXStateTreeMachine(machine, { actions, selectors, view, slots: [] }), 80 | { 81 | basePath: "/", 82 | history: hist, 83 | routes: [parentRoute, redirectRoute, childRoute], 84 | } 85 | ); 86 | 87 | it("handles a top/middle/bottom route hierarchy where top and middle perform a redirect", async () => { 88 | const { queryByText } = render(); 89 | 90 | childRoute.navigate({ params: { bar: "redirect", notFoo: "foo" } }); 91 | 92 | await delay(200); 93 | expect(queryByText("redirected")).toBeDefined(); 94 | expect(hist.location.pathname).toBe("/notFoo/foo/redirected/child/"); 95 | }); 96 | 97 | it("does a history.replace when redirecting", async () => { 98 | render(); 99 | 100 | childRoute.navigate({ params: { bar: "redirect", notFoo: "foo" } }); 101 | 102 | await delay(140); 103 | // not sure why it's 3, but when using history.push it's 5 104 | expect(hist.length).toBe(3); 105 | }); 106 | 107 | it("respects the abort controller and aborts the redirect on route navigation", async () => { 108 | const { queryByText } = render(); 109 | 110 | childRoute.navigate({ params: { bar: "redirect", notFoo: "foo" } }); 111 | await delay(); 112 | childRoute.navigate({ 113 | params: { bar: "icancelledtheredirect", notFoo: "foo" }, 114 | }); 115 | 116 | await delay(140); 117 | expect(queryByText("icancelledtheredirect")).toBeDefined(); 118 | expect(hist.location.pathname).toBe( 119 | "/notFoo/foo/icancelledtheredirect/child/" 120 | ); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { type History } from "history"; 2 | import React from "react"; 3 | import type { 4 | AnyFunction, 5 | AnyStateMachine, 6 | ContextFrom, 7 | EventFrom, 8 | InterpreterFrom, 9 | StateFrom, 10 | StateMachine, 11 | } from "xstate"; 12 | 13 | import { Slot, GetSlotNames } from "./slots"; 14 | 15 | /** 16 | * @public 17 | */ 18 | export type XStateTreeMachineMetaV1< 19 | TMachine extends AnyStateMachine, 20 | TSelectors, 21 | TActions extends AnyActions, 22 | TSlots extends readonly Slot[] = Slot[] 23 | > = { 24 | slots: TSlots; 25 | view: React.ComponentType< 26 | ViewProps< 27 | OutputFromSelector, 28 | ReturnType, 29 | TSlots, 30 | MatchesFrom 31 | > 32 | >; 33 | selectors: TSelectors; 34 | actions: TActions; 35 | xstateTreeMachine?: true; 36 | }; 37 | 38 | /** 39 | * @public 40 | */ 41 | export type XstateTreeMachineStateSchemaV1< 42 | TMachine extends AnyStateMachine, 43 | TSelectors extends AnySelector, 44 | TActions extends AnyActions 45 | > = { 46 | meta: XStateTreeMachineMetaV1 & { 47 | builderVersion: 1; 48 | }; 49 | }; 50 | 51 | /** 52 | * @public 53 | */ 54 | export type ViewProps< 55 | TSelectors, 56 | TActions, 57 | TSlots extends readonly Slot[], 58 | TMatches extends AnyFunction 59 | > = { 60 | slots: Record, React.ComponentType>; 61 | actions: TActions; 62 | selectors: TSelectors; 63 | /** 64 | * @deprecated see https://github.com/koordinates/xstate-tree/issues/33 use `inState` in the selector function instead 65 | */ 66 | inState: TMatches; 67 | }; 68 | 69 | declare global { 70 | /** 71 | * 72 | * This is a global container interface for all global event types 73 | * Different files can extend this interface with their own types by adding a `declare global` 74 | * 75 | * Events are defined as follows, as properties on the interface 76 | * 77 | * NO_PAYLOAD: string => { type: "NO_PAYLOAD" } 78 | * A_PAYLOAD: { a: "payload" } => { type: "A_PAYLOAD", a: "payload" } 79 | */ 80 | interface XstateTreeEvents {} 81 | } 82 | /** 83 | * @public 84 | * Extracts the properties defined on the XstateTreeEvents interface and converts them 85 | * into proper event objects. 86 | * 87 | * Properties extending `string` have no payloads, any other type is the payload for the event 88 | * The property name is extracted as the `type` of the event 89 | */ 90 | export type GlobalEvents = { 91 | [I in keyof XstateTreeEvents]: XstateTreeEvents[I] extends string 92 | ? { type: I } 93 | : XstateTreeEvents[I] & { type: I }; 94 | }[keyof XstateTreeEvents]; 95 | 96 | /** 97 | * @public 98 | * 99 | * Extracts the event objects for the specified event types from the GlobalEvents union 100 | */ 101 | export type PickEvent< 102 | T extends Extract["type"] 103 | > = Extract; 104 | 105 | /** 106 | * @public 107 | */ 108 | export type XstateTreeHistory = History<{ 109 | meta?: T; 110 | previousUrl?: string; 111 | }>; 112 | 113 | /** 114 | * @public 115 | */ 116 | export type V1Selectors = ( 117 | ctx: TContext, 118 | canHandleEvent: (e: TEvent) => boolean, 119 | inState: TMatches, 120 | __currentState: never 121 | ) => TSelectors; 122 | 123 | /** 124 | * @internal 125 | */ 126 | export type MatchesFrom = StateFrom["matches"]; 127 | 128 | /** 129 | * @public 130 | */ 131 | export type OutputFromSelector = T extends V1Selectors< 132 | any, 133 | any, 134 | infer O, 135 | any 136 | > 137 | ? O 138 | : never; 139 | 140 | /** 141 | * @public 142 | */ 143 | export type AnySelector = V1Selectors; 144 | 145 | /** 146 | * @public 147 | */ 148 | export type AnyActions = (send: any, selectors: any) => any; 149 | 150 | /** 151 | * @public 152 | */ 153 | export type AnyXstateTreeMachine = StateMachine< 154 | any, 155 | | XstateTreeMachineStateSchemaV1 156 | | XstateTreeMachineStateSchemaV2, 157 | any 158 | >; 159 | 160 | /** 161 | * @internal 162 | */ 163 | export type CanHandleEvent = ( 164 | e: EventFrom 165 | ) => boolean; 166 | 167 | /** 168 | * @public 169 | */ 170 | export type Selectors = (args: { 171 | ctx: ContextFrom; 172 | canHandleEvent: CanHandleEvent; 173 | inState: MatchesFrom; 174 | meta?: unknown; 175 | }) => TOut; 176 | 177 | /** 178 | * @public 179 | */ 180 | export type Actions< 181 | TMachine extends AnyStateMachine, 182 | TSelectorsOutput, 183 | TOut 184 | > = (args: { 185 | send: InterpreterFrom["send"]; 186 | selectors: TSelectorsOutput; 187 | }) => TOut; 188 | 189 | /** 190 | * @public 191 | */ 192 | export type View< 193 | TActionsOutput, 194 | TSelectorsOutput, 195 | TSlots extends readonly Slot[] 196 | > = React.ComponentType<{ 197 | slots: Record, React.ComponentType>; 198 | actions: TActionsOutput; 199 | selectors: TSelectorsOutput; 200 | }>; 201 | 202 | /** 203 | * @public 204 | */ 205 | export type V2BuilderMeta< 206 | TMachine extends AnyStateMachine, 207 | TSelectorsOutput = ContextFrom, 208 | TActionsOutput = Record, 209 | TSlots extends readonly Slot[] = Slot[] 210 | > = { 211 | selectors?: Selectors; 212 | actions?: Actions; 213 | slots?: TSlots; 214 | View: View; 215 | }; 216 | 217 | /** 218 | * @public 219 | */ 220 | export type XstateTreeMachineStateSchemaV2< 221 | TMachine extends AnyStateMachine, 222 | TSelectorsOutput = ContextFrom, 223 | TActionsOutput = Record, 224 | TSlots extends readonly Slot[] = Slot[] 225 | > = { 226 | meta: Required< 227 | V2BuilderMeta & { 228 | builderVersion: 2; 229 | } 230 | >; 231 | }; 232 | 233 | /** 234 | * @public 235 | * 236 | * Retrieves the selector return type from the xstate-tree machine 237 | */ 238 | export type SelectorsFrom = 239 | TMachine extends StateMachine 240 | ? TMeta extends { meta: { selectors: infer TOut } } 241 | ? TOut extends (...args: any) => any 242 | ? ReturnType 243 | : never 244 | : never 245 | : never; 246 | 247 | /** 248 | * @public 249 | * 250 | * Retrieves the actions return type from the xstate-tree machine 251 | */ 252 | export type ActionsFrom = 253 | TMachine extends StateMachine 254 | ? TMeta extends { meta: { actions: infer TOut } } 255 | ? TOut extends (...args: any) => any 256 | ? ReturnType 257 | : never 258 | : never 259 | : never; 260 | -------------------------------------------------------------------------------- /src/useConstant.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | 3 | interface ResultBox { 4 | v: T; 5 | } 6 | 7 | export function useConstant(fn: () => T): T { 8 | const ref = useRef>(); 9 | 10 | if (!ref.current) { 11 | ref.current = { v: fn() }; 12 | } 13 | 14 | return ref.current.v; 15 | } 16 | -------------------------------------------------------------------------------- /src/useService.ts: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect } from "react"; 2 | import { EventObject, Interpreter, InterpreterFrom, AnyState } from "xstate"; 3 | 4 | import { AnyXstateTreeMachine, XstateTreeMachineStateSchemaV1 } from "./types"; 5 | import { isEqual } from "./utils"; 6 | 7 | /** 8 | * @public 9 | */ 10 | export function loggingMetaOptions( 11 | ignoredEvents: TEvents["type"][], 12 | ignoreContext: (keyof TContext)[] | undefined = undefined 13 | ) { 14 | const ignoredEventMap = new Map(); 15 | 16 | ignoredEvents.forEach((event) => { 17 | ignoredEventMap.set(event, true); 18 | }); 19 | 20 | return { 21 | xstateTree: { 22 | ignoredEvents: ignoredEventMap, 23 | ignoreContext, 24 | }, 25 | }; 26 | } 27 | 28 | /** 29 | * @internal 30 | */ 31 | export function useService< 32 | TInterpreter extends InterpreterFrom 33 | >(service: TInterpreter) { 34 | const [current, setCurrent] = useState(service.state); 35 | const [children, setChildren] = useState(service.children); 36 | const childrenRef = useRef(new Map()); 37 | 38 | useEffect(() => { 39 | childrenRef.current = children; 40 | }, [children]); 41 | 42 | useEffect( 43 | function () { 44 | // Set to current service state as there is a possibility 45 | // of a transition occurring between the initial useState() 46 | // initialization and useEffect() commit. 47 | setCurrent(service.state); 48 | setChildren(service.children); 49 | const listener = function (state: AnyState) { 50 | if (state.changed) { 51 | setCurrent(state); 52 | 53 | if (!isEqual(childrenRef.current, service.children)) { 54 | setChildren(new Map(service.children)); 55 | } 56 | } 57 | }; 58 | const sub = service.subscribe(listener); 59 | return function () { 60 | sub.unsubscribe(); 61 | }; 62 | }, 63 | [service, setChildren] 64 | ); 65 | useEffect(() => { 66 | function handler(event: EventObject) { 67 | if (event.type.includes("done")) { 68 | const idOfFinishedChild = event.type.split(".")[2]; 69 | childrenRef.current.delete(idOfFinishedChild); 70 | setChildren(new Map(childrenRef.current)); 71 | } 72 | 73 | console.debug( 74 | `[xstate-tree] ${service.id} handling event`, 75 | (service.machine.meta as any)?.xstateTree?.ignoredEvents?.has( 76 | event.type 77 | ) 78 | ? event.type 79 | : event 80 | ); 81 | } 82 | 83 | let prevState: undefined | AnyState = undefined; 84 | function transitionHandler(state: AnyState) { 85 | const ignoreContext: string[] | undefined = (service.machine.meta as any) 86 | ?.xstateTree?.ignoreContext; 87 | const context = ignoreContext ? "[context omitted]" : state.context; 88 | if (prevState) { 89 | console.debug( 90 | `[xstate-tree] ${service.id} transitioning from`, 91 | prevState.value, 92 | "to", 93 | state.value, 94 | context 95 | ); 96 | } else { 97 | console.debug( 98 | `[xstate-tree] ${service.id} transitioning to ${state.value}`, 99 | context 100 | ); 101 | } 102 | 103 | prevState = state; 104 | } 105 | 106 | service.onEvent(handler); 107 | service.onTransition(transitionHandler); 108 | 109 | return () => { 110 | service.off(handler); 111 | service.off(transitionHandler); 112 | }; 113 | }, [service, setChildren]); 114 | 115 | return [ 116 | current, 117 | children as unknown as Map< 118 | string | number, 119 | Interpreter, any, any> 120 | >, 121 | ] as const; 122 | } 123 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { ComponentPropsWithRef, JSXElementConstructor } from "react"; 2 | import { Interpreter, StateMachine } from "xstate"; 3 | 4 | export type PropsOf< 5 | C extends keyof JSX.IntrinsicElements | JSXElementConstructor 6 | > = JSX.LibraryManagedAttributes>; 7 | 8 | export function delay(ms = 0): Promise { 9 | return new Promise((resolve) => setTimeout(resolve, ms)); 10 | } 11 | 12 | export type OmitOptional = { 13 | [P in keyof Required as Pick extends Required> 14 | ? P 15 | : never]: T[P]; 16 | }; 17 | export type IsEmptyObject< 18 | Obj, 19 | ExcludeOptional extends boolean = false 20 | > = undefined extends Obj 21 | ? true 22 | : [keyof (ExcludeOptional extends true ? OmitOptional : Obj)] extends [ 23 | never 24 | ] 25 | ? true 26 | : false; 27 | 28 | export function assertIsDefined( 29 | val: T, 30 | msg?: string 31 | ): asserts val is NonNullable { 32 | if (val === undefined || val === null) { 33 | throw new Error( 34 | `Expected 'val' to be defined, but received ${val} ${ 35 | msg ? `(${msg})` : "" 36 | }` 37 | ); 38 | } 39 | } 40 | 41 | export function assert(value: unknown, msg?: string): asserts value { 42 | if (typeof expect !== "undefined") { 43 | if (value !== true && msg) { 44 | console.error(msg); 45 | } 46 | expect(value).toEqual(true); 47 | } else if (value !== true) { 48 | if (msg) { 49 | console.error(msg); 50 | } 51 | throw new Error("assertion failed"); 52 | } 53 | } 54 | 55 | export type StateMachineToInterpreter = T extends StateMachine< 56 | infer TContext, 57 | infer TSchema, 58 | infer TEvents, 59 | infer TState, 60 | any, 61 | any, 62 | any 63 | > 64 | ? Interpreter 65 | : never; 66 | 67 | export function difference(a: any, b: any) { 68 | const result: Record = {}; 69 | 70 | for (const key in b) { 71 | if (!a.hasOwnProperty(key)) { 72 | result[key] = b[key]; 73 | } else if (Array.isArray(b[key]) && Array.isArray(a[key])) { 74 | if (JSON.stringify(b[key]) !== JSON.stringify(a[key])) { 75 | result[key] = b[key]; 76 | } 77 | } else if (typeof b[key] === "object" && typeof a[key] === "object") { 78 | const value = difference(a[key], b[key]); 79 | if (Object.keys(value).length > 0) { 80 | result[key] = value; 81 | } 82 | } else if (b[key] !== a[key]) { 83 | result[key] = b[key]; 84 | } 85 | } 86 | 87 | return result; 88 | } 89 | 90 | /* 91 | * @private 92 | * 93 | * Check if two objects or arrays are equal 94 | * (c) 2021 Chris Ferdinandi, MIT License, https://gomakethings.com 95 | * @param {*} obj1 The first item 96 | * @param {*} obj2 The second item 97 | * @return {Boolean} Returns true if they're equal in value 98 | */ 99 | export function isEqual(obj1: any, obj2: any): boolean { 100 | /** 101 | * More accurately check the type of a JavaScript object 102 | * @param {Object} obj The object 103 | * @return {String} The object type 104 | */ 105 | function getType(obj: Record) { 106 | return Object.prototype.toString.call(obj).slice(8, -1).toLowerCase(); 107 | } 108 | 109 | function areArraysEqual() { 110 | if (obj1.length !== obj2.length) return false; 111 | 112 | for (let i = 0; i < obj1.length; i++) { 113 | if (!isEqual(obj1[i], obj2[i])) return false; 114 | } 115 | 116 | return true; 117 | } 118 | 119 | function areObjectsEqual() { 120 | if (Object.keys(obj1).length !== Object.keys(obj2).length) return false; 121 | 122 | for (const key in obj1) { 123 | if (Object.prototype.hasOwnProperty.call(obj1, key)) { 124 | if (!isEqual(obj1[key], obj2[key])) return false; 125 | } 126 | } 127 | 128 | return true; 129 | } 130 | 131 | function areFunctionsEqual() { 132 | return obj1.toString() === obj2.toString(); 133 | } 134 | 135 | function arePrimitivesEqual() { 136 | return obj1 === obj2; 137 | } 138 | 139 | const type = getType(obj1); 140 | 141 | if (type !== getType(obj2)) return false; 142 | 143 | if (type === "array") return areArraysEqual(); 144 | if (type === "object") return areObjectsEqual(); 145 | if (type === "function") return areFunctionsEqual(); 146 | return arePrimitivesEqual(); 147 | } 148 | 149 | export function isNil( 150 | // eslint-disable-next-line @rushstack/no-new-null 151 | value: T | null | undefined 152 | ): value is null | undefined { 153 | return value === null || value === undefined; 154 | } 155 | 156 | export function mergeMeta(meta: Record) { 157 | return Object.keys(meta).reduce((acc, key) => { 158 | const value = meta[key]; 159 | 160 | // Assuming each meta value is an object 161 | Object.assign(acc, value); 162 | 163 | return acc; 164 | }, {}); 165 | } 166 | -------------------------------------------------------------------------------- /src/xstateTree.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import { assign } from "@xstate/immer"; 3 | import { createMemoryHistory } from "history"; 4 | import React from "react"; 5 | import { createMachine, interpret } from "xstate"; 6 | 7 | import { 8 | buildXStateTreeMachine, 9 | buildView, 10 | buildSelectors, 11 | buildActions, 12 | createXStateTreeMachine, 13 | } from "./builders"; 14 | import { TestRoutingContext } from "./routing"; 15 | import { singleSlot } from "./slots"; 16 | import { delay } from "./utils"; 17 | import { 18 | broadcast, 19 | buildRootComponent, 20 | getMultiSlotViewForChildren, 21 | } from "./xstateTree"; 22 | 23 | describe("xstate-tree", () => { 24 | describe("a machine with a guarded event that triggers external side effects in an action", () => { 25 | it("does not execute the side effects of events passed to canHandleEvent", async () => { 26 | const sideEffect = jest.fn(); 27 | const machine = createMachine({ 28 | initial: "a", 29 | states: { 30 | a: { 31 | on: { 32 | SWAP: { 33 | cond: () => true, 34 | // Don't do this. There is a reason why assign actions should be pure. 35 | // but it triggers the issue 36 | actions: assign(() => { 37 | sideEffect(); 38 | }), 39 | target: "b", 40 | }, 41 | }, 42 | }, 43 | b: {}, 44 | }, 45 | }); 46 | 47 | const xstateTreeMachine = createXStateTreeMachine(machine, { 48 | selectors({ canHandleEvent }) { 49 | return { 50 | canSwap: canHandleEvent({ type: "SWAP" }), 51 | }; 52 | }, 53 | View({ selectors }) { 54 | return

    Can swap: {selectors.canSwap}

    ; 55 | }, 56 | }); 57 | const Root = buildRootComponent(xstateTreeMachine); 58 | render(); 59 | await delay(10); 60 | 61 | expect(sideEffect).not.toHaveBeenCalled(); 62 | }); 63 | }); 64 | 65 | describe("machines that don't have any visible change after initializing", () => { 66 | it("still renders the machines view", async () => { 67 | const renderCallback = jest.fn(); 68 | const machine = createMachine({ 69 | initial: "a", 70 | states: { 71 | a: {}, 72 | }, 73 | }); 74 | 75 | const selectors = buildSelectors(machine, (ctx) => ctx); 76 | const actions = buildActions(machine, selectors, () => ({})); 77 | const view = buildView(machine, selectors, actions, [], () => { 78 | renderCallback(); 79 | 80 | return null; 81 | }); 82 | 83 | const XstateTreeMachine = buildXStateTreeMachine(machine, { 84 | actions, 85 | selectors, 86 | slots: [], 87 | view, 88 | }); 89 | const Root = buildRootComponent(XstateTreeMachine); 90 | 91 | render(); 92 | await delay(50); 93 | expect(renderCallback).toHaveBeenCalledTimes(1); 94 | }); 95 | }); 96 | 97 | describe("selectors & action references do not change, state does change", () => { 98 | it("re-renders the view for the machine", async () => { 99 | const renderCallback = jest.fn(); 100 | const machine = createMachine({ 101 | context: { foo: 1 }, 102 | initial: "a", 103 | states: { 104 | a: { 105 | on: { 106 | SWAP: "b", 107 | }, 108 | }, 109 | b: {}, 110 | }, 111 | }); 112 | 113 | const selectors = buildSelectors(machine, (ctx) => ctx); 114 | const actions = buildActions(machine, selectors, () => ({})); 115 | const view = buildView(machine, selectors, actions, [], () => { 116 | renderCallback(); 117 | 118 | return null; 119 | }); 120 | 121 | const XstateTreeMachine = buildXStateTreeMachine(machine, { 122 | actions, 123 | selectors, 124 | slots: [], 125 | view, 126 | }); 127 | const Root = buildRootComponent(XstateTreeMachine); 128 | 129 | const { rerender } = render(); 130 | await delay(10); 131 | rerender(); 132 | expect(renderCallback).toHaveBeenCalledTimes(1); 133 | 134 | broadcast({ type: "SWAP" } as any as never); 135 | await delay(10); 136 | rerender(); 137 | expect(renderCallback).toHaveBeenCalledTimes(2); 138 | }); 139 | }); 140 | 141 | describe("broadcasting event with handler raising error", () => { 142 | it("does not bubble the error up", () => { 143 | const machine = createMachine({ 144 | context: { foo: 1 }, 145 | initial: "a", 146 | states: { 147 | a: { 148 | on: { 149 | SWAP: { 150 | actions: () => { 151 | throw new Error(); 152 | }, 153 | }, 154 | }, 155 | }, 156 | b: {}, 157 | }, 158 | }); 159 | 160 | const selectors = buildSelectors(machine, (ctx) => ctx); 161 | const actions = buildActions(machine, selectors, () => ({})); 162 | const view = buildView(machine, selectors, actions, [], () => { 163 | return null; 164 | }); 165 | 166 | const XstateTreeMachine = buildXStateTreeMachine(machine, { 167 | actions, 168 | selectors, 169 | slots: [], 170 | view, 171 | }); 172 | const Root = buildRootComponent(XstateTreeMachine); 173 | 174 | render(); 175 | 176 | expect(() => 177 | broadcast({ type: "SWAP" } as any as never) 178 | ).not.toThrowError(); 179 | }); 180 | }); 181 | 182 | it("sends the event to machines after the machine that errored handling it", () => { 183 | const childMachineHandler = jest.fn(); 184 | const slots = [singleSlot("child")]; 185 | const childMachine = createMachine({ 186 | context: { foo: 2 }, 187 | initial: "a", 188 | states: { 189 | a: { 190 | on: { 191 | SWAP: { 192 | actions: () => { 193 | childMachineHandler(); 194 | }, 195 | }, 196 | }, 197 | }, 198 | b: {}, 199 | }, 200 | }); 201 | const machine = createMachine({ 202 | context: { foo: 1 }, 203 | initial: "a", 204 | invoke: { 205 | id: slots[0].getId(), 206 | src: () => { 207 | return childMachine; 208 | }, 209 | }, 210 | states: { 211 | a: { 212 | on: { 213 | SWAP: { 214 | actions: () => { 215 | throw new Error(); 216 | }, 217 | }, 218 | }, 219 | }, 220 | b: {}, 221 | }, 222 | }); 223 | 224 | const selectors = buildSelectors(machine, (ctx) => ctx); 225 | const actions = buildActions(machine, selectors, () => ({})); 226 | const view = buildView(machine, selectors, actions, [], () => { 227 | return null; 228 | }); 229 | 230 | const XstateTreeMachine = buildXStateTreeMachine(machine, { 231 | actions, 232 | selectors, 233 | slots: [], 234 | view, 235 | }); 236 | const Root = buildRootComponent(XstateTreeMachine); 237 | 238 | render(); 239 | 240 | try { 241 | broadcast({ type: "SWAP" } as any as never); 242 | } catch {} 243 | expect(childMachineHandler).toHaveBeenCalled(); 244 | }); 245 | 246 | it("passes the current states meta into the v2 selector functions", async () => { 247 | const machine = createMachine({ 248 | id: "test-selectors-meta", 249 | initial: "idle", 250 | states: { 251 | idle: { 252 | meta: { 253 | foo: "bar", 254 | }, 255 | }, 256 | }, 257 | }); 258 | 259 | const XstateTreeMachine = createXStateTreeMachine(machine, { 260 | selectors({ meta }) { 261 | return { foo: (meta as any)?.foo }; 262 | }, 263 | View: ({ selectors }) => { 264 | return

    {selectors.foo}

    ; 265 | }, 266 | }); 267 | const Root = buildRootComponent(XstateTreeMachine); 268 | 269 | const { findByText } = render(); 270 | 271 | expect(await findByText("bar")).toBeTruthy(); 272 | }); 273 | 274 | describe("getMultiSlotViewForChildren", () => { 275 | it("memoizes correctly", () => { 276 | const machine = createMachine({ 277 | id: "test", 278 | initial: "idle", 279 | states: { 280 | idle: {}, 281 | }, 282 | }); 283 | 284 | const interpreter1 = interpret(machine).start(); 285 | const interpreter2 = interpret(machine).start(); 286 | 287 | const view1 = getMultiSlotViewForChildren(interpreter1, "ignored"); 288 | const view2 = getMultiSlotViewForChildren(interpreter2, "ignored"); 289 | 290 | expect(view1).not.toBe(view2); 291 | expect(view1).toBe(getMultiSlotViewForChildren(interpreter1, "ignored")); 292 | expect(view2).toBe(getMultiSlotViewForChildren(interpreter2, "ignored")); 293 | }); 294 | }); 295 | 296 | describe("rendering a root inside of a root", () => { 297 | it("throws an error during rendering if both are routing roots", async () => { 298 | const machine = createMachine({ 299 | id: "test", 300 | initial: "idle", 301 | states: { 302 | idle: {}, 303 | }, 304 | }); 305 | 306 | const RootMachine = createXStateTreeMachine(machine, { 307 | View() { 308 | return

    I am root

    ; 309 | }, 310 | }); 311 | const Root = buildRootComponent(RootMachine, { 312 | basePath: "/", 313 | history: createMemoryHistory(), 314 | routes: [], 315 | }); 316 | 317 | const Root2Machine = createXStateTreeMachine(machine, { 318 | View() { 319 | return ; 320 | }, 321 | }); 322 | const Root2 = buildRootComponent(Root2Machine, { 323 | basePath: "/", 324 | history: createMemoryHistory(), 325 | routes: [], 326 | }); 327 | 328 | try { 329 | const { rerender } = render(); 330 | rerender(); 331 | } catch (e: any) { 332 | expect(e.message).toMatchInlineSnapshot( 333 | `"Routing root rendered inside routing context, this implies a bug"` 334 | ); 335 | return; 336 | } 337 | 338 | throw new Error("Should have thrown"); 339 | }); 340 | 341 | it("does not throw an error if it's inside a test routing context", async () => { 342 | const machine = createMachine({ 343 | id: "test", 344 | initial: "idle", 345 | states: { 346 | idle: {}, 347 | }, 348 | }); 349 | 350 | const RootMachine = createXStateTreeMachine(machine, { 351 | View() { 352 | return

    I am root

    ; 353 | }, 354 | }); 355 | const Root = buildRootComponent(RootMachine, { 356 | basePath: "/", 357 | history: createMemoryHistory(), 358 | routes: [], 359 | }); 360 | 361 | const { rerender } = render( 362 | 363 | 364 | 365 | ); 366 | rerender( 367 | 368 | 369 | 370 | ); 371 | }); 372 | 373 | it("does not throw an error if either or one are a routing root", async () => { 374 | const machine = createMachine({ 375 | id: "test", 376 | initial: "idle", 377 | states: { 378 | idle: {}, 379 | }, 380 | }); 381 | 382 | const RootMachine = createXStateTreeMachine(machine, { 383 | View() { 384 | return

    I am root

    ; 385 | }, 386 | }); 387 | const Root = buildRootComponent(RootMachine); 388 | 389 | const Root2Machine = createXStateTreeMachine(machine, { 390 | View() { 391 | return ; 392 | }, 393 | }); 394 | const Root2 = buildRootComponent(Root2Machine, { 395 | basePath: "/", 396 | history: createMemoryHistory(), 397 | routes: [], 398 | }); 399 | 400 | const { rerender } = render(); 401 | rerender(); 402 | }); 403 | }); 404 | }); 405 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["examples", "src/**/*.spec.ts", "src/**/*.spec.tsx", "src/test-app"], 4 | "compilerOptions": { 5 | "outDir": "./lib", 6 | } 7 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src/**/*", "./examples/**/*"], 3 | "compilerOptions": { 4 | "incremental": true, 5 | "declaration": true, 6 | "target": "ES2018", 7 | "module": "CommonJS", 8 | "jsx": "react", 9 | "strict": true, 10 | "noImplicitAny": true, 11 | "strictNullChecks": true, 12 | "strictFunctionTypes": true, 13 | "strictBindCallApply": true, 14 | "strictPropertyInitialization": true, 15 | "noImplicitThis": true, 16 | "alwaysStrict": true, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "moduleResolution": "node", 20 | "esModuleInterop": true, 21 | "declarationMap": true, 22 | "types": ["jest", "node"], 23 | "paths": { 24 | "@koordinates/xstate-tree": ["./src/index.ts"] 25 | }, 26 | "outDir": "out" 27 | }, 28 | } -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import tsconfigPaths from 'vite-tsconfig-paths' 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | server: { 8 | hmr: false 9 | }, 10 | plugins: [react(), tsconfigPaths()], 11 | }) --------------------------------------------------------------------------------