├── .editorconfig ├── .eslintrc ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .husky └── pre-commit ├── .lintstagedrc.json ├── .prettierrc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── concept-diagram.puml ├── concept-diagram.svg ├── og-image.png └── og-image.svg ├── jest.config.js ├── lerna.json ├── package-lock.json ├── package.json ├── packages ├── examples │ ├── CHANGELOG.md │ ├── LICENSE │ ├── package.json │ ├── pizzaShop.ts │ ├── pizzaShopComponent.tsx │ ├── src │ │ ├── calculator.test.ts │ │ └── counter.tsx │ └── tsconfig.json ├── rx-effects-react │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── docs │ │ ├── .nojekyll │ │ └── README.md │ ├── index.ts │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── mvc.test.tsx │ │ ├── mvc.tsx │ │ ├── test │ │ │ └── testUtils.ts │ │ ├── useConst.test.ts │ │ ├── useConst.ts │ │ ├── useController.test.ts │ │ ├── useController.ts │ │ ├── useObservable.test.ts │ │ ├── useObservable.ts │ │ ├── useObserver.test.ts │ │ ├── useObserver.ts │ │ ├── useQuery.test.ts │ │ ├── useQuery.ts │ │ ├── useSelector.test.ts │ │ ├── useSelector.ts │ │ ├── useStore.test.ts │ │ ├── useStore.ts │ │ └── utils.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ └── typedoc.json └── rx-effects │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── docs │ ├── .nojekyll │ └── README.md │ ├── index.ts │ ├── package.json │ ├── src │ ├── action.test.ts │ ├── action.ts │ ├── controller.ts │ ├── declareStore.test.ts │ ├── declareStore.ts │ ├── effect.test.ts │ ├── effect.ts │ ├── effectController.test.ts │ ├── effectController.ts │ ├── effectState.ts │ ├── index.ts │ ├── mvc.test.ts │ ├── mvc.ts │ ├── query.test.ts │ ├── query.ts │ ├── queryMappers.ts │ ├── scope.test.ts │ ├── scope.ts │ ├── store.test.ts │ ├── store.ts │ ├── storeEvents.ts │ ├── storeExtensions.test.ts │ ├── storeExtensions.ts │ ├── storeLoggerExtension.test.ts │ ├── storeLoggerExtension.ts │ ├── storeMetadata.ts │ ├── storeUtils.test.ts │ ├── storeUtils.ts │ ├── utils.test.ts │ └── utils.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ └── typedoc.json ├── rocket.svg ├── scripts └── docs.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 2 9 | indent_style = space 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "jest/globals": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:import/recommended", 9 | "plugin:jest/recommended", 10 | "prettier" 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "plugins": [ 14 | "@typescript-eslint", 15 | "import", 16 | "jest", 17 | "react-hooks", 18 | "eslint-comments" 19 | ], 20 | "rules": { 21 | "@typescript-eslint/no-explicit-any": "off" 22 | }, 23 | "overrides": [ 24 | { 25 | "files": ["*.ts", "*.tsx"], 26 | "extends": [ 27 | "eslint:recommended", 28 | "plugin:@typescript-eslint/recommended", 29 | "plugin:import/recommended", 30 | "plugin:import/typescript", 31 | "plugin:jest/recommended", 32 | "plugin:react-hooks/recommended", 33 | "prettier" 34 | ], 35 | "rules": { 36 | "@typescript-eslint/no-explicit-any": "off" 37 | } 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | on: ['push', 'pull_request'] 2 | 3 | name: Main 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-node@v2 11 | with: 12 | node-version: '16' 13 | - name: Install dependencies 14 | run: npm ci 15 | - name: Clean 16 | run: npm run clean 17 | - name: Build 18 | run: npm run build 19 | - name: Lint 20 | run: npm run lint 21 | - name: Test 22 | run: npm run test -- --coverage 23 | - name: Coveralls 24 | uses: coverallsapp/github-action@master 25 | with: 26 | github-token: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /.vscode 3 | /coverage 4 | /dist 5 | /packages/*/dist 6 | .DS_Store 7 | node_modules 8 | npm-debug.log 9 | Thumbs.db 10 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js|jsx|ts|tsx}": ["eslint --fix", "prettier --write"], 3 | "*.{css,json,md,html,yml}": ["prettier --write"] 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": true, 3 | "printWidth": 80, 4 | "semi": true, 5 | "singleQuote": true, 6 | "tabWidth": 2, 7 | "trailingComma": "all" 8 | } 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | ## [1.1.2](https://github.com/mnasyrov/rx-effects/compare/v1.1.1...v1.1.2) (2024-07-12) 7 | 8 | **Note:** Version bump only for package rx-effects-root 9 | 10 | ## [1.1.1](https://github.com/mnasyrov/rx-effects/compare/v1.1.0...v1.1.1) (2023-08-05) 11 | 12 | **Note:** Version bump only for package rx-effects-root 13 | 14 | # [1.1.0](https://github.com/mnasyrov/rx-effects/compare/v1.0.1...v1.1.0) (2023-02-01) 15 | 16 | ### Features 17 | 18 | - Made `ViewController` to accept queries for external parameters (breaking change) ([#17](https://github.com/mnasyrov/rx-effects/issues/17)) ([ad49f8a](https://github.com/mnasyrov/rx-effects/commit/ad49f8a70eda02a415c37de7de320582f4a91d0e)) 19 | - new declareStore factory ([#15](https://github.com/mnasyrov/rx-effects/issues/15)) ([824f156](https://github.com/mnasyrov/rx-effects/commit/824f156a00ce9b0e4a6488a201913f3abf82177b)) 20 | 21 | ## [1.0.1](https://github.com/mnasyrov/rx-effects/compare/v1.0.0...v1.0.1) (2023-01-23) 22 | 23 | ### Bug Fixes 24 | 25 | - Fixed rerendering by `useObserver()` and reduced excess unsubscribe/subscribe on rerendering a parent component ([#13](https://github.com/mnasyrov/rx-effects/issues/13)) ([469b251](https://github.com/mnasyrov/rx-effects/commit/469b251797980b6280eb98d097e1b24747675879)) 26 | - Usage of `declareViewController()` with a controller factory with the single `scope` argument ([#11](https://github.com/mnasyrov/rx-effects/issues/11)) ([08a3ba4](https://github.com/mnasyrov/rx-effects/commit/08a3ba442caae56e58edb6437807d076b54e879b)) 27 | 28 | # [1.0.0](https://github.com/mnasyrov/rx-effects/compare/v0.7.2...v1.0.0) (2022-12-20) 29 | 30 | ### Features 31 | 32 | - Updated API for the library. Introduced tooling for ViewControllers with Ditox.js DI container. ([7cffcd0](https://github.com/mnasyrov/rx-effects/commit/7cffcd03f915337fa27e3b55f30fd1ad0c45a087)) 33 | 34 | ## [0.7.2](https://github.com/mnasyrov/rx-effects/compare/v0.7.1...v0.7.2) (2022-10-29) 35 | 36 | ### Features 37 | 38 | - Introduced `scope.onDestroy()` and `scope.subscribe()`. Added info about API deprecation. ([#9](https://github.com/mnasyrov/rx-effects/issues/9)) ([4467782](https://github.com/mnasyrov/rx-effects/commit/44677829f889aa4fbca12fb467f149cd0fab6869)) 39 | 40 | ## [0.7.1](https://github.com/mnasyrov/rx-effects/compare/v0.7.0...v0.7.1) (2022-10-26) 41 | 42 | ### Bug Fixes 43 | 44 | - Fixed and renamed `scope.subscribe()` to `scope.observe()` ([d3cf291](https://github.com/mnasyrov/rx-effects/commit/d3cf291a10ecc9bac1ebce044c05ed140cd3b601)) 45 | 46 | # [0.7.0](https://github.com/mnasyrov/rx-effects/compare/v0.6.0...v0.7.0) (2022-10-26) 47 | 48 | ### Bug Fixes 49 | 50 | - Fixed usage of Effect's options by `handleAction()` and `scope.createEffect()` ([#7](https://github.com/mnasyrov/rx-effects/issues/7)) ([e44bd23](https://github.com/mnasyrov/rx-effects/commit/e44bd23b563f7a61ea1ecfa291b311f52d55e577)) 51 | 52 | ### Features 53 | 54 | - New scope's methods: `handleQuery()` and `subscribe()` ([#8](https://github.com/mnasyrov/rx-effects/issues/8)) ([5242c3e](https://github.com/mnasyrov/rx-effects/commit/5242c3e91b042b5eb060a0d1899a018c4b29294a)) 55 | 56 | # [0.6.0](https://github.com/mnasyrov/rx-effects/compare/v0.5.2...v0.6.0) (2022-08-28) 57 | 58 | ### Features 59 | 60 | - `EffectOptions.pipeline` for customising processing of event. ([#4](https://github.com/mnasyrov/rx-effects/issues/4)) ([e927bb3](https://github.com/mnasyrov/rx-effects/commit/e927bb31c5fd7fe5c6c1e54b344d95dffc6ffd97)) 61 | - Added `ExternalScope` type. ([#3](https://github.com/mnasyrov/rx-effects/issues/3)) ([11c8a9c](https://github.com/mnasyrov/rx-effects/commit/11c8a9cd181869e2f973233efe42c49dc51b5ad3)) 62 | - Refactored Query API ([0ba6d12](https://github.com/mnasyrov/rx-effects/commit/0ba6d12df5f99cf98f04f130a89be814c90180f8)) 63 | - Track all unhandled errors of Effects ([#5](https://github.com/mnasyrov/rx-effects/issues/5)) ([3c108a4](https://github.com/mnasyrov/rx-effects/commit/3c108a488eae471337cc727461a7a223f7c367f3)) 64 | 65 | ## [0.5.2](https://github.com/mnasyrov/rx-effects/compare/v0.5.1...v0.5.2) (2022-01-26) 66 | 67 | **Note:** Version bump only for package rx-effects-root 68 | 69 | ## [0.5.1](https://github.com/mnasyrov/rx-effects/compare/v0.5.0...v0.5.1) (2022-01-11) 70 | 71 | ### Bug Fixes 72 | 73 | - Do not expose internal stores to extensions ([27420cb](https://github.com/mnasyrov/rx-effects/commit/27420cb152ddfafa48f9d7f75b59e558ba982d64)) 74 | 75 | # [0.5.0](https://github.com/mnasyrov/rx-effects/compare/v0.4.1...v0.5.0) (2022-01-11) 76 | 77 | ### Bug Fixes 78 | 79 | - Fixed eslint rules ([6975806](https://github.com/mnasyrov/rx-effects/commit/69758063de4d9f6b7821b439aad054087df249b9)) 80 | - **rx-effects-react:** Fixed calling `destroy()` of a class-based controller by `useController()` ([1bdf6b5](https://github.com/mnasyrov/rx-effects/commit/1bdf6b55df6f41988bf7b481ec2019c97731f127)) 81 | 82 | ### Features 83 | 84 | - Introduced store actions and `createStoreActions()` factory. ([c51bd07](https://github.com/mnasyrov/rx-effects/commit/c51bd07fa24c6d111567f75ad190a9f9bd987a5e)) 85 | - Introduced Store extensions and StoreLoggerExtension ([931b949](https://github.com/mnasyrov/rx-effects/commit/931b949b0c5134d6261eac7f6381a293dab48599)) 86 | 87 | ## [0.4.1](https://github.com/mnasyrov/rx-effects/compare/v0.4.0...v0.4.1) (2021-11-10) 88 | 89 | ### Bug Fixes 90 | 91 | - Share and replay mapQuery() and mergeQueries() to subscriptions ([8308310](https://github.com/mnasyrov/rx-effects/commit/830831001630d2b2b7318d2e7126706803eff9ff)) 92 | 93 | # [0.4.0](https://github.com/mnasyrov/rx-effects/compare/v0.3.3...v0.4.0) (2021-09-30) 94 | 95 | ### Bug Fixes 96 | 97 | - Concurrent store updates by its subscribers ([bc29bb5](https://github.com/mnasyrov/rx-effects/commit/bc29bb545587c01386b7351e25c5ce4b5036dc9c)) 98 | 99 | ## [0.3.3](https://github.com/mnasyrov/rx-effects/compare/v0.3.2...v0.3.3) (2021-09-27) 100 | 101 | ### Features 102 | 103 | - Store.update() can apply an array of mutations ([d778ac9](https://github.com/mnasyrov/rx-effects/commit/d778ac99549a9ac1887ea03ab77d5f0fa6527d1f)) 104 | 105 | ## [0.3.2](https://github.com/mnasyrov/rx-effects/compare/v0.3.1...v0.3.2) (2021-09-14) 106 | 107 | ### Bug Fixes 108 | 109 | - useController() hook triggers rerenders if it is used without dependencies. ([f0b5582](https://github.com/mnasyrov/rx-effects/commit/f0b5582b7e801bd86882694d8d7dbb5456ca33bb)) 110 | 111 | ## [0.3.1](https://github.com/mnasyrov/rx-effects/compare/v0.3.0...v0.3.1) (2021-09-07) 112 | 113 | ### Bug Fixes 114 | 115 | - `mapQuery()` and `mergeQueries()` produce distinct values by default ([17721af](https://github.com/mnasyrov/rx-effects/commit/17721af837b3a43f047ef67ba475294e58492e80)) 116 | 117 | # [0.3.0](https://github.com/mnasyrov/rx-effects/compare/v0.2.2...v0.3.0) (2021-09-07) 118 | 119 | ### Features 120 | 121 | - Introduced `StateQueryOptions` for query mappers. Strict equality === is used by default as value comparators. ([5cc97e0](https://github.com/mnasyrov/rx-effects/commit/5cc97e0f7ab1623ffbdc133e5bfbe63911d68b56)) 122 | 123 | ## [0.2.2](https://github.com/mnasyrov/rx-effects/compare/v0.2.1...v0.2.2) (2021-09-02) 124 | 125 | ### Bug Fixes 126 | 127 | - Fixed typings and arguments of `mergeQueries()` ([156abcc](https://github.com/mnasyrov/rx-effects/commit/156abccc4dbe569751c1c79d1dba19e441da91cf)) 128 | 129 | ## [0.2.1](https://github.com/mnasyrov/rx-effects/compare/v0.2.0...v0.2.1) (2021-08-15) 130 | 131 | ### Bug Fixes 132 | 133 | - Added a missed export for `useController()` hook ([a5e5c92](https://github.com/mnasyrov/rx-effects/commit/a5e5c92da8a288f44c41dac2cb70c96d788eea38)) 134 | 135 | # [0.2.0](https://github.com/mnasyrov/rx-effects/compare/v0.1.0...v0.2.0) (2021-08-09) 136 | 137 | ### Features 138 | 139 | - Renamed `EffectScope` to `Scope`. Extended `Scope` and `declareState()`. ([21d97be](https://github.com/mnasyrov/rx-effects/commit/21d97be080897f33f674d461397e8f1e86ac8eef)) 140 | 141 | # [0.1.0](https://github.com/mnasyrov/rx-effects/compare/v0.0.8...v0.1.0) (2021-08-03) 142 | 143 | ### Features 144 | 145 | - Introduced 'destroy()' method to Store to complete it. ([199cbb7](https://github.com/mnasyrov/rx-effects/commit/199cbb70ab2163f9f8edc8045b988afd2604595b)) 146 | - Introduced `Controller`, `useController()` and `mergeQueries()` ([d84a2e2](https://github.com/mnasyrov/rx-effects/commit/d84a2e2b8d1f57ca59e9664004de844a1f8bcf1f)) 147 | 148 | ## [0.0.8](https://github.com/mnasyrov/rx-effects/compare/v0.0.7...v0.0.8) (2021-07-26) 149 | 150 | ### Bug Fixes 151 | 152 | - Dropped stateEffects for a while. Added stubs for docs. ([566ab80](https://github.com/mnasyrov/rx-effects/commit/566ab8085b6e493942bf908e3000097561a14724)) 153 | 154 | ## [0.0.7](https://github.com/mnasyrov/rx-effects/compare/v0.0.6...v0.0.7) (2021-07-23) 155 | 156 | ### Bug Fixes 157 | 158 | - Types for actions and effects ([49235fe](https://github.com/mnasyrov/rx-effects/commit/49235fe80728a3803a16251d4c163f002b4bb29f)) 159 | 160 | ## [0.0.6](https://github.com/mnasyrov/rx-effects/compare/v0.0.5...v0.0.6) (2021-07-12) 161 | 162 | **Note:** Version bump only for package rx-effects-root 163 | 164 | ## [0.0.5](https://github.com/mnasyrov/rx-effects/compare/v0.0.4...v0.0.5) (2021-07-11) 165 | 166 | **Note:** Version bump only for package @rx-effects/root 167 | 168 | ## [0.0.4](https://github.com/mnasyrov/rx-effects/compare/v0.0.3...v0.0.4) (2021-07-11) 169 | 170 | **Note:** Version bump only for package @rx-effects/root 171 | 172 | ## [0.0.3](https://github.com/mnasyrov/rx-effects/compare/v0.0.2...v0.0.3) (2021-07-11) 173 | 174 | **Note:** Version bump only for package @rx-effects/root 175 | 176 | ## 0.0.2 (2021-07-11) 177 | 178 | **Note:** Version bump only for package @rx-effects/root 179 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Mikhail Nasyrov 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 | # RxEffects 2 | 3 | rocket 4 | 5 | Reactive state and effect management with RxJS. 6 | 7 | [![npm](https://img.shields.io/npm/v/rx-effects.svg)](https://www.npmjs.com/package/rx-effects) 8 | [![downloads](https://img.shields.io/npm/dt/rx-effects.svg)](https://www.npmjs.com/package/rx-effects) 9 | [![types](https://img.shields.io/npm/types/rx-effects.svg)](https://www.npmjs.com/package/rx-effects) 10 | [![licence](https://img.shields.io/github/license/mnasyrov/rx-effects.svg)](https://github.com/mnasyrov/rx-effects/blob/master/LICENSE) 11 | [![Coverage Status](https://coveralls.io/repos/github/mnasyrov/rx-effects/badge.svg?branch=main)](https://coveralls.io/github/mnasyrov/rx-effects?branch=main) 12 | 13 | ## Overview 14 | 15 | The library provides a way to describe business and application logic using MVC-like architecture. Core elements include actions and effects, states and stores. All of them are optionated and can be used separately. The core package is framework-agnostic and can be used in different cases: libraries, server apps, web, SPA and micro-frontends apps. 16 | 17 | The library is inspired by [MVC](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller), [RxJS](https://github.com/ReactiveX/rxjs), [Akita](https://github.com/datorama/akita), [JetState](https://github.com/mnasyrov/jetstate) and [Effector](https://github.com/effector/effector). 18 | 19 | It is recommended to use RxEffects together with [Ditox.js] – a dependency injection container. 20 | 21 | ### Features 22 | 23 | - Reactive state and store 24 | - Declarative actions and effects 25 | - Effect container 26 | - Framework-agnostic 27 | - Functional API 28 | - Typescript typings 29 | 30 | ### Breaking changes 31 | 32 | Version 1.0 contains breaking changes due stabilizing API from the early stage. The previous API is available in 0.7.2 version. 33 | 34 | Documentation is coming soon. 35 | 36 | ### Packages 37 | 38 | | Package | Description | Links | 39 | | --------------------------------------------- | ------------------------------------------ | ---------------------------------------------------------- | 40 | | [**rx-effects**][rx-effects/docs] | Core elements, state and effect management | [Docs][rx-effects/docs], [API][rx-effects/api] | 41 | | [**rx-effects-react**][rx-effects-react/docs] | Tooling for React.js | [Docs][rx-effects-react/docs], [API][rx-effects-react/api] | 42 | 43 | ## Usage 44 | 45 | ### Installation 46 | 47 | ``` 48 | npm install rx-effects rx-effects-react --save 49 | ``` 50 | 51 | ### Concepts 52 | 53 | The main idea is to use the classic MVC pattern with event-based models (state stores) and reactive controllers (actions and effects). The view subscribes to model changes (state queries) of the controller and requests the controller to do some actions. 54 | 55 | concept-diagram 56 | 57 | Core elements: 58 | 59 | - `State` – a data model. 60 | - `Query` – a getter and subscriber for data of the state. 61 | - `StateMutation` – a pure function which changes the state. 62 | - `Store` – a state storage, it provides methods to update and subscribe the state. 63 | - `Action` – an event emitter. 64 | - `Effect` – a business logic which handles the action and makes state changes and side effects. 65 | - `Controller` – a controller type for effects and business logic 66 | - `Scope` – a controller-like boundary for effects and business logic 67 | 68 | ### Example 69 | 70 | Below is an implementation of the pizza shop, which allows order pizza from the menu and to submit the cart. The controller orchestrate the state store and side effects. The component renders the state and reacts on user events. 71 | 72 | ```ts 73 | // pizzaShop.ts 74 | 75 | import { 76 | Controller, 77 | createAction, 78 | createScope, 79 | declareStateUpdates, 80 | EffectState, 81 | Query, 82 | withStoreUpdates, 83 | } from 'rx-effects'; 84 | import { delay, filter, map, mapTo, of } from 'rxjs'; 85 | 86 | // The state 87 | type CartState = Readonly<{ orders: Array }>; 88 | 89 | // Declare the initial state. 90 | const CART_STATE: CartState = { orders: [] }; 91 | 92 | // Declare updates of the state. 93 | const CART_STATE_UPDATES = declareStateUpdates({ 94 | addPizzaToCart: (name: string) => (state) => ({ 95 | ...state, 96 | orders: [...state.orders, name], 97 | }), 98 | 99 | removePizzaFromCart: (name: string) => (state) => ({ 100 | ...state, 101 | orders: state.orders.filter((order) => order !== name), 102 | }), 103 | }); 104 | 105 | // Declaring the controller. 106 | // It should provide methods for triggering the actions, 107 | // and queries or observables for subscribing to data. 108 | export type PizzaShopController = Controller<{ 109 | ordersQuery: Query>; 110 | 111 | addPizza: (name: string) => void; 112 | removePizza: (name: string) => void; 113 | submitCart: () => void; 114 | submitState: EffectState>; 115 | }>; 116 | 117 | export function createPizzaShopController(): PizzaShopController { 118 | // Creates the scope to track subscriptions 119 | const scope = createScope(); 120 | 121 | // Creates the state store 122 | const store = withStoreUpdates( 123 | scope.createStore(CART_STATE), 124 | CART_STATE_UPDATES, 125 | ); 126 | 127 | // Creates queries for the state data 128 | const ordersQuery = store.query((state) => state.orders); 129 | 130 | // Introduces actions 131 | const addPizza = createAction(); 132 | const removePizza = createAction(); 133 | const submitCart = createAction(); 134 | 135 | // Handle simple actions 136 | scope.handle(addPizza, (order) => store.updates.addPizzaToCart(order)); 137 | 138 | scope.handle(removePizza, (name) => store.updates.removePizzaFromCart(name)); 139 | 140 | // Create a effect in a general way 141 | const submitEffect = scope.createEffect>((orders) => { 142 | // Sending an async request to a server 143 | return of(orders).pipe(delay(1000), mapTo(undefined)); 144 | }); 145 | 146 | // Effect can handle `Observable` and `Action`. It allows to filter action events 147 | // and transform data which is passed to effect's handler. 148 | submitEffect.handle( 149 | submitCart.event$.pipe( 150 | map(() => ordersQuery.get()), 151 | filter((orders) => !submitEffect.pending.get() && orders.length > 0), 152 | ), 153 | ); 154 | 155 | // Effect's results can be used as actions 156 | scope.handle(submitEffect.done$, () => store.set(CART_STATE)); 157 | 158 | return { 159 | ordersQuery, 160 | addPizza, 161 | removePizza, 162 | submitCart, 163 | submitState: submitEffect, 164 | destroy: () => scope.destroy(), 165 | }; 166 | } 167 | ``` 168 | 169 | ```tsx 170 | // pizzaShopComponent.tsx 171 | 172 | import React, { FC, useEffect } from 'react'; 173 | import { useConst, useObservable, useQuery } from 'rx-effects-react'; 174 | import { createPizzaShopController } from './pizzaShop'; 175 | 176 | export const PizzaShopComponent: FC = () => { 177 | // Creates the controller and destroy it on unmounting the component 178 | const controller = useConst(() => createPizzaShopController()); 179 | useEffect(() => controller.destroy, [controller]); 180 | 181 | // The same creation can be achieved by using `useController()` helper: 182 | // const controller = useController(createPizzaShopController); 183 | 184 | // Using the controller 185 | const { ordersQuery, addPizza, removePizza, submitCart, submitState } = 186 | controller; 187 | 188 | // Subscribing to state data and the effect stata 189 | const orders = useQuery(ordersQuery); 190 | const isPending = useQuery(submitState.pending); 191 | const submitError = useObservable(submitState.error$, undefined); 192 | 193 | return ( 194 | <> 195 |

Pizza Shop

196 | 197 |

Menu

198 |
    199 |
  • 200 | Pepperoni 201 | 204 |
  • 205 | 206 |
  • 207 | Margherita 208 | 211 |
  • 212 |
213 | 214 |

Cart

215 |
    216 | {orders.map((name) => ( 217 |
  • 218 | {name} 219 | 222 |
  • 223 | ))} 224 |
225 | 226 | 229 | 230 | {submitError &&
Failed to submit the cart
} 231 | 232 | ); 233 | }; 234 | ``` 235 | 236 | --- 237 | 238 | [rx-effects/docs]: packages/rx-effects/README.md 239 | [rx-effects/api]: packages/rx-effects/docs/README.md 240 | [rx-effects-react/docs]: packages/rx-effects-react/README.md 241 | [rx-effects-react/api]: packages/rx-effects-react/docs/README.md 242 | [ditox.js]: https://github.com/mnasyrov/ditox 243 | 244 | © 2021 [Mikhail Nasyrov](https://github.com/mnasyrov), [MIT license](./LICENSE) 245 | -------------------------------------------------------------------------------- /docs/concept-diagram.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | 3 | rectangle View { 4 | [Component] as "UI Component" 5 | } 6 | 7 | rectangle Controller { 8 | collections Effects 9 | collections Queries 10 | } 11 | 12 | rectangle Model { 13 | database Store as "State Store" 14 | } 15 | 16 | cloud Backend 17 | 18 | Component --> Effects : Actions 19 | Component <-- Queries : Rendering 20 | 21 | Effects --> Store : Updates 22 | Effects <--> Backend : Async API 23 | 24 | Effects -> Queries : Results 25 | 26 | Store --> Queries : Data 27 | 28 | 29 | @enduml 30 | -------------------------------------------------------------------------------- /docs/concept-diagram.svg: -------------------------------------------------------------------------------- 1 | ViewControllerModelUI ComponentEffectsQueriesState StoreBackendActionsRenderingUpdatesAsync APIResultsData -------------------------------------------------------------------------------- /docs/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mnasyrov/rx-effects/43609702569bd806a0a1859fb7f122672ebb18ab/docs/og-image.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // * https://jestjs.io/docs/en/configuration.html 3 | // * https://kulshekhar.github.io/ts-jest/user/config/ 4 | 5 | import fastGlob from 'fast-glob'; 6 | 7 | export default { 8 | roots: fastGlob.sync(['packages/*/src'], { 9 | onlyDirectories: true, 10 | ignore: ['packages/examples/*'], 11 | }), 12 | collectCoverageFrom: ['packages/*/src/**/{!(index|testUtils),}.{ts,tsx}'], 13 | preset: 'ts-jest', 14 | }; 15 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.1.2", 3 | "hoist": "**", 4 | "forceLocal": true, 5 | "command": { 6 | "bootstrap": { 7 | "npmClientArgs": ["--save-exact"] 8 | }, 9 | "run": { 10 | "stream": true 11 | }, 12 | "version": { 13 | "allowBranch": ["main", "1.x"], 14 | "conventionalCommits": true, 15 | "exact": true 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "rx-effects-root", 4 | "version": "0.0.1", 5 | "license": "MIT", 6 | "author": "Mikhail Nasyrov (https://github.com/mnasyrov)", 7 | "homepage": "https://github.com/mnasyrov/rx-effects", 8 | "bugs": { 9 | "url": "https://github.com/mnasyrov/rx-effects/issues" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/mnasyrov/rx-effects.git" 14 | }, 15 | "type": "module", 16 | "scripts": { 17 | "postinstall": "lerna bootstrap", 18 | "clean": "rm -rf dist && lerna run clean", 19 | "lint": "npm run lint:eslint && npm run lint:tsc", 20 | "lint:eslint": "eslint \"packages/*/{src,test*}/**\"", 21 | "lint:tsc": "tsc --noEmit --jsx react", 22 | "test": "jest", 23 | "build": "lerna run build", 24 | "docs": "node scripts/docs.js", 25 | "preversion": "npm run build && npm run lint && npm run test && npm run docs && git add --all", 26 | "prepare": "husky install", 27 | "prepublishOnly": "npm run clean && npm run build", 28 | "lerna-version": "lerna version", 29 | "lerna-publish": "lerna publish from-git", 30 | "pack": "npm run build && mkdir -p dist && npx lerna exec 'npm pack --pack-destination ../../dist'" 31 | }, 32 | "devDependencies": { 33 | "@types/jest": "29.2.4", 34 | "@types/shelljs": "0.8.11", 35 | "@typescript-eslint/eslint-plugin": "5.46.0", 36 | "@typescript-eslint/parser": "5.46.0", 37 | "ditox": "2.2.0", 38 | "ditox-react": "2.2.0", 39 | "eslint": "8.29.0", 40 | "eslint-config-prettier": "8.5.0", 41 | "eslint-plugin-eslint-comments": "3.2.0", 42 | "eslint-plugin-import": "2.26.0", 43 | "eslint-plugin-jest": "27.1.6", 44 | "eslint-plugin-react-hooks": "4.6.0", 45 | "fast-glob": "3.2.12", 46 | "husky": "8.0.2", 47 | "jest": "29.3.1", 48 | "lerna": "6.1.0", 49 | "lint-staged": "13.1.0", 50 | "markdown-toc": "1.2.0", 51 | "prettier": "2.8.1", 52 | "rxjs": "7.6.0", 53 | "shelljs": "0.8.5", 54 | "ts-jest": "29.0.3", 55 | "typedoc": "0.23.21", 56 | "typedoc-plugin-markdown": "3.14.0", 57 | "typescript": "4.9.4" 58 | }, 59 | "attributions": [ 60 | { 61 | "rocket.svg": [ 62 | "Creative Tail, CC BY 4.0 , via Wikimedia Commons", 63 | "https://commons.wikimedia.org/wiki/File:Creative-Tail-rocket.svg" 64 | ] 65 | } 66 | ] 67 | } 68 | -------------------------------------------------------------------------------- /packages/examples/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | ## [1.1.2](https://github.com/mnasyrov/rx-effects/compare/v1.1.1...v1.1.2) (2024-07-12) 7 | 8 | **Note:** Version bump only for package rx-effects-examples 9 | 10 | ## [1.1.1](https://github.com/mnasyrov/rx-effects/compare/v1.1.0...v1.1.1) (2023-08-05) 11 | 12 | **Note:** Version bump only for package rx-effects-examples 13 | 14 | # [1.1.0](https://github.com/mnasyrov/rx-effects/compare/v1.0.1...v1.1.0) (2023-02-01) 15 | 16 | **Note:** Version bump only for package rx-effects-examples 17 | 18 | ## [1.0.1](https://github.com/mnasyrov/rx-effects/compare/v1.0.0...v1.0.1) (2023-01-23) 19 | 20 | **Note:** Version bump only for package rx-effects-examples 21 | 22 | # [1.0.0](https://github.com/mnasyrov/rx-effects/compare/v0.7.2...v1.0.0) (2022-12-20) 23 | 24 | ### Features 25 | 26 | - Updated API for the library. Introduced tooling for ViewControllers with Ditox.js DI container. ([7cffcd0](https://github.com/mnasyrov/rx-effects/commit/7cffcd03f915337fa27e3b55f30fd1ad0c45a087)) 27 | 28 | ## [0.7.2](https://github.com/mnasyrov/rx-effects/compare/v0.7.1...v0.7.2) (2022-10-29) 29 | 30 | **Note:** Version bump only for package rx-effects-examples 31 | 32 | ## [0.7.1](https://github.com/mnasyrov/rx-effects/compare/v0.7.0...v0.7.1) (2022-10-26) 33 | 34 | **Note:** Version bump only for package rx-effects-examples 35 | 36 | # [0.7.0](https://github.com/mnasyrov/rx-effects/compare/v0.6.0...v0.7.0) (2022-10-26) 37 | 38 | **Note:** Version bump only for package rx-effects-examples 39 | 40 | # [0.6.0](https://github.com/mnasyrov/rx-effects/compare/v0.5.2...v0.6.0) (2022-08-28) 41 | 42 | ### Features 43 | 44 | - Refactored Query API ([0ba6d12](https://github.com/mnasyrov/rx-effects/commit/0ba6d12df5f99cf98f04f130a89be814c90180f8)) 45 | 46 | ## [0.5.2](https://github.com/mnasyrov/rx-effects/compare/v0.5.1...v0.5.2) (2022-01-26) 47 | 48 | **Note:** Version bump only for package rx-effects-examples 49 | 50 | ## [0.5.1](https://github.com/mnasyrov/rx-effects/compare/v0.5.0...v0.5.1) (2022-01-11) 51 | 52 | **Note:** Version bump only for package rx-effects-examples 53 | 54 | # [0.5.0](https://github.com/mnasyrov/rx-effects/compare/v0.4.1...v0.5.0) (2022-01-11) 55 | 56 | **Note:** Version bump only for package rx-effects-examples 57 | 58 | ## [0.4.1](https://github.com/mnasyrov/rx-effects/compare/v0.4.0...v0.4.1) (2021-11-10) 59 | 60 | **Note:** Version bump only for package rx-effects-examples 61 | 62 | # [0.4.0](https://github.com/mnasyrov/rx-effects/compare/v0.3.3...v0.4.0) (2021-09-30) 63 | 64 | **Note:** Version bump only for package rx-effects-examples 65 | 66 | ## [0.3.3](https://github.com/mnasyrov/rx-effects/compare/v0.3.2...v0.3.3) (2021-09-27) 67 | 68 | **Note:** Version bump only for package rx-effects-examples 69 | 70 | ## [0.3.2](https://github.com/mnasyrov/rx-effects/compare/v0.3.1...v0.3.2) (2021-09-14) 71 | 72 | **Note:** Version bump only for package rx-effects-examples 73 | 74 | ## [0.3.1](https://github.com/mnasyrov/rx-effects/compare/v0.3.0...v0.3.1) (2021-09-07) 75 | 76 | **Note:** Version bump only for package rx-effects-examples 77 | 78 | # [0.3.0](https://github.com/mnasyrov/rx-effects/compare/v0.2.2...v0.3.0) (2021-09-07) 79 | 80 | **Note:** Version bump only for package rx-effects-examples 81 | 82 | ## [0.2.2](https://github.com/mnasyrov/rx-effects/compare/v0.2.1...v0.2.2) (2021-09-02) 83 | 84 | **Note:** Version bump only for package rx-effects-examples 85 | 86 | ## [0.2.1](https://github.com/mnasyrov/rx-effects/compare/v0.2.0...v0.2.1) (2021-08-15) 87 | 88 | **Note:** Version bump only for package rx-effects-examples 89 | 90 | # [0.2.0](https://github.com/mnasyrov/rx-effects/compare/v0.1.0...v0.2.0) (2021-08-09) 91 | 92 | ### Features 93 | 94 | - Renamed `EffectScope` to `Scope`. Extended `Scope` and `declareState()`. ([21d97be](https://github.com/mnasyrov/rx-effects/commit/21d97be080897f33f674d461397e8f1e86ac8eef)) 95 | 96 | # [0.1.0](https://github.com/mnasyrov/rx-effects/compare/v0.0.8...v0.1.0) (2021-08-03) 97 | 98 | ### Features 99 | 100 | - Introduced 'destroy()' method to Store to complete it. ([199cbb7](https://github.com/mnasyrov/rx-effects/commit/199cbb70ab2163f9f8edc8045b988afd2604595b)) 101 | -------------------------------------------------------------------------------- /packages/examples/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Mikhail Nasyrov 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 | -------------------------------------------------------------------------------- /packages/examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "rx-effects-examples", 4 | "version": "1.1.2", 5 | "type": "module", 6 | "sideEffects": false, 7 | "dependencies": { 8 | "rx-effects": "1.1.2", 9 | "rx-effects-react": "1.1.2" 10 | }, 11 | "peerDependencies": { 12 | "react": ">=17.0.0", 13 | "rxjs": ">=7.0.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/examples/pizzaShop.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | createAction, 4 | createScope, 5 | declareStateUpdates, 6 | EffectState, 7 | Query, 8 | withStoreUpdates, 9 | } from 'rx-effects'; 10 | import { delay, filter, map, mapTo, of } from 'rxjs'; 11 | 12 | // The state 13 | type CartState = Readonly<{ orders: Array }>; 14 | 15 | // Declare the initial state. 16 | const CART_STATE: CartState = { orders: [] }; 17 | 18 | // Declare updates of the state. 19 | const CART_STATE_UPDATES = declareStateUpdates(CART_STATE, { 20 | addPizzaToCart: (name: string) => (state) => ({ 21 | ...state, 22 | orders: [...state.orders, name], 23 | }), 24 | 25 | removePizzaFromCart: (name: string) => (state) => ({ 26 | ...state, 27 | orders: state.orders.filter((order) => order !== name), 28 | }), 29 | }); 30 | 31 | // Declaring the controller. 32 | // It should provide methods for triggering the actions, 33 | // and queries or observables for subscribing to data. 34 | export type PizzaShopController = Controller<{ 35 | ordersQuery: Query>; 36 | 37 | addPizza: (name: string) => void; 38 | removePizza: (name: string) => void; 39 | submitCart: () => void; 40 | submitState: EffectState>; 41 | }>; 42 | 43 | export function createPizzaShopController(): PizzaShopController { 44 | // Creates the scope to track subscriptions 45 | const scope = createScope(); 46 | 47 | // Creates the state store 48 | const store = withStoreUpdates( 49 | scope.createStore(CART_STATE), 50 | CART_STATE_UPDATES, 51 | ); 52 | 53 | // Creates queries for the state data 54 | const ordersQuery = store.query((state) => state.orders); 55 | 56 | // Introduces actions 57 | const addPizza = createAction(); 58 | const removePizza = createAction(); 59 | const submitCart = createAction(); 60 | 61 | // Handle simple actions 62 | scope.handle(addPizza, (order) => store.updates.addPizzaToCart(order)); 63 | 64 | scope.handle(removePizza, (name) => store.updates.removePizzaFromCart(name)); 65 | 66 | // Create a effect in a general way 67 | const submitEffect = scope.createEffect>((orders) => { 68 | // Sending an async request to a server 69 | return of(orders).pipe(delay(1000), mapTo(undefined)); 70 | }); 71 | 72 | // Effect can handle `Observable` and `Action`. It allows to filter action events 73 | // and transform data which is passed to effect's handler. 74 | submitEffect.handle( 75 | submitCart.event$.pipe( 76 | map(() => ordersQuery.get()), 77 | filter((orders) => !submitEffect.pending.get() && orders.length > 0), 78 | ), 79 | ); 80 | 81 | // Effect's results can be used as actions 82 | scope.handle(submitEffect.done$, () => store.set(CART_STATE)); 83 | 84 | return { 85 | ordersQuery, 86 | addPizza, 87 | removePizza, 88 | submitCart, 89 | submitState: submitEffect, 90 | destroy: () => scope.destroy(), 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /packages/examples/pizzaShopComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect } from 'react'; 2 | import { useConst, useObservable, useQuery } from 'rx-effects-react'; 3 | import { createPizzaShopController } from './pizzaShop'; 4 | 5 | export const PizzaShopComponent: FC = () => { 6 | // Creates the controller and destroy it on unmounting the component 7 | const controller = useConst(() => createPizzaShopController()); 8 | useEffect(() => controller.destroy, [controller]); 9 | 10 | // The same creation can be achieved by using `useController()` helper: 11 | // const controller = useController(createPizzaShopController); 12 | 13 | // Using the controller 14 | const { ordersQuery, addPizza, removePizza, submitCart, submitState } = 15 | controller; 16 | 17 | // Subscribing to state data and the effect stata 18 | const orders = useQuery(ordersQuery); 19 | const isPending = useQuery(submitState.pending); 20 | const submitError = useObservable(submitState.error$, undefined); 21 | 22 | return ( 23 | <> 24 |

Pizza Shop

25 | 26 |

Menu

27 |
    28 |
  • 29 | Pepperoni 30 | 33 |
  • 34 | 35 |
  • 36 | Margherita 37 | 40 |
  • 41 |
42 | 43 |

Cart

44 |
    45 | {orders.map((name) => ( 46 |
  • 47 | {name} 48 | 51 |
  • 52 | ))} 53 |
54 | 55 | 58 | 59 | {submitError &&
Failed to submit the cart
} 60 | 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /packages/examples/src/calculator.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Action, 3 | Controller, 4 | createAction, 5 | createScope, 6 | createStore, 7 | Effect, 8 | Scope, 9 | StateMutation, 10 | Store, 11 | } from 'rx-effects'; 12 | import { firstValueFrom } from 'rxjs'; 13 | import { take, toArray } from 'rxjs/operators'; 14 | 15 | // Example usage of RxEffects: a calculator which has actions: increment, 16 | // decrement, add, subtract and reset. 17 | 18 | type CalculatorState = Readonly<{ value: number }>; 19 | type CalculatorStateMutation = StateMutation; 20 | type CalculatorStore = Store; 21 | 22 | const CALCULATOR_STATE: CalculatorState = { value: 0 }; 23 | 24 | const addValue: (value: number) => CalculatorStateMutation = 25 | (value) => (state) => ({ ...state, value: state.value + value }); 26 | 27 | function createCalculatorEffects( 28 | scope: Scope, 29 | store: CalculatorStore, 30 | ): { 31 | incrementEffect: Effect; 32 | decrementEffect: Effect; 33 | sumEffect: Effect; 34 | subtractEffect: Effect; 35 | resetEffect: Effect; 36 | } { 37 | const incrementEffect = scope.createEffect(() => store.update(addValue(1))); 38 | const decrementEffect = scope.createEffect(() => store.update(addValue(-1))); 39 | 40 | const sumEffect = scope.createEffect((value: number) => 41 | store.update(addValue(value)), 42 | ); 43 | 44 | const subtractEffect = scope.createEffect((value: number) => { 45 | store.update(addValue(-value)); 46 | return -value; 47 | }); 48 | 49 | const resetEffect = scope.createEffect(() => store.set(CALCULATOR_STATE)); 50 | 51 | return { 52 | incrementEffect, 53 | decrementEffect, 54 | sumEffect, 55 | subtractEffect, 56 | resetEffect, 57 | }; 58 | } 59 | 60 | // Example of the controller and the event bus 61 | 62 | type ControllerEvents = 63 | | { type: 'added'; value: number } 64 | | { type: 'subtracted'; value: number }; 65 | 66 | type CalculatorController = Controller<{ 67 | increment: () => void; 68 | decrement: () => void; 69 | sum: (value: number) => void; 70 | subtract: (value: number) => void; 71 | reset: () => void; 72 | }>; 73 | 74 | function createCalculatorController( 75 | store: CalculatorStore, 76 | eventBus: Action, 77 | ): CalculatorController { 78 | const scope = createScope(); 79 | 80 | const incrementAction = createAction(); 81 | const decrementAction = createAction(); 82 | const sumAction = createAction(); 83 | const subtractAction = createAction(); 84 | const resetAction = createAction(); 85 | 86 | const { 87 | incrementEffect, 88 | decrementEffect, 89 | sumEffect, 90 | subtractEffect, 91 | resetEffect, 92 | } = createCalculatorEffects(scope, store); 93 | 94 | incrementEffect.handle(incrementAction); 95 | decrementEffect.handle(decrementAction); 96 | sumEffect.handle(sumAction); 97 | subtractEffect.handle(subtractAction); 98 | resetEffect.handle(resetAction); 99 | 100 | scope.subscribe(incrementEffect.done$, () => 101 | eventBus({ type: 'added', value: 1 }), 102 | ); 103 | scope.subscribe(decrementEffect.done$, () => 104 | eventBus({ type: 'subtracted', value: 1 }), 105 | ); 106 | scope.subscribe(sumEffect.done$, ({ event }) => 107 | eventBus({ type: 'added', value: event }), 108 | ); 109 | scope.subscribe(subtractEffect.done$, ({ event }) => 110 | eventBus({ type: 'subtracted', value: event }), 111 | ); 112 | 113 | return { 114 | destroy: () => scope.destroy(), 115 | 116 | increment: incrementAction, 117 | decrement: decrementAction, 118 | sum: sumAction, 119 | subtract: subtractAction, 120 | reset: resetAction, 121 | }; 122 | } 123 | 124 | describe('Example usage of RxEffects: Calculator', () => { 125 | it('should increment the value', async () => { 126 | const store = createStore(CALCULATOR_STATE); 127 | const scope = createScope(); 128 | const incrementAction = createAction(); 129 | 130 | const { incrementEffect } = createCalculatorEffects(scope, store); 131 | incrementEffect.handle(incrementAction); 132 | 133 | expect(store.get().value).toBe(0); 134 | 135 | incrementAction(); 136 | expect(store.get().value).toBe(1); 137 | }); 138 | 139 | it('should unsubscribe effects on scope.destroy()', async () => { 140 | const store = createStore({ ...CALCULATOR_STATE, value: 10 }); 141 | const scope = createScope(); 142 | const decrementAction = createAction(); 143 | 144 | const { decrementEffect } = createCalculatorEffects(scope, store); 145 | decrementEffect.handle(decrementAction); 146 | 147 | decrementAction(); 148 | expect(store.get().value).toBe(9); 149 | 150 | scope.destroy(); 151 | decrementAction(); 152 | expect(store.get().value).toBe(9); 153 | }); 154 | 155 | it('should create actions inside the controller', async () => { 156 | const store = createStore({ ...CALCULATOR_STATE, value: 0 }); 157 | const eventBus = createAction(); 158 | 159 | const controller = createCalculatorController(store, eventBus); 160 | 161 | const eventsPromise = firstValueFrom( 162 | eventBus.event$.pipe(take(4), toArray()), 163 | ); 164 | 165 | controller.increment(); 166 | expect(store.get().value).toBe(1); 167 | 168 | controller.sum(5); 169 | expect(store.get().value).toBe(6); 170 | 171 | controller.subtract(2); 172 | expect(store.get().value).toBe(4); 173 | 174 | controller.decrement(); 175 | expect(store.get().value).toBe(3); 176 | 177 | const events = await eventsPromise; 178 | expect(events).toEqual([ 179 | { 180 | type: 'added', 181 | value: 1, 182 | }, 183 | { 184 | type: 'added', 185 | value: 5, 186 | }, 187 | { 188 | type: 'subtracted', 189 | value: 2, 190 | }, 191 | { 192 | type: 'subtracted', 193 | value: 1, 194 | }, 195 | ]); 196 | }); 197 | }); 198 | -------------------------------------------------------------------------------- /packages/examples/src/counter.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { declareStateUpdates } from 'rx-effects'; 3 | import { useStore } from 'rx-effects-react'; 4 | 5 | const COUNTER_STATE = 0; 6 | 7 | const COUNTER_UPDATES = declareStateUpdates()({ 8 | decrement: () => (state) => state - 1, 9 | increment: () => (state) => state + 1, 10 | }); 11 | 12 | export const App: FC = () => { 13 | const [counter, counterUpdates] = useStore(COUNTER_STATE, COUNTER_UPDATES); 14 | 15 | return ( 16 |
17 | 18 | {counter} 19 | 20 |
21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /packages/examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["ES2019", "DOM"], 5 | "jsx": "react" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/rx-effects-react/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | ## [1.1.2](https://github.com/mnasyrov/rx-effects/compare/v1.1.1...v1.1.2) (2024-07-12) 7 | 8 | **Note:** Version bump only for package rx-effects-react 9 | 10 | ## [1.1.1](https://github.com/mnasyrov/rx-effects/compare/v1.1.0...v1.1.1) (2023-08-05) 11 | 12 | **Note:** Version bump only for package rx-effects-react 13 | 14 | # [1.1.0](https://github.com/mnasyrov/rx-effects/compare/v1.0.1...v1.1.0) (2023-02-01) 15 | 16 | ### Features 17 | 18 | - Made `ViewController` to accept queries for external parameters (breaking change) ([#17](https://github.com/mnasyrov/rx-effects/issues/17)) ([ad49f8a](https://github.com/mnasyrov/rx-effects/commit/ad49f8a70eda02a415c37de7de320582f4a91d0e)) 19 | 20 | ## [1.0.1](https://github.com/mnasyrov/rx-effects/compare/v1.0.0...v1.0.1) (2023-01-23) 21 | 22 | ### Bug Fixes 23 | 24 | - Fixed rerendering by `useObserver()` and reduced excess unsubscribe/subscribe on rerendering a parent component ([#13](https://github.com/mnasyrov/rx-effects/issues/13)) ([469b251](https://github.com/mnasyrov/rx-effects/commit/469b251797980b6280eb98d097e1b24747675879)) 25 | 26 | # [1.0.0](https://github.com/mnasyrov/rx-effects/compare/v0.7.2...v1.0.0) (2022-12-20) 27 | 28 | ### Features 29 | 30 | - Updated API for the library. Introduced tooling for ViewControllers with Ditox.js DI container. ([7cffcd0](https://github.com/mnasyrov/rx-effects/commit/7cffcd03f915337fa27e3b55f30fd1ad0c45a087)) 31 | 32 | ## [0.7.2](https://github.com/mnasyrov/rx-effects/compare/v0.7.1...v0.7.2) (2022-10-29) 33 | 34 | ### Features 35 | 36 | - Introduced `scope.onDestroy()` and `scope.subscribe()`. Added info about API deprecation. ([#9](https://github.com/mnasyrov/rx-effects/issues/9)) ([4467782](https://github.com/mnasyrov/rx-effects/commit/44677829f889aa4fbca12fb467f149cd0fab6869)) 37 | 38 | ## [0.7.1](https://github.com/mnasyrov/rx-effects/compare/v0.7.0...v0.7.1) (2022-10-26) 39 | 40 | **Note:** Version bump only for package rx-effects-react 41 | 42 | # [0.7.0](https://github.com/mnasyrov/rx-effects/compare/v0.6.0...v0.7.0) (2022-10-26) 43 | 44 | **Note:** Version bump only for package rx-effects-react 45 | 46 | # [0.6.0](https://github.com/mnasyrov/rx-effects/compare/v0.5.2...v0.6.0) (2022-08-28) 47 | 48 | ### Features 49 | 50 | - Refactored Query API ([0ba6d12](https://github.com/mnasyrov/rx-effects/commit/0ba6d12df5f99cf98f04f130a89be814c90180f8)) 51 | 52 | ## [0.5.2](https://github.com/mnasyrov/rx-effects/compare/v0.5.1...v0.5.2) (2022-01-26) 53 | 54 | **Note:** Version bump only for package rx-effects-react 55 | 56 | ## [0.5.1](https://github.com/mnasyrov/rx-effects/compare/v0.5.0...v0.5.1) (2022-01-11) 57 | 58 | **Note:** Version bump only for package rx-effects-react 59 | 60 | # [0.5.0](https://github.com/mnasyrov/rx-effects/compare/v0.4.1...v0.5.0) (2022-01-11) 61 | 62 | ### Bug Fixes 63 | 64 | - Fixed eslint rules ([6975806](https://github.com/mnasyrov/rx-effects/commit/69758063de4d9f6b7821b439aad054087df249b9)) 65 | - **rx-effects-react:** Fixed calling `destroy()` of a class-based controller by `useController()` ([1bdf6b5](https://github.com/mnasyrov/rx-effects/commit/1bdf6b55df6f41988bf7b481ec2019c97731f127)) 66 | 67 | ## [0.4.1](https://github.com/mnasyrov/rx-effects/compare/v0.4.0...v0.4.1) (2021-11-10) 68 | 69 | **Note:** Version bump only for package rx-effects-react 70 | 71 | # [0.4.0](https://github.com/mnasyrov/rx-effects/compare/v0.3.3...v0.4.0) (2021-09-30) 72 | 73 | **Note:** Version bump only for package rx-effects-react 74 | 75 | ## [0.3.3](https://github.com/mnasyrov/rx-effects/compare/v0.3.2...v0.3.3) (2021-09-27) 76 | 77 | ### Features 78 | 79 | - Store.update() can apply an array of mutations ([d778ac9](https://github.com/mnasyrov/rx-effects/commit/d778ac99549a9ac1887ea03ab77d5f0fa6527d1f)) 80 | 81 | ## [0.3.2](https://github.com/mnasyrov/rx-effects/compare/v0.3.1...v0.3.2) (2021-09-14) 82 | 83 | ### Bug Fixes 84 | 85 | - useController() hook triggers rerenders if it is used without dependencies. ([f0b5582](https://github.com/mnasyrov/rx-effects/commit/f0b5582b7e801bd86882694d8d7dbb5456ca33bb)) 86 | 87 | ## [0.3.1](https://github.com/mnasyrov/rx-effects/compare/v0.3.0...v0.3.1) (2021-09-07) 88 | 89 | **Note:** Version bump only for package rx-effects-react 90 | 91 | # [0.3.0](https://github.com/mnasyrov/rx-effects/compare/v0.2.2...v0.3.0) (2021-09-07) 92 | 93 | ### Features 94 | 95 | - Introduced `StateQueryOptions` for query mappers. Strict equality === is used by default as value comparators. ([5cc97e0](https://github.com/mnasyrov/rx-effects/commit/5cc97e0f7ab1623ffbdc133e5bfbe63911d68b56)) 96 | 97 | ## [0.2.2](https://github.com/mnasyrov/rx-effects/compare/v0.2.1...v0.2.2) (2021-09-02) 98 | 99 | **Note:** Version bump only for package rx-effects-react 100 | 101 | ## [0.2.1](https://github.com/mnasyrov/rx-effects/compare/v0.2.0...v0.2.1) (2021-08-15) 102 | 103 | ### Bug Fixes 104 | 105 | - Added a missed export for `useController()` hook ([a5e5c92](https://github.com/mnasyrov/rx-effects/commit/a5e5c92da8a288f44c41dac2cb70c96d788eea38)) 106 | 107 | # [0.2.0](https://github.com/mnasyrov/rx-effects/compare/v0.1.0...v0.2.0) (2021-08-09) 108 | 109 | ### Features 110 | 111 | - Renamed `EffectScope` to `Scope`. Extended `Scope` and `declareState()`. ([21d97be](https://github.com/mnasyrov/rx-effects/commit/21d97be080897f33f674d461397e8f1e86ac8eef)) 112 | 113 | # [0.1.0](https://github.com/mnasyrov/rx-effects/compare/v0.0.8...v0.1.0) (2021-08-03) 114 | 115 | ### Features 116 | 117 | - Introduced `Controller`, `useController()` and `mergeQueries()` ([d84a2e2](https://github.com/mnasyrov/rx-effects/commit/d84a2e2b8d1f57ca59e9664004de844a1f8bcf1f)) 118 | 119 | ## [0.0.8](https://github.com/mnasyrov/rx-effects/compare/v0.0.7...v0.0.8) (2021-07-26) 120 | 121 | ### Bug Fixes 122 | 123 | - Dropped stateEffects for a while. Added stubs for docs. ([566ab80](https://github.com/mnasyrov/rx-effects/commit/566ab8085b6e493942bf908e3000097561a14724)) 124 | 125 | ## [0.0.7](https://github.com/mnasyrov/rx-effects/compare/v0.0.6...v0.0.7) (2021-07-23) 126 | 127 | **Note:** Version bump only for package rx-effects-react 128 | 129 | ## [0.0.6](https://github.com/mnasyrov/rx-effects/compare/v0.0.5...v0.0.6) (2021-07-12) 130 | 131 | **Note:** Version bump only for package rx-effects-react 132 | 133 | ## [0.0.5](https://github.com/mnasyrov/rx-effects/compare/v0.0.4...v0.0.5) (2021-07-11) 134 | 135 | **Note:** Version bump only for package @rx-effects/react 136 | 137 | ## [0.0.4](https://github.com/mnasyrov/rx-effects/compare/v0.0.3...v0.0.4) (2021-07-11) 138 | 139 | **Note:** Version bump only for package @rx-effects/react 140 | 141 | ## [0.0.3](https://github.com/mnasyrov/rx-effects/compare/v0.0.2...v0.0.3) (2021-07-11) 142 | 143 | **Note:** Version bump only for package @rx-effects/react 144 | 145 | ## 0.0.2 (2021-07-11) 146 | 147 | **Note:** Version bump only for package @rx-effects/react 148 | -------------------------------------------------------------------------------- /packages/rx-effects-react/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Mikhail Nasyrov 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 | -------------------------------------------------------------------------------- /packages/rx-effects-react/README.md: -------------------------------------------------------------------------------- 1 | # RxEffects: rx-effects-react 2 | 3 | rocket 4 | 5 | Reactive state and effect management with RxJS. Tooling for React.js. 6 | 7 | [![npm](https://img.shields.io/npm/v/rx-effects-react.svg)](https://www.npmjs.com/package/rx-effects-react) 8 | [![downloads](https://img.shields.io/npm/dt/rx-effects-react.svg)](https://www.npmjs.com/package/rx-effects-react) 9 | [![types](https://img.shields.io/npm/types/rx-effects-react.svg)](https://www.npmjs.com/package/rx-effects-react) 10 | [![licence](https://img.shields.io/github/license/mnasyrov/rx-effects.svg)](https://github.com/mnasyrov/rx-effects/blob/master/LICENSE) 11 | [![Coverage Status](https://coveralls.io/repos/github/mnasyrov/rx-effects/badge.svg?branch=main)](https://coveralls.io/github/mnasyrov/rx-effects?branch=main) 12 | 13 | ## Documentation 14 | 15 | - [Main docs](https://github.com/mnasyrov/rx-effects#readme) 16 | - [API docs](docs/README.md) 17 | 18 | ## Installation 19 | 20 | ``` 21 | npm install rx-effects rx-effects-react --save 22 | ``` 23 | 24 | ## Usage 25 | 26 | The package provides utility hooks to bind the core [RxEffects][rx-effects/docs] 27 | to React components and hooks: 28 | 29 | - [`useConst`](docs/README.md#useconst) – keeps the value as a constant between renders. 30 | - [`useController`](docs/README.md#usecontroller) – creates an ad-hoc controller by the factory and destroys it on unmounting. 31 | - [`useObservable`](docs/README.md#useobservable) – returns a value provided by `source$` observable. 32 | - [`useObserver`](docs/README.md#useobserver) – subscribes the provided observer or `next` handler on `source$` observable. 33 | - [`useSelector`](docs/README.md#useselector) – returns a value provided by `source$` observable. 34 | - [`useQuery`](docs/README.md#usequery) – returns a value which is provided by the query. 35 | 36 | Example: 37 | 38 | ```tsx 39 | // pizzaShopComponent.tsx 40 | 41 | import React, { FC, useEffect } from 'react'; 42 | import { useConst, useObservable, useQuery } from 'rx-effects-react'; 43 | import { createPizzaShopController } from './pizzaShop'; 44 | 45 | export const PizzaShopComponent: FC = () => { 46 | // Creates the controller and destroy it on unmounting the component 47 | const controller = useConst(() => createPizzaShopController()); 48 | useEffect(() => controller.destroy, [controller]); 49 | 50 | // The same creation can be achieved by using `useController()` helper: 51 | // const controller = useController(createPizzaShopController); 52 | 53 | // Using the controller 54 | const { ordersQuery, addPizza, removePizza, submitCart, submitState } = 55 | controller; 56 | 57 | // Subscribing to state data and the effect stata 58 | const orders = useQuery(ordersQuery); 59 | const isPending = useQuery(submitState.pending); 60 | const submitError = useObservable(submitState.error$, undefined); 61 | 62 | // Actual rendering should be here. 63 | return null; 64 | }; 65 | ``` 66 | 67 | --- 68 | 69 | [rx-effects/docs]: https://github.com/mnasyrov/rx-effects/blob/main/packages/rx-effects/README.md 70 | 71 | © 2021 [Mikhail Nasyrov](https://github.com/mnasyrov), [MIT license](./LICENSE) 72 | -------------------------------------------------------------------------------- /packages/rx-effects-react/docs/.nojekyll: -------------------------------------------------------------------------------- 1 | TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. -------------------------------------------------------------------------------- /packages/rx-effects-react/index.ts: -------------------------------------------------------------------------------- 1 | export * from './src'; 2 | -------------------------------------------------------------------------------- /packages/rx-effects-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rx-effects-react", 3 | "version": "1.1.2", 4 | "description": "Reactive state and effects management. Tooling for React.js", 5 | "license": "MIT", 6 | "author": "Mikhail Nasyrov (https://github.com/mnasyrov)", 7 | "homepage": "https://github.com/mnasyrov/rx-effects", 8 | "bugs": { 9 | "url": "https://github.com/mnasyrov/rx-effects/issues" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/mnasyrov/rx-effects.git" 14 | }, 15 | "keywords": [ 16 | "react", 17 | "state", 18 | "effect", 19 | "management", 20 | "state-management", 21 | "reactive", 22 | "rxjs", 23 | "rx", 24 | "effector" 25 | ], 26 | "engines": { 27 | "node": ">=12" 28 | }, 29 | "main": "dist/cjs/index.js", 30 | "module": "dist/esm/index.js", 31 | "types": "dist/esm/index.d.ts", 32 | "sideEffects": false, 33 | "files": [ 34 | "dist", 35 | "docs", 36 | "src", 37 | "index.ts", 38 | "LICENSE", 39 | "README.md" 40 | ], 41 | "scripts": { 42 | "clean": "rm -rf dist", 43 | "build": "npm run build:cjs && npm run build:esm", 44 | "build:cjs": "tsc -p tsconfig.build.json --outDir dist/cjs --module commonjs", 45 | "build:esm": "tsc -p tsconfig.build.json --outDir dist/esm --module es2015" 46 | }, 47 | "dependencies": { 48 | "rx-effects": "1.1.2" 49 | }, 50 | "peerDependencies": { 51 | "ditox-react": ">=2.2 || >=3", 52 | "react": ">=17.0.0", 53 | "rxjs": ">=7.0.0" 54 | }, 55 | "devDependencies": { 56 | "@testing-library/react": "12.1.2", 57 | "@testing-library/react-hooks": "7.0.2", 58 | "@types/react": "17.0.38", 59 | "react": "17.0.2", 60 | "react-dom": "17.0.2", 61 | "react-test-renderer": "17.0.2" 62 | }, 63 | "gitHead": "666d5b8672ebe6cb02174366a799d1da27d387a9" 64 | } 65 | -------------------------------------------------------------------------------- /packages/rx-effects-react/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useConst'; 2 | export * from './useController'; 3 | export * from './useObservable'; 4 | export { useObserver } from './useObserver'; 5 | export * from './useSelector'; 6 | export * from './useQuery'; 7 | export * from './useStore'; 8 | export * from './mvc'; 9 | -------------------------------------------------------------------------------- /packages/rx-effects-react/src/mvc.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react-hooks'; 2 | import { Container, token } from 'ditox'; 3 | import { DependencyContainer, useDependency } from 'ditox-react'; 4 | import React from 'react'; 5 | import { 6 | declareController, 7 | declareViewController, 8 | InferredService, 9 | Query, 10 | } from 'rx-effects'; 11 | import { 12 | createControllerContainer, 13 | useInjectableController, 14 | useViewController, 15 | } from './mvc'; 16 | 17 | describe('useInjectableController()', () => { 18 | it('should fail in case there is no a dependency container in the render tree', () => { 19 | const viewController = declareController({}, () => ({})); 20 | 21 | const { result } = renderHook(() => 22 | useInjectableController(viewController), 23 | ); 24 | 25 | expect(result.error).toEqual( 26 | new Error('Container is not provided by DependencyContainer component'), 27 | ); 28 | }); 29 | 30 | it('should not fail in case there is a dependency container in the render tree', () => { 31 | const viewController = declareController({}, () => ({})); 32 | 33 | const { result } = renderHook( 34 | () => useInjectableController(viewController), 35 | { 36 | wrapper: ({ children }) => ( 37 | {children} 38 | ), 39 | }, 40 | ); 41 | 42 | expect(result.error).toBeUndefined(); 43 | }); 44 | 45 | it('should injects dependencies from a container in the render tree', () => { 46 | const VALUE_TOKEN = token(); 47 | const valueBinder = (container: Container) => { 48 | container.bindValue(VALUE_TOKEN, 1); 49 | }; 50 | 51 | const onDestroy = jest.fn(); 52 | 53 | const viewController = declareController( 54 | { value: VALUE_TOKEN }, 55 | ({ value }) => ({ 56 | getValue: () => value * 10, 57 | destroy: () => onDestroy(), 58 | }), 59 | ); 60 | 61 | const { result, unmount } = renderHook( 62 | () => useInjectableController(viewController), 63 | { 64 | wrapper: ({ children }) => ( 65 | 66 | {children} 67 | 68 | ), 69 | }, 70 | ); 71 | 72 | expect(result.current.getValue()).toBe(10); 73 | expect(onDestroy).toHaveBeenCalledTimes(0); 74 | 75 | unmount(); 76 | expect(onDestroy).toHaveBeenCalledTimes(1); 77 | }); 78 | }); 79 | describe('useViewController()', () => { 80 | it('should fail in case there is no a dependency container in the render tree', () => { 81 | const viewController = declareViewController(() => ({})); 82 | 83 | const { result } = renderHook(() => useViewController(viewController)); 84 | 85 | expect(result.error).toEqual( 86 | new Error('Container is not provided by DependencyContainer component'), 87 | ); 88 | }); 89 | 90 | it('should not fail in case there is a dependency container in the render tree', () => { 91 | const viewController = declareViewController(() => ({})); 92 | 93 | const { result } = renderHook(() => useViewController(viewController), { 94 | wrapper: ({ children }) => ( 95 | {children} 96 | ), 97 | }); 98 | 99 | expect(result.error).toBeUndefined(); 100 | }); 101 | 102 | it('should injects dependencies from a container in the render tree', () => { 103 | const VALUE_TOKEN = token(); 104 | const valueBinder = (container: Container) => { 105 | container.bindValue(VALUE_TOKEN, 1); 106 | }; 107 | 108 | const onDestroy = jest.fn(); 109 | 110 | const viewController = declareViewController( 111 | { value: VALUE_TOKEN }, 112 | ({ value }) => ({ 113 | getValue: () => value * 10, 114 | destroy: () => onDestroy(), 115 | }), 116 | ); 117 | 118 | const { result, unmount } = renderHook( 119 | () => useViewController(viewController), 120 | { 121 | wrapper: ({ children }) => ( 122 | 123 | {children} 124 | 125 | ), 126 | }, 127 | ); 128 | 129 | expect(result.current.getValue()).toBe(10); 130 | expect(onDestroy).toHaveBeenCalledTimes(0); 131 | 132 | unmount(); 133 | expect(onDestroy).toHaveBeenCalledTimes(1); 134 | }); 135 | 136 | it('should create a view controller and pass parameters to it without recreation', () => { 137 | const VALUE_TOKEN = token(); 138 | const valueBinder = (container: Container) => { 139 | container.bindValue(VALUE_TOKEN, 1); 140 | }; 141 | 142 | const onDestroy = jest.fn(); 143 | 144 | const viewController = declareViewController( 145 | { value: VALUE_TOKEN }, 146 | ({ value }) => 147 | (scope, param: Query) => ({ 148 | getValue: () => value * 10 + param.get(), 149 | destroy: () => onDestroy(), 150 | }), 151 | ); 152 | 153 | const { result, rerender, unmount } = renderHook( 154 | (param: number) => useViewController(viewController, param), 155 | { 156 | initialProps: 2, 157 | wrapper: ({ children }) => ( 158 | 159 | {children} 160 | 161 | ), 162 | }, 163 | ); 164 | 165 | expect(result.current.getValue()).toBe(12); 166 | expect(onDestroy).toHaveBeenCalledTimes(0); 167 | 168 | rerender(4); 169 | expect(result.current.getValue()).toBe(14); 170 | expect(onDestroy).toHaveBeenCalledTimes(0); 171 | 172 | unmount(); 173 | expect(onDestroy).toHaveBeenCalledTimes(1); 174 | }); 175 | }); 176 | 177 | describe('createControllerContainer()', () => { 178 | it('should create a Functional Component which add a provided controller to DI tree', () => { 179 | const VALUE_TOKEN = token(); 180 | const valueBinder = (container: Container) => { 181 | container.bindValue(VALUE_TOKEN, 1); 182 | }; 183 | 184 | const controllerFactory = declareController( 185 | { value: VALUE_TOKEN }, 186 | ({ value }) => { 187 | return { 188 | getValue: () => value, 189 | }; 190 | }, 191 | ); 192 | 193 | const serviceToken = token>(); 194 | const ControllerContainer = createControllerContainer( 195 | serviceToken, 196 | controllerFactory, 197 | ); 198 | 199 | const { result } = renderHook(() => useDependency(serviceToken), { 200 | wrapper: ({ children }) => ( 201 | 202 | {children} 203 | 204 | ), 205 | }); 206 | 207 | expect(result.current.getValue()).toBe(1); 208 | }); 209 | }); 210 | -------------------------------------------------------------------------------- /packages/rx-effects-react/src/mvc.tsx: -------------------------------------------------------------------------------- 1 | import { declareModule, Token } from 'ditox'; 2 | import { DependencyModule, useDependencyContainer } from 'ditox-react'; 3 | import React, { FC, useEffect, useMemo, useRef } from 'react'; 4 | import { 5 | Controller, 6 | ControllerFactory, 7 | createStore, 8 | Query, 9 | ViewControllerFactory, 10 | } from 'rx-effects'; 11 | import { Store } from 'rx-effects/src/index'; 12 | 13 | type AnyObject = Record; 14 | 15 | export function useInjectableController>( 16 | factory: ControllerFactory, 17 | ): Controller { 18 | const container = useDependencyContainer('strict'); 19 | const controller = useMemo(() => factory(container), [container, factory]); 20 | 21 | useEffect(() => () => controller.destroy(), [controller]); 22 | 23 | return controller; 24 | } 25 | 26 | export function useViewController< 27 | Result extends Record, 28 | Params extends unknown[], 29 | QueryParams extends { 30 | [K in keyof Params]: Params[K] extends infer V ? Query : never; 31 | }, 32 | >( 33 | factory: ViewControllerFactory, 34 | ...params: Params 35 | ): Controller { 36 | const container = useDependencyContainer('strict'); 37 | 38 | const storesRef = useRef[]>(); 39 | 40 | const controller = useMemo(() => { 41 | if (!storesRef.current) { 42 | storesRef.current = createStoresForParams(params); 43 | } 44 | 45 | return factory(container, ...(storesRef.current as unknown as QueryParams)); 46 | 47 | // eslint-disable-next-line react-hooks/exhaustive-deps 48 | }, [container, factory]); 49 | 50 | useEffect(() => { 51 | const stores = storesRef.current; 52 | if (stores) { 53 | params.forEach((value, index) => stores[index].set(value)); 54 | } 55 | // eslint-disable-next-line react-hooks/exhaustive-deps 56 | }, params); 57 | 58 | useEffect(() => () => controller.destroy(), [controller]); 59 | 60 | return controller; 61 | } 62 | 63 | function createStoresForParams(params: any[]): Store[] { 64 | return params.length === 0 65 | ? [] 66 | : new Array(params.length) 67 | .fill(undefined) 68 | .map((_, index) => createStore(params[index])); 69 | } 70 | 71 | export function createControllerContainer( 72 | token: Token, 73 | factory: ControllerFactory, 74 | ): FC { 75 | const module = declareModule({ factory, token }); 76 | 77 | return ({ children }) => ( 78 | {children} 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /packages/rx-effects-react/src/test/testUtils.ts: -------------------------------------------------------------------------------- 1 | import { defer, MonoTypeOperatorFunction, noop } from 'rxjs'; 2 | import { finalize } from 'rxjs/operators'; 3 | 4 | export function monitorSubscriptionCount( 5 | onCountUpdate: (count: number) => void = noop, 6 | ): MonoTypeOperatorFunction { 7 | return (source$) => { 8 | let counter = 0; 9 | 10 | return defer(() => { 11 | counter += 1; 12 | onCountUpdate(counter); 13 | return source$; 14 | }).pipe( 15 | finalize(() => { 16 | counter -= 1; 17 | onCountUpdate(counter); 18 | }), 19 | ); 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /packages/rx-effects-react/src/useConst.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react-hooks'; 2 | 3 | import { useConst } from './useConst'; 4 | 5 | describe('useConst()', () => { 6 | it('should return preserve the first value between rerenders', () => { 7 | const { result, rerender } = renderHook(({ value }) => useConst(value), { 8 | initialProps: { value: 1 }, 9 | }); 10 | expect(result.current).toBe(1); 11 | 12 | rerender({ value: 2 }); 13 | expect(result.current).toBe(1); 14 | }); 15 | 16 | it('should call the factory only once', () => { 17 | const factory1 = jest.fn(() => 1); 18 | const factory2 = jest.fn(() => 2); 19 | 20 | const { result, rerender } = renderHook( 21 | ({ factory }) => useConst(factory), 22 | { 23 | initialProps: { factory: factory1 }, 24 | }, 25 | ); 26 | expect(result.current).toBe(1); 27 | 28 | rerender({ factory: factory2 }); 29 | expect(result.current).toBe(1); 30 | 31 | expect(factory1).toHaveBeenCalledTimes(1); 32 | expect(factory2).toHaveBeenCalledTimes(0); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /packages/rx-effects-react/src/useConst.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | 3 | import { useRef } from 'react'; 4 | 5 | /** 6 | * Keeps the value as a constant between renders of a component. 7 | * 8 | * If the factory is provided, it is called only once. 9 | * 10 | * @param initialValue a value or a factory for the value 11 | */ 12 | export function useConst(initialValue: (() => T) | T): T { 13 | const constRef = useRef<{ value: T } | void>(); 14 | 15 | if (constRef.current === undefined) { 16 | constRef.current = { 17 | value: 18 | typeof initialValue === 'function' 19 | ? (initialValue as Function)() 20 | : initialValue, 21 | }; 22 | } 23 | 24 | return constRef.current.value; 25 | } 26 | -------------------------------------------------------------------------------- /packages/rx-effects-react/src/useController.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react-hooks'; 2 | import { Controller } from 'rx-effects'; 3 | import { useController } from './useController'; 4 | 5 | describe('useController()', () => { 6 | it('should create a controller by the factory and destroy it on unmount', () => { 7 | const action = jest.fn(); 8 | const destroy = jest.fn(); 9 | 10 | function createController(): Controller<{ 11 | value: number; 12 | action: () => void; 13 | }> { 14 | return { value: 1, action, destroy }; 15 | } 16 | 17 | const { result, unmount } = renderHook(() => 18 | useController(createController), 19 | ); 20 | 21 | expect(result.current.value).toBe(1); 22 | 23 | result.current.action(); 24 | expect(action).toHaveBeenCalledTimes(1); 25 | 26 | unmount(); 27 | expect(destroy).toHaveBeenCalledTimes(1); 28 | }); 29 | 30 | it('should not recreate the controller with empty dependencies after rerendering', () => { 31 | const destroy = jest.fn(); 32 | 33 | const createController = () => ({ destroy }); 34 | 35 | const { result, rerender, unmount } = renderHook(() => 36 | useController(createController), 37 | ); 38 | 39 | const controller1 = result.current; 40 | rerender(); 41 | const controller2 = result.current; 42 | 43 | expect(controller1 === controller2).toBe(true); 44 | 45 | unmount(); 46 | expect(destroy).toHaveBeenCalledTimes(1); 47 | }); 48 | 49 | it('should recreate the controller if a dependency is changed', () => { 50 | const destroy = jest.fn(); 51 | 52 | const createController = (value: number) => ({ value, destroy }); 53 | 54 | const { result, rerender, unmount } = renderHook( 55 | ({ value }) => useController(() => createController(value), [value]), 56 | { initialProps: { value: 1 } }, 57 | ); 58 | 59 | const controller1 = result.current; 60 | expect(controller1.value).toBe(1); 61 | 62 | rerender({ value: 1 }); 63 | const controller2 = result.current; 64 | expect(controller2).toBe(controller1); 65 | expect(controller2.value).toBe(1); 66 | 67 | rerender({ value: 2 }); 68 | const controller3 = result.current; 69 | expect(controller3).not.toBe(controller2); 70 | expect(controller3.value).toBe(2); 71 | 72 | unmount(); 73 | expect(destroy).toHaveBeenCalledTimes(2); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /packages/rx-effects-react/src/useController.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | 3 | import { useEffect, useMemo } from 'react'; 4 | import { Controller } from 'rx-effects'; 5 | 6 | const EMPTY_DEPENDENCIES: unknown[] = []; 7 | 8 | /** 9 | * Creates an ad-hoc controller by the factory and destroys it on unmounting a 10 | * component. 11 | * 12 | * The factory is not part of the dependencies by default. It should be 13 | * included explicitly when it is needed. 14 | * 15 | * @param factory a controller factory 16 | * @param dependencies array of hook dependencies to recreate the controller. 17 | */ 18 | export function useController>>( 19 | factory: () => T, 20 | dependencies: unknown[] = EMPTY_DEPENDENCIES, 21 | ): T { 22 | const controller = useMemo(factory, dependencies); 23 | useEffect(() => () => controller.destroy(), [controller]); 24 | 25 | return controller; 26 | } 27 | -------------------------------------------------------------------------------- /packages/rx-effects-react/src/useObservable.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks'; 2 | import { Subject } from 'rxjs'; 3 | import { monitorSubscriptionCount } from './test/testUtils'; 4 | import { useObservable } from './useObservable'; 5 | 6 | describe('useObservable()', () => { 7 | it('should render with the initial state', () => { 8 | const source$ = new Subject(); 9 | const { result } = renderHook(() => useObservable(source$, 1)); 10 | expect(result.current).toBe(1); 11 | }); 12 | 13 | it('should subscribe on changes and unsubscribe on unmount', () => { 14 | const source$ = new Subject(); 15 | 16 | let subscriptionCount = 0; 17 | const monitor$ = source$.pipe( 18 | monitorSubscriptionCount((count) => (subscriptionCount = count)), 19 | ); 20 | 21 | const { result, unmount } = renderHook(() => useObservable(monitor$, 1)); 22 | expect(subscriptionCount).toBe(1); 23 | 24 | act(() => source$.next(2)); 25 | expect(result.current).toBe(2); 26 | 27 | unmount(); 28 | act(() => source$.next(3)); 29 | expect(result.current).toBe(2); 30 | expect(subscriptionCount).toBe(0); 31 | }); 32 | 33 | it('should update the result only if the state comparator does not match a previous state with a next state', () => { 34 | type State = { key: number; value: number }; 35 | const source$ = new Subject(); 36 | const initialState = { key: 1, value: 1 }; 37 | 38 | const { result } = renderHook(() => 39 | useObservable( 40 | source$, 41 | initialState, 42 | (state1, state2) => state1.key === state2.key, 43 | ), 44 | ); 45 | expect(result.current).toEqual({ key: 1, value: 1 }); 46 | 47 | act(() => source$.next({ key: 1, value: 2 })); 48 | expect(result.current).toEqual({ key: 1, value: 1 }); 49 | 50 | act(() => source$.next({ key: 2, value: 3 })); 51 | expect(result.current).toEqual({ key: 2, value: 3 }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /packages/rx-effects-react/src/useObservable.ts: -------------------------------------------------------------------------------- 1 | import { identity, Observable } from 'rxjs'; 2 | import { useSelector } from './useSelector'; 3 | 4 | /** 5 | * Returns a value provided by `source$`. 6 | * 7 | * The hook returns the initial value and subscribes on the `source$`. After 8 | * that, the hook returns values which are provided by the source. 9 | * 10 | * @param source$ an observable for values 11 | * @param initialValue th first value which is returned by the hook 12 | * @param comparator a comparator for previous and next values 13 | * 14 | * @example 15 | * ```ts 16 | * const value = useObservable(source$, undefined); 17 | * ``` 18 | */ 19 | export function useObservable( 20 | source$: Observable, 21 | initialValue: T, 22 | comparator?: (v1: T, v2: T) => boolean, 23 | ): T { 24 | return useSelector(source$, initialValue, identity, comparator); 25 | } 26 | -------------------------------------------------------------------------------- /packages/rx-effects-react/src/useObserver.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react-hooks'; 2 | import { BehaviorSubject, PartialObserver, Subject } from 'rxjs'; 3 | import { isBrowser, useObserver } from './useObserver'; 4 | 5 | describe('useObserver()', () => { 6 | it('should subscribe a listener for next values', () => { 7 | const source$ = new Subject(); 8 | const listener = jest.fn(); 9 | 10 | renderHook(() => useObserver(source$, listener)); 11 | 12 | source$.next(1); 13 | expect(listener).toHaveBeenNthCalledWith(1, 1); 14 | }); 15 | 16 | it('should subscribe an observer', () => { 17 | const source$ = new Subject(); 18 | const observer: PartialObserver = { 19 | next: jest.fn(), 20 | complete: jest.fn(), 21 | }; 22 | 23 | renderHook(() => useObserver(source$, observer)); 24 | source$.next(1); 25 | source$.complete(); 26 | 27 | expect(observer.next).toHaveBeenCalledWith(1); 28 | expect(observer.complete).toHaveBeenCalled(); 29 | }); 30 | 31 | it('should resubscribe if a only source is changed', () => { 32 | const source1$ = new BehaviorSubject(1); 33 | const source2$ = new BehaviorSubject(2); 34 | const listener1 = jest.fn(); 35 | const listener2 = jest.fn(); 36 | 37 | const { rerender } = renderHook( 38 | ({ source$, listener }) => useObserver(source$, listener), 39 | { initialProps: { source$: source1$, listener: listener1 } }, 40 | ); 41 | rerender({ source$: source2$, listener: listener1 }); 42 | rerender({ source$: source2$, listener: listener2 }); 43 | 44 | expect(listener1).toHaveBeenNthCalledWith(1, 1); 45 | expect(listener1).toHaveBeenNthCalledWith(2, 2); 46 | expect(listener2).toHaveBeenCalledTimes(0); 47 | 48 | source2$.next(1); 49 | expect(listener2).toHaveBeenNthCalledWith(1, 1); 50 | expect(listener2).toHaveBeenCalledTimes(1); 51 | }); 52 | 53 | it('should handled it all variants of the listener', () => { 54 | const sourceNext$ = new BehaviorSubject(1); 55 | 56 | expect(() => { 57 | renderHook(() => 58 | useObserver(sourceNext$, { 59 | next: undefined, 60 | }), 61 | ); 62 | renderHook(() => useObserver(sourceNext$, undefined as any)); 63 | 64 | sourceNext$.next(1); 65 | }).not.toThrow(); 66 | 67 | const sourceError$ = new BehaviorSubject(1); 68 | expect(() => { 69 | renderHook(() => 70 | useObserver(sourceError$, { 71 | error: undefined, 72 | }), 73 | ); 74 | renderHook(() => useObserver(sourceError$, undefined as any)); 75 | sourceError$.error(new Error('some error')); 76 | }).not.toThrow(); 77 | 78 | const sourceComplete$ = new BehaviorSubject(1); 79 | expect(() => { 80 | renderHook(() => 81 | useObserver(sourceComplete$, { 82 | complete: undefined, 83 | }), 84 | ); 85 | renderHook(() => useObserver(sourceComplete$, undefined as any)); 86 | sourceComplete$.complete(); 87 | }).not.toThrow(); 88 | }); 89 | 90 | it('should subscribe to error', () => { 91 | const source$ = new Subject(); 92 | const observer: PartialObserver = { 93 | error: jest.fn(), 94 | }; 95 | 96 | renderHook(() => useObserver(source$, observer)); 97 | 98 | source$.error(new Error('some error')); 99 | 100 | expect(observer.error).toHaveBeenCalledTimes(1); 101 | }); 102 | 103 | it('should use useLayoutEffect when isBrowser is true', () => { 104 | jest.resetModules(); 105 | 106 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 107 | // @ts-ignore 108 | global.window = {}; 109 | 110 | const useIsomorphicLayoutEffect = 111 | // eslint-disable-next-line @typescript-eslint/no-var-requires 112 | require('./useObserver').useIsomorphicLayoutEffect; 113 | 114 | expect(typeof useIsomorphicLayoutEffect).toBe('function'); 115 | 116 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 117 | // @ts-ignore 118 | delete global.window; 119 | }); 120 | 121 | it('should unsubscribe on unmount', () => { 122 | const source$ = new BehaviorSubject(1); 123 | const listener = jest.fn(); 124 | 125 | const { unmount } = renderHook(() => useObserver(source$, listener)); 126 | 127 | unmount(); 128 | source$.next(2); 129 | 130 | expect(listener).toHaveBeenCalledTimes(1); 131 | expect(listener).toHaveBeenCalledWith(1); 132 | }); 133 | 134 | it('should unsubscribe old Observable and subscribe to new one when it changes', () => { 135 | const source1$ = new BehaviorSubject(1); 136 | const source2$ = new BehaviorSubject(2); 137 | const listener = jest.fn(); 138 | 139 | const { rerender } = renderHook( 140 | ({ source$ }) => { 141 | useObserver(source$, listener); 142 | }, 143 | { 144 | initialProps: { 145 | source$: source1$, 146 | }, 147 | }, 148 | ); 149 | 150 | expect(listener).toHaveBeenCalledTimes(1); 151 | expect(listener).toHaveBeenLastCalledWith(1); 152 | 153 | rerender({ source$: source2$ }); 154 | 155 | expect(listener).toHaveBeenCalledTimes(2); 156 | expect(listener).toHaveBeenLastCalledWith(2); 157 | }); 158 | 159 | it('should not subscribe a new observer in case a listener is changed', () => { 160 | const source$ = new BehaviorSubject(1); 161 | const listener1 = jest.fn(); 162 | const listener2 = jest.fn(); 163 | 164 | const { rerender } = renderHook( 165 | ({ listener }) => useObserver(source$, listener), 166 | { initialProps: { listener: listener1 } }, 167 | ); 168 | 169 | const observer = source$.observers[0]; 170 | expect(observer).toBeDefined(); 171 | 172 | rerender({ listener: listener2 }); 173 | source$.next(2); 174 | 175 | expect(source$.observers.length).toBe(1); 176 | expect(source$.observers[0]).toBe(observer); 177 | 178 | expect(listener1).toHaveBeenCalledTimes(1); 179 | expect(listener1).toHaveBeenCalledWith(1); 180 | 181 | expect(listener2).toHaveBeenCalledTimes(1); 182 | }); 183 | }); 184 | 185 | describe('isBrowser()', () => { 186 | it('should return true when the window exists', () => { 187 | const isBrowserFalsy = isBrowser(); 188 | expect(isBrowserFalsy).toBeFalsy(); 189 | 190 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 191 | // @ts-ignore 192 | global.window = {}; 193 | 194 | const isBrowserTruthy = isBrowser(); 195 | expect(isBrowserTruthy).toBeTruthy(); 196 | 197 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 198 | // @ts-ignore 199 | delete global.window; 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /packages/rx-effects-react/src/useObserver.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect, useRef } from 'react'; 2 | import { Observable, Observer } from 'rxjs'; 3 | 4 | /** 5 | * Subscribes the provided observer or `next` handler on `source$` observable. 6 | * 7 | * This hook allows to do fine handling of the source observable. 8 | * 9 | * @param source$ an observable 10 | * @param observerOrNext `Observer` or `next` handler 11 | * 12 | * @example 13 | * ```ts 14 | * useObserver(source$, (nextValue) => { 15 | * logger.log(nextValue); 16 | * }); 17 | * ``` 18 | */ 19 | export function useObserver( 20 | source$: Observable, 21 | observerOrNext: Partial> | ((value: T) => void), 22 | ): void { 23 | const argsRef = useRef>>(); 24 | 25 | // Update the latest observable and callbacks 26 | // synchronously after render being committed 27 | useIsomorphicLayoutEffect(() => { 28 | argsRef.current = 29 | typeof observerOrNext === 'function' 30 | ? { next: observerOrNext } 31 | : observerOrNext; 32 | }); 33 | 34 | useEffect(() => { 35 | const subscription = source$.subscribe({ 36 | next: (value) => argsRef.current?.next?.(value), 37 | error: (error) => argsRef.current?.error?.(error), 38 | complete: () => argsRef.current?.complete?.(), 39 | }); 40 | 41 | return () => subscription.unsubscribe(); 42 | }, [source$]); 43 | } 44 | 45 | export function isBrowser() { 46 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 47 | // @ts-ignore 48 | return typeof window !== 'undefined'; 49 | } 50 | 51 | /** 52 | * Prevent React warning when using useLayoutEffect on server. 53 | */ 54 | export const useIsomorphicLayoutEffect = isBrowser() 55 | ? useLayoutEffect 56 | : /* istanbul ignore next: both are not called on server */ 57 | useEffect; 58 | -------------------------------------------------------------------------------- /packages/rx-effects-react/src/useQuery.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks'; 2 | import { createStore, Query } from 'rx-effects'; 3 | import { monitorSubscriptionCount } from './test/testUtils'; 4 | import { useQuery } from './useQuery'; 5 | 6 | describe('useQuery()', () => { 7 | it('should render with a current value and watch for value changes', () => { 8 | const store = createStore(1); 9 | let subscriptionCount = 0; 10 | 11 | const query: Query = { 12 | get: store.get, 13 | value$: store.value$.pipe( 14 | monitorSubscriptionCount((count) => (subscriptionCount = count)), 15 | ), 16 | }; 17 | 18 | const { result, unmount } = renderHook(() => useQuery(query)); 19 | expect(result.current).toBe(1); 20 | expect(subscriptionCount).toBe(1); 21 | 22 | act(() => store.set(2)); 23 | expect(result.current).toBe(2); 24 | 25 | unmount(); 26 | act(() => store.set(3)); 27 | expect(result.current).toBe(2); 28 | expect(subscriptionCount).toBe(0); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /packages/rx-effects-react/src/useQuery.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { Query } from 'rx-effects'; 3 | 4 | /** 5 | * Returns a value which is provided by the query. 6 | * 7 | * @param query – a query for a value 8 | */ 9 | export function useQuery(query: Query): T { 10 | const [value, setValue] = useState(query.get); 11 | 12 | useEffect(() => { 13 | const subscription = query.value$.subscribe((nextValue) => { 14 | setValue(nextValue); 15 | }); 16 | 17 | return () => subscription.unsubscribe(); 18 | }, [query.value$]); 19 | 20 | return value; 21 | } 22 | -------------------------------------------------------------------------------- /packages/rx-effects-react/src/useSelector.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks'; 2 | import { identity, Subject } from 'rxjs'; 3 | import { monitorSubscriptionCount } from './test/testUtils'; 4 | import { useSelector } from './useSelector'; 5 | 6 | describe('useSelector()', () => { 7 | it('should render with the initial state', () => { 8 | const source$ = new Subject(); 9 | const { result } = renderHook(() => useSelector(source$, 1, identity)); 10 | expect(result.current).toBe(1); 11 | }); 12 | 13 | it('should subscribe on changes and unsubscribe on unmount', () => { 14 | const source$ = new Subject(); 15 | 16 | let subscriptionCount = 0; 17 | const monitor$ = source$.pipe( 18 | monitorSubscriptionCount((count) => (subscriptionCount = count)), 19 | ); 20 | 21 | const { result, unmount } = renderHook(() => 22 | useSelector(monitor$, 1, identity), 23 | ); 24 | expect(subscriptionCount).toBe(1); 25 | 26 | act(() => source$.next(2)); 27 | expect(result.current).toBe(2); 28 | 29 | unmount(); 30 | act(() => source$.next(3)); 31 | expect(result.current).toBe(2); 32 | expect(subscriptionCount).toBe(0); 33 | }); 34 | 35 | it('should map state with the selector', () => { 36 | type State = { value: number }; 37 | const source$ = new Subject(); 38 | const initialState = { value: 1 }; 39 | 40 | const { result } = renderHook(() => 41 | useSelector(source$, initialState, (state) => state.value), 42 | ); 43 | expect(result.current).toBe(1); 44 | 45 | act(() => source$.next({ value: 2 })); 46 | expect(result.current).toBe(2); 47 | }); 48 | 49 | it('should update the result only if the state comparator does not match a previous state with a next state', () => { 50 | type State = { key: number; value: number }; 51 | const source$ = new Subject(); 52 | const initialState = { key: 1, value: 1 }; 53 | 54 | const { result } = renderHook(() => 55 | useSelector( 56 | source$, 57 | initialState, 58 | identity, 59 | (state1, state2) => state1.key === state2.key, 60 | ), 61 | ); 62 | expect(result.current).toEqual({ key: 1, value: 1 }); 63 | 64 | act(() => source$.next({ key: 1, value: 2 })); 65 | expect(result.current).toEqual({ key: 1, value: 1 }); 66 | 67 | act(() => source$.next({ key: 2, value: 3 })); 68 | expect(result.current).toEqual({ key: 2, value: 3 }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /packages/rx-effects-react/src/useSelector.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { Observable } from 'rxjs'; 3 | import { DEFAULT_COMPARATOR } from './utils'; 4 | 5 | /** 6 | * Returns a value provided by `source$`. 7 | * 8 | * The hook returns the initial value and subscribes on the `source$`. After 9 | * that, the hook returns values which are provided by the source. 10 | * 11 | * @param source$ an observable for values 12 | * @param initialValue th first value which is returned by the hook 13 | * @param selector a transform function for getting a derived value based on 14 | * the source value 15 | * @param comparator a comparator for previous and next values 16 | * 17 | * @example 18 | * ```ts 19 | * const value = useSelector<{data: Record}>( 20 | * source$, 21 | * undefined, 22 | * (state) => state.data, 23 | * (data1, data2) => data1.key === data2.key 24 | * ); 25 | * ``` 26 | */ 27 | export function useSelector( 28 | source$: Observable, 29 | initialValue: S, 30 | selector: (state: S) => R, 31 | comparator: (v1: R, v2: R) => boolean = DEFAULT_COMPARATOR, 32 | ): R { 33 | const [value, setValue] = useState(() => selector(initialValue)); 34 | 35 | useEffect(() => { 36 | const subscription = source$.subscribe((state) => { 37 | const value = selector(state); 38 | setValue((prevValue) => 39 | comparator(value, prevValue) ? prevValue : value, 40 | ); 41 | }); 42 | 43 | return () => subscription.unsubscribe(); 44 | }, [comparator, selector, source$]); 45 | 46 | return value; 47 | } 48 | -------------------------------------------------------------------------------- /packages/rx-effects-react/src/useStore.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react-hooks'; 2 | import { act } from 'react-test-renderer'; 3 | import { declareStateUpdates } from 'rx-effects'; 4 | import { useStore } from './useStore'; 5 | 6 | describe('useStore()', () => { 7 | const STATE_UPDATES = declareStateUpdates()({ 8 | increase: () => (state) => state + 1, 9 | decrease: () => (state) => state - 1, 10 | }); 11 | 12 | it('should render with the initial value, updates and a store', () => { 13 | const { result } = renderHook(() => useStore(0, STATE_UPDATES)); 14 | 15 | const [value, updates, store] = result.current; 16 | 17 | expect(value).toBe(0); 18 | 19 | expect(updates).toMatchObject({ 20 | increase: expect.any(Function), 21 | decrease: expect.any(Function), 22 | }); 23 | 24 | expect(store).toMatchObject( 25 | expect.objectContaining({ 26 | value$: expect.any(Object), 27 | get: expect.any(Function), 28 | set: expect.any(Function), 29 | update: expect.any(Function), 30 | }), 31 | ); 32 | }); 33 | 34 | it('should render a new value when the store is updated', () => { 35 | const { result } = renderHook(() => useStore(0, STATE_UPDATES)); 36 | 37 | const [value1, updates1, store1] = result.current; 38 | expect(value1).toBe(0); 39 | 40 | act(() => { 41 | updates1.increase(); 42 | }); 43 | const [value2, updates2, store2] = result.current; 44 | 45 | expect(value2).toBe(1); 46 | expect(updates2).toBe(updates1); 47 | expect(store2).toBe(store1); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /packages/rx-effects-react/src/useStore.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createStore, 3 | StateUpdates, 4 | StoreOptions, 5 | StoreWithUpdates, 6 | withStoreUpdates, 7 | } from 'rx-effects'; 8 | import { useController } from './useController'; 9 | import { useQuery } from './useQuery'; 10 | 11 | export function useStore>( 12 | initialState: State, 13 | updates: Updates, 14 | options?: StoreOptions, 15 | ): [State, Updates, StoreWithUpdates] { 16 | const store: StoreWithUpdates = useController(() => { 17 | return withStoreUpdates( 18 | createStore(initialState, options), 19 | updates, 20 | ); 21 | }); 22 | 23 | const state = useQuery(store); 24 | 25 | return [state, store.updates, store] as [ 26 | State, 27 | Updates, 28 | StoreWithUpdates, 29 | ]; 30 | } 31 | -------------------------------------------------------------------------------- /packages/rx-effects-react/src/utils.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_COMPARATOR = (a: unknown, b: unknown): boolean => a === b; 2 | -------------------------------------------------------------------------------- /packages/rx-effects-react/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "files": ["src/index.ts"], 4 | "compilerOptions": { 5 | "noEmit": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/rx-effects-react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "react-jsx" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/rx-effects-react/typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["src/index.ts"], 3 | "exclude": ["**/*+(.test).ts"], 4 | "readme": "none" 5 | } 6 | -------------------------------------------------------------------------------- /packages/rx-effects/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | ## [1.1.2](https://github.com/mnasyrov/rx-effects/compare/v1.1.1...v1.1.2) (2024-07-12) 7 | 8 | **Note:** Version bump only for package rx-effects 9 | 10 | ## [1.1.1](https://github.com/mnasyrov/rx-effects/compare/v1.1.0...v1.1.1) (2023-08-05) 11 | 12 | **Note:** Version bump only for package rx-effects 13 | 14 | # [1.1.0](https://github.com/mnasyrov/rx-effects/compare/v1.0.1...v1.1.0) (2023-02-01) 15 | 16 | ### Features 17 | 18 | - Made `ViewController` to accept queries for external parameters (breaking change) ([#17](https://github.com/mnasyrov/rx-effects/issues/17)) ([ad49f8a](https://github.com/mnasyrov/rx-effects/commit/ad49f8a70eda02a415c37de7de320582f4a91d0e)) 19 | - new declareStore factory ([#15](https://github.com/mnasyrov/rx-effects/issues/15)) ([824f156](https://github.com/mnasyrov/rx-effects/commit/824f156a00ce9b0e4a6488a201913f3abf82177b)) 20 | 21 | ## [1.0.1](https://github.com/mnasyrov/rx-effects/compare/v1.0.0...v1.0.1) (2023-01-23) 22 | 23 | ### Bug Fixes 24 | 25 | - Fixed rerendering by `useObserver()` and reduced excess unsubscribe/subscribe on rerendering a parent component ([#13](https://github.com/mnasyrov/rx-effects/issues/13)) ([469b251](https://github.com/mnasyrov/rx-effects/commit/469b251797980b6280eb98d097e1b24747675879)) 26 | - Usage of `declareViewController()` with a controller factory with the single `scope` argument ([#11](https://github.com/mnasyrov/rx-effects/issues/11)) ([08a3ba4](https://github.com/mnasyrov/rx-effects/commit/08a3ba442caae56e58edb6437807d076b54e879b)) 27 | 28 | # [1.0.0](https://github.com/mnasyrov/rx-effects/compare/v0.7.2...v1.0.0) (2022-12-20) 29 | 30 | ### Features 31 | 32 | - Updated API for the library. Introduced tooling for ViewControllers with Ditox.js DI container. ([7cffcd0](https://github.com/mnasyrov/rx-effects/commit/7cffcd03f915337fa27e3b55f30fd1ad0c45a087)) 33 | 34 | ## [0.7.2](https://github.com/mnasyrov/rx-effects/compare/v0.7.1...v0.7.2) (2022-10-29) 35 | 36 | ### Features 37 | 38 | - Introduced `scope.onDestroy()` and `scope.subscribe()`. Added info about API deprecation. ([#9](https://github.com/mnasyrov/rx-effects/issues/9)) ([4467782](https://github.com/mnasyrov/rx-effects/commit/44677829f889aa4fbca12fb467f149cd0fab6869)) 39 | 40 | ## [0.7.1](https://github.com/mnasyrov/rx-effects/compare/v0.7.0...v0.7.1) (2022-10-26) 41 | 42 | ### Bug Fixes 43 | 44 | - Fixed and renamed `scope.subscribe()` to `scope.observe()` ([d3cf291](https://github.com/mnasyrov/rx-effects/commit/d3cf291a10ecc9bac1ebce044c05ed140cd3b601)) 45 | 46 | # [0.7.0](https://github.com/mnasyrov/rx-effects/compare/v0.6.0...v0.7.0) (2022-10-26) 47 | 48 | ### Bug Fixes 49 | 50 | - Fixed usage of Effect's options by `handleAction()` and `scope.createEffect()` ([#7](https://github.com/mnasyrov/rx-effects/issues/7)) ([e44bd23](https://github.com/mnasyrov/rx-effects/commit/e44bd23b563f7a61ea1ecfa291b311f52d55e577)) 51 | 52 | ### Features 53 | 54 | - New scope's methods: `handleQuery()` and `subscribe()` ([#8](https://github.com/mnasyrov/rx-effects/issues/8)) ([5242c3e](https://github.com/mnasyrov/rx-effects/commit/5242c3e91b042b5eb060a0d1899a018c4b29294a)) 55 | 56 | # [0.6.0](https://github.com/mnasyrov/rx-effects/compare/v0.5.2...v0.6.0) (2022-08-28) 57 | 58 | ### Features 59 | 60 | - `EffectOptions.pipeline` for customising processing of event. ([#4](https://github.com/mnasyrov/rx-effects/issues/4)) ([e927bb3](https://github.com/mnasyrov/rx-effects/commit/e927bb31c5fd7fe5c6c1e54b344d95dffc6ffd97)) 61 | - Added `ExternalScope` type. ([#3](https://github.com/mnasyrov/rx-effects/issues/3)) ([11c8a9c](https://github.com/mnasyrov/rx-effects/commit/11c8a9cd181869e2f973233efe42c49dc51b5ad3)) 62 | - Refactored Query API ([0ba6d12](https://github.com/mnasyrov/rx-effects/commit/0ba6d12df5f99cf98f04f130a89be814c90180f8)) 63 | - Track all unhandled errors of Effects ([#5](https://github.com/mnasyrov/rx-effects/issues/5)) ([3c108a4](https://github.com/mnasyrov/rx-effects/commit/3c108a488eae471337cc727461a7a223f7c367f3)) 64 | 65 | ## [0.5.2](https://github.com/mnasyrov/rx-effects/compare/v0.5.1...v0.5.2) (2022-01-26) 66 | 67 | **Note:** Version bump only for package rx-effects 68 | 69 | ## [0.5.1](https://github.com/mnasyrov/rx-effects/compare/v0.5.0...v0.5.1) (2022-01-11) 70 | 71 | ### Bug Fixes 72 | 73 | - Do not expose internal stores to extensions ([27420cb](https://github.com/mnasyrov/rx-effects/commit/27420cb152ddfafa48f9d7f75b59e558ba982d64)) 74 | 75 | # [0.5.0](https://github.com/mnasyrov/rx-effects/compare/v0.4.1...v0.5.0) (2022-01-11) 76 | 77 | ### Bug Fixes 78 | 79 | - Fixed eslint rules ([6975806](https://github.com/mnasyrov/rx-effects/commit/69758063de4d9f6b7821b439aad054087df249b9)) 80 | 81 | ### Features 82 | 83 | - Introduced store actions and `createStoreActions()` factory. ([c51bd07](https://github.com/mnasyrov/rx-effects/commit/c51bd07fa24c6d111567f75ad190a9f9bd987a5e)) 84 | - Introduced Store extensions and StoreLoggerExtension ([931b949](https://github.com/mnasyrov/rx-effects/commit/931b949b0c5134d6261eac7f6381a293dab48599)) 85 | 86 | ## [0.4.1](https://github.com/mnasyrov/rx-effects/compare/v0.4.0...v0.4.1) (2021-11-10) 87 | 88 | ### Bug Fixes 89 | 90 | - Share and replay mapQuery() and mergeQueries() to subscriptions ([8308310](https://github.com/mnasyrov/rx-effects/commit/830831001630d2b2b7318d2e7126706803eff9ff)) 91 | 92 | # [0.4.0](https://github.com/mnasyrov/rx-effects/compare/v0.3.3...v0.4.0) (2021-09-30) 93 | 94 | ### Bug Fixes 95 | 96 | - Concurrent store updates by its subscribers ([bc29bb5](https://github.com/mnasyrov/rx-effects/commit/bc29bb545587c01386b7351e25c5ce4b5036dc9c)) 97 | 98 | ## [0.3.3](https://github.com/mnasyrov/rx-effects/compare/v0.3.2...v0.3.3) (2021-09-27) 99 | 100 | ### Features 101 | 102 | - Store.update() can apply an array of mutations ([d778ac9](https://github.com/mnasyrov/rx-effects/commit/d778ac99549a9ac1887ea03ab77d5f0fa6527d1f)) 103 | 104 | ## [0.3.1](https://github.com/mnasyrov/rx-effects/compare/v0.3.0...v0.3.1) (2021-09-07) 105 | 106 | ### Bug Fixes 107 | 108 | - `mapQuery()` and `mergeQueries()` produce distinct values by default ([17721af](https://github.com/mnasyrov/rx-effects/commit/17721af837b3a43f047ef67ba475294e58492e80)) 109 | 110 | # [0.3.0](https://github.com/mnasyrov/rx-effects/compare/v0.2.2...v0.3.0) (2021-09-07) 111 | 112 | ### Features 113 | 114 | - Introduced `StateQueryOptions` for query mappers. Strict equality === is used by default as value comparators. ([5cc97e0](https://github.com/mnasyrov/rx-effects/commit/5cc97e0f7ab1623ffbdc133e5bfbe63911d68b56)) 115 | 116 | ## [0.2.2](https://github.com/mnasyrov/rx-effects/compare/v0.2.1...v0.2.2) (2021-09-02) 117 | 118 | ### Bug Fixes 119 | 120 | - Fixed typings and arguments of `mergeQueries()` ([156abcc](https://github.com/mnasyrov/rx-effects/commit/156abccc4dbe569751c1c79d1dba19e441da91cf)) 121 | 122 | ## [0.2.1](https://github.com/mnasyrov/rx-effects/compare/v0.2.0...v0.2.1) (2021-08-15) 123 | 124 | **Note:** Version bump only for package rx-effects 125 | 126 | # [0.2.0](https://github.com/mnasyrov/rx-effects/compare/v0.1.0...v0.2.0) (2021-08-09) 127 | 128 | ### Features 129 | 130 | - Renamed `EffectScope` to `Scope`. Extended `Scope` and `declareState()`. ([21d97be](https://github.com/mnasyrov/rx-effects/commit/21d97be080897f33f674d461397e8f1e86ac8eef)) 131 | 132 | # [0.1.0](https://github.com/mnasyrov/rx-effects/compare/v0.0.8...v0.1.0) (2021-08-03) 133 | 134 | ### Features 135 | 136 | - Introduced 'destroy()' method to Store to complete it. ([199cbb7](https://github.com/mnasyrov/rx-effects/commit/199cbb70ab2163f9f8edc8045b988afd2604595b)) 137 | - Introduced `Controller`, `useController()` and `mergeQueries()` ([d84a2e2](https://github.com/mnasyrov/rx-effects/commit/d84a2e2b8d1f57ca59e9664004de844a1f8bcf1f)) 138 | 139 | ## [0.0.8](https://github.com/mnasyrov/rx-effects/compare/v0.0.7...v0.0.8) (2021-07-26) 140 | 141 | ### Bug Fixes 142 | 143 | - Dropped stateEffects for a while. Added stubs for docs. ([566ab80](https://github.com/mnasyrov/rx-effects/commit/566ab8085b6e493942bf908e3000097561a14724)) 144 | 145 | ## [0.0.7](https://github.com/mnasyrov/rx-effects/compare/v0.0.6...v0.0.7) (2021-07-23) 146 | 147 | ### Bug Fixes 148 | 149 | - Types for actions and effects ([49235fe](https://github.com/mnasyrov/rx-effects/commit/49235fe80728a3803a16251d4c163f002b4bb29f)) 150 | 151 | ## [0.0.6](https://github.com/mnasyrov/rx-effects/compare/v0.0.5...v0.0.6) (2021-07-12) 152 | 153 | **Note:** Version bump only for package rx-effects 154 | 155 | ## [0.0.5](https://github.com/mnasyrov/rx-effects/compare/v0.0.4...v0.0.5) (2021-07-11) 156 | 157 | **Note:** Version bump only for package rx-effects 158 | 159 | ## [0.0.4](https://github.com/mnasyrov/rx-effects/compare/v0.0.3...v0.0.4) (2021-07-11) 160 | 161 | **Note:** Version bump only for package rx-effects 162 | 163 | ## [0.0.3](https://github.com/mnasyrov/rx-effects/compare/v0.0.2...v0.0.3) (2021-07-11) 164 | 165 | **Note:** Version bump only for package rx-effects 166 | 167 | ## 0.0.2 (2021-07-11) 168 | 169 | **Note:** Version bump only for package rx-effects 170 | -------------------------------------------------------------------------------- /packages/rx-effects/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Mikhail Nasyrov 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 | -------------------------------------------------------------------------------- /packages/rx-effects/README.md: -------------------------------------------------------------------------------- 1 | # RxEffects 2 | 3 | rocket 4 | 5 | Reactive state and effect management with RxJS. 6 | 7 | [![npm](https://img.shields.io/npm/v/rx-effects.svg)](https://www.npmjs.com/package/rx-effects) 8 | [![downloads](https://img.shields.io/npm/dt/rx-effects.svg)](https://www.npmjs.com/package/rx-effects) 9 | [![types](https://img.shields.io/npm/types/rx-effects.svg)](https://www.npmjs.com/package/rx-effects) 10 | [![licence](https://img.shields.io/github/license/mnasyrov/rx-effects.svg)](https://github.com/mnasyrov/rx-effects/blob/master/LICENSE) 11 | [![Coverage Status](https://coveralls.io/repos/github/mnasyrov/rx-effects/badge.svg?branch=main)](https://coveralls.io/github/mnasyrov/rx-effects?branch=main) 12 | 13 | ## Documentation 14 | 15 | - [Main docs](https://github.com/mnasyrov/rx-effects#readme) 16 | - [API docs](docs/README.md) 17 | 18 | ## Installation 19 | 20 | ``` 21 | npm install rx-effects --save 22 | ``` 23 | 24 | ## Concepts 25 | 26 | The main idea is to use the classic MVC pattern with event-based models (state stores) and reactive controllers (actions 27 | and effects). The view subscribes to model changes (state queries) of the controller and requests the controller to do 28 | some actions. 29 | 30 | concept-diagram 31 | 32 | Main elements: 33 | 34 | - `State` – a data model. 35 | - `Query` – a getter and subscriber for data of the state. 36 | - `StateMutation` – a pure function which changes the state. 37 | - `Store` – a state storage, it provides methods to update and subscribe the state. 38 | - `Action` – an event emitter. 39 | - `Effect` – a business logic which handles the action and makes state changes and side effects. 40 | - `Controller` – a controller type for effects and business logic 41 | - `Scope` – a controller-like boundary for effects and business logic 42 | 43 | ## State and Store 44 | 45 | ### Define State 46 | 47 | A state can be a primitive value or an object, and it is described as a type. 48 | 49 | ```ts 50 | type CartState = { orders: Array }; 51 | ``` 52 | 53 | ### State Mutations 54 | 55 | After that, it is recommended to declare a set of `StateMutation` functions which can be used to update the state. These 56 | functions should be pure and return a new state or the previous one. For providing an argument use currying functions. 57 | 58 | Actually, `StateMutation` function can change the state in place, but it is responsible for a developer to track state 59 | changes by providing custom `stateCompare` function to a store. 60 | 61 | ```ts 62 | const addPizzaToCart = 63 | (name: string): StateMutation => 64 | (state) => ({ ...state, orders: [...state.orders, name] }); 65 | 66 | const removePizzaFromCart = 67 | (name: string): StateMutation => 68 | (state) => ({ 69 | ...state, 70 | orders: state.orders.filter((order) => order !== name), 71 | }); 72 | ``` 73 | 74 | ### Creation of Store 75 | 76 | A store is created by `createStore()` function, which takes an initial state: 77 | 78 | ```ts 79 | const INITIAL_STATE: CartState = { orders: [] }; 80 | const cartStore: Store = createStore(INITIAL_STATE); 81 | ``` 82 | 83 | ### Updating Store 84 | 85 | The store can be updated by `set()` and `update()` methods: 86 | 87 | - `set()` applies the provided `State` value to the store. 88 | - `update()` creates the new state by the provided `StateMutation` and applies it to the store. 89 | 90 | ```ts 91 | function resetCart() { 92 | cartStore.set(INITIAL_STATE); 93 | } 94 | 95 | function addPizza(name: string) { 96 | cartStore.update(addPizzaToCart(name)); 97 | } 98 | ``` 99 | 100 | `Store.update()` can apply an array of mutations while skipping empty mutation: 101 | 102 | ```ts 103 | function addPizza(name: string) { 104 | cartStore.update([ 105 | addPizzaToCart(name), 106 | name === 'Pepperoni' && addPizzaToCart('Bonus Pizza'), 107 | ]); 108 | } 109 | ``` 110 | 111 | There is `pipeStateMutations()` helper, which can merge state updates into the single mutation: 112 | 113 | ```ts 114 | import { pipeStateMutations } from './stateMutation'; 115 | 116 | const addPizzaToCartWithBonus = (name: string): StateMutation => 117 | pipeStateMutations([addPizzaToCart(name), addPizzaToCart('Bonus Pizza')]); 118 | 119 | function addPizza(name: string) { 120 | cartStore.update(addPizzaToCartWithBonus(name)); 121 | } 122 | ``` 123 | 124 | ### Getting State 125 | 126 | The store implements `Query` type for providing the state: 127 | 128 | - `get()` returns the current state. 129 | - `value$` is an observable for the current state and future changes. 130 | 131 | It is allowed to get the current state at any time. However, you should be aware how it is used during async functions, 132 | because the state can be changed after awaiting a promise: 133 | 134 | ```ts 135 | // Not recommended 136 | async function submitForm() { 137 | await validate(formStore.get()); 138 | await postData(formStore.get()); // `formStore` can return another data here 139 | } 140 | 141 | // Recommended 142 | async function submitForm() { 143 | const data = formStore.get(); 144 | await validate(data); 145 | await postData(data); 146 | } 147 | ``` 148 | 149 | ### State Queries 150 | 151 | The store has `select()` and `query()` methods: 152 | 153 | - `select()` returns `Observable` for the part of the state. 154 | - `value$` returns `Query` for the part of the state. 155 | 156 | Both of the methods takes `selector()` and `valueComparator()` arguments: 157 | 158 | - `selector()` takes a state and produce a value based on the state. 159 | - `valueComparator()` is optional and allows change an equality check for the produced value. 160 | 161 | ```ts 162 | const orders$: Observable> = cartStore.select( 163 | (state) => state.orders, 164 | ); 165 | 166 | const ordersQuery: Query> = cartStore.query( 167 | (state) => state.orders, 168 | ); 169 | ``` 170 | 171 | Utility functions: 172 | 173 | - `mapQuery()` takes a query and a value mapper and returns a new query which projects the mapped value. 174 | ```ts 175 | const store = createStore<{ values: Array }>(); 176 | const valuesQuery = store.query((state) => state.values); 177 | const top5values = mapQuery(valuesQuery, (values) => values.slice(0, 5)); 178 | ``` 179 | - `mergeQueries()` takes queries and a value merger and returns a new query which projects the derived value of queries. 180 | ```ts 181 | const store1 = createStore(2); 182 | const store2 = createStore(3); 183 | const sumValueQuery = mergeQueries( 184 | [store1, store2], 185 | (value1, value2) => value1 + value2, 186 | ); 187 | ``` 188 | 189 | ### Destroying Store 190 | 191 | The store implements `Controller` type and has `destroy()` method. 192 | 193 | `destory()` completes internal `Observable` sources and all derived observables, which are created by `select()` and `query()` methods. 194 | 195 | After calling `destroy()` the store stops sending updates for state changes. 196 | 197 | ### Usage of libraries for immutable state 198 | 199 | Types and factories for states and store are compatible with external libraries for immutable values like Immer.js or Immutable.js. All state changes are encapsulated by `StateMutation` functions so using the API remains the same. The one thing which should be considered is providing the right state comparators to the `Store`. 200 | 201 | #### Immer.js 202 | 203 | Integration of [Immer.js](https://github.com/immerjs/immer) is straightforward: it is enough to use `produce()` function inside `StateMutation` functions: 204 | 205 | Example: 206 | 207 | ```ts 208 | import { produce } from 'immer'; 209 | import { StateMutation } from 'rx-effects'; 210 | 211 | export type CartState = { orders: Array }; 212 | 213 | export const CART_STATE = declareState({ orders: [] }); 214 | 215 | export const addPizzaToCart = (name: string): StateMutation => 216 | produce((state) => { 217 | state.orders.push(name); 218 | }); 219 | 220 | export const removePizzaFromCart = (name: string): StateMutation => 221 | produce((state) => { 222 | state.orders = state.orders.filter((order) => order !== name); 223 | }); 224 | ``` 225 | 226 | #### Immutable.js 227 | 228 | Integration of [Immutable.js](https://github.com/immutable-js/immutable-js): 229 | 230 | - It is recommended to use `Record` and `RecordOf` for object-list states. 231 | - Use `Immutable.is()` function as a comparator for Immutable's state and values. 232 | 233 | Example: 234 | 235 | ```ts 236 | import { is, List, Record, RecordOf } from 'immutable'; 237 | import { declareState, StateMutation } from 'rx-effects'; 238 | 239 | export type CartState = RecordOf<{ orders: Immutable.List }>; 240 | 241 | export const CART_STATE = declareState( 242 | Record({ orders: List() }), 243 | is, // State comparator of Immutable.js 244 | ); 245 | 246 | export const addPizzaToCart = 247 | (name: string): StateMutation => 248 | (state) => 249 | state.set('orders', state.orders.push(name)); 250 | 251 | export const removePizzaFromCart = 252 | (name: string): StateMutation => 253 | (state) => 254 | state.set( 255 | 'orders', 256 | state.orders.filter((order) => order !== name), 257 | ); 258 | ``` 259 | 260 | ## Actions and Effects 261 | 262 | ### Action 263 | 264 | `// TODO` 265 | 266 | ### Effect 267 | 268 | `// TODO` 269 | 270 | ### Controller 271 | 272 | `// TODO` 273 | 274 | ### Scope 275 | 276 | `// TODO` 277 | 278 | --- 279 | 280 | © 2021 [Mikhail Nasyrov](https://github.com/mnasyrov), [MIT license](./LICENSE) 281 | -------------------------------------------------------------------------------- /packages/rx-effects/docs/.nojekyll: -------------------------------------------------------------------------------- 1 | TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. -------------------------------------------------------------------------------- /packages/rx-effects/index.ts: -------------------------------------------------------------------------------- 1 | export * from './src'; 2 | -------------------------------------------------------------------------------- /packages/rx-effects/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rx-effects", 3 | "description": "Reactive state and effects management", 4 | "version": "1.1.2", 5 | "license": "MIT", 6 | "author": "Mikhail Nasyrov (https://github.com/mnasyrov)", 7 | "homepage": "https://github.com/mnasyrov/rx-effects", 8 | "bugs": { 9 | "url": "https://github.com/mnasyrov/rx-effects/issues" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/mnasyrov/rx-effects.git" 14 | }, 15 | "keywords": [ 16 | "state", 17 | "effect", 18 | "management", 19 | "state-management", 20 | "reactive", 21 | "rxjs", 22 | "rx", 23 | "effector" 24 | ], 25 | "engines": { 26 | "node": ">=12" 27 | }, 28 | "main": "dist/cjs/index.js", 29 | "module": "dist/esm/index.js", 30 | "types": "dist/esm/index.d.ts", 31 | "sideEffects": false, 32 | "files": [ 33 | "dist", 34 | "docs", 35 | "src", 36 | "index.ts", 37 | "LICENSE", 38 | "README.md" 39 | ], 40 | "scripts": { 41 | "clean": "rm -rf dist", 42 | "build": "npm run build:cjs && npm run build:esm", 43 | "build:cjs": "tsc -p tsconfig.build.json --outDir dist/cjs --module commonjs", 44 | "build:esm": "tsc -p tsconfig.build.json --outDir dist/esm --module es2015" 45 | }, 46 | "peerDependencies": { 47 | "ditox": ">=2.2 || >=3", 48 | "rxjs": ">=7.0.0" 49 | }, 50 | "gitHead": "666d5b8672ebe6cb02174366a799d1da27d387a9" 51 | } 52 | -------------------------------------------------------------------------------- /packages/rx-effects/src/action.test.ts: -------------------------------------------------------------------------------- 1 | import { firstValueFrom, map } from 'rxjs'; 2 | import { createAction } from './action'; 3 | 4 | describe('Action', () => { 5 | it('should emit the event', async () => { 6 | const action = createAction(); 7 | 8 | const promise = firstValueFrom(action.event$); 9 | action(1); 10 | 11 | expect(await promise).toBe(1); 12 | }); 13 | 14 | it('should use void type and undefined value if a generic type is not specified', async () => { 15 | const action = createAction(); 16 | 17 | const promise = firstValueFrom(action.event$); 18 | action(); 19 | 20 | expect(await promise).toBe(undefined); 21 | }); 22 | 23 | it('should use the provided operator in the event pipeline', async () => { 24 | const action = createAction(map((value) => value * 10)); 25 | 26 | const promise = firstValueFrom(action.event$); 27 | action(1); 28 | 29 | expect(await promise).toBe(10); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /packages/rx-effects/src/action.ts: -------------------------------------------------------------------------------- 1 | import { MonoTypeOperatorFunction, Observable, Subject } from 'rxjs'; 2 | 3 | /** 4 | * Action is an event emitter 5 | * 6 | * @param operator Optional transformation or handler for an event 7 | * 8 | * @field event$ - Observable for emitted events. 9 | * 10 | * @example 11 | * ```ts 12 | * // Create the action 13 | * const submitForm = createAction<{login: string, password: string}>(); 14 | * 15 | * // Call the action 16 | * submitForm({login: 'foo', password: 'bar'}); 17 | * 18 | * // Handle action's events 19 | * submitForm.even$.subscribe((formData) => { 20 | * // Process the formData 21 | * }); 22 | * ``` 23 | */ 24 | export type Action = { 25 | readonly event$: Observable; 26 | (event: Event): void; 27 | } & ([Event] extends [undefined | void] 28 | ? { (event?: Event): void } 29 | : { (event: Event): void }); 30 | 31 | export function createAction( 32 | operator?: MonoTypeOperatorFunction, 33 | ): Action { 34 | const source$ = new Subject(); 35 | 36 | const emitter = (event: Event): void => source$.next(event); 37 | emitter.event$ = operator ? source$.pipe(operator) : source$.asObservable(); 38 | 39 | return emitter as unknown as Action; 40 | } 41 | -------------------------------------------------------------------------------- /packages/rx-effects/src/controller.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | 3 | import { AnyObject } from './utils'; 4 | 5 | /** 6 | * Effects and business logic controller. 7 | * 8 | * Implementation of the controller must provide `destroy()` method. It should 9 | * be used for closing subscriptions and disposing resources. 10 | * 11 | * @example 12 | * ```ts 13 | * type LoggerController = Controller<{ 14 | * log: (message: string) => void; 15 | * }>; 16 | * ``` 17 | */ 18 | export type Controller = 19 | Readonly< 20 | ControllerProps & { 21 | /** Dispose the controller and clean its resources */ 22 | destroy: () => void; 23 | } 24 | >; 25 | -------------------------------------------------------------------------------- /packages/rx-effects/src/declareStore.test.ts: -------------------------------------------------------------------------------- 1 | import { declareStore } from './declareStore'; 2 | 3 | describe('createStore()', () => { 4 | type SimpleState = { 5 | value: string; 6 | count: number; 7 | }; 8 | const initialState: SimpleState = { 9 | value: 'initial text', 10 | count: 0, 11 | }; 12 | const createSimpleStore = declareStore({ 13 | initialState, 14 | updates: { 15 | set: (value: number) => (state) => { 16 | return { 17 | ...state, 18 | count: value, 19 | }; 20 | }, 21 | sum: (value1: number, value2: number) => (state) => { 22 | return { 23 | ...state, 24 | count: value1 + value2, 25 | }; 26 | }, 27 | increment: () => (state) => { 28 | return { 29 | ...state, 30 | count: state.count + 1, 31 | }; 32 | }, 33 | decrement: () => (state) => { 34 | return { 35 | ...state, 36 | count: state.count - 1, 37 | }; 38 | }, 39 | }, 40 | }); 41 | 42 | it('should allow testing of updates', () => { 43 | expect(initialState.count).toBe(0); 44 | 45 | const result = createSimpleStore.updates.set(10)(initialState); 46 | 47 | expect(result.count).toBe(10); 48 | }); 49 | 50 | it('should executing without mutation for initial state', () => { 51 | createSimpleStore.updates.set(10)(initialState); 52 | 53 | expect(initialState.count).toBe(0); 54 | }); 55 | 56 | it('should update initial state during initialization', () => { 57 | const simpleStore = createSimpleStore({ 58 | count: 12, 59 | value: 'new initial text', 60 | }); 61 | 62 | const { get } = simpleStore.asQuery(); 63 | 64 | expect(get().count).toBe(12); 65 | 66 | expect(get().value).toBe('new initial text'); 67 | }); 68 | 69 | it('should initial state to be able to be a function', () => { 70 | const simpleStore = createSimpleStore((state) => ({ 71 | ...state, 72 | value: 'updated text', 73 | })); 74 | 75 | const { get } = simpleStore.asQuery(); 76 | 77 | expect(get().count).toBe(0); 78 | 79 | expect(get().value).toBe('updated text'); 80 | }); 81 | 82 | it('should update the state', () => { 83 | const simpleStore = createSimpleStore(); 84 | const { get } = simpleStore.asQuery(); 85 | expect(get().count).toBe(0); 86 | 87 | simpleStore.updates.set(10); 88 | 89 | expect(get().count).toBe(10); 90 | 91 | simpleStore.updates.increment(); 92 | 93 | expect(get().count).toBe(11); 94 | 95 | simpleStore.updates.decrement(); 96 | simpleStore.updates.decrement(); 97 | 98 | expect(get().count).toBe(9); 99 | }); 100 | 101 | it('should execute query selector', () => { 102 | const simpleStore = createSimpleStore(); 103 | const { get } = simpleStore.query((state) => state.count); 104 | 105 | simpleStore.updates.set(1); 106 | 107 | expect(get()).toBe(1); 108 | }); 109 | 110 | it('should use a lot of arguments in updates', () => { 111 | const simpleStore = createSimpleStore(); 112 | const { get } = simpleStore.asQuery(); 113 | 114 | simpleStore.updates.sum(1, 11); 115 | 116 | expect(get().count).toBe(12); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /packages/rx-effects/src/declareStore.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | import { 3 | createStore, 4 | StateMutation, 5 | StateUpdates, 6 | StoreOptions, 7 | StoreWithUpdates, 8 | withStoreUpdates, 9 | } from './store'; 10 | 11 | export type StoreDeclaration< 12 | State, 13 | Updates extends StateUpdates = StateUpdates, 14 | > = Readonly<{ 15 | initialState: State; 16 | updates: Updates; 17 | options?: StoreOptions; 18 | }>; 19 | 20 | type FactoryStateArg = 21 | | (State extends Function ? never : State) 22 | | StateMutation; 23 | 24 | export type DeclaredStoreFactory> = { 25 | ( 26 | initialState?: FactoryStateArg, 27 | options?: StoreOptions, 28 | ): StoreWithUpdates; 29 | 30 | updates: Updates; 31 | }; 32 | 33 | /** 34 | * declare the base interface for create store 35 | * @example 36 | ```ts 37 | type State = { 38 | id: string; 39 | name: string; 40 | isAdmin: boolean 41 | }; 42 | const initialState: State = { 43 | id: '', 44 | name: '', 45 | isAdmin: false 46 | }; 47 | const createUserStore = declareStore({ 48 | initialState, 49 | updates: { 50 | setId: (id: string) => (state) => { 51 | return { 52 | ...state, 53 | id: id, 54 | }; 55 | }, 56 | setName: (name: string) => (state) => { 57 | return { 58 | ...state, 59 | name: name, 60 | }; 61 | }, 62 | update: (id: string name: string) => (state) => { 63 | return { 64 | ...state, 65 | id: id, 66 | name: name, 67 | }; 68 | }, 69 | setIsAdmin: () => (state) => { 70 | return { 71 | ...state, 72 | isAdmin: true, 73 | }; 74 | }, 75 | }, 76 | }); 77 | 78 | const userStore1 = createUserStore({ id: '1', name: 'User 1', isAdmin: false }); 79 | 80 | const userStore2 = createUserStore({ id: '2', name: 'User 2', isAdmin: true }); 81 | 82 | // OR 83 | 84 | const users = [ 85 | createUserStore({id: 1, name: 'User 1'}), 86 | createUserStore({id: 2, name: 'User 2'}), 87 | ] 88 | 89 | userStore1.updates.setName('User from store 1'); 90 | 91 | assets.isEqual(userStore1.get().name, 'User from store 1') 92 | 93 | assets.isEqual(userStore2.get().name, 'User 2') 94 | 95 | // type of createUserStore 96 | type UserStore = ReturnType; 97 | ``` 98 | */ 99 | export function declareStore< 100 | State, 101 | Updates extends StateUpdates = StateUpdates, 102 | >( 103 | declaration: StoreDeclaration, 104 | ): DeclaredStoreFactory { 105 | const { 106 | initialState: baseState, 107 | updates, 108 | options: baseOptions, 109 | } = declaration; 110 | 111 | function factory( 112 | initialState?: FactoryStateArg, 113 | options?: StoreOptions, 114 | ) { 115 | const state = 116 | initialState === undefined 117 | ? baseState 118 | : typeof initialState === 'function' 119 | ? (initialState as StateMutation)(baseState) 120 | : initialState; 121 | 122 | const store = createStore(state, { 123 | ...baseOptions, 124 | ...options, 125 | }); 126 | 127 | return withStoreUpdates(store, updates); 128 | } 129 | 130 | return Object.assign(factory, { updates }); 131 | } 132 | -------------------------------------------------------------------------------- /packages/rx-effects/src/effect.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defer, 3 | mergeMap, 4 | Observable, 5 | of, 6 | OperatorFunction, 7 | retry, 8 | Subject, 9 | Subscription, 10 | tap, 11 | } from 'rxjs'; 12 | import { Action } from './action'; 13 | import { Controller } from './controller'; 14 | import { createEffectController } from './effectController'; 15 | import { EffectState } from './effectState'; 16 | import { Query } from './query'; 17 | 18 | /** 19 | * Handler for an event. It can be asynchronous. 20 | * 21 | * @result a result, Promise or Observable 22 | */ 23 | export type EffectHandler = ( 24 | event: Event, 25 | ) => Result | Promise | Observable; 26 | 27 | export type EffectEventProject = ( 28 | event: Event, 29 | ) => Observable; 30 | 31 | export type EffectPipeline = ( 32 | eventProject: EffectEventProject, 33 | ) => OperatorFunction; 34 | 35 | const DEFAULT_MERGE_MAP_PIPELINE: EffectPipeline = (eventProject) => 36 | mergeMap(eventProject); 37 | 38 | export type EffectOptions = Readonly<{ 39 | /** 40 | * Custom pipeline for processing effect's events. 41 | * 42 | * `mergeMap` pipeline is used by default. 43 | */ 44 | pipeline?: EffectPipeline; 45 | }>; 46 | 47 | /** 48 | * Effect encapsulates a handler for Action or Observable. 49 | * 50 | * It provides the state of execution results, which can be used to construct 51 | * a graph of business logic. 52 | * 53 | * Effect collects all internal subscriptions, and provides `destroy()` methods 54 | * unsubscribe from them and deactivate the effect. 55 | */ 56 | export type Effect = Controller< 57 | EffectState & { 58 | handle: ( 59 | source: Action | Observable | Query, 60 | ) => Subscription; 61 | } 62 | >; 63 | 64 | /** 65 | * Creates `Effect` from the provided handler. 66 | * 67 | * @example 68 | * ```ts 69 | * const sumEffect = createEffect<{a: number, b: number}, number>((event) => { 70 | * return a + b; 71 | * }); 72 | * ``` 73 | */ 74 | export function createEffect( 75 | handler: EffectHandler, 76 | options?: EffectOptions, 77 | ): Effect { 78 | const pipeline: EffectPipeline = 79 | options?.pipeline ?? DEFAULT_MERGE_MAP_PIPELINE; 80 | 81 | const event$: Subject = new Subject(); 82 | const controller = createEffectController(); 83 | 84 | const subscriptions = new Subscription(() => { 85 | event$.complete(); 86 | controller.destroy(); 87 | }); 88 | 89 | const eventProject: EffectEventProject = (event: Event) => { 90 | return defer(() => { 91 | controller.start(); 92 | 93 | const result = handler(event); 94 | 95 | return result instanceof Observable || result instanceof Promise 96 | ? result 97 | : of(result); 98 | }).pipe( 99 | tap({ 100 | next: (result) => { 101 | controller.next({ event, result }); 102 | }, 103 | complete: () => { 104 | controller.complete(); 105 | }, 106 | error: (error) => { 107 | controller.error({ origin: 'handler', event, error }); 108 | }, 109 | }), 110 | ); 111 | }; 112 | 113 | subscriptions.add(event$.pipe(pipeline(eventProject), retry()).subscribe()); 114 | 115 | return { 116 | ...controller.state, 117 | 118 | handle( 119 | source: Observable | Action | Query, 120 | ): Subscription { 121 | const observable = getSourceObservable(source); 122 | 123 | const subscription = observable.subscribe({ 124 | next: (event) => event$.next(event), 125 | error: (error) => controller.error({ origin: 'source', error }), 126 | }); 127 | subscriptions.add(subscription); 128 | 129 | return subscription; 130 | }, 131 | 132 | destroy: () => { 133 | subscriptions.unsubscribe(); 134 | }, 135 | }; 136 | } 137 | 138 | function getSourceObservable( 139 | source: Observable | Action | Query, 140 | ): Observable { 141 | const type = typeof source; 142 | 143 | if (type === 'function' && 'event$' in source) { 144 | return source.event$; 145 | } 146 | 147 | if (type === 'object' && 'value$' in source) { 148 | return source.value$; 149 | } 150 | 151 | if (source instanceof Observable) { 152 | return source; 153 | } 154 | 155 | throw new TypeError('Unexpected source type'); 156 | } 157 | -------------------------------------------------------------------------------- /packages/rx-effects/src/effectController.test.ts: -------------------------------------------------------------------------------- 1 | import { createEffectController } from './effectController'; 2 | 3 | describe('EffectController', () => { 4 | describe('start()', () => { 5 | it('should increase pendingCount', () => { 6 | const controller = createEffectController(); 7 | expect(controller.state.pendingCount.get()).toBe(0); 8 | 9 | controller.start(); 10 | expect(controller.state.pendingCount.get()).toBe(1); 11 | 12 | controller.start(); 13 | expect(controller.state.pendingCount.get()).toBe(2); 14 | }); 15 | }); 16 | 17 | describe('complete()', () => { 18 | it('should decrease pendingCount', () => { 19 | const controller = createEffectController(); 20 | 21 | controller.start(); 22 | controller.start(); 23 | expect(controller.state.pendingCount.get()).toBe(2); 24 | 25 | controller.complete(); 26 | controller.complete(); 27 | expect(controller.state.pendingCount.get()).toBe(0); 28 | }); 29 | 30 | it('should not decrease pendingCount below zero', () => { 31 | const controller = createEffectController(); 32 | 33 | controller.complete(); 34 | expect(controller.state.pendingCount.get()).toBe(0); 35 | 36 | controller.start(); 37 | controller.complete(); 38 | controller.complete(); 39 | expect(controller.state.pendingCount.get()).toBe(0); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /packages/rx-effects/src/effectController.ts: -------------------------------------------------------------------------------- 1 | import { identity, map, merge, Observable, Subject, Subscription } from 'rxjs'; 2 | import { Controller } from './controller'; 3 | import { 4 | EffectError, 5 | EffectNotification, 6 | EffectResult, 7 | EffectState, 8 | } from './effectState'; 9 | import { createStore, InternalStoreOptions } from './store'; 10 | 11 | const GLOBAL_EFFECT_UNHANDLED_ERROR_SUBJECT = new Subject< 12 | EffectError 13 | >(); 14 | 15 | export const GLOBAL_EFFECT_UNHANDLED_ERROR$ = 16 | GLOBAL_EFFECT_UNHANDLED_ERROR_SUBJECT.asObservable(); 17 | 18 | function emitGlobalUnhandledError( 19 | effectError: EffectError, 20 | ): void { 21 | if (GLOBAL_EFFECT_UNHANDLED_ERROR_SUBJECT.observed) { 22 | GLOBAL_EFFECT_UNHANDLED_ERROR_SUBJECT.next(effectError); 23 | } else { 24 | console.error('Uncaught error in Effect', effectError); 25 | } 26 | } 27 | 28 | export type EffectController = Controller<{ 29 | state: EffectState; 30 | 31 | start: () => void; 32 | next: (result: EffectResult) => void; 33 | complete: () => void; 34 | error: (error: EffectError) => void; 35 | }>; 36 | 37 | const increaseCount = (count: number): number => count + 1; 38 | const decreaseCount = (count: number): number => (count > 0 ? count - 1 : 0); 39 | 40 | export function createEffectController< 41 | Event, 42 | Result, 43 | ErrorType = Error, 44 | >(): EffectController { 45 | const subscriptions = new Subscription(); 46 | 47 | const event$: Subject = new Subject(); 48 | const done$: Subject> = new Subject(); 49 | const error$: Subject> = new Subject(); 50 | const pendingCount = createStore(0, { 51 | internal: true, 52 | } as InternalStoreOptions); 53 | 54 | subscriptions.add(() => { 55 | event$.complete(); 56 | done$.complete(); 57 | error$.complete(); 58 | pendingCount.destroy(); 59 | }); 60 | 61 | const notifications$: Observable< 62 | EffectNotification 63 | > = merge( 64 | done$.pipe( 65 | map< 66 | EffectResult, 67 | EffectNotification 68 | >((entry) => ({ type: 'result', ...entry })), 69 | ), 70 | 71 | error$.pipe( 72 | map< 73 | EffectError, 74 | EffectNotification 75 | >((entry) => ({ type: 'error', ...entry })), 76 | ), 77 | ); 78 | 79 | return { 80 | state: { 81 | done$: done$.asObservable(), 82 | result$: done$.pipe(map(({ result }) => result)), 83 | error$: error$.asObservable(), 84 | final$: notifications$, 85 | pending: pendingCount.query((count) => count > 0), 86 | pendingCount: pendingCount.query(identity), 87 | }, 88 | 89 | start: () => pendingCount.update(increaseCount), 90 | 91 | next: (result) => done$.next(result), 92 | 93 | complete: () => pendingCount.update(decreaseCount), 94 | 95 | error: (effectError) => { 96 | if (effectError.origin === 'handler') { 97 | pendingCount.update(decreaseCount); 98 | } 99 | 100 | if (error$.observed) { 101 | error$.next(effectError); 102 | } else { 103 | emitGlobalUnhandledError(effectError); 104 | } 105 | }, 106 | 107 | destroy: () => { 108 | subscriptions.unsubscribe(); 109 | }, 110 | }; 111 | } 112 | -------------------------------------------------------------------------------- /packages/rx-effects/src/effectState.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { Query } from './query'; 3 | 4 | export type EffectResult = Readonly<{ 5 | event: Event; 6 | result: Value; 7 | }>; 8 | 9 | export type EffectErrorOrigin = 'source' | 'handler'; 10 | 11 | export type EffectError = Readonly< 12 | | { 13 | origin: 'source'; 14 | event?: undefined; 15 | error: any; 16 | } 17 | | { 18 | origin: 'handler'; 19 | event: Event; 20 | error: ErrorType; 21 | } 22 | >; 23 | 24 | export type EffectNotification = Readonly< 25 | | ({ type: 'result' } & EffectResult) 26 | | ({ type: 'error' } & EffectError) 27 | >; 28 | 29 | /** 30 | * Details about performing the effect. 31 | */ 32 | export type EffectState = Readonly<{ 33 | /** Provides a result of successful execution of the handler */ 34 | result$: Observable; 35 | 36 | /** Provides a source event and a result of successful execution of the handler */ 37 | done$: Observable>; 38 | 39 | /** Provides an error emitter by a source (`event` is `undefined`) 40 | * or by the handler (`event` is not `undefined`) */ 41 | error$: Observable>; 42 | 43 | /** Provides a notification after execution of the handler for both success or error result */ 44 | final$: Observable>; 45 | 46 | /** Provides `true` if there is any execution of the handler in progress */ 47 | pending: Query; 48 | 49 | /** Provides a count of the handler in progress */ 50 | pendingCount: Query; 51 | }>; 52 | -------------------------------------------------------------------------------- /packages/rx-effects/src/index.ts: -------------------------------------------------------------------------------- 1 | export type { Action } from './action'; 2 | export { createAction } from './action'; 3 | 4 | export type { 5 | EffectError, 6 | EffectNotification, 7 | EffectState, 8 | EffectErrorOrigin, 9 | EffectResult, 10 | } from './effectState'; 11 | 12 | export { 13 | createEffectController, 14 | GLOBAL_EFFECT_UNHANDLED_ERROR$, 15 | } from './effectController'; 16 | export type { EffectController } from './effectController'; 17 | 18 | export type { 19 | Effect, 20 | EffectHandler, 21 | EffectOptions, 22 | EffectPipeline, 23 | EffectEventProject, 24 | } from './effect'; 25 | export { createEffect } from './effect'; 26 | 27 | export type { Scope, ExternalScope } from './scope'; 28 | export { createScope } from './scope'; 29 | 30 | export type { Controller } from './controller'; 31 | 32 | export * from './mvc'; 33 | 34 | export type { Query, QueryOptions } from './query'; 35 | export { mapQuery, mergeQueries } from './queryMappers'; 36 | 37 | export * from './store'; 38 | export { pipeStore, declareStoreWithUpdates } from './storeUtils'; 39 | export type { StoreEvent } from './storeEvents'; 40 | 41 | export type { StateMutationMetadata } from './storeMetadata'; 42 | 43 | export type { StoreExtension } from './storeExtensions'; 44 | export { registerStoreExtension } from './storeExtensions'; 45 | export { createStoreLoggerExtension } from './storeLoggerExtension'; 46 | 47 | export { OBJECT_COMPARATOR } from './utils'; 48 | 49 | export type { StoreDeclaration, DeclaredStoreFactory } from './declareStore'; 50 | export { declareStore } from './declareStore'; 51 | -------------------------------------------------------------------------------- /packages/rx-effects/src/mvc.test.ts: -------------------------------------------------------------------------------- 1 | import { createContainer, token } from 'ditox'; 2 | import { 3 | createController, 4 | declareController, 5 | declareViewController, 6 | InferredService, 7 | } from './mvc'; 8 | import { Query } from './query'; 9 | import { createStore } from './store'; 10 | 11 | describe('createController()', () => { 12 | it('should create a controller', () => { 13 | const onDestroy = jest.fn(); 14 | 15 | const controller = createController((scope) => { 16 | const store = scope.createStore(1); 17 | 18 | return { 19 | counter: store.asQuery(), 20 | increase: () => store.update((state) => state + 1), 21 | decrease: () => store.update((state) => state - 1), 22 | destroy: () => onDestroy(), 23 | }; 24 | }); 25 | 26 | const { counter, increase, decrease, destroy } = controller; 27 | expect(counter.get()).toBe(1); 28 | 29 | increase(); 30 | expect(counter.get()).toBe(2); 31 | 32 | decrease(); 33 | expect(counter.get()).toBe(1); 34 | 35 | destroy(); 36 | expect(onDestroy).toHaveBeenCalled(); 37 | }); 38 | 39 | it('should defined a scope which is destroyed after destroying a controller', () => { 40 | const onDestroy = jest.fn(); 41 | 42 | const controller = createController((scope) => { 43 | scope.add(() => onDestroy()); 44 | 45 | return {}; 46 | }); 47 | 48 | controller.destroy(); 49 | expect(onDestroy).toHaveBeenCalledTimes(1); 50 | }); 51 | 52 | it('should defined a scope which can be destroyed twice: by a controller and by a proxy of destroy() function', () => { 53 | const onControllerDestroy = jest.fn(); 54 | const onScopeDestroy = jest.fn(); 55 | 56 | const controller = createController((scope) => { 57 | scope.add(() => onScopeDestroy()); 58 | 59 | return { 60 | destroy() { 61 | onControllerDestroy(); 62 | scope.destroy(); 63 | }, 64 | }; 65 | }); 66 | 67 | controller.destroy(); 68 | expect(onControllerDestroy).toHaveBeenCalledTimes(1); 69 | expect(onScopeDestroy).toHaveBeenCalledTimes(1); 70 | }); 71 | }); 72 | 73 | describe('declareController()', () => { 74 | it('should create a factory which accepts a DI container, resolves dependencies and constructs a controller', () => { 75 | const VALUE_TOKEN = token(); 76 | 77 | const controllerFactory = declareController( 78 | { value: VALUE_TOKEN }, 79 | ({ value }) => ({ 80 | getValue: () => value * 10, 81 | }), 82 | ); 83 | 84 | const container = createContainer(); 85 | container.bindValue(VALUE_TOKEN, 1); 86 | 87 | const controller = controllerFactory(container); 88 | expect(controller.getValue()).toBe(10); 89 | 90 | // Check inferring of a service type 91 | type Service = InferredService; 92 | const service: Service = controller; 93 | expect(service.getValue()).toBe(10); 94 | }); 95 | }); 96 | 97 | describe('declareViewController()', () => { 98 | it('should create a factory which accepts a DI container, resolves dependencies and constructs a controller', () => { 99 | const VALUE_TOKEN = token(); 100 | 101 | const controllerFactory = declareViewController( 102 | { value: VALUE_TOKEN }, 103 | ({ value }) => ({ 104 | getValue: () => value * 10, 105 | }), 106 | ); 107 | 108 | const container = createContainer(); 109 | container.bindValue(VALUE_TOKEN, 1); 110 | 111 | const controller = controllerFactory(container); 112 | expect(controller.getValue()).toBe(10); 113 | 114 | // Check inferring of a service type 115 | type Service = InferredService; 116 | const service: Service = controller; 117 | expect(service.getValue()).toBe(10); 118 | }); 119 | 120 | it('should create a factory without DI dependencies', () => { 121 | const controllerFactory = declareViewController((scope) => { 122 | const $value = scope.createStore(10); 123 | 124 | return { 125 | getValue: () => $value.get(), 126 | }; 127 | }); 128 | 129 | const container = createContainer(); 130 | 131 | const controller = controllerFactory(container); 132 | expect(controller.getValue()).toBe(10); 133 | 134 | // Check inferring of a service type 135 | type Service = InferredService; 136 | const service: Service = controller; 137 | expect(service.getValue()).toBe(10); 138 | }); 139 | 140 | it('should create a factory which accepts resolved dependencies and parameters as Queries', () => { 141 | const VALUE_TOKEN = token(); 142 | 143 | const controllerFactory = declareViewController( 144 | { value: VALUE_TOKEN }, 145 | ({ value }) => 146 | (scope, arg: Query) => { 147 | const $value = scope.createStore(10); 148 | return { 149 | getValue: () => value * $value.get() + arg.get(), 150 | }; 151 | }, 152 | ); 153 | 154 | const container = createContainer(); 155 | container.bindValue(VALUE_TOKEN, 1); 156 | 157 | const controller = controllerFactory(container, createStore(2)); 158 | expect(controller.getValue()).toBe(12); 159 | 160 | // Check inferring of a service type 161 | type Service = InferredService; 162 | const service: Service = controller; 163 | expect(service.getValue()).toBe(12); 164 | }); 165 | }); 166 | -------------------------------------------------------------------------------- /packages/rx-effects/src/mvc.ts: -------------------------------------------------------------------------------- 1 | import { Container, injectable, Token } from 'ditox'; 2 | import { Controller } from './controller'; 3 | import { Query } from './query'; 4 | import { createScope, Scope } from './scope'; 5 | import { AnyObject } from './utils'; 6 | 7 | export function createController( 8 | factory: (scope: Scope) => Service & { destroy?: () => void }, 9 | ): Controller { 10 | const scope = createScope(); 11 | 12 | const controller = factory(scope); 13 | 14 | return { 15 | ...controller, 16 | 17 | destroy: () => { 18 | controller.destroy?.(); 19 | scope.destroy(); 20 | }, 21 | }; 22 | } 23 | 24 | export type ControllerFactory = ( 25 | container: Container, 26 | ) => Controller; 27 | 28 | declare type DependencyProps = { 29 | [key: string]: unknown; 30 | }; 31 | 32 | declare type TokenProps = { 33 | [K in keyof Props]: Token; 34 | }; 35 | 36 | export function declareController< 37 | Dependencies extends DependencyProps, 38 | Service extends AnyObject, 39 | >( 40 | tokens: TokenProps, 41 | factory: (deps: Dependencies, scope: Scope) => Service, 42 | ): ControllerFactory { 43 | return injectable( 44 | (deps) => createController((scope) => factory(deps as Dependencies, scope)), 45 | tokens, 46 | ); 47 | } 48 | 49 | export type ViewControllerFactory< 50 | Service extends AnyObject, 51 | Params extends Query[], 52 | > = (container: Container, ...params: Params) => Controller; 53 | 54 | export type InferredService = Factory extends ViewControllerFactory< 55 | infer Service, 56 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 57 | infer Params 58 | > 59 | ? Service 60 | : never; 61 | 62 | export function declareViewController< 63 | Service extends AnyObject, 64 | Params extends Query[], 65 | >( 66 | factory: (scope: Scope, ...params: Params) => Service, 67 | ): ViewControllerFactory; 68 | 69 | export function declareViewController< 70 | Dependencies extends DependencyProps, 71 | Service extends AnyObject, 72 | Params extends Query[], 73 | >( 74 | tokens: TokenProps, 75 | factory: ( 76 | deps: Dependencies, 77 | scope: Scope, 78 | ) => ((scope: Scope, ...params: Params) => Service) | Service, 79 | ): ViewControllerFactory; 80 | 81 | export function declareViewController< 82 | Dependencies extends DependencyProps, 83 | Service extends AnyObject, 84 | Params extends Query[], 85 | Factory extends (scope: Scope, ...params: Params) => Service, 86 | FactoryWithDependencies extends 87 | | ((deps: Dependencies, scope: Scope) => Service) 88 | | (( 89 | deps: Dependencies, 90 | scope: Scope, 91 | ) => (scope: Scope, ...params: Params) => Service), 92 | >( 93 | tokensOrFactory: TokenProps | Factory, 94 | factory?: FactoryWithDependencies, 95 | ): ViewControllerFactory { 96 | return (container: Container, ...params: Params) => { 97 | if (typeof tokensOrFactory === 'function') { 98 | return createController((scope) => { 99 | return tokensOrFactory(scope, ...params); 100 | }); 101 | } 102 | 103 | return injectable((dependencies) => { 104 | return createController((scope) => { 105 | const factoryValue = factory as FactoryWithDependencies; 106 | 107 | const result = factoryValue(dependencies as Dependencies, scope); 108 | 109 | if (typeof result === 'function') { 110 | return result(scope, ...params); 111 | } 112 | return result; 113 | }); 114 | }, tokensOrFactory)(container); 115 | }; 116 | } 117 | -------------------------------------------------------------------------------- /packages/rx-effects/src/query.test.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject, firstValueFrom } from 'rxjs'; 2 | import { Query } from './query'; 3 | import { mapQuery, mergeQueries } from './queryMappers'; 4 | import { createStore } from './store'; 5 | 6 | describe('mapQuery()', () => { 7 | it('should return a new state query with applied mapper which transforms the selected value', async () => { 8 | const sourceValue$ = new BehaviorSubject(0); 9 | const sourceQuery: Query = { 10 | get: () => sourceValue$.getValue(), 11 | value$: sourceValue$, 12 | }; 13 | 14 | const query = mapQuery(sourceQuery, (value) => value + 10); 15 | 16 | expect(query.get()).toBe(10); 17 | expect(await firstValueFrom(query.value$)).toBe(10); 18 | 19 | sourceValue$.next(1); 20 | expect(query.get()).toBe(11); 21 | expect(await firstValueFrom(query.value$)).toBe(11); 22 | }); 23 | 24 | it('should not produce values for each source emission if distinct is false', () => { 25 | const sourceValue$ = new BehaviorSubject(0); 26 | const sourceQuery: Query = { 27 | get: () => sourceValue$.getValue(), 28 | value$: sourceValue$, 29 | }; 30 | 31 | const query = mapQuery(sourceQuery, (value) => value, { distinct: false }); 32 | const listener = jest.fn(); 33 | query.value$.subscribe(listener); 34 | 35 | sourceValue$.next(1); 36 | sourceValue$.next(1); 37 | 38 | expect(listener).toHaveBeenCalledTimes(3); 39 | expect(listener).toHaveBeenNthCalledWith(1, 0); 40 | expect(listener).toHaveBeenNthCalledWith(2, 1); 41 | expect(listener).toHaveBeenNthCalledWith(3, 1); 42 | }); 43 | 44 | it('should produce distinct values by default', () => { 45 | const sourceValue$ = new BehaviorSubject(0); 46 | const sourceQuery: Query = { 47 | get: () => sourceValue$.getValue(), 48 | value$: sourceValue$, 49 | }; 50 | 51 | const query = mapQuery(sourceQuery, (value) => value); 52 | const listener = jest.fn(); 53 | query.value$.subscribe(listener); 54 | 55 | sourceValue$.next(1); 56 | sourceValue$.next(1); 57 | 58 | expect(listener).toHaveBeenCalledTimes(2); 59 | expect(listener).toHaveBeenNthCalledWith(1, 0); 60 | expect(listener).toHaveBeenNthCalledWith(2, 1); 61 | }); 62 | 63 | it('should produce distinct values when distinct = true', () => { 64 | const sourceValue$ = new BehaviorSubject(0); 65 | const sourceQuery: Query = { 66 | get: () => sourceValue$.getValue(), 67 | value$: sourceValue$, 68 | }; 69 | 70 | const query = mapQuery(sourceQuery, (value) => value, { distinct: true }); 71 | const listener = jest.fn(); 72 | query.value$.subscribe(listener); 73 | 74 | sourceValue$.next(1); 75 | sourceValue$.next(1); 76 | 77 | expect(listener).toHaveBeenCalledTimes(2); 78 | expect(listener).toHaveBeenNthCalledWith(1, 0); 79 | expect(listener).toHaveBeenNthCalledWith(2, 1); 80 | }); 81 | 82 | it('should produce distinct values with the custom comparator', () => { 83 | type Value = { v: number }; 84 | const sourceValue$ = new BehaviorSubject({ v: 0 }); 85 | const sourceQuery: Query = { 86 | get: () => sourceValue$.getValue(), 87 | value$: sourceValue$, 88 | }; 89 | 90 | const query = mapQuery(sourceQuery, (value) => value, { 91 | distinct: { comparator: (a, b) => a.v === b.v }, 92 | }); 93 | const listener = jest.fn(); 94 | query.value$.subscribe(listener); 95 | 96 | sourceValue$.next({ v: 1 }); 97 | sourceValue$.next({ v: 1 }); 98 | sourceValue$.next({ v: 2 }); 99 | 100 | expect(listener).toHaveBeenCalledTimes(3); 101 | expect(listener).toHaveBeenNthCalledWith(1, { v: 0 }); 102 | expect(listener).toHaveBeenNthCalledWith(2, { v: 1 }); 103 | expect(listener).toHaveBeenNthCalledWith(3, { v: 2 }); 104 | }); 105 | 106 | it('should produce distinct values with the custom keySelector', () => { 107 | type Value = { v: number }; 108 | const sourceValue$ = new BehaviorSubject({ v: 0 }); 109 | const sourceQuery: Query = { 110 | get: () => sourceValue$.getValue(), 111 | value$: sourceValue$, 112 | }; 113 | 114 | const query = mapQuery(sourceQuery, (value) => value, { 115 | distinct: { keySelector: (a) => a.v }, 116 | }); 117 | const listener = jest.fn(); 118 | query.value$.subscribe(listener); 119 | 120 | sourceValue$.next({ v: 1 }); 121 | sourceValue$.next({ v: 1 }); 122 | sourceValue$.next({ v: 2 }); 123 | 124 | expect(listener).toHaveBeenCalledTimes(3); 125 | expect(listener).toHaveBeenNthCalledWith(1, { v: 0 }); 126 | expect(listener).toHaveBeenNthCalledWith(2, { v: 1 }); 127 | expect(listener).toHaveBeenNthCalledWith(3, { v: 2 }); 128 | }); 129 | 130 | it('should return the same calculated value if there is a subscription and the source was not changed', async () => { 131 | const source = createStore(1); 132 | const result = mapQuery(source, (value) => ({ value })); 133 | 134 | expect(result.get() === result.get()).toBe(false); 135 | 136 | let obj3; 137 | let obj4; 138 | const subscription1 = result.value$.subscribe((value) => (obj3 = value)); 139 | const subscription2 = result.value$.subscribe((value) => (obj4 = value)); 140 | 141 | expect(obj3 === obj4).toBe(true); 142 | 143 | const obj1 = result.get(); 144 | const obj2 = result.get(); 145 | expect(obj1 === obj2).toBe(true); 146 | 147 | expect(obj1 === obj3).toBe(true); 148 | 149 | subscription1.unsubscribe(); 150 | expect(result.get() === obj1).toBe(true); 151 | 152 | subscription2.unsubscribe(); 153 | expect(result.get() === obj1).toBe(false); 154 | }); 155 | }); 156 | 157 | describe('mergeQueries()', () => { 158 | it('should return a calculated value from source queries', () => { 159 | const store1 = createStore(2); 160 | const store2 = createStore('text'); 161 | const query = mergeQueries([store1, store2], (a, b) => ({ a, b })); 162 | 163 | expect(query.get()).toEqual({ a: 2, b: 'text' }); 164 | 165 | store1.set(3); 166 | expect(query.get()).toEqual({ a: 3, b: 'text' }); 167 | 168 | store2.set('text2'); 169 | expect(query.get()).toEqual({ a: 3, b: 'text2' }); 170 | }); 171 | 172 | it('should return an observable with the calculated value from source queries', async () => { 173 | const store1 = createStore(2); 174 | const store2 = createStore('text'); 175 | const query = mergeQueries([store1, store2], (a, b) => ({ a, b })); 176 | 177 | expect(await firstValueFrom(query.value$)).toEqual({ a: 2, b: 'text' }); 178 | 179 | store1.set(3); 180 | expect(await firstValueFrom(query.value$)).toEqual({ a: 3, b: 'text' }); 181 | 182 | store2.set('text2'); 183 | expect(await firstValueFrom(query.value$)).toEqual({ a: 3, b: 'text2' }); 184 | }); 185 | 186 | it('should infer types for values of the queries', () => { 187 | const store1 = createStore(2); 188 | const store2 = createStore<{ value: number }>({ value: 3 }); 189 | 190 | const query: Query = mergeQueries( 191 | [store1, store2], 192 | (value, obj) => value + obj.value, 193 | ); 194 | 195 | expect(query.get()).toEqual(5); 196 | }); 197 | 198 | it('should produce values for each source emission if distinct is false', () => { 199 | const store1 = createStore(0); 200 | const store2 = createStore({ k: 0, value: 0 }); 201 | 202 | const query = mergeQueries( 203 | [store1, store2], 204 | (a, b) => ({ a, b: b.value }), 205 | { distinct: false }, 206 | ); 207 | const listener = jest.fn(); 208 | query.value$.subscribe(listener); 209 | 210 | store2.set({ k: 1, value: 0 }); 211 | store2.set({ k: 2, value: 1 }); 212 | 213 | expect(listener).toHaveBeenCalledTimes(3); 214 | expect(listener).toHaveBeenNthCalledWith(1, { a: 0, b: 0 }); 215 | expect(listener).toHaveBeenNthCalledWith(2, { a: 0, b: 0 }); 216 | expect(listener).toHaveBeenNthCalledWith(3, { a: 0, b: 1 }); 217 | }); 218 | 219 | it('should produce distinct values for each source emission by default', () => { 220 | const store1 = createStore(0); 221 | const store2 = createStore({ k: 0, value: 0 }); 222 | 223 | const query = mergeQueries([store1, store2], (a, b) => a + b.value); 224 | const listener = jest.fn(); 225 | query.value$.subscribe(listener); 226 | 227 | store2.set({ k: 1, value: 0 }); 228 | store2.set({ k: 2, value: 1 }); 229 | 230 | expect(listener).toHaveBeenCalledTimes(2); 231 | expect(listener).toHaveBeenNthCalledWith(1, 0); 232 | expect(listener).toHaveBeenNthCalledWith(2, 1); 233 | }); 234 | 235 | it('should produce distinct values with the custom comparator', () => { 236 | const store1 = createStore(0); 237 | const store2 = createStore({ k: 0, value: 0 }); 238 | 239 | const query = mergeQueries( 240 | [store1, store2], 241 | (a, b) => ({ a, b: b.value }), 242 | { distinct: { comparator: (a, b) => a.a === b.a } }, 243 | ); 244 | const listener = jest.fn(); 245 | query.value$.subscribe(listener); 246 | 247 | store2.set({ k: 1, value: 1 }); 248 | store2.set({ k: 2, value: 2 }); 249 | store1.set(1); 250 | 251 | expect(listener).toHaveBeenCalledTimes(2); 252 | expect(listener).toHaveBeenNthCalledWith(1, { a: 0, b: 0 }); 253 | expect(listener).toHaveBeenNthCalledWith(2, { a: 1, b: 2 }); 254 | }); 255 | 256 | it('should produce distinct values with the custom keySelector', () => { 257 | const store1 = createStore(0); 258 | const store2 = createStore({ k: 0, value: 0 }); 259 | 260 | const query = mergeQueries( 261 | [store1, store2], 262 | (a, b) => ({ a, b: b.value }), 263 | { distinct: { keySelector: (a) => a.a } }, 264 | ); 265 | const listener = jest.fn(); 266 | query.value$.subscribe(listener); 267 | 268 | store2.set({ k: 1, value: 1 }); 269 | store2.set({ k: 2, value: 2 }); 270 | store1.set(1); 271 | 272 | expect(listener).toHaveBeenCalledTimes(2); 273 | expect(listener).toHaveBeenNthCalledWith(1, { a: 0, b: 0 }); 274 | expect(listener).toHaveBeenNthCalledWith(2, { a: 1, b: 2 }); 275 | }); 276 | 277 | it('should return the same calculated value if there is a subscription and the source was not changed', async () => { 278 | const source1 = createStore(1); 279 | const source2 = createStore(2); 280 | const result = mergeQueries([source1, source2], (value1, value2) => ({ 281 | value: value1 + value2, 282 | })); 283 | 284 | expect(result.get() === result.get()).toBe(false); 285 | 286 | let obj3; 287 | let obj4; 288 | const subscription1 = result.value$.subscribe((value) => (obj3 = value)); 289 | const subscription2 = result.value$.subscribe((value) => (obj4 = value)); 290 | 291 | expect(obj3 === obj4).toBe(true); 292 | 293 | const obj1 = result.get(); 294 | const obj2 = result.get(); 295 | expect(obj1 === obj2).toBe(true); 296 | 297 | expect(obj1 === obj3).toBe(true); 298 | 299 | subscription1.unsubscribe(); 300 | expect(result.get() === obj1).toBe(true); 301 | 302 | subscription2.unsubscribe(); 303 | expect(result.get() === obj1).toBe(false); 304 | }); 305 | }); 306 | -------------------------------------------------------------------------------- /packages/rx-effects/src/query.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | 3 | /** 4 | * Provider for a value of a state. 5 | */ 6 | export type Query = Readonly<{ 7 | /** Returns the value of a state */ 8 | get: () => T; 9 | 10 | /** `Observable` for value changes. */ 11 | value$: Observable; 12 | }>; 13 | 14 | /** 15 | * Options for processing the query result 16 | * 17 | * @property distinct Enables distinct results 18 | * @property distinct.comparator Custom comparator for values. Strict equality `===` is used by default. 19 | * @property distinct.keySelector Getter for keys of values to compare. Values itself are used for comparing by default. 20 | */ 21 | export type QueryOptions = Readonly<{ 22 | distinct?: 23 | | boolean 24 | | { 25 | comparator?: (previous: K, current: K) => boolean; 26 | keySelector?: (value: T) => K; 27 | }; 28 | }>; 29 | -------------------------------------------------------------------------------- /packages/rx-effects/src/queryMappers.ts: -------------------------------------------------------------------------------- 1 | import { combineLatest, identity, Observable, shareReplay } from 'rxjs'; 2 | import { distinctUntilChanged, map, tap } from 'rxjs/operators'; 3 | import { DEFAULT_COMPARATOR } from './utils'; 4 | import { Query, QueryOptions } from './query'; 5 | 6 | /** 7 | * Creates a new `Query` which maps a source value by the provided mapping 8 | * function. 9 | * 10 | * @param query source query 11 | * @param mapper value mapper 12 | * @param options options for processing the result value 13 | */ 14 | export function mapQuery( 15 | query: Query, 16 | mapper: (value: T) => R, 17 | options?: QueryOptions, 18 | ): Query { 19 | const { shareReplayWithRef, buffer } = createShareReplayWithRef(); 20 | 21 | let value$ = query.value$.pipe(map(mapper)); 22 | value$ = distinctValue(value$, options?.distinct).pipe(shareReplayWithRef); 23 | 24 | function get(): R { 25 | return buffer.ref ? buffer.ref.value : mapper(query.get()); 26 | } 27 | 28 | return { get, value$ }; 29 | } 30 | 31 | /** 32 | * Creates a new `Query` which takes the latest values from source queries 33 | * and merges them into a single value. 34 | * 35 | * @param queries source queries 36 | * @param merger value merger 37 | * @param options options for processing the result value 38 | */ 39 | export function mergeQueries< 40 | Values extends unknown[], 41 | Result, 42 | ResultKey = Result, 43 | >( 44 | queries: [ 45 | ...{ 46 | [K in keyof Values]: Query; 47 | }, 48 | ], 49 | merger: (...values: Values) => Result, 50 | options?: QueryOptions, 51 | ): Query { 52 | const { shareReplayWithRef, buffer } = createShareReplayWithRef(); 53 | 54 | let value$ = combineLatest(queries.map((query) => query.value$)).pipe( 55 | map((values) => merger(...(values as Values))), 56 | ); 57 | 58 | value$ = distinctValue(value$, options?.distinct).pipe(shareReplayWithRef); 59 | 60 | function get(): Result { 61 | if (buffer.ref) { 62 | return buffer.ref.value; 63 | } 64 | 65 | return merger(...(queries.map((query) => query.get()) as Values)); 66 | } 67 | 68 | return { get, value$ }; 69 | } 70 | 71 | function distinctValue( 72 | value$: Observable, 73 | distinct: QueryOptions['distinct'], 74 | ): Observable { 75 | if (distinct === false) { 76 | return value$; 77 | } 78 | 79 | const comparator = 80 | (distinct === true ? undefined : distinct?.comparator) ?? 81 | DEFAULT_COMPARATOR; 82 | 83 | const keySelector = 84 | (distinct === true ? undefined : distinct?.keySelector) ?? 85 | (identity as (value: T) => K); 86 | 87 | return value$.pipe(distinctUntilChanged(comparator, keySelector)); 88 | } 89 | 90 | function createShareReplayWithRef() { 91 | const buffer: { ref?: { value: T } | undefined } = {}; 92 | 93 | const shareReplayWithRef = (source$: Observable) => 94 | source$.pipe( 95 | tap({ 96 | next: (value) => (buffer.ref = { value }), 97 | unsubscribe: () => (buffer.ref = undefined), 98 | }), 99 | shareReplay({ bufferSize: 1, refCount: true }), 100 | ); 101 | 102 | return { shareReplayWithRef, buffer }; 103 | } 104 | -------------------------------------------------------------------------------- /packages/rx-effects/src/scope.test.ts: -------------------------------------------------------------------------------- 1 | import { firstValueFrom, materialize, Subject, toArray } from 'rxjs'; 2 | import { createAction } from './action'; 3 | import { createScope } from './scope'; 4 | import { createStore } from './store'; 5 | 6 | describe('Scope', () => { 7 | describe('destroy()', () => { 8 | it('should unsubscribe all collected subscriptions', () => { 9 | const scope = createScope(); 10 | const teardown = jest.fn(); 11 | 12 | scope.add(teardown); 13 | scope.destroy(); 14 | 15 | expect(teardown).toHaveBeenCalledTimes(1); 16 | }); 17 | }); 18 | 19 | describe('handleAction()', () => { 20 | it('should be able to unsubscribe the created effect from the action', async () => { 21 | const scope = createScope(); 22 | 23 | const action = createAction(); 24 | const handler = jest.fn((value) => value * 3); 25 | 26 | const effect = scope.handle(action, handler); 27 | scope.destroy(); 28 | 29 | const resultPromise = firstValueFrom(effect.result$.pipe(materialize())); 30 | action(2); 31 | 32 | expect(await resultPromise).toEqual({ hasValue: false, kind: 'C' }); 33 | }); 34 | }); 35 | 36 | describe('createEffect()', () => { 37 | it('should be able to unsubscribe the created effect from the action', async () => { 38 | const scope = createScope(); 39 | 40 | const action = createAction(); 41 | const handler = jest.fn((value) => value * 3); 42 | 43 | const effect = scope.createEffect(handler); 44 | effect.handle(action); 45 | scope.destroy(); 46 | 47 | const resultPromise = firstValueFrom(effect.result$.pipe(materialize())); 48 | action(2); 49 | 50 | expect(await resultPromise).toEqual({ hasValue: false, kind: 'C' }); 51 | }); 52 | }); 53 | 54 | describe('createController()', () => { 55 | it('should be able to unsubscribe the created controller', async () => { 56 | const scope = createScope(); 57 | 58 | const destroy = jest.fn(); 59 | scope.createController(() => ({ destroy })); 60 | 61 | scope.destroy(); 62 | expect(destroy).toHaveBeenCalledTimes(1); 63 | }); 64 | }); 65 | 66 | describe('createStore()', () => { 67 | it('should be able to unsubscribe the created store', async () => { 68 | const scope = createScope(); 69 | 70 | const store = scope.createStore(1); 71 | const valuePromise = firstValueFrom(store.value$.pipe(toArray())); 72 | 73 | store.set(2); 74 | scope.destroy(); 75 | store.set(3); 76 | expect(await valuePromise).toEqual([1, 2]); 77 | }); 78 | }); 79 | 80 | describe('handleQuery()', () => { 81 | it('should be able to unsubscribe the created effect from the query', async () => { 82 | const store = createStore(1); 83 | 84 | const scope = createScope(); 85 | 86 | const handler = jest.fn((value) => value * 3); 87 | 88 | const effect = scope.handle(store, handler); 89 | 90 | const resultPromise = firstValueFrom( 91 | effect.result$.pipe(materialize(), toArray()), 92 | ); 93 | store.set(2); 94 | 95 | scope.destroy(); 96 | store.set(3); 97 | 98 | expect(await resultPromise).toEqual([ 99 | { hasValue: true, kind: 'N', value: 6 }, 100 | { hasValue: false, kind: 'C' }, 101 | ]); 102 | }); 103 | }); 104 | 105 | describe('subscribe()', () => { 106 | it('should be able to unsubscribe the created subscription from the observable', async () => { 107 | const subject = new Subject(); 108 | 109 | const scope = createScope(); 110 | 111 | const handler = jest.fn((value) => value * 3); 112 | scope.subscribe(subject, handler); 113 | 114 | subject.next(2); 115 | 116 | scope.destroy(); 117 | subject.next(3); 118 | 119 | expect(handler).toHaveBeenCalledTimes(1); 120 | expect(handler).toHaveBeenLastCalledWith(2); 121 | expect(handler).toHaveLastReturnedWith(6); 122 | }); 123 | 124 | it('should be subscribe an observer', async () => { 125 | const subject = new Subject(); 126 | 127 | const scope = createScope(); 128 | 129 | const handler = jest.fn((value) => value * 3); 130 | scope.subscribe(subject, { 131 | next: handler, 132 | }); 133 | 134 | subject.next(2); 135 | 136 | scope.destroy(); 137 | subject.next(3); 138 | 139 | expect(handler).toHaveBeenCalledTimes(1); 140 | expect(handler).toHaveBeenLastCalledWith(2); 141 | expect(handler).toHaveLastReturnedWith(6); 142 | }); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /packages/rx-effects/src/scope.ts: -------------------------------------------------------------------------------- 1 | import { Observable, Observer, Subscription, TeardownLogic } from 'rxjs'; 2 | import { Action } from './action'; 3 | import { Controller } from './controller'; 4 | import { createEffect, Effect, EffectHandler, EffectOptions } from './effect'; 5 | import { Query } from './query'; 6 | import { createStore, Store, StoreOptions } from './store'; 7 | import { AnyObject } from './utils'; 8 | 9 | /** 10 | * A controller-like boundary for effects and business logic. 11 | * 12 | * `Scope` collects all subscriptions which are made by child entities and provides 13 | * `destroy()` method to unsubscribe from them. 14 | */ 15 | export type Scope = Controller<{ 16 | /** 17 | * Register subscription-like or teardown function to be called with 18 | * `destroy()` method. 19 | */ 20 | add: (teardown: TeardownLogic) => void; 21 | 22 | /** 23 | * Creates a store which will be destroyed with the scope. 24 | * 25 | * @param initialState Initial state 26 | * @param options Parameters for the store 27 | */ 28 | createStore( 29 | initialState: State, 30 | options?: StoreOptions, 31 | ): Store; 32 | 33 | /** 34 | * Creates a controller which will be destroyed with the scope. 35 | */ 36 | createController: ( 37 | factory: () => Controller, 38 | ) => Controller; 39 | 40 | /** 41 | * Creates an effect which will be destroyed with the scope. 42 | */ 43 | createEffect: ( 44 | handler: EffectHandler, 45 | options?: EffectOptions, 46 | ) => Effect; 47 | 48 | /** 49 | * Creates an effect which handles `source` by `handler`, and it will be 50 | * destroyed with the scope. 51 | */ 52 | handle: ( 53 | source: Observable | Action | Query, 54 | handler: EffectHandler, 55 | options?: EffectOptions, 56 | ) => Effect; 57 | 58 | /** 59 | * Subscribes to the `source` observable until the scope will be destroyed. 60 | */ 61 | subscribe: { 62 | (source: Observable): Subscription; 63 | (source: Observable, next: (value: T) => void): Subscription; 64 | (source: Observable, observer: Partial>): Subscription; 65 | }; 66 | }>; 67 | 68 | /** 69 | * `ExternalScope` and `Scope` types allow to distinct which third-party code can invoke `destroy()` method. 70 | */ 71 | export type ExternalScope = Omit; 72 | 73 | /** 74 | * Creates `Scope` instance. 75 | */ 76 | export function createScope(): Scope { 77 | const subscriptions = new Subscription(); 78 | 79 | function registerTeardown(teardown: TeardownLogic): void { 80 | subscriptions.add(teardown); 81 | } 82 | 83 | function destroy(): void { 84 | subscriptions.unsubscribe(); 85 | } 86 | 87 | function createController( 88 | factory: () => Controller, 89 | ) { 90 | const controller = factory(); 91 | registerTeardown(controller.destroy); 92 | 93 | return controller; 94 | } 95 | 96 | return { 97 | add: registerTeardown, 98 | destroy, 99 | 100 | createController, 101 | 102 | createStore( 103 | initialState: State, 104 | options?: StoreOptions, 105 | ): Store { 106 | return createController(() => createStore(initialState, options)); 107 | }, 108 | 109 | createEffect( 110 | handler: EffectHandler, 111 | options?: EffectOptions, 112 | ) { 113 | return createController(() => 114 | createEffect(handler, options), 115 | ); 116 | }, 117 | 118 | handle( 119 | source: Observable | Action | Query, 120 | handler: EffectHandler, 121 | options?: EffectOptions, 122 | ) { 123 | const effect = createController(() => 124 | createEffect(handler, options), 125 | ); 126 | 127 | effect.handle(source); 128 | 129 | return effect; 130 | }, 131 | 132 | subscribe( 133 | source: Observable, 134 | nextOrObserver?: Partial> | ((value: T) => unknown), 135 | ): Subscription { 136 | const subscription = source.subscribe(nextOrObserver); 137 | 138 | registerTeardown(subscription); 139 | 140 | return subscription; 141 | }, 142 | }; 143 | } 144 | -------------------------------------------------------------------------------- /packages/rx-effects/src/store.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject, Observable } from 'rxjs'; 2 | import { Controller } from './controller'; 3 | import { Query, QueryOptions } from './query'; 4 | import { mapQuery } from './queryMappers'; 5 | import { STORE_EVENT_BUS } from './storeEvents'; 6 | import { setInternalStoreFlag, setStateMutationName } from './storeMetadata'; 7 | import { DEFAULT_COMPARATOR, isReadonlyArray } from './utils'; 8 | 9 | let STORE_SEQ_NUMBER = 0; 10 | 11 | /** 12 | * A function to update a state. 13 | * 14 | * It is recommended to return a new state or the previous one. 15 | * 16 | * Actually, the function can change the state in place, but it is responsible 17 | * for a developer to provide `comparator` function to the store which handles 18 | * the changes. 19 | * 20 | * For making changes use a currying function to provide arguments: 21 | * ```ts 22 | * const addPizzaToCart = (name: string): StateMutation> => 23 | * (state) => ([...state, name]); 24 | * ``` 25 | * 26 | * @param state a previous state 27 | * @returns a next state 28 | */ 29 | export type StateMutation = (state: State) => State; 30 | 31 | /** 32 | * A record of factories which create state mutations. 33 | */ 34 | export type StateUpdates = Readonly< 35 | Record StateMutation> 36 | >; 37 | 38 | /** 39 | * Declare a record of factories for creating state mutations. 40 | */ 41 | export function declareStateUpdates(): < 42 | Updates extends StateUpdates = StateUpdates, 43 | >( 44 | updates: Updates, 45 | ) => Updates; 46 | 47 | /** 48 | * Declare a record of factories for creating state mutations. 49 | */ 50 | export function declareStateUpdates< 51 | State, 52 | Updates extends StateUpdates = StateUpdates, 53 | >(stateExample: State, updates: Updates): Updates; 54 | 55 | export function declareStateUpdates< 56 | State, 57 | Updates extends StateUpdates = StateUpdates, 58 | >( 59 | stateExample?: State, 60 | updates?: Updates, 61 | ): 62 | | Updates 63 | | ( = StateUpdates>( 64 | updates: Updates, 65 | ) => Updates) { 66 | if (updates) { 67 | return updates; 68 | } 69 | 70 | return (updates) => updates; 71 | } 72 | 73 | /** 74 | * Returns a mutation which applies all provided mutations for a state. 75 | * 76 | * You can use this helper to apply multiple changes at the same time. 77 | */ 78 | export function pipeStateMutations( 79 | mutations: ReadonlyArray>, 80 | ): StateMutation { 81 | return (state) => 82 | mutations.reduce((nextState, mutation) => mutation(nextState), state); 83 | } 84 | 85 | /** 86 | * Read-only interface of a store. 87 | */ 88 | export type StoreQuery = Readonly< 89 | Query & { 90 | /** 91 | * Returns a part of the state as `Observable` 92 | * The result observable produces distinct values by default. 93 | * 94 | * @example 95 | * ```ts 96 | * const state: StateReader<{form: {login: 'foo'}}> = // ... 97 | * const value$ = state.select((state) => state.form.login); 98 | * ``` 99 | */ 100 | select: ( 101 | selector: (state: State) => R, 102 | options?: QueryOptions, 103 | ) => Observable; 104 | 105 | /** 106 | * Returns a part of the state as `Query`. 107 | * The result query produces distinct values by default. 108 | * 109 | * @example 110 | * ```ts 111 | * const state: StateReader<{form: {login: 'foo'}}> = // ... 112 | * const query = state.query((state) => state.form.login); 113 | * ``` 114 | * */ 115 | query: ( 116 | selector: (state: State) => R, 117 | options?: QueryOptions, 118 | ) => Query; 119 | 120 | /** 121 | * Cast the store to a narrowed `Query` type. 122 | */ 123 | asQuery: () => Query; 124 | } 125 | >; 126 | 127 | /** 128 | * @internal 129 | * Updates the state by provided mutations 130 | * */ 131 | export type StoreUpdateFunction = ( 132 | mutation: 133 | | StateMutation 134 | | ReadonlyArray | undefined | null | false>, 135 | ) => void; 136 | 137 | /** Function which changes a state of the store */ 138 | export type StoreUpdate = (...args: Args) => void; 139 | 140 | /** Record of store update functions */ 141 | export type StoreUpdates< 142 | State, 143 | Updates extends StateUpdates, 144 | > = Readonly<{ 145 | [K in keyof Updates]: StoreUpdate>; 146 | }>; 147 | 148 | /** 149 | * Store of a state 150 | */ 151 | export type Store = Controller< 152 | StoreQuery & { 153 | id: number; 154 | name?: string; 155 | 156 | /** Sets a new state to the store */ 157 | set: (state: State) => void; 158 | 159 | /** Updates the store by provided mutations */ 160 | update: StoreUpdateFunction; 161 | } 162 | >; 163 | 164 | /** Store of a state with updating functions */ 165 | export type StoreWithUpdates< 166 | State, 167 | Updates extends StateUpdates, 168 | > = Readonly & { updates: StoreUpdates }>; 169 | 170 | type StateMutationQueue = ReadonlyArray< 171 | StateMutation | undefined | null | false 172 | >; 173 | 174 | export type StoreOptions = Readonly<{ 175 | name?: string; 176 | 177 | /** A comparator for detecting changes between old and new states */ 178 | comparator?: (prevState: State, nextState: State) => boolean; 179 | 180 | /** Callback is called when the store is destroyed */ 181 | onDestroy?: () => void; 182 | }>; 183 | 184 | /** @internal */ 185 | export type InternalStoreOptions = Readonly< 186 | StoreOptions & { internal?: boolean } 187 | >; 188 | 189 | /** 190 | * Creates the state store. 191 | * 192 | * @param initialState Initial state 193 | * @param options Parameters for the store 194 | */ 195 | export function createStore( 196 | initialState: State, 197 | options?: StoreOptions, 198 | ): Store { 199 | const stateComparator = options?.comparator ?? DEFAULT_COMPARATOR; 200 | 201 | const store$: BehaviorSubject = new BehaviorSubject(initialState); 202 | const state$ = store$.asObservable(); 203 | 204 | let isUpdating = false; 205 | let pendingMutations: StateMutationQueue | undefined; 206 | 207 | const store: Store = { 208 | id: ++STORE_SEQ_NUMBER, 209 | name: options?.name, 210 | 211 | value$: state$, 212 | 213 | get(): State { 214 | return store$.value; 215 | }, 216 | 217 | select( 218 | selector: (state: State) => R, 219 | options?: QueryOptions, 220 | ): Observable { 221 | return this.query(selector, options).value$; 222 | }, 223 | 224 | query( 225 | selector: (state: State) => R, 226 | options?: QueryOptions, 227 | ): Query { 228 | return mapQuery(this, selector, options); 229 | }, 230 | 231 | asQuery: () => store, 232 | 233 | set(nextState: State) { 234 | apply([() => nextState]); 235 | }, 236 | 237 | update, 238 | 239 | destroy() { 240 | store$.complete(); 241 | STORE_EVENT_BUS.next({ type: 'destroyed', store }); 242 | options?.onDestroy?.(); 243 | }, 244 | }; 245 | 246 | function update( 247 | mutation: 248 | | StateMutation 249 | | ReadonlyArray | undefined | null | false>, 250 | ) { 251 | if (isReadonlyArray(mutation)) { 252 | apply(mutation); 253 | } else { 254 | apply([mutation]); 255 | } 256 | } 257 | 258 | function apply(mutations: StateMutationQueue) { 259 | if (isUpdating) { 260 | pendingMutations = (pendingMutations ?? []).concat(mutations); 261 | return; 262 | } 263 | 264 | const prevState = store$.value; 265 | 266 | let nextState = prevState; 267 | 268 | for (let i = 0; i < mutations.length; i++) { 269 | const mutation = mutations[i]; 270 | if (mutation) { 271 | const stateBeforeMutation = nextState; 272 | nextState = mutation(nextState); 273 | 274 | STORE_EVENT_BUS.next({ 275 | type: 'mutation', 276 | store, 277 | mutation, 278 | nextState, 279 | prevState: stateBeforeMutation, 280 | }); 281 | } 282 | } 283 | 284 | if (stateComparator(prevState, nextState)) { 285 | return; 286 | } 287 | 288 | isUpdating = true; 289 | store$.next(nextState); 290 | STORE_EVENT_BUS.next({ 291 | type: 'updated', 292 | store, 293 | nextState, 294 | prevState, 295 | }); 296 | isUpdating = false; 297 | 298 | if (pendingMutations?.length) { 299 | const mutationsToApply = pendingMutations; 300 | pendingMutations = []; 301 | apply(mutationsToApply); 302 | } 303 | } 304 | 305 | if ((options as InternalStoreOptions | undefined)?.internal) { 306 | setInternalStoreFlag(store); 307 | } 308 | 309 | STORE_EVENT_BUS.next({ type: 'created', store }); 310 | 311 | return store; 312 | } 313 | 314 | /** Creates StateUpdates for updating the store by provided state mutations */ 315 | export function createStoreUpdates>( 316 | storeUpdate: Store['update'], 317 | stateUpdates: Updates, 318 | ): StoreUpdates { 319 | const updates: any = {}; 320 | 321 | Object.entries(stateUpdates).forEach(([key, mutationFactory]) => { 322 | (updates as any)[key] = (...args: any[]) => { 323 | const mutation = mutationFactory(...args); 324 | setStateMutationName(mutation, key); 325 | 326 | storeUpdate(mutation); 327 | }; 328 | }); 329 | 330 | return updates; 331 | } 332 | 333 | /** Creates a proxy for the store with "updates" to change a state by provided mutations */ 334 | export function withStoreUpdates< 335 | State, 336 | Updates extends StateUpdates = StateUpdates, 337 | >(store: Store, updates: Updates): StoreWithUpdates { 338 | const storeUpdates: StoreUpdates = createStoreUpdates< 339 | State, 340 | Updates 341 | >(store.update, updates); 342 | 343 | return { ...store, updates: storeUpdates }; 344 | } 345 | -------------------------------------------------------------------------------- /packages/rx-effects/src/storeEvents.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs'; 2 | import { StateMutation, Store } from './store'; 3 | 4 | export type StoreEvent = 5 | | { 6 | type: 'created'; 7 | store: Store; 8 | } 9 | | { 10 | type: 'destroyed'; 11 | store: Store; 12 | } 13 | | { 14 | type: 'mutation'; 15 | store: Store; 16 | mutation: StateMutation; 17 | nextState: State; 18 | prevState: State; 19 | } 20 | | { 21 | type: 'updated'; 22 | store: Store; 23 | nextState: State; 24 | prevState: State; 25 | }; 26 | 27 | /** @internal */ 28 | export const STORE_EVENT_BUS = new Subject>(); 29 | -------------------------------------------------------------------------------- /packages/rx-effects/src/storeExtensions.test.ts: -------------------------------------------------------------------------------- 1 | import { createStore, InternalStoreOptions } from './store'; 2 | import { registerStoreExtension } from './storeExtensions'; 3 | 4 | describe('registerStoreExtension()', () => { 5 | it('should register an extension', () => { 6 | const eventHandler = jest.fn(); 7 | 8 | registerStoreExtension(() => ({ 9 | onStoreEvent: eventHandler, 10 | })); 11 | 12 | createStore(0, { name: 'test' }); 13 | 14 | expect(eventHandler).toHaveBeenNthCalledWith(1, { 15 | type: 'created', 16 | store: expect.objectContaining({ name: 'test' }), 17 | }); 18 | }); 19 | 20 | it('should register an empty extension', () => { 21 | registerStoreExtension(() => ({})); 22 | createStore(0, { name: 'test' }); 23 | expect.assertions(0); 24 | }); 25 | 26 | it('should not emit events from internal stores', () => { 27 | const eventHandler = jest.fn(); 28 | 29 | registerStoreExtension(() => ({ 30 | onStoreEvent: eventHandler, 31 | })); 32 | 33 | createStore(0, { 34 | name: 'test', 35 | internal: true, 36 | } as InternalStoreOptions); 37 | 38 | expect(eventHandler).toHaveBeenCalledTimes(0); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /packages/rx-effects/src/storeExtensions.ts: -------------------------------------------------------------------------------- 1 | import { Subscription } from 'rxjs'; 2 | import { STORE_EVENT_BUS, StoreEvent } from './storeEvents'; 3 | import { getStateMutationMetadata, isInternalStore } from './storeMetadata'; 4 | 5 | export type StoreExtensionApi = Readonly<{ 6 | getStateMutationMetadata: typeof getStateMutationMetadata; 7 | }>; 8 | 9 | export type StoreExtension = (api: StoreExtensionApi) => { 10 | onStoreEvent?: (event: StoreEvent) => void; 11 | }; 12 | 13 | export function registerStoreExtension( 14 | extension: StoreExtension, 15 | ): Subscription { 16 | const api: StoreExtensionApi = { 17 | getStateMutationMetadata, 18 | }; 19 | 20 | const middleware = extension(api); 21 | 22 | return STORE_EVENT_BUS.subscribe((event) => { 23 | if (middleware.onStoreEvent && !isInternalStore(event.store)) { 24 | middleware.onStoreEvent(event); 25 | } 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /packages/rx-effects/src/storeLoggerExtension.test.ts: -------------------------------------------------------------------------------- 1 | import { createAction } from './action'; 2 | import { createScope } from './scope'; 3 | import { createStore, createStoreUpdates, StateMutation } from './store'; 4 | import { registerStoreExtension } from './storeExtensions'; 5 | import { createStoreLoggerExtension } from './storeLoggerExtension'; 6 | 7 | describe('createStoreLoggerExtension()', () => { 8 | it('should log store events', () => { 9 | const logger = jest.fn(); 10 | 11 | registerStoreExtension(createStoreLoggerExtension(logger)); 12 | 13 | const increment: StateMutation = (state) => state + 1; 14 | 15 | const store = createStore(0, { name: 'test' }); 16 | const updates = createStoreUpdates(store.update, { 17 | increment: () => increment, 18 | }); 19 | 20 | store.set(1); 21 | store.update(increment); 22 | updates.increment(); 23 | store.destroy(); 24 | 25 | expect(logger).toHaveBeenNthCalledWith(1, 'test#1', 'created'); 26 | 27 | expect(logger).toHaveBeenNthCalledWith( 28 | 2, 29 | 'test#1', 30 | 'mutation', 31 | 'anonymous', 32 | { 33 | nextState: 1, 34 | prevState: 0, 35 | }, 36 | ); 37 | expect(logger).toHaveBeenNthCalledWith(3, 'test#1', 'updated', { 38 | nextState: 1, 39 | prevState: 0, 40 | }); 41 | 42 | expect(logger).toHaveBeenNthCalledWith( 43 | 4, 44 | 'test#1', 45 | 'mutation', 46 | 'anonymous', 47 | { 48 | nextState: 2, 49 | prevState: 1, 50 | }, 51 | ); 52 | expect(logger).toHaveBeenNthCalledWith(5, 'test#1', 'updated', { 53 | nextState: 2, 54 | prevState: 1, 55 | }); 56 | 57 | expect(logger).toHaveBeenNthCalledWith( 58 | 6, 59 | 'test#1', 60 | 'mutation', 61 | 'increment', 62 | { 63 | nextState: 3, 64 | prevState: 2, 65 | }, 66 | ); 67 | expect(logger).toHaveBeenNthCalledWith(7, 'test#1', 'updated', { 68 | nextState: 3, 69 | prevState: 2, 70 | }); 71 | 72 | expect(logger).toHaveBeenNthCalledWith(8, 'test#1', 'destroyed'); 73 | }); 74 | 75 | it('should log only store ID in case its name is empty', () => { 76 | const logger = jest.fn(); 77 | 78 | registerStoreExtension(createStoreLoggerExtension(logger)); 79 | 80 | createStore(0); 81 | expect(logger).toHaveBeenNthCalledWith( 82 | 1, 83 | expect.stringMatching(/^#\d/), 84 | 'created', 85 | ); 86 | }); 87 | 88 | it('should not log events from internal stores', () => { 89 | const logger = jest.fn(); 90 | 91 | registerStoreExtension(createStoreLoggerExtension(logger)); 92 | 93 | const scope = createScope(); 94 | const action = createAction(); 95 | scope.handle(action, () => { 96 | // Do nothing 97 | }); 98 | action(1); 99 | 100 | expect(logger).toHaveBeenCalledTimes(0); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /packages/rx-effects/src/storeLoggerExtension.ts: -------------------------------------------------------------------------------- 1 | import { StoreExtension } from './storeExtensions'; 2 | 3 | export function createStoreLoggerExtension( 4 | logger: typeof console.log, 5 | ): StoreExtension { 6 | return (api) => ({ 7 | onStoreEvent(event) { 8 | const { type, store } = event; 9 | 10 | const storeName = `${store.name ?? ''}#${store.id}`; 11 | 12 | switch (type) { 13 | case 'created': 14 | case 'destroyed': { 15 | logger(storeName, type); 16 | break; 17 | } 18 | 19 | case 'updated': { 20 | logger(storeName, type, { 21 | nextState: event.nextState, 22 | prevState: event.prevState, 23 | }); 24 | break; 25 | } 26 | 27 | case 'mutation': { 28 | const mutationName = 29 | api.getStateMutationMetadata(event.mutation).name ?? 'anonymous'; 30 | 31 | logger(storeName, type, mutationName, { 32 | nextState: event.nextState, 33 | prevState: event.prevState, 34 | }); 35 | } 36 | } 37 | }, 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /packages/rx-effects/src/storeMetadata.ts: -------------------------------------------------------------------------------- 1 | import { StateMutation, Store } from './store'; 2 | 3 | const MUTATION_NAME_SYMBOL = Symbol(); 4 | const INTERNAL_STORE_SYMBOL = Symbol(); 5 | 6 | export type StateMutationMetadata = Readonly<{ 7 | name?: string; 8 | }>; 9 | 10 | export function getStateMutationMetadata( 11 | mutation: StateMutation, 12 | ): StateMutationMetadata { 13 | const name = (mutation as any)[MUTATION_NAME_SYMBOL]; 14 | 15 | return { name }; 16 | } 17 | 18 | /** @internal */ 19 | export function setStateMutationName( 20 | mutation: StateMutation, 21 | name: string, 22 | ): void { 23 | (mutation as any)[MUTATION_NAME_SYMBOL] = name; 24 | } 25 | 26 | /** @internal */ 27 | export function isInternalStore(store: Store): boolean { 28 | return (store as any)[INTERNAL_STORE_SYMBOL] === true; 29 | } 30 | 31 | /** @internal */ 32 | export function setInternalStoreFlag(store: Store): void { 33 | (store as any)[INTERNAL_STORE_SYMBOL] = true; 34 | } 35 | -------------------------------------------------------------------------------- /packages/rx-effects/src/storeUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { debounceTime, filter, firstValueFrom, materialize, timer } from 'rxjs'; 2 | import { bufferWhen, map } from 'rxjs/operators'; 3 | import { createStore, declareStateUpdates } from './store'; 4 | import { declareStoreWithUpdates, pipeStore } from './storeUtils'; 5 | 6 | describe('pipeStore', () => { 7 | it('should creates a transformed view of the source store', () => { 8 | const source = createStore(1); 9 | 10 | const result = pipeStore( 11 | source, 12 | map((value) => value * 10), 13 | ); 14 | 15 | expect(result.get()).toBe(10); 16 | 17 | source.set(2); 18 | expect(result.get()).toBe(20); 19 | }); 20 | 21 | it('should unsubscribe the view when the source store is destroyed', async () => { 22 | const source = createStore(1); 23 | 24 | const result = pipeStore( 25 | source, 26 | map((value) => value * 10), 27 | ); 28 | 29 | const notifications$ = result.value$.pipe(materialize()); 30 | 31 | const notificationsPromise = firstValueFrom( 32 | notifications$.pipe( 33 | bufferWhen(() => notifications$.pipe(filter((e) => e.kind === 'C'))), 34 | ), 35 | ); 36 | 37 | source.destroy(); 38 | source.set(2); 39 | expect(result.get()).toBe(10); 40 | 41 | expect(await notificationsPromise).toEqual([ 42 | { 43 | hasValue: true, 44 | kind: 'N', 45 | value: 10, 46 | }, 47 | ]); 48 | }); 49 | 50 | it('should creates a debounced view of the source store', async () => { 51 | const source = createStore(1); 52 | 53 | const result = pipeStore(source, (state$) => 54 | state$.pipe( 55 | debounceTime(10), 56 | map((value) => value * 10), 57 | ), 58 | ); 59 | 60 | expect(result.get()).toBe(1); 61 | 62 | await firstValueFrom(timer(20)); 63 | expect(result.get()).toBe(10); 64 | 65 | source.set(2); 66 | expect(result.get()).toBe(10); 67 | 68 | await firstValueFrom(timer(20)); 69 | expect(result.get()).toBe(20); 70 | }); 71 | }); 72 | 73 | describe('declareStoreWithUpdates()', () => { 74 | const COUNTER_UPDATES = declareStateUpdates()({ 75 | increase: () => (state) => state + 1, 76 | decrease: () => (state) => state - 1, 77 | }); 78 | 79 | it('should declare a store with updates', () => { 80 | const createStore = declareStoreWithUpdates(0, COUNTER_UPDATES, { 81 | name: 'counterStore', 82 | }); 83 | 84 | const store = createStore(); 85 | expect(store.get()).toBe(0); 86 | expect(store.name).toBe('counterStore'); 87 | 88 | store.updates.increase(); 89 | expect(store.get()).toBe(1); 90 | 91 | store.updates.decrease(); 92 | expect(store.get()).toBe(0); 93 | }); 94 | 95 | it('should return a factory which can override initial parameters', () => { 96 | const createStore = declareStoreWithUpdates(0, COUNTER_UPDATES, { 97 | name: 'counterStore', 98 | }); 99 | 100 | const store = createStore(10, { name: 'myCounter' }); 101 | expect(store.get()).toBe(10); 102 | expect(store.name).toBe('myCounter'); 103 | 104 | store.updates.increase(); 105 | expect(store.get()).toBe(11); 106 | 107 | store.updates.decrease(); 108 | expect(store.get()).toBe(10); 109 | }); 110 | 111 | it('should work with a discriminate type of a state', () => { 112 | type State = { status: 'success' } | { status: 'error'; error: string }; 113 | 114 | const initialState: State = { status: 'success' }; 115 | 116 | const stateUpdates = declareStateUpdates()({ 117 | setSuccess: () => () => ({ status: 'success' }), 118 | setError: (error: string) => () => ({ status: 'error', error: error }), 119 | }); 120 | 121 | const createStore = declareStoreWithUpdates( 122 | initialState, 123 | stateUpdates, 124 | ); 125 | 126 | const store = createStore(); 127 | expect(store.get()).toEqual({ status: 'success' }); 128 | 129 | store.updates.setError('fail'); 130 | expect(store.get()).toEqual({ status: 'error', error: 'fail' }); 131 | 132 | store.updates.setSuccess(); 133 | expect(store.get()).toEqual({ status: 'success' }); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /packages/rx-effects/src/storeUtils.ts: -------------------------------------------------------------------------------- 1 | import { MonoTypeOperatorFunction, Subscription } from 'rxjs'; 2 | import { 3 | createStore, 4 | InternalStoreOptions, 5 | StateUpdates, 6 | Store, 7 | StoreOptions, 8 | StoreQuery, 9 | StoreWithUpdates, 10 | withStoreUpdates, 11 | } from './store'; 12 | 13 | /** 14 | * Creates a deferred or transformed view of the store. 15 | */ 16 | export function pipeStore( 17 | store: Store, 18 | operator: MonoTypeOperatorFunction, 19 | ): StoreQuery { 20 | let subscription: Subscription | undefined; 21 | 22 | const clone = createStore(store.get(), { 23 | internal: true, 24 | onDestroy: () => { 25 | if (subscription) { 26 | subscription.unsubscribe(); 27 | subscription = undefined; 28 | } 29 | }, 30 | } as InternalStoreOptions); 31 | 32 | subscription = store.value$.pipe(operator).subscribe({ 33 | next: (state) => { 34 | clone.set(state); 35 | }, 36 | complete: () => { 37 | clone.destroy(); 38 | }, 39 | }); 40 | 41 | return clone; 42 | } 43 | 44 | /** 45 | * @deprecated Use `declareStore()` 46 | */ 47 | export function declareStoreWithUpdates< 48 | State, 49 | Updates extends StateUpdates, 50 | >( 51 | initialState: State, 52 | updates: Updates, 53 | baseOptions?: StoreOptions, 54 | ): ( 55 | state?: State, 56 | options?: StoreOptions, 57 | ) => StoreWithUpdates { 58 | return (state = initialState, options?: StoreOptions) => { 59 | return withStoreUpdates( 60 | createStore(state, { ...baseOptions, ...options }), 61 | updates, 62 | ); 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /packages/rx-effects/src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { isReadonlyArray, OBJECT_COMPARATOR } from './utils'; 2 | 3 | describe('isReadonlyArray()', () => { 4 | it('should return true for the array', () => { 5 | expect(isReadonlyArray([1, 2, 3])).toBe(true); 6 | }); 7 | 8 | it('should return false for a non-array value', () => { 9 | expect(isReadonlyArray('foo')).toBe(false); 10 | }); 11 | }); 12 | 13 | describe('OBJECT_COMPARATOR', () => { 14 | it('should compare properties of objects', () => { 15 | const obj = { a: 1 }; 16 | 17 | expect(OBJECT_COMPARATOR({}, {})).toBe(true); 18 | expect(OBJECT_COMPARATOR(obj, obj)).toBe(true); 19 | expect(OBJECT_COMPARATOR({ a: 1 }, {})).toBe(false); 20 | expect(OBJECT_COMPARATOR({}, { a: 1 })).toBe(false); 21 | expect(OBJECT_COMPARATOR({ a: 1 }, { a: 1 })).toBe(true); 22 | expect(OBJECT_COMPARATOR({ a: 1 }, { a: 2 })).toBe(false); 23 | expect(OBJECT_COMPARATOR({ a: 1 }, { a: 1, b: 3 })).toBe(false); 24 | expect(OBJECT_COMPARATOR({ a: 1 }, { b: 1 })).toBe(false); 25 | expect(OBJECT_COMPARATOR({ a: 1 }, { a: 1, b: undefined })).toBe(false); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /packages/rx-effects/src/utils.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_COMPARATOR = (a: unknown, b: unknown): boolean => a === b; 2 | 3 | const hasOwnProperty = Object.prototype.hasOwnProperty; 4 | 5 | export type AnyObject = Record; 6 | export type EmptyObject = Record; 7 | 8 | export type PartialProp = Omit & 9 | Partial>; 10 | 11 | /** 12 | * Makes shallow comparison of two objects. 13 | */ 14 | export const OBJECT_COMPARATOR = ( 15 | objA: Record, 16 | objB: Record, 17 | ): boolean => { 18 | if (objA === objB) { 19 | return true; 20 | } 21 | 22 | const keysA = Object.keys(objA); 23 | const keysB = Object.keys(objB); 24 | 25 | if (keysA.length !== keysB.length) { 26 | return false; 27 | } 28 | 29 | for (let i = 0; i < keysA.length; i++) { 30 | const key = keysA[i]; 31 | if (!hasOwnProperty.call(objB, key) || objA[key] !== objB[key]) { 32 | return false; 33 | } 34 | } 35 | 36 | return true; 37 | }; 38 | 39 | export function isReadonlyArray( 40 | value: ReadonlyArray | unknown, 41 | ): value is ReadonlyArray { 42 | return Array.isArray(value); 43 | } 44 | -------------------------------------------------------------------------------- /packages/rx-effects/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "files": ["src/index.ts"], 4 | "compilerOptions": { 5 | "noEmit": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/rx-effects/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/rx-effects/typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["src/index.ts"], 3 | "exclude": ["**/*+(.test).ts"], 4 | "readme": "none" 5 | } 6 | -------------------------------------------------------------------------------- /rocket.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 10 | 11 | 14 | 17 | 20 | 21 | 23 | 25 | 26 | 29 | 30 | 32 | 34 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 45 | 46 | 47 | 48 | 49 | 52 | 53 | 54 | 55 | 56 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /scripts/docs.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | 3 | import fastGlob from 'fast-glob'; 4 | import * as fs from 'fs'; 5 | import path from 'path'; 6 | import shell from 'shelljs'; 7 | 8 | function exec(command, workDir) { 9 | if (workDir) { 10 | shell.pushd('-q', '.'); 11 | shell.cd(workDir); 12 | } 13 | 14 | shell.exec(command); 15 | 16 | if (workDir) { 17 | shell.popd('-q'); 18 | } 19 | } 20 | 21 | function processPackages() { 22 | const packagePaths = fastGlob.sync(['packages/*'], { 23 | onlyDirectories: true, 24 | ignore: ['packages/examples'], 25 | }); 26 | 27 | console.log('\nCopy LICENSE files...'); 28 | packagePaths.forEach((packagePath) => { 29 | console.log(`- ${packagePath}`); 30 | shell.cp('LICENSE', packagePath); 31 | }); 32 | 33 | console.log('\nRun markdown-toc...'); 34 | ['.', ...packagePaths].forEach((packagePath) => { 35 | const readmePath = path.join(packagePath, 'README.md'); 36 | if (!fs.existsSync(readmePath)) { 37 | return; 38 | } 39 | 40 | console.log(`- ${readmePath}`); 41 | exec(`markdown-toc -i ${readmePath}`); 42 | }); 43 | 44 | console.log('\nRun typedoc...'); 45 | packagePaths.forEach((packagePath) => { 46 | console.log(`- ${packagePath}`); 47 | exec(`typedoc`, packagePath); 48 | }); 49 | } 50 | 51 | processPackages(); 52 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "target": "ES2019", 5 | "module": "ES2015", 6 | "lib": ["ES2019"], 7 | "jsx": "react-jsx", 8 | "importHelpers": false, 9 | "declaration": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "noImplicitReturns": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "moduleResolution": "node", 15 | "esModuleInterop": true, 16 | "skipLibCheck": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "isolatedModules": true 19 | } 20 | } 21 | --------------------------------------------------------------------------------