├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── pr.yml │ └── test.yml ├── .gitignore ├── .huskyrc.json ├── .npmignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── assets ├── by-moveax.png ├── guarded-transitions.png ├── onentry-onexit.png ├── policies.png ├── reactions.png ├── search-example.png ├── simple-state-machine.png ├── simple-transition.png └── transition-with-command.png ├── docs └── README.md ├── example ├── README.md ├── api.ts ├── full-typescript.ts └── light-typescript.ts ├── global.d.ts ├── jest.config.js ├── package.json ├── rollup.config.js ├── src ├── StateMachine.ts ├── bindStm.ts ├── constants.ts ├── guards.ts ├── index.ts ├── policies.ts ├── spec │ ├── actions.ts │ ├── activities.ts │ ├── base.ts │ ├── events.ts │ ├── guards.ts │ ├── index.ts │ ├── reactions.ts │ ├── states.ts │ ├── storage.ts │ ├── subMachines.ts │ └── transitions.ts ├── stateMachineStarterSaga.ts └── typeGuards.ts ├── tests ├── context.test.ts ├── guards.test.ts ├── lateTransition.test.ts ├── onEntry.test.ts ├── onExit.test.ts ├── races.test.ts ├── reactions.test.ts ├── slowOnExit.test.ts ├── startup.test.ts ├── subMachinesWithContext.test.ts ├── subStateMachine.test.ts └── transitions.test.ts ├── tsconfig.base.json ├── tsconfig.build-types.json ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | types/ 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@moveaxlab'], 3 | }; 4 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: PR CI 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | 7 | jobs: 8 | test: 9 | name: Run tests on node ${{ matrix.node-version }} 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [10.x, 12.x, 14.x] 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - name: Install dependencies 22 | run: yarn 23 | - name: Check TypeScript definitions 24 | run: yarn test:types 25 | - name: Check linting 26 | run: yarn test:lint 27 | - name: Run unit tests with coverage 28 | run: yarn test:unit 29 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | jobs: 8 | test: 9 | name: Run tests on node ${{ matrix.node-version }} 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [10.x, 12.x, 14.x] 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - name: Install dependencies 22 | run: yarn 23 | - name: Check TypeScript definitions 24 | run: yarn test:types 25 | - name: Check linting 26 | run: yarn test:lint 27 | - name: Run unit tests with coverage 28 | run: yarn test:unit 29 | - name: Upload coverage data to coveralls 30 | uses: coverallsapp/github-action@v1.1.2 31 | with: 32 | github-token: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | coverage/ 3 | lib/ 4 | node_modules/ 5 | types/ 6 | 7 | *.tgz 8 | -------------------------------------------------------------------------------- /.huskyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "yarn test && yarn build" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github/ 2 | .idea/ 3 | assets/ 4 | coverage/ 5 | docs/ 6 | example/ 7 | src/ 8 | tests/ 9 | 10 | .eslintignore 11 | .eslintrc.js 12 | .gitignore 13 | .huskyrc.json 14 | .travis.yml 15 | CODE_OF_CONDUCT.md 16 | CONTRIBUTING.md 17 | global.d.ts 18 | jest.config.js 19 | rollup.config.js 20 | tsconfig.* 21 | yarn.lock 22 | 23 | *.tgz 24 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Contributor Covenant Code of Conduct 3 | 4 | ## Our Pledge 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, religion, or sexual identity 11 | and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the 27 | overall community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or 32 | advances of any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email 36 | address, without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | [info@moveax.it](mailto:info@moveax.it). 65 | All complaints will be reviewed and investigated promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series 87 | of actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or 94 | permanent ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within 114 | the community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 119 | version 2.0, available at 120 | [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0]. 121 | 122 | Community Impact Guidelines were inspired by 123 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available 127 | at [https://www.contributor-covenant.org/translations][translations]. 128 | 129 | [homepage]: https://www.contributor-covenant.org 130 | [v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html 131 | [Mozilla CoC]: https://github.com/mozilla/diversity 132 | [FAQ]: https://www.contributor-covenant.org/faq 133 | [translations]: https://www.contributor-covenant.org/translations 134 | 135 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | All contributors to `redux-sigma` must follow our [code of conduct](./CODE_OF_CONDUCT.md). 4 | 5 | All contributions are welcome: 6 | fixing typo or errors in the documentation or examples, 7 | adding additional examples, 8 | fixing bugs in the codebase, 9 | or integrating new features in the library. 10 | 11 | `redux-sigma` has a strong focus on semantics. 12 | If you are developing a new feature or fixing a bug, 13 | open an issue first to describe the bug or to discuss with us the new feature. 14 | 15 | We believe that the first step before implementation should be clearing all doubts 16 | about the semantics of the new feature. 17 | 18 | ## Development 19 | 20 | To contribute to `redux-sigma`, start by forking and cloning this repo: 21 | 22 | ```bash 23 | $ git clone https://github.com//redux-sigma.git 24 | ``` 25 | 26 | `redux-sigma` use `yarn` to manage dependencies. 27 | After cloning the repo, install dependencies by running this command inside its root folder: 28 | 29 | ```bash 30 | $ yarn 31 | ``` 32 | 33 | After changing some code, you can lint and test it by running this command: 34 | 35 | ```bash 36 | $ yarn test 37 | ``` 38 | 39 | Testing checks the following things: 40 | - type definitions are consistent across the library, its tests, and the examples 41 | - all code follows our linter rules 42 | - unit tests ensure that no semantic guarantee is broken 43 | 44 | All your changes should start from the `master` branch of `redux-sigma`. 45 | When your new feature or bugfix is ready, open a PR towards the `master` branch of this repo. 46 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 moveax 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redux-sigma 2 | 3 | moveax 4 | 5 | [![npm](https://img.shields.io/npm/v/redux-sigma)](https://www.npmjs.com/package/redux-sigma) 6 | [![Builds](https://img.shields.io/github/workflow/status/moveaxlab/redux-sigma/Test%20CI/master)](https://github.com/moveaxlab/redux-sigma/actions) 7 | [![Code coverage](https://img.shields.io/coveralls/github/moveaxlab/redux-sigma/master)](https://coveralls.io/github/moveaxlab/redux-sigma) 8 | 9 | `redux-sigma` is a library that allows implementation of state machines on top 10 | of `redux` and `redux-saga`. 11 | 12 | State machines implemented with `redux-sigma` react to events dispatched via `redux`, 13 | and their state can be stored inside `redux` using a dedicated reducer. 14 | 15 | The aim of `redux-sigma` is providing developers with a formal framework 16 | that can be used when dealing with complex business flows inside front-end applications. 17 | 18 | Being based on `redux-saga`, `redux-sigma` expects all your redux actions to follow 19 | the [FSA](https://github.com/redux-utilities/flux-standard-action) pattern. 20 | 21 | `redux-sigma` has extensive TypeScript support, and we recommend using it with TypeScript. 22 | 23 | You can read what features `redux-sigma` offers in the 24 | [docs](https://github.com/moveaxlab/redux-sigma/tree/master/docs), 25 | or you can start by reading the quick start below. 26 | If you want to look at a more detailed example, 27 | check out the [example](https://github.com/moveaxlab/redux-sigma/tree/master/example) folder. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | $ yarn add redux-sigma 33 | ``` 34 | Assuming you are using `yarn`. 35 | 36 | `redux-sigma` has `redux` and `redux-saga` as peer dependencies. 37 | 38 | ## Quick Start 39 | 40 | State machines in `redux-sigma` must extend a generic `StateMachine` class. 41 | 42 | The simplest way to define a state machine is to extend the `StateMachine` class, 43 | and to define its abstract fields: 44 | 45 | ```typescript 46 | import { StateMachine } from 'redux-sigma'; 47 | 48 | class MyStateMachine extends StateMachine { 49 | initialState = 'first_state'; 50 | 51 | name = 'my_state_machine'; 52 | 53 | spec = { 54 | first_state: { 55 | transitions: { 56 | first_event: 'second_state', 57 | }, 58 | }, 59 | second_state: { 60 | transitions: { 61 | second_event: 'first_state', 62 | }, 63 | }, 64 | }; 65 | } 66 | ``` 67 | 68 | This state machine can be represented graphically as follows: 69 | 70 | ![A simple state machine](https://github.com/moveaxlab/redux-sigma/raw/master/assets/simple-state-machine.png?raw=true) 71 | 72 | The `spec` field is the actual _specification_ of the state machine: 73 | a high level description of what its states are, and how the state machine 74 | goes from one state to another. 75 | More on this [in the docs](https://github.com/moveaxlab/redux-sigma/tree/master/docs). 76 | 77 | The `initialState` field indicates what will be the state of the state machine 78 | when it first starts. 79 | 80 | The `name` field is what identifies state machines: for `redux-sigma`, 81 | two state machines cannot have the same name. 82 | 83 | ### Running your state machine 84 | 85 | To use a state machine, you first need to instantiate it: 86 | 87 | ```typescript 88 | export const myStateMachine = new MyStateMachine(); 89 | ``` 90 | 91 | Then, you must connect your state machine to `redux` via `redux-saga`. 92 | 93 | `redux-sigma` provides a `stateMachineStarterSaga` utility to coordinate state machines startup 94 | that integrates with your `redux` store and your `redux-saga` middleware. 95 | 96 | ```typescript 97 | import { createStore, applyMiddleware } from 'redux'; 98 | import { createSagaMiddleware } from 'redux-saga'; 99 | import { stateMachineStarterSaga } from 'redux-sigma'; 100 | import { rootReducer } from './root-reducer'; 101 | import { myStateMachine } from './my-state-machine'; 102 | 103 | const sagaMiddleware = createSagaMiddleware(); 104 | 105 | const store = createStore(rootReducer, applyMiddleware(sagaMiddleware)); 106 | 107 | sagaMiddleware.run(stateMachineStarterSaga, myStateMachine); 108 | ``` 109 | 110 | > Having more than one state machine with the same name 111 | > or two instances of the same state machine passed to `stateMachineStarterSaga` 112 | > will crash `redux-sigma`! 113 | 114 | State machines can be started and stopped by dispatching actions to `redux`: 115 | 116 | ```typescript 117 | store.dispatch(myStateMachine.start({})); 118 | 119 | store.dispatch(myStateMachine.stop()); 120 | ``` 121 | 122 | Multiple `start` actions dispatched one after another have no effect on the state machine: 123 | the state machine is started only once. 124 | The same is true for `stop` actions. 125 | To restart a running state machine, dispatch a `stop` action followed by a `start` action. 126 | 127 | ### Reading data from your state machine 128 | 129 | To have the state of your state machines available inside your `redux` store, 130 | use the `stateReducer` of the state machine: 131 | 132 | ```typescript 133 | import { combineReducers } from 'redux'; 134 | import { myStateMachine } from './my-state-machine'; 135 | 136 | const rootReducer = combineReducers({ 137 | my_state_machine: myStateMachine.stateReducer, 138 | }); 139 | ``` 140 | 141 | While the state machine is not running, its state will look like this: 142 | 143 | ```typescript 144 | console.log(store.getState().my_state_machine); 145 | // { state: null } 146 | ``` 147 | 148 | Once the state machine starts running, its state will look like this: 149 | 150 | ```typescript 151 | store.dispatch(myStateMachine.start({})); 152 | 153 | console.log(store.getState().my_state_machine); 154 | // { state: 'first_state', context: {} } 155 | ``` 156 | 157 | The state and context of the state machines will be updated independently 158 | during the state machine lifetime, according to its specification. 159 | -------------------------------------------------------------------------------- /assets/by-moveax.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moveaxlab/redux-sigma/02396af66a513e71440bdcc886c5a2d0cf3bd9eb/assets/by-moveax.png -------------------------------------------------------------------------------- /assets/guarded-transitions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moveaxlab/redux-sigma/02396af66a513e71440bdcc886c5a2d0cf3bd9eb/assets/guarded-transitions.png -------------------------------------------------------------------------------- /assets/onentry-onexit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moveaxlab/redux-sigma/02396af66a513e71440bdcc886c5a2d0cf3bd9eb/assets/onentry-onexit.png -------------------------------------------------------------------------------- /assets/policies.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moveaxlab/redux-sigma/02396af66a513e71440bdcc886c5a2d0cf3bd9eb/assets/policies.png -------------------------------------------------------------------------------- /assets/reactions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moveaxlab/redux-sigma/02396af66a513e71440bdcc886c5a2d0cf3bd9eb/assets/reactions.png -------------------------------------------------------------------------------- /assets/search-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moveaxlab/redux-sigma/02396af66a513e71440bdcc886c5a2d0cf3bd9eb/assets/search-example.png -------------------------------------------------------------------------------- /assets/simple-state-machine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moveaxlab/redux-sigma/02396af66a513e71440bdcc886c5a2d0cf3bd9eb/assets/simple-state-machine.png -------------------------------------------------------------------------------- /assets/simple-transition.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moveaxlab/redux-sigma/02396af66a513e71440bdcc886c5a2d0cf3bd9eb/assets/simple-transition.png -------------------------------------------------------------------------------- /assets/transition-with-command.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moveaxlab/redux-sigma/02396af66a513e71440bdcc886c5a2d0cf3bd9eb/assets/transition-with-command.png -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # redux-sigma docs 2 | 3 | This document contains an explanation of what features of state machines are offered 4 | by `redux-sigma`, and what is the semantics of all the features. 5 | 6 | ### Table of Contents 7 | 8 | - [Activities](#activities) 9 | - [Context or extended state](#context-or-extended-state) 10 | * [Initial context](#initial-context) 11 | - [State machines specification](#state-machines-specification) 12 | * [Transitions](#transitions) 13 | + [Running activities on transition](#running-activities-on-transition) 14 | + [Conditional transitions: guards](#conditional-transitions-guards) 15 | * [Reactions or internal transitions](#reactions-or-internal-transitions) 16 | * [onEntry and onExit activities](#onentry-and-onexit-activities) 17 | * [Sub state machines](#sub-state-machines) 18 | - [Going full TypeScript](#going-full-typescript) 19 | 20 | ## Activities 21 | 22 | The state machines in `redux-sigma` can perform actions based on their state 23 | and on the events they receive. 24 | 25 | Actions or activities are defined using simple functions or using sagas. 26 | 27 | In the majority of cases, you will need only the following `redux-saga` effects: 28 | 29 | - `call`, to call remote endpoints or other services 30 | - `select`, to read data from the `redux` store 31 | - `put`, to send events to other state machines or actions to `redux` reducers 32 | - `delay`, to delay the execution of successive actions 33 | 34 | Take a look at the examples in the [`example`](./example) folder to see how activities could be implemented. 35 | 36 | ## Context or extended state 37 | 38 | Relying only on the states to implement your flows cannot be enough when they are complex. 39 | State machines allow you to define an _extended state_, or _context_ of your state machine, 40 | that can hold additional information for each state. 41 | 42 | A simple example would be a state machine containing a counter: 43 | modeling each possible value of the counter as a different state is an anti-pattern. 44 | The value of the counter is an example of something that can be stored in the extended state. 45 | 46 | Each state machine implemented with `redux-sigma` has a context available in its activities. 47 | 48 | ```typescript 49 | class MyStateMachine extends StateMachine { 50 | *activity() { 51 | // values can be retrieved from the context 52 | const counter = this.context.counter; 53 | // the context is immutable: to update it, use the setContext method 54 | // the setContext accepts an immer-style callback... 55 | yield* this.setContext(ctx => { 56 | ctx.counter += 1; 57 | }); 58 | // ...or a new value for the whole context 59 | yield* this.setContext({ 60 | counter: 5, 61 | }); 62 | } 63 | } 64 | ``` 65 | 66 | To change the context, use the [`yield*` generator delegation expression](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/yield*) 67 | in combination with the `setContext` method. 68 | 69 | The `setContext` method allows you to override the whole context with a new value, 70 | or to use an [`immer`-style callback](https://immerjs.github.io/immer/docs/produce) to mutate the context. 71 | 72 | > `redux-sigma` uses `immer` under the hood to update its context! 73 | > The context is thus [freezed](https://immerjs.github.io/immer/docs/freezing) for you by `immer`, 74 | > to avoid accidental mutations. 75 | 76 | The context is immutable: you must always use the `setContext` method to update it. 77 | Updating the context via `setContext` updates its value stored inside `redux`. 78 | 79 | Context is available in all activities and in transition guards. 80 | Transition guards cannot mutate the context. 81 | 82 | ### Initial context 83 | 84 | If a state machine requires some information in its context on start, 85 | you can make that field required in the context definition: 86 | 87 | ```typescript 88 | interface Context { 89 | counter: number; 90 | } 91 | 92 | class MyStateMachine extends StateMachine { 93 | /* ... */ 94 | } 95 | ``` 96 | 97 | To start this state machine you are now required to provide an initial context: 98 | 99 | ```typescript 100 | store.dispatch(myStateMachine.start({ counter: 0 })); 101 | ``` 102 | 103 | If the context of your state machine is filled during the state machine lifecycle, 104 | and there is no reasonable value for the initial context, then make all fields optional: 105 | 106 | ```typescript 107 | interface Context { 108 | counter?: number; 109 | } 110 | 111 | class MyStateMachine extends StateMachine { 112 | /* ... */ 113 | } 114 | ``` 115 | 116 | Now you are not required to provide an initial context (but you still can): 117 | 118 | ```typescript 119 | // this works 120 | store.dispatch(myStateMachine.start({})); 121 | // this works too 122 | store.dispatch(myStateMachine.start({ counter: 5 })); 123 | ``` 124 | 125 | ## State machines specification 126 | 127 | The `spec` field of each state machine is an object. 128 | Its keys represent the state names (or identifiers) of your state machine: 129 | 130 | ```typescript 131 | class MyStateMachine extends StateMachine { 132 | spec = { 133 | state_1: { /* definition of state 1 */ }, 134 | state_2: { /* definition of state 2 */ }, 135 | state_3: { /* definition of state 3 */ }, 136 | }; 137 | } 138 | ``` 139 | 140 | Each state definition can contain several fields: 141 | 142 | ```typescript 143 | class MyStateMachine extends StateMachine { 144 | spec = { 145 | state_1: { 146 | onEntry: [ /* activities that will run upon entering the state */ ], 147 | subMachines: [ /* other state machines that will run while in this state */ ], 148 | transitions: { /* transition definition */ }, 149 | reactions: { /* reactions (or internal transitions) definition */ }, 150 | onExit: [ /* activities that will run upon exiting the state */ ], 151 | }, 152 | }; 153 | } 154 | ``` 155 | 156 | All fields are optional, and are described in the detail in the following sections. 157 | 158 | ### Transitions 159 | 160 | Transitions are the core of all state machines: they define how the state of the state machine 161 | evolves in response to events that happen in your application. 162 | 163 | The `transitions` field is an object mapping events (which are the `type` of your `redux` actions) 164 | to the state that your state machine will reach upon receiving that event. 165 | 166 | ```typescript 167 | class MyStateMachine extends StateMachine { 168 | spec = { 169 | state_1: { 170 | transitions: { 171 | event_1: 'state_2', 172 | }, 173 | }, 174 | state_2: { /* ... */ }, 175 | }; 176 | } 177 | ``` 178 | 179 | Simple transitions can be illustrated as follows: 180 | 181 | ![A state machine with a simple transition](https://github.com/moveaxlab/redux-sigma/raw/master/assets/simple-transition.png?raw=true) 182 | 183 | The transition from `state_1` to `state_2` will be triggered by any `redux` action 184 | having a `type` equal to `'event_1'`: 185 | 186 | ```typescript 187 | // this action would trigger the transition 188 | store.dispatch({ type: 'event_1' }); 189 | 190 | // this action would trigger the transition too 191 | store.dispatch({ type: 'event_1', payload: { value: 'something something'} }); 192 | ``` 193 | 194 | #### Running activities on transition 195 | 196 | Sometimes you may want to perform an activity when doing a specific transition. 197 | `redux-sigma` allows you to specify the activity that must be performed on a given transition: 198 | 199 | ```typescript 200 | class MyStateMachine extends StateMachine { 201 | spec = { 202 | state_1: { 203 | transitions: { 204 | event_1: 'state_2', 205 | event_2: { 206 | target: 'state_3', 207 | command: this.transitionActivity, 208 | }, 209 | }, 210 | }, 211 | state_2: { /* ... */ }, 212 | state_3: { /* ... */ }, 213 | }; 214 | 215 | *transitionActivity(event) { 216 | // do something with `event` 217 | } 218 | } 219 | ``` 220 | 221 | Transitions paired with commands can be represented in this way: 222 | 223 | ![A state machine with a command on a transition](https://github.com/moveaxlab/redux-sigma/raw/master/assets/transition-with-command.png?raw=true) 224 | 225 | The `target` in the `event_2` transition identifies the state that your state machine will reach 226 | after executing the `command`. 227 | 228 | Commands can be regular functions or generators (sagas), or an array of both. 229 | They receive in input the event that triggered the transition. 230 | There is no guarantee on the order in which commands are executed when performing the transition, 231 | but all commands will execute to completion before entering the `target` state. 232 | 233 | #### Conditional transitions: guards 234 | 235 | For more advanced use-cases, you may want to perform a transition when receiving an event 236 | but _only_ if some conditions are met. 237 | This is know as a **guard** in UML State Machines and StateCharts. 238 | 239 | Guards can be implemented in `redux-sigma` with this syntax: 240 | 241 | ```typescript 242 | class MyStateMachine extends StateMachine { 243 | spec = { 244 | state_1: { 245 | transitions: { 246 | /* this transition is triggered only if the guard returns true */ 247 | event_1: { 248 | guard: (event, context) => { /* ... */ }, 249 | target: 'state_2', 250 | }, 251 | /* this event can take the state machines to several states: 252 | only the transition for which the guard returns true is triggered */ 253 | event_2: [ 254 | { 255 | guard: (event, context) => { /* ... */ }, 256 | target: 'state_2', 257 | }, 258 | { 259 | guard: (event, context) => { /* ... */ }, 260 | target: 'state_3', 261 | command: this.transitionActivity, 262 | }, 263 | ], 264 | }, 265 | }, 266 | state_2: { /* ... */ }, 267 | state_3: { /* ... */ }, 268 | }; 269 | } 270 | ``` 271 | 272 | Guarded transitions are represented with the guard condition between square brackets: 273 | 274 | ![A state machine with guarded transitions](https://github.com/moveaxlab/redux-sigma/raw/master/assets/guarded-transitions.png?raw=true) 275 | 276 | For `event_1`, the transition will be triggered only if the `guard` function returns `true`, 277 | otherwise nothing will happen. 278 | 279 | For `event_2`, we have defined an array of possible transitions: 280 | the only transition that will be triggered is the one for which the `guard` function returns `true`. 281 | 282 | There is no guarantee on the order in which `guard`s are executed: 283 | it's up to you to make sure that at most one `guard` returns `true` for any given input. 284 | 285 | Note that the `event_2` transition to `state_3` contains a command: 286 | this command will run if that specific transition is triggered. 287 | 288 | All `guard`s receive in input the event that could trigger the transition, 289 | and the current `context` of the state machine. 290 | You can learn more about the context in its [section](#context-or-extended-state). 291 | 292 | ### Reactions or internal transitions 293 | 294 | Sometimes you want your state machine to perform some action in response to an event 295 | _without_ changing the state. 296 | In `redux-sigma` this is possible via _reactions_ or internal transitions. 297 | When a reaction is triggered the state machine does not exit the current state: 298 | reactions do not trigger execution of `onEntry` or `onExit` activities. 299 | 300 | The `reactions` are defined as a map between event types and functions or generators (saga). 301 | Reaction activities will receive in input the event that triggered the transition. 302 | 303 | ```typescript 304 | class MyStateMachine { 305 | spec = { 306 | state_1: { 307 | reactions: { 308 | event_1: this.reaction, 309 | }, 310 | }, 311 | }; 312 | 313 | *reaction(event) { 314 | // use the triggering event in your reaction 315 | } 316 | } 317 | ``` 318 | 319 | Reactions are shown inside the state, separated from the name: 320 | 321 | ![A state with a reaction](https://github.com/moveaxlab/redux-sigma/raw/master/assets/reactions.png?raw=true) 322 | 323 | If your state machine receives an event that triggers a transition while a reaction is running, 324 | `redux-sigma` will stop that reaction using the [`cancel`](https://redux-saga.js.org/docs/api/#canceltask) 325 | effect from `redux-saga`. 326 | 327 | By default, while the state machine is in `state_1`, each action of type `event_1` 328 | dispatched to the `redux` store will trigger the reaction. 329 | If several events that trigger a reaction are dispatched one after another, 330 | one call to the reaction will be enqueued for each event. 331 | Each call waits for the previous calls to complete before running. 332 | 333 | You may find yourself needing a different behaviour than the default. 334 | `redux-sigma` allows three different reaction policies: 335 | 336 | - the reaction policy `all` (the default) processes all events that can trigger 337 | a reaction sequentially, in the order they are received 338 | - the reaction policy `first` processes the first event it receives, and ignores 339 | the same event until processing of the first event is complete 340 | (processing is resumed once the first event completes) 341 | - the reaction policy `last` processes all events it receives, but if a new event 342 | is receive while another event is processing, the reaction that started first 343 | is stopped, and a new reaction is started for the new event 344 | 345 | This image illustrates the three policies: 346 | 347 | ![Reaction policies illustration](https://github.com/moveaxlab/redux-sigma/raw/master/assets/policies.png?raw=true) 348 | 349 | Reaction policies can be specified in the following way: 350 | 351 | ```typescript 352 | import { StateMachine, all, first, last } from 'redux-sigma'; 353 | 354 | class MyStateMachine extends StateMachine { 355 | spec = { 356 | state_1: { 357 | reactions: { 358 | event_1: all(this.reaction), 359 | event_2: first(this.reaction), 360 | event_3: last(this.reaction), 361 | }, 362 | }, 363 | }; 364 | } 365 | ``` 366 | 367 | ### onEntry and onExit activities 368 | 369 | The `onEntry` and `onExit` fields are an array that mixes functions and generators (sagas). 370 | They receive nothing in input and must not return anything. 371 | Both arrays can contain methods of the state machine class. 372 | 373 | If your state has only one `onEntry` or one `onExit`, you can use this short-hand syntax: 374 | 375 | ```typescript 376 | class MyStateMachine extends StateMachine { 377 | spec = { 378 | state_1: { 379 | onEntry: this.entryActivity, 380 | onExit: this.exitActivity, 381 | }, 382 | }; 383 | } 384 | ``` 385 | 386 | `onEntry` and `onExit` activities are represented just like other reactions: 387 | 388 | ![A state with onEntry and onExit activities](https://github.com/moveaxlab/redux-sigma/raw/master/assets/onentry-onexit.png?raw=true) 389 | 390 | The `onEntry` activities will start running as soon as your state machine enters the state. 391 | While `onEntry` activities are running, the state machine can change state if it receives 392 | one of the events described among its `transitions`. 393 | If a transition happens, `onEntry` activities will be stopped 394 | (using the [`cancel`](https://redux-saga.js.org/docs/api/#canceltask) effect of `redux-saga`). 395 | 396 | The `onExit` activities will run when your state machine exits the state, 397 | or when your state machine is stopped while in that specific state. 398 | They always run to completion before exiting the state. 399 | 400 | There is no guarantee about the order in which `onEntry` and `onExit` activities are run 401 | inside a state. 402 | 403 | ### Sub state machines 404 | 405 | Each state of a state machine represents a configuration of your application in which an invariant is holding. 406 | Sometimes a single state can be subdivided into additional states, or configurations. 407 | To accomplish this in `redux-sigma` we rely on _sub state machines_. 408 | 409 | ```typescript 410 | import { mySubStateMachine } from './my-sub-state-machine'; 411 | 412 | class MyStateMachine extends StateMachine { 413 | spec = { 414 | state_1: { 415 | subMachines: [mySubStateMachine], 416 | }, 417 | }; 418 | } 419 | ``` 420 | 421 | To add a sub state machine to a state, define the sub state machine 422 | and add its instance to the `subMachines` field of the parent state machine. 423 | 424 | This syntax only works with sub state machines that use the default context, 425 | or that have no required fields in their context. 426 | 427 | If your sub state machine requires some info in the initial context (see the [context section](#initial-context)), 428 | you must build the initial context from the context of the parent state machine, 429 | and bind it to the sub state machine: 430 | 431 | ```typescript 432 | import { StateMachine, bindStm } from 'redux-sigma'; 433 | import { mySubStateMachine } from './my-sub-state-machine'; 434 | 435 | class MyStateMachine extends StateMachine { 436 | spec = { 437 | state_1: { 438 | subMachines: [bindStm(mySubStateMachine, this.buildContext)], 439 | }, 440 | }; 441 | 442 | buildContext() { 443 | // you can access the context of MyStateMachine from here 444 | return { 445 | counter: 5, 446 | }; 447 | }; 448 | } 449 | ``` 450 | 451 | ## Going full TypeScript 452 | 453 | The `StateMachine` class is generic, and the recommended usage is to rely on those generics. 454 | 455 | The generics allow you to restrict the possible values of: 456 | 457 | - the events that the state machine reacts to 458 | - the states that the state machine can be in 459 | - the names of all your state machines (to avoid conflicts) 460 | - the context (or extended state) of the state machine 461 | 462 | Generics are specified in this order: 463 | 464 | ```typescript 465 | class MyStateMachine extends StateMachine { 466 | /* ... */ 467 | } 468 | ``` 469 | 470 | The `Events`, `States`, and `StateMachineNames` generics must all be string-like. 471 | The `Context` must be an object. 472 | 473 | - The `Events` generic restricts what action types can be used as keys 474 | in the `transitions` and `reactions` section of the specification. 475 | - The `States` generic determines what states must be used as keys in the specification. 476 | - The `StateMachineNames` generic restricts what value can be assigned to the `name` of the state machine. 477 | - For more details on `Context`, see the [context section](#context-or-extended-state). 478 | 479 | By default, the state machine can have any string as `Events`, `States`, and `StateMachineNames`. 480 | The `Context` defaults to an empty object. 481 | 482 | Our preferred approach is to use TypeScript `enum`s to define `Events`, `States`, and `StateMachineNames`: 483 | 484 | ```typescript 485 | enum Events { 486 | event_1 = 'first event', 487 | event_2 = 'second event', 488 | } 489 | 490 | enum States { 491 | state_1 = 'first state', 492 | state_2 = 'second state', 493 | } 494 | 495 | enum StateMachineNames { 496 | my_state_machine = 'my state machine', 497 | } 498 | ``` 499 | 500 | You can use a single `Events` enum for all your state machines, 501 | or divide your events in multiple enums, possibly combining them in a single state machine. 502 | 503 | Each state machine should have its own `States` enum. 504 | It's still possible to reuse a single `States` enum across multiple state machines. 505 | 506 | There should be a single `StateMachineNames` enum in your application. 507 | 508 | When relying on generics and on TypeScript, your state machines will look like this: 509 | 510 | ```typescript 511 | class MyStateMachine extends StateMachine { 512 | protected readonly initialState = States.state_1; 513 | 514 | readonly name = StateMachineNames.my_state_machine; 515 | 516 | protected readonly spec = { 517 | [States.state_1]: { 518 | transitions: { 519 | [Events.event_1]: States.state_2, 520 | }, 521 | }, 522 | [States.state_2]: { 523 | transitions: { 524 | [Events.event_2]: States.state_1, 525 | }, 526 | }, 527 | }; 528 | } 529 | ``` 530 | 531 | When using generics, TypeScript will enforce that all the states have a definition 532 | inside the state machine `spec`, and that all events that are used in `reactions` 533 | or `transitions` appear in your events definition. 534 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Search example 2 | 3 | This folder contains an example taken from our talk at the 4 | [**Codemotion Online Tech Conference 2020 - Italian edition**](https://events.codemotion.com/conferences/online/2020/online-tech-conference-italian-edition#home). 5 | 6 | The example implements this state machine: 7 | 8 | ![Search state machine example](https://github.com/moveaxlab/redux-sigma/raw/master/assets/search-example.png?raw=true) 9 | 10 | There are two implementations in this folder: 11 | 12 | - a "light" TypeScript implementation, that does not rely too heavily on generics 13 | - a full TypeScript implementation, that adds a lot of types annotation 14 | 15 | The example shows the following features of `redux-sigma`: 16 | 17 | - guarded transitions 18 | - `onEntry` activities 19 | - commands on transitions 20 | - usage of the context 21 | 22 | You can run both examples using `ts-node`: 23 | 24 | ```bash 25 | $ yarn run ts-node ./examples/full-typescrpit.ts 26 | ``` 27 | -------------------------------------------------------------------------------- /example/api.ts: -------------------------------------------------------------------------------- 1 | const possibleResults = ['result 1', 'result 2', 'result 3']; 2 | 3 | export const API = { 4 | search: (input: string): string[] => { 5 | return possibleResults.filter(result => result.match(input)); 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /example/full-typescript.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { StateMachine, stateMachineStarterSaga } from '../src'; 3 | import { Action, applyMiddleware, combineReducers, createStore } from 'redux'; 4 | import createSagaMiddleware from 'redux-saga'; 5 | import { 6 | delay, 7 | call, 8 | put, 9 | take, 10 | select, 11 | StrictEffect, 12 | } from 'redux-saga/effects'; 13 | import { API } from './api'; 14 | import { storeStmStateActionType } from '../src/constants'; 15 | import { StmStorage } from '../src/spec/storage'; 16 | 17 | interface Context { 18 | input?: string; 19 | results?: string[]; 20 | } 21 | 22 | enum Events { 23 | inputChanged = 'input_changed', 24 | noResults = 'no_results', 25 | resultsFound = 'results_found', 26 | searchFailed = 'search_failed', 27 | } 28 | 29 | enum States { 30 | noInput = 'no_input', 31 | searching = 'searching', 32 | noResults = 'no_results', 33 | resultsFound = 'results_found', 34 | searchFailed = 'search_failed', 35 | } 36 | 37 | enum StateMachineNames { 38 | search = 'search', 39 | } 40 | 41 | interface InputChangedEvent extends Action { 42 | payload: string; 43 | } 44 | 45 | class SearchStateMachine extends StateMachine< 46 | Events, 47 | States, 48 | StateMachineNames, 49 | Context 50 | > { 51 | readonly name = StateMachineNames.search; 52 | 53 | protected readonly initialState = States.noInput; 54 | 55 | protected readonly spec = { 56 | [States.noInput]: { 57 | transitions: { 58 | [Events.inputChanged]: { 59 | guard: (event: InputChangedEvent) => event.payload.length > 0, 60 | command: this.storeInput, 61 | target: States.searching, 62 | }, 63 | }, 64 | }, 65 | [States.searching]: { 66 | onEntry: this.search, 67 | transitions: { 68 | [Events.inputChanged]: [ 69 | { 70 | guard: (event: InputChangedEvent) => event.payload.length == 0, 71 | command: this.storeInput, 72 | target: States.noInput, 73 | }, 74 | { 75 | guard: (event: InputChangedEvent) => event.payload.length > 0, 76 | command: this.storeInput, 77 | target: States.searching, 78 | }, 79 | ], 80 | [Events.resultsFound]: States.resultsFound, 81 | [Events.noResults]: States.noResults, 82 | [Events.searchFailed]: States.searchFailed, 83 | }, 84 | }, 85 | [States.noResults]: { 86 | transitions: { 87 | [Events.inputChanged]: [ 88 | { 89 | guard: (event: InputChangedEvent) => event.payload.length == 0, 90 | command: this.storeInput, 91 | target: States.noInput, 92 | }, 93 | { 94 | guard: (event: InputChangedEvent) => event.payload.length > 0, 95 | command: this.storeInput, 96 | target: States.searching, 97 | }, 98 | ], 99 | }, 100 | }, 101 | [States.searchFailed]: { 102 | transitions: { 103 | [Events.inputChanged]: [ 104 | { 105 | guard: (event: InputChangedEvent) => event.payload.length == 0, 106 | command: this.storeInput, 107 | target: States.noInput, 108 | }, 109 | { 110 | guard: (event: InputChangedEvent) => event.payload.length > 0, 111 | command: this.storeInput, 112 | target: States.searching, 113 | }, 114 | ], 115 | }, 116 | }, 117 | [States.resultsFound]: { 118 | transitions: { 119 | [Events.inputChanged]: [ 120 | { 121 | guard: (event: InputChangedEvent) => event.payload.length == 0, 122 | command: this.storeInput, 123 | target: States.noInput, 124 | }, 125 | { 126 | guard: (event: InputChangedEvent) => event.payload.length > 0, 127 | command: this.storeInput, 128 | target: States.searching, 129 | }, 130 | ], 131 | }, 132 | }, 133 | }; 134 | 135 | *storeInput(event: InputChangedEvent): Generator { 136 | yield* this.setContext(ctx => { 137 | ctx.input = event.payload; 138 | }); 139 | } 140 | 141 | *search(): Generator { 142 | yield delay(300); 143 | try { 144 | const results = (yield call(API.search, this.context.input!)) as string[]; 145 | yield* this.setContext(ctx => { 146 | ctx.results = results; 147 | }); 148 | if (results.length > 0) { 149 | yield put({ type: Events.resultsFound }); 150 | } else { 151 | yield put({ type: Events.noResults }); 152 | } 153 | } catch (e) { 154 | yield put({ type: Events.searchFailed }); 155 | } 156 | } 157 | } 158 | 159 | const stateMachine = new SearchStateMachine(); 160 | 161 | const sagaMiddleware = createSagaMiddleware(); 162 | 163 | const rootReducer = combineReducers({ 164 | search: stateMachine.stateReducer, 165 | }); 166 | 167 | createStore(rootReducer, applyMiddleware(sagaMiddleware)); 168 | 169 | type Store = ReturnType; 170 | 171 | sagaMiddleware.run(stateMachineStarterSaga, stateMachine); 172 | 173 | function* rootSaga(): Generator { 174 | yield put(stateMachine.start({})); 175 | 176 | yield put({ type: 'wait for redux saga to flush action queue ' }); 177 | 178 | let state = (yield select((state: Store) => state.search)) as StmStorage< 179 | States, 180 | Context 181 | >; 182 | 183 | console.log('initial state:', state.state); 184 | console.log('initial input:', state.context!.input); 185 | 186 | console.log('changing input to', '1'); 187 | yield put({ type: Events.inputChanged, payload: '1' }); 188 | yield take(storeStmStateActionType); // wait for the state machine to change state 189 | 190 | state = (yield select((state: Store) => state.search)) as StmStorage< 191 | States, 192 | Context 193 | >; 194 | 195 | console.log('state:', state.state); 196 | console.log('input:', state.context!.input); 197 | 198 | yield take(Events.resultsFound); 199 | yield take(storeStmStateActionType); // wait for the state machine to change state 200 | console.log('results found'); 201 | 202 | state = (yield select((state: Store) => state.search)) as StmStorage< 203 | States, 204 | Context 205 | >; 206 | 207 | console.log('state:', state.state); 208 | console.log('results:', state.context!.results); 209 | 210 | console.log('changing input to', 'res'); 211 | yield put({ type: Events.inputChanged, payload: 'res' }); 212 | yield take(storeStmStateActionType); // wait for the state machine to change state 213 | 214 | state = (yield select((state: Store) => state.search)) as StmStorage< 215 | States, 216 | Context 217 | >; 218 | 219 | console.log('state:', state.state); 220 | console.log('input:', state.context!.input); 221 | 222 | console.log('changing input to', 'response'); 223 | yield put({ type: Events.inputChanged, payload: 'response' }); 224 | yield take(storeStmStateActionType); // wait for the state machine to change state 225 | 226 | state = (yield select((state: Store) => state.search)) as StmStorage< 227 | States, 228 | Context 229 | >; 230 | 231 | console.log('state:', state.state); 232 | console.log('input:', state.context!.input); 233 | 234 | yield take(Events.noResults); 235 | yield take(storeStmStateActionType); // wait for the state machine to change state 236 | console.log('no results found'); 237 | 238 | state = (yield select((state: Store) => state.search)) as StmStorage< 239 | States, 240 | Context 241 | >; 242 | 243 | console.log('state:', state.state); 244 | } 245 | 246 | sagaMiddleware.run(rootSaga); 247 | -------------------------------------------------------------------------------- /example/light-typescript.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { StateMachine, stateMachineStarterSaga } from '../src'; 3 | import { Action, applyMiddleware, combineReducers, createStore } from 'redux'; 4 | import createSagaMiddleware from 'redux-saga'; 5 | import { delay, call, put, take, select } from 'redux-saga/effects'; 6 | import { API } from './api'; 7 | import { storeStmStateActionType } from '../src/constants'; 8 | 9 | interface Context { 10 | input?: string; 11 | results?: string[]; 12 | } 13 | 14 | interface InputChangedEvent extends Action { 15 | payload: string; 16 | } 17 | 18 | class SearchStateMachine extends StateMachine { 19 | readonly name = 'search'; 20 | 21 | protected readonly initialState = 'no_input'; 22 | 23 | protected readonly spec = { 24 | no_input: { 25 | transitions: { 26 | input_changed: { 27 | guard: (event: InputChangedEvent) => event.payload.length > 0, 28 | command: this.storeInput, 29 | target: 'searching', 30 | }, 31 | }, 32 | }, 33 | searching: { 34 | onEntry: this.search, 35 | transitions: { 36 | input_changed: [ 37 | { 38 | guard: (event: InputChangedEvent) => event.payload.length == 0, 39 | command: this.storeInput, 40 | target: 'no_input', 41 | }, 42 | { 43 | guard: (event: InputChangedEvent) => event.payload.length > 0, 44 | command: this.storeInput, 45 | target: 'searching', 46 | }, 47 | ], 48 | results_found: 'results_found', 49 | no_results: 'no_results', 50 | search_failed: 'search_failed', 51 | }, 52 | }, 53 | no_results: { 54 | transitions: { 55 | input_changed: [ 56 | { 57 | guard: (event: InputChangedEvent) => event.payload.length == 0, 58 | command: this.storeInput, 59 | target: 'no_input', 60 | }, 61 | { 62 | guard: (event: InputChangedEvent) => event.payload.length > 0, 63 | command: this.storeInput, 64 | target: 'searching', 65 | }, 66 | ], 67 | }, 68 | }, 69 | search_failed: { 70 | transitions: { 71 | input_changed: [ 72 | { 73 | guard: (event: InputChangedEvent) => event.payload.length == 0, 74 | command: this.storeInput, 75 | target: 'no_input', 76 | }, 77 | { 78 | guard: (event: InputChangedEvent) => event.payload.length > 0, 79 | command: this.storeInput, 80 | target: 'searching', 81 | }, 82 | ], 83 | }, 84 | }, 85 | results_found: { 86 | transitions: { 87 | input_changed: [ 88 | { 89 | guard: (event: InputChangedEvent) => event.payload.length == 0, 90 | command: this.storeInput, 91 | target: 'no_input', 92 | }, 93 | { 94 | guard: (event: InputChangedEvent) => event.payload.length > 0, 95 | command: this.storeInput, 96 | target: 'searching', 97 | }, 98 | ], 99 | }, 100 | }, 101 | }; 102 | 103 | *storeInput(event: InputChangedEvent) { 104 | yield* this.setContext(ctx => { 105 | ctx.input = event.payload; 106 | }); 107 | } 108 | 109 | *search() { 110 | yield delay(300); 111 | try { 112 | const results = yield call(API.search, this.context.input!); 113 | yield* this.setContext(ctx => { 114 | ctx.results = results; 115 | }); 116 | if (results.length > 0) { 117 | yield put({ type: 'results_found' }); 118 | } else { 119 | yield put({ type: 'no_results' }); 120 | } 121 | } catch (e) { 122 | yield put({ type: 'search_failed' }); 123 | } 124 | } 125 | } 126 | 127 | const stateMachine = new SearchStateMachine(); 128 | 129 | const sagaMiddleware = createSagaMiddleware(); 130 | 131 | const rootReducer = combineReducers({ 132 | search: stateMachine.stateReducer, 133 | }); 134 | 135 | createStore(rootReducer, applyMiddleware(sagaMiddleware)); 136 | 137 | sagaMiddleware.run(stateMachineStarterSaga, stateMachine); 138 | 139 | function* rootSaga() { 140 | yield put(stateMachine.start({})); 141 | 142 | yield put({ type: 'wait for redux saga to flush action queue ' }); 143 | 144 | let state = yield select(state => state.search); 145 | 146 | console.log('initial state:', state.state); 147 | console.log('initial input:', state.context.input); 148 | 149 | console.log('changing input to', '1'); 150 | yield put({ type: 'input_changed', payload: '1' }); 151 | yield take(storeStmStateActionType); // wait for the state machine to change state 152 | 153 | state = yield select(state => state.search); 154 | 155 | console.log('state:', state.state); 156 | console.log('input:', state.context.input); 157 | 158 | yield take('results_found'); 159 | yield take(storeStmStateActionType); // wait for the state machine to change state 160 | console.log('results found'); 161 | 162 | state = yield select(state => state.search); 163 | 164 | console.log('state:', state.state); 165 | console.log('results:', state.context.results); 166 | 167 | console.log('changing input to', 'res'); 168 | yield put({ type: 'input_changed', payload: 'res' }); 169 | yield take(storeStmStateActionType); // wait for the state machine to change state 170 | 171 | state = yield select(state => state.search); 172 | 173 | console.log('state:', state.state); 174 | console.log('input:', state.context.input); 175 | 176 | console.log('changing input to', 'response'); 177 | yield put({ type: 'input_changed', payload: 'response' }); 178 | yield take(storeStmStateActionType); // wait for the state machine to change state 179 | 180 | state = yield select(state => state.search); 181 | 182 | console.log('state:', state.state); 183 | console.log('input:', state.context.input); 184 | 185 | yield take('no_results'); 186 | yield take(storeStmStateActionType); // wait for the state machine to change state 187 | console.log('no results found'); 188 | 189 | state = yield select(state => state.search); 190 | 191 | console.log('state:', state.state); 192 | } 193 | 194 | sagaMiddleware.run(rootSaga); 195 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | import 'jest-extended'; 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testMatch: ['/tests/*.test.ts'], 5 | collectCoverageFrom: ['/src/**/*.ts'], 6 | collectCoverage: true, 7 | coverageReporters: ['text', 'lcov'], 8 | setupFilesAfterEnv: ['jest-extended'], 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-sigma", 3 | "version": "0.8.0-beta.3", 4 | "description": "A state machine library for redux and redux-saga.", 5 | "contributors": [ 6 | "Fabrizio Cavaniglia ", 7 | "Furio Dipoppa ", 8 | "Gabrio Tognozzi ", 9 | "Michelle Laurenti " 10 | ], 11 | "homepage": "https://github.com/moveaxlab/redux-sigma", 12 | "license": "MIT", 13 | "main": "./lib/index.js", 14 | "module": "./lib/index.es.js", 15 | "types": "./types/index.d.ts", 16 | "directories": { 17 | "lib": "src" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/moveaxlab/redux-sigma.git" 22 | }, 23 | "scripts": { 24 | "prepack": "yarn build", 25 | "build": "npm-run-all build:*", 26 | "build:clean": "rimraf lib types", 27 | "build:code": "rollup -c", 28 | "test": "npm-run-all test:*", 29 | "test:types": "tsc -p tsconfig.json", 30 | "test:lint": "yarn eslint --ext .ts src", 31 | "test:unit": "jest", 32 | "test:example": "npm-run-all test:example:*", 33 | "test:example:light": "ts-node ./example/light-typescript.ts", 34 | "test:example:full": "ts-node ./example/full-typescript.ts" 35 | }, 36 | "bugs": { 37 | "url": "https://github.com/moveaxlab/redux-sigma/issues" 38 | }, 39 | "devDependencies": { 40 | "@moveaxlab/eslint-config": "^0.4.0", 41 | "@moveaxlab/redux-saga-tester": "^2.0.4", 42 | "@types/jest": "^26.0.15", 43 | "@types/node": "^12.12.6", 44 | "coveralls": "^3.1.0", 45 | "eslint": "^6.8.0", 46 | "husky": "^4.3.0", 47 | "jest": "^26.6.3", 48 | "jest-extended": "^0.11.5", 49 | "npm-run-all": "^4.1.5", 50 | "redux": "^4.0.0", 51 | "redux-saga": "^1.1.0", 52 | "rollup": "^2.33.3", 53 | "rollup-plugin-auto-external": "^2.0.0", 54 | "rollup-plugin-ts": "^1.3.7", 55 | "ts-jest": "^26.4.4", 56 | "ts-node": "^8.3.0", 57 | "typescript": "^3.7.5", 58 | "wait-for-expect": "^3.0.1" 59 | }, 60 | "peerDependencies": { 61 | "redux": "^4.0.0", 62 | "redux-saga": "^1.1.0" 63 | }, 64 | "dependencies": { 65 | "immer": "^8.0.1" 66 | }, 67 | "keywords": [ 68 | "state machines", 69 | "redux", 70 | "redux-saga", 71 | "statecharts" 72 | ] 73 | } 74 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import ts from 'rollup-plugin-ts'; 2 | import autoExternal from 'rollup-plugin-auto-external'; 3 | 4 | export default [ 5 | { 6 | input: 'src/index.ts', 7 | output: [ 8 | { 9 | file: 'lib/index.js', 10 | format: 'cjs', 11 | }, 12 | { 13 | file: 'lib/index.es.js', 14 | format: 'es', 15 | }, 16 | ], 17 | external: ['redux-saga/effects'], 18 | plugins: [ 19 | autoExternal(), 20 | ts({ 21 | tsconfig: './tsconfig.build.json', 22 | }), 23 | ], 24 | }, 25 | { 26 | input: 'src/index.ts', 27 | output: { 28 | file: 'types/index.d.ts', 29 | }, 30 | external: ['redux-saga/effects'], 31 | plugins: [ 32 | autoExternal(), 33 | ts({ 34 | tsconfig: './tsconfig.build-types.json', 35 | transpileOnly: true, 36 | }), 37 | ], 38 | }, 39 | ]; 40 | -------------------------------------------------------------------------------- /src/StateMachine.ts: -------------------------------------------------------------------------------- 1 | import { Channel, StrictEffect, Task } from '@redux-saga/types'; 2 | import produce from 'immer'; 3 | import { AnyAction } from 'redux'; 4 | import { 5 | actionChannel, 6 | call, 7 | cancel, 8 | fork, 9 | put, 10 | putResolve, 11 | race, 12 | take, 13 | } from 'redux-saga/effects'; 14 | import { 15 | REACTION_POLICY_ALL, 16 | REACTION_POLICY_FIRST, 17 | REACTION_POLICY_LAST, 18 | startStmActionType, 19 | stmStartedActionType, 20 | stmStoppedActionType, 21 | stopStmActionType, 22 | storeStmContextActionType, 23 | storeStmStateActionType, 24 | } from './constants'; 25 | import { StateMachineSpec } from './spec'; 26 | import { 27 | isFunction, 28 | isGuardedTransition, 29 | isReactionSpec, 30 | isSimpleTransition, 31 | isStarted, 32 | isStateTransition, 33 | } from './typeGuards'; 34 | import { AnyEvent, Event } from './spec/events'; 35 | import { Activity } from './spec/activities'; 36 | import { TransitionSpec, TransitionTrigger } from './spec/transitions'; 37 | import { ReactionPolicy } from './spec/reactions'; 38 | import { StmStorage } from './spec/storage'; 39 | import { 40 | StartStateMachineAction, 41 | StateMachineStartedAction, 42 | StateMachineStoppedAction, 43 | StopStateMachineAction, 44 | StoreStateMachineContext, 45 | StoreStateMachineState, 46 | } from './spec/actions'; 47 | import { StateMachineInterface } from './spec/base'; 48 | import { buffers } from 'redux-saga'; 49 | 50 | export abstract class StateMachine< 51 | E extends string = string, 52 | S extends string = string, 53 | SM extends string = string, 54 | C = {}, 55 | IS extends S = S, 56 | N extends SM = SM 57 | > implements StateMachineInterface { 58 | abstract readonly name: N; 59 | 60 | private _context!: C; 61 | 62 | protected abstract readonly spec: StateMachineSpec; 63 | 64 | protected abstract readonly initialState: IS; 65 | 66 | private runningTasks: Task[] = []; 67 | 68 | private currentState!: S; 69 | 70 | private transitionChannel!: Channel>; 71 | 72 | /** 73 | * Returns a redux action that will start this state machine when dispatched, 74 | * with the initial context provided in input. 75 | * 76 | * @param context The initial context of the state machine. 77 | */ 78 | start = (context: C): StartStateMachineAction => { 79 | const initialContext = produce(null, () => context) as C; 80 | return { 81 | type: startStmActionType, 82 | payload: { 83 | name: this.name, 84 | context: initialContext, 85 | }, 86 | }; 87 | }; 88 | 89 | /** 90 | * Returns a redux action that will stop this state machine when dispatched. 91 | */ 92 | stop = (): StopStateMachineAction => { 93 | return { 94 | type: stopStmActionType, 95 | payload: { 96 | name: this.name, 97 | }, 98 | }; 99 | }; 100 | 101 | /** 102 | * Returns a redux action that signals that the state machine 103 | * was successfully started. 104 | */ 105 | private started = (context: C): StateMachineStartedAction => { 106 | return { 107 | type: stmStartedActionType, 108 | payload: { 109 | name: this.name, 110 | context, 111 | }, 112 | }; 113 | }; 114 | 115 | /** 116 | * Returns a redux action that signals that the state machine 117 | * was successfully stopped. 118 | */ 119 | private stopped = (): StateMachineStoppedAction => { 120 | return { 121 | type: stmStoppedActionType, 122 | payload: { 123 | name: this.name, 124 | }, 125 | }; 126 | }; 127 | 128 | /** 129 | * Returns an action that will update the state of this state machine stored 130 | * by the `stateReducer`. 131 | * 132 | * @param state The new state. 133 | */ 134 | private storeState = (state: S): StoreStateMachineState => { 135 | return { 136 | type: storeStmStateActionType, 137 | payload: { 138 | name: this.name, 139 | state, 140 | }, 141 | }; 142 | }; 143 | 144 | /** 145 | * Returns an action that will update the context of this state machine stored 146 | * by the `stateReducer`. 147 | * 148 | * @param context The new context. 149 | */ 150 | private storeContext = (context: C): StoreStateMachineContext => { 151 | return { 152 | type: storeStmContextActionType, 153 | payload: { 154 | name: this.name, 155 | context, 156 | }, 157 | }; 158 | }; 159 | 160 | /** 161 | * Computes the new context and stores it using `storeContext`. 162 | * 163 | * @param newContext The new context, or an immer-style function that mutates 164 | * the current context. 165 | */ 166 | protected *setContext(newContext: C | ((ctx: C) => void)) { 167 | if (isFunction(newContext)) { 168 | this._context = produce(this._context, newContext); 169 | } else { 170 | this._context = produce(null, () => newContext) as C; 171 | } 172 | yield putResolve(this.storeContext(this._context)); 173 | } 174 | 175 | /** 176 | * Returns the current context. 177 | */ 178 | get context(): C { 179 | return this._context; 180 | } 181 | 182 | /** 183 | * This saga is responsible for starting and stopping this state machine. 184 | * It listens to the `start` actions returned by the methods of 185 | * this state machine, and relies on the `run` method to catch `stop` actions. 186 | * 187 | * This saga shouldn't be used directly: rely on `stateMachineStarterSaga` 188 | * instead. 189 | */ 190 | *starterSaga(): Generator { 191 | const startChannel = (yield actionChannel( 192 | (action: AnyAction) => 193 | action.type == startStmActionType && action.payload.name == this.name, 194 | buffers.sliding(1) 195 | )) as Channel>; 196 | 197 | while (true) { 198 | const action = (yield take(startChannel)) as StartStateMachineAction< 199 | N, 200 | C 201 | >; 202 | 203 | yield put(this.started(action.payload.context)); 204 | 205 | yield call([this, this.run], action.payload.context); 206 | 207 | yield put(this.stopped()); 208 | } 209 | } 210 | 211 | /** 212 | * Runs the state machine described by this state machines' spec. 213 | */ 214 | private *run(context: C): Generator { 215 | this._context = context; 216 | 217 | this.currentState = this.initialState; 218 | 219 | const stopChannel = (yield actionChannel( 220 | (action: AnyAction) => 221 | action.type == stopStmActionType && action.payload.name == this.name 222 | )) as Channel>; 223 | 224 | while (true) { 225 | const nextState = (yield call([this, this.stateLoop], stopChannel)) as 226 | | TransitionTrigger 227 | | undefined; 228 | 229 | // no next state was returned => a stop event was received, exit 230 | if (!nextState) { 231 | return; 232 | } 233 | 234 | if (nextState.command) { 235 | if (Array.isArray(nextState.command)) { 236 | for (const saga of nextState.command) { 237 | yield call([this, saga], nextState.event); 238 | } 239 | } else { 240 | yield call([this, nextState.command], nextState.event); 241 | } 242 | } 243 | 244 | this.currentState = nextState.nextState; 245 | 246 | yield put(this.storeState(this.currentState)); 247 | } 248 | } 249 | 250 | /** 251 | * A generator that runs the "loop" for the current state. 252 | * It listens to transition events while running `onEntry` activities and 253 | * `reactions`, and starts sub state machines. As soon as a transition event 254 | * is returned, the state loop is stopped, and the transition trigger is 255 | * returned to the calling function. 256 | * 257 | * The loop listens for both transition events and the stop event. 258 | * The first event that is received exits the loop: a transition event 259 | * returns a TransitionTrigger, while a stop event returns nothing. 260 | */ 261 | private *stateLoop( 262 | stopChannel: Channel> 263 | ): Generator | undefined> { 264 | try { 265 | const { transitions } = this.spec[this.currentState]; 266 | 267 | const transitionEvents = transitions 268 | ? (Object.keys(transitions) as E[]) 269 | : []; 270 | 271 | this.transitionChannel = (yield actionChannel( 272 | transitionEvents 273 | )) as Channel>; 274 | 275 | this.runningTasks.push( 276 | (yield fork([this, this.startOnEntryActivities])) as Task 277 | ); 278 | 279 | this.runningTasks.push( 280 | (yield fork([this, this.registerToReactions])) as Task 281 | ); 282 | 283 | yield call([this, this.startSubMachines]); 284 | 285 | const { nextState } = (yield race({ 286 | nextState: call([this, this.getNextState]), 287 | stop: take(stopChannel), 288 | })) as { nextState: TransitionTrigger | undefined }; 289 | 290 | return nextState; 291 | } finally { 292 | yield call([this, this.cancelRunningTasks]); 293 | yield call([this, this.stopSubMachines]); 294 | yield call([this, this.runOnExitActivities]); 295 | } 296 | } 297 | 298 | /** 299 | * Waits for the first event matching a regular transition or a guarded 300 | * transition, and returns it together with the next state and the 301 | * optional command (or commands) that must be executed before transitioning. 302 | */ 303 | private *getNextState(): Generator> { 304 | while (true) { 305 | const event = (yield take(this.transitionChannel)) as Event; 306 | 307 | const transitionSpec = this.spec[this.currentState].transitions![ 308 | event.type 309 | ]! as TransitionSpec; 310 | 311 | if (isStateTransition(transitionSpec)) { 312 | return { 313 | event, 314 | nextState: transitionSpec, 315 | }; 316 | } else if (isSimpleTransition(transitionSpec)) { 317 | return { 318 | event, 319 | nextState: transitionSpec.target, 320 | command: transitionSpec.command, 321 | }; 322 | } else if (isGuardedTransition(transitionSpec)) { 323 | if (yield call(transitionSpec.guard, event, this.context)) { 324 | return { 325 | event, 326 | nextState: transitionSpec.target, 327 | command: transitionSpec.command, 328 | }; 329 | } 330 | } else { 331 | for (const transitionOption of transitionSpec) { 332 | if (yield call(transitionOption.guard, event, this.context)) 333 | return { 334 | event, 335 | nextState: transitionOption.target, 336 | command: transitionOption.command, 337 | }; 338 | } 339 | } 340 | } 341 | } 342 | 343 | /** 344 | * This reducer stores the current state of the State Machine. It can be 345 | * added to your application reducers if you need to access the state of a 346 | * State Machine somewhere in your application. 347 | * 348 | * @param state The current state and context. 349 | * @param action The action taken by the reducer. 350 | */ 351 | stateReducer = ( 352 | state: StmStorage = { state: null, context: undefined }, 353 | action: 354 | | StoreStateMachineState 355 | | StartStateMachineAction 356 | | StopStateMachineAction 357 | | StateMachineStoppedAction 358 | | StateMachineStartedAction 359 | | StoreStateMachineContext 360 | ): StmStorage => { 361 | if (action.payload?.name !== this.name) { 362 | return state; 363 | } 364 | 365 | switch (action.type) { 366 | case stmStartedActionType: 367 | return { 368 | state: this.initialState, 369 | context: action.payload.context, 370 | }; 371 | 372 | case stopStmActionType: 373 | return { 374 | state: null, 375 | context: undefined, 376 | }; 377 | 378 | case storeStmContextActionType: 379 | if (!isStarted(state)) { 380 | return state; 381 | } else { 382 | return { 383 | state: state.state, 384 | context: action.payload.context, 385 | }; 386 | } 387 | 388 | case storeStmStateActionType: 389 | if (isStarted(state)) { 390 | return { 391 | state: action.payload.state, 392 | context: state.context, 393 | }; 394 | } else { 395 | return state; 396 | } 397 | 398 | default: 399 | return state; 400 | } 401 | }; 402 | 403 | /** 404 | * Starts all onEntry activities. Does not wait for them to complete. 405 | */ 406 | private *startOnEntryActivities(): Generator { 407 | const { onEntry } = this.spec[this.currentState]; 408 | 409 | if (onEntry) { 410 | if (Array.isArray(onEntry)) { 411 | for (const saga of onEntry) { 412 | this.runningTasks.push((yield fork([this, saga])) as Task); 413 | } 414 | } else { 415 | this.runningTasks.push((yield fork([this, onEntry])) as Task); 416 | } 417 | } 418 | } 419 | 420 | /** 421 | * Starts and adds the background tasks listening to reactions 422 | * in the background task list. 423 | */ 424 | private *registerToReactions(): Generator { 425 | const { reactions } = this.spec[this.currentState]; 426 | if (reactions) { 427 | const eventTypes = Object.keys(reactions) as E[]; 428 | 429 | for (const eventType of eventTypes) { 430 | const reaction = reactions[eventType]! as Activity; 431 | 432 | const [activity, policy] = isReactionSpec(reaction) 433 | ? [reaction.activity, reaction.policy] 434 | : [reaction, REACTION_POLICY_ALL as ReactionPolicy]; 435 | 436 | let task: Task; 437 | 438 | switch (policy) { 439 | case REACTION_POLICY_LAST: { 440 | task = (yield fork( 441 | [this, this.takeLast], 442 | eventType, 443 | activity 444 | )) as Task; 445 | break; 446 | } 447 | case REACTION_POLICY_FIRST: { 448 | task = (yield fork( 449 | [this, this.takeFirst], 450 | eventType, 451 | activity 452 | )) as Task; 453 | break; 454 | } 455 | case REACTION_POLICY_ALL: { 456 | task = (yield fork( 457 | [this, this.takeAll], 458 | eventType, 459 | activity 460 | )) as Task; 461 | break; 462 | } 463 | } 464 | //enqueue task 465 | this.runningTasks.push(task); 466 | } 467 | } 468 | } 469 | 470 | /** 471 | * Implements the `first` reaction policy: once an event triggering a reaction 472 | * is received, no other event are processed until the first event has 473 | * complete its processing. 474 | * 475 | * @param eventType The event triggering the reaction 476 | * @param activity The reaction activity 477 | */ 478 | private *takeFirst( 479 | eventType: E, 480 | activity: Activity 481 | ): Generator { 482 | while (true) { 483 | const event = (yield take(eventType)) as AnyEvent; 484 | yield call([this, activity], event); 485 | } 486 | } 487 | 488 | /** 489 | * Implements the `last` reaction policy: events are processed as they come. 490 | * If a new event is received while a reaction is running, the old reaction 491 | * is stopped, and a new reaction starts running. 492 | * 493 | * @param eventType The event triggering the reaction 494 | * @param activity The reaction activity 495 | */ 496 | private *takeLast( 497 | eventType: E, 498 | activity: Activity 499 | ): Generator { 500 | const channel = (yield actionChannel(eventType)) as Channel>; 501 | let task: Task | null = null; 502 | while (true) { 503 | const event = (yield take(channel)) as AnyEvent; 504 | if (task !== null) { 505 | yield cancel(task); 506 | } 507 | task = (yield fork([this, activity], event)) as Task; 508 | } 509 | } 510 | 511 | /** 512 | * Implements the `all` reaction policy: events that can trigger a reaction 513 | * are stored in a queue, and processed sequentially. 514 | * 515 | * @param eventType The event triggering the reaction 516 | * @param activity The reaction activity 517 | */ 518 | private *takeAll( 519 | eventType: E, 520 | activity: Activity 521 | ): Generator { 522 | const channel = (yield actionChannel(eventType)) as Channel>; 523 | while (true) { 524 | const event = (yield take(channel)) as AnyEvent; 525 | yield call([this, activity], event); 526 | } 527 | } 528 | 529 | /** 530 | * Stops all running tasks. Used when exiting from a state 531 | * or when the state machine is stopped. 532 | */ 533 | private *cancelRunningTasks(): Generator { 534 | yield cancel(this.runningTasks); 535 | this.runningTasks = []; 536 | } 537 | 538 | /** 539 | * Starts all sub state machines for the current state.. 540 | */ 541 | private *startSubMachines(): Generator { 542 | let { subMachines } = this.spec[this.currentState]; 543 | 544 | if (!subMachines) return; 545 | 546 | if (!Array.isArray(subMachines)) { 547 | subMachines = [subMachines]; 548 | } 549 | 550 | for (const subMachine of subMachines) { 551 | if ('stm' in subMachine) { 552 | const ctx = yield call([this, subMachine.contextBuilder]); 553 | yield put(subMachine.stm.start(ctx)); 554 | } else { 555 | yield put(subMachine.start({})); 556 | } 557 | } 558 | } 559 | 560 | /** 561 | * Stops all state machines for the current state. 562 | */ 563 | private *stopSubMachines(): Generator { 564 | let { subMachines } = this.spec[this.currentState]; 565 | 566 | if (!subMachines) return; 567 | 568 | if (!Array.isArray(subMachines)) { 569 | subMachines = [subMachines]; 570 | } 571 | 572 | for (const subMachine of subMachines) { 573 | if ('stm' in subMachine) { 574 | yield put(subMachine.stm.stop()); 575 | } else { 576 | yield put(subMachine.stop()); 577 | } 578 | } 579 | } 580 | 581 | /** 582 | * Runs all onExit activities, and waits for them to return before continuing. 583 | */ 584 | private *runOnExitActivities(): Generator { 585 | const { onExit } = this.spec[this.currentState]; 586 | 587 | if (onExit) { 588 | if (Array.isArray(onExit)) { 589 | for (const saga of onExit) { 590 | yield call([this, saga]); 591 | } 592 | } else { 593 | yield call([this, onExit]); 594 | } 595 | } 596 | } 597 | } 598 | -------------------------------------------------------------------------------- /src/bindStm.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { StrictEffect } from 'redux-saga/effects'; 3 | import { StateMachineInterface } from './spec/base'; 4 | import { SubStateMachineWithContext } from './spec/subMachines'; 5 | 6 | /** 7 | * Helper function to bind a sub STM to a context builder function. 8 | * The context builder function is responsible for creating the initial context 9 | * for the sub STM. 10 | * 11 | * This is just some TypeScript magic (aka inference) to make sure that 12 | * the sub state machine context contract is respected. 13 | * 14 | * @param stm the sub state machine to start 15 | * @param contextBuilder a function or saga that returns 16 | * the initial context for `stm` 17 | * 18 | * @returns a sub state machine with context descriptor, 19 | * used inside another STM spec 20 | */ 21 | export function bindStm( 22 | stm: StateMachineInterface, 23 | contextBuilder: (() => SC) | (() => Generator) 24 | ): SubStateMachineWithContext { 25 | return { 26 | stm, 27 | contextBuilder, 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const startStmActionType = '@@redux-sigma/start-stm'; 2 | 3 | export const stmStartedActionType = '@@redux-sigma/stm-started'; 4 | 5 | export const stopStmActionType = '@@redux-sigma/stop-stm'; 6 | 7 | export const stmStoppedActionType = '@@redux-sigma/stm-stopped'; 8 | 9 | export const storeStmStateActionType = '@@redux-sigma/store-state'; 10 | 11 | export const storeStmContextActionType = '@@redux-sigma/store-context'; 12 | 13 | export const REACTION_POLICY_FIRST = 'REACTION_POLICY_FIRST'; 14 | 15 | export const REACTION_POLICY_LAST = 'REACTION_POLICY_LAST'; 16 | 17 | export const REACTION_POLICY_ALL = 'REACTION_POLICY_ALL'; 18 | -------------------------------------------------------------------------------- /src/guards.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns the negation of the input function. 3 | * 4 | * @param f A function. 5 | */ 6 | export function not( 7 | f: (...args: A) => boolean 8 | ): (...args: A) => boolean { 9 | return (...args: A) => !f(...args); 10 | } 11 | 12 | /** 13 | * Returns a boolean function returning true if all input functions 14 | * return true. 15 | * 16 | * @param fs An array of functions. 17 | */ 18 | export function and( 19 | ...fs: Array<(...args: A) => boolean> 20 | ): (...args: A) => boolean { 21 | return function(...args: A): boolean { 22 | return fs.every(f => f(...args)); 23 | }; 24 | } 25 | 26 | /** 27 | * Returns a boolean function returning true if at least one input functions 28 | * returns true. 29 | * 30 | * @param fs An array of functions. 31 | */ 32 | export function or( 33 | ...fs: Array<(...args: A) => boolean> 34 | ): (...args: A) => boolean { 35 | return function(...args: A): boolean { 36 | return fs.some(f => f(...args)); 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { StateMachine } from './StateMachine'; 2 | import { StateMachineSpec } from './spec'; 3 | import { and, not, or } from './guards'; 4 | import { all, first, last } from './policies'; 5 | import { bindStm } from './bindStm'; 6 | import { stateMachineStarterSaga } from './stateMachineStarterSaga'; 7 | import { StateMachineInterface } from './spec/base'; 8 | import { 9 | StmStorage, 10 | StartedStmStorage, 11 | StoppedStmStorage, 12 | } from './spec/storage'; 13 | 14 | export { 15 | StateMachine, 16 | StateMachineInterface, 17 | StateMachineSpec, 18 | StmStorage, 19 | StartedStmStorage, 20 | StoppedStmStorage, 21 | stateMachineStarterSaga, 22 | and, 23 | not, 24 | or, 25 | all, 26 | first, 27 | last, 28 | bindStm, 29 | }; 30 | -------------------------------------------------------------------------------- /src/policies.ts: -------------------------------------------------------------------------------- 1 | import { 2 | REACTION_POLICY_ALL, 3 | REACTION_POLICY_FIRST, 4 | REACTION_POLICY_LAST, 5 | } from './constants'; 6 | import { Activity } from './spec/activities'; 7 | import { ReactionSpec } from './spec/reactions'; 8 | 9 | /** 10 | * Instructs redux-sigma to use the `all` reaction policy for the input activity. 11 | * 12 | * @param activity An activity. 13 | */ 14 | export function all(activity: Activity): ReactionSpec { 15 | return { 16 | activity, 17 | policy: REACTION_POLICY_ALL, 18 | }; 19 | } 20 | 21 | /** 22 | * Instructs redux-sigma to use the `last` reaction policy for the input activity. 23 | * 24 | * @param activity An activity. 25 | */ 26 | export function last(activity: Activity): ReactionSpec { 27 | return { 28 | activity, 29 | policy: REACTION_POLICY_LAST, 30 | }; 31 | } 32 | 33 | /** 34 | * Instructs redux-sigma to use the `first` reaction policy for the input activity. 35 | * 36 | * @param activity An activity. 37 | */ 38 | export function first( 39 | activity: Activity 40 | ): ReactionSpec { 41 | return { 42 | activity, 43 | policy: REACTION_POLICY_FIRST, 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /src/spec/actions.ts: -------------------------------------------------------------------------------- 1 | import { Action } from 'redux'; 2 | import { 3 | startStmActionType, 4 | stmStartedActionType, 5 | stopStmActionType, 6 | stmStoppedActionType, 7 | storeStmContextActionType, 8 | storeStmStateActionType, 9 | } from '../constants'; 10 | 11 | /** 12 | * This type defines the action that can start the state machine. 13 | * It contains the state machine identifier (its name), 14 | * and the initial context for that state machine. 15 | * 16 | * This action has no actual effect on the store. 17 | * The state machine will propagate the context through `StateMachineStartedAction` 18 | * according to its actual state. 19 | */ 20 | export interface StartStateMachineAction 21 | extends Action { 22 | payload: { 23 | name: N; 24 | context: C; 25 | }; 26 | } 27 | 28 | /** 29 | * This type defines the action that signals that a state machine 30 | * was successfully started. It initializes the store with the initial context 31 | * and the initial state of the state machine. 32 | */ 33 | export interface StateMachineStartedAction 34 | extends Action { 35 | payload: { 36 | name: N; 37 | context: C; 38 | }; 39 | } 40 | 41 | /** 42 | * This type defines the action that can stop the state machine. 43 | * It contains the state machine identifier (its name). 44 | */ 45 | export interface StopStateMachineAction 46 | extends Action { 47 | payload: { 48 | name: N; 49 | }; 50 | } 51 | 52 | /** 53 | * This type defines the action that signals that a state machine 54 | * was successfully stopped. It has no practical use at the moment: 55 | * it is only triggered to flush the redux-saga event queue. 56 | * It contains the state machine identifier (its name). 57 | */ 58 | export interface StateMachineStoppedAction 59 | extends Action { 60 | payload: { 61 | name: N; 62 | }; 63 | } 64 | 65 | /** 66 | * This type defines the action that will update the state of the state machine 67 | * stored inside its `stateReducer`. 68 | * It contains the state machine identifier (its name), 69 | * and the new state that will be stored. 70 | */ 71 | export interface StoreStateMachineState 72 | extends Action { 73 | payload: { 74 | name: N; 75 | state: S; 76 | }; 77 | } 78 | 79 | /** 80 | * This type defines the action that will update the context of the state machine 81 | * stored inside its `stateReducer`. 82 | * It contains the state machine identifier (its name), 83 | * and the new context that will be stored. 84 | */ 85 | export interface StoreStateMachineContext 86 | extends Action { 87 | payload: { 88 | name: N; 89 | context: C; 90 | }; 91 | } 92 | -------------------------------------------------------------------------------- /src/spec/activities.ts: -------------------------------------------------------------------------------- 1 | import { Saga } from 'redux-saga'; 2 | import { ErrorEvent, Event, PayloadEvent } from './events'; 3 | 4 | /** 5 | * An activity that takes no input 6 | */ 7 | export type VoidActivity = Saga<[]> | ((...args: []) => void); 8 | 9 | /** 10 | * An activity that takes an event as input 11 | */ 12 | export type Activity = 13 | | Saga<[Event]> 14 | | Saga<[PayloadEvent]> 15 | | Saga<[ErrorEvent]> 16 | | Saga<[]> 17 | | ((...args: [Event]) => void) 18 | | ((...args: [PayloadEvent]) => void) 19 | | ((...args: [ErrorEvent]) => void); 20 | -------------------------------------------------------------------------------- /src/spec/base.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { StrictEffect } from 'redux-saga/effects'; 3 | import { StartStateMachineAction, StopStateMachineAction } from './actions'; 4 | import { StmStorage } from './storage'; 5 | 6 | /** 7 | * This is the public interface for a state machine. 8 | * It removes private and protected fields, and can be used by other libraries. 9 | */ 10 | export interface StateMachineInterface { 11 | name: SM; 12 | starterSaga: () => Generator; 13 | stateReducer: ( 14 | state: StmStorage | undefined, 15 | action: any 16 | ) => StmStorage; 17 | start: (ctx: C) => StartStateMachineAction; 18 | stop: () => StopStateMachineAction; 19 | } 20 | -------------------------------------------------------------------------------- /src/spec/events.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { Action } from 'redux'; 3 | 4 | /** 5 | * An event without payload 6 | */ 7 | export interface Event extends Action { 8 | type: T; 9 | } 10 | 11 | /** 12 | * An event with generic payload 13 | */ 14 | export interface PayloadEvent extends Event { 15 | payload: any; 16 | } 17 | 18 | /** 19 | * An error event (FSA compliant) 20 | */ 21 | export interface ErrorEvent extends Event { 22 | payload: Error; 23 | error: true; 24 | } 25 | 26 | /** 27 | * All events that can be handled by a state machine 28 | */ 29 | export type AnyEvent = 30 | | Event 31 | | PayloadEvent 32 | | ErrorEvent; 33 | -------------------------------------------------------------------------------- /src/spec/guards.ts: -------------------------------------------------------------------------------- 1 | import { ErrorEvent, Event, PayloadEvent } from './events'; 2 | 3 | /** 4 | * A guard is a boolean function that takes in input an event and the 5 | * current context of the STM. 6 | */ 7 | export type Guard = 8 | | ((...args: [Event, C]) => boolean) 9 | | ((...args: [PayloadEvent, C]) => boolean) 10 | | ((...args: [ErrorEvent, C]) => boolean); 11 | -------------------------------------------------------------------------------- /src/spec/index.ts: -------------------------------------------------------------------------------- 1 | import { StateAttributes } from './states'; 2 | 3 | /** 4 | * This is the root type of the `spec` field of each state machine. 5 | * It's a mapping of each possible state to the specification of that 6 | * state. 7 | */ 8 | export type StateMachineSpec< 9 | E extends string, 10 | S extends string, 11 | SM extends string, 12 | C 13 | > = { [key in S]: StateAttributes }; 14 | -------------------------------------------------------------------------------- /src/spec/reactions.ts: -------------------------------------------------------------------------------- 1 | import { Activity } from './activities'; 2 | import { 3 | REACTION_POLICY_ALL, 4 | REACTION_POLICY_FIRST, 5 | REACTION_POLICY_LAST, 6 | } from '../constants'; 7 | 8 | /** 9 | * A reaction policy determines what the state machine will do when a reaction 10 | * is triggered several times during a short period of time. 11 | */ 12 | export type ReactionPolicy = 13 | | typeof REACTION_POLICY_FIRST 14 | | typeof REACTION_POLICY_LAST 15 | | typeof REACTION_POLICY_ALL; 16 | 17 | /** 18 | * This type defines what a reaction looks like when a reaction policy 19 | * is specified explicitly. 20 | */ 21 | export interface ReactionSpec { 22 | activity: Activity; 23 | policy: ReactionPolicy; 24 | } 25 | 26 | /** 27 | * The reaction map is a partial mapping between possible events and 28 | * the commands to run when the event happens. 29 | */ 30 | export type ReactionMap = Partial< 31 | { [key in E]: Activity | ReactionSpec } 32 | >; 33 | -------------------------------------------------------------------------------- /src/spec/states.ts: -------------------------------------------------------------------------------- 1 | import { VoidActivity } from './activities'; 2 | import { SubStateMachine } from './subMachines'; 3 | import { TransitionMap } from './transitions'; 4 | import { ReactionMap } from './reactions'; 5 | 6 | /** 7 | * Each state definition can contain the following fields: 8 | * 9 | * - what to do onEntry and onExit 10 | * - which subMachines to run when inside the state 11 | * - the possible transitions for the state 12 | * - the reactions for the state 13 | */ 14 | export interface StateAttributes< 15 | E extends string, 16 | S extends string, 17 | SM extends string, 18 | C 19 | > { 20 | onEntry?: VoidActivity | VoidActivity[]; 21 | onExit?: VoidActivity | VoidActivity[]; 22 | subMachines?: SubStateMachine | SubStateMachine[]; 23 | transitions?: TransitionMap; 24 | reactions?: ReactionMap; 25 | } 26 | -------------------------------------------------------------------------------- /src/spec/storage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is the state and context of a state machine that is not running. 3 | */ 4 | export interface StoppedStmStorage { 5 | state: null; 6 | context: undefined; 7 | } 8 | 9 | /** 10 | * This is the state and context of a state machine that IS running. 11 | */ 12 | export interface StartedStmStorage { 13 | state: S; 14 | context: C; 15 | } 16 | 17 | /** 18 | * The actual state and context of a state machine. 19 | */ 20 | export type StmStorage = 21 | | StartedStmStorage 22 | | StoppedStmStorage; 23 | -------------------------------------------------------------------------------- /src/spec/subMachines.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { StateMachineInterface } from './base'; 3 | import { StrictEffect } from 'redux-saga/effects'; 4 | 5 | /** 6 | * This is a state machine that can be used as a sub state machine 7 | * without providing an initial context, since an empty object 8 | * can be assigned to its context. 9 | */ 10 | export interface SubStateMachineWithoutContext 11 | extends StateMachineInterface {} 12 | 13 | /** 14 | * This is a state machine that can only be used as a sub state machine 15 | * by providing an initial context, since an empty object may not be 16 | * assignable to its context. 17 | * 18 | * The `contextBuilder` field is a function or generator that will return 19 | * the initial context for this state machine. 20 | */ 21 | export interface SubStateMachineWithContext { 22 | stm: StateMachineInterface; 23 | contextBuilder: (() => SC) | (() => Generator); 24 | } 25 | 26 | /** 27 | * This is any state machine that can be used as a sub state machine. 28 | */ 29 | export type SubStateMachine = 30 | | SubStateMachineWithContext 31 | | SubStateMachineWithoutContext; 32 | -------------------------------------------------------------------------------- /src/spec/transitions.ts: -------------------------------------------------------------------------------- 1 | import { Guard } from './guards'; 2 | import { Activity } from './activities'; 3 | import { Event } from './events'; 4 | 5 | /** 6 | * A transition can be defined as a target state and a command (or commands) 7 | * to execute before reaching the target state. 8 | */ 9 | export interface Transition { 10 | target: S; 11 | command: Activity | Activity[]; 12 | } 13 | 14 | /** 15 | * A guarded transition is defined by a target state, and a guard that 16 | * returns true if the transition should happen. It can optionally have a 17 | * command (or commands). 18 | */ 19 | export interface GuardedTransition { 20 | target: S; 21 | guard: Guard; 22 | command?: Activity | Activity[]; 23 | } 24 | 25 | /** 26 | * A transition can be one of the following: 27 | * - just a state 28 | * - a target state and a command to execute 29 | * - a state and a guard, and an optional command 30 | * - more than one state and guard, with optional commands 31 | */ 32 | export type TransitionSpec = 33 | | S 34 | | Transition 35 | | GuardedTransition 36 | | GuardedTransition[]; 37 | 38 | /** 39 | * The transition map is a partial mapping between events and target states. 40 | * Transitions may have a command, and a guard. 41 | */ 42 | export type TransitionMap = Partial< 43 | { [key in E]: TransitionSpec } 44 | >; 45 | 46 | /** 47 | * A transition trigger contains information about a specific transition 48 | * from a specific state: it contains information about the event that 49 | * triggered the transition, the commands that must be executed, and the 50 | * state that must be reached. 51 | */ 52 | export interface TransitionTrigger { 53 | event: Event; 54 | nextState: S; 55 | command?: Activity | Activity[]; 56 | } 57 | -------------------------------------------------------------------------------- /src/stateMachineStarterSaga.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { call, fork, takeEvery } from 'redux-saga/effects'; 3 | import { 4 | startStmActionType, 5 | stopStmActionType, 6 | storeStmContextActionType, 7 | storeStmStateActionType, 8 | } from './constants'; 9 | import { AnyAction } from 'redux'; 10 | import { StateMachineInterface } from './spec/base'; 11 | 12 | /* istanbul ignore next */ 13 | function* reportUnknownStateMachine(action: AnyAction) { 14 | // eslint-disable-next-line no-console 15 | yield call(console.warn, `Unkwnown state machine ${action.payload.name}`); 16 | } 17 | 18 | /** 19 | * Creates a saga that runs the `starterSaga` for the state machines 20 | * given in input. 21 | * 22 | * It does some additional checks for you: 23 | * 24 | * - if two or more state machines have the same identifier (their name), 25 | * this saga throws an error, since running more than one instance of 26 | * the same state machine _will_ result in undefined behaviour 27 | * - if you try to start or stop a state machine that was not passed 28 | * to this saga, it will log an error in console (in development only) 29 | * 30 | * @param stms An array of StateMachine instances. 31 | */ 32 | export function* stateMachineStarterSaga( 33 | ...stms: StateMachineInterface[] 34 | ) { 35 | const duplicateStm = stms 36 | .map(stm => stm.name) 37 | .find((name, idx, arr) => arr.lastIndexOf(name) !== idx); 38 | 39 | /* istanbul ignore next */ 40 | if (duplicateStm) { 41 | throw new Error(`Duplicate STM detected with name ${duplicateStm}`); 42 | } 43 | 44 | for (const stm of stms) { 45 | yield fork([stm, stm.starterSaga]); 46 | } 47 | 48 | /* istanbul ignore next */ 49 | if (process.env.NODE_ENV !== 'production') { 50 | const stmNames = stms.map(stm => stm.name); 51 | yield takeEvery( 52 | (action: AnyAction) => 53 | action.payload && 54 | action.payload.name && 55 | [ 56 | startStmActionType, 57 | stopStmActionType, 58 | storeStmContextActionType, 59 | storeStmStateActionType, 60 | ].includes(action.type) && 61 | !stmNames.includes(action.payload.name), 62 | reportUnknownStateMachine 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/typeGuards.ts: -------------------------------------------------------------------------------- 1 | import { Activity } from './spec/activities'; 2 | import { 3 | GuardedTransition, 4 | Transition, 5 | TransitionSpec, 6 | } from './spec/transitions'; 7 | import { ReactionSpec } from './spec/reactions'; 8 | import { StartedStmStorage, StmStorage } from './spec/storage'; 9 | 10 | export function isStateTransition( 11 | value: TransitionSpec 12 | ): value is S { 13 | return typeof value === 'string'; 14 | } 15 | 16 | export function isGuardedTransition( 17 | value: TransitionSpec 18 | ): value is GuardedTransition { 19 | return 'guard' in value; 20 | } 21 | 22 | export function isGuardedTransitionArray( 23 | value: TransitionSpec 24 | ): value is Array> { 25 | return value instanceof Array; 26 | } 27 | 28 | export function isSimpleTransition( 29 | value: TransitionSpec 30 | ): value is Transition { 31 | return ( 32 | !isStateTransition(value) && 33 | !isGuardedTransition(value) && 34 | !isGuardedTransitionArray(value) 35 | ); 36 | } 37 | 38 | export function isReactionSpec( 39 | value: Activity | ReactionSpec 40 | ): value is ReactionSpec { 41 | return 'policy' in value; 42 | } 43 | 44 | export function isFunction(value: unknown): value is Function { 45 | return typeof value === 'function'; 46 | } 47 | 48 | export function isStarted( 49 | storage: StmStorage 50 | ): storage is StartedStmStorage { 51 | return storage.state !== null; 52 | } 53 | -------------------------------------------------------------------------------- /tests/context.test.ts: -------------------------------------------------------------------------------- 1 | import { StateMachine, stateMachineStarterSaga } from '../src'; 2 | import { combineReducers } from 'redux'; 3 | import { SagaTester } from '@moveaxlab/redux-saga-tester'; 4 | import { put } from 'redux-saga/effects'; 5 | 6 | const guardSpy = jest.fn(); 7 | 8 | interface Context { 9 | counter: number; 10 | message?: string; 11 | } 12 | 13 | enum Events { 14 | updateContext = 'updateContext', 15 | overwriteContext = 'overwriteContext', 16 | emitContext = 'emitContext', 17 | guardTransition = 'guardTransition', 18 | } 19 | 20 | interface UpdateContextEvent { 21 | type: Events.updateContext; 22 | payload: { 23 | increase_counter: number; 24 | }; 25 | } 26 | 27 | interface OverwriteContextEvent { 28 | type: Events.overwriteContext; 29 | payload: { 30 | new_counter: number; 31 | new_message?: string; 32 | }; 33 | } 34 | 35 | class StateMachineWithContext extends StateMachine< 36 | Events, 37 | string, 38 | string, 39 | Context 40 | > { 41 | protected readonly initialState = 'state_1'; 42 | 43 | readonly name = 'on_exit'; 44 | 45 | protected readonly spec = { 46 | state_1: { 47 | reactions: { 48 | [Events.updateContext]: this.updateContext, 49 | [Events.overwriteContext]: this.overwriteContext, 50 | [Events.emitContext]: this.emitContext, 51 | }, 52 | transitions: { 53 | [Events.guardTransition]: { 54 | guard: guardSpy, 55 | target: 'state_1', 56 | }, 57 | }, 58 | }, 59 | }; 60 | 61 | *updateContext(event: UpdateContextEvent) { 62 | yield* this.setContext(ctx => { 63 | ctx.counter += event.payload.increase_counter; 64 | ctx.message = 'Context updated'; 65 | }); 66 | yield put({ type: 'context_updated' }); 67 | } 68 | 69 | *overwriteContext(event: OverwriteContextEvent) { 70 | yield* this.setContext({ 71 | counter: event.payload.new_counter, 72 | message: event.payload.new_message, 73 | }); 74 | yield put({ type: 'context_updated' }); 75 | } 76 | 77 | *emitContext() { 78 | yield put({ type: 'context_value', payload: this.context }); 79 | } 80 | } 81 | 82 | const stateMachine = new StateMachineWithContext(); 83 | 84 | describe('Context semantics', () => { 85 | const reducers = combineReducers({ 86 | stateMachine: stateMachine.stateReducer, 87 | }); 88 | 89 | const tester = new SagaTester({ reducers }); 90 | tester.start(stateMachineStarterSaga, stateMachine); 91 | 92 | afterEach(() => { 93 | tester.dispatch(stateMachine.stop()); 94 | tester.reset(true); 95 | jest.resetAllMocks(); 96 | }); 97 | 98 | test('context is initialized correctly', () => { 99 | tester.dispatch(stateMachine.start({ counter: 1 })); 100 | 101 | expect(tester.getState().stateMachine.context).toEqual({ counter: 1 }); 102 | }); 103 | 104 | test('context is discarded when the state machine is stopped', () => { 105 | tester.dispatch(stateMachine.start({ counter: 2, message: 'message' })); 106 | 107 | expect(tester.getState().stateMachine.context).toEqual({ 108 | counter: 2, 109 | message: 'message', 110 | }); 111 | 112 | tester.dispatch(stateMachine.stop()); 113 | 114 | expect(tester.getState().stateMachine.context).toBeUndefined(); 115 | 116 | tester.dispatch(stateMachine.start({ counter: 1 })); 117 | 118 | expect(tester.getState().stateMachine.context).toEqual({ 119 | counter: 1, 120 | }); 121 | }); 122 | 123 | test('context can be updated with functions', () => { 124 | tester.dispatch(stateMachine.start({ counter: 0 })); 125 | 126 | const event: UpdateContextEvent = { 127 | type: Events.updateContext, 128 | payload: { 129 | increase_counter: 5, 130 | }, 131 | }; 132 | 133 | tester.dispatch(event); 134 | 135 | expect(tester.getState().stateMachine.context).toEqual({ 136 | counter: 5, 137 | message: 'Context updated', 138 | }); 139 | 140 | tester.dispatch(event); 141 | 142 | expect(tester.getState().stateMachine.context).toEqual({ 143 | counter: 10, 144 | message: 'Context updated', 145 | }); 146 | }); 147 | 148 | test('context can be overwritten', () => { 149 | tester.dispatch(stateMachine.start({ counter: 0 })); 150 | 151 | let event: OverwriteContextEvent = { 152 | type: Events.overwriteContext, 153 | payload: { 154 | new_counter: 3, 155 | new_message: 'message 1', 156 | }, 157 | }; 158 | 159 | tester.dispatch(event); 160 | 161 | expect(tester.getState().stateMachine.context).toEqual({ 162 | counter: 3, 163 | message: 'message 1', 164 | }); 165 | 166 | event = { 167 | type: Events.overwriteContext, 168 | payload: { 169 | new_counter: 6, 170 | new_message: 'message 2', 171 | }, 172 | }; 173 | 174 | tester.dispatch(event); 175 | 176 | expect(tester.getState().stateMachine.context).toEqual({ 177 | counter: 6, 178 | message: 'message 2', 179 | }); 180 | }); 181 | 182 | test('context can be read by activities', async () => { 183 | tester.dispatch(stateMachine.start({ counter: 0 })); 184 | 185 | let eventFuture = tester.waitFor('context_value', true); 186 | 187 | tester.dispatch({ type: Events.emitContext }); 188 | 189 | await expect(eventFuture).resolves.toEqual({ 190 | type: 'context_value', 191 | payload: { 192 | counter: 0, 193 | }, 194 | }); 195 | 196 | const updateEvent: OverwriteContextEvent = { 197 | type: Events.overwriteContext, 198 | payload: { 199 | new_counter: 3, 200 | new_message: 'message 1', 201 | }, 202 | }; 203 | 204 | tester.dispatch(updateEvent); 205 | 206 | eventFuture = tester.waitFor('context_value', true); 207 | 208 | tester.dispatch({ type: Events.emitContext }); 209 | 210 | await expect(eventFuture).resolves.toEqual({ 211 | type: 'context_value', 212 | payload: { 213 | counter: 3, 214 | message: 'message 1', 215 | }, 216 | }); 217 | }); 218 | 219 | test('guards are passed the current value of the context', () => { 220 | guardSpy.mockReturnValue(false); 221 | 222 | tester.dispatch(stateMachine.start({ counter: 0 })); 223 | 224 | const event = { type: Events.guardTransition }; 225 | 226 | tester.dispatch(event); 227 | 228 | expect(guardSpy).toHaveBeenLastCalledWith(event, { counter: 0 }); 229 | 230 | const updateEvent: OverwriteContextEvent = { 231 | type: Events.overwriteContext, 232 | payload: { 233 | new_counter: 3, 234 | new_message: 'message 1', 235 | }, 236 | }; 237 | 238 | tester.dispatch(updateEvent); 239 | 240 | tester.dispatch(event); 241 | 242 | expect(guardSpy).toHaveBeenLastCalledWith(event, { 243 | counter: 3, 244 | message: 'message 1', 245 | }); 246 | }); 247 | }); 248 | -------------------------------------------------------------------------------- /tests/guards.test.ts: -------------------------------------------------------------------------------- 1 | import { and, not, or } from '../src'; 2 | 3 | function firstGreaterThanSecond(a: number, b: number): boolean { 4 | return a > b; 5 | } 6 | 7 | function greaterThanFive(a: number): boolean { 8 | return a > 5; 9 | } 10 | 11 | function lessThanTen(a: number): boolean { 12 | return a < 10; 13 | } 14 | 15 | function greaterThanTwenty(a: number): boolean { 16 | return a > 20; 17 | } 18 | 19 | describe('Test guard utilities', () => { 20 | expect(firstGreaterThanSecond(4, 5)).toBeFalse(); 21 | expect(firstGreaterThanSecond(5, 4)).toBeTrue(); 22 | 23 | expect(greaterThanFive(4)).toBeFalse(); 24 | expect(greaterThanFive(6)).toBeTrue(); 25 | 26 | expect(lessThanTen(9)).toBeTrue(); 27 | expect(lessThanTen(11)).toBeFalse(); 28 | 29 | expect(greaterThanTwenty(10)).toBeFalse(); 30 | expect(greaterThanTwenty(30)).toBeTrue(); 31 | 32 | test('not utility works', () => { 33 | const secondGreaterThanFirst = not(firstGreaterThanSecond); 34 | 35 | expect(secondGreaterThanFirst(4, 5)).toBeTrue(); 36 | 37 | expect(secondGreaterThanFirst(5, 4)).toBeFalse(); 38 | }); 39 | 40 | test('and utility works', () => { 41 | const betweenFiveAndTen = and(greaterThanFive, lessThanTen); 42 | 43 | expect(betweenFiveAndTen(6)).toBeTrue(); 44 | expect(betweenFiveAndTen(11)).toBeFalse(); 45 | expect(betweenFiveAndTen(3)).toBeFalse(); 46 | }); 47 | 48 | test('or utility works', () => { 49 | const lessThanTenOrMoreThanTwenty = or(lessThanTen, greaterThanTwenty); 50 | 51 | expect(lessThanTenOrMoreThanTwenty(5)).toBeTrue(); 52 | expect(lessThanTenOrMoreThanTwenty(15)).toBeFalse(); 53 | expect(lessThanTenOrMoreThanTwenty(30)).toBeTrue(); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /tests/lateTransition.test.ts: -------------------------------------------------------------------------------- 1 | import { StateMachine, stateMachineStarterSaga } from '../src'; 2 | import { storeStmContextActionType } from '../src/constants'; 3 | import { call, put } from 'redux-saga/effects'; 4 | import { SagaTester } from '@moveaxlab/redux-saga-tester'; 5 | import { combineReducers, Middleware } from 'redux'; 6 | import waitForExpect from 'wait-for-expect'; 7 | 8 | async function loadData() { 9 | await Promise.resolve(); 10 | return 'data'; 11 | } 12 | 13 | interface Context { 14 | run: number; 15 | data?: string; 16 | loadRun?: number; 17 | } 18 | 19 | interface LoadAction { 20 | type: 'loaded'; 21 | payload: { 22 | currentRun: number; 23 | }; 24 | } 25 | 26 | type LoadActionType = LoadAction['type']; 27 | 28 | class RestartStateMachine extends StateMachine< 29 | LoadActionType, 30 | string, 31 | string, 32 | Context 33 | > { 34 | protected readonly initialState = 'loading'; 35 | 36 | readonly name = 'restart'; 37 | 38 | protected readonly spec = { 39 | loading: { 40 | onEntry: this.onEntry, 41 | transitions: { 42 | loaded: { 43 | target: 'loaded', 44 | command: this.storeRunTrigger, 45 | }, 46 | }, 47 | }, 48 | loaded: {}, 49 | }; 50 | 51 | *onEntry() { 52 | const currentRun = this.context.run; 53 | const data = yield call(loadData); 54 | yield* this.setContext(ctx => { 55 | ctx.data = data; 56 | }); 57 | yield put({ type: 'loaded', payload: { currentRun } }); 58 | } 59 | 60 | *storeRunTrigger(action: LoadAction) { 61 | yield* this.setContext(ctx => { 62 | ctx.loadRun = action.payload.currentRun; 63 | }); 64 | } 65 | } 66 | 67 | describe("Transition events from previous run don't trigger transition in new run", () => { 68 | it('checks that restarting before transition event does not affect new run', async () => { 69 | const stm = new RestartStateMachine(); 70 | 71 | const reducers = combineReducers({ 72 | stm: stm.stateReducer, 73 | }); 74 | 75 | let count = 0; 76 | 77 | const restartMiddleware: Middleware = store => next => action => { 78 | const res = next(action); 79 | // restart STM right before the loaded event is dispatched 80 | if (action.type === storeStmContextActionType && count == 1) { 81 | store.dispatch(stm.stop()); 82 | store.dispatch(stm.start({ run: ++count })); 83 | } 84 | return res; 85 | }; 86 | 87 | const middlewares = [restartMiddleware]; 88 | 89 | const tester = new SagaTester({ 90 | reducers, 91 | middlewares, 92 | }); 93 | 94 | tester.start(stateMachineStarterSaga, stm); 95 | 96 | tester.dispatch(stm.start({ run: ++count })); 97 | 98 | await waitForExpect(() => { 99 | expect(tester.getState().stm.state).toEqual('loaded'); 100 | }); 101 | 102 | const { context } = tester.getState().stm; 103 | 104 | expect(context!.run).toEqual(context!.loadRun); 105 | expect(tester.numCalled('loaded')).toEqual(2); 106 | expect(context!.data).toEqual('data'); 107 | }); 108 | 109 | it('checks that restarting on transition event does not affect new runs', async () => { 110 | const stm = new RestartStateMachine(); 111 | 112 | const reducers = combineReducers({ 113 | stm: stm.stateReducer, 114 | }); 115 | 116 | let count = 0; 117 | 118 | const restartMiddleware: Middleware = store => next => action => { 119 | const res = next(action); 120 | // restart STM as soon as the loaded event is dispatched 121 | if (action.type === 'loaded' && count == 1) { 122 | store.dispatch(stm.stop()); 123 | store.dispatch(stm.start({ run: ++count })); 124 | } 125 | return res; 126 | }; 127 | 128 | const middlewares = [restartMiddleware]; 129 | 130 | const tester = new SagaTester({ 131 | reducers, 132 | middlewares, 133 | }); 134 | 135 | tester.start(stateMachineStarterSaga, stm); 136 | 137 | tester.dispatch(stm.start({ run: ++count })); 138 | 139 | await waitForExpect(() => { 140 | expect(tester.getState().stm.state).toEqual('loaded'); 141 | }); 142 | 143 | const { context } = tester.getState().stm; 144 | 145 | expect(context!.run).toEqual(context!.loadRun); 146 | expect(tester.numCalled('loaded')).toEqual(2); 147 | expect(context!.data).toEqual('data'); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /tests/onEntry.test.ts: -------------------------------------------------------------------------------- 1 | import { SagaTester } from '@moveaxlab/redux-saga-tester'; 2 | import { StateMachine, stateMachineStarterSaga } from '../src'; 3 | import { put, delay, call } from 'redux-saga/effects'; 4 | import { combineReducers } from 'redux'; 5 | 6 | const spy1 = jest.fn(); 7 | const spy2 = jest.fn(); 8 | const spy3 = jest.fn(); 9 | const spy4 = jest.fn(); 10 | const spy5 = jest.fn(); 11 | 12 | class StateMachineWithOnEntry extends StateMachine { 13 | protected readonly initialState = 'state_1'; 14 | 15 | readonly name = 'on_entry'; 16 | 17 | protected readonly spec = { 18 | state_1: { 19 | onEntry: this.onEntry1, 20 | transitions: { 21 | go_to_state_2: 'state_2', 22 | go_to_state_3: 'state_3', 23 | }, 24 | }, 25 | state_2: { 26 | onEntry: spy3, 27 | }, 28 | state_3: { 29 | onEntry: [spy4, spy5], 30 | }, 31 | }; 32 | 33 | *onEntry1() { 34 | yield call(spy1); 35 | // short delay to allow concurrent events 36 | yield delay(100); 37 | yield call(spy2); 38 | yield put({ type: 'on_entry_complete' }); 39 | } 40 | } 41 | 42 | const stateMachine = new StateMachineWithOnEntry(); 43 | 44 | describe('onEntry semantics', () => { 45 | const reducers = combineReducers({ 46 | stateMachine: stateMachine.stateReducer, 47 | }); 48 | 49 | const tester = new SagaTester({ reducers }); 50 | tester.start(stateMachineStarterSaga, stateMachine); 51 | 52 | afterEach(() => { 53 | tester.dispatch(stateMachine.stop()); 54 | tester.reset(true); 55 | jest.resetAllMocks(); 56 | }); 57 | 58 | test('onEntry is called', async () => { 59 | tester.dispatch(stateMachine.start({})); 60 | 61 | // wait for on entry to complete 62 | await tester.waitFor('on_entry_complete'); 63 | 64 | expect(spy1).toHaveBeenCalledTimes(1); 65 | expect(spy2).toHaveBeenCalledTimes(1); 66 | expect(spy2).toHaveBeenCalledAfter(spy1); 67 | }); 68 | 69 | test('onEntry is interrupted when state machine is stopped', () => { 70 | tester.dispatch(stateMachine.start({})); 71 | 72 | // stop state machine right away 73 | tester.dispatch(stateMachine.stop()); 74 | 75 | expect(spy1).toHaveBeenCalledTimes(1); 76 | expect(spy2).not.toHaveBeenCalled(); 77 | }); 78 | 79 | test('onEntry is interrupted when state machine changes state', () => { 80 | tester.dispatch(stateMachine.start({})); 81 | 82 | // change state to stop onEntry 83 | tester.dispatch({ type: 'go_to_state_2' }); 84 | 85 | expect(spy1).toHaveBeenCalledTimes(1); 86 | expect(spy2).not.toHaveBeenCalled(); 87 | expect(spy3).toHaveBeenCalledTimes(1); 88 | expect(spy3).toHaveBeenCalledAfter(spy1); 89 | 90 | expect(tester.getState().stateMachine.state).toEqual('state_2'); 91 | }); 92 | 93 | test('multiple onEntry are all executed', () => { 94 | tester.dispatch(stateMachine.start({})); 95 | 96 | tester.dispatch({ type: 'go_to_state_3' }); 97 | 98 | // both onEntry of state_3 have been executed 99 | expect(spy4).toHaveBeenCalledTimes(1); 100 | expect(spy5).toHaveBeenCalledTimes(1); 101 | 102 | expect(tester.getState().stateMachine.state).toEqual('state_3'); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /tests/onExit.test.ts: -------------------------------------------------------------------------------- 1 | import { SagaTester } from '@moveaxlab/redux-saga-tester'; 2 | import { StateMachine, stateMachineStarterSaga } from '../src'; 3 | import { combineReducers } from 'redux'; 4 | 5 | const spy1 = jest.fn(); 6 | const spy2 = jest.fn(); 7 | const spy3 = jest.fn(); 8 | 9 | const onEntrySpy = jest.fn(); 10 | 11 | const commandSpy = jest.fn(); 12 | 13 | class StateMachineWithOnExit extends StateMachine { 14 | protected readonly initialState = 'state_1'; 15 | 16 | readonly name = 'on_exit'; 17 | 18 | protected readonly spec = { 19 | state_1: { 20 | transitions: { 21 | go_to_state_2: 'state_2', 22 | go_to_state_2_with_command: { 23 | target: 'state_2', 24 | command: commandSpy, 25 | }, 26 | }, 27 | onExit: spy1, 28 | }, 29 | state_2: { 30 | onEntry: onEntrySpy, 31 | transitions: { 32 | go_to_state_1: 'state_1', 33 | }, 34 | onExit: [spy2, spy3], 35 | }, 36 | }; 37 | } 38 | 39 | const stateMachine = new StateMachineWithOnExit(); 40 | 41 | describe('onExit semantics', () => { 42 | const reducers = combineReducers({ 43 | stateMachine: stateMachine.stateReducer, 44 | }); 45 | 46 | const tester = new SagaTester({ reducers }); 47 | tester.start(stateMachineStarterSaga, stateMachine); 48 | 49 | afterEach(() => { 50 | tester.dispatch(stateMachine.stop()); 51 | tester.reset(true); 52 | jest.resetAllMocks(); 53 | }); 54 | 55 | test('onExit is called when a transition happens', () => { 56 | tester.dispatch(stateMachine.start({})); 57 | 58 | tester.dispatch({ type: 'go_to_state_2' }); 59 | 60 | expect(tester.getState().stateMachine.state).toEqual('state_2'); 61 | 62 | expect(spy1).toHaveBeenCalledTimes(1); 63 | expect(spy1).toHaveBeenCalledBefore(onEntrySpy); 64 | }); 65 | 66 | test('onExit is called when a state machine is stopped', () => { 67 | tester.dispatch(stateMachine.start({})); 68 | 69 | tester.dispatch(stateMachine.stop()); 70 | 71 | expect(tester.getState().stateMachine.state).toEqual(null); 72 | 73 | expect(spy1).toHaveBeenCalledTimes(1); 74 | }); 75 | 76 | test('onExit is called before commands on transitions', () => { 77 | tester.dispatch(stateMachine.start({})); 78 | 79 | tester.dispatch({ type: 'go_to_state_2_with_command' }); 80 | 81 | expect(tester.getState().stateMachine.state).toEqual('state_2'); 82 | 83 | expect(spy1).toHaveBeenCalledTimes(1); 84 | expect(commandSpy).toHaveBeenCalledTimes(1); 85 | expect(spy1).toHaveBeenCalledBefore(commandSpy); 86 | expect(commandSpy).toHaveBeenCalledBefore(onEntrySpy); 87 | }); 88 | 89 | test('multiple onExit are all called', () => { 90 | tester.dispatch(stateMachine.start({})); 91 | 92 | tester.dispatch({ type: 'go_to_state_2' }); 93 | 94 | expect(tester.getState().stateMachine.state).toEqual('state_2'); 95 | 96 | tester.dispatch({ type: 'go_to_state_1' }); 97 | 98 | expect(spy2).toHaveBeenCalledTimes(1); 99 | expect(spy3).toHaveBeenCalledTimes(1); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /tests/races.test.ts: -------------------------------------------------------------------------------- 1 | import { StateMachine, stateMachineStarterSaga } from '../src'; 2 | import { put } from 'redux-saga/effects'; 3 | import { combineReducers } from 'redux'; 4 | import { SagaTester } from '@moveaxlab/redux-saga-tester'; 5 | 6 | class RacingStateMachine extends StateMachine { 7 | readonly name = 'racing'; 8 | 9 | protected readonly initialState = 'state_1'; 10 | 11 | protected readonly spec = { 12 | state_1: { 13 | onEntry: this.onEntry, 14 | transitions: { 15 | change_state: 'state_2', 16 | }, 17 | }, 18 | state_2: {}, 19 | }; 20 | 21 | *onEntry() { 22 | yield put({ type: 'change_state' }); 23 | } 24 | } 25 | 26 | const stateMachine = new RacingStateMachine(); 27 | 28 | describe('onEntry and transitions race conditions', () => { 29 | const reducers = combineReducers({ 30 | stateMachine: stateMachine.stateReducer, 31 | }); 32 | 33 | const tester = new SagaTester({ reducers }); 34 | tester.start(stateMachineStarterSaga, stateMachine); 35 | 36 | afterEach(() => { 37 | tester.dispatch(stateMachine.stop()); 38 | tester.reset(true); 39 | jest.resetAllMocks(); 40 | }); 41 | 42 | test('events emitted by onEntry that trigger transitions are detected', async () => { 43 | tester.dispatch(stateMachine.start({})); 44 | 45 | await tester.waitFor('change_state'); 46 | 47 | expect(tester.getState().stateMachine.state).toEqual('state_2'); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /tests/reactions.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | all, 3 | first, 4 | last, 5 | StateMachine, 6 | stateMachineStarterSaga, 7 | } from '../src'; 8 | import { call, put, cancelled } from 'redux-saga/effects'; 9 | import { delay } from 'redux-saga/effects'; 10 | import { combineReducers } from 'redux'; 11 | import { SagaTester } from '@moveaxlab/redux-saga-tester'; 12 | import waitForExpect from 'wait-for-expect'; 13 | 14 | const spy = jest.fn(); 15 | 16 | const startSpy = jest.fn(); 17 | const endSpy = jest.fn(); 18 | const cancelledSpy = jest.fn(); 19 | 20 | enum Events { 21 | changeState = 'change_state', 22 | slowReaction = 'slow_reaction', 23 | defaultReaction = 'default_reaction', 24 | reactionPolicyAll = 'reaction_policy_all', 25 | reactionPolicyFirst = 'reaction_policy_first', 26 | reactionPolicyLast = 'reaction_policy_last', 27 | } 28 | 29 | interface ReactionEvent { 30 | type: E; 31 | payload: { 32 | counter: number; 33 | }; 34 | } 35 | 36 | class StateMachineWithReactions extends StateMachine { 37 | protected readonly initialState = 'state_1'; 38 | 39 | readonly name = 'reactions'; 40 | 41 | protected readonly spec = { 42 | state_1: { 43 | reactions: { 44 | [Events.slowReaction]: this.slowReaction, 45 | [Events.defaultReaction]: this.reaction, 46 | [Events.reactionPolicyAll]: all(this.reaction), 47 | [Events.reactionPolicyFirst]: first(this.reaction), 48 | [Events.reactionPolicyLast]: last(this.reaction), 49 | }, 50 | transitions: { 51 | [Events.changeState]: 'state_2', 52 | }, 53 | }, 54 | state_2: {}, 55 | }; 56 | 57 | *slowReaction() { 58 | try { 59 | yield put({ type: 'reaction_start' }); 60 | yield call(startSpy); 61 | yield delay(100); 62 | yield call(endSpy); 63 | } finally { 64 | if (yield cancelled()) { 65 | yield call(cancelledSpy); 66 | } 67 | } 68 | } 69 | 70 | *reaction(event: ReactionEvent) { 71 | yield delay(10); 72 | yield call(spy, event.payload.counter); 73 | yield put({ type: 'reaction_done' }); 74 | } 75 | } 76 | 77 | const stateMachine = new StateMachineWithReactions(); 78 | 79 | describe('Reactions semantics', () => { 80 | const reducers = combineReducers({ 81 | stateMachine: stateMachine.stateReducer, 82 | }); 83 | 84 | const tester = new SagaTester({ reducers }); 85 | tester.start(stateMachineStarterSaga, stateMachine); 86 | 87 | afterEach(() => { 88 | tester.dispatch(stateMachine.stop()); 89 | tester.reset(true); 90 | jest.resetAllMocks(); 91 | }); 92 | 93 | test('reactions are stopped by transitions', () => { 94 | tester.dispatch(stateMachine.start({})); 95 | 96 | tester.dispatch({ type: Events.slowReaction }); 97 | 98 | tester.dispatch({ type: Events.changeState }); 99 | 100 | expect(tester.getState().stateMachine.state).toEqual('state_2'); 101 | 102 | expect(startSpy).toHaveBeenCalledTimes(1); 103 | expect(endSpy).not.toHaveBeenCalled(); 104 | expect(cancelledSpy).toHaveBeenCalledTimes(1); 105 | expect(startSpy).toHaveBeenCalledBefore(cancelledSpy); 106 | }); 107 | 108 | test('reactions are stopped when the state machine is stopped', () => { 109 | tester.dispatch(stateMachine.start({})); 110 | 111 | tester.dispatch({ type: Events.slowReaction }); 112 | 113 | tester.dispatch(stateMachine.stop()); 114 | 115 | expect(tester.getState().stateMachine.state).toEqual(null); 116 | 117 | expect(startSpy).toHaveBeenCalledTimes(1); 118 | expect(endSpy).not.toHaveBeenCalled(); 119 | expect(cancelledSpy).toHaveBeenCalledTimes(1); 120 | expect(startSpy).toHaveBeenCalledBefore(cancelledSpy); 121 | }); 122 | 123 | test('default reaction policy processes every event', async () => { 124 | tester.dispatch(stateMachine.start({})); 125 | 126 | for (let i = 0; i < 10; i++) { 127 | tester.dispatch({ 128 | type: Events.defaultReaction, 129 | payload: { counter: i }, 130 | }); 131 | } 132 | 133 | await waitForExpect(() => { 134 | expect(tester.numCalled('reaction_done')).toEqual(10); 135 | }); 136 | 137 | expect(spy).toHaveBeenCalledTimes(10); 138 | 139 | for (let i = 0; i < 10; i++) { 140 | expect(spy).toHaveBeenNthCalledWith(i + 1, i); 141 | } 142 | }); 143 | 144 | test('`all` reaction policy processes every event', async () => { 145 | tester.dispatch(stateMachine.start({})); 146 | 147 | for (let i = 0; i < 10; i++) { 148 | tester.dispatch({ 149 | type: Events.reactionPolicyAll, 150 | payload: { counter: i }, 151 | }); 152 | } 153 | 154 | await waitForExpect(() => { 155 | expect(tester.numCalled('reaction_done')).toEqual(10); 156 | }); 157 | 158 | expect(spy).toHaveBeenCalledTimes(10); 159 | 160 | for (let i = 0; i < 10; i++) { 161 | expect(spy).toHaveBeenNthCalledWith(i + 1, i); 162 | } 163 | }); 164 | 165 | test('`first` reaction policy processes only the first event', async () => { 166 | tester.dispatch(stateMachine.start({})); 167 | 168 | for (let i = 0; i < 10; i++) { 169 | tester.dispatch({ 170 | type: Events.reactionPolicyFirst, 171 | payload: { counter: i }, 172 | }); 173 | } 174 | 175 | await waitForExpect(() => { 176 | expect(tester.numCalled('reaction_done')).toEqual(1); 177 | }); 178 | 179 | expect(spy).toHaveBeenCalledTimes(1); 180 | expect(spy).toHaveBeenNthCalledWith(1, 0); 181 | }); 182 | 183 | test('`last` reaction policy processes only the last event', async () => { 184 | tester.dispatch(stateMachine.start({})); 185 | 186 | for (let i = 0; i < 10; i++) { 187 | tester.dispatch({ 188 | type: Events.reactionPolicyLast, 189 | payload: { counter: i }, 190 | }); 191 | } 192 | 193 | await waitForExpect(() => { 194 | expect(tester.numCalled('reaction_done')).toEqual(1); 195 | }); 196 | 197 | expect(spy).toHaveBeenCalledTimes(1); 198 | expect(spy).toHaveBeenNthCalledWith(1, 9); 199 | }); 200 | }); 201 | -------------------------------------------------------------------------------- /tests/slowOnExit.test.ts: -------------------------------------------------------------------------------- 1 | import { StateMachine, stateMachineStarterSaga } from '../src'; 2 | import { call, put, take } from 'redux-saga/effects'; 3 | import { combineReducers, Middleware } from 'redux'; 4 | import { SagaTester } from '@moveaxlab/redux-saga-tester'; 5 | 6 | interface Context { 7 | run: number; 8 | triggerRun?: number; 9 | } 10 | 11 | interface Event2 { 12 | type: 'event_2'; 13 | payload: { 14 | run: number; 15 | }; 16 | } 17 | 18 | class SlowOnExitStm extends StateMachine< 19 | 'event_1' | 'event_2', 20 | string, 21 | string, 22 | Context 23 | > { 24 | protected readonly initialState = 'state_1'; 25 | 26 | readonly name = 'slow on exit'; 27 | 28 | protected readonly spec = { 29 | state_1: { 30 | onEntry: this.onEntry, 31 | onExit: this.onExit, 32 | transitions: { 33 | event_1: 'state_2', 34 | event_2: { 35 | target: 'state_3', 36 | command: this.storeTriggerRun, 37 | }, 38 | }, 39 | }, 40 | state_2: {}, 41 | state_3: {}, 42 | }; 43 | 44 | *onEntry() { 45 | yield take('go'); 46 | yield call(async () => await Promise.resolve()); 47 | yield put({ type: 'event_1' }); 48 | } 49 | 50 | *onExit() { 51 | // execute a "slow" on exit by dispatching lots of actions to redux saga 52 | const currentRun = this.context.run; 53 | yield put({ type: 'on exit event 1', payload: { currentRun } }); 54 | yield put({ type: 'on exit event 2', payload: { currentRun } }); 55 | yield put({ type: 'on exit event 3', payload: { currentRun } }); 56 | yield put({ type: 'on exit event 4', payload: { currentRun } }); 57 | yield put({ type: 'event_2', payload: { currentRun } }); 58 | } 59 | 60 | *storeTriggerRun(event: Event2) { 61 | yield* this.setContext(ctx => { 62 | ctx.triggerRun = event.payload.run; 63 | }); 64 | } 65 | } 66 | 67 | describe('Slow on exit do not affect other states', () => { 68 | it('checks that slow on exit does not affect state change', async () => { 69 | const stm = new SlowOnExitStm(); 70 | 71 | const reducers = combineReducers({ 72 | stm: stm.stateReducer, 73 | }); 74 | 75 | const tester = new SagaTester({ reducers }); 76 | 77 | tester.start(stateMachineStarterSaga, stm); 78 | 79 | tester.dispatch(stm.start({ run: 1 })); 80 | tester.dispatch({ type: 'go' }); 81 | expect(tester.getState().stm.state).toEqual('state_1'); 82 | 83 | await tester.waitFor('event_1'); 84 | 85 | // event 2 was received, but does not trigger a transition to state_3 86 | await tester.waitFor('event_2'); 87 | 88 | expect(tester.getState().stm.state).toEqual('state_2'); 89 | }); 90 | 91 | it('checks that slow on exit does not affect state machine restart', async () => { 92 | const stm = new SlowOnExitStm(); 93 | 94 | const reducers = combineReducers({ 95 | stm: stm.stateReducer, 96 | }); 97 | 98 | let run = 0; 99 | let firstRun = true; 100 | 101 | const middleware: Middleware = store => next => action => { 102 | const res = next(action); 103 | if (action.type === 'go' && firstRun) { 104 | store.dispatch(stm.stop()); 105 | store.dispatch(stm.start({ run: ++run })); 106 | firstRun = false; 107 | } 108 | return res; 109 | }; 110 | 111 | const middlewares = [middleware]; 112 | 113 | const tester = new SagaTester({ 114 | reducers, 115 | middlewares, 116 | }); 117 | 118 | tester.start(stateMachineStarterSaga, stm); 119 | 120 | tester.dispatch(stm.start({ run: ++run })); 121 | tester.dispatch({ type: 'go' }); 122 | 123 | await tester.waitFor('event_2'); 124 | 125 | expect(tester.getState().stm.state).toEqual('state_1'); 126 | }); 127 | 128 | it('checks that slow on exit does not affect state machine restart on transition', async () => { 129 | const stm = new SlowOnExitStm(); 130 | 131 | const reducers = combineReducers({ 132 | stm: stm.stateReducer, 133 | }); 134 | 135 | let run = 0; 136 | let firstRun = true; 137 | 138 | const middleware: Middleware = store => next => action => { 139 | const res = next(action); 140 | if (action.type === 'event_1' && firstRun) { 141 | store.dispatch(stm.stop()); 142 | store.dispatch(stm.start({ run: ++run })); 143 | firstRun = false; 144 | } 145 | return res; 146 | }; 147 | 148 | const middlewares = [middleware]; 149 | 150 | const tester = new SagaTester({ 151 | reducers, 152 | middlewares, 153 | }); 154 | 155 | tester.start(stateMachineStarterSaga, stm); 156 | 157 | tester.dispatch(stm.start({ run: ++run })); 158 | tester.dispatch({ type: 'go' }); 159 | 160 | await tester.waitFor('event_2'); 161 | 162 | expect(tester.getState().stm.state).toEqual('state_1'); 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /tests/startup.test.ts: -------------------------------------------------------------------------------- 1 | import { StateMachine, stateMachineStarterSaga } from '../src'; 2 | import { put } from 'redux-saga/effects'; 3 | import { combineReducers } from 'redux'; 4 | import { SagaTester } from '@moveaxlab/redux-saga-tester'; 5 | import { 6 | StoreStateMachineContext, 7 | StoreStateMachineState, 8 | } from '../src/spec/actions'; 9 | import { 10 | stmStartedActionType, 11 | storeStmContextActionType, 12 | storeStmStateActionType, 13 | } from '../src/constants'; 14 | 15 | class SimpleStateMachine extends StateMachine { 16 | readonly name = 'simple_stm'; 17 | 18 | protected readonly initialState = 'state_1'; 19 | 20 | protected readonly spec = { 21 | state_1: { 22 | onEntry: this.onEntry, 23 | }, 24 | }; 25 | 26 | *onEntry() { 27 | yield put({ type: 'stm_started' }); 28 | } 29 | } 30 | 31 | const stateMachine = new SimpleStateMachine(); 32 | 33 | describe('Start and stop semantics', () => { 34 | const reducers = combineReducers({ 35 | stateMachine: stateMachine.stateReducer, 36 | }); 37 | 38 | const tester = new SagaTester({ reducers }); 39 | tester.start(stateMachineStarterSaga, stateMachine); 40 | 41 | afterEach(() => { 42 | tester.dispatch(stateMachine.stop()); 43 | tester.reset(true); 44 | jest.resetAllMocks(); 45 | }); 46 | 47 | test('state machines can be started and stopped', () => { 48 | expect(tester.getState().stateMachine.state).toEqual(null); 49 | 50 | tester.dispatch(stateMachine.start({})); 51 | 52 | expect(tester.getState().stateMachine.state).toEqual('state_1'); 53 | 54 | tester.dispatch(stateMachine.stop()); 55 | 56 | expect(tester.getState().stateMachine.state).toEqual(null); 57 | 58 | tester.dispatch(stateMachine.start({})); 59 | 60 | expect(tester.getState().stateMachine.state).toEqual('state_1'); 61 | }); 62 | 63 | test('multiple start actions start the state machine only once', () => { 64 | tester.dispatch(stateMachine.start({})); 65 | 66 | expect(tester.numCalled('stm_started')).toEqual(1); 67 | expect(tester.numCalled(stmStartedActionType)).toEqual(1); 68 | 69 | tester.dispatch(stateMachine.start({})); 70 | tester.dispatch(stateMachine.start({})); 71 | 72 | expect(tester.numCalled('stm_started')).toEqual(1); 73 | expect(tester.numCalled(stmStartedActionType)).toEqual(1); 74 | }); 75 | 76 | test('multiple stop actions have no effect on the state machine', () => { 77 | tester.dispatch(stateMachine.stop()); 78 | 79 | expect(tester.getState().stateMachine.state).toEqual(null); 80 | 81 | tester.dispatch(stateMachine.start({})); 82 | 83 | tester.dispatch(stateMachine.stop()); 84 | tester.dispatch(stateMachine.stop()); 85 | 86 | expect(tester.getState().stateMachine.state).toEqual(null); 87 | }); 88 | 89 | test('state updates are ignored if the state machine is not running', () => { 90 | const event: StoreStateMachineState = { 91 | type: storeStmStateActionType, 92 | payload: { 93 | name: stateMachine.name, 94 | state: 'state_1', 95 | }, 96 | }; 97 | 98 | tester.dispatch(event); 99 | 100 | expect(tester.getState().stateMachine.state).toEqual(null); 101 | }); 102 | 103 | test('context updates are ignored if the state machine is not running', () => { 104 | const event: StoreStateMachineContext = { 105 | type: storeStmContextActionType, 106 | payload: { 107 | name: stateMachine.name, 108 | context: {}, 109 | }, 110 | }; 111 | 112 | tester.dispatch(event); 113 | 114 | expect(tester.getState().stateMachine.context).toBeUndefined(); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /tests/subMachinesWithContext.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | import { bindStm, StateMachine, stateMachineStarterSaga } from '../src'; 3 | import { combineReducers } from 'redux'; 4 | import { SagaTester } from '@moveaxlab/redux-saga-tester'; 5 | import { startStmActionType, stopStmActionType } from '../src/constants'; 6 | 7 | const buildContextSpy = jest.fn(); 8 | 9 | class FirstSubMachine extends StateMachine { 10 | readonly name = 'sub_1'; 11 | 12 | protected readonly initialState = 'state_1'; 13 | 14 | protected readonly spec = { 15 | state_1: {}, 16 | }; 17 | } 18 | 19 | const firstSubMachine = new FirstSubMachine(); 20 | 21 | interface SubMachineContext { 22 | counter: number; 23 | } 24 | 25 | class SecondSubMachine extends StateMachine< 26 | string, 27 | string, 28 | string, 29 | SubMachineContext 30 | > { 31 | readonly name = 'sub_2'; 32 | 33 | protected readonly initialState = 'state_1'; 34 | 35 | protected readonly spec = { 36 | state_1: {}, 37 | }; 38 | } 39 | 40 | const secondSubMachine = new SecondSubMachine(); 41 | 42 | interface ParentContext { 43 | counter: number; 44 | } 45 | 46 | class ParentMachine extends StateMachine< 47 | string, 48 | string, 49 | string, 50 | ParentContext 51 | > { 52 | readonly name = 'parent'; 53 | 54 | protected readonly initialState = 'state_1'; 55 | 56 | protected readonly spec = { 57 | state_1: { 58 | subMachines: [ 59 | firstSubMachine, 60 | bindStm(secondSubMachine, this.buildContext), 61 | ], 62 | transitions: { 63 | change_state: 'state_2', 64 | }, 65 | }, 66 | state_2: { 67 | subMachines: bindStm(secondSubMachine, this.buildContext), 68 | transitions: { 69 | change_state: 'state_3', 70 | }, 71 | }, 72 | state_3: {}, 73 | }; 74 | 75 | buildContext() { 76 | return buildContextSpy(this.context); 77 | } 78 | } 79 | 80 | const parentStateMachine = new ParentMachine(); 81 | 82 | describe('Sub state machines with context semantics', () => { 83 | const reducers = combineReducers({ 84 | parent: parentStateMachine.stateReducer, 85 | firstSub: firstSubMachine.stateReducer, 86 | secondSub: secondSubMachine.stateReducer, 87 | }); 88 | 89 | const tester = new SagaTester({ reducers }); 90 | tester.start( 91 | stateMachineStarterSaga, 92 | parentStateMachine, 93 | firstSubMachine, 94 | secondSubMachine 95 | ); 96 | 97 | afterEach(() => { 98 | tester.dispatch(parentStateMachine.stop()); 99 | tester.reset(true); 100 | jest.resetAllMocks(); 101 | }); 102 | 103 | test('sub state machines context can be built from parent context', () => { 104 | buildContextSpy.mockReturnValue({ counter: 5 }); 105 | 106 | tester.dispatch(parentStateMachine.start({ counter: 1 })); 107 | 108 | expect(tester.getState().parent.state).toEqual('state_1'); 109 | expect(tester.getState().parent.context).toEqual({ counter: 1 }); 110 | expect(tester.getState().firstSub.state).toEqual('state_1'); 111 | expect(tester.getState().secondSub.state).toEqual('state_1'); 112 | expect(tester.getState().secondSub.context).toEqual({ counter: 5 }); 113 | 114 | expect(buildContextSpy).toHaveBeenCalledTimes(1); 115 | expect(buildContextSpy).toHaveBeenCalledWith({ counter: 1 }); 116 | 117 | jest.resetAllMocks(); 118 | 119 | buildContextSpy.mockReturnValue({ counter: 3 }); 120 | 121 | tester.dispatch({ type: 'change_state' }); 122 | 123 | expect(tester.getState().parent.state).toEqual('state_2'); 124 | expect(tester.getState().parent.context).toEqual({ counter: 1 }); 125 | expect(tester.getState().firstSub.state).toEqual(null); 126 | expect(tester.getState().secondSub.state).toEqual('state_1'); 127 | expect(tester.getState().secondSub.context).toEqual({ counter: 3 }); 128 | 129 | expect(buildContextSpy).toHaveBeenCalledTimes(1); 130 | expect(buildContextSpy).toHaveBeenCalledWith({ counter: 1 }); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /tests/subStateMachine.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | import { StateMachine, stateMachineStarterSaga } from '../src'; 3 | import { combineReducers } from 'redux'; 4 | import { SagaTester } from '@moveaxlab/redux-saga-tester'; 5 | import { startStmActionType, stopStmActionType } from '../src/constants'; 6 | 7 | class FirstSubMachine extends StateMachine { 8 | readonly name = 'sub_1'; 9 | 10 | protected readonly initialState = 'state_1'; 11 | 12 | protected readonly spec = { 13 | state_1: {}, 14 | }; 15 | } 16 | 17 | const firstSubMachine = new FirstSubMachine(); 18 | 19 | class SecondSubMachine extends StateMachine { 20 | readonly name = 'sub_2'; 21 | 22 | protected readonly initialState = 'state_1'; 23 | 24 | protected readonly spec = { 25 | state_1: {}, 26 | }; 27 | } 28 | 29 | const secondSubMachine = new SecondSubMachine(); 30 | 31 | class ParentMachine extends StateMachine { 32 | readonly name = 'parent'; 33 | 34 | protected readonly initialState = 'state_1'; 35 | 36 | protected readonly spec = { 37 | state_1: { 38 | subMachines: [firstSubMachine, secondSubMachine], 39 | transitions: { 40 | change_state: 'state_2', 41 | }, 42 | }, 43 | state_2: { 44 | subMachines: firstSubMachine, 45 | transitions: { 46 | change_state: 'state_3', 47 | }, 48 | }, 49 | state_3: {}, 50 | }; 51 | } 52 | 53 | const parentStateMachine = new ParentMachine(); 54 | 55 | describe('Sub state machines semantics', () => { 56 | const reducers = combineReducers({ 57 | parent: parentStateMachine.stateReducer, 58 | firstSub: firstSubMachine.stateReducer, 59 | secondSub: secondSubMachine.stateReducer, 60 | }); 61 | 62 | const tester = new SagaTester({ reducers }); 63 | tester.start( 64 | stateMachineStarterSaga, 65 | parentStateMachine, 66 | firstSubMachine, 67 | secondSubMachine 68 | ); 69 | 70 | afterEach(() => { 71 | tester.dispatch(parentStateMachine.stop()); 72 | tester.reset(true); 73 | jest.resetAllMocks(); 74 | }); 75 | 76 | test('sub state machines are started and stopped with their parent', () => { 77 | expect(tester.getState().parent.state).toEqual(null); 78 | expect(tester.getState().firstSub.state).toEqual(null); 79 | expect(tester.getState().secondSub.state).toEqual(null); 80 | 81 | tester.dispatch(parentStateMachine.start({})); 82 | 83 | expect(tester.getState().parent.state).toEqual('state_1'); 84 | expect(tester.getState().firstSub.state).toEqual('state_1'); 85 | expect(tester.getState().secondSub.state).toEqual('state_1'); 86 | 87 | expect(tester.numCalled(startStmActionType)).toEqual(3); 88 | 89 | tester.dispatch(parentStateMachine.stop()); 90 | 91 | expect(tester.getState().parent.state).toEqual(null); 92 | expect(tester.getState().firstSub.state).toEqual(null); 93 | expect(tester.getState().secondSub.state).toEqual(null); 94 | 95 | expect(tester.numCalled(stopStmActionType)).toEqual(3); 96 | }); 97 | 98 | test('transitions change the running sub machines', () => { 99 | tester.dispatch(parentStateMachine.start({})); 100 | 101 | expect(tester.getState().parent.state).toEqual('state_1'); 102 | expect(tester.getState().firstSub.state).toEqual('state_1'); 103 | expect(tester.getState().secondSub.state).toEqual('state_1'); 104 | 105 | tester.dispatch({ type: 'change_state' }); 106 | 107 | expect(tester.getState().parent.state).toEqual('state_2'); 108 | expect(tester.getState().firstSub.state).toEqual('state_1'); 109 | expect(tester.getState().secondSub.state).toEqual(null); 110 | 111 | tester.dispatch({ type: 'change_state' }); 112 | 113 | expect(tester.getState().parent.state).toEqual('state_3'); 114 | expect(tester.getState().firstSub.state).toEqual(null); 115 | expect(tester.getState().secondSub.state).toEqual(null); 116 | }); 117 | 118 | test('quick restart works', () => { 119 | expect(tester.getState().parent.state).toEqual(null); 120 | expect(tester.getState().firstSub.state).toEqual(null); 121 | expect(tester.getState().secondSub.state).toEqual(null); 122 | 123 | tester.dispatch(parentStateMachine.start({})); 124 | 125 | expect(tester.getState().parent.state).toEqual('state_1'); 126 | expect(tester.getState().firstSub.state).toEqual('state_1'); 127 | expect(tester.getState().secondSub.state).toEqual('state_1'); 128 | 129 | expect(tester.numCalled(startStmActionType)).toEqual(3); 130 | 131 | tester.dispatch(parentStateMachine.stop()); 132 | tester.dispatch(parentStateMachine.start({})); 133 | 134 | expect(tester.getState().parent.state).toEqual('state_1'); 135 | expect(tester.getState().firstSub.state).toEqual('state_1'); 136 | expect(tester.getState().secondSub.state).toEqual('state_1'); 137 | 138 | expect(tester.numCalled(stopStmActionType)).toEqual(3); 139 | expect(tester.numCalled(startStmActionType)).toEqual(6); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /tests/transitions.test.ts: -------------------------------------------------------------------------------- 1 | import { SagaTester } from '@moveaxlab/redux-saga-tester'; 2 | import { StateMachine, stateMachineStarterSaga } from '../src'; 3 | import { combineReducers } from 'redux'; 4 | 5 | const command1 = jest.fn(); 6 | const command2 = jest.fn(); 7 | const command3 = jest.fn(); 8 | 9 | const onEntrySpy = jest.fn(); 10 | 11 | const simpleGuard = jest.fn(); 12 | const simpleGuardCommand = jest.fn(); 13 | 14 | const combinedGuard1 = jest.fn(); 15 | const combinedGuardCommand1 = jest.fn(); 16 | const combinedGuard2 = jest.fn(); 17 | const combinedGuardCommand2 = jest.fn(); 18 | 19 | class StateMachineTransitions extends StateMachine { 20 | readonly name = 'transitions'; 21 | 22 | protected readonly initialState = 'state_1'; 23 | 24 | protected readonly spec = { 25 | state_1: { 26 | transitions: { 27 | simple_transition: 'state_2', 28 | concurrent_transition: 'state_3', 29 | transition_with_command: { 30 | target: 'state_4', 31 | command: command1, 32 | }, 33 | transition_with_multiple_commands: { 34 | target: 'state_4', 35 | command: [command2, command3], 36 | }, 37 | guarded_transition: { 38 | guard: simpleGuard, 39 | target: 'state_2', 40 | command: simpleGuardCommand, 41 | }, 42 | combined_guard: [ 43 | { 44 | guard: combinedGuard1, 45 | target: 'state_2', 46 | command: combinedGuardCommand1, 47 | }, 48 | { 49 | guard: combinedGuard2, 50 | target: 'state_3', 51 | command: combinedGuardCommand2, 52 | }, 53 | ], 54 | }, 55 | }, 56 | state_2: {}, 57 | state_3: {}, 58 | state_4: { 59 | onEntry: onEntrySpy, 60 | }, 61 | }; 62 | } 63 | 64 | const stateMachine = new StateMachineTransitions(); 65 | 66 | describe('Transitions semantics', () => { 67 | const reducers = combineReducers({ 68 | stateMachine: stateMachine.stateReducer, 69 | }); 70 | 71 | const tester = new SagaTester({ reducers }); 72 | tester.start(stateMachineStarterSaga, stateMachine); 73 | 74 | afterEach(() => { 75 | tester.dispatch(stateMachine.stop()); 76 | tester.reset(true); 77 | jest.resetAllMocks(); 78 | }); 79 | 80 | test('only one transition is taken', () => { 81 | tester.dispatch(stateMachine.start({})); 82 | 83 | expect(tester.getState().stateMachine.state).toEqual('state_1'); 84 | 85 | tester.dispatch({ type: 'simple_transition' }); 86 | tester.dispatch({ type: 'concurrent_transition' }); 87 | 88 | expect(tester.getState().stateMachine.state).toEqual('state_2'); 89 | }); 90 | 91 | test('a simple command is executed before entering the new state', () => { 92 | tester.dispatch(stateMachine.start({})); 93 | 94 | const event = { type: 'transition_with_command' }; 95 | 96 | tester.dispatch(event); 97 | 98 | expect(tester.getState().stateMachine.state).toEqual('state_4'); 99 | 100 | expect(command1).toHaveBeenCalledTimes(1); 101 | expect(command1).toHaveBeenCalledWith(event); 102 | 103 | expect(onEntrySpy).toHaveBeenCalledTimes(1); 104 | 105 | expect(command1).toHaveBeenCalledBefore(onEntrySpy); 106 | }); 107 | 108 | test('multiple commands are executed before entering the new state', () => { 109 | tester.dispatch(stateMachine.start({})); 110 | 111 | const event = { type: 'transition_with_multiple_commands' }; 112 | 113 | tester.dispatch(event); 114 | 115 | expect(tester.getState().stateMachine.state).toEqual('state_4'); 116 | 117 | expect(command2).toHaveBeenCalledTimes(1); 118 | expect(command2).toHaveBeenCalledWith(event); 119 | 120 | expect(command3).toHaveBeenCalledTimes(1); 121 | expect(command3).toHaveBeenCalledWith(event); 122 | 123 | expect(onEntrySpy).toHaveBeenCalledTimes(1); 124 | 125 | expect(command2).toHaveBeenCalledBefore(onEntrySpy); 126 | expect(command3).toHaveBeenCalledBefore(onEntrySpy); 127 | }); 128 | 129 | test('guarded transitions are executed only if the guard returns true', () => { 130 | tester.dispatch(stateMachine.start({})); 131 | 132 | const event = { type: 'guarded_transition' }; 133 | 134 | simpleGuard.mockReturnValue(false); 135 | 136 | tester.dispatch(event); 137 | 138 | expect(tester.getState().stateMachine.state).toEqual('state_1'); 139 | 140 | expect(simpleGuard).toHaveBeenCalledTimes(1); 141 | expect(simpleGuard).toHaveBeenCalledWith(event, stateMachine.context); 142 | 143 | expect(simpleGuardCommand).not.toHaveBeenCalled(); 144 | 145 | simpleGuard.mockReset(); 146 | simpleGuard.mockReturnValue(true); 147 | 148 | tester.dispatch(event); 149 | 150 | expect(tester.getState().stateMachine.state).toEqual('state_2'); 151 | 152 | expect(simpleGuard).toHaveBeenCalledTimes(1); 153 | expect(simpleGuard).toHaveBeenCalledWith(event, stateMachine.context); 154 | 155 | expect(simpleGuardCommand).toHaveBeenCalledTimes(1); 156 | expect(simpleGuardCommand).toHaveBeenCalledWith(event); 157 | }); 158 | 159 | test('only one guarded transition is taken if many are specified', () => { 160 | tester.dispatch(stateMachine.start({})); 161 | 162 | const event = { type: 'combined_guard' }; 163 | 164 | combinedGuard1.mockReturnValue(false); 165 | combinedGuard2.mockReturnValue(false); 166 | 167 | tester.dispatch(event); 168 | 169 | expect(tester.getState().stateMachine.state).toEqual('state_1'); 170 | 171 | expect(combinedGuard1).toHaveBeenCalledTimes(1); 172 | expect(combinedGuard2).toHaveBeenCalledTimes(1); 173 | expect(combinedGuard1).toHaveBeenCalledWith(event, stateMachine.context); 174 | expect(combinedGuard2).toHaveBeenCalledWith(event, stateMachine.context); 175 | 176 | expect(combinedGuardCommand1).not.toHaveBeenCalled(); 177 | expect(combinedGuardCommand2).not.toHaveBeenCalled(); 178 | 179 | jest.resetAllMocks(); 180 | combinedGuard1.mockReturnValue(false); 181 | combinedGuard2.mockReturnValue(true); 182 | 183 | tester.dispatch(event); 184 | 185 | expect(tester.getState().stateMachine.state).toEqual('state_3'); 186 | 187 | expect(combinedGuard2).toHaveBeenCalledTimes(1); 188 | expect(combinedGuard2).toHaveBeenCalledWith(event, stateMachine.context); 189 | 190 | expect(combinedGuardCommand1).not.toHaveBeenCalled(); 191 | expect(combinedGuardCommand2).toHaveBeenCalledTimes(1); 192 | expect(combinedGuardCommand2).toHaveBeenCalledWith(event); 193 | }); 194 | }); 195 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "es2016"], 4 | "target": "es2016", 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "declaration": true, 8 | "noImplicitAny": true, 9 | "skipLibCheck": true, 10 | "strictNullChecks": true, 11 | "esModuleInterop": true, 12 | "strict": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.build-types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "compilerOptions": { 4 | "removeComments": false, 5 | "emitDeclarationOnly": true, 6 | "outDir": "./types", 7 | "rootDir": "./src", 8 | "declaration": true 9 | }, 10 | "exclude": [ 11 | "node_modules", 12 | "example", 13 | "lib", 14 | "tests", 15 | "types" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "./lib", 5 | "rootDir": "./src", 6 | "removeComments": true, 7 | "declaration": false 8 | }, 9 | "exclude": [ 10 | "node_modules", 11 | "example", 12 | "lib", 13 | "tests", 14 | "types" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "noEmit": true 5 | }, 6 | "exclude": [ 7 | "node_modules" 8 | ] 9 | } 10 | --------------------------------------------------------------------------------