├── .editorconfig ├── .eslintignore ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── pr-check.yml │ └── publish.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .lintstagedrc.js ├── .npmignore ├── .nvmrc ├── .prettierrc.js ├── .run └── All Tests.run.xml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── __helpers__ └── setup.ts ├── __tests__ ├── manager-stream.ts └── wakeup.ts ├── commitlint.config.js ├── eslint.config.js ├── logo.png ├── package-lock.json ├── package.json ├── release.config.js ├── rollup.config.js ├── sonar-project.properties ├── src ├── constants.ts ├── context.tsx ├── deep-compare.ts ├── deep-merge.ts ├── events.ts ├── index.ts ├── logger.ts ├── make-exported.ts ├── manager-stream.ts ├── manager.ts ├── on-change-listener.ts ├── plugins │ ├── dev-extension │ │ ├── index.ts │ │ └── state-listener.ts │ ├── helpers.ts │ └── vite │ │ ├── id-generator.ts │ │ └── index.ts ├── storages │ ├── async-storage.ts │ ├── combined-storage.ts │ ├── cookie-storage.ts │ └── local-storage.ts ├── store-status.ts ├── suspense-query.ts ├── types.ts ├── wakeup.ts └── with-stores.tsx ├── tsconfig.checks.json ├── tsconfig.json ├── typing └── @lomray │ └── event-manager.d.ts └── vitest.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /node_modules/* 2 | /lib/* 3 | *.js 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [Lomray-Software] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/pr-check.yml: -------------------------------------------------------------------------------- 1 | name: Check PR 2 | 3 | on: 4 | pull_request: 5 | branches: [ staging, prod ] 6 | types: [opened, synchronize, reopened] 7 | 8 | env: 9 | NODE_VERSION: 20.10.0 10 | ENABLE_CACHE: yes 11 | 12 | jobs: 13 | prepare: 14 | runs-on: ubuntu-latest 15 | concurrency: 16 | group: ${{ github.ref }}-prepare 17 | cancel-in-progress: true 18 | outputs: 19 | node-version: ${{ steps.variable.outputs.node-version }} 20 | enable-cache: ${{ steps.variable.outputs.enable-cache }} 21 | 22 | steps: 23 | - name: Define variables 24 | id: variable 25 | run: | 26 | echo "node-version=$NODE_VERSION" >> $GITHUB_OUTPUT 27 | echo "enable-cache=$ENABLE_CACHE" >> $GITHUB_OUTPUT 28 | 29 | check: 30 | name: Check PR 31 | needs: [prepare] 32 | runs-on: ubuntu-latest 33 | 34 | steps: 35 | - uses: actions/checkout@v4 36 | 37 | - uses: actions/setup-node@v4 38 | with: 39 | node-version: ${{ needs.prepare.outputs.node-version }} 40 | cache: ${{ needs.prepare.outputs.enable-cache == 'yes' && 'npm' || '' }} 41 | 42 | - name: Install dependencies 43 | run: npm ci --ignore-scripts 44 | 45 | - name: Commit lint PR title 46 | run: echo "${{ github.event.pull_request.title }}" | npx --no-install commitlint -g commitlint.config.js 47 | 48 | - name: Build lib 49 | run: npm run build 50 | 51 | - name: Typescript check 52 | run: npm run ts:check 53 | 54 | - name: Check eslint 55 | run: npm run lint:check 56 | 57 | - name: Test 58 | run: npm run test -- --coverage 59 | 60 | - uses: actions/upload-artifact@v4 61 | with: 62 | name: coverage-lcov 63 | path: coverage 64 | 65 | sonarcube: 66 | runs-on: ubuntu-latest 67 | needs: [check] 68 | concurrency: 69 | group: ${{ github.ref }}-sonarcube 70 | cancel-in-progress: true 71 | 72 | steps: 73 | - uses: actions/checkout@v4 74 | with: 75 | fetch-depth: 0 76 | 77 | - uses: actions/download-artifact@v4 78 | with: 79 | name: coverage-lcov 80 | path: coverage 81 | 82 | - uses: SonarSource/sonarcloud-github-action@master 83 | env: 84 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 85 | SONAR_TOKEN: ${{ secrets.SONAR_CLOUD_TOKEN }} 86 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Build & Publish 2 | 3 | on: 4 | push: 5 | branches: [ prod, staging ] 6 | 7 | env: 8 | NODE_VERSION: 20.10.0 9 | ENABLE_CACHE: yes 10 | 11 | jobs: 12 | prepare: 13 | runs-on: ubuntu-latest 14 | concurrency: 15 | group: ${{ github.ref }}-prepare 16 | cancel-in-progress: true 17 | outputs: 18 | node-version: ${{ steps.variable.outputs.node-version }} 19 | enable-cache: ${{ steps.variable.outputs.enable-cache }} 20 | 21 | steps: 22 | - name: Define variables 23 | id: variable 24 | run: | 25 | echo "node-version=$NODE_VERSION" >> $GITHUB_OUTPUT 26 | echo "enable-cache=$ENABLE_CACHE" >> $GITHUB_OUTPUT 27 | 28 | release: 29 | needs: [prepare] 30 | runs-on: ubuntu-latest 31 | 32 | steps: 33 | - uses: actions/checkout@v4 34 | 35 | - uses: actions/setup-node@v4 36 | with: 37 | node-version: ${{ needs.prepare.outputs.node-version }} 38 | cache: ${{ needs.prepare.outputs.enable-cache == 'yes' && 'npm' || '' }} 39 | 40 | - name: Install dependencies 41 | run: npm ci --ignore-scripts 42 | 43 | - name: Typescript check 44 | run: npm run ts:check 45 | 46 | - name: Check eslint 47 | run: npm run lint:check 48 | 49 | - name: Test 50 | run: npm run test -- --coverage 51 | 52 | - uses: actions/upload-artifact@v4 53 | with: 54 | name: coverage-lcov 55 | path: coverage 56 | 57 | - name: Build 58 | run: | 59 | npm pkg delete scripts.prepare 60 | npm run build 61 | 62 | - name: Publish npm packages / create github release 63 | run: npx semantic-release 64 | env: 65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 67 | 68 | sonarcube: 69 | runs-on: ubuntu-latest 70 | needs: [release] 71 | concurrency: 72 | group: ${{ github.ref }}-sonarcube 73 | cancel-in-progress: true 74 | 75 | steps: 76 | - uses: actions/checkout@v4 77 | with: 78 | fetch-depth: 0 79 | 80 | - uses: actions/download-artifact@v4 81 | with: 82 | name: coverage-lcov 83 | path: coverage 84 | 85 | - id: package-version 86 | run: npx @lomray/microservices-cli package-version 87 | 88 | - uses: SonarSource/sonarcloud-github-action@master 89 | env: 90 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 91 | SONAR_TOKEN: ${{ secrets.SONAR_CLOUD_TOKEN }} 92 | with: 93 | args: > 94 | -Dsonar.projectVersion=${{ steps.package-version.outputs.version }} 95 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | node_modules 4 | lib 5 | coverage 6 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | # validate commit 5 | npx --no-install commitlint -g commitlint.config.js --edit "$1" 6 | 7 | LC_ALL=C 8 | 9 | local_branch="$(git rev-parse --abbrev-ref HEAD)" 10 | 11 | valid_branch_regex="^(feature|bugfix|improvement|hotfix)\/[a-z0-9-]+$" 12 | 13 | message="There is something wrong with your branch name. Branch names in this project must adhere to this contract: $valid_branch_regex. Your commit will be rejected. You should rename your branch to a valid name and try again." 14 | 15 | # validate branch name 16 | # shellcheck disable=SC2039 17 | if [ ! $local_branch =~ $valid_branch_regex ] && [ ! "$local_branch" == "staging" ] && [ ! "$local_branch" == "prod" ]; then 18 | echo "$message" 19 | exit 1 20 | fi 21 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '(src|__tests__|__mocks__|__helpers__)/**/*.{ts,tsx,js}': [ 3 | 'eslint --max-warnings=0', 4 | 'prettier --write', 5 | ] 6 | }; 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .github 3 | .run 4 | commitlint.config.js 5 | release.config.js 6 | rollup.config.js 7 | .eslintignore 8 | .eslintrc.js 9 | .lintstagedrc.js 10 | .mocharc.json 11 | .prettierrc.js 12 | sonar-project.properties 13 | .husky 14 | .idea 15 | .editorconfig 16 | /index.ts 17 | /src 18 | /example 19 | /coverage 20 | /typing 21 | tsconfig.json 22 | tsconfig.checks.json 23 | CODE_OF_CONDUCT.md 24 | CONTRIBUTING.md 25 | SECURITY.md 26 | logo.png 27 | .nvmrc 28 | __mocks__ 29 | __helpers__ 30 | __tests__ 31 | .c8rc.json 32 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.10.0 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | import config from '@lomray/prettier-config'; 2 | 3 | export default { 4 | ...config, 5 | }; 6 | -------------------------------------------------------------------------------- /.run/All Tests.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | Github issues. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Ensure any unused install dependencies are removed before create PR. 11 | 2. Update the README.md with details of changes to the interface. 12 | 3. You may merge the Pull Request in once you have the sign-off of two other developers, or if you 13 | do not have permission to do that, you may request the second reviewer to merge it for you. 14 | 15 | ## Code of Conduct 16 | 17 | ### Our Pledge 18 | 19 | In the interest of fostering an open and welcoming environment, we as 20 | contributors and maintainers pledge to making participation in our project and 21 | our community a harassment-free experience for everyone, regardless of age, body 22 | size, disability, ethnicity, gender identity and expression, level of experience, 23 | nationality, personal appearance, race, religion, or sexual identity and 24 | orientation. 25 | 26 | ### Our Standards 27 | 28 | Examples of behavior that contributes to creating a positive environment 29 | include: 30 | 31 | * Using welcoming and inclusive language 32 | * Being respectful of differing viewpoints and experiences 33 | * Gracefully accepting constructive criticism 34 | * Focusing on what is best for the community 35 | * Showing empathy towards other community members 36 | 37 | Examples of unacceptable behavior by participants include: 38 | 39 | * The use of sexualized language or imagery and unwelcome sexual attention or 40 | advances 41 | * Trolling, insulting/derogatory comments, and personal or political attacks 42 | * Public or private harassment 43 | * Publishing others' private information, such as a physical or electronic 44 | address, without explicit permission 45 | * Other conduct which could reasonably be considered inappropriate in a 46 | professional setting 47 | 48 | ### Our Responsibilities 49 | 50 | Project maintainers are responsible for clarifying the standards of acceptable 51 | behavior and are expected to take appropriate and fair corrective action in 52 | response to any instances of unacceptable behavior. 53 | 54 | Project maintainers have the right and responsibility to remove, edit, or 55 | reject comments, commits, code, wiki edits, issues, and other contributions 56 | that are not aligned to this Code of Conduct, or to ban temporarily or 57 | permanently any contributor for other behaviors that they deem inappropriate, 58 | threatening, offensive, or harmful. 59 | 60 | ### Scope 61 | 62 | This Code of Conduct applies both within project spaces and in public spaces 63 | when an individual is representing the project or its community. Examples of 64 | representing a project or community include using an official project e-mail 65 | address, posting via an official social media account, or acting as an appointed 66 | representative at an online or offline event. Representation of a project may be 67 | further defined and clarified by project maintainers. 68 | 69 | ### Enforcement 70 | 71 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 72 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All 73 | complaints will be reviewed and investigated and will result in a response that 74 | is deemed necessary and appropriate to the circumstances. The project team is 75 | obligated to maintain confidentiality with regard to the reporter of an incident. 76 | Further details of specific enforcement policies may be posted separately. 77 | 78 | Project maintainers who do not follow or enforce the Code of Conduct in good 79 | faith may face temporary or permanent repercussions as determined by other 80 | members of the project's leadership. 81 | 82 | ### Attribution 83 | 84 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 85 | available at [http://contributor-covenant.org/version/1/4][version] 86 | 87 | [homepage]: http://contributor-covenant.org 88 | [version]: http://contributor-covenant.org/version/1/4/ 89 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Lomray Software 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 |

Mobx stores manager for React

2 | 3 |

4 | Mobx stores manager logo 5 |

6 | 7 | ### Key features: 8 | 9 | - One way to escape state tree 🌲🌳🌴. 10 | - Ready to use with Suspense. 11 | - Support SSR. 12 | - Support render to stream. 13 | - Manage your Mobx stores like a boss - debug like a hacker. 14 | - Simple idea - simple implementation. 15 | - Small package size. 16 | - Support code splitting out of the box. 17 | - Access stores from other stores. 18 | - Can be a replacement for react context. 19 | - And many other nice things 😎 20 | 21 |

22 | reliability 23 | Security Rating 24 | Maintainability Rating 25 | Vulnerabilities 26 | Bugs 27 | Lines of Code 28 | code coverage 29 | size 30 | size 31 | semantic version 32 |

33 | 34 | ## Table of contents 35 | 36 | - [Getting started](#getting-started) 37 | - [Usage](#usage) 38 | - [Support SSR](#support-ssr) 39 | - [Important Tips](#important-tips) 40 | - [Documentation](#documentation) 41 | - [Manager](#manager) 42 | - [withStores](#withstores) 43 | - [StoreManagerProvider](#storemanagerprovider) 44 | - [useStoreManagerContext](#usestoremanager) 45 | - [useStoreManagerParentContext](#usestoremanagerparent) 46 | - [Store](#store) 47 | - [Example](#demo) 48 | - [React Native Debug Plugin](#react-native-debug-plugin) 49 | - [Bugs and feature requests](#bugs-and-feature-requests) 50 | - [License](#license) 51 | 52 | ## Getting started 53 | 54 | The React-mobx-manager package is distributed using [npm](https://www.npmjs.com/), the node package manager. 55 | 56 | ``` 57 | npm i --save @lomray/react-mobx-manager @lomray/consistent-suspense 58 | ``` 59 | 60 | __NOTE:__ this package use [@lomray/consistent-suspense](https://github.com/Lomray-Software/consistent-suspense) for generate stable id's inside Suspense. 61 | 62 | __Choose one of store id generating strategy (1 or 2 or 3)__: 63 | 64 | 1. Configure your bundler to keep classnames and function names. Store id will be generated from class names (chose unique class names). 65 | - **React:** (craco or webpack config, terser options) 66 | ```bash 67 | terserOptions.keep_classnames = true; 68 | terserOptions.keep_fnames = true; 69 | ``` 70 | 71 | - **React Native:** (metro bundler config: metro.config.js) 72 | ```js 73 | module.exports = { 74 | transformer: { 75 | minifierConfig: { 76 | keep_classnames: true, 77 | keep_fnames: true, 78 | }, 79 | } 80 | } 81 | ``` 82 | 2. Define `id` for each store. 83 | 84 | ```typescript 85 | import { makeObservable } from "mobx"; 86 | 87 | class MyStore { 88 | /** 89 | * Define unique store id 90 | */ 91 | static id = 'Unique-store-id'; 92 | 93 | constructor() { 94 | makeObservable(this, {}) 95 | } 96 | } 97 | ``` 98 | 3. Use `Vite plugins`. 99 | 100 | ```typescript 101 | import { defineConfig } from 'vite'; 102 | import react from '@vitejs/plugin-react'; 103 | import MobxManager from '@lomray/react-mobx-manager/plugins/vite/index'; 104 | 105 | // https://vitejs.dev/config/ 106 | export default defineConfig({ 107 | /** 108 | * Store id's will be generated automatically, just chill 109 | */ 110 | plugins: [react(), MobxManager()] 111 | }); 112 | 113 | /** 114 | * Detect mobx store: 115 | - by makeObservable or makeAutoObservable 116 | - by @mobx-store jsdoc before class 117 | */ 118 | ``` 119 | 120 | ## Usage 121 | 122 | Import `Manager, StoreManagerProvider` from `@lomray/react-mobx-manager` into your index file and wrap `` with `` 123 | 124 | ```typescript jsx 125 | import React from 'react'; 126 | import ReactDOM from 'react-dom/client'; 127 | import { ConsistentSuspenseProvider } from '@lomray/consistent-suspense'; 128 | import { Manager, StoreManagerProvider, MobxLocalStorage } from '@lomray/react-mobx-manager'; 129 | import App from './app'; 130 | import MyApiClient from './services/my-api-client'; 131 | import './index.css'; 132 | 133 | const apiClient = new MyApiClient(); 134 | const storeManager = new Manager({ 135 | storage: new MobxLocalStorage(), // optional: needs for persisting stores 136 | storesParams: { apiClient }, // optional: we can provide our api client for access from the each store 137 | }); 138 | 139 | const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); 140 | 141 | root.render( 142 | 143 | {/** required **/} 144 | 145 | 146 | 147 | 148 | , 149 | ); 150 | ``` 151 | 152 | Connect mobx store to the manager, and you're good to go! 153 | 154 | ```typescript 155 | import { withStores, Manager } from '@lomray/react-mobx-manager'; 156 | import { makeObservable, observable, action } from 'mobx'; 157 | import type { IConstructorParams, ClassReturnType } from '@lomray/react-mobx-manager'; 158 | 159 | /** 160 | * Mobx user store 161 | * 162 | * Usually store like that are related to the global store, 163 | * because they store information about the current user, 164 | * which may be needed in different places of the application. 165 | * 166 | * You may also want to save the state of the store, for example, 167 | * to local storage, so that it can be restored after page reload, 168 | * in this case, just export wrap export with 'persist': 169 | * export default Manager.persistStore(UserStore, 'user'); 170 | */ 171 | class UserStore { 172 | /** 173 | * Required only if we don't configure our bundler to keep classnames and function names 174 | * Default: current class name 175 | */ 176 | static id = 'user'; 177 | 178 | /** 179 | * You can also enable behavior for global application stores 180 | * Default: false 181 | */ 182 | static isGlobal = true; 183 | 184 | /** 185 | * Our state 186 | */ 187 | public name = 'Matthew' 188 | 189 | /** 190 | * Our API client 191 | */ 192 | private apiClient: MyApiClient; 193 | 194 | /** 195 | * @constructor 196 | */ 197 | constructor({ getStore, apiClient }: IConstructorParams) { 198 | this.apiClient = apiClient; 199 | // if we need, we can get a global store or store from the parent context 200 | // this.otherStore = getStore(SomeOtherStore); 201 | 202 | makeObservable(this, { 203 | name: observable, 204 | setName: action.bound, 205 | }); 206 | } 207 | 208 | /** 209 | * Set user name 210 | */ 211 | public setName(name: string): void { 212 | this.name = name; 213 | } 214 | 215 | /** 216 | * Example async 217 | * Call this func from component 218 | */ 219 | public getNameFromApi = async (userId: number) => { 220 | const name = await this.apiClient.fetchName(userId); 221 | 222 | this.setName(name); 223 | } 224 | } 225 | 226 | /** 227 | * Define stores for component 228 | */ 229 | const stores = { 230 | userStore: UserStore 231 | }; 232 | 233 | // support typescript 234 | type TProps = StoresType ; 235 | 236 | /** 237 | * User component 238 | */ 239 | const User: FC = ({ userStore: { name } }) => { 240 | return ( 241 |
{name}
242 | ) 243 | } 244 | 245 | /** 246 | * Connect stores to component 247 | */ 248 | export default withStores(User, stores); 249 | ``` 250 | 251 | [See app example](https://github.com/Lomray-Software/vite-template) for a better understanding. 252 | 253 | ## Support SSR 254 | Does this library support SSR? Short answer - yes, but we need some steps to prepare our framework. 255 | - Look at [Vite demo app](https://github.com/Lomray-Software/vite-template) for a better understanding. 256 | - Look at [After.js (razzle) based project](https://github.com/Lomray-Software/microservices-dashboard/blob/staging/src/pages/user/index.tsx#L82) for a better understanding. 257 | - Look at [NextJS example](https://github.com/Lomray-Software/nextjs-mobx-store-manager-example) for a better understanding (needs writing a wrapper). 258 | 259 | ## Important Tips 260 | - Create **global** store only for e.g: application settings, logged user, theme, etc. 261 | - To get started, stick to the concept: Store for Component. Don't connect (through withStores) not global store to several components. 262 | 263 | ## Documentation 264 | 265 | ### Manager 266 | ```typescript 267 | import { Manager, MobxLocalStorage, MobxAsyncStorage } from '@lomray/react-mobx-manager'; 268 | // import AsyncStorage from '@react-native-async-storage/async-storage'; 269 | 270 | // Params 271 | const storeManager = new Manager({ 272 | /** 273 | * Optional: needs for persisting stores when you use Manager.persistStore 274 | * Available: MobxLocalStorage and MobxAsyncStorage 275 | * Default: none 276 | */ 277 | storage: new MobxLocalStorage(), // React 278 | // storage: new MobxAsyncStorage(AsyncStorage), // React Native 279 | // storage: new CombinedStorage({ local: MobxAsyncStorage, cookie: CookieStorage }), // Define multiple storages 280 | /** 281 | * Optional: provide some params for access from store constructor 282 | * E.g. we can provide our api client for access from the store 283 | * Default: {} 284 | */ 285 | storesParams: { apiClient }, 286 | /** 287 | * Initial stores state. 288 | * E.g. in SSR case, restore client state from a server 289 | * Default: {} 290 | */ 291 | initState: { storeId: { param: 'test' } }, 292 | /** 293 | * Additional manager options 294 | */ 295 | options: { 296 | /** 297 | * Disable persisting stores 298 | * E.g., it should be 'true' on a server-side (SSR) 299 | * Default: false 300 | */ 301 | shouldDisablePersist: false, 302 | /** 303 | * Remove the initial store state after initialization 304 | * Default: true 305 | */ 306 | shouldRemoveInitState: true, 307 | /** 308 | * Configure store destroy timers 309 | */ 310 | destroyTimers: { 311 | init: 500, 312 | touched: 10000, // NOTE: set to max request timeout 313 | unused: 1000, 314 | }, 315 | } 316 | }); 317 | 318 | // Methods 319 | 320 | /** 321 | * Optional: Call this method to load persisting data from persist storage 322 | * E.g., you may want manually to do this before the app render 323 | * Default: StoreManagerProvider does this automatically with 'shoudInit' prop 324 | */ 325 | await storeManager.init(); 326 | 327 | /** 328 | * Get all-created stores 329 | */ 330 | const managerStores = storeManager.getStores(); 331 | 332 | /** 333 | * Get specific store 334 | */ 335 | const store = storeManager.getStore(SomeGlobalStore); 336 | const store2 = storeManager.getStore(SomeStore, { contextId: 'necessary-context-id' }); 337 | 338 | /** 339 | * Get stores context's and relations 340 | */ 341 | const relations = storeManager.getStoresRelations(); 342 | 343 | /** 344 | * Manually create stores for component 345 | * NOTE: 'withStores' wrapper use this method, probably you won't need it 346 | * WARNING: Avoid using this method directly, it may cause unexpected behavior 347 | */ 348 | const stores = storeManager.createStores(['someStore', MyStore], 'parent-id', 'context-id', 'suspense-id', 'HomePage', { componentProp: 'test' }); 349 | 350 | /** 351 | * Mount/Unmount simple stores to component 352 | * WARNING: Avoid using this method directly, it may cause unexpected behavior 353 | */ 354 | const unmount = storeManager.mountStores(stores); 355 | 356 | /** 357 | * Get all-stores state 358 | */ 359 | const storesState = storeManager.toJSON(); 360 | 361 | /** 362 | * Get all persisted store's state 363 | */ 364 | const persistedStoresState = storeManager.toPersistedJSON(); 365 | 366 | /** 367 | * Get only persisted stores id's 368 | */ 369 | const persistedIds = Manager.getPersistedStoresIds(); 370 | 371 | /** 372 | * Get store observable props 373 | */ 374 | const observableProps = Manager.getObservableProps(store); 375 | 376 | /** 377 | * Static method for access to manager from anywhere 378 | * NOTE: Be careful with this, especially with SSR on server-side 379 | */ 380 | const manager = Manager.get(); 381 | 382 | /** 383 | * Enable persisting state for store 384 | */ 385 | const storeClass = Manager.persistStore(class MyStore {}, 'my-store'); 386 | 387 | /** 388 | * Choose storage and attributes 389 | */ 390 | const storeClass2 = Manager.persistStore(class MyStore {}, 'my-store', { 391 | attributes: { 392 | local: ['someProp'], // thees attributes will be stored in local storage 393 | cookie: ['specificProp'], // thees attributes will be stored in cookie storage 394 | } 395 | }); 396 | ``` 397 | 398 | ### withStores 399 | ```typescript 400 | import { withStores } from '@lomray/react-mobx-manager'; 401 | 402 | const stores = { 403 | myStore: MyStore, 404 | anotherStore: AnotherStore, 405 | // assign static id to future store 406 | demoStore: { store: SomeStore, id: 'my-id' }, 407 | // get parent store, do this only inside children components 408 | parentStore: { store: SomeParentStore, isParent: true }, 409 | }; 410 | 411 | /** 412 | * Create and connect 'stores' to component with custom context id 413 | * NOTE: In most cases, you don't need to pass a third argument (contextId). 414 | */ 415 | withStores(Component, stores, { customContextId: 'optional-context-id' }); 416 | ``` 417 | 418 | ### StoreManagerProvider 419 | ```typescript jsx 420 | import { StoreManagerProvider } from '@lomray/react-mobx-manager'; 421 | 422 | /** 423 | * Wrap your application for a pass-down store manager, context id, and init persisted state 424 | * 425 | * shouldInit - default: false, enable initialize peristed state 426 | * fallback - show loader while the manager has initialized 427 | */ 428 | }> 429 | {/* your components */} 430 | 431 | ``` 432 | 433 | ### useStoreManager 434 | ```typescript jsx 435 | import { useStoreManager } from '@lomray/react-mobx-manager'; 436 | 437 | const MyComponent: FC = () => { 438 | /** 439 | * Get store manager inside your function component 440 | */ 441 | const storeManager = useStoreManager(); 442 | } 443 | ``` 444 | 445 | ### useStoreManagerParent 446 | ```typescript jsx 447 | import { useStoreManagerParent } from '@lomray/react-mobx-manager'; 448 | 449 | const MyComponent: FC = () => { 450 | /** 451 | * Get parent context id 452 | */ 453 | const { parentId } = useStoreManagerParent(); 454 | } 455 | ``` 456 | 457 | ### Store 458 | 459 | ```typescript 460 | import { makeObservable, observable, action } from 'mobx'; 461 | 462 | class MyStore { 463 | /** 464 | * Required only if we don't configure our bundler to keep classnames and function names 465 | * Default: current class name 466 | */ 467 | static id = 'user'; 468 | 469 | /** 470 | * You can also enable behavior for global application stores 471 | * Default: false 472 | */ 473 | static isGlobal = true; 474 | 475 | /** 476 | * Store observable state 477 | */ 478 | public state = { 479 | name: 'Matthew', 480 | username: 'meow', 481 | } 482 | 483 | /** 484 | * @private 485 | */ 486 | private readonly someParentStore: ClassReturnType; 487 | 488 | /** 489 | * @constructor 490 | * 491 | * getStore - get parent store or global store 492 | * storeManager - access to store manager 493 | * apiClient - your custom param, see 'storesParams' in Manager 494 | */ 495 | constructor({ getStore, storeManager, apiClient, componentProps }: IConstructorParams) { 496 | this.apiClient = apiClient; 497 | this.someParentStore = getStore(SomeParentStore); 498 | 499 | // In case when store is't global you can get access to component props 500 | console.log(componentProps); 501 | 502 | makeObservable(this, { 503 | state: observable, 504 | }); 505 | } 506 | 507 | /** 508 | * Define this method if you want to do something after initialize the store 509 | * State restored, store ready for usage 510 | * Optional. 511 | * @private 512 | */ 513 | private init(): void { 514 | // do something 515 | } 516 | 517 | /** 518 | * Define this method if you want to do something when a component with this store is unmount 519 | * @private 520 | */ 521 | private onDestroy(): void { 522 | // do something 523 | } 524 | 525 | /** 526 | * Custom method for return store state 527 | * Optional. 528 | * Default: @see Manager.toJSON 529 | */ 530 | public toJSON(): Record { 531 | return { state: { username: this.state.username } }; 532 | } 533 | } 534 | ``` 535 | 536 | Lifecycles: 537 | - constructor 538 | - wakeup (restore state from persisted store) 539 | - init 540 | - onDestroy 541 | 542 | ## Demo 543 | Explore [demo app](https://github.com/Lomray-Software/vite-template) to more understand. 544 | 545 | ## React Native debug plugin 546 | For debug state, you can use [Reactotron debug plugin](https://github.com/Lomray-Software/reactotron-mobx-store-manager) 547 | 548 | ## Bugs and feature requests 549 | 550 | Bug or a feature request, [please open a new issue](https://github.com/Lomray-Software/react-mobx-manager/issues/new). 551 | 552 | ## License 553 | Made with 💚 554 | 555 | Published under [MIT License](./LICENSE). 556 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 1.1.x | :white_check_mark: | 8 | 9 | ## Reporting a Vulnerability 10 | 11 | Please report (suspected) security vulnerabilities to this repository issues. 12 | -------------------------------------------------------------------------------- /__helpers__/setup.ts: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import sinon from 'sinon'; 3 | import sinonChai from 'sinon-chai'; 4 | import { afterAll } from 'vitest'; 5 | 6 | chai.use(sinonChai); 7 | 8 | afterAll(() => { 9 | sinon.restore(); 10 | }); 11 | -------------------------------------------------------------------------------- /__tests__/manager-stream.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import sinon from 'sinon'; 3 | import { describe, it, afterEach } from 'vitest'; 4 | import type Manager from '@src/manager'; 5 | import ManagerStream from '@src/manager-stream'; 6 | 7 | describe('ManagerStream', () => { 8 | const sandbox = sinon.createSandbox(); 9 | const manager = { 10 | getSuspenseRelations: sandbox.stub(), 11 | toJSON: sandbox.stub(), 12 | }; 13 | 14 | afterEach(() => { 15 | sandbox.restore(); 16 | }); 17 | 18 | it('should return undefined if no suspense relations for given suspenseId', () => { 19 | const managerStream = new ManagerStream(manager as unknown as Manager); 20 | 21 | manager.getSuspenseRelations.returns(new Map()); 22 | 23 | const result = managerStream.take('suspenseId'); 24 | 25 | expect(result).to.be.undefined; 26 | }); 27 | 28 | it('should return script chunk with suspense stores', () => { 29 | const storesIds = new Set(['store1', 'store2']); 30 | const managerStream = new ManagerStream(manager as unknown as Manager); 31 | 32 | manager.getSuspenseRelations.returns(new Map([['suspenseId', storesIds]])); 33 | manager.toJSON.returns({ store1: { data: 'value1' }, store2: { data: 'value2' } }); 34 | 35 | const result = managerStream.take('suspenseId'); 36 | 37 | expect(result).to.equal( 38 | '', 39 | ); 40 | }); 41 | 42 | it('should only include preamble in the first call', () => { 43 | const storesIds = new Set(['store1', 'store2']); 44 | const managerStream = new ManagerStream(manager as unknown as Manager); 45 | 46 | manager.getSuspenseRelations.returns(new Map([['suspenseId', storesIds]])); 47 | manager.toJSON.returns({ store1: { data: 'value1' }, store2: { data: 'value2' } }); 48 | 49 | managerStream.take('suspenseId'); // first call 50 | const result = managerStream.take('suspenseId'); // second call 51 | 52 | expect(result).to.equal( 53 | '', 54 | ); 55 | }); 56 | 57 | it('should properly escape JSON for usage as an object literal inside of a script tag', () => { 58 | const storesIds = new Set(['store1', 'store2']); 59 | const managerStream = new ManagerStream(manager as unknown as Manager); 60 | 61 | manager.getSuspenseRelations.returns(new Map([['suspenseId', storesIds]])); 62 | manager.toJSON.returns({ 63 | store1: { data: 'value1' }, 64 | store2: { data: '' }, 65 | }); 66 | 67 | managerStream.take('suspenseId'); // first call 68 | const result = managerStream.take('suspenseId'); // second call 69 | 70 | expect(result).to.equal( 71 | '', 72 | ); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /__tests__/wakeup.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { describe, it } from 'vitest'; 3 | import wakeup from '@src/wakeup'; 4 | 5 | describe('wakeup', () => { 6 | it('should restore persisted store state', () => { 7 | const persistedState = { prop1: 'value1', prop2: 'value2' }; 8 | const stores = { prop1: 'initialValue1', prop2: 'initialValue2' }; 9 | 10 | const context = { 11 | persistedState, 12 | }; 13 | 14 | wakeup.call(stores, context); 15 | 16 | expect(stores.prop1).to.equal(persistedState.prop1); 17 | expect(stores.prop2).to.equal(persistedState.prop2); 18 | }); 19 | 20 | it('should not modify store if persistedState is undefined', () => { 21 | const stores = { prop1: 'initialValue1', prop2: 'initialValue2' }; 22 | 23 | const context = { 24 | persistedState: undefined, 25 | }; 26 | 27 | wakeup.call(stores, context); 28 | 29 | // Ensure that the stores remain unchanged 30 | expect(stores.prop1).to.equal('initialValue1'); 31 | expect(stores.prop2).to.equal('initialValue2'); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ['@commitlint/config-conventional'], 3 | }; 4 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import lomrayConfig from '@lomray/eslint-config-react'; 2 | // noinspection NpmUsedModulesInstalled 3 | import baseConfig from '@lomray/eslint-config'; 4 | // noinspection NpmUsedModulesInstalled 5 | import globals from 'globals'; 6 | 7 | const customFilesIgnores = { 8 | ...baseConfig['filesIgnores'], 9 | files: [ 10 | ...baseConfig['filesIgnores'].files, 11 | '__tests__/**/*.{ts,tsx,*.ts,*tsx}', 12 | '__mocks__/**/*.{ts,tsx,*.ts,*tsx}', 13 | '__helpers__/**/*.{ts,tsx,*.ts,*tsx}', 14 | ], 15 | } 16 | 17 | export default [ 18 | ...lomrayConfig.config(customFilesIgnores), 19 | { 20 | ...customFilesIgnores, 21 | languageOptions: { 22 | globals: { 23 | ...globals.node, 24 | NodeJS: true, 25 | } 26 | }, 27 | rules: { 28 | '@typescript-eslint/no-explicit-any': 0, 29 | '@typescript-eslint/no-unsafe-assignment': 0, 30 | '@typescript-eslint/no-unsafe-member-access': 0, 31 | '@typescript-eslint/no-unsafe-call': 0, 32 | '@typescript-eslint/no-unsafe-return': 0, 33 | } 34 | }, 35 | ]; 36 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lomray-Software/react-mobx-manager/9fd2a30ee0e72f9ab74b6413e9053ba36b3c1c9a/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lomray/react-mobx-manager", 3 | "version": "1.0.0", 4 | "description": "This package provides Mobx stores manager for react.", 5 | "type": "module", 6 | "main": "index.js", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/Lomray-Software/react-mobx-manager.git" 10 | }, 11 | "keywords": [ 12 | "react", 13 | "types", 14 | "typescript", 15 | "lomray", 16 | "javascript", 17 | "mobx", 18 | "store", 19 | "manager", 20 | "stores", 21 | "state" 22 | ], 23 | "publishConfig": { 24 | "access": "public" 25 | }, 26 | "author": "Mikhail Yarmaliuk", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/Lomray-Software/react-mobx-manager/issues" 30 | }, 31 | "homepage": "https://github.com/Lomray-Software/react-mobx-manager", 32 | "scripts": { 33 | "build": "rollup -c", 34 | "build:watch": "rollup -c -w", 35 | "lint:check": "eslint \"src/**/*.{ts,tsx,*.ts,*tsx}\"", 36 | "lint:format": "eslint --fix \"src/**/*.{ts,tsx,*.ts,*tsx}\"", 37 | "ts:check": "tsc --project ./tsconfig.checks.json --skipLibCheck --noemit", 38 | "test": "vitest run", 39 | "prepare": "husky install" 40 | }, 41 | "devDependencies": { 42 | "@commitlint/cli": "^19.3.0", 43 | "@commitlint/config-conventional": "^19.2.2", 44 | "@lomray/eslint-config-react": "^5.0.6", 45 | "@lomray/prettier-config": "^2.0.1", 46 | "@rollup/plugin-terser": "^0.4.4", 47 | "@types/chai": "^4.3.11", 48 | "@types/hoist-non-react-statics": "^3.3.5", 49 | "@types/lodash": "^4.14.202", 50 | "@types/react": "^18.2.47", 51 | "@types/sinon": "^17.0.3", 52 | "@types/sinon-chai": "^3.2.12", 53 | "@vitest/coverage-v8": "^3.0.6", 54 | "chai": "^4.4.0", 55 | "eslint": "^8.57.0", 56 | "husky": "^9.0.11", 57 | "jsdom": "^24.1.0", 58 | "lint-staged": "^15.2.5", 59 | "prettier": "^3.2.5", 60 | "rollup": "^4.34.8", 61 | "rollup-plugin-copy": "^3.5.0", 62 | "rollup-plugin-folder-input": "^1.0.1", 63 | "rollup-plugin-ts": "^3.4.5", 64 | "semantic-release": "^23.1.1", 65 | "sinon": "^17.0.2", 66 | "sinon-chai": "^3.7.0", 67 | "typescript": "^4.9.5", 68 | "vite": "^6.1.1", 69 | "vite-tsconfig-paths": "^4.3.2", 70 | "vitest": "^3.0.6" 71 | }, 72 | "peerDependencies": { 73 | "@lomray/consistent-suspense": ">=2.0.1", 74 | "@lomray/event-manager": ">=2.0.2", 75 | "hoist-non-react-statics": ">=3.3.2", 76 | "lodash": ">=4.17.21", 77 | "mobx": ">=6.9.0", 78 | "mobx-react-lite": "^3 || ^4", 79 | "react": "^18 || ^17" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | branches: [ 3 | 'prod', 4 | { 5 | name: 'staging', 6 | prerelease: 'beta', 7 | channel: 'beta', 8 | }, 9 | ], 10 | plugins: [ 11 | '@semantic-release/commit-analyzer', 12 | '@semantic-release/release-notes-generator', 13 | ['@semantic-release/npm', { 14 | pkgRoot: './lib' 15 | }], 16 | '@semantic-release/github', 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-ts'; 2 | import { folderInput } from 'rollup-plugin-folder-input'; 3 | import copy from 'rollup-plugin-copy'; 4 | import terser from '@rollup/plugin-terser'; 5 | 6 | const dest = 'lib'; 7 | 8 | export default { 9 | input: [ 10 | 'src/**/*.ts*', 11 | ], 12 | output: { 13 | dir: dest, 14 | format: 'es', 15 | sourcemap: true, 16 | preserveModules: true, 17 | preserveModulesRoot: 'src', 18 | exports: 'auto', 19 | }, 20 | external: [ 21 | 'node:process', 22 | 'node:path', 23 | 'node:fs', 24 | 'node:url', 25 | 'react', 26 | 'mobx', 27 | 'lodash', 28 | 'hoist-non-react-statics', 29 | 'mobx-react-lite', 30 | '@lomray/event-manager', 31 | '@lomray/consistent-suspense', 32 | ], 33 | plugins: [ 34 | folderInput(), 35 | typescript({ 36 | tsconfig: resolvedConfig => ({ 37 | ...resolvedConfig, 38 | declaration: true, 39 | importHelpers: true, 40 | plugins: [ 41 | { 42 | "transform": "@zerollup/ts-transform-paths", 43 | "exclude": ["*"] 44 | } 45 | ] 46 | }), 47 | }), 48 | terser(), 49 | copy({ 50 | targets: [ 51 | { src: 'package.json', dest: dest }, 52 | { src: 'README.md', dest: dest }, 53 | { src: 'LICENSE', dest: dest }, 54 | ] 55 | }) 56 | ], 57 | }; 58 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=Lomray-Software_react-mobx-manager 2 | sonar.organization=lomray-software 3 | sonar.sources=src 4 | sonar.tests=__tests__ 5 | sonar.coverage.exclusions=src/types* 6 | sonar.javascript.lcov.reportPaths=coverage/lcov.info 7 | 8 | # ===================================================== 9 | # Meta-data for the project 10 | # ===================================================== 11 | 12 | sonar.links.homepage=https://github.com/Lomray-Software/react-mobx-manager 13 | sonar.links.ci=https://github.com/Lomray-Software/react-mobx-manager/actions 14 | sonar.links.issue=https://github.com/Lomray-Software/react-mobx-manager/issues 15 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/prefer-default-export 2 | export const ROOT_CONTEXT_ID = 'root'; 3 | -------------------------------------------------------------------------------- /src/context.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, ReactElement } from 'react'; 2 | import React, { useContext, useEffect, useState } from 'react'; 3 | import { ROOT_CONTEXT_ID } from './constants'; 4 | import type Manager from './manager'; 5 | import type { TStores } from './types'; 6 | 7 | interface IStoreManagerProvider { 8 | storeManager: Manager; 9 | shouldInit?: boolean; 10 | fallback?: ReactElement; 11 | children?: React.ReactNode; 12 | } 13 | 14 | interface IStoreManagerParentProvider { 15 | parentId: string; 16 | children?: React.ReactNode; 17 | touchableStores?: TStores; 18 | } 19 | 20 | /** 21 | * Mobx store manager context 22 | */ 23 | const StoreManagerContext = React.createContext({} as Manager); 24 | 25 | /** 26 | * To spread relationships 27 | */ 28 | const StoreManagerParentContext = 29 | React.createContext(ROOT_CONTEXT_ID); 30 | 31 | /** 32 | * Mobx store manager parent provider 33 | * @constructor 34 | */ 35 | const StoreManagerParentProvider: FC> = ({ 36 | parentId, 37 | children, 38 | touchableStores, 39 | }) => { 40 | const storeManager = useStoreManager(); 41 | 42 | if (touchableStores) { 43 | storeManager.touchedStores(touchableStores); 44 | } 45 | 46 | return ; 47 | }; 48 | 49 | /** 50 | * Mobx store manager provider 51 | * @constructor 52 | */ 53 | const StoreManagerProvider: FC = ({ 54 | children, 55 | storeManager, 56 | fallback, 57 | shouldInit = false, 58 | }) => { 59 | const [isInit, setInit] = useState(!shouldInit); 60 | 61 | useEffect(() => { 62 | if (!shouldInit) { 63 | return; 64 | } 65 | 66 | void storeManager.init().then(() => setInit(true)); 67 | }, [shouldInit, storeManager]); 68 | 69 | return ( 70 | 71 | 72 | {isInit ? children : fallback || children} 73 | 74 | 75 | ); 76 | }; 77 | 78 | const useStoreManager = (): Manager => useContext(StoreManagerContext); 79 | 80 | const useStoreManagerParent = (): IStoreManagerParentProvider['parentId'] => 81 | useContext(StoreManagerParentContext); 82 | 83 | export { 84 | StoreManagerContext, 85 | StoreManagerParentContext, 86 | StoreManagerProvider, 87 | StoreManagerParentProvider, 88 | useStoreManager, 89 | useStoreManagerParent, 90 | }; 91 | -------------------------------------------------------------------------------- /src/deep-compare.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Deep compare two objects 3 | */ 4 | const deepCompare = (obj1: unknown, obj2: unknown): boolean => { 5 | if (obj1 === obj2) { 6 | return true; 7 | } 8 | 9 | if (typeof obj1 !== 'object' || obj1 === null || typeof obj2 !== 'object' || obj2 === null) { 10 | return false; 11 | } 12 | 13 | const keys1 = Object.keys(obj1); 14 | const keys2 = Object.keys(obj2); 15 | 16 | if (keys1.length !== keys2.length) { 17 | return false; 18 | } 19 | 20 | for (const key of keys1) { 21 | if (!keys2.includes(key) || !deepCompare(obj1[key], obj2[key])) { 22 | return false; 23 | } 24 | } 25 | 26 | return true; 27 | }; 28 | 29 | export default deepCompare; 30 | -------------------------------------------------------------------------------- /src/deep-merge.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper function to check if a variable is an object 3 | */ 4 | const isObject = (obj: any) => obj !== null && typeof obj === 'object'; 5 | 6 | /** 7 | * Custom small deep merge function for restore store state 8 | */ 9 | const deepMerge = (target: any, source: any): boolean => { 10 | if (!isObject(target) || !isObject(source)) { 11 | return false; 12 | } 13 | 14 | for (const key in source) { 15 | if (target.hasOwnProperty(key)) { 16 | if (isObject(target[key]) && isObject(source[key])) { 17 | deepMerge(target[key] as Record, source[key] as Record); 18 | } else { 19 | target[key] = source[key]; 20 | } 21 | } else { 22 | target[key] = source[key]; 23 | } 24 | } 25 | 26 | return true; 27 | }; 28 | 29 | export default deepMerge; 30 | -------------------------------------------------------------------------------- /src/events.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Store manager events 3 | */ 4 | enum Events { 5 | CREATE_STORE = 'mobx-manager:store-create', 6 | MOUNT_STORE = 'mobx-manager:store-mount', 7 | UNMOUNT_STORE = 'mobx-manager:store-unmount', 8 | DELETE_STORE = 'mobx-manager:store-delete', 9 | } 10 | 11 | export default Events; 12 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | 3 | export * from './context'; 4 | 5 | export { default as Manager } from './manager'; 6 | 7 | export { default as onChangeListener } from './on-change-listener'; 8 | 9 | export { default as wakeup } from './wakeup'; 10 | 11 | export { default as withStores } from './with-stores'; 12 | 13 | export * from './make-exported'; 14 | 15 | export { default as Events } from './events'; 16 | 17 | export { default as Logger } from './logger'; 18 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import type Manager from './manager'; 2 | 3 | export interface ILoggerOpts { 4 | /** 5 | * 0 - disabled 6 | * 1 - error 7 | * 2 - warning 8 | * 3 - info 9 | * 4 - debug 10 | */ 11 | level: number; 12 | manager: Manager; 13 | } 14 | 15 | export interface ILoggerLogOpts { 16 | level: ILoggerOpts['level']; 17 | err?: Error; 18 | payload?: Record; 19 | } 20 | 21 | class Logger { 22 | /** 23 | * Logger options 24 | */ 25 | protected options: ILoggerOpts; 26 | 27 | /** 28 | * @constructor 29 | */ 30 | constructor(opts: ILoggerOpts) { 31 | this.options = opts; 32 | } 33 | 34 | /** 35 | * Log message 36 | */ 37 | public log(msg: string, { level, err, payload }: ILoggerLogOpts): void { 38 | if (this.options.level < level) { 39 | return; 40 | } 41 | 42 | let type = 'log'; 43 | 44 | switch (level) { 45 | case 1: 46 | type = 'error'; 47 | break; 48 | 49 | case 2: 50 | type = 'warn'; 51 | break; 52 | 53 | case 3: 54 | type = 'info'; 55 | break; 56 | } 57 | 58 | console[type](...[msg, err, payload].filter(Boolean)); 59 | } 60 | 61 | /** 62 | * Log error message 63 | */ 64 | public err(msg: string, err?: unknown, payload?: Record): void { 65 | this.log(msg, { err: err as Error, level: 1, payload }); 66 | } 67 | 68 | /** 69 | * Log warning message 70 | */ 71 | public warn(msg: string, payload?: Record): void { 72 | this.log(msg, { level: 2, payload }); 73 | } 74 | 75 | /** 76 | * Log info message 77 | */ 78 | public info(msg: string, payload?: Record): void { 79 | this.log(msg, { level: 3, payload }); 80 | } 81 | 82 | /** 83 | * Log debug message 84 | */ 85 | public debug(msg: string, payload: Record = {}, hasSnapshot = false): void { 86 | if (hasSnapshot) { 87 | payload.additional = { 88 | relations: Object.fromEntries(this.options.manager.getStoresRelations().entries()), 89 | }; 90 | } 91 | 92 | this.log(`DEBUG: ${msg}`, { level: 4, payload: { ...payload } }); 93 | } 94 | } 95 | 96 | export default Logger; 97 | -------------------------------------------------------------------------------- /src/make-exported.ts: -------------------------------------------------------------------------------- 1 | import type { TAnyStore } from '@src/types'; 2 | 3 | const exportedPropName = 'libExported'; 4 | 5 | /** 6 | * Make store props exported for Manager.toJSON 7 | * @see Manager.toJSON 8 | */ 9 | const makeExported = ( 10 | store: T, 11 | props: { 12 | [P in Exclude]?: 'observable' | 'simple' | 'excluded'; 13 | }, 14 | shouldExtend = true, 15 | ): void => { 16 | store[exportedPropName] = { ...(shouldExtend ? store?.[exportedPropName] ?? {} : {}), ...props }; 17 | }; 18 | 19 | /** 20 | * Excluded in persistStore level 21 | * @see IPersistOptions 22 | */ 23 | const isPropExcludedInPersist = (store: TAnyStore): boolean => { 24 | return store?.['libStorageOptions']?.isNotExported || false; 25 | }; 26 | 27 | /** 28 | * Check if store prop is observable exported 29 | */ 30 | const isPropObservableExported = (store: TAnyStore, prop: string): boolean => 31 | store?.[exportedPropName]?.[prop] === 'observable'; 32 | 33 | /** 34 | * Check if store prop is simple exported 35 | */ 36 | const isPropSimpleExported = (store: TAnyStore, prop: string): boolean => 37 | store?.[exportedPropName]?.[prop] === 'simple'; 38 | 39 | /** 40 | * Check if store prop is excluded from export 41 | */ 42 | const isPropExcludedFromExport = ( 43 | store: TAnyStore, 44 | prop: string, 45 | withNotExported = false, 46 | ): boolean => 47 | store?.[exportedPropName]?.[prop] === 'excluded' || 48 | (!withNotExported && isPropExcludedInPersist(store)); 49 | 50 | export { makeExported, isPropObservableExported, isPropSimpleExported, isPropExcludedFromExport }; 51 | -------------------------------------------------------------------------------- /src/manager-stream.ts: -------------------------------------------------------------------------------- 1 | import type Manager from './manager'; 2 | 3 | const ESCAPE_LOOKUP: { [match: string]: string } = { 4 | '&': '\\u0026', 5 | '>': '\\u003e', 6 | '<': '\\u003c', 7 | '\u2028': '\\u2028', 8 | '\u2029': '\\u2029', 9 | }; 10 | const ESCAPE_REGEX = /[&><\u2028\u2029]/g; 11 | 12 | /** 13 | * Stream mobx manager stores 14 | */ 15 | class ManagerStream { 16 | /** 17 | * Already pushed preamble 18 | */ 19 | protected isPreamblePushed = false; 20 | 21 | /** 22 | * Mobx store manager 23 | */ 24 | protected manager: Manager; 25 | 26 | /** 27 | * @constructor 28 | */ 29 | public constructor(manager: Manager) { 30 | this.manager = manager; 31 | } 32 | 33 | /** 34 | * This utility is based on https://github.com/zertosh/htmlescape 35 | * License: https://github.com/zertosh/htmlescape/blob/0527ca7156a524d256101bb310a9f970f63078ad/LICENSE 36 | */ 37 | private htmlEscape(str: string): string { 38 | return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]); 39 | } 40 | 41 | /** 42 | * Return script with suspense stores to push on stream 43 | */ 44 | public take(suspenseId: string): string | void { 45 | const storesIds = this.manager.getSuspenseRelations().get(suspenseId); 46 | 47 | if (!storesIds?.size) { 48 | return; 49 | } 50 | 51 | const storesState = this.htmlEscape( 52 | JSON.stringify(JSON.stringify(this.manager.toJSON([...storesIds]))), 53 | ); 54 | const chunk = this.isPreamblePushed 55 | ? '' 56 | : ''; 57 | 58 | if (!this.isPreamblePushed) { 59 | this.isPreamblePushed = true; 60 | } 61 | 62 | return `${chunk}`; 63 | } 64 | } 65 | 66 | export default ManagerStream; 67 | -------------------------------------------------------------------------------- /src/manager.ts: -------------------------------------------------------------------------------- 1 | import EventManager from '@lomray/event-manager'; 2 | import { isObservableProp, toJS } from 'mobx'; 3 | import { ROOT_CONTEXT_ID } from './constants'; 4 | import deepMerge from './deep-merge'; 5 | import Events from './events'; 6 | import Logger from './logger'; 7 | import { 8 | isPropExcludedFromExport, 9 | isPropObservableExported, 10 | isPropSimpleExported, 11 | } from './make-exported'; 12 | import onChangeListener from './on-change-listener'; 13 | import CombinedStorage from './storages/combined-storage'; 14 | import StoreStatus from './store-status'; 15 | import type { 16 | IConstructableStore, 17 | IGroupedStores, 18 | IManagerOptions, 19 | IManagerParams, 20 | IPersistOptions, 21 | IStoreParams, 22 | IStorePersisted, 23 | TAnyStore, 24 | TInitStore, 25 | TStoreDefinition, 26 | TStores, 27 | } from './types'; 28 | import wakeup from './wakeup'; 29 | 30 | /** 31 | * Mobx stores manager 32 | */ 33 | class Manager { 34 | /** 35 | * Manger instance 36 | */ 37 | protected static instance: Manager; 38 | 39 | /** 40 | * Created stores 41 | */ 42 | protected readonly stores = new Map(); 43 | 44 | /** 45 | * Relations between stores 46 | */ 47 | protected readonly storesRelations = new Map< 48 | string, // contextId 49 | { ids: Set; parentId: string | null; componentName?: string } 50 | >(); 51 | 52 | /** 53 | * Save persisted stores identities 54 | */ 55 | protected static readonly persistedStores = new Set(); 56 | 57 | /** 58 | * Initial stores state (local storage, custom etc.) 59 | */ 60 | protected readonly initState: Record; 61 | 62 | /** 63 | * Storage for persisted stores 64 | */ 65 | public readonly storage?: CombinedStorage; 66 | 67 | /** 68 | * Additional store's constructor params 69 | */ 70 | protected readonly storesParams: IManagerParams['storesParams']; 71 | 72 | /** 73 | * Manager options 74 | */ 75 | public readonly options: IManagerOptions = { 76 | shouldDisablePersist: false, 77 | shouldRemoveInitState: true, 78 | failedCreationStrategy: 'empty', 79 | }; 80 | 81 | /** 82 | * Suspense stores relations 83 | * @see withStores 84 | */ 85 | protected suspenseRelations: Map> = new Map(); 86 | 87 | /** 88 | * Mobx manager logger 89 | */ 90 | protected readonly logger: Logger; 91 | 92 | /** 93 | * @constructor 94 | */ 95 | public constructor({ initState, storesParams, storage, options, logger }: IManagerParams = {}) { 96 | this.initState = initState || {}; 97 | this.storesParams = storesParams || {}; 98 | this.logger = 99 | logger && 'log' in logger 100 | ? logger 101 | : new Logger({ level: 3, ...(logger ?? {}), manager: this }); 102 | this.storage = 103 | storage instanceof CombinedStorage 104 | ? storage 105 | : storage 106 | ? new CombinedStorage({ default: storage }) 107 | : undefined; 108 | 109 | Object.assign(this.options, options || {}); 110 | 111 | Manager.instance = this; 112 | 113 | // only client side 114 | if (typeof window !== 'undefined') { 115 | const state = window.mbxM; 116 | 117 | window.mbxM = { push: this.pushInitState }; 118 | 119 | (Array.isArray(state) ? state : []).forEach(this.pushInitState); 120 | } 121 | } 122 | 123 | /** 124 | * Init store manager 125 | */ 126 | public async init(): Promise { 127 | try { 128 | if (this.storage) { 129 | await this.storage.get(); 130 | } 131 | } catch (e) { 132 | this.logger.err('Failed initialized store manager: ', e); 133 | } 134 | 135 | return this; 136 | } 137 | 138 | /** 139 | * Get manager instance 140 | */ 141 | public static get(): Manager { 142 | if (!Manager.instance) { 143 | throw new Error('Store manager is not initialized.'); 144 | } 145 | 146 | return Manager.instance; 147 | } 148 | 149 | /** 150 | * Get all stores 151 | */ 152 | public getStores(): Manager['stores'] { 153 | return this.stores; 154 | } 155 | 156 | /** 157 | * Get stores relations 158 | */ 159 | public getStoresRelations(): Manager['storesRelations'] { 160 | return this.storesRelations; 161 | } 162 | 163 | /** 164 | * Get suspense relations with stores 165 | */ 166 | public getSuspenseRelations(): Manager['suspenseRelations'] { 167 | return this.suspenseRelations; 168 | } 169 | 170 | /** 171 | * Get persisted stores ids 172 | */ 173 | public static getPersistedStoresIds(): Set { 174 | return Manager.persistedStores; 175 | } 176 | 177 | /** 178 | * Push initial state dynamically 179 | * E.g. when stream html 180 | */ 181 | public pushInitState = (storesState: Record = {}): void => { 182 | for (const [storeId, state] of Object.entries(storesState)) { 183 | this.initState[storeId] = state; 184 | } 185 | }; 186 | 187 | /** 188 | * Get store identity 189 | */ 190 | protected getStoreId( 191 | store: IConstructableStore | TInitStore, 192 | params: IStoreParams = {}, 193 | ): string { 194 | const { id, contextId, key } = params; 195 | 196 | if (id) { 197 | return id; 198 | } 199 | 200 | if (store.libStoreId) { 201 | return store.libStoreId; 202 | } 203 | 204 | let storeId = (store['id'] as string) || (store['name'] as string) || store.constructor.name; 205 | 206 | if (store.isGlobal) { 207 | return storeId; 208 | } 209 | 210 | storeId = `${storeId}--${contextId!}`; 211 | 212 | return key ? `${storeId}--${key}` : storeId; 213 | } 214 | 215 | /** 216 | * Get exist store 217 | */ 218 | public getStore(store: IConstructableStore, params: IStoreParams = {}): T | undefined { 219 | const storeId = this.getStoreId(store, params); 220 | 221 | // full match 222 | if (this.stores.has(storeId)) { 223 | return this.stores.get(storeId) as T; 224 | } 225 | 226 | // in case with global store (create if not exist) 227 | if (store.isGlobal) { 228 | return this.createStore(store, { 229 | id: storeId, 230 | contextId: 'global', 231 | parentId: ROOT_CONTEXT_ID, 232 | suspenseId: '', 233 | componentName: 'root-app', 234 | componentProps: {}, 235 | }); 236 | } 237 | 238 | // try to look up store in current or parent context 239 | return this.lookupStore(storeId, params) as T; 240 | } 241 | 242 | /** 243 | * Lookup store 244 | */ 245 | protected lookupStore(id: string, params: IStoreParams): TInitStore | undefined { 246 | const { contextId, parentId: defaultParentId } = params; 247 | const clearId = id.split('--')?.[0]; 248 | const { ids, parentId } = this.storesRelations.get(contextId!) ?? { 249 | ids: new Set(), 250 | parentId: defaultParentId, 251 | }; 252 | 253 | const matchedIds = [...ids].filter((storeId) => storeId.startsWith(`${clearId}--`)); 254 | 255 | if (matchedIds.length === 1) { 256 | return this.stores.get(matchedIds[0]); 257 | } else if (matchedIds.length > 1) { 258 | this.logger.err( 259 | 'Parent context has multiple stores with the same id, please pass key to getStore function.', 260 | ); 261 | 262 | return undefined; 263 | } 264 | 265 | if (!parentId || parentId === ROOT_CONTEXT_ID) { 266 | return undefined; 267 | } 268 | 269 | return this.lookupStore(id, { contextId: this.getBiggerContext(parentId, defaultParentId) }); 270 | } 271 | 272 | /** 273 | * Get bigger context from two 274 | */ 275 | protected getBiggerContext(ctx1?: string, ctx2?: string): string | undefined { 276 | if (!ctx1) { 277 | return ctx2; 278 | } else if (!ctx2) { 279 | return ctx1; 280 | } 281 | 282 | const regexp = /[^a-zA-Z]/g; 283 | 284 | return ctx1.replace(regexp, '') > ctx2.replace(regexp, '') ? ctx1 : ctx2; 285 | } 286 | 287 | /** 288 | * Create new store instance 289 | */ 290 | protected createStore( 291 | store: IConstructableStore, 292 | params: Omit, 'key'>, 293 | ): T { 294 | const { id, contextId, parentId, suspenseId, componentName, componentProps } = params; 295 | 296 | // only for global store 297 | if (this.stores.has(id)) { 298 | return this.stores.get(id) as T; 299 | } 300 | 301 | const newStore = new store({ 302 | ...this.storesParams, 303 | storeManager: this, 304 | getStore: ( 305 | targetStore: IConstructableStore, 306 | targetParams = { contextId, parentId }, 307 | ) => this.getStore(targetStore, targetParams), 308 | componentProps, 309 | initState: this.initState[id], 310 | }); 311 | 312 | // assign params to new store 313 | newStore.libStoreId = id; 314 | newStore.isGlobal = store.isGlobal; 315 | newStore.libStoreContextId = store.isGlobal ? 'global' : contextId; 316 | newStore.libStoreParentId = 317 | store.isGlobal || !parentId || parentId === contextId ? ROOT_CONTEXT_ID : parentId; 318 | newStore.libStoreSuspenseId = suspenseId; 319 | newStore.libStoreComponentName = componentName; 320 | 321 | this.setStoreStatus(newStore, store.isGlobal ? StoreStatus.inUse : StoreStatus.init); 322 | this.prepareStore(newStore); 323 | EventManager.publish(Events.CREATE_STORE, { store }); 324 | 325 | return newStore as T; 326 | } 327 | 328 | /** 329 | * Create stores for component 330 | * 331 | * NOTE: use only inside withStores wrapper 332 | */ 333 | public createStores( 334 | map: [string, TStoreDefinition][], 335 | parentId: string, 336 | contextId: string, 337 | suspenseId: string, 338 | componentName: string, 339 | componentProps: Record = {}, 340 | ): IGroupedStores { 341 | const { failedCreationStrategy } = this.options; 342 | 343 | const result = map.reduce( 344 | (res, [key, store]) => { 345 | const { 346 | id, 347 | store: s, 348 | isParent = false, 349 | } = 'store' in store ? store : { store, id: undefined, isParent: false }; 350 | let storeId = 351 | id || 352 | (isParent 353 | ? (this.getStore(s, { contextId, parentId })?.libStoreId as string) 354 | : this.getStoreId(s, { key, contextId })); 355 | 356 | if (!storeId) { 357 | const msg = `Cannot find or create store '${key}': '${this.getStoreId(s)}'`; 358 | 359 | this.logger.warn(msg); 360 | this.logger.debug( 361 | msg, 362 | { contextId, parentId, suspenseId, componentName, isParent }, 363 | true, 364 | ); 365 | 366 | if (failedCreationStrategy === 'dummy') { 367 | // try to force create store 368 | storeId = this.getStoreId(s, { key, contextId }); 369 | } else { 370 | if (failedCreationStrategy === 'empty') { 371 | res.hasCreationFailure = true; 372 | } 373 | 374 | return res; 375 | } 376 | } 377 | 378 | const storeInstance = this.createStore(s, { 379 | id: storeId, 380 | contextId, 381 | parentId, 382 | suspenseId, 383 | componentName, 384 | componentProps, 385 | }); 386 | 387 | if (isParent) { 388 | res.parentStores[key] = storeInstance; 389 | } else if (storeInstance.isGlobal) { 390 | res.globalStores[key] = storeInstance; 391 | } else { 392 | res.relativeStores[key] = storeInstance; 393 | } 394 | 395 | return res; 396 | }, 397 | { relativeStores: {}, parentStores: {}, globalStores: {}, hasCreationFailure: false }, 398 | ); 399 | 400 | // need create context relation in case when component doesn't include relative stores 401 | this.createRelationContext(contextId, parentId, componentName); 402 | 403 | return result; 404 | } 405 | 406 | /** 407 | * Create empty relation context 408 | */ 409 | protected createRelationContext( 410 | contextId: string, 411 | parentId?: string, 412 | componentName?: string, 413 | ): void { 414 | if (this.storesRelations.has(contextId)) { 415 | return; 416 | } 417 | 418 | this.storesRelations.set(contextId, { 419 | ids: new Set(), 420 | parentId: !parentId || parentId === contextId ? ROOT_CONTEXT_ID : parentId, 421 | componentName, 422 | }); 423 | } 424 | 425 | /** 426 | * Delete relation context id 427 | */ 428 | protected removeRelationContext(contextId: string): void { 429 | const storesRelations = this.storesRelations.get(contextId); 430 | 431 | if (!storesRelations || contextId === ROOT_CONTEXT_ID || storesRelations.ids.size > 0) { 432 | return; 433 | } 434 | 435 | this.storesRelations.delete(contextId); 436 | } 437 | 438 | /** 439 | * Prepare store before usage 440 | */ 441 | protected prepareStore(store: TStores[string]): void { 442 | const storeId = store.libStoreId!; 443 | const contextId = store.libStoreContextId!; 444 | const suspenseId = store.libStoreSuspenseId!; 445 | 446 | if (this.stores.has(storeId)) { 447 | return; 448 | } 449 | 450 | // restore initial state from server 451 | const initState = this.initState[storeId]; 452 | 453 | if (initState) { 454 | deepMerge(store, initState); 455 | } 456 | 457 | // restore persisted state 458 | if ('wakeup' in store && Manager.persistedStores.has(storeId)) { 459 | store.wakeup?.({ 460 | initState, 461 | persistedState: this.storage?.getStoreData(store), 462 | manager: this, 463 | }); 464 | } 465 | 466 | // track changes in persisted store 467 | if (Manager.persistedStores.has(storeId) && 'addOnChangeListener' in store) { 468 | const onDestroyDefault = store.onDestroy?.bind(store); 469 | const removeListener = store.addOnChangeListener!(store, this); 470 | 471 | store.onDestroy = () => { 472 | removeListener?.(); 473 | onDestroyDefault?.(); 474 | }; 475 | } 476 | 477 | store.init?.(); 478 | this.createRelationContext(contextId, store.libStoreParentId, store.libStoreComponentName); 479 | 480 | if (!this.suspenseRelations.has(suspenseId)) { 481 | this.suspenseRelations.set(suspenseId, new Set()); 482 | } 483 | 484 | const { ids } = this.storesRelations.get(contextId)!; 485 | 486 | // add store to manager 487 | this.stores.set(storeId, store); 488 | ids.add(storeId); 489 | // add store relation with suspense 490 | this.suspenseRelations.get(suspenseId)!.add(storeId); 491 | } 492 | 493 | /** 494 | * Remove store 495 | */ 496 | protected removeStore(store: TStores[string]): void { 497 | const storeId = store.libStoreId!; 498 | const suspenseId = store.libStoreSuspenseId!; 499 | const { ids } = this.storesRelations.get(store.libStoreContextId!) ?? { ids: new Set() }; 500 | 501 | if (!this.stores.has(storeId)) { 502 | return; 503 | } 504 | 505 | this.stores.delete(storeId); 506 | ids.delete(storeId); 507 | 508 | if (suspenseId && this.suspenseRelations.get(suspenseId)?.has(storeId)) { 509 | this.suspenseRelations.get(suspenseId)!.delete(storeId); 510 | } 511 | 512 | this.removeRelationContext(store.libStoreContextId!); 513 | 514 | if ('onDestroy' in store) { 515 | store.onDestroy?.(); 516 | } 517 | 518 | EventManager.publish(Events.DELETE_STORE, { store }); 519 | } 520 | 521 | /** 522 | * Mount stores to component 523 | * 524 | * NOTE: use only inside withStores wrapper 525 | */ 526 | public mountStores( 527 | contextId: string, 528 | { globalStores = {}, relativeStores = {} }: Partial, 529 | ): () => void { 530 | const { shouldRemoveInitState } = this.options; 531 | const touchableStores = { ...globalStores, ...relativeStores }; 532 | 533 | Object.values(touchableStores).forEach((store) => { 534 | const storeId = store.libStoreId!; 535 | 536 | // cleanup init state 537 | if (shouldRemoveInitState && this.initState[storeId]) { 538 | delete this.initState[storeId]; 539 | } 540 | 541 | this.setStoreStatus(store, StoreStatus.inUse); 542 | EventManager.publish(Events.MOUNT_STORE, { store }); 543 | }); 544 | 545 | return () => { 546 | Object.values(touchableStores).forEach((store) => { 547 | if (store.isGlobal) { 548 | return; 549 | } 550 | 551 | this.setStoreStatus(store, StoreStatus.unused); 552 | EventManager.publish(Events.UNMOUNT_STORE, { store }); 553 | }); 554 | 555 | this.removeRelationContext(contextId); 556 | }; 557 | } 558 | 559 | /** 560 | * Change the stores status to touched 561 | */ 562 | public touchedStores(stores: TStores): void { 563 | Object.values(stores).forEach((store) => { 564 | if (store.libStoreStatus !== StoreStatus.init || store.isGlobal) { 565 | return; 566 | } 567 | 568 | this.setStoreStatus(store, StoreStatus.touched); 569 | }); 570 | } 571 | 572 | /** 573 | * Change store status 574 | */ 575 | protected setStoreStatus(store: TStores[string], status: StoreStatus): void { 576 | const { destroyTimers: { init = 500, touched = 10000, unused = 1000 } = {} } = this.options; 577 | 578 | store.libStoreStatus = status; 579 | 580 | clearTimeout(store.libDestroyTimer); 581 | 582 | let destroyTime = 0; 583 | 584 | switch (status) { 585 | case StoreStatus.init: 586 | destroyTime = init; 587 | break; 588 | 589 | case StoreStatus.touched: 590 | destroyTime = touched; 591 | break; 592 | 593 | case StoreStatus.unused: 594 | destroyTime = unused; 595 | break; 596 | } 597 | 598 | if (!destroyTime) { 599 | return; 600 | } 601 | 602 | store.libDestroyTimer = setTimeout(() => this.removeStore(store), destroyTime); 603 | } 604 | 605 | /** 606 | * Get store state 607 | */ 608 | public getStoreState(store: TAnyStore, withNotExported = false): Record { 609 | return store.toJSON?.() ?? Manager.getObservableProps(store, withNotExported); 610 | } 611 | 612 | /** 613 | * Get store's state 614 | */ 615 | public toJSON(ids?: string[], isIncludeExported = false): Record { 616 | const result = {}; 617 | const stores = Array.isArray(ids) 618 | ? ids.reduce((res, id) => { 619 | if (this.stores.has(id)) { 620 | res.set(id, this.stores.get(id)!); 621 | } 622 | 623 | return res; 624 | }, new Map()) 625 | : this.stores; 626 | 627 | for (const [storeId, store] of stores.entries()) { 628 | result[storeId] = this.getStoreState(store, isIncludeExported); 629 | } 630 | 631 | return result; 632 | } 633 | 634 | /** 635 | * Save persisted store state to provided storage 636 | */ 637 | public async savePersistedStore(store: IStorePersisted): Promise { 638 | if (this.options.shouldDisablePersist || !this.storage) { 639 | return false; 640 | } 641 | 642 | try { 643 | await this.storage.saveStoreData(store, this.getStoreState(store, true)); 644 | 645 | return true; 646 | } catch (e) { 647 | this.logger.err('Failed to persist stores: ', e); 648 | } 649 | 650 | return false; 651 | } 652 | 653 | /** 654 | * Get observable store props (fields) 655 | */ 656 | public static getObservableProps(store: TAnyStore, withNotExported = false): Record { 657 | const props = toJS(store); 658 | 659 | return Object.entries(props).reduce( 660 | (res, [prop, value]) => ({ 661 | ...res, 662 | ...((isObservableProp(store, prop) && 663 | !isPropExcludedFromExport(store, prop, withNotExported)) || 664 | isPropSimpleExported(store, prop) 665 | ? { [prop]: value } 666 | : {}), 667 | ...(isPropObservableExported(store, prop) 668 | ? { [prop]: Manager.getObservableProps(store[prop] as TAnyStore) } 669 | : {}), 670 | }), 671 | {}, 672 | ); 673 | } 674 | 675 | /** 676 | * Persist store 677 | */ 678 | public static persistStore( 679 | store: IConstructableStore, 680 | id: string, 681 | options: IPersistOptions = {}, 682 | ): IConstructableStore { 683 | Manager.persistedStores.add(id); 684 | 685 | store.libStoreId = id; 686 | 687 | // add storage options 688 | if (!('libStorageOptions' in store.prototype)) { 689 | store.prototype.libStorageOptions = options; 690 | } 691 | 692 | // add default wakeup handler 693 | if (!('wakeup' in store.prototype)) { 694 | store.prototype.wakeup = wakeup; 695 | } 696 | 697 | // add default changes listener 698 | if (!('addOnChangeListener' in store.prototype)) { 699 | store.prototype.addOnChangeListener = onChangeListener; 700 | } 701 | 702 | return store; 703 | } 704 | } 705 | 706 | export default Manager; 707 | -------------------------------------------------------------------------------- /src/on-change-listener.ts: -------------------------------------------------------------------------------- 1 | import { reaction, toJS } from 'mobx'; 2 | import type { IStorePersisted } from './types'; 3 | 4 | /** 5 | * Listen persist store changes 6 | */ 7 | const onChangeListener: IStorePersisted['addOnChangeListener'] = (store, manager) => { 8 | if (manager.options.shouldDisablePersist || !manager.storage) { 9 | return; 10 | } 11 | 12 | return reaction( 13 | () => store.toJSON?.() ?? toJS(store), 14 | () => { 15 | void manager.savePersistedStore(store); 16 | }, 17 | ); 18 | }; 19 | 20 | export default onChangeListener; 21 | -------------------------------------------------------------------------------- /src/plugins/dev-extension/index.ts: -------------------------------------------------------------------------------- 1 | import type Manager from '../../manager'; 2 | import StateListener from './state-listener'; 3 | 4 | function connectDevExtension(storeManager: Manager): void { 5 | window['__MOBX_STORE_MANAGER__'] = new StateListener(storeManager).subscribe(); 6 | } 7 | 8 | export default connectDevExtension; 9 | -------------------------------------------------------------------------------- /src/plugins/dev-extension/state-listener.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { spy } from 'mobx'; 3 | import { ROOT_CONTEXT_ID } from '@src/constants'; 4 | import Manager from '../../manager'; 5 | 6 | enum Listeners { 7 | SPY = 'spy', 8 | } 9 | 10 | /** 11 | * State listener 12 | */ 13 | class StateListener { 14 | /** 15 | * @protected 16 | */ 17 | protected manager: Manager; 18 | 19 | /** 20 | * Store global listeners 21 | * @protected 22 | */ 23 | protected static listeners: Record void> = {} as never; 24 | 25 | /** 26 | * @constructor 27 | */ 28 | public constructor(manager: Manager) { 29 | this.manager = manager; 30 | 31 | Object.values(StateListener.listeners).forEach((unsubscribe) => { 32 | unsubscribe(); 33 | }); 34 | } 35 | 36 | /** 37 | * Get context tree key 38 | * @protected 39 | */ 40 | protected getContextKey(contextId: string, nestedKey?: string): string { 41 | if (contextId === ROOT_CONTEXT_ID) { 42 | return contextId; 43 | } 44 | 45 | const { parentId } = this.manager.getStoresRelations().get(contextId) ?? {}; 46 | 47 | if (!parentId || parentId === ROOT_CONTEXT_ID) { 48 | return `${parentId ?? ROOT_CONTEXT_ID}.${nestedKey ?? contextId}`; 49 | } 50 | 51 | return this.getContextKey(parentId, `${parentId}.${nestedKey ?? contextId}`); 52 | } 53 | 54 | /** 55 | * Get stores state 56 | * @protected 57 | */ 58 | protected getStoresState(): { root: Record } { 59 | const state: { root: Record } = { root: {} }; 60 | 61 | try { 62 | const stores = this.manager.getStores(); 63 | 64 | this.manager.getStoresRelations().forEach(({ ids, componentName }, contextId) => { 65 | const key = this.getContextKey(contextId); 66 | 67 | ids.forEach((id) => { 68 | const store = stores.get(id); 69 | 70 | if (store) { 71 | const storeState = store?.toJSON?.() ?? Manager.getObservableProps(store); 72 | 73 | _.set(state, `${key}.stores.${id}`, storeState); 74 | _.set(state, `${key}.componentName`, componentName); 75 | } 76 | }); 77 | }); 78 | } catch (e) { 79 | // manager has not initialized yet 80 | } 81 | 82 | return state; 83 | } 84 | 85 | /** 86 | * Subscribe on stores changes 87 | * @protected 88 | */ 89 | public subscribe(): Manager { 90 | StateListener.listeners[Listeners.SPY] = spy((event) => { 91 | if (['report-end', 'reaction'].includes(event.type)) { 92 | return; 93 | } 94 | 95 | this.manager?.['__devOnChange']?.({ 96 | event: _.cloneDeep(event), 97 | storesState: this.getStoresState(), 98 | }); 99 | }); 100 | 101 | return this.manager; 102 | } 103 | } 104 | 105 | export default StateListener; 106 | -------------------------------------------------------------------------------- /src/plugins/helpers.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | 5 | type ICache = Map; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | 10 | const cacheFolder = `${ 11 | __dirname.split('node_modules')[0] 12 | }node_modules/.cache/@lomray/react-mobx-manager`; 13 | const cacheFile = `${cacheFolder}/store-ids.json`; 14 | 15 | /** 16 | * Load cached store id's by file name 17 | */ 18 | const loadCache = (isProd = false): ICache => { 19 | if (isProd && fs.existsSync(cacheFile)) { 20 | const cache = JSON.parse(fs.readFileSync(cacheFile, { encoding: 'utf-8' })); 21 | 22 | return new Map(cache as any[]); 23 | } 24 | 25 | return new Map(); 26 | }; 27 | 28 | /** 29 | * Save store id's cache 30 | */ 31 | const saveCache = (cache: ICache): void => { 32 | if (!fs.existsSync(cacheFolder)) { 33 | fs.mkdirSync(cacheFolder, { recursive: true }); 34 | } 35 | 36 | fs.writeFileSync(cacheFile, JSON.stringify([...cache.entries()], null, 2), { encoding: 'utf-8' }); 37 | }; 38 | 39 | /** 40 | * Get next letter 41 | */ 42 | const getNextLetter = (str = ''): string => { 43 | const letters = str.split(''); 44 | const letter = letters.pop() ?? '`'; // default char code is '`' and next is 'a' 45 | 46 | if (letter === 'z') { 47 | return [...letters, 'A'].join(''); 48 | } else if (letter === 'Z') { 49 | const prevLetter = letters.pop(); 50 | 51 | if (!prevLetter) { 52 | return 'aa'; 53 | } 54 | 55 | return [getNextLetter([...letters, prevLetter].join('')), 'a'].join(''); 56 | } 57 | 58 | return [...letters, String.fromCharCode(letter.charCodeAt(0) + 1)].join(''); 59 | }; 60 | 61 | /** 62 | * Store is generator 63 | */ 64 | class Generator { 65 | public cache: ICache; 66 | protected root: string; // project root 67 | protected lastId: string; // keep last generated production store id (letter) 68 | 69 | constructor(root: string, isProd = false) { 70 | this.root = root; 71 | this.cache = loadCache(isProd); 72 | } 73 | 74 | /** 75 | * Inject store id 76 | */ 77 | public injectId = (code: string, fileId: string): string => { 78 | const { classname, storeId } = this.cache.get(fileId)!; 79 | const regexp = new RegExp(`(class\\s${classname}\\s+?{)`); 80 | 81 | return code.replace(regexp, `$1static id = '${storeId}';`); 82 | }; 83 | 84 | /** 85 | * Get development id 86 | */ 87 | public getDevId = (id: string, classname: string): string => { 88 | const cleanPath = id 89 | .replace(this.root, '') 90 | .replace(/\/index.(js|ts|tsx)/, '') 91 | .split('/') 92 | .filter(Boolean) 93 | .join('-'); 94 | 95 | return `${cleanPath}-${classname}`; 96 | }; 97 | 98 | /** 99 | * Get production store id 100 | */ 101 | public getProdId = (): string => { 102 | const nextLetter = getNextLetter(this.lastId); 103 | const id = `S${nextLetter}`; 104 | 105 | this.lastId = nextLetter; 106 | 107 | return id; 108 | }; 109 | 110 | /** 111 | * Try to find mobx store 112 | */ 113 | public matchMobxStore = (code: string): string | undefined => { 114 | /** 115 | * Match store pattern 1 116 | * 117 | * 1. Match 'class Classname' (capture Classname) 118 | * 2. Except 'static id =' 119 | * 3. Include makeObservable or makeAutoObservable 120 | * 4. Except persistStore(Classname 121 | */ 122 | const { classname } = 123 | code.match( 124 | /class\s(?\w+)\s+?{(?!.*static\sid\s*=.*).+(makeObservable|makeAutoObservable)(?!.*persistStore\(\1.*)/s, 125 | )?.groups ?? {}; 126 | 127 | if (classname) { 128 | return classname; 129 | } 130 | 131 | /** 132 | * Match store pattern 2 133 | * 134 | * 1. Match '@mobx-store' in jsdoc before class 135 | * 2. Match 'class Classname' (capture Classname) 136 | * 3. Except 'static id =' 137 | * 4. Except persistStore(Classname 138 | */ 139 | const { classname: classnameSecond } = 140 | code.match( 141 | /(@mobx-store).+class\s(?\w+)\s+?{(?!.*static\sid\s*=.*).+}(?!.*persistStore.*)/s, 142 | )?.groups ?? {}; 143 | 144 | return classnameSecond; 145 | }; 146 | } 147 | 148 | export { saveCache, loadCache, getNextLetter, Generator }; 149 | -------------------------------------------------------------------------------- /src/plugins/vite/id-generator.ts: -------------------------------------------------------------------------------- 1 | import { extname } from 'node:path'; 2 | import { cwd } from 'node:process'; 3 | import type { Plugin } from 'vite'; 4 | import { Generator, saveCache } from '../helpers'; 5 | 6 | export interface IPluginOptions { 7 | root?: string; // default: process.cwd() 8 | isProd?: boolean; 9 | } 10 | 11 | /** 12 | * Generate unique store id's 13 | * 14 | * Detect mobx store: 15 | * - by makeObservable or makeAutoObservable 16 | * - by @mobx-store jsdoc before class 17 | * @constructor 18 | */ 19 | function IdGenerator({ root = cwd(), isProd = false }: IPluginOptions = {}): Plugin { 20 | const service = new Generator(root, isProd); 21 | 22 | return { 23 | name: '@lomray/react-mobx-manager-id-generator', 24 | transform(code, id) { 25 | const [extName] = extname(id).split('?'); 26 | 27 | if ( 28 | id.includes('node_modules') || 29 | !['.js', '.ts', '.tsx'].includes(extName) || 30 | !/(makeObservable|makeAutoObservable)\(/.test(code) 31 | ) { 32 | return; 33 | } 34 | 35 | if (service.cache.has(id)) { 36 | return { 37 | code: service.injectId(code, id), 38 | map: { mappings: '' }, 39 | }; 40 | } 41 | 42 | const classname = service.matchMobxStore(code); 43 | 44 | if (!classname) { 45 | return; 46 | } 47 | 48 | if (!service.cache.has(id)) { 49 | const storeId = isProd ? service.getProdId() : service.getDevId(id, classname); 50 | 51 | service.cache.set(id, { classname, storeId }); 52 | } 53 | 54 | return { 55 | code: service.injectId(code, id), 56 | map: { mappings: '' }, 57 | }; 58 | }, 59 | buildEnd() { 60 | if (!isProd) { 61 | return; 62 | } 63 | 64 | saveCache(service.cache); 65 | }, 66 | }; 67 | } 68 | 69 | export default IdGenerator; 70 | -------------------------------------------------------------------------------- /src/plugins/vite/index.ts: -------------------------------------------------------------------------------- 1 | import * as process from 'node:process'; 2 | import type { Plugin, TransformResult } from 'vite'; 3 | import IdGenerator from './id-generator'; 4 | 5 | const isProduction = (mode?: string): boolean => 6 | mode === 'production' || process.env.NODE_ENV === 'production'; 7 | 8 | /** 9 | * Mobx manager vite plugins 10 | * @constructor 11 | */ 12 | function ViteReactMobxManager(): Plugin[] { 13 | let idGeneratorPlugin: Plugin; 14 | 15 | return [ 16 | { 17 | name: IdGenerator().name, 18 | configResolved({ root }) { 19 | idGeneratorPlugin = IdGenerator({ root, isProd: isProduction() }); 20 | }, 21 | transform(...args) { 22 | return idGeneratorPlugin.transform?.['call'](this, ...args) as TransformResult | undefined; 23 | }, 24 | buildEnd(...args) { 25 | idGeneratorPlugin.buildEnd?.['call'](this, ...args); 26 | }, 27 | }, 28 | ]; 29 | } 30 | 31 | export default ViteReactMobxManager; 32 | -------------------------------------------------------------------------------- /src/storages/async-storage.ts: -------------------------------------------------------------------------------- 1 | import type { IStorage } from '../types'; 2 | 3 | interface IAsyncStorage { 4 | getItem: (key: string) => Promise; 5 | setItem: (key: string, value: string) => Promise; 6 | removeItem: (key: string) => Promise; 7 | } 8 | 9 | interface IAsyncStorageOptions { 10 | storage: IAsyncStorage; 11 | globalKey?: string; 12 | } 13 | 14 | /** 15 | * Async storage for mobx store manager 16 | */ 17 | class AsyncStorage implements IStorage { 18 | /** 19 | * Cookie storage key 20 | */ 21 | protected globalKey: string; 22 | 23 | /** 24 | * @protected 25 | */ 26 | protected storage: IAsyncStorage; 27 | 28 | /** 29 | * @constructor 30 | */ 31 | constructor({ storage, globalKey }: IAsyncStorageOptions) { 32 | this.storage = storage; 33 | this.globalKey = globalKey ?? 'stores'; 34 | } 35 | 36 | /** 37 | * @inheritDoc 38 | */ 39 | async get(): Promise | undefined> { 40 | try { 41 | return JSON.parse((await this.storage.getItem(this.globalKey)) || '{}') as Record< 42 | string, 43 | any 44 | >; 45 | } catch (e) { 46 | console.error('Failed to get item from async storage:', e); 47 | 48 | return {}; 49 | } 50 | } 51 | 52 | /** 53 | * @inheritDoc 54 | */ 55 | async flush(): Promise { 56 | try { 57 | return await this.storage.removeItem(this.globalKey); 58 | } catch (e) { 59 | console.error('Failed to flush async storage key:', e); 60 | } 61 | } 62 | 63 | /** 64 | * @inheritDoc 65 | */ 66 | async set(value: Record | undefined): Promise { 67 | try { 68 | return await this.storage.setItem(this.globalKey, JSON.stringify(value || '{}')); 69 | } catch (e) { 70 | console.error('Failed to set value to async storage:', e); 71 | } 72 | } 73 | } 74 | 75 | export default AsyncStorage; 76 | -------------------------------------------------------------------------------- /src/storages/combined-storage.ts: -------------------------------------------------------------------------------- 1 | import deepCompare from '../deep-compare'; 2 | import type { IPersistOptions, IStorage, IStorePersisted } from '../types'; 3 | 4 | interface ICombinedStorage { 5 | [name: string]: IStorage; 6 | } 7 | 8 | /** 9 | * Combined storage for mobx store manager 10 | */ 11 | class CombinedStorage implements IStorage { 12 | /** 13 | * @protected 14 | */ 15 | protected storages: ICombinedStorage; 16 | 17 | /** 18 | * Restored persist storage data 19 | * @protected 20 | */ 21 | protected persistData: Record = {}; 22 | 23 | /** 24 | * Default storage id 25 | * @protected 26 | */ 27 | protected defaultId: string; 28 | 29 | /** 30 | * @constructor 31 | * 32 | * First storage will be used as default 33 | */ 34 | constructor(storages: ICombinedStorage) { 35 | this.storages = storages; 36 | this.defaultId = Object.keys(storages)?.[0]; 37 | } 38 | 39 | /** 40 | * @inheritDoc 41 | */ 42 | public async get(): Promise | undefined> { 43 | try { 44 | const data = await Promise.all( 45 | Object.values(this.storages).map((storage) => storage.get() || {}), 46 | ); 47 | 48 | this.persistData = Object.keys(this.storages).reduce( 49 | (res, key, index) => ({ 50 | ...res, 51 | [key]: data[index], 52 | }), 53 | {}, 54 | ); 55 | 56 | return this.persistData; 57 | } catch (e) { 58 | return {}; 59 | } 60 | } 61 | 62 | /** 63 | * @inheritDoc 64 | */ 65 | public flush(): void | Promise { 66 | return Promise.all(Object.values(this.storages).map((storage) => storage.flush())); 67 | } 68 | 69 | /** 70 | * @inheritDoc 71 | */ 72 | public set( 73 | value: Record | undefined, 74 | storageId?: string, 75 | ): ReturnType { 76 | const storage = this.storages[storageId ?? this.defaultId]; 77 | 78 | if (!storage) { 79 | return; 80 | } 81 | 82 | return storage.set(value); 83 | } 84 | 85 | /** 86 | * Return store storage options 87 | */ 88 | protected getStoreOptions(store: IStorePersisted): IPersistOptions { 89 | return { 90 | attributes: { 91 | [this.defaultId]: ['*'], 92 | }, 93 | behaviour: 'exclude', 94 | ...(store.libStorageOptions ?? {}), 95 | }; 96 | } 97 | 98 | /** 99 | * Return store persist data 100 | */ 101 | public getStoreData(store: IStorePersisted): Record | undefined { 102 | const storeId = store.libStoreId!; 103 | const { attributes } = this.getStoreOptions(store); 104 | 105 | return Object.entries(attributes!).reduce((res, [storageId, attr]) => { 106 | const storageData = this.persistData[storageId]?.[storeId] ?? {}; 107 | const allowedData = 108 | attr[0] === '*' 109 | ? storageData 110 | : attr.reduce( 111 | (r, attrName) => ({ 112 | ...r, 113 | ...(storageData[attrName] !== undefined 114 | ? { [attrName]: storageData[attrName] } 115 | : {}), 116 | }), 117 | {}, 118 | ); 119 | 120 | return { 121 | ...res, 122 | ...allowedData, 123 | }; 124 | }, {}); 125 | } 126 | 127 | /** 128 | * Save store data in storage 129 | */ 130 | public async saveStoreData( 131 | store: IStorePersisted, 132 | data: Record | undefined, 133 | ): Promise { 134 | const storeId = store.libStoreId!; 135 | const { attributes, behaviour } = this.getStoreOptions(store); 136 | const dataKeys = new Set(Object.keys(data ?? {})); 137 | 138 | const dataByStorages = Object.entries(attributes!).map(([storageId, attr]) => { 139 | const storeData = (attr[0] === '*' ? [...dataKeys] : attr).reduce((r, attrName) => { 140 | if (!dataKeys.has(attrName)) { 141 | return r; 142 | } 143 | 144 | if (behaviour === 'exclude') { 145 | dataKeys.delete(attrName); 146 | } 147 | 148 | return { 149 | ...r, 150 | [attrName]: data?.[attrName], 151 | }; 152 | }, {}); 153 | 154 | const newData = { 155 | ...(this.persistData?.[storageId] ?? {}), 156 | [storeId]: storeData, 157 | } as Record; 158 | 159 | // skip updating if nothing changed 160 | if (deepCompare(this.persistData?.[storageId]?.[storeId] ?? {}, storeData)) { 161 | return null; 162 | } 163 | 164 | if (!this.persistData[storageId]?.[storeId]) { 165 | this.persistData[storageId][storeId] = {}; 166 | } 167 | 168 | this.persistData[storageId][storeId] = storeData; 169 | 170 | return this.set(newData, storageId); 171 | }); 172 | 173 | await Promise.all(dataByStorages); 174 | } 175 | } 176 | 177 | export default CombinedStorage; 178 | -------------------------------------------------------------------------------- /src/storages/cookie-storage.ts: -------------------------------------------------------------------------------- 1 | import type { IStorage } from '../types'; 2 | 3 | interface ICookiesStorageAttributes { 4 | expires?: number | Date | undefined; 5 | path?: string | undefined; 6 | domain?: string | undefined; 7 | secure?: boolean | undefined; 8 | sameSite?: 'strict' | 'Strict' | 'lax' | 'Lax' | 'none' | 'None' | undefined; 9 | [property: string]: any; 10 | } 11 | 12 | interface ICookieStorage { 13 | get: (key: string) => string | null | undefined; 14 | set: (key: string, value: string, options: ICookiesStorageAttributes) => any; 15 | remove: (key: string, options: ICookiesStorageAttributes) => any; 16 | } 17 | 18 | interface ICookiesStorageOptions { 19 | storage: ICookieStorage; 20 | globalKey?: string; 21 | cookieAttr?: ICookiesStorageAttributes; 22 | } 23 | 24 | /** 25 | * Cookie storage for mobx store manager 26 | */ 27 | class CookieStorage implements IStorage { 28 | /** 29 | * Cookie storage key 30 | */ 31 | protected globalKey: string; 32 | 33 | /** 34 | * @protected 35 | */ 36 | protected storage: ICookieStorage; 37 | 38 | /** 39 | * Cookie attributes 40 | */ 41 | protected cookieAttr: ICookiesStorageAttributes; 42 | 43 | /** 44 | * @constructor 45 | */ 46 | constructor({ storage, cookieAttr, globalKey }: ICookiesStorageOptions) { 47 | this.storage = storage; 48 | this.cookieAttr = cookieAttr ?? {}; 49 | this.globalKey = globalKey ?? 'stores'; 50 | } 51 | 52 | /** 53 | * @inheritDoc 54 | */ 55 | public get(): Record | Promise | undefined> { 56 | try { 57 | return JSON.parse(this.storage.get(this.globalKey) || '{}') as Record; 58 | } catch (e) { 59 | return {}; 60 | } 61 | } 62 | 63 | /** 64 | * @inheritDoc 65 | */ 66 | public flush(): void | Promise { 67 | return this.storage.remove(this.globalKey, this.cookieAttr); 68 | } 69 | 70 | /** 71 | * @inheritDoc 72 | */ 73 | public set(value: Record | undefined): void { 74 | return this.storage.set(this.globalKey, JSON.stringify(value || '{}'), this.cookieAttr); 75 | } 76 | } 77 | 78 | export default CookieStorage; 79 | -------------------------------------------------------------------------------- /src/storages/local-storage.ts: -------------------------------------------------------------------------------- 1 | import type { IStorage } from '../types'; 2 | 3 | interface ILocalStorageOptions { 4 | globalKey?: string; 5 | storage?: Storage; 6 | } 7 | 8 | /** 9 | * Local storage for mobx store manager 10 | */ 11 | class LocalStorage implements IStorage { 12 | /** 13 | * Local storage key 14 | */ 15 | protected globalKey: string; 16 | 17 | /** 18 | * @protected 19 | */ 20 | protected storage: Storage; 21 | 22 | /** 23 | * @constructor 24 | */ 25 | constructor({ storage, globalKey }: ILocalStorageOptions = {}) { 26 | this.storage = storage ?? localStorage; 27 | this.globalKey = globalKey ?? 'stores'; 28 | } 29 | 30 | /** 31 | * @inheritDoc 32 | */ 33 | get(): Record | Promise | undefined> { 34 | try { 35 | return JSON.parse(this.storage.getItem(this.globalKey) || '{}') as Record; 36 | } catch (e) { 37 | return {}; 38 | } 39 | } 40 | 41 | /** 42 | * @inheritDoc 43 | */ 44 | flush(): void | Promise { 45 | return this.storage.removeItem(this.globalKey); 46 | } 47 | 48 | /** 49 | * @inheritDoc 50 | */ 51 | set(value: Record | undefined): void { 52 | return this.storage.setItem(this.globalKey, JSON.stringify(value || '{}')); 53 | } 54 | } 55 | 56 | export default LocalStorage; 57 | -------------------------------------------------------------------------------- /src/store-status.ts: -------------------------------------------------------------------------------- 1 | enum StoreStatus { 2 | init = 'init', // created, but never used and was not passed to the children components 3 | touched = 'touched', // store was passed to the children component 4 | inUse = 'in-use', 5 | unused = 'unused', 6 | } 7 | 8 | export default StoreStatus; 9 | -------------------------------------------------------------------------------- /src/suspense-query.ts: -------------------------------------------------------------------------------- 1 | import { makeExported } from './make-exported'; 2 | import type { TInitStore } from './types'; 3 | 4 | export interface IPromise extends Promise { 5 | status?: 'fulfilled' | 'pending' | 'rejected'; 6 | value?: TReturn; 7 | reason?: any; 8 | } 9 | 10 | interface ISuspenseQueryParams { 11 | fieldName?: string; // field name in target store for save suspense state 12 | errorFields?: string[]; 13 | } 14 | 15 | interface ISuspenseQueryOptions { 16 | hash?: unknown; 17 | } 18 | 19 | interface ISuspenseSubqueryOptions { 20 | id: string; 21 | hash: unknown; 22 | } 23 | 24 | /** 25 | * Run request and cache promise 26 | * Sync suspense status between server and client 27 | */ 28 | class SuspenseQuery { 29 | /** 30 | * @protected 31 | */ 32 | protected promise: Promise | undefined; 33 | 34 | /** 35 | * Subqueries info 36 | */ 37 | protected subqueries: Map }> = new Map(); 38 | 39 | /** 40 | * Target store 41 | */ 42 | protected readonly store: TInitStore; 43 | 44 | /** 45 | * @protected 46 | */ 47 | protected readonly params: Required; 48 | 49 | /** 50 | * @constructor 51 | */ 52 | constructor( 53 | store: TInitStore, 54 | { fieldName = 'sR', errorFields = ['name', 'message'] }: ISuspenseQueryParams = {}, 55 | ) { 56 | this.store = store; 57 | this.params = { fieldName, errorFields }; 58 | 59 | const defaultInit = store.init?.bind(store); 60 | 61 | store.init = () => { 62 | this.throwError(); // throw error immediately from server side if exist 63 | defaultInit?.(); 64 | }; 65 | 66 | makeExported(store, { [fieldName]: 'simple' }); 67 | } 68 | 69 | /** 70 | * Error to json 71 | */ 72 | protected errorJson(e: any): void { 73 | e.toJSON = () => 74 | this.params.errorFields.reduce( 75 | (res, name) => ({ 76 | ...res, 77 | [name]: e?.[name], 78 | }), 79 | {}, 80 | ); 81 | } 82 | 83 | /** 84 | * Assign custom error fields to error 85 | */ 86 | protected jsonToError(e: Error, values: Record): Error { 87 | this.params.errorFields.forEach((name) => { 88 | e[name] = values?.[name]; 89 | }); 90 | 91 | return e; 92 | } 93 | 94 | /** 95 | * Throw suspense error 96 | */ 97 | protected throwError(): void { 98 | const value = this.store[this.params.fieldName]; 99 | 100 | // pass error to error boundary 101 | if (value?.error) { 102 | throw this.jsonToError( 103 | new Error((value?.error?.message ?? value?.error?.name) as string), 104 | value?.error as Record, 105 | ); 106 | } 107 | } 108 | 109 | /** 110 | * Detect if suspense is restored from server side: 111 | * - throw error if exist 112 | * - skip run suspense if already completed 113 | */ 114 | protected isComplete(hash: unknown): boolean { 115 | const value = this.store[this.params.fieldName]; 116 | 117 | // pass error to error boundary 118 | if (value?.error) { 119 | this.throwError(); 120 | } 121 | 122 | return value?.done === true && value.hash === hash; 123 | } 124 | 125 | /** 126 | * Run request 127 | * Save request resolve status 128 | */ 129 | public query = ( 130 | promise: () => Promise, 131 | options: ISuspenseQueryOptions = {}, 132 | ): TReturn | undefined => { 133 | const { hash = '' } = options; 134 | const { fieldName } = this.params; 135 | 136 | if (this.isComplete(hash)) { 137 | return; 138 | } 139 | 140 | if (this.store[fieldName]?.hash !== hash) { 141 | this.store[fieldName] = { hash, done: false }; 142 | this.promise = undefined; 143 | } 144 | 145 | if (!this.promise) { 146 | this.promise = promise(); 147 | 148 | this.promise.then( 149 | () => { 150 | this.store[fieldName] = { hash, done: true }; 151 | }, 152 | (e) => { 153 | this.errorJson(e); 154 | 155 | this.store[fieldName] = { error: e }; 156 | }, 157 | ); 158 | } 159 | 160 | return SuspenseQuery.run(this.promise); 161 | }; 162 | 163 | /** 164 | * Run subquery 165 | * Re-fetch data from query by hash changes in children components 166 | * NOTE: only client side 167 | */ 168 | public subquery = ( 169 | promise: () => Promise, 170 | options: ISuspenseSubqueryOptions, 171 | ): TReturn | undefined => { 172 | const { id, hash } = options; 173 | const subquery = this.subqueries.get(id); 174 | 175 | // skip first run 176 | if (!subquery) { 177 | this.subqueries.set(id, { hash }); 178 | 179 | return undefined; 180 | } 181 | 182 | if (subquery?.hash === hash) { 183 | return SuspenseQuery.run(subquery?.promise); 184 | } 185 | 186 | const newQuery = promise(); 187 | 188 | this.subqueries.set(id, { hash, promise: newQuery }); 189 | 190 | return SuspenseQuery.run(newQuery); 191 | }; 192 | 193 | /** 194 | * Change status of promise. 195 | * Throw promise to react suspense 196 | */ 197 | public static run = (promise: IPromise | undefined): TReturn | undefined => { 198 | if (!promise) { 199 | return; 200 | } 201 | 202 | switch (promise.status) { 203 | case 'fulfilled': 204 | return promise.value; 205 | 206 | case 'pending': 207 | throw promise; 208 | 209 | case 'rejected': 210 | throw promise.reason; 211 | 212 | default: 213 | promise.status = 'pending'; 214 | 215 | promise.then( 216 | (result) => { 217 | promise.status = 'fulfilled'; 218 | promise.value = result; 219 | }, 220 | (reason) => { 221 | promise.status = 'rejected'; 222 | promise.reason = reason; 223 | }, 224 | ); 225 | } 226 | 227 | throw promise; 228 | }; 229 | } 230 | 231 | export default SuspenseQuery; 232 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type Events from './events'; 2 | import type { ILoggerOpts } from './logger'; 3 | import type Logger from './logger'; 4 | import type Manager from './manager'; 5 | import type CombinedStorage from './storages/combined-storage'; 6 | import type StoreStatus from './store-status'; 7 | 8 | export interface IWindowManager { 9 | push: (state: Record) => void; 10 | } 11 | 12 | declare global { 13 | // eslint-disable-next-line @typescript-eslint/naming-convention 14 | interface Window { 15 | mbxM: Record[] | IWindowManager; 16 | } 17 | } 18 | 19 | export interface IConstructorParams { 20 | storeManager: Manager; 21 | getStore: (store: IConstructableStore, params?: Partial) => T | undefined; 22 | componentProps: TProps; 23 | initState?: Record; 24 | } 25 | 26 | export interface IStoreLifecycle { 27 | onDestroy?: () => void; 28 | } 29 | 30 | export interface IStore extends IStoreLifecycle { 31 | libStoreId?: string; // static 32 | libStoreContextId?: string; // static 33 | libStoreParentId?: string; // static 34 | libStoreSuspenseId?: string; // static 35 | libStoreComponentName?: string; // static 36 | libStoreStatus?: StoreStatus; // static 37 | libDestroyTimer?: NodeJS.Timeout; 38 | isGlobal?: boolean; // static 39 | init?: () => void; 40 | toJSON?: () => Record; 41 | } 42 | 43 | export interface IStorePersisted extends IStore { 44 | libStorageOptions?: IPersistOptions; // static 45 | addOnChangeListener?: (store: IStorePersisted, manager: Manager) => (() => void) | undefined; 46 | wakeup?: TWakeup; 47 | } 48 | 49 | export type TInitStore = TSto & TAnyStore; 50 | 51 | export type IConstructableStore = (new ( 52 | props: IConstructorParams, 53 | ) => TInitStore) & 54 | Partial; 55 | 56 | /** 57 | * Store params 58 | */ 59 | export type IStoreConfig = { id?: string; isParent?: boolean }; 60 | 61 | export type TStoreDefinition = 62 | | IConstructableStore 63 | | ({ store: IConstructableStore } & IStoreConfig); 64 | 65 | export type TMapStores = Record; 66 | 67 | export interface IManagerParams { 68 | storesParams?: Omit; 69 | storage?: IStorage | CombinedStorage; 70 | options?: IManagerOptions; 71 | initState?: Record; 72 | logger?: Logger | Omit; 73 | } 74 | 75 | export type TWakeup = (state: { 76 | manager: Manager; 77 | initState?: Record; 78 | persistedState?: Record; 79 | }) => void; 80 | 81 | export interface IStorage { 82 | get: () => Record | undefined | Promise | undefined>; 83 | set: ( 84 | value: Record | undefined, 85 | ) => Record | undefined | Promise | void; 86 | flush: () => void | Promise; 87 | } 88 | 89 | export interface IManagerOptions { 90 | shouldDisablePersist?: boolean; // e.g. in server side 91 | shouldRemoveInitState?: boolean; // remove init state for store after initialize 92 | destroyTimers?: { 93 | init?: number; 94 | touched?: number; // NOTE: set to max timeout request 95 | unused?: number; 96 | }; 97 | /** 98 | * When for some strange reason stores cannot be created or found in the parent context: 99 | * none: don't do anything 100 | * dummy: force create empty store 101 | * empty (default): don't render component if any of the stores not created 102 | */ 103 | failedCreationStrategy?: 'none' | 'dummy' | 'empty'; 104 | } 105 | 106 | export type TAnyStore = IStore | IStorePersisted; 107 | 108 | export type TStores = { [storeKey: string]: TAnyStore }; 109 | 110 | /** 111 | * Convert class type to class constructor 112 | */ 113 | export type ClassReturnType = T extends new (...args: any) => infer R 114 | ? R 115 | : T extends { store: any } 116 | ? ClassReturnType 117 | : never; 118 | 119 | /** 120 | * Stores map to type 121 | */ 122 | export type StoresType = { 123 | [keys in keyof TSt]: ClassReturnType; 124 | }; 125 | 126 | export interface IStoreParams { 127 | id?: string; 128 | key?: string; 129 | contextId?: string; 130 | parentId?: string; 131 | suspenseId?: string; 132 | componentName?: string; 133 | componentProps?: Record; 134 | } 135 | 136 | export interface IWithStoreOptions { 137 | customContextId?: string; 138 | } 139 | 140 | export interface IMobxManagerEvents { 141 | [Events.CREATE_STORE]: { 142 | store: IConstructableStore; 143 | }; 144 | [Events.MOUNT_STORE]: { 145 | store: TAnyStore; 146 | }; 147 | [Events.UNMOUNT_STORE]: { 148 | store: TAnyStore; 149 | }; 150 | [Events.DELETE_STORE]: { 151 | store: TAnyStore; 152 | }; 153 | } 154 | 155 | export interface IGroupedStores { 156 | relativeStores: TStores; 157 | parentStores: TStores; 158 | globalStores: TStores; 159 | hasCreationFailure: boolean; 160 | } 161 | 162 | export interface IPersistOptions { 163 | // default: exclude. Exclude - except attributes from other storages 164 | behaviour?: 'exclude' | 'include'; 165 | attributes?: { 166 | // storageId => attributes, * - all attributes 167 | // first storage => *, by default 168 | [storageId: string]: string[]; 169 | }; 170 | // disable export all store observable props except props marker with makeExported 171 | // default: false 172 | isNotExported?: boolean; 173 | } 174 | -------------------------------------------------------------------------------- /src/wakeup.ts: -------------------------------------------------------------------------------- 1 | import deepMerge from './deep-merge'; 2 | import type { IStorePersisted, TWakeup } from './types'; 3 | 4 | /** 5 | * Restore persisted store state 6 | */ 7 | function wakeup( 8 | this: IStorePersisted, 9 | { initState, persistedState, manager }: Parameters[0], 10 | ) { 11 | const resState = {}; 12 | 13 | deepMerge(resState, persistedState); 14 | 15 | const shouldSave = initState && deepMerge(resState, initState); 16 | 17 | deepMerge(this, resState); 18 | 19 | if (shouldSave) { 20 | void manager.savePersistedStore(this); 21 | } 22 | } 23 | 24 | export default wakeup; 25 | -------------------------------------------------------------------------------- /src/with-stores.tsx: -------------------------------------------------------------------------------- 1 | import { useConsistentSuspense, useId } from '@lomray/consistent-suspense'; 2 | import hoistNonReactStatics from 'hoist-non-react-statics'; 3 | import { observer } from 'mobx-react-lite'; 4 | import type { FC } from 'react'; 5 | import React, { useEffect, useState } from 'react'; 6 | import { useStoreManager, useStoreManagerParent, StoreManagerParentProvider } from './context'; 7 | import type { TMapStores, IWithStoreOptions } from './types'; 8 | 9 | /** 10 | * Make component observable and pass stores as props 11 | */ 12 | const withStores = , TS extends TMapStores>( 13 | Component: FC, 14 | stores: TS, 15 | { customContextId }: IWithStoreOptions = {}, 16 | ): FC> => { 17 | const ObservableComponent = observer(Component) as FC; 18 | const manualContextId = customContextId || (Component['libStoreContextId'] as string); 19 | const componentName = Component.displayName || Component.name; 20 | 21 | const Element: FC> = (props) => { 22 | const storeManager = useStoreManager(); 23 | const parentId = useStoreManagerParent(); 24 | const { suspenseId } = useConsistentSuspense(); 25 | const id = useId(); 26 | const [{ contextId, hasFailure, touchableStores, componentStores, mountStores }] = useState( 27 | () => { 28 | const ctxId = manualContextId || id; 29 | const groupedStores = storeManager.createStores( 30 | Object.entries(stores), 31 | parentId, 32 | ctxId, 33 | suspenseId, 34 | componentName, 35 | props, 36 | ); 37 | const { globalStores, relativeStores, parentStores, hasCreationFailure } = groupedStores; 38 | const tStores = { ...relativeStores, ...globalStores }; 39 | 40 | return { 41 | contextId: ctxId, 42 | hasFailure: hasCreationFailure, 43 | touchableStores: tStores, 44 | componentStores: { ...tStores, ...parentStores }, 45 | mountStores: () => storeManager.mountStores(ctxId, groupedStores), 46 | }; 47 | }, 48 | ); 49 | 50 | useEffect(mountStores, [mountStores]); 51 | 52 | return ( 53 | 54 | {!hasFailure && } 55 | 56 | ); 57 | }; 58 | 59 | hoistNonReactStatics(Element, Component); 60 | Element.displayName = `Mobx(${componentName})`; 61 | 62 | return Object.defineProperty(Element, 'name', { 63 | value: Element.displayName, 64 | writable: false, 65 | enumerable: false, 66 | }); 67 | }; 68 | 69 | export default withStores; 70 | -------------------------------------------------------------------------------- /tsconfig.checks.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "src/**/*" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowUnreachableCode": false, 5 | "noFallthroughCasesInSwitch": true, 6 | "allowSyntheticDefaultImports": true, 7 | "downlevelIteration": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "resolveJsonModule": true, 11 | "jsx": "react", 12 | "lib": [ 13 | "es6", 14 | "es2015", 15 | "es2017", 16 | "es2019", 17 | "es2021", 18 | "dom", 19 | "webworker" 20 | ], 21 | "module": "ES2022", 22 | "moduleResolution": "Node", 23 | "target": "ESNext", 24 | "noImplicitAny": true, 25 | "noImplicitReturns": true, 26 | "noImplicitThis": true, 27 | "noUnusedLocals": false, 28 | "sourceMap": true, 29 | "strictNullChecks": true, 30 | "strict": false, 31 | "pretty": true, 32 | "suppressImplicitAnyIndexErrors": true, 33 | "useDefineForClassFields": true, 34 | "baseUrl": ".", 35 | "paths": { 36 | "@__helpers__/*": ["./__helpers__/*"], 37 | "@__mocks__/*": ["./__mocks__/*"], 38 | "@src/*": ["./src/*"] 39 | } 40 | }, 41 | "exclude": [ 42 | "node_modules", 43 | "lib", 44 | "commitlint.config.js", 45 | "nyc.config.js", 46 | "rollup.config.js", 47 | "example" 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /typing/@lomray/event-manager.d.ts: -------------------------------------------------------------------------------- 1 | import type { IMobxManagerEvents } from '../../src'; 2 | 3 | declare module '@lomray/event-manager' { 4 | export interface IEventsPayload extends IMobxManagerEvents {} 5 | } 6 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | import tsconfigPaths from 'vite-tsconfig-paths'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | include: ['__tests__/**/*'], 7 | setupFiles: ['__helpers__/setup.ts'], 8 | coverage: { 9 | include: ['src/**/*'], 10 | exclude: ['src/interfaces/**'], 11 | reporter: ['text', 'text-summary', 'lcov', 'html'], 12 | }, 13 | environment: 'jsdom', 14 | }, 15 | plugins: [tsconfigPaths()], 16 | }); 17 | --------------------------------------------------------------------------------