├── .eslintignore ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── .npmignore ├── .prettierignore ├── LICENSE ├── README.md ├── babel.config.js ├── jest.config.js ├── package.json ├── rollup.config.js ├── src ├── appContext.ts ├── constant.ts ├── container │ ├── Injector.ts │ └── container.ts ├── decorators │ ├── Injectable.ts │ ├── effect.ts │ ├── hook.ts │ ├── inject.ts │ ├── memo.ts │ ├── observable.ts │ ├── props.ts │ ├── store.ts │ ├── storePart.ts │ └── unobserve.ts ├── hooks │ └── useStore.ts ├── index.ts ├── proxy │ ├── adtProxy │ │ ├── adtProxyBuilder.ts │ │ ├── array.proxyBuilder.ts │ │ ├── map.proxyBuilder.ts │ │ └── object.proxyBuilder.ts │ ├── deepUnproxy.ts │ ├── proxyValueAndSaveIt.ts │ └── storeForConsumerComponentProxy.ts ├── reactStore.ts ├── store │ ├── StoreProvider.tsx │ ├── administrator │ │ ├── getters │ │ │ ├── memoizedProperty.ts │ │ │ └── storeGettersManager.ts │ │ ├── hooksManager.ts │ │ ├── methods │ │ │ ├── methodProxyHandler.ts │ │ │ └── storeMethodsManager.ts │ │ ├── propertyKeys │ │ │ ├── observableProperty.ts │ │ │ ├── storePropertyKeysManager.ts │ │ │ └── unobservableProperty.ts │ │ ├── propsManager.ts │ │ ├── storeAdministrator.ts │ │ └── storeEffectsManager.ts │ ├── connect.tsx │ └── storeFactory.ts ├── types.ts └── utils │ ├── decoratorsMetadataStorage.ts │ ├── getUnProxiedValue.ts │ ├── isClass.ts │ ├── isPrimitive.ts │ ├── toPlainObj.ts │ ├── useForceUpdate.ts │ ├── useLazyRef.ts │ └── useWillMount.ts ├── tests ├── container.spec.ts ├── defineInjectable.spec.ts ├── depsInjection.spec.tsx ├── e2e │ ├── cypress.config.ts │ ├── cypress.d.ts │ ├── cypress │ │ ├── component │ │ │ └── methods │ │ │ │ └── methodExecutionContext.cy.tsx │ │ └── support │ │ │ ├── commands.ts │ │ │ ├── component-index.html │ │ │ └── component.ts │ ├── tsconfig.json │ └── webpack.config.ts ├── effectDecorator.spec.tsx ├── hookDecorator.spec.tsx ├── immutableObjects.spec.tsx ├── memoDecorator.spec.tsx ├── methods.spec.tsx ├── observableDecorator.spec.tsx ├── propertyKeyObservability.spec.tsx ├── propsDecorator.spec.tsx ├── proxyStoreForComponent.spec.tsx ├── pureReactCompatibility.spec.tsx ├── renderPerformance.spec.tsx ├── setupTest.ts ├── storeDecorator.spec.tsx └── storePartDecorator.spec.tsx ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | sampleApp/* 4 | tests/* 5 | *.config.js 6 | *.spec.ts 7 | *.spec.tsx 8 | *._spec.tsx 9 | *._spec.ts 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v1 10 | - name: Run a one-line script 11 | run: | 12 | yarn install 13 | yarn build 14 | yarn lint 15 | yarn test 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | yarn-error.log 4 | .vscode 5 | coverage 6 | videos 7 | screenshots -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | yarn lint-staged 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | tests 3 | sampleApp 4 | webpack.config.js 5 | tsconfig* 6 | babel* 7 | jest* 8 | .vscode 9 | _config.yml 10 | rollup* 11 | coverage 12 | .husky 13 | .eslintignore 14 | .prettierignore 15 | README.md 16 | LICENSE 17 | .github -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Amir Hossien Qasemi Moqaddam 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 | # React Store 2 | 3 | ![ci](https://github.com/amirqasemi74/react-store/actions/workflows/ci.yml/badge.svg) 4 | ![npm](https://img.shields.io/npm/dw/@react-store/core) 5 | ![version](https://img.shields.io/npm/v/@react-store/core) 6 | 7 | **React Store** is a state management library for React which facilitates to split components into smaller 8 | and maintainable ones then share `States` between them and also let developers to use `class`es to manage 9 | their components logic alongside it's IOC container. 10 | 11 | ## Table of content 12 | 13 | - [Installation](#installation) 14 | - [Usage](#usage) 15 | - [Effects](#effects) 16 | - [Props](#props) 17 | - [Store Part](#store-part) 18 | - [Computed Property](#computed-property) 19 | - [Dependency Injection](#dependency-injection) 20 | 21 | ## Installation 22 | 23 | First install core library: 24 | 25 | `yarn add @react-store/core` 26 | 27 | Then enable **decorators** and **decorators metadata** in typescript: 28 | 29 | ```json 30 | { 31 | "compilerOptions": { 32 | "emitDecoratorMetadata": true, 33 | "experimentalDecorators": true 34 | } 35 | ``` 36 | 37 | You can also use other javascript transpilers such as babel. 38 | 39 | > See `example` folder for those how use Create-React-App 40 | 41 | ## Usage 42 | 43 | Now it's ready. First create a `Store`: 44 | 45 | ```ts 46 | // user.store.ts 47 | import { Store } from "@react-store/core"; 48 | 49 | @Store() 50 | class UserStore { 51 | name: string; 52 | 53 | onNameChange(e: ChangeEvent) { 54 | this.name = e.target.value; 55 | } 56 | } 57 | ``` 58 | 59 | Then connect it to the component **tree** by using `connect` function as component wrapper, call `useStore` and pass it **store class** to access store instance: 60 | 61 | ```tsx 62 | // App.tsx 63 | import { connect, useStore } from "@react-store/core"; 64 | 65 | interface Props { 66 | p1: string; 67 | } 68 | 69 | const App = connect((props: Props) => { 70 | const st = useStore(UserStore); 71 | return ( 72 |
73 | {st.name} 74 | 75 |
76 | ); 77 | }, UserStore); 78 | ``` 79 | 80 | And enjoy to use store in child components. 81 | 82 | ```jsx 83 | import { useStore } from "@react-store/core"; 84 | 85 | function Input() { 86 | const st = useStore(UserStore); 87 | return ( 88 |
89 | Name is: 90 | 91 |
92 | ); 93 | } 94 | ``` 95 | 96 | ## Store property & method 97 | 98 | - _Property_: Each store property behind the sense is a `[state, setState] = useState(initVal)` it means when you set store property, actually you are doing `setState` and also when you read the property, actually you are reading the `state` but in reading scenario if you have been mutated `state` before reading it you will receive new value even before any rerender. 99 | 100 | - _Method_: Store methods are used for state mutations. store methods are bound to store class instance by default. feel free to use them like below: 101 | 102 | ```tsx 103 | function Input() { 104 | const st = useStore(UserStore); 105 | return ; 106 | } 107 | ``` 108 | 109 | ## Effects 110 | 111 | You can manage side effects with `@Effect()` decorator. Like react `useEffect` dependency array you must define an array of dependencies. 112 |
For **clear effect** you can return a function from this method. 113 | 114 | ```ts 115 | @Store() 116 | class UserStore { 117 | name: string; 118 | 119 | @Effect((_: UserStore) => [_.name]) 120 | nameChanged() { 121 | console.log("name changed to:", this.name); 122 | return () => console.log("Clear Effect"); 123 | } 124 | } 125 | ``` 126 | 127 | You also can pass object as dependency item with **deep equal** mode. To do that, pass **true** as second parameters: 128 | 129 | ```ts 130 | @Store() 131 | export class UserStore { 132 | user = { name: "" }; 133 | 134 | @Effect((_) => [_.user], true) 135 | usernameChanged() { 136 | console.log("name changed to:", this.name); 137 | } 138 | } 139 | ``` 140 | 141 | Instead of passing a function to effect decorator to detect dependencies you can pass an array of paths
142 | 143 | ```ts 144 | @Store() 145 | export class UserStore { 146 | user = { name: "" }; 147 | 148 | @Effect(["user.name"]) 149 | usernameChanged() {} 150 | 151 | // Only one dependency does not need to be warped by an array 152 | @Effect("user", true) 153 | userChanged() {} 154 | } 155 | ``` 156 | 157 | ## Memo 158 | 159 | To memoize a value you can use `@Memo` decorator. Memo decorator parameters is like effect decorator: 160 | 161 | ```ts 162 | @Store() 163 | export class UserStore { 164 | user = { name: "", pass: "" }; 165 | 166 | // @Memo(["user.name"]) 167 | @Memo("user.name") 168 | get usernameLen() { 169 | return this.user.name.length; 170 | } 171 | 172 | @Memo(["user"], true) 173 | get passLen() { 174 | return this.user.pass; 175 | } 176 | } 177 | ``` 178 | 179 | You can manage side effects with `@Effect()` decorator. Like react `useEffect` dependency array you must define an array of dependencies. 180 |
For **clear effect** you can return a function from this method. 181 | 182 | > Methods which decorate with `@Effect()` can be async, but if you want to return `clear effect` function make it sync method 183 | 184 | ## Props 185 | 186 | To have store parent component props (the component directly connected to store by using `connect`) inside store class use `@Props()`: 187 | 188 | ```ts 189 | // user.store.ts 190 | import type { Props as AppProps } from "./App"; 191 | import { Props, Store } from "@react-store/core"; 192 | 193 | @Store() 194 | export class UserStore { 195 | @Props() 196 | props: AppProps; 197 | } 198 | ``` 199 | 200 | ## Store Part 201 | 202 | `Store Part` like store is a class which is decorated with `@StorePart()` and can **only** be connected to a store with `@Wire()` decorator. 203 | 204 | ```ts 205 | @StorePart() 206 | class Validator { 207 | object: Record; 208 | 209 | hasError = false; 210 | 211 | @Effect("object", true) 212 | validate() { 213 | this.hasError = someValidator(object).hasError; 214 | } 215 | } 216 | 217 | @Store() 218 | class UserForm { 219 | user: User; 220 | 221 | @Wire(Validator) 222 | validator: Validator; 223 | 224 | @Effect([]) 225 | onMount() { 226 | this.validator.object = this.user; 227 | } 228 | 229 | onUsernameChange(username) { 230 | this.user.username = username; 231 | } 232 | } 233 | ``` 234 | 235 | - Store part **can not** be used directly with `useStore` and must be wired to a store. 236 | - Like store, store part can have it's effects, dependency injection. 237 | - Store part is piece of logics and states can be wired to any other store and play a role like React `custom hooks` 238 | 239 | ## Computed Property 240 | 241 | You can define getter in store class and automatically it will be a `computed` value. it means that if any underlying class properties which is used in 242 | getter change, we will recompute getter value and cache it. 243 | 244 | ```ts 245 | @Store() 246 | class BoxStore { 247 | width: number; 248 | 249 | height: number; 250 | 251 | get area() { 252 | return (this.width + this.height) * 2; 253 | } 254 | } 255 | ``` 256 | 257 | ## Dependency Injection 258 | 259 | In this library we have also supported dependency injection. To define `Injectable`s, decorate class with `@Injectable()`: 260 | 261 | ```ts 262 | @Injectable() 263 | class UserService {} 264 | ``` 265 | 266 | In order to inject dependencies into injectable, use `@Inject(...)`: 267 | 268 | ```ts 269 | @Injectable() 270 | @Inject(AuthService, UserService) 271 | class PostService { 272 | constructor(private authService: AuthService, private userService: UserService) {} 273 | } 274 | ``` 275 | 276 | Also you can use `@Inject()` as parameter decorator: 277 | 278 | ```ts 279 | @Injectable() 280 | @Inject(AuthService) 281 | class PostService { 282 | constructor( 283 | private authService: AuthService, 284 | @Inject(UserService) private userService: UserService 285 | ) {} 286 | } 287 | ``` 288 | 289 | Injection works fine for **stores**. Injectable can be injected into all stores. Also stores can be injected into other stores but there is one condition. For example, you want to inject `A` store into `B` store so the component which is wrapped with `connect(..., A)` must be higher in `B` store parent component. In other words, it works like React `useContext` rule. 290 | 291 | ```ts 292 | @Injectable() 293 | @Inject(AlertsStore) 294 | class UserStore { 295 | constructor(private alertsStore: AlertsStore) {} 296 | } 297 | ``` 298 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"], 3 | plugins: [ 4 | "babel-plugin-transform-typescript-metadata", 5 | ["@babel/plugin-proposal-decorators", { legacy: true }], 6 | ["@babel/plugin-proposal-class-properties", { loose: true }], 7 | ["@babel/plugin-proposal-private-methods", { loose: true }], 8 | ["@babel/plugin-proposal-private-property-in-object", { loose: true }], 9 | "@babel/plugin-transform-runtime", 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/private/var/folders/s4/bxb34dln0n79bskmg8l_njpw0000gn/T/jest_dx", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | collectCoverage: false, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: undefined, 25 | 26 | // The directory where Jest should output its coverage files 27 | coverageDirectory: "coverage", 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | // coverageThreshold: undefined, 44 | 45 | // A path to a custom dependency extractor 46 | // dependencyExtractor: undefined, 47 | 48 | // Make calling deprecated APIs throw helpful error messages 49 | // errorOnDeprecated: false, 50 | 51 | // Force coverage collection from ignored files using an array of glob patterns 52 | // forceCoverageMatch: [], 53 | 54 | // A path to a module which exports an async function that is triggered once before all test suites 55 | // globalSetup: undefined, 56 | 57 | // A path to a module which exports an async function that is triggered once after all test suites 58 | // globalTeardown: undefined, 59 | 60 | // A set of global variables that need to be available in all test environments 61 | // globals: {}, 62 | 63 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 64 | // maxWorkers: "50%", 65 | 66 | // An array of directory names to be searched recursively up from the requiring module's location 67 | // moduleDirectories: [ 68 | // "node_modules" 69 | // ], 70 | 71 | // An array of file extensions your modules use 72 | // moduleFileExtensions: [ 73 | // "js", 74 | // "json", 75 | // "jsx", 76 | // "ts", 77 | // "tsx", 78 | // "node" 79 | // ], 80 | 81 | // A map from regular expressions to module names that allow to stub out resources with a single module 82 | moduleNameMapper: { 83 | "@react-store/core": "/src", 84 | "src(.*)$": "/src/$1", 85 | }, 86 | 87 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 88 | // modulePathIgnorePatterns: [], 89 | 90 | // Activates notifications for test results 91 | // notify: false, 92 | 93 | // An enum that specifies notification mode. Requires { notify: true } 94 | // notifyMode: "failure-change", 95 | 96 | // A preset that is used as a base for Jest's configuration 97 | // preset: undefined, 98 | 99 | // Run tests from one or more projects 100 | // projects: undefined, 101 | 102 | // Use this configuration option to add custom reporters to Jest 103 | // reporters: undefined, 104 | 105 | // Automatically reset mock state between every test 106 | // resetMocks: false, 107 | 108 | // Reset the module registry before running each individual test 109 | // resetModules: false, 110 | 111 | // A path to a custom resolver 112 | // resolver: undefined, 113 | 114 | // Automatically restore mock state between every test 115 | // restoreMocks: false, 116 | 117 | // The root directory that Jest should scan for tests and modules within 118 | // rootDir: undefined, 119 | 120 | // A list of paths to directories that Jest should use to search for files in 121 | // roots: [ 122 | // "" 123 | // ], 124 | 125 | // Allows you to use a custom runner instead of Jest's default test runner 126 | // runner: "jest-runner", 127 | 128 | // The paths to modules that run some code to configure or set up the testing environment before each test 129 | // setupFiles: ["/tests/setupTest.ts"], 130 | 131 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 132 | setupFilesAfterEnv: ["/tests/setupTest.ts"], 133 | 134 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 135 | // snapshotSerializers: [], 136 | 137 | // The test environment that will be used for testing 138 | testEnvironment: "jsdom", 139 | 140 | // Options that will be passed to the testEnvironment 141 | // testEnvironmentOptions: {}, 142 | 143 | // Adds a location field to test results 144 | // testLocationInResults: false, 145 | 146 | // The glob patterns Jest uses to detect test files 147 | // testMatch: [ 148 | // "**/__tests__/**/*.[jt]s?(x)", 149 | // "**/?(*.)+(spec|test).[tj]s?(x)" 150 | // ], 151 | 152 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 153 | // testPathIgnorePatterns: [ 154 | // "/node_modules/" 155 | // ], 156 | 157 | // The regexp pattern or array of patterns that Jest uses to detect test files 158 | // testRegex: [], 159 | 160 | // This option allows the use of a custom results processor 161 | // testResultsProcessor: undefined, 162 | 163 | // This option allows use of a custom test runner 164 | // testRunner: "jasmine2", 165 | 166 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 167 | // testURL: "http://localhost", 168 | 169 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 170 | // timers: "real", 171 | 172 | // A map from regular expressions to paths to transformers 173 | // transform: undefined, 174 | 175 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 176 | // transformIgnorePatterns: [ 177 | // "/node_modules/" 178 | // ], 179 | 180 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 181 | // unmockedModulePathPatterns: undefined, 182 | 183 | // Indicates whether each individual test should be reported during the run 184 | // verbose: undefined, 185 | 186 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 187 | // watchPathIgnorePatterns: [], 188 | 189 | // Whether to use watchman for file crawling 190 | // watchman: true, 191 | }; 192 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@react-store/core", 3 | "version": "0.0.40", 4 | "main": "dist/index.js", 5 | "repository": "https://github.com/amirqasemi74/react-store.git", 6 | "author": "Amir Hossein Qasemi Moqaddam ", 7 | "license": "MIT", 8 | "dependencies": { 9 | "clone-deep": "^4.0.1", 10 | "dequal": "^2.0.3", 11 | "is-promise": "^4.0.0", 12 | "lodash": "^4.17.21", 13 | "reflect-metadata": "^0.1.13" 14 | }, 15 | "peerDependencies": { 16 | "react": "^17.0.0 || ^18.0.0", 17 | "react-dom": "^17.0.0 || ^18.0.0" 18 | }, 19 | "devDependencies": { 20 | "@babel/plugin-proposal-class-properties": "^7.18.6", 21 | "@babel/plugin-proposal-decorators": "^7.18.6", 22 | "@babel/plugin-transform-runtime": "^7.18.6", 23 | "@babel/preset-env": "^7.18.6", 24 | "@babel/preset-react": "^7.18.6", 25 | "@babel/preset-typescript": "^7.18.6", 26 | "@commitlint/cli": "^17.0.3", 27 | "@commitlint/config-conventional": "^17.0.3", 28 | "@testing-library/jest-dom": "^5.16.4", 29 | "@testing-library/react": "^13.3.0", 30 | "@trivago/prettier-plugin-sort-imports": "^3.2.0", 31 | "@types/clone-deep": "^4.0.1", 32 | "@types/jest": "^28.1.5", 33 | "@types/lodash": "^4.14.182", 34 | "@types/react": "^18.0.15", 35 | "@types/react-dom": "^18.0.6", 36 | "@types/testing-library__jest-dom": "^5.14.5", 37 | "@typescript-eslint/eslint-plugin": "^5.30.6", 38 | "@typescript-eslint/parser": "^5.30.6", 39 | "@zerollup/ts-transform-paths": "^1.7.18", 40 | "babel-jest": "^28.1.3", 41 | "babel-plugin-transform-typescript-metadata": "^0.3.2", 42 | "cypress": "^10.8.0", 43 | "eslint": "^8.19.0", 44 | "eslint-config-prettier": "^8.5.0", 45 | "graphql": "^16.6.0", 46 | "html-webpack-plugin": "^5.5.0", 47 | "husky": "^8.0.1", 48 | "jest": "^28.1.3", 49 | "jest-environment-jsdom": "^28.1.3", 50 | "lint-staged": ">=13", 51 | "prettier": "^2.7.1", 52 | "react": "^18.2.0", 53 | "react-dom": "^18.2.0", 54 | "react-router-dom": "6", 55 | "rollup": "^2.77.0", 56 | "rollup-plugin-typescript2": "^0.32.1", 57 | "ts-loader": "^9.3.1", 58 | "ttypescript": "^1.5.13", 59 | "typescript": "^4.7.4", 60 | "webpack": "^5.73.0", 61 | "webpack-cli": "^4.10.0", 62 | "webpack-dev-server": "^4.9.3", 63 | "websocket-extensions": "^0.1.4" 64 | }, 65 | "scripts": { 66 | "start:app": "webpack serve --config tests/browser/webpack.config.js", 67 | "test": "yarn test:jest && yarn test:cypress", 68 | "test:jest": "jest", 69 | "test:cypress": "yarn cy:run", 70 | "test:debug": "node --inspect-brk node_modules/.bin/jest --runInBand --detectOpenHandles", 71 | "build": "rm -rf dist && rollup -c rollup.config.js", 72 | "prepare": "husky install && yarn build", 73 | "pub": "npm publish --access public", 74 | "format": "yarn prettier --write", 75 | "lint": "yarn eslint 'src/**/*.{ts,tsx}' --max-warnings=0", 76 | "cy:open": "yarn cypress open --project tests/e2e", 77 | "cy:run": "yarn cypress run --component --project tests/e2e" 78 | }, 79 | "lint-staged": { 80 | "src/**/*.{js,css,ts,tsx}": [ 81 | "yarn format", 82 | "yarn lint" 83 | ] 84 | }, 85 | "prettier": { 86 | "printWidth": 85, 87 | "importOrderSeparation": true, 88 | "importOrderSortSpecifiers": true, 89 | "importOrderParserPlugins": [ 90 | "typescript", 91 | "jsx", 92 | "classProperties", 93 | "decorators-legacy" 94 | ] 95 | }, 96 | "commitlint": { 97 | "extends": [ 98 | "@commitlint/config-conventional" 99 | ] 100 | }, 101 | "eslintConfig": { 102 | "root": true, 103 | "parser": "@typescript-eslint/parser", 104 | "plugins": [ 105 | "@typescript-eslint" 106 | ], 107 | "extends": [ 108 | "eslint:recommended", 109 | "plugin:@typescript-eslint/recommended", 110 | "prettier" 111 | ], 112 | "rules": { 113 | "no-console": [ 114 | "error", 115 | { 116 | "allow": [ 117 | "error", 118 | "warn" 119 | ] 120 | } 121 | ], 122 | "@typescript-eslint/no-explicit-any": [ 123 | "error", 124 | { 125 | "ignoreRestArgs": true 126 | } 127 | ], 128 | "@typescript-eslint/ban-ts-comment": "off", 129 | "@typescript-eslint/no-non-null-assertion": "off", 130 | "prefer-const": "off", 131 | "no-empty-pattern": "off", 132 | "no-self-assign": "off" 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | // import typescript from "@rollup/plugin-typescript"; 2 | import { resolve } from "path"; 3 | import typescript from "rollup-plugin-typescript2"; 4 | import ttypescript from "ttypescript"; 5 | 6 | const tsconfig = resolve(__dirname, "tsconfig.build.json"); 7 | 8 | export default { 9 | input: "src/index.ts", 10 | output: { 11 | dir: "dist", 12 | format: "es", 13 | }, 14 | plugins: [typescript({ tsconfig, typescript: ttypescript })], 15 | external: [ 16 | "dequal", 17 | "react", 18 | "react-dom", 19 | "lodash/get", 20 | "is-promise", 21 | "clone-deep", 22 | "reflect-metadata", 23 | ], 24 | }; 25 | -------------------------------------------------------------------------------- /src/appContext.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "."; 2 | import { StoreAdministrator } from "./store/administrator/storeAdministrator"; 3 | import React from "react"; 4 | import { ClassType } from "src/types"; 5 | 6 | @Injectable() 7 | export class ReactApplicationContext { 8 | private storeContexts = new Map(); 9 | 10 | registerStoreContext( 11 | storeType: ClassType, 12 | context: StoreAdministratorReactContext 13 | ) { 14 | this.storeContexts.set(storeType, context); 15 | } 16 | 17 | getStoreReactContext(storeType: ClassType) { 18 | return this.storeContexts.get(storeType); 19 | } 20 | } 21 | 22 | export type StoreAdministratorReactContext = React.Context<{ 23 | id: string; 24 | storeAdmin: StoreAdministrator; 25 | } | null>; 26 | -------------------------------------------------------------------------------- /src/constant.ts: -------------------------------------------------------------------------------- 1 | export const TARGET = Symbol("TARGET"); 2 | export const STORE_ADMINISTRATION = Symbol("STORE_ADMINISTRATION"); 3 | export const PROXY_HANDLER_TYPE = Symbol("PROXY_HANDLER_TYPE"); 4 | -------------------------------------------------------------------------------- /src/container/Injector.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, ReactStore } from ".."; 2 | import { ClassType } from "src/types"; 3 | 4 | @Injectable() 5 | export class Injector { 6 | get(token: T) { 7 | return ReactStore.container.resolve(token); 8 | } 9 | 10 | getLazy(token: T) { 11 | return new Promise>((res) => 12 | setTimeout(() => res(ReactStore.container.resolve(token)), 0) 13 | ); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/container/container.ts: -------------------------------------------------------------------------------- 1 | import { Scope } from ".."; 2 | import { InjectableMetadata } from "src/decorators/Injectable"; 3 | import { getClassDependenciesType } from "src/decorators/inject"; 4 | import { ClassType, Func } from "src/types"; 5 | import { decoratorsMetadataStorage } from "src/utils/decoratorsMetadataStorage"; 6 | import { isClass } from "src/utils/isClass"; 7 | 8 | class Container { 9 | private readonly instances = new Map(); 10 | 11 | resolve(token: InjectableToken): T extends ClassType ? InstanceType : T { 12 | const scope = isClass(token) 13 | ? decoratorsMetadataStorage.get("Injectable", token)[0] 14 | : Scope.SINGLETON; 15 | 16 | if (!scope) { 17 | if (isClass(token)) { 18 | throw new Error( 19 | `\`class ${token.name}\` has not been decorated with @Injectable()` 20 | ); 21 | } else { 22 | throw new Error(`\`${token.toString()}\` can't be retrieved from container`); 23 | } 24 | } 25 | 26 | if (isClass(token)) { 27 | switch (scope) { 28 | case Scope.TRANSIENT: { 29 | //eslint-disable-next-line 30 | return new token(...this.resolveDependencies(token)) as any; 31 | } 32 | case Scope.SINGLETON: 33 | default: { 34 | let instance = this.instances.get(token); 35 | if (!instance) { 36 | instance = new token(...this.resolveDependencies(token)); 37 | this.instances.set(token, instance); 38 | } 39 | //eslint-disable-next-line 40 | return instance as any; 41 | } 42 | } 43 | } else { 44 | //eslint-disable-next-line 45 | return this.instances.get(token) as any; 46 | } 47 | } 48 | 49 | resolveDependencies(someClass: ClassType) { 50 | // INJECTABLE 51 | return getClassDependenciesType(someClass).map((type) => this.resolve(type)); 52 | } 53 | 54 | defineInjectable( 55 | injectable: //eslint-disable-next-line 56 | | { token: InjectableToken; value: any } 57 | | { token: InjectableToken; class: ClassType } 58 | | { token: InjectableToken; factory: Func; inject?: Array } 59 | ) { 60 | const token = injectable.token; 61 | if ("value" in injectable) { 62 | this.instances.set(token, injectable.value); 63 | } else if ("class" in injectable) { 64 | if (isClass(token)) { 65 | this.instances.set( 66 | token, 67 | new injectable.class(...this.resolveDependencies(token)) 68 | ); 69 | } else { 70 | this.instances.set(token, new injectable.class()); 71 | } 72 | } else { 73 | this.instances.set( 74 | token, 75 | injectable.factory(...(injectable.inject || []).map((t) => this.resolve(t))) 76 | ); 77 | } 78 | } 79 | 80 | remove(someClass: ClassType) { 81 | this.instances.delete(someClass); 82 | } 83 | 84 | clear() { 85 | this.instances.clear(); 86 | } 87 | } 88 | 89 | export const container = new Container(); 90 | 91 | //eslint-disable-next-line 92 | type InjectableToken = string | symbol | ClassType; 93 | -------------------------------------------------------------------------------- /src/decorators/Injectable.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from "./inject"; 2 | import "reflect-metadata"; 3 | import { ClassType } from "src/types"; 4 | import { decoratorsMetadataStorage } from "src/utils/decoratorsMetadataStorage"; 5 | 6 | export function Injectable(scope = Scope.SINGLETON): ClassDecorator { 7 | return function (target: ClassType) { 8 | decoratorsMetadataStorage.add("Injectable", target, scope); 9 | Inject()(target); 10 | } as ClassDecorator; 11 | } 12 | 13 | export enum Scope { 14 | SINGLETON = "SINGLETON", 15 | TRANSIENT = "TRANSIENT", 16 | } 17 | 18 | export type InjectableMetadata = Scope; 19 | -------------------------------------------------------------------------------- /src/decorators/effect.ts: -------------------------------------------------------------------------------- 1 | import { decoratorsMetadataStorage } from "../utils/decoratorsMetadataStorage"; 2 | import lodashGet from "lodash/get"; 3 | import type { ClassType } from "src/types"; 4 | 5 | type DepFn = (storeInstance: T) => Array; 6 | 7 | export function Effect( 8 | deps?: DepFn | Array | string, 9 | deepEqual?: boolean 10 | ): MethodDecorator { 11 | return function (target, propertyKey, descriptor) { 12 | let depsFn: DepFn | undefined; 13 | if (typeof deps === "function") { 14 | depsFn = deps; 15 | } else if (Array.isArray(deps)) { 16 | depsFn = (o) => deps.map((d) => lodashGet(o, d)); 17 | } else if (typeof deps === "string") { 18 | depsFn = (o) => [lodashGet(o, deps)]; 19 | } 20 | 21 | decoratorsMetadataStorage.add( 22 | "Effect", 23 | target.constructor as ClassType, 24 | { 25 | options: { 26 | deps: depsFn, 27 | deepEqual, 28 | } as ManualEffectOptions, 29 | propertyKey, 30 | } 31 | ); 32 | return descriptor; 33 | }; 34 | } 35 | 36 | export interface ManualEffectOptions { 37 | auto?: false; 38 | deps?: (_: T) => Array; 39 | deepEqual?: boolean; 40 | } 41 | 42 | type EffectOptions = ManualEffectOptions; 43 | 44 | export interface EffectMetaData { 45 | propertyKey: PropertyKey; 46 | options: EffectOptions; 47 | } 48 | -------------------------------------------------------------------------------- /src/decorators/hook.ts: -------------------------------------------------------------------------------- 1 | import { decoratorsMetadataStorage } from "../utils/decoratorsMetadataStorage"; 2 | import { ClassType, Func } from "src/types"; 3 | 4 | export function Hook(hook: Func): PropertyDecorator { 5 | return function (target, propertyKey) { 6 | decoratorsMetadataStorage.add( 7 | "Hook", 8 | target.constructor as ClassType, 9 | { 10 | propertyKey, 11 | hook, 12 | } 13 | ); 14 | }; 15 | } 16 | 17 | export interface HookMetadata { 18 | hook: Func; 19 | propertyKey: PropertyKey; 20 | } 21 | -------------------------------------------------------------------------------- /src/decorators/inject.ts: -------------------------------------------------------------------------------- 1 | import { ClassType } from "src/types"; 2 | import { decoratorsMetadataStorage } from "src/utils/decoratorsMetadataStorage"; 3 | 4 | export function Inject(...deps: any[]) { 5 | return function (...args: [ClassType] | [ClassType, undefined, number]) { 6 | const [target, , paramIndex] = args; 7 | let type: InjectType; 8 | 9 | // deps len === 0: 10 | // 1. called from @Injectable 11 | // 2. parameter decorator 12 | if (deps.length === 0 && args.length === 1) { 13 | type = "CLASS_METADATA"; 14 | deps = Reflect.getOwnMetadata("design:paramtypes", target) || null; 15 | } else { 16 | type = args.length === 1 ? "CLASS" : "PARAMETER"; 17 | } 18 | 19 | const hasInjectType = (type: InjectType) => 20 | decoratorsMetadataStorage 21 | .getOwn("Inject", target) 22 | .some((inj) => 23 | inj.type === "PARAMETER" 24 | ? inj.type === type 25 | : inj.type === type && inj.deps !== null 26 | ); 27 | 28 | if (hasInjectType("PARAMETER") && type === "CLASS") { 29 | throw new Error( 30 | `Dependencies are injecting by @Inject() as parameter and class decorator for \`class ${target.name}\`. Use one of them.` 31 | ); 32 | } 33 | 34 | if (type === "PARAMETER") { 35 | decoratorsMetadataStorage.add("Inject", target, { 36 | type, 37 | dep: deps[0], 38 | paramIndex: paramIndex!, 39 | }); 40 | } else { 41 | decoratorsMetadataStorage.add("Inject", target, { 42 | deps, 43 | type, 44 | }); 45 | } 46 | 47 | if (hasInjectType("CLASS") && hasInjectType("CLASS_METADATA")) { 48 | console.warn( 49 | `Dependencies are automatically detected for \`class ${target.name}\`. Remove @Inject(...)` 50 | ); 51 | } 52 | }; 53 | } 54 | 55 | export const getClassDependenciesType = ( 56 | classType: ClassType | null 57 | ): ClassType[] => { 58 | if (classType) { 59 | let depsType: ClassType[] = []; 60 | const metadata = decoratorsMetadataStorage.getOwn( 61 | "Inject", 62 | classType 63 | ); 64 | metadata.forEach((inj) => { 65 | if (inj.type === "CLASS" || inj.type === "CLASS_METADATA") { 66 | depsType = inj.deps || []; 67 | } 68 | }); 69 | metadata.forEach((inj) => { 70 | if (inj.type === "PARAMETER") { 71 | depsType[inj.paramIndex] = inj.dep; 72 | } 73 | }); 74 | 75 | return depsType.length 76 | ? depsType 77 | : getClassDependenciesType(Reflect.getPrototypeOf(classType) as ClassType); 78 | } 79 | 80 | return []; 81 | }; 82 | 83 | export type DecoratedWith = "STORE" | "STORE_PART" | "INJECTABLE"; 84 | type InjectType = "CLASS" | "CLASS_METADATA" | "PARAMETER"; 85 | 86 | type InjectMetadata = 87 | | { 88 | deps: ClassType[] | null; 89 | type: "CLASS" | "CLASS_METADATA"; 90 | } 91 | | { 92 | type: "PARAMETER"; 93 | dep: ClassType; 94 | paramIndex: number; 95 | }; 96 | -------------------------------------------------------------------------------- /src/decorators/memo.ts: -------------------------------------------------------------------------------- 1 | import { decoratorsMetadataStorage } from "../utils/decoratorsMetadataStorage"; 2 | import lodashGet from "lodash/get"; 3 | import { ClassType } from "src/types"; 4 | 5 | type DepFn = (storeInstance: T) => Array; 6 | 7 | export function Memo( 8 | deps: DepFn | Array | string, 9 | deepEqual?: boolean 10 | ): MethodDecorator { 11 | return function (target, propertyKey, descriptor) { 12 | let depsFn: DepFn | undefined; 13 | if (typeof deps === "function") { 14 | depsFn = deps; 15 | } else if (Array.isArray(deps)) { 16 | depsFn = (o) => deps.map((d) => lodashGet(o, d)); 17 | } else if (typeof deps === "string") { 18 | depsFn = (o) => [lodashGet(o, deps)]; 19 | } 20 | 21 | decoratorsMetadataStorage.add( 22 | "Memo", 23 | target.constructor as ClassType, 24 | { 25 | options: { 26 | deps: depsFn, 27 | deepEqual, 28 | } as MemoOptions, 29 | propertyKey, 30 | } 31 | ); 32 | return descriptor; 33 | }; 34 | } 35 | 36 | interface MemoOptions { 37 | deps?: (_: T) => Array; 38 | deepEqual?: boolean; 39 | } 40 | 41 | export interface MemoMetadata { 42 | propertyKey: PropertyKey; 43 | options: MemoOptions; 44 | } 45 | -------------------------------------------------------------------------------- /src/decorators/observable.ts: -------------------------------------------------------------------------------- 1 | import { decoratorsMetadataStorage } from "../utils/decoratorsMetadataStorage"; 2 | import { ClassType } from "src/types"; 3 | 4 | export function Observable() { 5 | return function (target: ClassType) { 6 | decoratorsMetadataStorage.add("Observable", target, true); 7 | } as ClassDecorator; 8 | } 9 | 10 | export type ObservableMetadata = boolean; 11 | -------------------------------------------------------------------------------- /src/decorators/props.ts: -------------------------------------------------------------------------------- 1 | import { decoratorsMetadataStorage } from "../utils/decoratorsMetadataStorage"; 2 | import { ClassType } from "src/types"; 3 | 4 | export function Props(): PropertyDecorator { 5 | return function (target, propertyKey) { 6 | decoratorsMetadataStorage.add( 7 | "Props", 8 | target.constructor as ClassType, 9 | propertyKey 10 | ); 11 | }; 12 | } 13 | 14 | export type PropsMetadata = PropertyKey; 15 | -------------------------------------------------------------------------------- /src/decorators/store.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from ".."; 2 | import { decoratorsMetadataStorage } from "../utils/decoratorsMetadataStorage"; 3 | import { ClassType } from "src/types"; 4 | 5 | export function Store() { 6 | return function (StoreType: ClassType) { 7 | decoratorsMetadataStorage.add("Store", StoreType, true); 8 | Inject()(StoreType); 9 | } as ClassDecorator; 10 | } 11 | 12 | export type StoreMetadata = boolean; 13 | -------------------------------------------------------------------------------- /src/decorators/storePart.ts: -------------------------------------------------------------------------------- 1 | import { Inject } from ".."; 2 | import { decoratorsMetadataStorage } from "../utils/decoratorsMetadataStorage"; 3 | import { ClassType } from "src/types"; 4 | 5 | export function StorePart() { 6 | return function (StorePartType: ClassType) { 7 | decoratorsMetadataStorage.add("StorePart", StorePartType, true); 8 | Inject()(StorePartType); 9 | } as ClassDecorator; 10 | } 11 | 12 | export type StorePartMetadata = boolean; 13 | -------------------------------------------------------------------------------- /src/decorators/unobserve.ts: -------------------------------------------------------------------------------- 1 | import { decoratorsMetadataStorage } from "../utils/decoratorsMetadataStorage"; 2 | import { ClassType } from "src/types"; 3 | 4 | export function Unobserve(): PropertyDecorator { 5 | return function (target, propertyKey) { 6 | decoratorsMetadataStorage.add( 7 | "Unobserve", 8 | target.constructor as ClassType, 9 | propertyKey 10 | ); 11 | }; 12 | } 13 | 14 | export type UnobserveMetadata = PropertyKey; 15 | -------------------------------------------------------------------------------- /src/hooks/useStore.ts: -------------------------------------------------------------------------------- 1 | import { ReactStore } from ".."; 2 | import { ReactApplicationContext } from "../appContext"; 3 | import { useContext } from "react"; 4 | import { ClassType } from "src/types"; 5 | 6 | export const useStore = (storeType: T): InstanceType => { 7 | const StoreContext = ReactStore.container 8 | .resolve(ReactApplicationContext) 9 | .getStoreReactContext(storeType); 10 | 11 | if (!StoreContext) { 12 | throw new Error( 13 | `${storeType.name} haven't been connected to the component tree!` 14 | ); 15 | } 16 | 17 | const context = useContext(StoreContext); 18 | 19 | if (!context) { 20 | throw new Error(`\`${storeType.name}\` can't be reached.`); 21 | } 22 | 23 | return context.storeAdmin.instanceForComponents; 24 | }; 25 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { ReactStore } from "./reactStore"; 2 | export { Injectable, Scope } from "./decorators/Injectable"; 3 | export { Injector } from "./container/Injector"; 4 | export { useStore } from "./hooks/useStore"; 5 | export { connect } from "./store/connect"; 6 | export { StoreProvider } from "./store/StoreProvider"; 7 | export { Props } from "./decorators/props"; 8 | export { Effect } from "./decorators/effect"; 9 | export { Store } from "./decorators/store"; 10 | export { Inject } from "./decorators/inject"; 11 | export { StorePart } from "./decorators/storePart"; 12 | export { Observable } from "./decorators/observable"; 13 | export { toPlainObj } from "./utils/toPlainObj"; 14 | export { Hook } from "./decorators/hook"; 15 | export { Memo } from "./decorators/memo"; 16 | export { Unobserve } from "./decorators/unobserve"; 17 | -------------------------------------------------------------------------------- /src/proxy/adtProxy/adtProxyBuilder.ts: -------------------------------------------------------------------------------- 1 | import { arrayProxyBuilder } from "./array.proxyBuilder"; 2 | import { mapProxyBuilder } from "./map.proxyBuilder"; 3 | import { objectProxyBuilder } from "./object.proxyBuilder"; 4 | import { ObservableMetadata } from "src/decorators/observable"; 5 | import { StoreMetadata } from "src/decorators/store"; 6 | import { StorePartMetadata } from "src/decorators/storePart"; 7 | import { decoratorsMetadataStorage } from "src/utils/decoratorsMetadataStorage"; 8 | 9 | export interface BaseAdtProxyBuilderArgs { 10 | onSet?: () => void; 11 | proxyTypes?: Array<"Array" | "Object" | "Map">; 12 | proxiedValuesStorage: Map; 13 | } 14 | 15 | interface AdtProxyBuilderArgs extends BaseAdtProxyBuilderArgs { 16 | value: unknown; 17 | } 18 | 19 | export const adtProxyBuilder = ({ value, ...restOfArgs }: AdtProxyBuilderArgs) => { 20 | // eslint-disable-next-line 21 | const valType = (value as any)?.constructor; 22 | const { proxyTypes } = restOfArgs; 23 | const doMapProxy = proxyTypes?.includes("Map") ?? true; 24 | const doArrayProxy = proxyTypes?.includes("Array") ?? true; 25 | const doObjectProxy = proxyTypes?.includes("Object") ?? true; 26 | 27 | try { 28 | if ( 29 | (valType === Object || 30 | (value instanceof Object && 31 | (decoratorsMetadataStorage.get( 32 | "Observable", 33 | valType 34 | )[0] || 35 | decoratorsMetadataStorage.get("Store", valType).length || 36 | decoratorsMetadataStorage.get("StorePart", valType) 37 | .length))) && 38 | doObjectProxy 39 | ) { 40 | return objectProxyBuilder({ 41 | object: value as object, 42 | ...restOfArgs, 43 | }); 44 | } 45 | 46 | if (valType === Array && doArrayProxy) { 47 | return arrayProxyBuilder({ 48 | array: value as unknown[], 49 | ...restOfArgs, 50 | }); 51 | } 52 | 53 | if (value instanceof Map && doMapProxy) { 54 | return mapProxyBuilder({ 55 | map: value, 56 | ...restOfArgs, 57 | }); 58 | } 59 | } catch (error) { 60 | // Nothing to do 61 | } 62 | return value; 63 | }; 64 | -------------------------------------------------------------------------------- /src/proxy/adtProxy/array.proxyBuilder.ts: -------------------------------------------------------------------------------- 1 | import { deepUnproxy } from "../deepUnproxy"; 2 | import { proxyValueAndSaveIt } from "../proxyValueAndSaveIt"; 3 | import { BaseAdtProxyBuilderArgs } from "./adtProxyBuilder"; 4 | import { TARGET } from "src/constant"; 5 | 6 | interface ArrayProxyBuilderArgs extends BaseAdtProxyBuilderArgs { 7 | array: unknown[]; 8 | } 9 | 10 | export const arrayProxyBuilder = ({ 11 | array, 12 | ...restOfArgs 13 | }: ArrayProxyBuilderArgs): unknown[] => { 14 | const { onSet } = restOfArgs; 15 | const isFrozen = Object.isFrozen(array); 16 | 17 | return new Proxy(isFrozen ? [...array] : array, { 18 | get(target: unknown[], propertyKey: PropertyKey, receiver: unknown) { 19 | if (propertyKey === TARGET) { 20 | return target; 21 | } 22 | const value = proxyValueAndSaveIt( 23 | isFrozen ? array : target, 24 | propertyKey, 25 | receiver, 26 | restOfArgs 27 | ); 28 | 29 | return value; 30 | }, 31 | 32 | set( 33 | target: unknown[], 34 | propertyKey: PropertyKey, 35 | value: unknown, 36 | receiver: unknown 37 | ) { 38 | const res = Reflect.set(target, propertyKey, deepUnproxy(value), receiver); 39 | onSet?.(); 40 | return res; 41 | }, 42 | }); 43 | }; 44 | -------------------------------------------------------------------------------- /src/proxy/adtProxy/map.proxyBuilder.ts: -------------------------------------------------------------------------------- 1 | import { deepUnproxy } from "../deepUnproxy"; 2 | import { BaseAdtProxyBuilderArgs, adtProxyBuilder } from "./adtProxyBuilder"; 3 | import { TARGET } from "src/constant"; 4 | import { Func } from "src/types"; 5 | 6 | interface MapProxyBuilderArgs extends BaseAdtProxyBuilderArgs { 7 | map: Map; 8 | } 9 | 10 | export const mapProxyBuilder = ({ 11 | map, 12 | ...restOfArgs 13 | }: MapProxyBuilderArgs): object => { 14 | const { onSet } = restOfArgs; 15 | return new Proxy(map, { 16 | get(target: Map, propertyKey: PropertyKey) { 17 | if (propertyKey === TARGET) { 18 | return target; 19 | } 20 | const value = target[propertyKey]; 21 | switch (propertyKey) { 22 | case "get": { 23 | return function (mapKey: unknown) { 24 | return (value as Func).call(target, mapKey); 25 | }; 26 | } 27 | 28 | case "set": { 29 | return function (mapKey: unknown, mapValue: unknown) { 30 | (value as Func).call( 31 | target, 32 | mapKey, 33 | adtProxyBuilder({ 34 | onSet, 35 | value: deepUnproxy(mapValue), 36 | ...restOfArgs, 37 | }) 38 | ); 39 | onSet?.(); 40 | }; 41 | } 42 | 43 | case "delete": { 44 | return function (mapKey: unknown) { 45 | (value as Func).call(target, mapKey); 46 | onSet?.(); 47 | }; 48 | } 49 | 50 | case "clear": { 51 | return function () { 52 | (value as Func).call(target); 53 | onSet?.(); 54 | }; 55 | } 56 | } 57 | 58 | return value instanceof Function ? value.bind(map) : value; 59 | }, 60 | }); 61 | }; 62 | -------------------------------------------------------------------------------- /src/proxy/adtProxy/object.proxyBuilder.ts: -------------------------------------------------------------------------------- 1 | import { deepUnproxy } from "../deepUnproxy"; 2 | import { proxyValueAndSaveIt } from "../proxyValueAndSaveIt"; 3 | import { BaseAdtProxyBuilderArgs } from "./adtProxyBuilder"; 4 | import React from "react"; 5 | import { TARGET } from "src/constant"; 6 | 7 | interface ObjectProxyBuilderArgs extends BaseAdtProxyBuilderArgs { 8 | object: object; 9 | } 10 | 11 | export const objectProxyBuilder = ({ 12 | object, 13 | ...restOfArgs 14 | }: ObjectProxyBuilderArgs): object => { 15 | if (React.isValidElement(object)) { 16 | return object; 17 | } 18 | 19 | const { onSet } = restOfArgs; 20 | const isFrozen = Object.isFrozen(object); 21 | 22 | return new Proxy(isFrozen ? { ...object } : object, { 23 | get(target: object, propertyKey: PropertyKey, receiver: unknown) { 24 | if (propertyKey === TARGET) { 25 | return target; 26 | } 27 | 28 | const value = proxyValueAndSaveIt( 29 | isFrozen ? object : target, 30 | propertyKey, 31 | receiver, 32 | restOfArgs 33 | ); 34 | return value; 35 | }, 36 | 37 | set( 38 | target: object, 39 | propertyKey: PropertyKey, 40 | value: unknown, 41 | receiver: unknown 42 | ) { 43 | const res = Reflect.set(target, propertyKey, deepUnproxy(value), receiver); 44 | onSet?.(); 45 | return res; 46 | }, 47 | }); 48 | }; 49 | -------------------------------------------------------------------------------- /src/proxy/deepUnproxy.ts: -------------------------------------------------------------------------------- 1 | import { ObservableMetadata } from "src/decorators/observable"; 2 | import { decoratorsMetadataStorage } from "src/utils/decoratorsMetadataStorage"; 3 | import { getUnproxiedValue } from "src/utils/getUnProxiedValue"; 4 | import { isPrimitive } from "src/utils/isPrimitive"; 5 | 6 | export const deepUnproxy = (val: unknown) => { 7 | if (isPrimitive(val)) return val; 8 | const unproxied = getUnproxiedValue(val, true); 9 | // eslint-disable-next-line 10 | const valType = (val as any)?.constructor; 11 | if (Object.isFrozen(unproxied)) return unproxied; 12 | 13 | if ( 14 | valType === Array || 15 | valType === Object || 16 | (unproxied instanceof Object && 17 | decoratorsMetadataStorage.get("Observable", valType)[0]) 18 | ) { 19 | Object.getOwnPropertyNames(unproxied).forEach((key: PropertyKey) => { 20 | unproxied[key] = deepUnproxy(unproxied[key]); 21 | }); 22 | Object.getOwnPropertySymbols(unproxied).forEach((key: PropertyKey) => { 23 | unproxied[key] = deepUnproxy(unproxied[key]); 24 | }); 25 | } 26 | return unproxied; 27 | }; 28 | -------------------------------------------------------------------------------- /src/proxy/proxyValueAndSaveIt.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseAdtProxyBuilderArgs, 3 | adtProxyBuilder, 4 | } from "./adtProxy/adtProxyBuilder"; 5 | import { getUnproxiedValue } from "src/utils/getUnProxiedValue"; 6 | import { isPrimitive } from "src/utils/isPrimitive"; 7 | 8 | /** 9 | * Proxy value if need and then proxied value for next usage 10 | * - Array & Object prototype methods and properties will not proxied 11 | * - Object & Array & Function proxied values only will save 12 | */ 13 | export function proxyValueAndSaveIt( 14 | target: object, 15 | propertyKey: PropertyKey, 16 | receiver: unknown, 17 | adtProxyBuilderArgs: BaseAdtProxyBuilderArgs 18 | ) { 19 | const storage = adtProxyBuilderArgs.proxiedValuesStorage; 20 | const value = Reflect.get(target, propertyKey, receiver); 21 | 22 | if (isPrimitive(value) || typeof value === "function") { 23 | return value; 24 | } 25 | 26 | if (storage.has(getUnproxiedValue(value))) { 27 | return storage.get(getUnproxiedValue(value)); 28 | } 29 | 30 | const proxiedValue = () => 31 | adtProxyBuilder({ 32 | value, 33 | ...adtProxyBuilderArgs, 34 | }); 35 | 36 | if (!storage.has(value)) { 37 | storage.set(value, proxiedValue()); 38 | } 39 | 40 | return storage.get(value); 41 | } 42 | -------------------------------------------------------------------------------- /src/proxy/storeForConsumerComponentProxy.ts: -------------------------------------------------------------------------------- 1 | import { StoreAdministrator } from "../store/administrator/storeAdministrator"; 2 | import { PROXY_HANDLER_TYPE } from "src/constant"; 3 | import { getUnproxiedValue } from "src/utils/getUnProxiedValue"; 4 | 5 | export class StoreForConsumerComponentProxy implements ProxyHandler { 6 | get(target: object, propertyKey: PropertyKey, receiver: unknown) { 7 | if (propertyKey === PROXY_HANDLER_TYPE) { 8 | return StoreForConsumerComponentProxy; 9 | } 10 | 11 | const storeAdmin = StoreAdministrator.get(target); 12 | 13 | if (storeAdmin?.propertyKeysManager.propertyKeys.has(propertyKey)) { 14 | const value = storeAdmin?.propertyKeysManager.propertyKeys 15 | .get(propertyKey) 16 | ?.getValue("State"); 17 | 18 | return ( 19 | StoreAdministrator.get(getUnproxiedValue(value))?.instanceForComponents || 20 | value 21 | ); 22 | } 23 | 24 | if (storeAdmin?.gettersManager.getters.has(propertyKey)) { 25 | return storeAdmin.gettersManager.getters.get(propertyKey)?.getValue("State"); 26 | } 27 | 28 | return Reflect.get(target, propertyKey, receiver); 29 | } 30 | 31 | set(target: object, propertyKey: PropertyKey) { 32 | console.error( 33 | `Mutating (${ 34 | target.constructor.name 35 | }.${propertyKey.toString()}) store properties from outside of store class is not valid.` 36 | ); 37 | return true; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/reactStore.ts: -------------------------------------------------------------------------------- 1 | import { container } from "./container/container"; 2 | 3 | export class ReactStore { 4 | static container: typeof container = container; 5 | } 6 | -------------------------------------------------------------------------------- /src/store/StoreProvider.tsx: -------------------------------------------------------------------------------- 1 | import { ReactStore } from ".."; 2 | import { 3 | ReactApplicationContext, 4 | StoreAdministratorReactContext, 5 | } from "../appContext"; 6 | import { StoreFactory } from "./storeFactory"; 7 | import React, { useMemo, useRef } from "react"; 8 | import { StoreMetadata } from "src/decorators/store"; 9 | import { ClassType } from "src/types"; 10 | import { decoratorsMetadataStorage } from "src/utils/decoratorsMetadataStorage"; 11 | import { useForceUpdate } from "src/utils/useForceUpdate"; 12 | import { useFixedLazyRef } from "src/utils/useLazyRef"; 13 | 14 | interface Props { 15 | props?: object; 16 | type: ClassType; 17 | render: React.FC; 18 | } 19 | 20 | export const StoreProvider = React.memo(({ type, render, props }: Props) => { 21 | const renderId = useRef(0); 22 | const isRenderRelax = useRef(true); 23 | const [forceRenderId, forceRenderContext] = useForceUpdate(); 24 | const TheContext = useFixedLazyRef(() => { 25 | if (!decoratorsMetadataStorage.get("Store", type).length) { 26 | throw new Error(`\`${type.name}\` does not decorated with @Store()`); 27 | } 28 | 29 | const appContext = ReactStore.container.resolve(ReactApplicationContext); 30 | let context = appContext.getStoreReactContext(type); 31 | if (!context) { 32 | context = 33 | React.createContext>(null); 34 | context.displayName = `${type.name}`; 35 | // store context provider in app container 36 | // to use context ref in useStore to get context value 37 | appContext.registerStoreContext(type, context); 38 | } 39 | return context; 40 | }); 41 | 42 | const renderContext = (relax?: boolean) => { 43 | isRenderRelax.current = !!relax; 44 | if (relax) { 45 | renderId.current++; 46 | } else { 47 | forceRenderContext(); 48 | } 49 | }; 50 | 51 | const storeAdmin = StoreFactory.create(type, renderContext, props); 52 | 53 | const Component = useMemo(() => React.memo(render), []); 54 | 55 | const value = useMemo( 56 | () => ({ 57 | storeAdmin, 58 | id: isRenderRelax.current 59 | ? `relax-${renderId.current}` 60 | : `force-${forceRenderId}`, 61 | }), 62 | [renderId.current, forceRenderId, isRenderRelax.current] 63 | ); 64 | 65 | return ( 66 | 67 | 68 | 69 | ); 70 | }); 71 | -------------------------------------------------------------------------------- /src/store/administrator/getters/memoizedProperty.ts: -------------------------------------------------------------------------------- 1 | import { StoreAdministrator } from "../storeAdministrator"; 2 | import cloneDeep from "clone-deep"; 3 | import { dequal } from "dequal"; 4 | import { getUnproxiedValue } from "src/utils/getUnProxiedValue"; 5 | 6 | export class MemoizedProperty { 7 | private getterFn: () => unknown; 8 | 9 | private inited = false; 10 | 11 | private manualDepsFn: DepFn; 12 | 13 | private preDepValues: unknown[]; 14 | 15 | private deepEqual: boolean; 16 | 17 | private storeAdmin: StoreAdministrator; 18 | 19 | private value: unknown; 20 | 21 | constructor(options: { 22 | deepEqual?: boolean; 23 | depFn?: DepFn; 24 | getter: () => unknown; 25 | storeAdmin: StoreAdministrator; 26 | }) { 27 | this.deepEqual = this.deepEqual; 28 | options.depFn && (this.manualDepsFn = options.depFn); 29 | this.getterFn = options.getter; 30 | this.storeAdmin = options.storeAdmin; 31 | } 32 | 33 | getValue(from: "State" | "Store") { 34 | if (!this.inited) { 35 | this.calcStoreValue(); 36 | } 37 | return from === "Store" ? this.value : getUnproxiedValue(this.value); 38 | } 39 | 40 | private calcStoreValue() { 41 | this.inited = true; 42 | this.value = this.getterFn.call(this.storeAdmin.instanceForComponents); 43 | } 44 | 45 | tryRecomputeIfNeed() { 46 | const isEqual = this.deepEqual ? dequal : Object.is; 47 | /** 48 | * Here because we get deps for instanceForComponents, it's unproxied 49 | * by default. So we don't need to make it unproxy 50 | */ 51 | const depsValues = this.manualDepsFn(this.storeAdmin.instanceForComponents); 52 | 53 | if (depsValues.some((v, i) => !isEqual(v, this.preDepValues?.[i]))) { 54 | this.preDepValues = this.deepEqual ? cloneDeep(depsValues, true) : depsValues; 55 | this.calcStoreValue(); 56 | } 57 | } 58 | } 59 | 60 | type DepFn = (o: object) => Array; 61 | -------------------------------------------------------------------------------- /src/store/administrator/getters/storeGettersManager.ts: -------------------------------------------------------------------------------- 1 | import { StoreAdministrator } from "../storeAdministrator"; 2 | import { MemoizedProperty } from "./memoizedProperty"; 3 | import { MemoMetadata } from "src/decorators/memo"; 4 | import { decoratorsMetadataStorage } from "src/utils/decoratorsMetadataStorage"; 5 | 6 | export class StoreGettersManager { 7 | readonly getters = new Map(); 8 | 9 | constructor(private storeAdmin: StoreAdministrator) {} 10 | 11 | registerMemos() { 12 | this.memosMetaData.forEach((metadata) => { 13 | const descriptor = Object.getOwnPropertyDescriptor( 14 | Object.getPrototypeOf(this.storeAdmin.instance), 15 | metadata.propertyKey 16 | ); 17 | 18 | if (descriptor?.get) { 19 | const memoized = new MemoizedProperty({ 20 | getter: descriptor.get, 21 | storeAdmin: this.storeAdmin, 22 | depFn: metadata.options.deps, 23 | deepEqual: metadata.options.deepEqual, 24 | }); 25 | 26 | Object.defineProperty(this.storeAdmin.instance, metadata.propertyKey, { 27 | ...descriptor, 28 | get: () => memoized.getValue("Store"), 29 | }); 30 | 31 | this.getters.set(metadata.propertyKey, memoized); 32 | } 33 | }); 34 | 35 | this.storeAdmin.hooksManager.reactHooks.add({ 36 | hook: () => this.getters.forEach((cp) => cp.tryRecomputeIfNeed()), 37 | }); 38 | } 39 | 40 | get memosMetaData() { 41 | // For overridden store methods we have two metadata 42 | // so we must filter duplicate ones 43 | return decoratorsMetadataStorage 44 | .get("Memo", this.storeAdmin.type) 45 | .filter( 46 | (v, i, data) => 47 | i === data.findIndex((vv) => vv.propertyKey === v.propertyKey) 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/store/administrator/hooksManager.ts: -------------------------------------------------------------------------------- 1 | import { StoreAdministrator } from "./storeAdministrator"; 2 | import { HookMetadata } from "src/decorators/hook"; 3 | import { decoratorsMetadataStorage } from "src/utils/decoratorsMetadataStorage"; 4 | 5 | export class HooksManager { 6 | reactHooks = new Set(); 7 | 8 | constructor(private storeAdmin: StoreAdministrator) {} 9 | 10 | register() { 11 | decoratorsMetadataStorage 12 | .get("Hook", this.storeAdmin.type) 13 | .forEach(({ hook, propertyKey }) => { 14 | this.reactHooks.add({ 15 | hook: () => hook(this.storeAdmin.instanceForComponents), 16 | result: (res) => { 17 | this.storeAdmin.propertyKeysManager.onSetPropertyKey( 18 | propertyKey, 19 | res, 20 | true 21 | ); 22 | }, 23 | }); 24 | }); 25 | } 26 | } 27 | 28 | export interface StoreAdministratorReactHooks { 29 | result?: (...args: any[]) => void; 30 | hook: (storeAdmin: StoreAdministrator, props?: object) => void; 31 | } 32 | -------------------------------------------------------------------------------- /src/store/administrator/methods/methodProxyHandler.ts: -------------------------------------------------------------------------------- 1 | import { StoreAdministrator } from "../storeAdministrator"; 2 | 3 | /** 4 | * Effect context must be proxied to use value from state insteadOf store 5 | * because we have hooks like useDeferredValue or useTransition can others hooks cause 6 | * rerenders but these hooks value not change in that render 7 | * So we must bind effect context to state values. 8 | */ 9 | export class MethodProxyHandler implements ProxyHandler { 10 | directMutatedStoreProperties = new Map(); 11 | 12 | constructor(private storeAdmin: StoreAdministrator) {} 13 | 14 | get(target: object, propertyKey: PropertyKey, receiver: unknown) { 15 | /** 16 | * Because we change effect context to state if we set value it will be 17 | * done async and if we read the value immediately it doesn't work 18 | * so we make trick here only for primitive types 19 | */ 20 | if (this.directMutatedStoreProperties.has(propertyKey)) { 21 | return this.directMutatedStoreProperties.get(propertyKey); 22 | } 23 | 24 | if (this.storeAdmin?.propertyKeysManager.propertyKeys.has(propertyKey)) { 25 | return this.storeAdmin?.propertyKeysManager.propertyKeys 26 | .get(propertyKey) 27 | ?.getValue("State", false); 28 | } 29 | 30 | // Getters 31 | if (this.storeAdmin?.gettersManager.getters.has(propertyKey)) { 32 | return this.storeAdmin.gettersManager.getters 33 | .get(propertyKey) 34 | ?.getValue("State"); 35 | } 36 | 37 | return Reflect.get(target, propertyKey, receiver); 38 | } 39 | 40 | set(_: object, propertyKey: PropertyKey, value: unknown) { 41 | if (this.storeAdmin?.propertyKeysManager.onSetPropertyKey(propertyKey, value)) { 42 | this.directMutatedStoreProperties.set( 43 | propertyKey, 44 | this.storeAdmin.propertyKeysManager.propertyKeys 45 | .get(propertyKey) 46 | ?.getValue("Store") 47 | ); 48 | } 49 | return true; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/store/administrator/methods/storeMethodsManager.ts: -------------------------------------------------------------------------------- 1 | import type { StoreAdministrator } from "../storeAdministrator"; 2 | import { MethodProxyHandler } from "./methodProxyHandler"; 3 | import { PROXY_HANDLER_TYPE, STORE_ADMINISTRATION } from "src/constant"; 4 | import { StoreForConsumerComponentProxy } from "src/proxy/storeForConsumerComponentProxy"; 5 | import { Func } from "src/types"; 6 | 7 | export class StoreMethodsManager { 8 | private methods = new Map(); 9 | 10 | constructor(private storeAdmin: StoreAdministrator) {} 11 | 12 | bindMethods() { 13 | Object.entries(this.getMethodsPropertyDescriptors(this.storeAdmin.instance)) 14 | .filter(([key]) => key !== "constructor") 15 | .filter(([, desc]) => desc.value) // only methods not getter or setter 16 | .forEach(([methodKey, descriptor]) => { 17 | const self = this; //eslint-disable-line 18 | 19 | this.methods.set(methodKey, function (this: unknown, ...args) { 20 | /** 21 | * if: 22 | * 1. function has no this 23 | * 2. or this === window 24 | * 3. or this is equal useStore context 25 | * 4. 26 | * we created own context for it 27 | */ 28 | const context = 29 | !this || 30 | (typeof this === "object" && 31 | this[STORE_ADMINISTRATION] !== self.storeAdmin) || 32 | (typeof this === "object" && 33 | Reflect.get(this, PROXY_HANDLER_TYPE) === 34 | StoreForConsumerComponentProxy) 35 | ? new Proxy( 36 | self.storeAdmin.instance, 37 | new MethodProxyHandler(self.storeAdmin) 38 | ) 39 | : this; 40 | 41 | const res = (descriptor.value as Func)?.apply(context, args); 42 | return res; 43 | }); 44 | 45 | Object.defineProperty(this.storeAdmin.instance, methodKey, { 46 | enumerable: false, 47 | configurable: true, 48 | get: () => this.methods.get(methodKey), 49 | }); 50 | }); 51 | } 52 | 53 | private getMethodsPropertyDescriptors( 54 | o: unknown 55 | ): Record { 56 | const _get = (o: unknown, methods = {}) => { 57 | const proto = Object.getPrototypeOf(o); 58 | if (proto && proto !== Object.prototype) { 59 | methods = { ...Object.getOwnPropertyDescriptors(proto), ...methods }; 60 | return _get(proto, methods); 61 | } else { 62 | return methods; 63 | } 64 | }; 65 | return _get(o); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/store/administrator/propertyKeys/observableProperty.ts: -------------------------------------------------------------------------------- 1 | import { StoreAdministrator } from "../storeAdministrator"; 2 | import { adtProxyBuilder } from "src/proxy/adtProxy/adtProxyBuilder"; 3 | import { getUnproxiedValue } from "src/utils/getUnProxiedValue"; 4 | import { isPrimitive } from "src/utils/isPrimitive"; 5 | 6 | export class ObservableProperty { 7 | isPrimitive: boolean; 8 | 9 | isSetStatePending = false; 10 | 11 | proxiedValuesStorage = new Map(); 12 | 13 | private value: { 14 | store?: unknown; 15 | state?: unknown; 16 | }; 17 | 18 | private _reactSetState?: () => void; 19 | 20 | constructor(private storeAdmin: StoreAdministrator, value: unknown) { 21 | this.isPrimitive = isPrimitive(value); 22 | value = this.makeDeepObservable(value); 23 | const _val = this.isPrimitive ? value : { $: value }; 24 | this.value = { 25 | state: _val, 26 | store: _val, 27 | }; 28 | } 29 | 30 | setReactSetState(setState: React.Dispatch) { 31 | this._reactSetState = () => { 32 | const newValue = this.getValue("Store"); 33 | setState?.(this.isPrimitive ? newValue : { $: newValue }); 34 | this.isSetStatePending = false; 35 | }; 36 | } 37 | 38 | get reactSetState() { 39 | return this._reactSetState; 40 | } 41 | 42 | setValue(value: unknown, to: "State" | "Store") { 43 | this.isPrimitive = isPrimitive(value); 44 | if (to === "Store") { 45 | value = this.makeDeepObservable(value); 46 | } 47 | switch (to) { 48 | case "State": 49 | return (this.value.state = value); 50 | case "Store": 51 | return (this.value.store = this.isPrimitive ? value : { $: value }); 52 | } 53 | } 54 | 55 | getValue(from: "State" | "Store", doUnproxy = true) { 56 | switch (from) { 57 | case "State": { 58 | const value = this.isPrimitive 59 | ? this.value.state 60 | : // due to performance we return pure values of store properties 61 | // not proxied ones, pure value does not collect access logs 62 | // and this is good 63 | (this.value.state as { $: unknown } | undefined)?.$; 64 | 65 | return doUnproxy ? getUnproxiedValue(value) : value; 66 | } 67 | case "Store": 68 | return this.isPrimitive 69 | ? this.value.store 70 | : (this.value.store as { $: unknown } | undefined)?.$; 71 | } 72 | } 73 | 74 | private makeDeepObservable(value: unknown) { 75 | const observable = adtProxyBuilder({ 76 | value, 77 | proxiedValuesStorage: this.proxiedValuesStorage, 78 | onSet: () => this.doOnSet(), 79 | }); 80 | return observable; 81 | } 82 | 83 | doOnSet() { 84 | this.isSetStatePending = true; 85 | this.storeAdmin.renderConsumers(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/store/administrator/propertyKeys/storePropertyKeysManager.ts: -------------------------------------------------------------------------------- 1 | import { StoreAdministrator } from "../storeAdministrator"; 2 | import { ObservableProperty } from "./observableProperty"; 3 | import { UnobservableProperty } from "./unobservableProperty"; 4 | import { useState } from "react"; 5 | import { InjectableMetadata } from "src/decorators/Injectable"; 6 | import { HookMetadata } from "src/decorators/hook"; 7 | import { PropsMetadata } from "src/decorators/props"; 8 | import { StoreMetadata } from "src/decorators/store"; 9 | import { StorePartMetadata } from "src/decorators/storePart"; 10 | import { UnobserveMetadata } from "src/decorators/unobserve"; 11 | import { deepUnproxy } from "src/proxy/deepUnproxy"; 12 | import { decoratorsMetadataStorage } from "src/utils/decoratorsMetadataStorage"; 13 | import { getUnproxiedValue } from "src/utils/getUnProxiedValue"; 14 | import { useFixedLazyRef } from "src/utils/useLazyRef"; 15 | 16 | export class StorePropertyKeysManager { 17 | readonly propertyKeys = new Map< 18 | PropertyKey, 19 | ObservableProperty | UnobservableProperty 20 | >(); 21 | 22 | private readonly unobservablePropertyKeys: Array<{ 23 | isReadonly: boolean; 24 | onSet?: (pk: PropertyKey) => void; 25 | matcher: (pk: PropertyKey) => boolean; 26 | }> = []; 27 | 28 | constructor(private storeAdmin: StoreAdministrator) { 29 | // @Props 30 | this.unobservablePropertyKeys.push({ 31 | isReadonly: true, 32 | matcher: (propertyKey) => 33 | decoratorsMetadataStorage 34 | .get("Props", storeAdmin.type) 35 | .some((pk) => pk === propertyKey), 36 | onSet: (propertyKey) => 37 | console.error( 38 | `\`${ 39 | this.storeAdmin.type.name 40 | }.${propertyKey.toString()}\` is decorated with \`@Props()\`, so can't be mutated.` 41 | ), 42 | }); 43 | 44 | // @Hook 45 | this.unobservablePropertyKeys.push({ 46 | isReadonly: true, 47 | matcher: (propertyKey) => 48 | decoratorsMetadataStorage 49 | .get("Hook", this.storeAdmin.type) 50 | .some((md) => md.propertyKey === propertyKey), 51 | onSet: (propertyKey) => 52 | console.error( 53 | `\`${ 54 | this.storeAdmin.type.name 55 | }.${propertyKey.toString()}\` is decorated with \`@Hook(...)\`, so can't be mutated.` 56 | ), 57 | }); 58 | 59 | // Injected Store Part 60 | this.unobservablePropertyKeys.push({ 61 | isReadonly: true, 62 | matcher: (propertyKey) => { 63 | const type = getUnproxiedValue( 64 | this.storeAdmin.instance[propertyKey] 65 | )?.constructor; 66 | return ( 67 | type && 68 | decoratorsMetadataStorage.get("StorePart", type).length 69 | ); 70 | }, 71 | onSet: (propertyKey) => 72 | console.error( 73 | `\`${ 74 | this.storeAdmin.type.name 75 | }.${propertyKey.toString()}\` is an injected storePart, so can't be mutated.` 76 | ), 77 | }); 78 | 79 | // Injected injectable 80 | this.unobservablePropertyKeys.push({ 81 | isReadonly: true, 82 | matcher: (propertyKey) => { 83 | const type = getUnproxiedValue( 84 | this.storeAdmin.instance[propertyKey] 85 | )?.constructor; 86 | return ( 87 | type && 88 | decoratorsMetadataStorage.get("Injectable", type) 89 | .length 90 | ); 91 | }, 92 | onSet: (propertyKey) => 93 | console.error( 94 | `\`${ 95 | this.storeAdmin.type.name 96 | }.${propertyKey.toString()}\` is an injected @Injectable() , so can't be mutated.` 97 | ), 98 | }); 99 | 100 | // Injected Stores 101 | const storeMatcher = (propertyKey: PropertyKey) => { 102 | const type = getUnproxiedValue( 103 | this.storeAdmin.instance[propertyKey] 104 | )?.constructor; 105 | return ( 106 | type && !!decoratorsMetadataStorage.get("Store", type).length 107 | ); 108 | }; 109 | this.unobservablePropertyKeys.push({ 110 | isReadonly: true, 111 | matcher: storeMatcher, 112 | onSet: (propertyKey) => 113 | console.error( 114 | `\`${ 115 | this.storeAdmin.type.name 116 | }.${propertyKey.toString()}\` is an injected store, so can't be mutated` 117 | ), 118 | }); 119 | 120 | //@Unobserve 121 | decoratorsMetadataStorage 122 | .get("Unobserve", storeAdmin.type) 123 | .forEach((pk) => { 124 | this.unobservablePropertyKeys.push({ 125 | isReadonly: false, 126 | matcher: (_pk) => _pk === pk, 127 | }); 128 | }); 129 | } 130 | 131 | makeAllObservable() { 132 | Object.keys(this.storeAdmin.instance).forEach((propertyKey) => { 133 | const unobservablePK = this.unobservablePropertyKeys.find(({ matcher }) => 134 | matcher(propertyKey) 135 | ); 136 | const value = this.storeAdmin.instance[propertyKey]; 137 | this.propertyKeys.set( 138 | propertyKey, 139 | unobservablePK 140 | ? new UnobservableProperty(value, unobservablePK.isReadonly) 141 | : new ObservableProperty(this.storeAdmin, value) 142 | ); 143 | 144 | // Define setter and getter 145 | // to intercept this props getting and 146 | // return proxied value 147 | Object.defineProperty(this.storeAdmin.instance, propertyKey, { 148 | enumerable: true, 149 | configurable: true, 150 | get: () => this.onGetPropertyKey(propertyKey), 151 | set: (value: unknown) => this.onSetPropertyKey(propertyKey, value), 152 | }); 153 | }); 154 | } 155 | 156 | private onGetPropertyKey(propertyKey: PropertyKey) { 157 | return this.propertyKeys.get(propertyKey)?.getValue("Store"); 158 | } 159 | 160 | /** 161 | * @param propertyKey 162 | * @param value 163 | * @param force to set props in props handler or developer hooks 164 | */ 165 | onSetPropertyKey(propertyKey: PropertyKey, value: unknown, force?: boolean) { 166 | value = deepUnproxy(value); 167 | const info = this.propertyKeys.get(propertyKey)!; 168 | 169 | const storeValueAndRenderIfNeed = () => { 170 | const preValue = info?.getValue("Store"); 171 | info.setValue(value, "Store"); 172 | const purePreValue = getUnproxiedValue(Object(preValue)) || preValue; 173 | if (purePreValue !== value) { 174 | this.storeAdmin.renderConsumers(force); 175 | } 176 | }; 177 | 178 | if (info instanceof ObservableProperty) { 179 | info.isSetStatePending = true; 180 | storeValueAndRenderIfNeed(); 181 | return true; 182 | } 183 | 184 | if (info instanceof UnobservableProperty) { 185 | if (force && info.isReadonly) { 186 | storeValueAndRenderIfNeed(); 187 | return true; 188 | } else if (info.isReadonly) { 189 | this.unobservablePropertyKeys 190 | .find(({ matcher }) => matcher(propertyKey)) 191 | ?.onSet?.(propertyKey); 192 | return false; 193 | } else { 194 | info.setValue(value); 195 | } 196 | } 197 | } 198 | 199 | hasPendingSetStates() { 200 | return Array.from(this.propertyKeys.values()).some( 201 | (info) => info instanceof ObservableProperty && info.isSetStatePending 202 | ); 203 | } 204 | 205 | doPendingSetStates() { 206 | this.propertyKeys.forEach((info) => { 207 | if (info instanceof ObservableProperty && info.isSetStatePending) { 208 | info.reactSetState?.(); 209 | } 210 | }); 211 | } 212 | /** 213 | * *********************** Store UseStates ****************************** 214 | */ 215 | registerUseStates() { 216 | this.storeAdmin.hooksManager.reactHooks.add({ 217 | hook: () => { 218 | const propertyKeysInfo = useFixedLazyRef(() => 219 | Array.from(this.propertyKeys.values()).filter( 220 | (info) => info instanceof ObservableProperty 221 | ) 222 | ) as ObservableProperty[]; 223 | 224 | propertyKeysInfo.forEach((info) => { 225 | const [state, setState] = useState(() => 226 | info.isPrimitive ? info.getValue("Store") : { $: info.getValue("Store") } 227 | ); 228 | info.setValue(state, "State"); 229 | info.setReactSetState(setState); 230 | }); 231 | }, 232 | }); 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/store/administrator/propertyKeys/unobservableProperty.ts: -------------------------------------------------------------------------------- 1 | export class UnobservableProperty { 2 | constructor(private value: unknown, public readonly isReadonly = false) {} 3 | 4 | setValue(value: unknown) { 5 | this.value = value; 6 | } 7 | 8 | getValue() { 9 | return this.value; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/store/administrator/propsManager.ts: -------------------------------------------------------------------------------- 1 | import { StoreAdministrator } from "./storeAdministrator"; 2 | import { PropsMetadata } from "src/decorators/props"; 3 | import { decoratorsMetadataStorage } from "src/utils/decoratorsMetadataStorage"; 4 | 5 | export class PropsManager { 6 | constructor(private storeAdmin: StoreAdministrator) {} 7 | 8 | register() { 9 | this.storeAdmin.hooksManager.reactHooks.add({ 10 | hook: (storeAdmin, props) => { 11 | const propsPropertyKey = decoratorsMetadataStorage.get( 12 | "Props", 13 | storeAdmin.type 14 | )[0]; 15 | if (propsPropertyKey) { 16 | storeAdmin.propertyKeysManager.onSetPropertyKey( 17 | propsPropertyKey, 18 | props, 19 | true 20 | ); 21 | } 22 | }, 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/store/administrator/storeAdministrator.ts: -------------------------------------------------------------------------------- 1 | import { STORE_ADMINISTRATION } from "../../constant"; 2 | import { StoreForConsumerComponentProxy } from "../../proxy/storeForConsumerComponentProxy"; 3 | import { StoreGettersManager } from "./getters/storeGettersManager"; 4 | import { HooksManager } from "./hooksManager"; 5 | import { StoreMethodsManager } from "./methods/storeMethodsManager"; 6 | import { StorePropertyKeysManager } from "./propertyKeys/storePropertyKeysManager"; 7 | import { PropsManager } from "./propsManager"; 8 | import { StoreEffectsManager } from "./storeEffectsManager"; 9 | import { ClassType } from "src/types"; 10 | import { decoratorsMetadataStorage } from "src/utils/decoratorsMetadataStorage"; 11 | 12 | export class StoreAdministrator { 13 | type: ClassType; 14 | 15 | instance: InstanceType; 16 | 17 | renderContext?: (relax?: boolean) => void; 18 | 19 | injectedInTos = new Set(); 20 | 21 | storePartAdministrators = new Set(); 22 | 23 | instanceForComponents: InstanceType; 24 | 25 | propsManager!: PropsManager; 26 | 27 | hooksManager!: HooksManager; 28 | 29 | gettersManager!: StoreGettersManager; 30 | 31 | methodsManager!: StoreMethodsManager; 32 | 33 | effectsManager!: StoreEffectsManager; 34 | 35 | propertyKeysManager!: StorePropertyKeysManager; 36 | 37 | constructor(type: ClassType, renderContext?: (relax?: boolean) => void) { 38 | this.type = type; 39 | this.renderContext = renderContext; 40 | this.propsManager = new PropsManager(this); 41 | this.hooksManager = new HooksManager(this); 42 | this.gettersManager = new StoreGettersManager(this); 43 | this.methodsManager = new StoreMethodsManager(this); 44 | this.effectsManager = new StoreEffectsManager(this); 45 | this.propertyKeysManager = new StorePropertyKeysManager(this); 46 | } 47 | 48 | static get(value: unknown) { 49 | return ((value && typeof value === "object" && value[STORE_ADMINISTRATION]) || 50 | null) as null | StoreAdministrator; 51 | } 52 | 53 | createInstance(instanceDepsValue: unknown[]) { 54 | // for example if we inject store A into other store B 55 | // if then injected store A change all store b consumer must be 56 | // notified to rerender base of their deps 57 | // so here we save store B ref in store A 58 | // to notify B if A changed 59 | instanceDepsValue.map(StoreAdministrator.get).forEach((sourceStoreAdmin) => { 60 | sourceStoreAdmin?.injectedInTos.add(this); 61 | if ( 62 | sourceStoreAdmin && 63 | decoratorsMetadataStorage.getOwn("StorePart", sourceStoreAdmin.type).length 64 | ) { 65 | this.storePartAdministrators.add(sourceStoreAdmin); 66 | } 67 | }); 68 | 69 | this.instance = new this.type(...instanceDepsValue); 70 | this.instance[STORE_ADMINISTRATION] = this; 71 | this.instanceForComponents = new Proxy( 72 | this.instance, 73 | new StoreForConsumerComponentProxy() 74 | ); 75 | 76 | // !!!! Orders matter !!!! 77 | this.propsManager.register(); 78 | this.hooksManager.register(); 79 | this.propertyKeysManager.registerUseStates(); 80 | this.propertyKeysManager.makeAllObservable(); 81 | this.effectsManager.registerEffects(); 82 | this.gettersManager.registerMemos(); 83 | this.methodsManager.bindMethods(); 84 | } 85 | 86 | renderConsumers(relax?: boolean) { 87 | this.renderContext?.(relax || this.propertyKeysManager.hasPendingSetStates()); 88 | this.propertyKeysManager.doPendingSetStates(); 89 | this.injectedInTos.forEach((st) => st.renderConsumers(relax)); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/store/administrator/storeEffectsManager.ts: -------------------------------------------------------------------------------- 1 | import type { StoreAdministrator } from "./storeAdministrator"; 2 | import cloneDeep from "clone-deep"; 3 | import { dequal } from "dequal"; 4 | import isPromise from "is-promise"; 5 | import { useEffect, useRef } from "react"; 6 | import { EffectMetaData, ManualEffectOptions } from "src/decorators/effect"; 7 | import { Func } from "src/types"; 8 | import { decoratorsMetadataStorage } from "src/utils/decoratorsMetadataStorage"; 9 | 10 | export class StoreEffectsManager { 11 | readonly effects = new Map }>(); 12 | 13 | constructor(private storeAdmin: StoreAdministrator) {} 14 | 15 | registerEffects() { 16 | this.effectsMetaData.forEach((e) => this.effects.set(e.propertyKey, {})); 17 | 18 | this.effectsMetaData.forEach((metadata) => { 19 | const handler = this.manualEffectHandler; 20 | this.storeAdmin.hooksManager.reactHooks.add({ 21 | hook: () => { 22 | handler.call(this, this.storeAdmin, metadata); 23 | }, 24 | }); 25 | }); 26 | } 27 | 28 | private manualEffectHandler( 29 | storeAdmin: StoreAdministrator, 30 | { 31 | propertyKey: effectKey, 32 | options, 33 | }: { options: ManualEffectOptions; propertyKey: PropertyKey } 34 | ) { 35 | const { effectsManager, instanceForComponents } = storeAdmin; 36 | const signal = useRef(0); 37 | const preDepsValue = useRef(); 38 | const isEqual = options.deepEqual ? dequal : Object.is; 39 | const depsValue = options.deps?.(instanceForComponents); 40 | 41 | if (depsValue) { 42 | if (depsValue.some((v, i) => !isEqual(v, preDepsValue.current?.[i]))) { 43 | preDepsValue.current = options.deepEqual 44 | ? cloneDeep(depsValue, true) 45 | : depsValue; 46 | signal.current++; 47 | } 48 | } else { 49 | signal.current++; 50 | } 51 | 52 | useEffect(() => { 53 | this.runEffect(effectKey, storeAdmin); 54 | return () => effectsManager.getClearEffect(effectKey)?.(); 55 | }, [signal.current]); 56 | } 57 | 58 | private runEffect(effectKey: PropertyKey, storeAdmin: StoreAdministrator) { 59 | /** 60 | * Run Effect 61 | * Context of effect function execution will be handled in methods manager 62 | * */ 63 | const clearEffect = (storeAdmin.instance[effectKey] as Func)?.apply(null) as 64 | | Func 65 | | undefined; 66 | 67 | if ( 68 | clearEffect && 69 | !isPromise(clearEffect) && 70 | typeof clearEffect === "function" 71 | ) { 72 | storeAdmin.effectsManager.setClearEffect(effectKey, clearEffect); 73 | } 74 | } 75 | 76 | get effectsMetaData() { 77 | // For overridden store methods we have two metadata 78 | // so we must filter duplicate ones 79 | return decoratorsMetadataStorage 80 | .get("Effect", this.storeAdmin.type) 81 | .filter( 82 | (v, i, data) => 83 | i === data.findIndex((vv) => vv.propertyKey === v.propertyKey) 84 | ); 85 | } 86 | 87 | setClearEffect(effectKey: PropertyKey, clear: Func) { 88 | const info = this.effects.get(effectKey); 89 | if (info) info.clear = clear; 90 | } 91 | 92 | getClearEffect(effectKey: PropertyKey) { 93 | return this.effects.get(effectKey)?.clear; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/store/connect.tsx: -------------------------------------------------------------------------------- 1 | import { StoreProvider } from "./StoreProvider"; 2 | import React from "react"; 3 | import { ClassType } from "src/types"; 4 | 5 | export const connect = 6 | (Component: React.FC, storeType: ClassType): React.FC => 7 | (props: T) => 8 | ; 9 | -------------------------------------------------------------------------------- /src/store/storeFactory.ts: -------------------------------------------------------------------------------- 1 | import { ReactStore } from ".."; 2 | import { 3 | ReactApplicationContext, 4 | StoreAdministratorReactContext, 5 | } from "../appContext"; 6 | import { StoreAdministrator } from "./administrator/storeAdministrator"; 7 | import { useContext } from "react"; 8 | import { getClassDependenciesType } from "src/decorators/inject"; 9 | import { StorePartMetadata } from "src/decorators/storePart"; 10 | import { ClassType } from "src/types"; 11 | import { decoratorsMetadataStorage } from "src/utils/decoratorsMetadataStorage"; 12 | import { useFixedLazyRef } from "src/utils/useLazyRef"; 13 | import { useWillMount } from "src/utils/useWillMount"; 14 | 15 | export class StoreFactory { 16 | static create( 17 | StoreType: ClassType, 18 | renderContext: (relax?: boolean) => void, 19 | props?: object 20 | ) { 21 | const deps = this.resolveStoreDeps(StoreType); 22 | 23 | const storeAdmin = useFixedLazyRef( 24 | () => new StoreAdministrator(StoreType, renderContext) 25 | ); 26 | 27 | useWillMount(() => { 28 | storeAdmin.createInstance(deps); 29 | }); 30 | this.runHooks(storeAdmin, props); 31 | 32 | return storeAdmin; 33 | } 34 | 35 | /** 36 | * *********************** Dependency Injection ******************* 37 | */ 38 | 39 | private static resolveStoreDeps(storeType: ClassType): unknown[] { 40 | // STORE 41 | const storeDeps = useFixedLazyRef(() => getClassDependenciesType(storeType)); 42 | 43 | const storeDepsContexts = useFixedLazyRef(() => { 44 | const storeDepsContexts = new Map(); 45 | const appContext = ReactStore.container.resolve(ReactApplicationContext); 46 | 47 | // Find dependencies which is store type 48 | // then resolve them from context 49 | storeDeps.forEach((depType) => { 50 | if (depType === storeType) { 51 | throw new Error( 52 | `You can't inject ${storeType.name} into ${storeType.name}!` 53 | ); 54 | } 55 | 56 | const storeContext = appContext.getStoreReactContext(depType); 57 | if (storeContext) { 58 | storeDepsContexts.set(depType, storeContext); 59 | } 60 | }); 61 | 62 | return Array.from(storeDepsContexts.entries()); 63 | }); 64 | 65 | const storicalDepsValues = storeDepsContexts.map(([type, context]) => { 66 | const storeAdmin = useContext(context); 67 | if (!storeAdmin) { 68 | throw new Error( 69 | `${type.name} haven't been connected to the component tree!` 70 | ); 71 | } 72 | return storeAdmin; 73 | }); 74 | 75 | /** 76 | * ******************************************************************** 77 | */ 78 | const storeStorePartTypes = useFixedLazyRef(() => 79 | storeDeps.filter( 80 | (depType) => 81 | !!decoratorsMetadataStorage.get("StorePart", depType) 82 | .length 83 | ) 84 | ); 85 | 86 | const storeStorePartDepsValues = storeStorePartTypes.map((storePartType) => ({ 87 | storePartType, 88 | deps: this.resolveStoreDeps(storePartType), 89 | })); 90 | 91 | return useFixedLazyRef(() => 92 | storeDeps.map((depType) => { 93 | const store = storicalDepsValues.find( 94 | (sdv) => sdv.storeAdmin.type === depType 95 | )?.storeAdmin.instance; 96 | if (store) { 97 | return store; 98 | } 99 | 100 | const isStorePart = storeStorePartTypes.includes(depType); 101 | if (isStorePart) { 102 | const deps = storeStorePartDepsValues.find( 103 | (e) => e.storePartType === depType 104 | )!.deps; 105 | const storePartAdmin = new StoreAdministrator(depType); 106 | storePartAdmin.createInstance(deps); 107 | return storePartAdmin.instance; 108 | } 109 | 110 | return ReactStore.container.resolve(depType as ClassType); 111 | }) 112 | ); 113 | } 114 | 115 | /** 116 | * ************** Hooks ************* 117 | */ 118 | static runHooks(storeAdmin: StoreAdministrator, props?: object) { 119 | storeAdmin.storePartAdministrators.forEach(this.runHooks.bind(this)); 120 | Array.from(storeAdmin.hooksManager.reactHooks.values()).forEach( 121 | ({ hook, result }) => { 122 | const res = hook(storeAdmin, props); 123 | result?.(res); 124 | } 125 | ); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | export interface ClassType extends Function { 3 | new (...args: any[]): T; 4 | } 5 | 6 | export type Func = (...args: any[]) => T; 7 | -------------------------------------------------------------------------------- /src/utils/decoratorsMetadataStorage.ts: -------------------------------------------------------------------------------- 1 | import { ClassType } from "src/types"; 2 | 3 | class DecoratorsMetadataStorage { 4 | private readonly decoratorsKeys = new Map(); 5 | 6 | private getDecoratorKey(decoType: DecoratorType) { 7 | if (this.decoratorsKeys.has(decoType)) { 8 | return this.decoratorsKeys.get(decoType); 9 | } else { 10 | const key = Symbol(); 11 | this.decoratorsKeys.set(decoType, key); 12 | return key; 13 | } 14 | } 15 | 16 | getOwn(decoType: DecoratorType, target: ClassType): T[] { 17 | const KEY = this.getDecoratorKey(decoType); 18 | let mds = Reflect.getOwnMetadata(KEY, target); 19 | if (!mds) { 20 | mds = []; 21 | Reflect.defineMetadata(KEY, mds, target); 22 | } 23 | return mds; 24 | } 25 | 26 | get(decoType: DecoratorType, target: ClassType): T[] { 27 | let mds = this.getOwn(decoType, target); 28 | const parentMds = Reflect.getPrototypeOf(target) as ClassType; 29 | if (parentMds) { 30 | mds = mds.concat(this.get(decoType, parentMds)); 31 | } 32 | 33 | return mds; 34 | } 35 | 36 | add(decoType: DecoratorType, target: ClassType, metadata: T) { 37 | this.getOwn(decoType, target).push(metadata); 38 | } 39 | } 40 | 41 | type DecoratorType = 42 | | "Memo" 43 | | "Hook" 44 | | "Props" 45 | | "Store" 46 | | "Inject" 47 | | "Effect" 48 | | "StorePart" 49 | | "Unobserve" 50 | | "Observable" 51 | | "Injectable"; 52 | 53 | export const decoratorsMetadataStorage = new DecoratorsMetadataStorage(); 54 | -------------------------------------------------------------------------------- /src/utils/getUnProxiedValue.ts: -------------------------------------------------------------------------------- 1 | import { TARGET } from "src/constant"; 2 | 3 | // eslint-disable-next-line 4 | export const getUnproxiedValue = (val: any, deep?: boolean) => { 5 | const v = val?.[TARGET] || val; 6 | return v?.[TARGET] && deep ? getUnproxiedValue(v, deep) : v; 7 | }; 8 | -------------------------------------------------------------------------------- /src/utils/isClass.ts: -------------------------------------------------------------------------------- 1 | import { ClassType } from "src/types"; 2 | 3 | export const isClass = (value: unknown): value is ClassType => 4 | typeof value === "function"; 5 | -------------------------------------------------------------------------------- /src/utils/isPrimitive.ts: -------------------------------------------------------------------------------- 1 | export const isPrimitive = (v: unknown) => Object(v) !== v; 2 | -------------------------------------------------------------------------------- /src/utils/toPlainObj.ts: -------------------------------------------------------------------------------- 1 | import cloneDeep from "clone-deep"; 2 | 3 | export const toPlainObj = (obj: unknown) => cloneDeep(obj); 4 | -------------------------------------------------------------------------------- /src/utils/useForceUpdate.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | 3 | export const useForceUpdate = () => { 4 | const [renderKey, setRenderKey] = useState(1); 5 | return [renderKey, useCallback(() => setRenderKey((pre) => ++pre), [])] as const; 6 | }; 7 | -------------------------------------------------------------------------------- /src/utils/useLazyRef.ts: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, useRef } from "react"; 2 | 3 | /** 4 | * React useRef not accept lazy values. here we made it 5 | * @param initValFunc 6 | */ 7 | export const useLazyRef = (initValFunc: () => T) => { 8 | const inited = useRef(false); 9 | const ref = useRef() as MutableRefObject; 10 | if (!inited.current) { 11 | inited.current = true; 12 | ref.current = initValFunc(); 13 | } 14 | return ref; 15 | }; 16 | 17 | export const useFixedLazyRef = (initValFunc: () => T) => { 18 | const inited = useRef(false); 19 | const ref = useRef() as MutableRefObject; 20 | if (!inited.current) { 21 | inited.current = true; 22 | ref.current = initValFunc(); 23 | } 24 | return ref.current; 25 | }; 26 | -------------------------------------------------------------------------------- /src/utils/useWillMount.ts: -------------------------------------------------------------------------------- 1 | import { useFixedLazyRef } from "./useLazyRef"; 2 | import { useEffect } from "react"; 3 | 4 | export const useWillMount = (fn: () => (() => void) | void) => { 5 | const clearEffect = useFixedLazyRef(fn); 6 | useEffect(() => clearEffect, []); 7 | }; 8 | -------------------------------------------------------------------------------- /tests/container.spec.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, ReactStore, Scope } from "../src"; 2 | import { Injector } from "../src/container/Injector"; 3 | 4 | describe("IOC Container", () => { 5 | beforeEach(() => { 6 | ReactStore.container.clear(); 7 | jest.restoreAllMocks(); 8 | }); 9 | 10 | it("should resolve dependencies automatically", () => { 11 | @Injectable() 12 | class UserInfo1 { 13 | username = "user1"; 14 | password = "1"; 15 | } 16 | 17 | @Injectable() 18 | class UserInfo2 { 19 | username = "user2"; 20 | password = "2"; 21 | } 22 | 23 | @Injectable() 24 | class App1 { 25 | constructor(public user1: UserInfo1, public user2: UserInfo2) {} 26 | } 27 | 28 | @Injectable() 29 | class App2 { 30 | constructor(public user1: UserInfo1, public user2: UserInfo2) {} 31 | } 32 | expect(ReactStore.container.resolve(App1).user1).toBeDefined(); 33 | expect(ReactStore.container.resolve(App1).user2).toBeDefined(); 34 | expect(ReactStore.container.resolve(App1).user1).toBe( 35 | ReactStore.container.resolve(App1).user1 36 | ); 37 | expect(ReactStore.container.resolve(App1).user2).toBe( 38 | ReactStore.container.resolve(App1).user2 39 | ); 40 | expect(ReactStore.container.resolve(App1).user1.username).toBe("user1"); 41 | expect(ReactStore.container.resolve(App1).user2.username).toBe("user2"); 42 | expect(ReactStore.container.resolve(App2).user1.username).toBe("user1"); 43 | expect(ReactStore.container.resolve(App2).user2.username).toBe("user2"); 44 | }); 45 | 46 | it("should remove singleton instance", () => { 47 | @Injectable() 48 | class App { 49 | username = "test"; 50 | } 51 | 52 | expect(ReactStore.container.resolve(App)).toBeDefined(); 53 | const app1 = ReactStore.container.resolve(App); 54 | ReactStore.container.clear(); 55 | const app2 = ReactStore.container.resolve(App); 56 | expect(app2).toBeDefined(); 57 | expect(app1).not.toBe(app2); 58 | }); 59 | 60 | it("should can remove class instance from container", () => { 61 | let createCount = 0; 62 | 63 | @Injectable() 64 | class A { 65 | constructor() { 66 | createCount++; 67 | } 68 | } 69 | ReactStore.container.resolve(A); 70 | ReactStore.container.remove(A); 71 | ReactStore.container.resolve(A); 72 | expect(createCount).toBe(2); 73 | }); 74 | 75 | describe("Scope", () => { 76 | it("should resolve deps with default scope (singleton)", () => { 77 | @Injectable() 78 | class App { 79 | p1 = Math.random(); 80 | } 81 | expect(ReactStore.container.resolve(App)).toBeInstanceOf(App); 82 | expect(ReactStore.container.resolve(App)).toBe( 83 | ReactStore.container.resolve(App) 84 | ); 85 | expect(ReactStore.container.resolve(App).p1).toBe( 86 | ReactStore.container.resolve(App).p1 87 | ); 88 | }); 89 | 90 | it("should resolve deps with transient scope", () => { 91 | @Injectable(Scope.TRANSIENT) 92 | class App { 93 | p1 = Math.random(); 94 | } 95 | expect(ReactStore.container.resolve(App)).toBeInstanceOf(App); 96 | expect(ReactStore.container.resolve(App)).not.toBe( 97 | ReactStore.container.resolve(App) 98 | ); 99 | expect(ReactStore.container.resolve(App).p1).not.toBe( 100 | ReactStore.container.resolve(App).p1 101 | ); 102 | }); 103 | }); 104 | 105 | it("should @Inject param decorator override dependencies", () => { 106 | @Injectable() 107 | class A {} 108 | 109 | @Injectable() 110 | class B {} 111 | 112 | @Injectable() 113 | class C { 114 | constructor(public injector: Injector, @Inject(B) public a: A) {} 115 | } 116 | 117 | const c = ReactStore.container.resolve(C); 118 | expect(c.a).toBeInstanceOf(B); 119 | }); 120 | 121 | describe("Injector", () => { 122 | it("should inject injector class instance", () => { 123 | expect.assertions(1); 124 | @Injectable() 125 | class UserService { 126 | constructor(injector: Injector) { 127 | expect(injector).toBeInstanceOf(Injector); 128 | } 129 | } 130 | ReactStore.container.resolve(UserService); 131 | }); 132 | 133 | it("should resolve dependency with injector", () => { 134 | expect.assertions(1); 135 | @Injectable() 136 | class UserService {} 137 | 138 | @Injectable() 139 | class ToDoService { 140 | constructor(injector: Injector) { 141 | expect(injector.get(UserService)).toBeInstanceOf(UserService); 142 | } 143 | } 144 | ReactStore.container.resolve(ToDoService); 145 | }); 146 | 147 | it("should resolve dependency with injector in lazy mode", () => { 148 | expect.assertions(1); 149 | let wait: Promise; 150 | 151 | @Injectable() 152 | class UserService {} 153 | 154 | @Injectable() 155 | class ToDoService { 156 | constructor(injector: Injector) { 157 | wait = injector.getLazy(UserService).then((userService) => { 158 | expect(userService).toBeInstanceOf(UserService); 159 | }); 160 | } 161 | } 162 | ReactStore.container.resolve(ToDoService); 163 | return wait!; 164 | }); 165 | 166 | it("should resolve dependency with injector with circular deps", () => { 167 | expect.assertions(2); 168 | 169 | let resolve; 170 | const wait = new Promise((res) => { 171 | resolve = res; 172 | }); 173 | 174 | @Injectable() 175 | class UserService { 176 | constructor(injector: Injector) { 177 | injector.getLazy(ToDoService).then((toDoService) => { 178 | expect(toDoService).toBeInstanceOf(ToDoService); 179 | resolve(); 180 | }); 181 | } 182 | } 183 | 184 | @Injectable() 185 | class ToDoService { 186 | constructor(userService: UserService) { 187 | expect(userService).toBeInstanceOf(UserService); 188 | } 189 | } 190 | ReactStore.container.resolve(ToDoService); 191 | 192 | return wait; 193 | }); 194 | }); 195 | 196 | describe("Inheritance", () => { 197 | it("should inject dependencies from parent class", () => { 198 | @Injectable() 199 | class A {} 200 | 201 | @Injectable() 202 | class B { 203 | constructor(public a: A) {} 204 | } 205 | 206 | @Injectable() 207 | class C extends B {} 208 | 209 | const c = ReactStore.container.resolve(C); 210 | expect(c.a).toBeInstanceOf(A); 211 | }); 212 | 213 | it("should inject dependencies from main class and ignore parent class dependencies", () => { 214 | @Injectable() 215 | class A1 {} 216 | 217 | @Injectable() 218 | class A2 {} 219 | 220 | @Injectable() 221 | class B { 222 | constructor(public a1: A1) {} 223 | } 224 | 225 | @Injectable() 226 | class C extends B { 227 | constructor(public a2: A2, public a1: A1) { 228 | super(a1); 229 | } 230 | } 231 | 232 | const c = ReactStore.container.resolve(C); 233 | expect(c.a1).toBeInstanceOf(A1); 234 | expect(c.a2).toBeInstanceOf(A2); 235 | }); 236 | }); 237 | 238 | describe("Injection Errors And Warnings", () => { 239 | it("should throw error if class is not decorated wit @Injectable()", () => { 240 | expect.assertions(1); 241 | 242 | try { 243 | class A {} 244 | ReactStore.container.resolve(A); 245 | } catch (error: any) { 246 | expect(error.message).toBe( 247 | "`class A` has not been decorated with @Injectable()" 248 | ); 249 | } 250 | }); 251 | 252 | it("should throw error if @Inject class decorator and @Injectable with access to decorator meta data is used simultaneously", () => { 253 | expect.assertions(1); 254 | try { 255 | @Injectable() 256 | class A {} 257 | 258 | @Inject(A) 259 | @Injectable() 260 | class B { 261 | constructor(@Inject(A) a: A) {} 262 | } 263 | 264 | ReactStore.container.resolve(B); 265 | } catch (error: any) { 266 | expect(error.message).toBe( 267 | "Dependencies are injecting by @Inject() as parameter and class decorator for `class B`. Use one of them." 268 | ); 269 | } 270 | }); 271 | 272 | it("should show warning for auto dependency detection and explicit @Inject(...)", () => { 273 | const warnMock = jest.spyOn(console, "warn").mockImplementation(); 274 | expect.assertions(1); 275 | @Injectable() 276 | class A {} 277 | 278 | @Injectable() 279 | @Inject(A) 280 | class B { 281 | constructor(a: A) {} 282 | } 283 | expect(warnMock).toHaveBeenLastCalledWith( 284 | "Dependencies are automatically detected for `class B`. Remove @Inject(...)" 285 | ); 286 | ReactStore.container.resolve(B); 287 | }); 288 | }); 289 | 290 | describe("Without Decorator Metadata", () => { 291 | it("should inject dependencies with class @Inject(...) decorator", () => { 292 | const getOwnMetadata = Reflect.getOwnMetadata; 293 | jest 294 | .spyOn(Reflect, "getOwnMetadata") 295 | .mockImplementation((mdKey, target) => 296 | mdKey === "design:paramtypes" ? null : getOwnMetadata(mdKey, target) 297 | ); 298 | 299 | @Injectable() 300 | class A {} 301 | 302 | @Inject(A) 303 | @Injectable() 304 | class B { 305 | constructor(public a: A) {} 306 | } 307 | 308 | const b = ReactStore.container.resolve(B); 309 | expect(b.a).toBeInstanceOf(A); 310 | expect(Reflect.getOwnMetadata("design:paramtypes", B)).toBeNull(); 311 | }); 312 | }); 313 | }); 314 | -------------------------------------------------------------------------------- /tests/defineInjectable.spec.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, ReactStore } from "../src"; 2 | 3 | describe("Define Injectable", () => { 4 | beforeEach(() => { 5 | ReactStore.container.clear(); 6 | }); 7 | 8 | it("should define injectable using value", () => { 9 | expect.assertions(1); 10 | 11 | const CurrentUser = () => Inject("CURRENT_USER"); 12 | 13 | ReactStore.container.defineInjectable({ 14 | token: "CURRENT_USER", 15 | value: "amir.qasemi74", 16 | }); 17 | 18 | @Injectable() 19 | class PostService { 20 | constructor(@CurrentUser() currentUser: string) { 21 | expect(currentUser).toBe("amir.qasemi74"); 22 | } 23 | } 24 | 25 | ReactStore.container.resolve(PostService); 26 | }); 27 | 28 | it("should define injectable using class when token is class", () => { 29 | expect.assertions(2); 30 | 31 | @Injectable() 32 | class User { 33 | username?: string; 34 | } 35 | 36 | class AmirUser { 37 | username = "amir.qasemi74"; 38 | } 39 | 40 | ReactStore.container.defineInjectable({ 41 | token: User, 42 | class: AmirUser, 43 | }); 44 | 45 | @Injectable() 46 | class PostService { 47 | constructor(user: User) { 48 | expect(user).toBeInstanceOf(AmirUser); 49 | expect(user.username).toBe("amir.qasemi74"); 50 | } 51 | } 52 | 53 | ReactStore.container.resolve(PostService); 54 | }); 55 | 56 | it("should define injectable using class when token is string or symbol", () => { 57 | expect.assertions(2); 58 | 59 | const CurrentUser = Symbol(); 60 | 61 | class User { 62 | username = "amir.qasemi74"; 63 | } 64 | 65 | ReactStore.container.defineInjectable({ 66 | token: CurrentUser, 67 | class: User, 68 | }); 69 | 70 | @Injectable() 71 | class PostService { 72 | constructor(@Inject(CurrentUser) user: User) { 73 | expect(user).toBeInstanceOf(User); 74 | expect(user.username).toBe("amir.qasemi74"); 75 | } 76 | } 77 | 78 | ReactStore.container.resolve(PostService); 79 | }); 80 | 81 | it("should define injectable using factory", () => { 82 | expect.assertions(2); 83 | 84 | @Injectable() 85 | class UserService { 86 | getUsername() { 87 | return "amir.qasemi74"; 88 | } 89 | } 90 | 91 | @Injectable() 92 | class User { 93 | username: string; 94 | } 95 | 96 | ReactStore.container.defineInjectable({ 97 | token: User, 98 | inject: [UserService], 99 | factory: (userService: UserService) => { 100 | const user = new User(); 101 | user.username = userService.getUsername(); 102 | return user; 103 | }, 104 | }); 105 | 106 | @Injectable() 107 | class PostService { 108 | constructor(user: User) { 109 | expect(user).toBeInstanceOf(User); 110 | expect(user.username).toBe("amir.qasemi74"); 111 | } 112 | } 113 | 114 | ReactStore.container.resolve(PostService); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /tests/depsInjection.spec.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | Store, 4 | StoreProvider, 5 | connect, 6 | useStore, 7 | } from "@react-store/core"; 8 | import "@testing-library/jest-dom/extend-expect"; 9 | import { fireEvent, render } from "@testing-library/react"; 10 | import React, { memo } from "react"; 11 | import { act } from "react-dom/test-utils"; 12 | import { UnobservableProperty } from "src/store/administrator/propertyKeys/unobservableProperty"; 13 | import { StoreAdministrator } from "src/store/administrator/storeAdministrator"; 14 | 15 | describe("Dependency Injection", () => { 16 | it("should inject @Injectable into store", () => { 17 | expect.assertions(5); 18 | 19 | @Injectable() 20 | class UserService {} 21 | 22 | @Injectable() 23 | class PostService {} 24 | 25 | @Store() 26 | class PostStore { 27 | constructor(postService: PostService, userService: UserService) { 28 | expect(postService).toBeInstanceOf(PostService); 29 | expect(userService).toBeInstanceOf(UserService); 30 | userService = userService; 31 | } 32 | } 33 | 34 | @Store() 35 | class UserStore { 36 | username = "amir.qasemi74"; 37 | password = "123456"; 38 | 39 | constructor(postStore: PostStore, userService: UserService) { 40 | expect(postStore).toBeInstanceOf(PostStore); 41 | expect(userService).toBeInstanceOf(UserService); 42 | expect(userService).toBe(userService); 43 | } 44 | } 45 | 46 | const App = () => { 47 | const vm = useStore(UserStore); 48 | useStore(PostStore); 49 | return ( 50 |
51 | username: {vm.username} 52 | password: {vm.password} 53 |
54 | ); 55 | }; 56 | const AppWithStore = connect(connect(App, UserStore), PostStore); 57 | render(); 58 | }); 59 | 60 | it("should injectable injected property be readonly", () => { 61 | const errorMock = jest.spyOn(console, "error").mockImplementation(); 62 | let store!: PostStore; 63 | 64 | @Injectable() 65 | class PostService {} 66 | 67 | @Store() 68 | class PostStore { 69 | constructor(public postService: PostService) { 70 | store = this; 71 | } 72 | } 73 | 74 | const App = connect(() => { 75 | useStore(PostStore); 76 | return
; 77 | }, PostStore); 78 | 79 | render(); 80 | 81 | act(() => { 82 | store.postService = "qw"; 83 | }); 84 | expect(errorMock).toBeCalledWith( 85 | "`PostStore.postService` is an injected @Injectable() , so can't be mutated." 86 | ); 87 | 88 | const pkInfo = StoreAdministrator.get( 89 | store 90 | )!.propertyKeysManager.propertyKeys.get( 91 | "postService" 92 | ) as UnobservableProperty; 93 | 94 | expect(pkInfo).toBeInstanceOf(UnobservableProperty); 95 | expect(pkInfo.isReadonly).toBeTruthy(); 96 | }); 97 | 98 | it("should upper store inject into lower store", () => { 99 | let appStore!: AppStore, appStoreInUserStore!: AppStore; 100 | 101 | @Store() 102 | class AppStore { 103 | theme = "black"; 104 | } 105 | 106 | @Store() 107 | class UserStore { 108 | username = "amir.qasemi74"; 109 | password = "123456"; 110 | 111 | constructor(public app: AppStore) { 112 | appStoreInUserStore = app; 113 | } 114 | } 115 | 116 | const User = () => { 117 | const vm = useStore(UserStore); 118 | return ( 119 | <> 120 | {vm.username} 121 | {vm.password} 122 | {vm.app.theme} 123 | 124 | ); 125 | }; 126 | 127 | const App = () => { 128 | const vm = useStore(AppStore); 129 | appStore = vm; 130 | return ; 131 | }; 132 | const AppWithStore = connect(App, AppStore); 133 | const { getByText } = render(); 134 | 135 | expect(appStore).not.toBe(null); 136 | expect(appStoreInUserStore).not.toBe(null); 137 | 138 | expect(StoreAdministrator.get(appStore)).toBe( 139 | StoreAdministrator.get(appStoreInUserStore) 140 | ); 141 | 142 | expect(getByText(/amir.qasemi74/i)).toBeInTheDocument(); 143 | expect(getByText(/123456/i)).toBeInTheDocument(); 144 | expect(getByText(/black/i)).toBeInTheDocument(); 145 | }); 146 | 147 | it("should injected store and readonly", () => { 148 | const errorMock = jest.spyOn(console, "error").mockImplementation(); 149 | 150 | let store!: UserStore; 151 | @Store() 152 | class AppStore {} 153 | 154 | @Store() 155 | class UserStore { 156 | constructor(public appStore: AppStore) { 157 | store = this; 158 | } 159 | } 160 | 161 | const User = () => { 162 | useStore(UserStore); 163 | return <>; 164 | }; 165 | 166 | const App = connect(() => { 167 | return ; 168 | }, AppStore); 169 | 170 | render(); 171 | 172 | act(() => { 173 | store.appStore = "sdf"; 174 | }); 175 | expect(errorMock).toBeCalledWith( 176 | "`UserStore.appStore` is an injected store, so can't be mutated" 177 | ); 178 | expect( 179 | StoreAdministrator.get(store)!.propertyKeysManager.propertyKeys.get( 180 | "appStore" 181 | ) 182 | ).toBeInstanceOf(UnobservableProperty); 183 | }); 184 | 185 | it("Upper store mutations should rerender it's consumers", () => { 186 | @Store() 187 | class AppStore { 188 | theme = "black"; 189 | 190 | changeTheme() { 191 | this.theme = "white"; 192 | } 193 | } 194 | 195 | @Store() 196 | class UserStore { 197 | username = "amir.qasemi74"; 198 | password = "123456"; 199 | 200 | constructor(public app: AppStore) {} 201 | } 202 | 203 | const User = memo(() => { 204 | const vm = useStore(UserStore); 205 | return ( 206 | <> 207 | {vm.username} 208 | {vm.password} 209 | {vm.app.theme} 210 | 211 | ); 212 | }); 213 | 214 | const App = () => { 215 | const vm = useStore(AppStore); 216 | return ( 217 | <> 218 | 219 | 220 | 221 | ); 222 | }; 223 | const AppWithStore = connect(App, AppStore); 224 | const { getByText } = render(); 225 | 226 | fireEvent.click(getByText("change Theme")); 227 | 228 | expect(getByText(/white/i)).toBeInTheDocument(); 229 | }); 230 | 231 | describe("Inheritance", () => { 232 | it("should inject parent store dependencies", () => { 233 | let mainStore!: MainStore; 234 | @Injectable() 235 | class A {} 236 | 237 | @Store() 238 | class BaseStore { 239 | constructor(public a: A) {} 240 | } 241 | 242 | @Store() 243 | class MainStore extends BaseStore {} 244 | 245 | const App = connect(() => { 246 | mainStore = useStore(MainStore); 247 | return <>; 248 | }, MainStore); 249 | 250 | render(); 251 | 252 | expect(mainStore.a).toBeInstanceOf(A); 253 | }); 254 | 255 | it("should inject main store dependencies and ignore parent dependencies", () => { 256 | let mainStore!: MainStore; 257 | @Injectable() 258 | class A {} 259 | 260 | @Injectable() 261 | class B {} 262 | 263 | @Store() 264 | class BaseStore { 265 | constructor(public a: A) {} 266 | } 267 | 268 | @Store() 269 | class MainStore extends BaseStore { 270 | constructor(public a: A, public b: B) { 271 | super(a); 272 | } 273 | } 274 | 275 | const App = connect(() => { 276 | mainStore = useStore(MainStore); 277 | return <>; 278 | }, MainStore); 279 | 280 | render(); 281 | 282 | expect(mainStore.a).toBeInstanceOf(A); 283 | expect(mainStore.b).toBeInstanceOf(B); 284 | }); 285 | }); 286 | }); 287 | -------------------------------------------------------------------------------- /tests/e2e/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | 3 | export default defineConfig({ 4 | projectId: 'ou87tg', 5 | component: { 6 | devServer: { 7 | framework: "react", 8 | bundler: "webpack", 9 | }, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /tests/e2e/cypress.d.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "cypress/react"; 2 | 3 | // Augment the Cypress namespace to include type definitions for 4 | // your custom command. 5 | // Alternatively, can be defined in cypress/support/component.d.ts 6 | // with a at the top of your spec. 7 | declare global { 8 | namespace Cypress { 9 | interface Chainable { 10 | mount: typeof mount; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/e2e/cypress/component/methods/methodExecutionContext.cy.tsx: -------------------------------------------------------------------------------- 1 | import { Effect, Store, connect, useStore } from "@react-store/core"; 2 | 3 | describe("Methods Execution Context", () => { 4 | it("should have correct value of store property if state is set and one render \ 5 | after it has been occurred but react not updated the state value", function () { 6 | const logs: string[] = []; 7 | 8 | @Store() 9 | class UserStore { 10 | username = "a"; 11 | 12 | @Effect([]) 13 | onMount() { 14 | this.username = "aa"; 15 | Promise.resolve().then(() => { 16 | logs.push(`promise.then: ${this.username}`); 17 | expect(this.username).to.be.eq("aa"); 18 | }); 19 | } 20 | 21 | @Effect([]) 22 | cupHungryJob() { 23 | let i = 0; 24 | while (i < 1_000_000_0) { 25 | i++; 26 | } 27 | } 28 | } 29 | 30 | const User = connect(() => { 31 | const st = useStore(UserStore); 32 | logs.push(`render: ${st.username}`); 33 | return <>{st.username}; 34 | }, UserStore); 35 | 36 | cy.mount(); 37 | 38 | cy.contains("aa").then(() => { 39 | /** 40 | * This pattern of logs occurred in react 18.2. 41 | * promise.then is called before second render. 42 | * why? still I don't know 43 | */ 44 | expect(logs).to.deep.eq(["render: a", "promise.then: aa", "render: aa"]); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /tests/e2e/cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /tests/e2e/cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Components App 8 | 9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /tests/e2e/cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/component.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | // Import commands.js using ES2015 syntax: 16 | 17 | import "./commands"; 18 | 19 | 20 | import { mount } from "cypress/react18"; 21 | 22 | Cypress.Commands.add("mount", mount); 23 | -------------------------------------------------------------------------------- /tests/e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "types": [ 5 | "cypress", 6 | "node", 7 | ], 8 | "jsx": "react-jsx" 9 | }, 10 | "include": [ 11 | "cypress/component", 12 | "**/*.d.ts" 13 | ] 14 | } -------------------------------------------------------------------------------- /tests/e2e/webpack.config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { ProvidePlugin } from "webpack"; 3 | 4 | const srcDir = path.resolve(__dirname, "../../src"); 5 | 6 | module.exports = { 7 | mode: "development", 8 | resolve: { 9 | extensions: [".ts", ".tsx", ".js"], 10 | alias: { 11 | src: srcDir, 12 | "@react-store/core": srcDir, 13 | }, 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.tsx?$/, 19 | loader: "ts-loader", 20 | }, 21 | ], 22 | }, 23 | plugins: [ 24 | new ProvidePlugin({ 25 | React: "react", 26 | }), 27 | ], 28 | }; 29 | -------------------------------------------------------------------------------- /tests/effectDecorator.spec.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Effect, 3 | Observable, 4 | ReactStore, 5 | Store, 6 | connect, 7 | useStore, 8 | } from "@react-store/core"; 9 | import { fireEvent, render, waitFor } from "@testing-library/react"; 10 | import React, { ChangeEvent } from "react"; 11 | import { act } from "react-dom/test-utils"; 12 | 13 | describe("Effects", () => { 14 | beforeEach(() => { 15 | ReactStore.container.clear(); 16 | }); 17 | 18 | it("should effect be called on each render", async () => { 19 | let store!: UserStore; 20 | const usernameChangeCallback = jest.fn(); 21 | @Store() 22 | class UserStore { 23 | username = "1"; 24 | 25 | constructor() { 26 | store = this; 27 | } 28 | @Effect() 29 | onUsernameChange() { 30 | usernameChangeCallback(); 31 | } 32 | } 33 | 34 | const User = connect(() => { 35 | const vm = useStore(UserStore); 36 | return <>{vm.username}; 37 | }, UserStore); 38 | 39 | render(); 40 | 41 | expect(usernameChangeCallback).toBeCalledTimes(1); 42 | 43 | act(() => { 44 | store.username = "2"; 45 | }); 46 | expect(usernameChangeCallback).toBeCalledTimes(2); 47 | 48 | act(() => { 49 | store.username = "3"; 50 | }); 51 | expect(usernameChangeCallback).toBeCalledTimes(3); 52 | }); 53 | 54 | it("should effect be called when dependencies are being changed", async () => { 55 | const usernameChangeCallback = jest.fn(); 56 | @Store() 57 | class UserStore { 58 | user = { name: "amir.qasemi74" }; 59 | password = "123456"; 60 | 61 | @Effect((_) => [_.user.name]) 62 | onUsernameChange() { 63 | this.user.name; 64 | this.password; 65 | usernameChangeCallback(); 66 | } 67 | 68 | changeUsername(e: ChangeEvent) { 69 | this.user.name = e.target.value; 70 | } 71 | } 72 | 73 | const User = connect(() => { 74 | const vm = useStore(UserStore); 75 | return ( 76 | <> 77 | {vm.user.name} 78 | {vm.password} 79 | 84 | 85 | ); 86 | }, UserStore); 87 | 88 | const { findByTestId } = render(); 89 | const input = await findByTestId("username-input"); 90 | 91 | expect(usernameChangeCallback).toBeCalledTimes(1); 92 | 93 | // change username dep 94 | await waitFor(() => { 95 | fireEvent.change(input, { target: { value: "amir.qasemi70" } }); 96 | }); 97 | await waitFor(() => expect(usernameChangeCallback).toBeCalledTimes(2)); 98 | 99 | // no change 100 | await waitFor(() => { 101 | fireEvent.change(input, { target: { value: "amir.qasemi70" } }); 102 | }); 103 | await waitFor(() => expect(usernameChangeCallback).toBeCalledTimes(2)); 104 | // change username dep again 105 | await waitFor(() => { 106 | fireEvent.change(input, { target: { value: "amir.qasemi75" } }); 107 | }); 108 | await waitFor(() => expect(usernameChangeCallback).toBeCalledTimes(3)); 109 | }); 110 | 111 | it("should call clear Effect before running new effect", async () => { 112 | const usernameChangeCallback = jest.fn(); 113 | const usernameChangeClearEffect = jest.fn(); 114 | const callStack: Array<"effect" | "clear-effect"> = []; 115 | 116 | @Store() 117 | class UserStore { 118 | username = "amir.qasemi74"; 119 | password = "123456"; 120 | 121 | changeUsername(e: ChangeEvent) { 122 | this.username = e.target.value; 123 | } 124 | 125 | @Effect((_) => [_.username]) 126 | onUsernameChange() { 127 | usernameChangeCallback(); 128 | callStack.push("effect"); 129 | return () => { 130 | callStack.push("clear-effect"); 131 | usernameChangeClearEffect(); 132 | }; 133 | } 134 | } 135 | 136 | const User = connect(() => { 137 | const vm = useStore(UserStore); 138 | return ( 139 | <> 140 | {vm.username} 141 | {vm.password} 142 | 147 | 148 | ); 149 | }, UserStore); 150 | 151 | const { findByTestId } = render(); 152 | const input = await findByTestId("username-input"); 153 | 154 | expect(usernameChangeCallback).toBeCalledTimes(1); 155 | expect(usernameChangeClearEffect).toBeCalledTimes(0); 156 | expect(callStack).toEqual(["effect"]); 157 | 158 | // change username dep 159 | await waitFor(() => { 160 | fireEvent.change(input, { target: { value: "amir.qasemi70" } }); 161 | }); 162 | await waitFor(() => expect(usernameChangeCallback).toBeCalledTimes(2)); 163 | expect(usernameChangeClearEffect).toBeCalledTimes(1); 164 | expect(callStack).toEqual(["effect", "clear-effect", "effect"]); 165 | 166 | // no change 167 | await waitFor(() => { 168 | fireEvent.change(input, { target: { value: "amir.qasemi70" } }); 169 | }); 170 | expect(usernameChangeCallback).toBeCalledTimes(2); 171 | expect(usernameChangeClearEffect).toBeCalledTimes(1); 172 | expect(callStack).toEqual(["effect", "clear-effect", "effect"]); 173 | 174 | // change username dep again 175 | await waitFor(() => { 176 | fireEvent.change(input, { target: { value: "amir.qasemi75" } }); 177 | }); 178 | await waitFor(() => expect(usernameChangeCallback).toBeCalledTimes(3)); 179 | expect(usernameChangeClearEffect).toBeCalledTimes(2); 180 | expect(callStack).toEqual([ 181 | "effect", 182 | "clear-effect", 183 | "effect", 184 | "clear-effect", 185 | "effect", 186 | ]); 187 | }); 188 | 189 | it("should run effect for observable class instance change in deep equal mode", async () => { 190 | @Observable() 191 | class User { 192 | name = "amir.qasemi74"; 193 | } 194 | 195 | const onUserChangeCB = jest.fn(); 196 | @Store() 197 | class UserStore { 198 | user = new User(); 199 | 200 | changeUsername(e: ChangeEvent) { 201 | this.user.name = e.target.value; 202 | } 203 | 204 | @Effect((_) => [_.user], true) 205 | onUserChange() { 206 | onUserChangeCB(); 207 | } 208 | } 209 | 210 | const App = connect(() => { 211 | const vm = useStore(UserStore); 212 | return ( 213 | <> 214 | {vm.user.name} 215 | 220 | 221 | ); 222 | }, UserStore); 223 | 224 | const { getByTestId } = render(); 225 | 226 | expect(onUserChangeCB).toBeCalledTimes(1); 227 | 228 | fireEvent.change(getByTestId("username-input"), { 229 | target: { value: "amir.qasemi70" }, 230 | }); 231 | expect(onUserChangeCB).toBeCalledTimes(2); 232 | }); 233 | 234 | it("should can pass effect deps as array of string object path", () => { 235 | const onMountChangeCB = jest.fn(); 236 | const onUserChangeCB = jest.fn(); 237 | const onUsernameChangeCB = jest.fn(); 238 | const onUsernameDepAsStringChangeCB = jest.fn(); 239 | @Store() 240 | class UserStore { 241 | user = { name: "sdf" }; 242 | 243 | changeUsername(e: ChangeEvent) { 244 | this.user.name = e.target.value; 245 | } 246 | 247 | @Effect([]) 248 | onMount() { 249 | onMountChangeCB(); 250 | } 251 | 252 | @Effect(["user.name"]) 253 | onUsernameChange() { 254 | onUsernameChangeCB(); 255 | } 256 | 257 | @Effect(["user"], true) 258 | onUserChange() { 259 | onUserChangeCB(); 260 | } 261 | 262 | @Effect("user.name") 263 | onUsernameAsStringDepChange() { 264 | onUsernameDepAsStringChangeCB(); 265 | } 266 | } 267 | 268 | const App = connect(() => { 269 | const vm = useStore(UserStore); 270 | return ( 271 | <> 272 | {vm.user.name} 273 | 278 | 279 | ); 280 | }, UserStore); 281 | 282 | const { getByTestId } = render(); 283 | 284 | expect(onMountChangeCB).toBeCalledTimes(1); 285 | expect(onUserChangeCB).toBeCalledTimes(1); 286 | expect(onUsernameChangeCB).toBeCalledTimes(1); 287 | expect(onUsernameDepAsStringChangeCB).toBeCalledTimes(1); 288 | 289 | fireEvent.change(getByTestId("username-input"), { 290 | target: { value: "amir.qasemi70" }, 291 | }); 292 | expect(onMountChangeCB).toBeCalledTimes(1); 293 | expect(onUserChangeCB).toBeCalledTimes(2); 294 | expect(onUsernameChangeCB).toBeCalledTimes(2); 295 | expect(onUsernameDepAsStringChangeCB).toBeCalledTimes(2); 296 | }); 297 | 298 | describe("Render Context", () => { 299 | it("should rerender if store property set in effect", () => { 300 | @Store() 301 | class UserStore { 302 | user = { name: "user" }; 303 | 304 | password = "pass"; 305 | 306 | @Effect([]) 307 | changeUsername() { 308 | this.user.name = "user2"; 309 | } 310 | 311 | @Effect([]) 312 | changePassword() { 313 | this.password = "pass2"; 314 | } 315 | } 316 | 317 | const User = connect(() => { 318 | const vm = useStore(UserStore); 319 | return ( 320 | <> 321 |

{vm.user.name}

322 |

{vm.password}

323 | 324 | ); 325 | }, UserStore); 326 | 327 | const { getByText } = render(); 328 | 329 | expect(getByText("user2")).toBeInTheDocument(); 330 | expect(getByText("pass2")).toBeInTheDocument(); 331 | }); 332 | 333 | it("should set store property return new value on read after assignment", () => { 334 | expect.assertions(1); 335 | @Store() 336 | class UserStore { 337 | password = "pass"; 338 | 339 | @Effect([]) 340 | changePassword() { 341 | this.password = "pass2"; 342 | expect(this.password).toBe("pass2"); 343 | } 344 | } 345 | 346 | const User = connect(() => { 347 | const vm = useStore(UserStore); 348 | return ( 349 | <> 350 |

{vm.password}

351 | 352 | ); 353 | }, UserStore); 354 | 355 | render(); 356 | }); 357 | 358 | it("should store method bind to effect context if is called from effect", async () => { 359 | expect.assertions(2); 360 | 361 | @Store() 362 | class UserStore { 363 | password = "pass"; 364 | 365 | getPassword() { 366 | expect(this.password).toBe("pass2"); 367 | } 368 | 369 | @Effect([]) 370 | async changePassword() { 371 | this.password = "pass2"; 372 | this.getPassword(); 373 | } 374 | } 375 | 376 | const User = connect(() => { 377 | const vm = useStore(UserStore); 378 | 379 | return

{vm.password}

; 380 | }, UserStore); 381 | 382 | const { getByText } = render(); 383 | 384 | await waitFor(() => expect(getByText("pass2")).toBeInTheDocument()); 385 | }); 386 | 387 | it("should direct mutated properties doesn't share between effects execution", () => { 388 | expect.assertions(1); 389 | @Store() 390 | class UserStore { 391 | password = "pass"; 392 | 393 | @Effect([]) 394 | effect1() { 395 | this.password = "pass2"; 396 | } 397 | 398 | @Effect([]) 399 | effect2() { 400 | expect(this.password).toBe("pass"); 401 | } 402 | } 403 | 404 | const User = connect(() => { 405 | const vm = useStore(UserStore); 406 | return

{vm.password}

; 407 | }, UserStore); 408 | 409 | render(); 410 | }); 411 | 412 | it("should async effect have correct change value after async action", (done) => { 413 | expect.assertions(1); 414 | @Store() 415 | class UserStore { 416 | password = "pass"; 417 | 418 | @Effect([]) 419 | async effect1() { 420 | this.password = "pass2"; 421 | await new Promise((res) => setTimeout(res, 0)); 422 | expect(this.password).toBe("pass2"); 423 | done(); 424 | } 425 | } 426 | 427 | const User = connect(() => { 428 | const vm = useStore(UserStore); 429 | 430 | return

{vm.password}

; 431 | }, UserStore); 432 | 433 | render(); 434 | }); 435 | }); 436 | 437 | describe("Parent Class", () => { 438 | it("should run parent store class effects", () => { 439 | const onMountedCB = jest.fn(); 440 | 441 | @Store() 442 | class A { 443 | @Effect([]) 444 | onMount() { 445 | onMountedCB(); 446 | } 447 | } 448 | 449 | @Store() 450 | class B extends A {} 451 | 452 | const App = connect(() => { 453 | return <>; 454 | }, B); 455 | 456 | render(); 457 | 458 | expect(onMountedCB).toBeCalledTimes(1); 459 | }); 460 | 461 | it("should only run overridden effect method", () => { 462 | const baseStoreMountCB = jest.fn(); 463 | const mainStoreMountCB = jest.fn(); 464 | 465 | @Store() 466 | class BaseStore { 467 | @Effect([]) 468 | onMount() { 469 | baseStoreMountCB(); 470 | } 471 | } 472 | 473 | @Store() 474 | class MainStore extends BaseStore { 475 | @Effect([]) 476 | onMount() { 477 | mainStoreMountCB(); 478 | } 479 | } 480 | 481 | const App = connect(() => { 482 | return <>; 483 | }, MainStore); 484 | 485 | render(); 486 | 487 | expect(baseStoreMountCB).toBeCalledTimes(0); 488 | expect(mainStoreMountCB).toBeCalledTimes(1); 489 | }); 490 | }); 491 | }); 492 | -------------------------------------------------------------------------------- /tests/hookDecorator.spec.tsx: -------------------------------------------------------------------------------- 1 | import { Effect, Hook, Store, connect, useStore } from "@react-store/core"; 2 | import "@testing-library/jest-dom/extend-expect"; 3 | import { act, render, waitFor } from "@testing-library/react"; 4 | import React, { useEffect, useState } from "react"; 5 | import { UnobservableProperty } from "src/store/administrator/propertyKeys/unobservableProperty"; 6 | import { StoreAdministrator } from "src/store/administrator/storeAdministrator"; 7 | 8 | describe("Hook Decorator", () => { 9 | it("should can use pure react hook in store", async () => { 10 | const useUrl = () => { 11 | const [url, setUrl] = useState(""); 12 | useEffect(() => { 13 | setUrl("google.com"); 14 | }, []); 15 | return url; 16 | }; 17 | 18 | @Store() 19 | class HooksStore { 20 | @Hook(useUrl) 21 | url: string; 22 | } 23 | 24 | const App = connect(() => { 25 | const st = useStore(HooksStore); 26 | 27 | return

{st.url}

; 28 | }, HooksStore); 29 | 30 | const { getByText } = render(); 31 | 32 | await waitFor(() => expect(getByText("google.com")).toBeInTheDocument()); 33 | }); 34 | 35 | it("should run effect on pure react hook run", async () => { 36 | const useUrl = () => { 37 | const [url, setUrl] = useState(""); 38 | useEffect(() => { 39 | setUrl("google.com"); 40 | }, []); 41 | return url; 42 | }; 43 | 44 | @Store() 45 | class HooksStore { 46 | @Hook(useUrl) 47 | url: string; 48 | 49 | address: string; 50 | 51 | @Effect("url") 52 | onUrlChanged() { 53 | this.address = `https://${this.url}`; 54 | } 55 | } 56 | 57 | const App = connect(() => { 58 | const st = useStore(HooksStore); 59 | return

{st.address}

; 60 | }, HooksStore); 61 | 62 | const { getByText } = render(); 63 | 64 | await waitFor(() => expect(getByText("https://google.com")).toBeInTheDocument()); 65 | }); 66 | 67 | it("should Hook property key be readonly", async () => { 68 | const errorMock = jest.spyOn(console, "error").mockImplementation(); 69 | let store!: HooksStore; 70 | const useUrl = () => { 71 | const [url] = useState("url"); 72 | return url; 73 | }; 74 | 75 | @Store() 76 | class HooksStore { 77 | @Hook(useUrl) 78 | url: string; 79 | 80 | constructor() { 81 | store = this; 82 | } 83 | } 84 | 85 | const App = connect(() => { 86 | const st = useStore(HooksStore); 87 | return <>{st.url}; 88 | }, HooksStore); 89 | 90 | render(); 91 | 92 | act(() => { 93 | store.url = "sdf"; 94 | }); 95 | expect(errorMock).toBeCalledWith( 96 | "`HooksStore.url` is decorated with `@Hook(...)`, so can't be mutated." 97 | ); 98 | 99 | const pkInfo = StoreAdministrator.get( 100 | store 101 | )!.propertyKeysManager.propertyKeys.get("url") as UnobservableProperty; 102 | 103 | expect(pkInfo).toBeInstanceOf(UnobservableProperty); 104 | expect(pkInfo.isReadonly).toBeTruthy(); 105 | 106 | expect(store.url).toBe("url"); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /tests/immutableObjects.spec.tsx: -------------------------------------------------------------------------------- 1 | import { Effect, Store, connect, useStore } from "@react-store/core"; 2 | import { render } from "@testing-library/react"; 3 | import React from "react"; 4 | 5 | describe("Immutable Objects & Arrays", () => { 6 | it("should inner immutable object have same instance ref in each render", () => { 7 | let store!: FrozenStore; 8 | @Store() 9 | class FrozenStore { 10 | obj: any = Object.freeze({ a: 1, b: 2, c: Object.freeze({ d: 1 }) }); 11 | } 12 | 13 | const App = connect(() => { 14 | store = useStore(FrozenStore); 15 | return <>Frozen Object; 16 | }, FrozenStore); 17 | 18 | render(); 19 | 20 | expect(store.obj.c).toBe(store.obj.c); 21 | }); 22 | 23 | describe("Frozen Objects & Arrays", () => { 24 | it("should return correct value of frozen objects", () => { 25 | let store!: FrozenStore; 26 | @Store() 27 | class FrozenStore { 28 | obj: any = Object.freeze({ a: 1, b: 2, c: Object.freeze({ d: 1 }) }); 29 | 30 | @Effect("obj.c.d") 31 | frozenAccess() { 32 | this.obj.c.d; 33 | } 34 | } 35 | 36 | const App = connect(() => { 37 | store = useStore(FrozenStore); 38 | return <>Frozen Object; 39 | }, FrozenStore); 40 | 41 | render(); 42 | 43 | expect(store.obj).toStrictEqual({ a: 1, b: 2, c: { d: 1 } }); 44 | expect(store.obj.c).toStrictEqual({ d: 1 }); 45 | }); 46 | 47 | it("should return correct value of frozen arrays", () => { 48 | let store!: FrozenStore; 49 | @Store() 50 | class FrozenStore { 51 | arr: any = Object.freeze([1, 2, Object.freeze([3, 4])]); 52 | } 53 | 54 | const App = connect(() => { 55 | store = useStore(FrozenStore); 56 | return <>Frozen Object; 57 | }, FrozenStore); 58 | 59 | render(); 60 | 61 | expect(store.arr).toStrictEqual([1, 2, [3, 4]]); 62 | expect(store.arr[2]).toStrictEqual([3, 4]); 63 | }); 64 | }); 65 | 66 | describe("Sealed Objects & Arrays", () => { 67 | it("should return correct value of seal objects", () => { 68 | let store!: SealedStore; 69 | @Store() 70 | class SealedStore { 71 | obj: any = Object.seal({ a: 1, b: 2, c: Object.seal({ d: 1 }) }); 72 | } 73 | 74 | const App = connect(() => { 75 | store = useStore(SealedStore); 76 | return <>Frozen Object; 77 | }, SealedStore); 78 | 79 | render(); 80 | 81 | expect(store.obj).toStrictEqual({ a: 1, b: 2, c: { d: 1 } }); 82 | expect(store.obj.c).toStrictEqual({ d: 1 }); 83 | }); 84 | 85 | it("should return correct value of seal arrays", () => { 86 | let store!: SealedStore; 87 | @Store() 88 | class SealedStore { 89 | arr: any = Object.seal([1, 2, Object.seal([3, 4])]); 90 | } 91 | 92 | const App = connect(() => { 93 | store = useStore(SealedStore); 94 | return <>Frozen Object; 95 | }, SealedStore); 96 | 97 | render(); 98 | 99 | expect(store.arr).toStrictEqual([1, 2, [3, 4]]); 100 | expect(store.arr[2]).toStrictEqual([3, 4]); 101 | }); 102 | }); 103 | 104 | describe("Prevented Extension Objects & Array", () => { 105 | it("should return correct value of prevented extensions objects", () => { 106 | let store!: PreventedExtensionStore; 107 | @Store() 108 | class PreventedExtensionStore { 109 | obj: any = Object.preventExtensions({ 110 | a: 1, 111 | b: 2, 112 | c: Object.preventExtensions({ d: 1 }), 113 | }); 114 | } 115 | 116 | const App = connect(() => { 117 | store = useStore(PreventedExtensionStore); 118 | return <>Frozen Object; 119 | }, PreventedExtensionStore); 120 | 121 | render(); 122 | 123 | expect(store.obj).toStrictEqual({ a: 1, b: 2, c: { d: 1 } }); 124 | expect(store.obj.c).toStrictEqual({ d: 1 }); 125 | }); 126 | 127 | it("should return correct value of prevented extensions arrays", () => { 128 | let store!: PreventedExtensionStore; 129 | @Store() 130 | class PreventedExtensionStore { 131 | arr: any = Object.seal([1, 2, Object.seal([3, 4])]); 132 | } 133 | 134 | const App = connect(() => { 135 | store = useStore(PreventedExtensionStore); 136 | return <>Frozen Object; 137 | }, PreventedExtensionStore); 138 | 139 | render(); 140 | 141 | expect(store.arr).toStrictEqual([1, 2, [3, 4]]); 142 | expect(store.arr[2]).toStrictEqual([3, 4]); 143 | }); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /tests/memoDecorator.spec.tsx: -------------------------------------------------------------------------------- 1 | import { Memo, Store, connect, useStore } from "@react-store/core"; 2 | import "@testing-library/jest-dom/extend-expect"; 3 | import { act, render } from "@testing-library/react"; 4 | import React from "react"; 5 | 6 | describe("Memo", () => { 7 | it("should compute getter if dependencies has been changed", () => { 8 | const usernameFn = jest.fn(); 9 | let storeRef!: SampleStore; 10 | 11 | @Store() 12 | class SampleStore { 13 | private user = { username: "amir" }; 14 | 15 | @Memo("user.username") 16 | get username() { 17 | usernameFn(); 18 | return this.user.username; 19 | } 20 | 21 | changeUser() { 22 | this.user.username = "reza"; 23 | } 24 | } 25 | 26 | const App = connect(() => { 27 | const st = useStore(SampleStore); 28 | storeRef = st; 29 | return ( 30 | <> 31 | {st.username} 32 | 33 | ); 34 | }, SampleStore); 35 | 36 | const { getByText } = render(); 37 | 38 | expect(getByText("amir")).toBeInTheDocument(); 39 | expect(usernameFn).toBeCalledTimes(1); 40 | 41 | act(() => { 42 | storeRef.changeUser(); 43 | }); 44 | 45 | expect(getByText("reza")).toBeInTheDocument(); 46 | expect(usernameFn).toBeCalledTimes(2); 47 | }); 48 | 49 | it("should not compute getter if object dependencies has not changed", () => { 50 | const usernameFn = jest.fn(); 51 | let storeRef!: SampleStore; 52 | 53 | @Store() 54 | class SampleStore { 55 | private user = { username: "amir" }; 56 | 57 | @Memo("user") 58 | get username() { 59 | usernameFn(); 60 | return this.user.username; 61 | } 62 | 63 | changeUser() { 64 | this.user.username = "reza"; 65 | } 66 | } 67 | 68 | const App = connect(() => { 69 | const st = useStore(SampleStore); 70 | storeRef = st; 71 | return ( 72 | <> 73 | {st.username} 74 | 75 | ); 76 | }, SampleStore); 77 | 78 | const { getByText } = render(); 79 | 80 | expect(getByText("amir")).toBeInTheDocument(); 81 | expect(usernameFn).toBeCalledTimes(1); 82 | 83 | act(() => { 84 | storeRef.changeUser(); 85 | }); 86 | 87 | expect(getByText("amir")).toBeInTheDocument(); 88 | expect(usernameFn).toBeCalledTimes(1); 89 | }); 90 | 91 | it("should compute getter in deep mode", () => { 92 | const usernameFn = jest.fn(); 93 | let storeRef!: SampleStore; 94 | 95 | @Store() 96 | class SampleStore { 97 | private user = { username: "amir" }; 98 | 99 | @Memo("user", true) 100 | get username() { 101 | usernameFn(); 102 | return this.user.username; 103 | } 104 | 105 | changeUser() { 106 | this.user.username = "reza"; 107 | } 108 | } 109 | 110 | const App = connect(() => { 111 | const st = useStore(SampleStore); 112 | storeRef = st; 113 | return ( 114 | <> 115 | {st.username} 116 | 117 | ); 118 | }, SampleStore); 119 | 120 | const { getByText } = render(); 121 | 122 | expect(getByText("amir")).toBeInTheDocument(); 123 | expect(usernameFn).toBeCalledTimes(1); 124 | 125 | act(() => { 126 | storeRef.changeUser(); 127 | }); 128 | 129 | expect(getByText("amir")).toBeInTheDocument(); 130 | expect(usernameFn).toBeCalledTimes(1); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /tests/methods.spec.tsx: -------------------------------------------------------------------------------- 1 | import { Effect, Store, StorePart, connect, useStore } from "@react-store/core"; 2 | import { render, waitFor } from "@testing-library/react"; 3 | import React from "react"; 4 | import { act } from "react-dom/test-utils"; 5 | 6 | describe("Methods", () => { 7 | describe("Auto Bind", () => { 8 | it("should auto bind store methods", () => { 9 | let store!: MethodsStore; 10 | 11 | @StorePart() 12 | class MethodsStorePart { 13 | a = 1; 14 | b() { 15 | return this.a; 16 | } 17 | } 18 | @Store() 19 | class MethodsStore { 20 | constructor(public part: MethodsStorePart) {} 21 | 22 | c = 2; 23 | d() { 24 | return this.c; 25 | } 26 | } 27 | 28 | const App = connect(() => { 29 | store = useStore(MethodsStore); 30 | return <>; 31 | }, MethodsStore); 32 | 33 | render(); 34 | 35 | const d = store.d; 36 | expect(d()).toBe(2); 37 | 38 | const b = store.part.b; 39 | expect(b()).toBe(1); 40 | }); 41 | 42 | it("should auto bind overridden method", () => { 43 | let store!: MethodsStore; 44 | 45 | @Store() 46 | class BaseMethodsStore { 47 | a = 3; 48 | method() { 49 | return this.a; 50 | } 51 | } 52 | 53 | @Store() 54 | class MethodsStore extends BaseMethodsStore { 55 | b = 2; 56 | method() { 57 | const res = super.method(); 58 | return this.b * res; 59 | } 60 | } 61 | 62 | const App = connect(() => { 63 | store = useStore(MethodsStore); 64 | return <>; 65 | }, MethodsStore); 66 | 67 | render(); 68 | 69 | expect(store.method()).toBe(6); 70 | }); 71 | 72 | it("should bind methods inside store class", async () => { 73 | @Store() 74 | class UserStore { 75 | username = "amir"; 76 | 77 | changeUsername() { 78 | this.username = "reza"; 79 | } 80 | 81 | @Effect([]) 82 | onMount() { 83 | setTimeout(this.changeUsername, 0); 84 | } 85 | } 86 | 87 | const User = connect(() => { 88 | const st = useStore(UserStore); 89 | 90 | return <>{st.username}; 91 | }, UserStore); 92 | 93 | const { getByText } = render(); 94 | 95 | expect(getByText("amir")).toBeInTheDocument(); 96 | 97 | await waitFor(() => expect(getByText("reza")).toBeInTheDocument()); 98 | }); 99 | 100 | it("should bind store methods to store context even if method called with other context", () => { 101 | let store!: MethodsStore; 102 | @Store() 103 | class MethodsStore { 104 | c = 2; 105 | 106 | d() { 107 | return this.c; 108 | } 109 | } 110 | 111 | const App = connect(() => { 112 | store = useStore(MethodsStore); 113 | return <>; 114 | }, MethodsStore); 115 | 116 | render(); 117 | 118 | const d = store.d; 119 | expect(d.apply({ c: 4 })).toBe(2); 120 | }); 121 | }); 122 | 123 | describe("Execution Context", () => { 124 | it("should render if store property of object type have change in deeper fields \ 125 | after store properties reassigned by any object", async () => { 126 | let appStore!: AppStore; 127 | 128 | @Store() 129 | class AppStore { 130 | user = { name: "" }; 131 | 132 | change() { 133 | this.user = { name: "amir" }; 134 | Promise.resolve().then(() => { 135 | this.user.name = "amir2"; 136 | }); 137 | } 138 | } 139 | 140 | const App = connect(() => { 141 | const st = useStore(AppStore); 142 | appStore = st; 143 | return <>{st.user.name}; 144 | }, AppStore); 145 | 146 | const { getByText } = render(); 147 | act(() => appStore.change()); 148 | await waitFor(() => expect(getByText("amir2")).toBeInTheDocument()); 149 | }); 150 | }); 151 | }); 152 | -------------------------------------------------------------------------------- /tests/observableDecorator.spec.tsx: -------------------------------------------------------------------------------- 1 | import { Observable, Store, connect, useStore } from "@react-store/core"; 2 | import "@testing-library/jest-dom/extend-expect"; 3 | import { render } from "@testing-library/react"; 4 | import React from "react"; 5 | import { act } from "react-dom/test-utils"; 6 | 7 | describe("Observable Decorator", () => { 8 | it("should render on observable class change", () => { 9 | let store!: ObservableTestStore; 10 | 11 | @Observable() 12 | class Obs2 { 13 | y = "obs2"; 14 | } 15 | 16 | @Observable() 17 | class Obs1 { 18 | x = "obs1"; 19 | 20 | obs2 = new Obs2(); 21 | } 22 | 23 | @Store() 24 | class ObservableTestStore { 25 | obs = new Obs1(); 26 | 27 | constructor() { 28 | store = this; 29 | } 30 | } 31 | 32 | const App = connect(() => { 33 | const st = useStore(ObservableTestStore); 34 | return ( 35 | <> 36 |

{st.obs.x}

37 |

{st.obs.obs2.y}

38 | 39 | ); 40 | }, ObservableTestStore); 41 | 42 | const { getByText } = render(); 43 | 44 | expect(getByText("obs1")).toBeInTheDocument(); 45 | expect(getByText("obs2")).toBeInTheDocument(); 46 | 47 | act(() => { 48 | store.obs.x = "obs1.1"; 49 | }); 50 | expect(getByText("obs1.1")).toBeInTheDocument(); 51 | 52 | act(() => { 53 | store.obs.obs2.y = "obs2.1"; 54 | }); 55 | expect(getByText("obs2.1")).toBeInTheDocument(); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /tests/propertyKeyObservability.spec.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Effect, 3 | Injectable, 4 | Observable, 5 | Store, 6 | Unobserve, 7 | connect, 8 | useStore, 9 | } from "@react-store/core"; 10 | import "@testing-library/jest-dom/extend-expect"; 11 | import { fireEvent, render, screen } from "@testing-library/react"; 12 | import React from "react"; 13 | import { act } from "react-dom/test-utils"; 14 | import { getUnproxiedValue } from "src/utils/getUnProxiedValue"; 15 | 16 | describe("Property Keys Observability", () => { 17 | it("should observe primitive types", () => { 18 | let store!: PrimitiveTypesStore; 19 | 20 | @Store() 21 | class PrimitiveTypesStore { 22 | number = 1; 23 | string = "string"; 24 | bigint = BigInt(11); 25 | boolean = false; 26 | undefined: any = undefined; 27 | symbol = Symbol("symbol1"); 28 | null: any = null; 29 | 30 | changeNumber() { 31 | this.number = 2; 32 | } 33 | 34 | changeString() { 35 | this.string = "string2"; 36 | } 37 | 38 | changeBigInt() { 39 | this.bigint = BigInt(111); 40 | } 41 | 42 | changeBoolean() { 43 | this.boolean = true; 44 | } 45 | 46 | changeUndefined() { 47 | this.undefined = "not undefined"; 48 | } 49 | 50 | changeSymbol() { 51 | this.symbol = Symbol("symbol2"); 52 | } 53 | 54 | changeNull() { 55 | this.null = "not null"; 56 | } 57 | } 58 | 59 | const App: React.FC = connect(() => { 60 | const vm = useStore(PrimitiveTypesStore); 61 | store = vm; 62 | return ( 63 |
64 | {vm.number} 65 | {vm.string} 66 | {vm.bigint.toString()} 67 | {vm.boolean.toString()} 68 | {vm.symbol.toString()} 69 | {vm.undefined} 70 | {vm.null} 71 |
72 | ); 73 | }, PrimitiveTypesStore); 74 | 75 | render(); 76 | 77 | // number 78 | expect(screen.getByText("1")).toHaveTextContent("1"); 79 | act(() => store.changeNumber()); 80 | expect(screen.getByText("2")).toHaveTextContent("2"); 81 | 82 | // string 83 | expect(screen.getByText("string")).toHaveTextContent("string"); 84 | act(() => store.changeString()); 85 | expect(screen.getByText("string2")).toHaveTextContent("string2"); 86 | 87 | // bigint 88 | expect(screen.getByText("11")).toHaveTextContent("11"); 89 | act(() => store.changeBigInt()); 90 | expect(screen.getByText("111")).toHaveTextContent("111"); 91 | 92 | // boolean 93 | expect(screen.getByText("false")).toHaveTextContent("false"); 94 | act(() => store.changeBoolean()); 95 | expect(screen.getByText("true")).toHaveTextContent("true"); 96 | 97 | // symbol 98 | expect(screen.getByText("Symbol(symbol1)")).toHaveTextContent("Symbol(symbol1)"); 99 | act(() => store.changeSymbol()); 100 | expect(screen.getByText("Symbol(symbol2)")).toHaveTextContent("Symbol(symbol2)"); 101 | 102 | // undefined 103 | act(() => store.changeUndefined()); 104 | expect(screen.getByText("not undefined")).toHaveTextContent("not undefined"); 105 | 106 | // null 107 | act(() => store.changeNull()); 108 | expect(screen.getByText("not null")).toHaveTextContent("not null"); 109 | }); 110 | 111 | it("should observe complex types", () => { 112 | let store!: ComplexTypesStore; 113 | 114 | @Store() 115 | class ComplexTypesStore { 116 | object = { a: 1, b: { c: 1 } }; 117 | array = [1, 2, [3, 4]]; 118 | nested = { 119 | a: [1, 2], 120 | c: { 121 | d: 1, 122 | }, 123 | }; 124 | 125 | map = new Map(); 126 | 127 | constructor() { 128 | store = this; 129 | } 130 | @Effect([]) 131 | onMount() { 132 | this.map.set("a", "map"); 133 | this.map.set("b", { map: 1 }); 134 | } 135 | 136 | changeObject() { 137 | this.object.b.c = 2; 138 | } 139 | 140 | changeArray() { 141 | this.array[2][1] = 5; 142 | } 143 | 144 | changeNested() { 145 | this.nested.a[2] = 3; 146 | this.nested.c.d = 3; 147 | } 148 | 149 | changeMapAKey() { 150 | this.map.set("a", "map2"); 151 | } 152 | 153 | changeMapBKey() { 154 | const b = this.map.get("b"); 155 | b.map = 11; 156 | } 157 | } 158 | 159 | const App: React.FC = connect(() => { 160 | const vm = useStore(ComplexTypesStore); 161 | return ( 162 |
163 | {JSON.stringify(vm.object)} 164 | {JSON.stringify(vm.array)} 165 | {JSON.stringify(vm.nested)} 166 | {vm.map.get("a")} 167 | {JSON.stringify(vm.map.get("b"))} 168 | Map Size: {vm.map.size} 169 |
170 | ); 171 | }, ComplexTypesStore); 172 | 173 | render(); 174 | 175 | // object 176 | expect(screen.getByText(JSON.stringify(store.object))).toHaveTextContent( 177 | JSON.stringify(store.object) 178 | ); 179 | act(() => store.changeObject()); 180 | expect( 181 | screen.getByText(JSON.stringify({ a: 1, b: { c: 2 } })) 182 | ).toHaveTextContent(JSON.stringify({ a: 1, b: { c: 2 } })); 183 | 184 | // array 185 | expect(screen.getByText(JSON.stringify(store.nested))).toHaveTextContent( 186 | JSON.stringify(store.nested) 187 | ); 188 | act(() => store.changeNested()); 189 | expect( 190 | screen.getByText( 191 | JSON.stringify({ 192 | a: [1, 2, 3], 193 | c: { 194 | d: 3, 195 | }, 196 | }) 197 | ) 198 | ).toHaveTextContent( 199 | JSON.stringify({ 200 | a: [1, 2, 3], 201 | c: { 202 | d: 3, 203 | }, 204 | }) 205 | ); 206 | 207 | // map 208 | expect(screen.getByText("map")).toHaveTextContent("map"); 209 | act(() => store.changeMapAKey()); 210 | expect(screen.getByText("map2")).toHaveTextContent("map2"); 211 | 212 | expect(screen.getByText(JSON.stringify(store.map.get("b")))).toHaveTextContent( 213 | JSON.stringify(store.map.get("b")) 214 | ); 215 | act(() => store.changeMapBKey()); 216 | expect(screen.getByText(JSON.stringify({ map: 11 }))).toHaveTextContent( 217 | JSON.stringify({ map: 11 }) 218 | ); 219 | }); 220 | 221 | it("should observe the observable classes", () => { 222 | @Observable() 223 | class User { 224 | username = "amir"; 225 | } 226 | 227 | @Store() 228 | class UserStore { 229 | user = new User(); 230 | 231 | changeUsername() { 232 | this.user.username = "amirhossein"; 233 | } 234 | } 235 | 236 | const App: React.FC = connect(() => { 237 | const vm = useStore(UserStore); 238 | return ( 239 |
240 | {vm.user.username} 241 | 242 |
243 | ); 244 | }, UserStore); 245 | 246 | const { getByText } = render(); 247 | expect(getByText("amir")).toBeInTheDocument(); 248 | fireEvent.click(getByText("change")); 249 | expect(getByText("amirhossein")).toBeInTheDocument(); 250 | }); 251 | 252 | it("should save proxy value for arrays, objects, functions, maps", () => { 253 | let store!: SavedProxiedValueStore; 254 | 255 | @Store() 256 | class SavedProxiedValueStore { 257 | array = [1, { a: 1 }]; 258 | object = { a: [2, 3, 4], b: 1 }; 259 | map = new Map(); 260 | 261 | constructor() { 262 | store = this; 263 | } 264 | onChange() {} 265 | } 266 | 267 | const App: React.FC = connect(() => { 268 | useStore(SavedProxiedValueStore); 269 | return
App
; 270 | }, SavedProxiedValueStore); 271 | 272 | render(); 273 | 274 | expect(store.array).toBeDefined(); 275 | act(() => { 276 | // make array [1, Proxy] to check cache proxied 277 | store.array = store.array.map((i) => i); 278 | }); 279 | 280 | // Here we check for cache proxied 281 | expect(store.array[1]).toBe(store.array[1]); 282 | 283 | expect(store.object).toBeDefined(); 284 | expect(store.object.a).toBe(store.object.a); 285 | expect(store.map).toBeDefined(); 286 | expect(store.map).toBe(store.map); 287 | expect(store.onChange).toBeDefined(); 288 | expect(store.onChange).toBe(store.onChange); 289 | }); 290 | 291 | it("should saved proxied value be per instance of store", () => { 292 | let store!: SavedProxiedValueStore; 293 | 294 | @Store() 295 | class SavedProxiedValueStore { 296 | array = [1, { a: 1 }]; 297 | object = { a: [], b: 1 }; 298 | map = new Map(); 299 | onChange() {} 300 | } 301 | 302 | const App: React.FC = connect(() => { 303 | const vm = useStore(SavedProxiedValueStore); 304 | store = vm; 305 | return
App
; 306 | }, SavedProxiedValueStore); 307 | 308 | const { unmount } = render(); 309 | 310 | const preArray = store.array; 311 | const preObject = store.object; 312 | const preOnChange = store.onChange; 313 | 314 | unmount(); 315 | render(); 316 | 317 | expect(store.array).not.toBe(preArray); 318 | expect(store.object).not.toBe(preObject); 319 | expect(store.onChange).not.toBe(preOnChange); 320 | }); 321 | 322 | it("should rerender if primitive store property change to non one or vice versa", () => { 323 | let store!: PropertyTypesStore; 324 | let renderCount = 0; 325 | @Store() 326 | class PropertyTypesStore { 327 | undefinedToObject?: any; 328 | 329 | objectToUndefined?: any = {}; 330 | 331 | constructor() { 332 | store = this; 333 | } 334 | } 335 | 336 | const App: React.FC = connect(() => { 337 | const vm = useStore(PropertyTypesStore); 338 | renderCount++; 339 | return ( 340 |
341 | {JSON.stringify(vm.objectToUndefined)} 342 | {JSON.stringify(vm.undefinedToObject)} 343 |
344 | ); 345 | }, PropertyTypesStore); 346 | 347 | render(); 348 | 349 | expect(renderCount).toBe(1); 350 | 351 | act(() => { 352 | store.objectToUndefined = undefined; 353 | }); 354 | 355 | expect(renderCount).toBe(2); 356 | 357 | act(() => { 358 | store.undefinedToObject = {}; 359 | }); 360 | 361 | expect(renderCount).toBe(3); 362 | }); 363 | 364 | describe("Same value assignment to observable properties", () => { 365 | it("should not rerender on set same value for primitive types", () => { 366 | let store!: PrimitiveTypesStore; 367 | let renderCount = 0; 368 | @Store() 369 | class PrimitiveTypesStore { 370 | number = 1; 371 | 372 | constructor() { 373 | store = this; 374 | } 375 | } 376 | 377 | const App: React.FC = connect(() => { 378 | const vm = useStore(PrimitiveTypesStore); 379 | renderCount++; 380 | return ( 381 |
382 | {vm.number} 383 |
384 | ); 385 | }, PrimitiveTypesStore); 386 | 387 | render(); 388 | 389 | act(() => { 390 | store.number = 1; 391 | }); 392 | 393 | expect(renderCount).toBe(1); 394 | }); 395 | 396 | it("should not rerender on set same value for non-primitive types", () => { 397 | let store!: NonPrimitiveTypesStore; 398 | let renderCount = 0; 399 | const constObj = { a: 1 }; 400 | const constArr = [1]; 401 | @Store() 402 | class NonPrimitiveTypesStore { 403 | object = constObj; 404 | array = constArr; 405 | 406 | constructor() { 407 | store = this; 408 | } 409 | } 410 | 411 | const App: React.FC = connect(() => { 412 | const vm = useStore(NonPrimitiveTypesStore); 413 | renderCount++; 414 | return ( 415 |
416 | {JSON.stringify(vm.object)} 417 | {JSON.stringify(vm.array)} 418 |
419 | ); 420 | }, NonPrimitiveTypesStore); 421 | 422 | render(); 423 | 424 | act(() => { 425 | store.object = constObj; 426 | }); 427 | act(() => { 428 | store.array = constArr; 429 | }); 430 | 431 | expect(renderCount).toBe(1); 432 | }); 433 | }); 434 | 435 | it("should unobserve store property key", () => { 436 | let store!: UserStore; 437 | let renderCount = 0; 438 | @Store() 439 | class UserStore { 440 | @Unobserve() 441 | username = "amir"; 442 | 443 | changeUsername() { 444 | this.username = "reza"; 445 | } 446 | } 447 | 448 | const App = connect(() => { 449 | const st = useStore(UserStore); 450 | store = st; 451 | renderCount++; 452 | return <>{st.username}; 453 | }, UserStore); 454 | 455 | const { getByText } = render(); 456 | 457 | expect(getByText("amir")).toBeInTheDocument(); 458 | act(() => store.changeUsername()); 459 | expect(getByText("amir")).toBeInTheDocument(); 460 | expect(renderCount).toBe(1); 461 | }); 462 | 463 | describe("Readonly Store Class Properties", () => { 464 | it("should inject stores as readonly class property", () => { 465 | let lowerStore!: LowerStore; 466 | 467 | @Store() 468 | class UpperStore {} 469 | 470 | @Store() 471 | class LowerStore { 472 | constructor(public upperStore: UpperStore) { 473 | lowerStore = this; 474 | } 475 | } 476 | 477 | const App = connect( 478 | connect(() => { 479 | useStore(LowerStore); 480 | return <>App; 481 | }, LowerStore), 482 | UpperStore 483 | ); 484 | 485 | render(); 486 | expect(lowerStore.upperStore).toBe(getUnproxiedValue(lowerStore.upperStore)); 487 | }); 488 | 489 | it("should inject Injectable as readonly class property", () => { 490 | let store!: UserStore; 491 | 492 | @Injectable() 493 | class UserService {} 494 | 495 | @Store() 496 | class UserStore { 497 | constructor(public userService: UserService) { 498 | store = this; 499 | } 500 | } 501 | 502 | const App = connect(() => { 503 | useStore(UserStore); 504 | return <>App; 505 | }, UserStore); 506 | 507 | render(); 508 | expect(store.userService).toBe(getUnproxiedValue(store.userService)); 509 | }); 510 | }); 511 | 512 | describe("Deep Full Unproxy", () => { 513 | it("should deep full unproxy value when assigns to store class property", () => { 514 | let upperStore!: UpperStore; 515 | let lowerStore!: LowerStore; 516 | 517 | @Store() 518 | class UpperStore { 519 | user = { username: "amir", password: "1234" }; 520 | 521 | constructor() { 522 | upperStore = this; 523 | } 524 | 525 | changeUser(user: any) { 526 | this.user = user; 527 | } 528 | } 529 | 530 | @Store() 531 | class LowerStore { 532 | user = { username: "reza", password: "4321" }; 533 | 534 | constructor(public upperStore: UpperStore) { 535 | lowerStore = this; 536 | } 537 | } 538 | 539 | const LowerCmp = connect(() => { 540 | const lst = useStore(LowerStore); 541 | return <>{JSON.stringify(lst.user)}; 542 | }, LowerStore); 543 | 544 | const App = connect(() => { 545 | useStore(UpperStore); 546 | return ; 547 | }, UpperStore); 548 | 549 | render(); 550 | 551 | act(() => { 552 | lowerStore.upperStore.changeUser(lowerStore.user); 553 | }); 554 | 555 | expect(getUnproxiedValue(upperStore.user)).toBe( 556 | getUnproxiedValue(getUnproxiedValue(upperStore.user)) 557 | ); 558 | }); 559 | 560 | it("should deep full unproxy on set array element", () => { 561 | let store!: ArrayStore; 562 | 563 | @Store() 564 | class ArrayStore { 565 | obj = { a: 1 }; 566 | 567 | array: any = [0]; 568 | constructor() { 569 | store = this; 570 | } 571 | } 572 | 573 | const App = connect(() => <>App, ArrayStore); 574 | 575 | render(); 576 | 577 | act(() => { 578 | store.array[1] = store.obj; 579 | }); 580 | 581 | expect(getUnproxiedValue(store.array[1])).toBe( 582 | getUnproxiedValue(getUnproxiedValue(store.array[1])) 583 | ); 584 | }); 585 | 586 | it("should deep full unproxy on set object property", () => { 587 | let store!: ArrayStore; 588 | 589 | @Store() 590 | class ArrayStore { 591 | obj = { a: 1 }; 592 | 593 | array: any = [0]; 594 | constructor() { 595 | store = this; 596 | } 597 | } 598 | 599 | const App = connect(() => <>App, ArrayStore); 600 | 601 | render(); 602 | 603 | act(() => { 604 | store.obj.a = store.array; 605 | }); 606 | 607 | expect(getUnproxiedValue(store.obj.a)).toBe( 608 | getUnproxiedValue(getUnproxiedValue(store.obj.a)) 609 | ); 610 | }); 611 | }); 612 | }); 613 | -------------------------------------------------------------------------------- /tests/propsDecorator.spec.tsx: -------------------------------------------------------------------------------- 1 | import { Effect, Props, Store, connect, useStore } from "@react-store/core"; 2 | import "@testing-library/jest-dom"; 3 | import { render } from "@testing-library/react"; 4 | import React from "react"; 5 | import { act } from "react-dom/test-utils"; 6 | import { UnobservableProperty } from "src/store/administrator/propertyKeys/unobservableProperty"; 7 | import { StoreAdministrator } from "src/store/administrator/storeAdministrator"; 8 | 9 | describe("Props Decorator", () => { 10 | it("should parent component have props directly", () => { 11 | let props!: any; 12 | @Store() 13 | class UserStore {} 14 | 15 | const App: React.FC<{ username: string }> = connect((_props) => { 16 | useStore(UserStore); 17 | props = _props; 18 | return <>store; 19 | }, UserStore); 20 | 21 | render(); 22 | expect(props.username).toBe("amir"); 23 | }); 24 | 25 | it("should connect props to store instance", () => { 26 | let userStoreFromUse!: UserStore; 27 | let userStoreFromThis!: UserStore; 28 | @Store() 29 | class UserStore { 30 | @Props() 31 | props: any; 32 | constructor() { 33 | userStoreFromThis = this; 34 | } 35 | } 36 | 37 | const App: React.FC<{ username: string }> = connect(() => { 38 | userStoreFromUse = useStore(UserStore); 39 | return <>store; 40 | }, UserStore); 41 | 42 | render(); 43 | 44 | expect(userStoreFromThis.props.username).toBe("amir"); 45 | expect(userStoreFromUse.props.username).toBe("amir"); 46 | }); 47 | 48 | it("should effect be called on props change", () => { 49 | const effectCalled = jest.fn(); 50 | @Store() 51 | class UserStore { 52 | @Props() 53 | props: any; 54 | 55 | @Effect("props.username") 56 | onPropUsernameChange() { 57 | effectCalled(this.props); 58 | } 59 | } 60 | 61 | const App: React.FC<{ username: string }> = connect((props) => { 62 | return ( 63 | <> 64 | {props.username} 65 | 66 | ); 67 | }, UserStore); 68 | 69 | const { getByText, rerender } = render(); 70 | 71 | expect(getByText("amir")).toBeInTheDocument(); 72 | expect(effectCalled).toBeCalledTimes(1); 73 | expect(effectCalled).toBeCalledWith( 74 | expect.objectContaining({ username: "amir" }) 75 | ); 76 | 77 | rerender(); 78 | expect(getByText("amirhossein")).toBeInTheDocument(); 79 | expect(effectCalled).toBeCalledTimes(2); 80 | expect(effectCalled).toBeCalledWith( 81 | expect.objectContaining({ username: "amirhossein" }) 82 | ); 83 | }); 84 | 85 | it("should render component which use props if props change", () => { 86 | @Store() 87 | class UserStore { 88 | @Props() 89 | props: any; 90 | } 91 | 92 | const Username = React.memo(() => { 93 | const st = useStore(UserStore); 94 | return

{st.props.username}

; 95 | }); 96 | 97 | const App: React.FC<{ username: string }> = connect(() => { 98 | return ; 99 | }, UserStore); 100 | 101 | const { getByText, rerender } = render(); 102 | 103 | expect(getByText("amir")).toBeInTheDocument(); 104 | 105 | rerender(); 106 | 107 | expect(getByText("amirhossein")).toBeInTheDocument(); 108 | }); 109 | 110 | it("should @Props property key be readonly", () => { 111 | let store!: UserStore; 112 | const errorMock = jest.spyOn(console, "error").mockImplementation(); 113 | @Store() 114 | class UserStore { 115 | @Props() 116 | props: any; 117 | 118 | constructor() { 119 | store = this; 120 | } 121 | } 122 | 123 | const App: React.FC<{ username: string }> = connect(() => { 124 | useStore(UserStore); 125 | return <>store; 126 | }, UserStore); 127 | 128 | render(); 129 | 130 | const pkInfo = StoreAdministrator.get( 131 | store 132 | )!.propertyKeysManager.propertyKeys.get("props") as UnobservableProperty; 133 | 134 | expect(pkInfo).toBeInstanceOf(UnobservableProperty); 135 | expect(pkInfo.isReadonly).toBeTruthy(); 136 | 137 | act(() => { 138 | store.props = {}; 139 | }); 140 | expect(errorMock).toBeCalledWith( 141 | "`UserStore.props` is decorated with `@Props()`, so can't be mutated." 142 | ); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /tests/proxyStoreForComponent.spec.tsx: -------------------------------------------------------------------------------- 1 | import { Store, StorePart, connect, useStore } from "@react-store/core"; 2 | import { act, render } from "@testing-library/react"; 3 | import React from "react"; 4 | import { PROXY_HANDLER_TYPE, TARGET } from "src/constant"; 5 | import { StoreForConsumerComponentProxy } from "src/proxy/storeForConsumerComponentProxy"; 6 | 7 | describe("Proxy Store For Consumers Component", () => { 8 | it("should log an error if direct store property mutated in component body", () => { 9 | let store!: TestStore; 10 | const errorMock = jest.spyOn(console, "error").mockImplementation(); 11 | 12 | @Store() 13 | class TestStore { 14 | val = 1; 15 | } 16 | 17 | const App = connect(() => { 18 | store = useStore(TestStore); 19 | return

{store.val}

; 20 | }, TestStore); 21 | 22 | const { getByText } = render(); 23 | 24 | act(() => { 25 | store.val = 2; 26 | }); 27 | 28 | expect(errorMock).toBeCalledWith( 29 | "Mutating (TestStore.val) store properties from outside of store class is not valid." 30 | ); 31 | expect(getByText("1")).toBeInTheDocument(); 32 | }); 33 | 34 | it("should not render if store property deep path set", () => { 35 | let store!: TestStore; 36 | @Store() 37 | class TestStore { 38 | val = [1]; 39 | } 40 | 41 | const App = connect(() => { 42 | store = useStore(TestStore); 43 | return

{JSON.stringify(store.val)}

; 44 | }, TestStore); 45 | 46 | const { getByText } = render(); 47 | 48 | act(() => { 49 | store.val[0] = 2; 50 | }); 51 | expect(getByText("[1]")).toBeInTheDocument(); 52 | expect(store.val[0]).toBe(2); 53 | }); 54 | 55 | it("should return unproxied value for store property", () => { 56 | let store!: TestStore; 57 | @Store() 58 | class TestStore { 59 | val = [1, { a: 1 }] as any; 60 | 61 | constructor() { 62 | this.x(); 63 | } 64 | 65 | x() { 66 | this.val[1].a = 3; 67 | } 68 | } 69 | 70 | const App = connect(() => { 71 | store = useStore(TestStore); 72 | return

{JSON.stringify(store.val)}

; 73 | }, TestStore); 74 | 75 | debugger; 76 | render(); 77 | 78 | expect(store.val[TARGET]).toBeUndefined(); 79 | expect(store.val[1][TARGET]).toBeUndefined(); 80 | }); 81 | 82 | it("should return unproxied value for wired store part properties", () => { 83 | let store!: TestStore; 84 | 85 | @StorePart() 86 | class TestStorePart { 87 | val = [1, { a: 1 }]; 88 | } 89 | 90 | @Store() 91 | class TestStore { 92 | constructor(public part: TestStorePart) {} 93 | 94 | get arrLen() { 95 | return this.part.val.length; 96 | } 97 | } 98 | 99 | const App = connect(() => { 100 | store = useStore(TestStore); 101 | return

{JSON.stringify(store.part.val)}

; 102 | }, TestStore); 103 | 104 | render(); 105 | 106 | expect(store.part[TARGET]).toBeUndefined(); 107 | expect(store.part[PROXY_HANDLER_TYPE]).toBe(StoreForConsumerComponentProxy); 108 | expect(store.part.val[TARGET]).toBeUndefined(); 109 | expect(store.part.val[1][TARGET]).toBeUndefined(); 110 | // expect( 111 | // StoreAdministrator.get(store)?.propertyKeysManager.accessedProperties 112 | // ).toHaveLength(0); 113 | // expect( 114 | // StoreAdministrator.get(store.part)?.propertyKeysManager.accessedProperties 115 | // ).toHaveLength(0); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /tests/pureReactCompatibility.spec.tsx: -------------------------------------------------------------------------------- 1 | import { Store, connect, useStore } from "@react-store/core"; 2 | import "@testing-library/jest-dom/extend-expect"; 3 | import { render, screen, waitFor } from "@testing-library/react"; 4 | import React, { useEffect } from "react"; 5 | 6 | describe("Pure React Compatibility", () => { 7 | it("should render on calling action in deeper pure react useEffect", async () => { 8 | @Store() 9 | class SampleStore { 10 | title = "title"; 11 | 12 | changeTitle() { 13 | this.title = "changed title"; 14 | } 15 | } 16 | 17 | const Title = () => { 18 | const st = useStore(SampleStore); 19 | 20 | useEffect(() => { 21 | st.changeTitle(); 22 | }, []); 23 | 24 | return <>static content; 25 | }; 26 | 27 | const App = connect(() => { 28 | const st = useStore(SampleStore); 29 | 30 | return ( 31 | <> 32 | {st.title} 33 | 34 | </> 35 | ); 36 | }, SampleStore); 37 | 38 | render(<App />); 39 | 40 | await waitFor(() => 41 | expect(screen.getByText("changed title")).toHaveTextContent("changed title") 42 | ); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /tests/renderPerformance.spec.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Effect, 3 | Hook, 4 | Store, 5 | StorePart, 6 | connect, 7 | useStore, 8 | } from "@react-store/core"; 9 | import "@testing-library/jest-dom/extend-expect"; 10 | import { render, waitFor } from "@testing-library/react"; 11 | import React, { Profiler, useEffect, useState } from "react"; 12 | 13 | describe("Render Performance", () => { 14 | it("should react have 2 commit phase", async () => { 15 | const renderCommits = new Set<number>(); 16 | 17 | const onRender: React.ProfilerOnRenderCallback = (...[, , , , , commitTime]) => { 18 | renderCommits.add(commitTime); 19 | }; 20 | 21 | @Store() 22 | class UserStore { 23 | username = "amir"; 24 | 25 | password = "123"; 26 | 27 | @Effect([]) 28 | m() { 29 | setTimeout(() => { 30 | this.changeUsername(); 31 | }); 32 | } 33 | 34 | changeUsername() { 35 | this.username = "reza"; 36 | } 37 | } 38 | 39 | const ShowPassword = React.memo(() => { 40 | const st = useStore(UserStore); 41 | return ( 42 | <Profiler id="password" onRender={onRender}> 43 | <span>{st.password}</span> 44 | </Profiler> 45 | ); 46 | }); 47 | 48 | const Main = () => { 49 | const st = useStore(UserStore); 50 | return ( 51 | <> 52 | <span>{st.username}</span> 53 | <ShowPassword /> 54 | </> 55 | ); 56 | }; 57 | 58 | const App = connect( 59 | () => ( 60 | <Profiler id="App" onRender={onRender}> 61 | <Main /> 62 | </Profiler> 63 | ), 64 | UserStore 65 | ); 66 | 67 | const { findByText } = render( 68 | <Profiler id="main" onRender={onRender}> 69 | <App /> 70 | </Profiler> 71 | ); 72 | expect(renderCommits.size).toBe(1); 73 | await waitFor(async () => expect(await findByText("amir")).toBeInTheDocument()); 74 | 75 | await waitFor(async () => expect(await findByText("reza")).toBeInTheDocument()); 76 | expect(renderCommits.size).toBe(2); 77 | }); 78 | 79 | it("should react have 2 commit phase along with having store part", async () => { 80 | const renderCommits = new Set<number>(); 81 | 82 | const onRender: React.ProfilerOnRenderCallback = (...[, , , , , commitTime]) => { 83 | renderCommits.add(commitTime); 84 | }; 85 | 86 | @StorePart() 87 | class Part { 88 | id = "id"; 89 | 90 | @Effect([]) 91 | n() { 92 | this.id = "ID"; 93 | } 94 | } 95 | 96 | @Store() 97 | class UserStore { 98 | username = "amir"; 99 | 100 | password = "123"; 101 | 102 | constructor(public p: Part) {} 103 | 104 | @Effect([]) 105 | m() { 106 | this.changeUsername(); 107 | } 108 | 109 | changeUsername() { 110 | this.username = "reza"; 111 | } 112 | } 113 | 114 | const ShowPassword = React.memo(() => { 115 | const st = useStore(UserStore); 116 | return ( 117 | <Profiler id="password" onRender={onRender}> 118 | <span>{st.password}</span> 119 | </Profiler> 120 | ); 121 | }); 122 | 123 | const _App = () => { 124 | const st = useStore(UserStore); 125 | return ( 126 | <> 127 | <span>{st.username}</span> 128 | <p>{st.p.id}</p> 129 | <ShowPassword /> 130 | </> 131 | ); 132 | }; 133 | 134 | const App = connect( 135 | () => ( 136 | <Profiler id="App" onRender={onRender}> 137 | <_App /> 138 | </Profiler> 139 | ), 140 | UserStore 141 | ); 142 | 143 | const { findByText } = render( 144 | <Profiler id="main" onRender={onRender}> 145 | <App /> 146 | </Profiler> 147 | ); 148 | await waitFor(async () => expect(await findByText("reza")).toBeInTheDocument()); 149 | await waitFor(async () => expect(await findByText("ID")).toBeInTheDocument()); 150 | expect(renderCommits.size).toBe(2); 151 | }); 152 | 153 | it("should react have 2 commit phase with using @Hook decorator", async () => { 154 | const renderCommits = new Set<number>(); 155 | 156 | const onRender: React.ProfilerOnRenderCallback = (...[, , , , , commitTime]) => { 157 | renderCommits.add(commitTime); 158 | }; 159 | 160 | function useUsername(userId: string) { 161 | const [username, setUsername] = useState(""); 162 | 163 | useEffect(() => { 164 | setUsername(userId); 165 | }, [userId]); 166 | 167 | return username; 168 | } 169 | 170 | @Store() 171 | class UserStore { 172 | @Hook(() => useUsername("amir")) 173 | username: string; 174 | } 175 | 176 | const Main = () => { 177 | const st = useStore(UserStore); 178 | return ( 179 | <> 180 | <span>{st.username}</span> 181 | </> 182 | ); 183 | }; 184 | 185 | const App = connect( 186 | () => ( 187 | <Profiler id="App" onRender={onRender}> 188 | <Main /> 189 | </Profiler> 190 | ), 191 | UserStore 192 | ); 193 | 194 | const { getByText } = render( 195 | <Profiler id="main" onRender={onRender}> 196 | <App /> 197 | </Profiler> 198 | ); 199 | expect(renderCommits.size).toBe(2); 200 | expect(getByText("amir")).toBeInTheDocument(); 201 | }); 202 | }); 203 | -------------------------------------------------------------------------------- /tests/setupTest.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import "reflect-metadata"; 3 | -------------------------------------------------------------------------------- /tests/storeDecorator.spec.tsx: -------------------------------------------------------------------------------- 1 | import { Store, connect, useStore } from "@react-store/core"; 2 | import "@testing-library/jest-dom/extend-expect"; 3 | import { render } from "@testing-library/react"; 4 | import React from "react"; 5 | 6 | describe("Store", () => { 7 | it("should each component which use store, have same instance of it", () => { 8 | let usernameStore!: UserStore, passwordStore!: UserStore, appStore!: UserStore; 9 | 10 | @Store() 11 | class UserStore { 12 | title = "User store"; 13 | username = "amir.qasemi74"; 14 | password = "123456"; 15 | } 16 | 17 | const Username = () => { 18 | const vm = useStore(UserStore); 19 | usernameStore = vm; 20 | return <p>username: {vm.username}</p>; 21 | }; 22 | const Password = () => { 23 | const vm = useStore(UserStore); 24 | passwordStore = vm; 25 | return <p>password: {vm.password}</p>; 26 | }; 27 | 28 | const App = () => { 29 | const vm = useStore(UserStore); 30 | appStore = vm; 31 | return ( 32 | <> 33 | <p>{vm.title}</p> 34 | <Password /> 35 | <Username /> 36 | </> 37 | ); 38 | }; 39 | const AppWithStore = connect(App, UserStore); 40 | const { getByText } = render(<AppWithStore />); 41 | 42 | expect(appStore).not.toBe(null); 43 | expect(passwordStore).not.toBe(null); 44 | expect(usernameStore).not.toBe(null); 45 | 46 | expect(appStore).toBe(passwordStore); 47 | expect(appStore).toBe(usernameStore); 48 | 49 | expect(getByText(/amir.qasemi74/i)).toBeInTheDocument(); 50 | expect(getByText(/123456/i)).toBeInTheDocument(); 51 | expect(getByText(/User store/i)).toBeInTheDocument(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /tests/storePartDecorator.spec.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Effect, 3 | Injectable, 4 | Store, 5 | StorePart, 6 | connect, 7 | useStore, 8 | } from "@react-store/core"; 9 | import "@testing-library/jest-dom/extend-expect"; 10 | import { fireEvent, render } from "@testing-library/react"; 11 | import React from "react"; 12 | import { UnobservableProperty } from "src/store/administrator/propertyKeys/unobservableProperty"; 13 | import { StoreAdministrator } from "src/store/administrator/storeAdministrator"; 14 | 15 | describe("Store Parts", () => { 16 | it("should store part state change rerender it's consumers", () => { 17 | let storePartRef: Validator | null = null; 18 | 19 | @StorePart() 20 | class Validator { 21 | hasError = false; 22 | 23 | setHasError(has: boolean) { 24 | this.hasError = has; 25 | } 26 | } 27 | 28 | @Store() 29 | class UserStore { 30 | username = "amirhossein"; 31 | 32 | constructor(public validator: Validator) {} 33 | } 34 | 35 | const App = () => { 36 | const vm = useStore(UserStore); 37 | storePartRef = vm.validator; 38 | return ( 39 | <> 40 | <p>{vm.username}</p> 41 | <button onClick={() => vm.validator.setHasError(true)}> 42 | Create Error 43 | </button> 44 | <span>{vm.validator.hasError ? "Has Error" : "No Error"}</span> 45 | </> 46 | ); 47 | }; 48 | const AppWithStore = connect(App, UserStore); 49 | const { getByText } = render(<AppWithStore />); 50 | 51 | fireEvent.click(getByText(/Create Error/i)); 52 | expect(StoreAdministrator.get(storePartRef!)?.type.name).toBe(Validator.name); 53 | expect(getByText(/Has Error/i)).toBeInTheDocument(); 54 | }); 55 | 56 | it("should run store part effects", () => { 57 | let hasErrorChanged = jest.fn(); 58 | 59 | @StorePart() 60 | class Validator { 61 | hasError = false; 62 | 63 | setHasError(has: boolean) { 64 | this.hasError = has; 65 | } 66 | 67 | @Effect<Validator>(($) => [$.hasError]) 68 | onHasErrorChange() { 69 | hasErrorChanged(); 70 | } 71 | } 72 | 73 | @Store() 74 | class UserStore { 75 | username = "amirhossein"; 76 | 77 | constructor(public validator: Validator) {} 78 | } 79 | 80 | const App = () => { 81 | const vm = useStore(UserStore); 82 | return ( 83 | <> 84 | <p>{vm.username}</p> 85 | <button onClick={() => vm.validator.setHasError(true)}> 86 | Create Error 87 | </button> 88 | <span>{vm.validator.hasError ? "Has Error" : "No Error"}</span> 89 | </> 90 | ); 91 | }; 92 | const AppWithStore = connect(App, UserStore); 93 | const { getByText } = render(<AppWithStore />); 94 | 95 | fireEvent.click(getByText(/Create Error/i)); 96 | expect(hasErrorChanged).toBeCalledTimes(2); 97 | }); 98 | 99 | it("should store @Wire property be read only", () => { 100 | const errorMock = jest.spyOn(console, "error").mockImplementation(); 101 | let pre, post; 102 | let store!: UserStore; 103 | @StorePart() 104 | class Validator {} 105 | 106 | @Store() 107 | class UserStore { 108 | constructor(public validator: Validator) {} 109 | 110 | resetStorePart() { 111 | this.validator = "sdf"; 112 | post = this.validator; 113 | } 114 | } 115 | 116 | const App = () => { 117 | const vm = useStore(UserStore); 118 | store = vm; 119 | if (!pre) pre = vm.validator; 120 | return ( 121 | <> 122 | <button onClick={vm.resetStorePart}>Reset</button> 123 | </> 124 | ); 125 | }; 126 | const AppWithStore = connect(App, UserStore); 127 | const { getByText } = render(<AppWithStore />); 128 | 129 | fireEvent.click(getByText(/Reset/i)); 130 | 131 | expect(pre).toBeDefined(); 132 | expect(post).toBeDefined(); 133 | 134 | expect(pre.constructor === post.constructor).toBeTruthy(); 135 | expect(errorMock).toHaveBeenLastCalledWith( 136 | "`UserStore.validator` is an injected storePart, so can't be mutated." 137 | ); 138 | 139 | const pkInfo = StoreAdministrator.get( 140 | store 141 | )!.propertyKeysManager.propertyKeys.get("validator") as UnobservableProperty; 142 | 143 | expect(pkInfo).toBeInstanceOf(UnobservableProperty); 144 | expect(pkInfo.isReadonly).toBeTruthy(); 145 | }); 146 | 147 | it("should inject dependencies", () => { 148 | let lowerStoreRef!: LowerStore; 149 | 150 | @Injectable() 151 | class A {} 152 | 153 | @Store() 154 | class UpperStore {} 155 | 156 | @StorePart() 157 | class BStorePart { 158 | message = "hi"; 159 | constructor(public upperStore: UpperStore, public a: A) {} 160 | } 161 | @Store() 162 | class LowerStore { 163 | constructor(public part: BStorePart) {} 164 | } 165 | 166 | const App = connect( 167 | connect(() => { 168 | const st = useStore(LowerStore); 169 | lowerStoreRef = st; 170 | return <>{st.part.message}</>; 171 | }, LowerStore), 172 | UpperStore 173 | ); 174 | 175 | render(<App />); 176 | 177 | expect(lowerStoreRef.part.a).toBeInstanceOf(A); 178 | expect(lowerStoreRef.part.upperStore).toBeInstanceOf(UpperStore); 179 | }); 180 | }); 181 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "importHelpers": true, 4 | "removeComments": true, 5 | "emitDecoratorMetadata": true, 6 | "experimentalDecorators": true, 7 | "strict": true, 8 | "strictPropertyInitialization": false, 9 | "noImplicitAny": false, 10 | "esModuleInterop": true, 11 | "baseUrl": ".", 12 | "moduleResolution": "node", 13 | "jsx": "react", 14 | "target": "ES6", 15 | "rootDir": "src", 16 | "declaration": true, 17 | "declarationDir": "dist", 18 | "plugins": [ 19 | { 20 | "transform": "@zerollup/ts-transform-paths", 21 | "exclude": ["*"] 22 | } 23 | ] 24 | }, 25 | 26 | "include": ["src"], 27 | "exclude": ["src/**/*.spec.ts"] 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "moduleResolution": "node", 5 | "baseUrl": ".", 6 | "downlevelIteration": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "esModuleInterop": true, 10 | "strict": true, 11 | "strictPropertyInitialization": false, 12 | "useDefineForClassFields": true, 13 | "noImplicitAny": false, 14 | "jsx": "react", 15 | "paths": { 16 | "@react-store/core": [ 17 | "src" 18 | ], 19 | }, 20 | "sourceMap": true 21 | }, 22 | "include": [ 23 | "src", 24 | "tests", 25 | ] 26 | } --------------------------------------------------------------------------------