├── .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 |
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 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
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 |
--------------------------------------------------------------------------------