├── .babelrc ├── .browserlistrc ├── .changeset ├── README.md └── config.json ├── .circleci └── config.yml ├── .eslintignore ├── .eslintrc.js ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md ├── lock.yml ├── stale.yml └── workflows │ └── release.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .size-limit.json ├── .vscode ├── launch.json └── settings.json ├── .watchmanconfig ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── README_v5.md ├── __mocks__ └── react-native.js ├── batchingForReactDom.js ├── batchingForReactNative.js ├── batchingOptOut.js ├── hooks.png ├── jest.setup.ts ├── package.json ├── src ├── Provider.tsx ├── disposeOnUnmount.ts ├── globals.d.ts ├── index.ts ├── inject.ts ├── observer.tsx ├── observerClass.ts ├── propTypes.ts ├── types │ ├── IReactComponent.ts │ ├── IStoresToProps.ts │ ├── IValueMap.ts │ └── IWrappedComponent.ts └── utils │ └── utils.ts ├── test ├── .eslintrc.yaml ├── ErrorCatcher.tsx ├── Provider.test.tsx ├── __snapshots__ │ ├── observer.test.tsx.snap │ └── stateless.test.tsx.snap ├── compile-ts.tsx ├── context.test.tsx ├── disposeOnUnmount.test.tsx ├── hooks.test.tsx ├── inject.test.tsx ├── issue21.test.tsx ├── issue806.test.tsx ├── misc.test.tsx ├── observer.test.tsx ├── propTypes.test.ts ├── stateless.test.tsx ├── symbol.test.tsx ├── transactions.test.tsx ├── tsconfig.json └── utils │ └── withConsole.ts ├── tsconfig.json ├── tsconfig.test.json ├── tsdx.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@babel/preset-env", { "targets": { "ie": "11" } }]], 3 | "plugins": [ 4 | ["@babel/plugin-proposal-decorators", { "legacy": true}], 5 | ["@babel/plugin-proposal-class-properties", { "loose": true}], 6 | "@babel/plugin-transform-react-jsx" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.browserlistrc: -------------------------------------------------------------------------------- 1 | { 2 | "targets": { 3 | "chrome": "58", 4 | "ie": "9" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/master/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "changelog": ["@changesets/changelog-github", { "repo": "mobxjs/mobx-react" }], 3 | "commit": false, 4 | "access": "public" 5 | } 6 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | executors: 4 | my-executor: 5 | docker: 6 | - image: circleci/node:11 7 | environment: 8 | CI: true 9 | 10 | orbs: 11 | node: circleci/node@4.0.1 12 | 13 | jobs: 14 | # mobx-react build 15 | build: 16 | executor: my-executor 17 | steps: 18 | - checkout 19 | 20 | - node/install-packages: 21 | pkg-manager: yarn 22 | 23 | - run: yarn build 24 | 25 | - persist_to_workspace: 26 | root: . 27 | paths: 28 | - ./* 29 | 30 | test-coverage: 31 | executor: my-executor 32 | steps: 33 | - attach_workspace: 34 | at: . 35 | 36 | - run: yarn test:coverage 37 | - store_test_results: 38 | path: ./test-results 39 | 40 | - persist_to_workspace: 41 | root: . 42 | paths: 43 | - ./coverage 44 | 45 | test-size: 46 | executor: my-executor 47 | steps: 48 | - attach_workspace: 49 | at: . 50 | 51 | - run: yarn test:size 52 | 53 | # upload coverage 54 | upload-coveralls: 55 | executor: my-executor 56 | steps: 57 | - attach_workspace: 58 | at: . 59 | 60 | # only run coveralls if the token is present (it is not present for fork PRs for security reasons) 61 | - run: 62 | name: upload coveralls 63 | command: | 64 | if [[ -v COVERALLS_REPO_TOKEN ]] 65 | then 66 | cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js 67 | echo "Coveralls info uploaded" 68 | else 69 | echo "Warning - Coveralls info could NOT be uploaded since the COVERALLS_REPO_TOKEN was not available" 70 | fi 71 | 72 | workflows: 73 | version: 2 74 | build-and-test: 75 | jobs: 76 | - build 77 | 78 | - test-coverage: 79 | requires: 80 | - build 81 | - test-size: 82 | requires: 83 | - build 84 | 85 | - upload-coveralls: 86 | requires: 87 | - test-coverage 88 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", 3 | plugins: ["@typescript-eslint", "react"], 4 | extends: ["eslint:recommended", "plugin:react/recommended"], 5 | env: { 6 | browser: true, 7 | es6: true, 8 | node: true 9 | }, 10 | globals: { 11 | process: "readonly", 12 | __DEV__: "readonly" 13 | }, 14 | parserOptions: { 15 | ecmaVersion: 6, 16 | sourceType: "module" 17 | }, 18 | settings: { 19 | react: { 20 | version: "detect" 21 | } 22 | }, 23 | overrides: [ 24 | { 25 | files: ["**/*.ts", "**/*.tsx"], 26 | rules: { 27 | // Things that don't play nicely with TS: 28 | "react/display-name": "off", 29 | "require-yield": "off", 30 | "no-unused-vars": "off", 31 | "no-extra-semi": "off" 32 | } 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | open_collective: mobx 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Create new issue 4 | url: https://github.com/mobxjs/mobx/issues/new/choose 5 | about: This repo will be archived soon. Please use this link to create an issue in mobx repo. 6 | - name: Documentation 7 | url: https://mobx.js.org 8 | about: Official Mobx documentation 9 | - name: GitHub discussions 10 | url: https://github.com/mobxjs/mobx/discussions 11 | about: We are open to your questions or discussion 12 | - name: StackOverflow 13 | url: https://stackoverflow.com/questions/tagged/mobx 14 | about: We are open to your questions or discussion 15 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 4 | 5 | ## Making a change to documentation only? Delete the rest of the template and go ahead. 6 | 7 | 14 | 15 | ### Code change checklist 16 | 17 | - [ ] Added/updated unit tests 18 | - [ ] Updated `README` if applicable 19 | 20 | 23 | -------------------------------------------------------------------------------- /.github/lock.yml: -------------------------------------------------------------------------------- 1 | # Configuration for Lock Threads - https://github.com/dessant/lock-threads 2 | 3 | # Number of days of inactivity before a closed issue or pull request is locked 4 | daysUntilLock: 60 5 | 6 | # Skip issues and pull requests created before a given timestamp. Timestamp must 7 | # follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable 8 | skipCreatedBefore: 2019-01-01 9 | 10 | # Issues and pull requests with these labels will be ignored. Set to `[]` to disable 11 | exemptLabels: [] 12 | 13 | # Label to add before locking, such as `outdated`. Set to `false` to disable 14 | lockLabel: false 15 | 16 | # Comment to post before locking. Set to `false` to disable 17 | lockComment: > 18 | This thread has been automatically locked since there has not been 19 | any recent activity after it was closed. Please open a new issue for 20 | related bugs or questions. 21 | 22 | # Assign `resolved` as the reason for locking. Set to `false` to disable 23 | setLockReason: true 24 | 25 | # Limit to only `issues` or `pulls` 26 | only: issues 27 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 14 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 4 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - breaking change 8 | - discuss 9 | - enhancement 10 | - documentation 11 | - has conflicts 12 | - has PR 13 | - help wanted 14 | - needs investigation 15 | - pinned 16 | - ready for volunteer 17 | # Label to use when marking an issue as stale 18 | staleLabel: stale 19 | # Comment to post when marking an issue as stale. Set to `false` to disable 20 | markComment: > 21 | This issue has been automatically marked as stale because it has not had 22 | recent activity. It will be closed if no further activity occurs. Thank you 23 | for your contributions. 24 | # Comment to post when closing a stale issue. Set to `false` to disable 25 | closeComment: false 26 | # Limit to only `issues` or `pulls` 27 | only: issues 28 | # Comment to post when removing the stale label. 29 | unmarkComment: > 30 | This issue has been automatically unmarked as stale. Please disregard previous warnings. 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout Repo 14 | uses: actions/checkout@master 15 | with: 16 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits 17 | fetch-depth: 0 18 | 19 | - name: Setup Node.js 12.x 20 | uses: actions/setup-node@master 21 | with: 22 | node-version: 12.x 23 | 24 | - name: Install Dependencies 25 | run: yarn 26 | 27 | - name: Create Release Pull Request or Publish to npm 28 | id: changesets 29 | uses: changesets/action@master 30 | with: 31 | # This expects you to have a script called release which does a build for your packages and calls changeset publish 32 | publish: yarn release 33 | commit: Version release 34 | title: Next release 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | /test/node_modules 4 | /npm-debug.log 5 | /test/custom.d.ts 6 | /test/index.d.ts 7 | /.idea 8 | /*.iml 9 | /*.ipr 10 | /*.iws 11 | /test/browser/test_bundle.js 12 | /.source.* 13 | yarn-error.log 14 | .DS_Store 15 | coverage 16 | 17 | # files generated by V5 18 | /custom.* 19 | /index.* 20 | /native.* 21 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | *.yml -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 4, 4 | "semi": false 5 | } -------------------------------------------------------------------------------- /.size-limit.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "path": "dist/mobxreact.umd.production.min.js", 4 | "limit": "4.1 KB", 5 | "webpack": false, 6 | "running": false 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | // Note; this config requires node 8.4 or higher 9 | "type": "node", 10 | "protocol": "auto", 11 | "request": "launch", 12 | "name": "debug unit test", 13 | "stopOnEntry": false, 14 | "program": "${workspaceRoot}/node_modules/jest-cli/bin/jest.js", 15 | "args": ["--verbose", "--testRegex",".*", "-i", "${file}"], 16 | "runtimeArgs": [ 17 | "--nolazy" 18 | ], 19 | "sourceMaps": true 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib", 3 | "javascript.implicitProjectConfig.experimentalDecorators": true 4 | } -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # MobX Contributor Guide 2 | 3 | Welcome to a community of developers just like you, striving to create the best experience around MobX. We welcome anyone who wants to contribute or provide constructive feedback, no matter the age or level of experience. 4 | 5 | Here are some ways to contribute to the project, from easiest to most difficult: 6 | 7 | - [Reporting bugs](#reporting-bugs) 8 | - [Improving the documentation](#improving-the-documentation) 9 | - [Responding to issues](#responding-to-issues) 10 | - [Small bug fixes](#small-bug-fixes) 11 | 12 | ## Issues 13 | 14 | ### Reporting bugs 15 | 16 | If you encounter a bug, please file an issue on GitHub via the repository of the sub-project you think contains the bug. If an issue you have is already reported, please add additional information or add a 👍 reaction to indicate your agreement. 17 | 18 | Include in the issue a link to your reproduction. A couple of good options are a small Github repo or a [CodeSandbox](https://codesandbox.io/s/minimal-mobx-react-project-ppgml). 19 | 20 | If you have a more complicated issue where it is helpful to run it locally, you can download CodeSandbox template and work on it and then commit to your GitHub repo. 21 | 22 | ### Improving the documentation 23 | 24 | Improving the documentation, examples, and other open-source content can be the easiest way to contribute to the library. If you see a piece of content that can be better, open a PR with an improvement, no matter how small! If you would like to suggest a big change or major rewrite, we’d love to hear your ideas but please open an issue for discussion before writing the PR. 25 | 26 | ### Responding to issues 27 | 28 | In addition to reporting issues, a great way to contribute to MobX is to respond to other peoples' issues and try to identify the problem or help them work around it. If you’re interested in taking a more active role in this process, please go ahead and respond to issues. 29 | 30 | ### Small bug fixes 31 | 32 | For a small bug fix change (less than 20 lines of code changed), feel free to open a pull request. We’ll try to merge it as fast as possible and ideally publish a new release on the same day. The only requirement is, make sure you also add a test that verifies the bug you are trying to fix. 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Michel Weststrate 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mobx-react 2 | 3 | ---- 4 | 5 | ## 🚨🚨🚨 This repo has been moved to [mobx](https://github.com/mobxjs/mobx/tree/main/packages/mobx-react) 6 | 7 | ---- 8 | 9 | [![CircleCI](https://circleci.com/gh/mobxjs/mobx-react.svg?style=svg)](https://circleci.com/gh/mobxjs/mobx-react) 10 | [![CDNJS](https://img.shields.io/cdnjs/v/mobx-react.svg)](https://cdnjs.com/libraries/mobx-react) 11 | [![Minzipped size](https://img.shields.io/bundlephobia/minzip/mobx-react-lite.svg)](https://bundlephobia.com/result?p=mobx-react-lite) 12 | 13 | [![TypeScript](https://badges.frapsoft.com/typescript/code/typescript.svg?v=101)](https://github.com/ellerbrock/typescript-badges/)[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 14 | 15 | [![Discuss on Github](https://img.shields.io/badge/discuss%20on-GitHub-orange)](https://github.com/mobxjs/mobx/discussions) 16 | [![View changelog](https://img.shields.io/badge/changelogs.xyz-Explore%20Changelog-brightgreen)](https://changelogs.xyz/mobx-react) 17 | 18 | Package with React component wrapper for combining React with MobX. 19 | Exports the `observer` decorator and other utilities. 20 | For documentation, see the [MobX](https://mobxjs.github.io/mobx) project. 21 | There is also work-in-progress [user guide](https://mobx-react.js.org) for additional information. 22 | This package supports both React and React Native. 23 | 24 | ## Choosing your version 25 | 26 | There are currently two actively maintained versions of mobx-react: 27 | 28 | | NPM Version | Support MobX version | Supported React versions | Supports hook based components | 29 | | ----------- | -------------------- | ------------------------ | -------------------------------------------------------------------------------- | 30 | | v7 | 6.\* | 16.8+ | Yes | 31 | | v6 | 4._ / 5._ | 16.8+ | Yes | 32 | | v5 | 4._ / 5._ | 0.13+ | No, but it is possible to use `` sections inside hook based components | 33 | 34 | mobx-react 6 / 7 is a repackage of the smaller [mobx-react-lite](https://github.com/mobxjs/mobx-react-lite) package + following features from the `mobx-react@5` package added: 35 | 36 | - Support for class based components for `observer` and `@observer` 37 | - `Provider / inject` to pass stores around (but consider to use `React.createContext` instead) 38 | - `PropTypes` to describe observable based property checkers (but consider to use TypeScript instead) 39 | - The `disposeOnUnmount` utility / decorator to easily clean up resources such as reactions created in your class based components. 40 | 41 | ## Installation 42 | 43 | `npm install mobx-react --save` 44 | 45 | Or CDN: https://unpkg.com/mobx-react (UMD namespace: `mobxReact`) 46 | 47 | ```javascript 48 | import { observer } from "mobx-react" 49 | ``` 50 | 51 | This package provides the bindings for MobX and React. 52 | See the [official documentation](https://mobx.js.org/react-integration.html) for how to get started. 53 | 54 | For greenfield projects you might want to consider to use [mobx-react-lite](https://github.com/mobxjs/mobx-react-lite), if you intend to only use function based components. `React.createContext` can be used to pass stores around. 55 | 56 | ## API documentation 57 | 58 | Please check [mobx.js.org](https://mobx.js.org/) for the general documentation. The documentation below highlights some specifics. 59 | 60 | ### `observer(component)` 61 | 62 | Function (and decorator) that converts a React component definition, React component class, or stand-alone render function, into a reactive component. A converted component will track which observables are used by its effective `render` and automatically re-render the component when one of these values changes. 63 | 64 | #### Functional Components 65 | 66 | `React.memo` is automatically applied to functional components provided to `observer`. `observer` does not accept a functional component already wrapped in `React.memo`, or an `observer`, in order to avoid consequences that might arise as a result of wrapping it twice. 67 | 68 | #### Class Components 69 | 70 | When using component classes, `this.props` and `this.state` will be made observables, so the component will react to all changes in props and state that are used by `render`. 71 | 72 | `shouldComponentUpdate` is not supported. As such, it is recommended that class components extend `React.PureComponent`. The `observer` will automatically patch non-pure class components with an internal implementation of `React.PureComponent` if necessary. 73 | 74 | See the [MobX](https://mobxjs.github.io/mobx/refguide/observer-component.html) documentation for more details. 75 | 76 | ```javascript 77 | import { observer } from "mobx-react" 78 | 79 | // ---- ES6 syntax ---- 80 | const TodoView = observer( 81 | class TodoView extends React.Component { 82 | render() { 83 | return
{this.props.todo.title}
84 | } 85 | } 86 | ) 87 | 88 | // ---- ESNext syntax with decorator syntax enabled ---- 89 | @observer 90 | class TodoView extends React.Component { 91 | render() { 92 | return
{this.props.todo.title}
93 | } 94 | } 95 | 96 | // ---- or just use function components: ---- 97 | const TodoView = observer(({ todo }) =>
{todo.title}
) 98 | ``` 99 | 100 | ### `Observer` 101 | 102 | `Observer` is a React component, which applies `observer` to an anonymous region in your component. 103 | It takes as children a single, argumentless function which should return exactly one React component. 104 | The rendering in the function will be tracked and automatically re-rendered when needed. 105 | This can come in handy when needing to pass render function to external components (for example the React Native listview), or if you 106 | dislike the `observer` decorator / function. 107 | 108 | ```javascript 109 | class App extends React.Component { 110 | render() { 111 | return ( 112 |
113 | {this.props.person.name} 114 | {() =>
{this.props.person.name}
}
115 |
116 | ) 117 | } 118 | } 119 | 120 | const person = observable({ name: "John" }) 121 | 122 | ReactDOM.render(, document.body) 123 | person.name = "Mike" // will cause the Observer region to re-render 124 | ``` 125 | 126 | In case you are a fan of render props, you can use that instead of children. Be advised, that you cannot use both approaches at once, children have a precedence. 127 | Example 128 | 129 | ```javascript 130 | class App extends React.Component { 131 | render() { 132 | return ( 133 |
134 | {this.props.person.name} 135 |
{this.props.person.name}
} /> 136 |
137 | ) 138 | } 139 | } 140 | 141 | const person = observable({ name: "John" }) 142 | 143 | ReactDOM.render(, document.body) 144 | person.name = "Mike" // will cause the Observer region to re-render 145 | ``` 146 | 147 | ### `useLocalObservable` hook 148 | 149 | [User guide](https://mobx-react.js.org/state-local) 150 | 151 | Local observable state can be introduced by using the `useLocalObservable` hook, that runs once to create an observable store. A quick example would be: 152 | 153 | ```javascript 154 | import { useLocalObservable, Observer } from "mobx-react-lite" 155 | 156 | const Todo = () => { 157 | const todo = useLocalObservable(() => ({ 158 | title: "Test", 159 | done: true, 160 | toggle() { 161 | this.done = !this.done 162 | } 163 | })) 164 | 165 | return ( 166 | 167 | {() => ( 168 |

169 | {todo.title} {todo.done ? "[DONE]" : "[TODO]"} 170 |

171 | )} 172 |
173 | ) 174 | } 175 | ``` 176 | 177 | When using `useLocalObservable`, all properties of the returned object will be made observable automatically, getters will be turned into computed properties, and methods will be bound to the store and apply mobx transactions automatically. If new class instances are returned from the initializer, they will be kept as is. 178 | 179 | It is important to realize that the store is created only once! It is not possible to specify dependencies to force re-creation, _nor should you directly be referring to props for the initializer function_, as changes in those won't propagate. 180 | 181 | Instead, if your store needs to refer to props (or `useState` based local state), the `useLocalObservable` should be combined with the `useAsObservableSource` hook, see below. 182 | 183 | Note that in many cases it is possible to extract the initializer function to a function outside the component definition. Which makes it possible to test the store itself in a more straight-forward manner, and avoids creating the initializer closure on each re-render. 184 | 185 | _Note: using `useLocalObservable` is mostly beneficial for really complex local state, or to obtain more uniform code base. Note that using a local store might conflict with future React features like concurrent rendering._ 186 | 187 | ### Server Side Rendering with `enableStaticRendering` 188 | 189 | When using server side rendering, normal lifecycle hooks of React components are not fired, as the components are rendered only once. 190 | Since components are never unmounted, `observer` components would in this case leak memory when being rendered server side. 191 | To avoid leaking memory, call `enableStaticRendering(true)` when using server side rendering. 192 | 193 | ```javascript 194 | import { enableStaticRendering } from "mobx-react" 195 | 196 | enableStaticRendering(true) 197 | ``` 198 | 199 | This makes sure the component won't try to react to any future data changes. 200 | 201 | ### Which components should be marked with `observer`? 202 | 203 | The simple rule of thumb is: _all components that render observable data_. 204 | If you don't want to mark a component as observer, for example to reduce the dependencies of a generic component package, make sure you only pass it plain data. 205 | 206 | ### Enabling decorators (optional) 207 | 208 | Decorators are currently a stage-2 ESNext feature. How to enable them is documented [here](https://github.com/mobxjs/mobx#enabling-decorators-optional). 209 | 210 | ### Should I still use smart and dumb components? 211 | 212 | See this [thread](https://www.reddit.com/r/reactjs/comments/4vnxg5/free_eggheadio_course_learn_mobx_react_in_30/d61oh0l). 213 | TL;DR: the conceptual distinction makes a lot of sense when using MobX as well, but use `observer` on all components. 214 | 215 | ### `PropTypes` 216 | 217 | MobX-react provides the following additional `PropTypes` which can be used to validate against MobX structures: 218 | 219 | - `observableArray` 220 | - `observableArrayOf(React.PropTypes.number)` 221 | - `observableMap` 222 | - `observableObject` 223 | - `arrayOrObservableArray` 224 | - `arrayOrObservableArrayOf(React.PropTypes.number)` 225 | - `objectOrObservableObject` 226 | 227 | Use `import { PropTypes } from "mobx-react"` to import them, then use for example `PropTypes.observableArray` 228 | 229 | ### `Provider` and `inject` 230 | 231 | See also [the migration guide to React Hooks](https://mobx-react.js.org/recipes-migration). 232 | 233 | _Note: usually there is no need anymore to use `Provider` / `inject` in new code bases; most of its features are now covered by `React.createContext`._ 234 | 235 | `Provider` is a component that can pass stores (or other stuff) using React's context mechanism to child components. 236 | This is useful if you have things that you don't want to pass through multiple layers of components explicitly. 237 | 238 | `inject` can be used to pick up those stores. It is a higher order component that takes a list of strings and makes those stores available to the wrapped component. 239 | 240 | Example (based on the official [context docs](https://facebook.github.io/react/docs/context.html#passing-info-automatically-through-a-tree)): 241 | 242 | ```javascript 243 | @inject("color") 244 | @observer 245 | class Button extends React.Component { 246 | render() { 247 | return 248 | } 249 | } 250 | 251 | class Message extends React.Component { 252 | render() { 253 | return ( 254 |
255 | {this.props.text} 256 |
257 | ) 258 | } 259 | } 260 | 261 | class MessageList extends React.Component { 262 | render() { 263 | const children = this.props.messages.map(message => ) 264 | return ( 265 | 266 |
{children}
267 |
268 | ) 269 | } 270 | } 271 | ``` 272 | 273 | Notes: 274 | 275 | - It is possible to read the stores provided by `Provider` using `React.useContext`, by using the `MobXProviderContext` context that can be imported from `mobx-react`. 276 | - If a component asks for a store and receives a store via a property with the same name, the property takes precedence. Use this to your advantage when testing! 277 | - When using both `@inject` and `@observer`, make sure to apply them in the correct order: `observer` should be the inner decorator, `inject` the outer. There might be additional decorators in between. 278 | - The original component wrapped by `inject` is available as the `wrappedComponent` property of the created higher order component. 279 | 280 | #### "The set of provided stores has changed" error 281 | 282 | Values provided through `Provider` should be final. Make sure that if you put things in `context` that might change over time, that they are `@observable` or provide some other means to listen to changes, like callbacks. However, if your stores will change over time, like an observable value of another store, MobX will throw an error. 283 | This restriction exists mainly for legacy reasons. If you have a scenario where you need to modify the set of stores, please leave a comment about it in this issue https://github.com/mobxjs/mobx-react/issues/745. Or a preferred way is to [use React Context](https://mobx-react.js.org/recipes-context) directly which does not have this restriction. 284 | 285 | #### Inject as function 286 | 287 | The above example in ES5 would start like: 288 | 289 | ```javascript 290 | var Button = inject("color")( 291 | observer( 292 | class Button extends Component { 293 | /* ... etc ... */ 294 | } 295 | ) 296 | ) 297 | ``` 298 | 299 | A functional stateless component would look like: 300 | 301 | ```javascript 302 | var Button = inject("color")( 303 | observer(({ color }) => { 304 | /* ... etc ... */ 305 | }) 306 | ) 307 | ``` 308 | 309 | #### Customizing inject 310 | 311 | Instead of passing a list of store names, it is also possible to create a custom mapper function and pass it to inject. 312 | The mapper function receives all stores as argument, the properties with which the components are invoked and the context, and should produce a new set of properties, 313 | that are mapped into the original: 314 | 315 | `mapperFunction: (allStores, props, context) => additionalProps` 316 | 317 | Since version 4.0 the `mapperFunction` itself is tracked as well, so it is possible to do things like: 318 | 319 | ```javascript 320 | const NameDisplayer = ({ name }) =>

{name}

321 | 322 | const UserNameDisplayer = inject(stores => ({ 323 | name: stores.userStore.name 324 | }))(NameDisplayer) 325 | 326 | const user = mobx.observable({ 327 | name: "Noa" 328 | }) 329 | 330 | const App = () => ( 331 | 332 | 333 | 334 | ) 335 | 336 | ReactDOM.render(, document.body) 337 | ``` 338 | 339 | _N.B. note that in this *specific* case neither `NameDisplayer` nor `UserNameDisplayer` needs to be decorated with `observer`, since the observable dereferencing is done in the mapper function_ 340 | 341 | #### Using `PropTypes` and `defaultProps` and other static properties in combination with `inject` 342 | 343 | Inject wraps a new component around the component you pass into it. 344 | This means that assigning a static property to the resulting component, will be applied to the HoC, and not to the original component. 345 | So if you take the following example: 346 | 347 | ```javascript 348 | const UserName = inject("userStore")(({ userStore, bold }) => someRendering()) 349 | 350 | UserName.propTypes = { 351 | bold: PropTypes.boolean.isRequired, 352 | userStore: PropTypes.object.isRequired // will always fail 353 | } 354 | ``` 355 | 356 | The above propTypes are incorrect, `bold` needs to be provided by the caller of the `UserName` component and is checked by React. 357 | However, `userStore` does not need to be required! Although it is required for the original stateless function component, it is not 358 | required for the resulting inject component. After all, the whole point of that component is to provide that `userStore` itself. 359 | 360 | So if you want to make assertions on the data that is being injected (either stores or data resulting from a mapper function), the propTypes 361 | should be defined on the _wrapped_ component. Which is available through the static property `wrappedComponent` on the inject component: 362 | 363 | ```javascript 364 | const UserName = inject("userStore")(({ userStore, bold }) => someRendering()) 365 | 366 | UserName.propTypes = { 367 | bold: PropTypes.boolean.isRequired // could be defined either here ... 368 | } 369 | 370 | UserName.wrappedComponent.propTypes = { 371 | // ... or here 372 | userStore: PropTypes.object.isRequired // correct 373 | } 374 | ``` 375 | 376 | The same principle applies to `defaultProps` and other static React properties. 377 | Note that it is not allowed to redefine `contextTypes` on `inject` components (but is possible to define it on `wrappedComponent`) 378 | 379 | Finally, mobx-react will automatically move non React related static properties from wrappedComponent to the inject component so that all static fields are 380 | actually available to the outside world without needing `.wrappedComponent`. 381 | 382 | #### Strongly typing inject 383 | 384 | ##### With TypeScript 385 | 386 | `inject` also accepts a function (`(allStores, nextProps, nextContext) => additionalProps`) that can be used to pick all the desired stores from the available stores like this. 387 | The `additionalProps` will be merged into the original `nextProps` before being provided to the next component. 388 | 389 | ```typescript 390 | import { IUserStore } from "myStore" 391 | 392 | @inject(allStores => ({ 393 | userStore: allStores.userStore as IUserStore 394 | })) 395 | class MyComponent extends React.Component<{ userStore?: IUserStore; otherProp: number }, {}> { 396 | /* etc */ 397 | } 398 | ``` 399 | 400 | Make sure to mark `userStore` as an optional property. It should not (necessarily) be passed in by parent components at all! 401 | 402 | Note: If you have strict null checking enabled, you could muffle the nullable type by using the `!` operator: 403 | 404 | ``` 405 | public render() { 406 | const {a, b} = this.store! 407 | // ... 408 | } 409 | ``` 410 | 411 | By [migrating to React Hooks](https://mobx-react.js.org/recipes-migration) you can avoid problems with TypeScript. 412 | 413 | #### Testing store injection 414 | 415 | It is allowed to pass any declared store in directly as a property as well. This makes it easy to set up individual component tests without a provider. 416 | 417 | So if you have in your app something like: 418 | 419 | ```javascript 420 | 421 | 422 | 423 | ``` 424 | 425 | In your test you can easily test the `Person` component by passing the necessary store as prop directly: 426 | 427 | ``` 428 | const profile = new Profile() 429 | const mountedComponent = mount( 430 | 431 | ) 432 | ``` 433 | 434 | Bear in mind that using shallow rendering won't provide any useful results when testing injected components; only the injector will be rendered. 435 | To test with shallow rendering, instantiate the `wrappedComponent` instead: `shallow()` 436 | 437 | ### disposeOnUnmount(componentInstance, propertyKey | function | function[]) 438 | 439 | Function (and decorator) that makes sure a function (usually a disposer such as the ones returned by `reaction`, `autorun`, etc.) is automatically executed as part of the componentWillUnmount lifecycle event. 440 | 441 | ```javascript 442 | import { disposeOnUnmount } from "mobx-react" 443 | 444 | class SomeComponent extends React.Component { 445 | // decorator version 446 | @disposeOnUnmount 447 | someReactionDisposer = reaction(...) 448 | 449 | // decorator version with arrays 450 | @disposeOnUnmount 451 | someReactionDisposers = [ 452 | reaction(...), 453 | reaction(...) 454 | ] 455 | 456 | 457 | // function version over properties 458 | someReactionDisposer = disposeOnUnmount(this, reaction(...)) 459 | 460 | // function version inside methods 461 | componentDidMount() { 462 | // single function 463 | disposeOnUnmount(this, reaction(...)) 464 | 465 | // or function array 466 | disposeOnUnmount(this, [ 467 | reaction(...), 468 | reaction(...) 469 | ]) 470 | } 471 | } 472 | ``` 473 | 474 | ## DevTools 475 | 476 | `mobx-react@6` and higher are no longer compatible with the mobx-react-devtools. 477 | That is, the MobX react devtools will no longer show render timings or dependency trees of the component. 478 | The reason is that the standard React devtools are also capable of highlighting re-rendering components. 479 | And the dependency tree of a component can now be inspected by the standard devtools as well, as shown in the image below: 480 | 481 | ![hooks.png](hooks.png) 482 | 483 | ## FAQ 484 | 485 | **Should I use `observer` for each component?** 486 | 487 | You should use `observer` on every component that displays observable data. 488 | Even the small ones. `observer` allows components to render independently from their parent and in general this means that 489 | the more you use `observer`, the better the performance become. 490 | The overhead of `observer` itself is negligible. 491 | See also [Do child components need `@observer`?](https://github.com/mobxjs/mobx/issues/101) 492 | 493 | **I see React warnings about `forceUpdate` / `setState` from React** 494 | 495 | The following warning will appear if you trigger a re-rendering between instantiating and rendering a component: 496 | 497 | ``` 498 | 499 | Warning: forceUpdate(...): Cannot update during an existing state transition (such as within `render`). Render methods should be a pure function of props and state.` 500 | 501 | ``` 502 | 503 | -- or -- 504 | 505 | ``` 506 | 507 | Warning: setState(...): Cannot update during an existing state transition (such as within `render` or another component's constructor). Render methods should be a pure function of props and state; constructor side-effects are an anti-pattern, but can be moved to `componentWillMount`. 508 | 509 | ``` 510 | 511 | Usually this means that (another) component is trying to modify observables used by this components in their `constructor` or `getInitialState` methods. 512 | This violates the React Lifecycle, `componentWillMount` should be used instead if state needs to be modified before mounting. 513 | -------------------------------------------------------------------------------- /README_v5.md: -------------------------------------------------------------------------------- 1 | # mobx-react 2 | 3 | [![Build Status](https://travis-ci.org/mobxjs/mobx-react.svg?branch=master)](https://travis-ci.org/mobxjs/mobx-react) 4 | [![Join the chat at https://gitter.im/mobxjs/mobx](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/mobxjs/mobx?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 5 | [![CDNJS](https://img.shields.io/cdnjs/v/mobx-react.svg)](https://cdnjs.com/libraries/mobx-react) 6 | 7 | Package with React component wrapper for combining React with MobX. 8 | Exports the `observer` decorator and some development utilities. 9 | For documentation, see the [MobX](https://mobxjs.github.io/mobx) project. 10 | This package supports both React and React Native. 11 | 12 | ## Installation 13 | 14 | `npm install mobx-react --save` 15 | 16 | Or CDN: https://unpkg.com/mobx-react (namespace: `mobxReact`) 17 | 18 | ```javascript 19 | import { observer } from "mobx-react" 20 | // - or, for custom renderers without DOM: - 21 | import { observer } from "mobx-react/custom" 22 | ``` 23 | 24 | This package provides the bindings for MobX and React. 25 | See the [official documentation](http://mobxjs.github.io/mobx/intro/overview.html) for how to get started. 26 | 27 | If you are using [React hooks](https://reactjs.org/docs/hooks-intro.html) with latest React 16.7 and you like living on the bleeding edge then have a look at the new [mobx-react-lite](https://github.com/mobxjs/mobx-react-lite). 28 | 29 | ## Boilerplate projects that use mobx-react 30 | 31 | * Minimal MobX, React, ES6, JSX, Hot reloading: [MobX-React-Boilerplate](https://github.com/mobxjs/mobx-react-boilerplate) 32 | * TodoMVC MobX, React, ES6, JSX, Hot reloading: [MobX-React-TodoMVC](https://github.com/mobxjs/mobx-react-todomvc) 33 | * Minimal MobX, React, Typescript, TSX: [MobX-React-Typescript-Boilerplate](https://github.com/mobxjs/mobx-react-typescript-boilerplate) 34 | * Minimal MobX, React, ES6(babel), JSPM with hot reloading modules: 35 | [jspm-react](https://github.com/capaj/jspm-react) 36 | * React Native Counter: [Mobx-React-Native-Counter](https://github.com/bartonhammond/mobx-react-native-counter) 37 | * React Native, TypeScript, React Navigation: [Ignite Bowser](https://github.com/infinitered/ignite-bowser) 38 | 39 | ## API documentation 40 | 41 | ### observer(componentClass) 42 | 43 | Function (and decorator) that converts a React component definition, React component class or stand-alone render function into a reactive component, which tracks which observables are used by `render` and automatically re-renders the component when one of these values changes. 44 | 45 | Apart from observables passed/injected in or defined inside an `observer` component, `this.props` and `this.state` are also observables themselves, so the component will react to all changes in props and state that are used by `render`. 46 | 47 | See the [MobX](https://mobxjs.github.io/mobx/refguide/observer-component.html) documentation for more details. 48 | 49 | ```javascript 50 | import { observer } from "mobx-react" 51 | 52 | // ---- ES5 syntax ---- 53 | 54 | const TodoView = observer( 55 | React.createClass({ 56 | displayName: "TodoView", 57 | render() { 58 | return
{this.props.todo.title}
59 | } 60 | }) 61 | ) 62 | 63 | // ---- ES6 syntax ---- 64 | 65 | const TodoView = observer( 66 | class TodoView extends React.Component { 67 | render() { 68 | return
{this.props.todo.title}
69 | } 70 | } 71 | ) 72 | 73 | // ---- ESNext syntax with decorators ---- 74 | 75 | @observer 76 | class TodoView extends React.Component { 77 | render() { 78 | return
{this.props.todo.title}
79 | } 80 | } 81 | 82 | // ---- or just use a stateless component function: ---- 83 | 84 | const TodoView = observer(({ todo }) =>
{todo.title}
) 85 | ``` 86 | 87 | ### `Observer` 88 | 89 | `Observer` is a React component, which applies `observer` to an anonymous region in your component. 90 | It takes as children a single, argumentless function which should return exactly one React component. 91 | The rendering in the function will be tracked and automatically re-rendered when needed. 92 | This can come in handy when needing to pass render function to external components (for example the React Native listview), or if you 93 | dislike the `observer` decorator / function. 94 | 95 | ```javascript 96 | class App extends React.Component { 97 | render() { 98 | return ( 99 |
100 | {this.props.person.name} 101 | {() =>
{this.props.person.name}
}
102 |
103 | ) 104 | } 105 | } 106 | 107 | const person = observable({ name: "John" }) 108 | 109 | ReactDOM.render(, document.body) 110 | person.name = "Mike" // will cause the Observer region to re-render 111 | ``` 112 | 113 | In case you are a fan of render props, you can use that instead of children. Be advised, that you cannot use both approaches at once, children have a precedence. 114 | Example 115 | 116 | ```javascript 117 | class App extends React.Component { 118 | render() { 119 | return ( 120 |
121 | {this.props.person.name} 122 |
{this.props.person.name}
} /> 123 |
124 | ) 125 | } 126 | } 127 | 128 | const person = observable({ name: "John" }) 129 | 130 | ReactDOM.render(, document.body) 131 | person.name = "Mike" // will cause the Observer region to re-render 132 | ``` 133 | 134 | ### Global error handler with `onError` 135 | 136 | If a component throws an error, this logs to the console but does not 'crash' the app, so it might go unnoticed. 137 | For this reason it is possible to attach a global error handler using `onError` to intercept any error thrown in the render of an `observer` component. 138 | This can be used to hook up any client side error collection system. 139 | 140 | ```javascript 141 | import { onError } from "mobx-react" 142 | 143 | onError(error => { 144 | console.log(error) 145 | }) 146 | ``` 147 | 148 | ### Server Side Rendering with `useStaticRendering` 149 | 150 | When using server side rendering, normal lifecycle hooks of React components are not fired, as the components are rendered only once. 151 | Since components are never unmounted, `observer` components would in this case leak memory when being rendered server side. 152 | To avoid leaking memory, call `useStaticRendering(true)` when using server side rendering. 153 | 154 | ```javascript 155 | import { useStaticRendering } from "mobx-react" 156 | 157 | useStaticRendering(true); 158 | ``` 159 | 160 | This makes sure the component won't try to react to any future data changes. 161 | 162 | ### Which components should be marked with `observer`? 163 | 164 | The simple rule of thumb is: _all components that render observable data_. 165 | If you don't want to mark a component as observer, for example to reduce the dependencies of a generic component package, make sure you only pass it plain data. 166 | 167 | ### Enabling decorators (optional) 168 | 169 | Decorators are currently a stage-2 ESNext feature. How to enable them is documented [here](https://github.com/mobxjs/mobx#enabling-decorators-optional). 170 | 171 | ### Should I still use smart and dumb components? 172 | 173 | See this [thread](https://www.reddit.com/r/reactjs/comments/4vnxg5/free_eggheadio_course_learn_mobx_react_in_30/d61oh0l). 174 | TL;DR: the conceptual distinction makes a lot of sense when using MobX as well, but use `observer` on all components. 175 | 176 | ### About `shouldComponentUpdate` 177 | 178 | When using `@observer` on a component, don't implement `shouldComponentUpdate`, as it will override the default implementation that MobX provides. 179 | When using mobx-react, you should in general not need to write an `sCU` (in our entire Mendix code base we have none). If you really need to implement `sCU`, split the component into two, a reactive and non-reactive (with the `sCU`) part, or use `` sections instead of `observer` on the entire component. 180 | 181 | Similarly, `PureComponent` should not be combined with `observer`. As pure components are supposed to be dumb and never update themselves automatically, but only by getting passed in new props from the parent. `observer` is the opposite, it makes components smart and dependency aware, allowing them to update without the parents even needing to be aware of the change. 182 | 183 | ### `componentWillReact` (lifecycle hook) 184 | 185 | React components usually render on a fresh stack, so that makes it often hard to figure out what _caused_ a component to re-render. 186 | When using `mobx-react` you can define a new life cycle hook, `componentWillReact` (pun intended) that will be triggered when a component is scheduled to be re-rendered because 187 | data it observes has changed. This makes it easy to trace renders back to the action that caused the rendering. 188 | 189 | ```javascript 190 | import { observer } from "mobx-react" 191 | 192 | @observer 193 | class TodoView extends React.Component { 194 | componentWillReact() { 195 | console.log("I will re-render, since the todo has changed!") 196 | } 197 | 198 | render() { 199 | return
{this.props.todo.title}
200 | } 201 | } 202 | ``` 203 | 204 | * `componentWillReact` doesn't take arguments 205 | * `componentWillReact` won't fire before the initial render (use `componentDidMount` or `constructor` instead) 206 | 207 | ### `PropTypes` 208 | 209 | MobX-react provides the following additional `PropTypes` which can be used to validate against MobX structures: 210 | 211 | * `observableArray` 212 | * `observableArrayOf(React.PropTypes.number)` 213 | * `observableMap` 214 | * `observableObject` 215 | * `arrayOrObservableArray` 216 | * `arrayOrObservableArrayOf(React.PropTypes.number)` 217 | * `objectOrObservableObject` 218 | 219 | Use `import { PropTypes } from "mobx-react"` to import them, then use for example `PropTypes.observableArray` 220 | 221 | ### `Provider` and `inject` 222 | 223 | `Provider` is a component that can pass stores (or other stuff) using React's context mechanism to child components. 224 | This is useful if you have things that you don't want to pass through multiple layers of components explicitly. 225 | 226 | `inject` can be used to pick up those stores. It is a higher order component that takes a list of strings and makes those stores available to the wrapped component. 227 | 228 | Example (based on the official [context docs](https://facebook.github.io/react/docs/context.html#passing-info-automatically-through-a-tree)): 229 | 230 | ```javascript 231 | @inject("color") 232 | @observer 233 | class Button extends React.Component { 234 | render() { 235 | return 236 | } 237 | } 238 | 239 | class Message extends React.Component { 240 | render() { 241 | return ( 242 |
243 | {this.props.text} 244 |
245 | ) 246 | } 247 | } 248 | 249 | class MessageList extends React.Component { 250 | render() { 251 | const children = this.props.messages.map(message => ) 252 | return ( 253 | 254 |
{children}
255 |
256 | ) 257 | } 258 | } 259 | ``` 260 | 261 | Notes: 262 | 263 | * If a component asks for a store and receives a store via a property with the same name, the property takes precedence. Use this to your advantage when testing! 264 | * If updates to an observable store are not triggering `render()`, make sure you are using Class methods for React lifecycle hooks such as `componentWillMount() {}`, using `componentWillMount = () => {}` will create a property on the instance and cause conflicts with mobx-react. 265 | * Values provided through `Provider` should be final, to avoid issues like mentioned in [React #2517](https://github.com/facebook/react/issues/2517) and [React #3973](https://github.com/facebook/react/pull/3973), where optimizations might stop the propagation of new context. Instead, make sure that if you put things in `context` that might change over time, that they are `@observable` or provide some other means to listen to changes, like callbacks. However, if your stores will change over time, like an observable value of another store, MobX will warn you. To suppress that warning explicitly, you can use `suppressChangedStoreWarning={true}` as a prop at your own risk. 266 | * When using both `@inject` and `@observer`, make sure to apply them in the correct order: `observer` should be the inner decorator, `inject` the outer. There might be additional decorators in between. 267 | * The original component wrapped by `inject` is available as the `wrappedComponent` property of the created higher order component. 268 | * For mounted component instances, the wrapped component instance is available through the `wrappedInstance` property (except for stateless components). 269 | 270 | #### Inject as function 271 | 272 | The above example in ES5 would start like: 273 | 274 | ```javascript 275 | var Button = inject("color")( 276 | observer( 277 | React.createClass({ 278 | /* ... etc ... */ 279 | }) 280 | ) 281 | ) 282 | ``` 283 | 284 | A functional stateless component would look like: 285 | 286 | ```javascript 287 | var Button = inject("color")( 288 | observer(({ color }) => { 289 | /* ... etc ... */ 290 | }) 291 | ) 292 | ``` 293 | 294 | #### Customizing inject 295 | 296 | Instead of passing a list of store names, it is also possible to create a custom mapper function and pass it to inject. 297 | The mapper function receives all stores as argument, the properties with which the components are invoked and the context, and should produce a new set of properties, 298 | that are mapped into the original: 299 | 300 | `mapperFunction: (allStores, props, context) => additionalProps` 301 | 302 | Since version 4.0 the `mapperFunction` itself is tracked as well, so it is possible to do things like: 303 | 304 | ```javascript 305 | const NameDisplayer = ({ name }) =>

{name}

306 | 307 | const UserNameDisplayer = inject(stores => ({ 308 | name: stores.userStore.name 309 | }))(NameDisplayer) 310 | 311 | const user = mobx.observable({ 312 | name: "Noa" 313 | }) 314 | 315 | const App = () => ( 316 | 317 | 318 | 319 | ) 320 | 321 | ReactDOM.render(, document.body) 322 | ``` 323 | 324 | _N.B. note that in this *specific* case neither `NameDisplayer` nor `UserNameDisplayer` needs to be decorated with `observer`, since the observable dereferencing is done in the mapper function_ 325 | 326 | #### Using `propTypes` and `defaultProps` and other static properties in combination with `inject` 327 | 328 | Inject wraps a new component around the component you pass into it. 329 | This means that assigning a static property to the resulting component, will be applied to the HoC, and not to the original component. 330 | So if you take the following example: 331 | 332 | ```javascript 333 | const UserName = inject("userStore")(({ userStore, bold }) => someRendering()) 334 | 335 | UserName.propTypes = { 336 | bold: PropTypes.boolean.isRequired, 337 | userStore: PropTypes.object.isRequired // will always fail 338 | } 339 | ``` 340 | 341 | The above propTypes are incorrect, `bold` needs to be provided by the caller of the `UserName` component and is checked by React. 342 | However, `userStore` does not need to be required! Although it is required for the original stateless function component, it is not 343 | required for the resulting inject component. After all, the whole point of that component is to provide that `userStore` itself. 344 | 345 | So if you want to make assertions on the data that is being injected (either stores or data resulting from a mapper function), the propTypes 346 | should be defined on the _wrapped_ component. Which is available through the static property `wrappedComponent` on the inject component: 347 | 348 | ```javascript 349 | const UserName = inject("userStore")(({ userStore, bold }) => someRendering()) 350 | 351 | UserName.propTypes = { 352 | bold: PropTypes.boolean.isRequired // could be defined either here ... 353 | } 354 | 355 | UserName.wrappedComponent.propTypes = { 356 | // ... or here 357 | userStore: PropTypes.object.isRequired // correct 358 | } 359 | ``` 360 | 361 | The same principle applies to `defaultProps` and other static React properties. 362 | Note that it is not allowed to redefine `contextTypes` on `inject` components (but is possible to define it on `wrappedComponent`) 363 | 364 | Finally, mobx-react will automatically move non React related static properties from wrappedComponent to the inject component so that all static fields are 365 | actually available to the outside world without needing `.wrappedComponent`. 366 | 367 | #### Strongly typing inject 368 | 369 | ##### With TypeScript 370 | 371 | `inject` also accepts a function (`(allStores, nextProps, nextContext) => additionalProps`) that can be used to pick all the desired stores from the available stores like this. 372 | The `additionalProps` will be merged into the original `nextProps` before being provided to the next component. 373 | 374 | ```typescript 375 | import { IUserStore } from "myStore" 376 | 377 | @inject(allStores => ({ 378 | userStore: allStores.userStore as IUserStore 379 | })) 380 | class MyComponent extends React.Component<{ userStore?: IUserStore; otherProp: number }, {}> { 381 | /* etc */ 382 | } 383 | ``` 384 | 385 | Make sure to mark `userStore` as an optional property. It should not (necessarily) be passed in by parent components at all! 386 | 387 | Note: If you have strict null checking enabled, you could muffle the nullable type by using the `!` operator: 388 | 389 | ``` 390 | public render() { 391 | const {a, b} = this.store! 392 | // ... 393 | } 394 | ``` 395 | 396 | ##### With Flow 397 | 398 | Currently, there is a community-discussion around the best way to use `inject` with Flow. Join the discussion at [this gist](https://gist.github.com/vonovak/29c972c6aa9efbb7d63a6853d021fba9). 399 | 400 | #### Testing store injection 401 | 402 | It is allowed to pass any declared store in directly as a property as well. This makes it easy to set up individual component tests without a provider. 403 | 404 | So if you have in your app something like: 405 | 406 | ```javascript 407 | 408 | 409 | 410 | ``` 411 | 412 | In your test you can easily test the `Person` component by passing the necessary store as prop directly: 413 | 414 | ``` 415 | const profile = new Profile() 416 | const mountedComponent = mount( 417 | 418 | ) 419 | ``` 420 | 421 | Bear in mind that using shallow rendering won't provide any useful results when testing injected components; only the injector will be rendered. 422 | To test with shallow rendering, instantiate the `wrappedComponent` instead: `shallow()` 423 | 424 | ### disposeOnUnmount(componentInstance, propertyKey | function | function[]) 425 | 426 | Function (and decorator) that makes sure a function (usually a disposer such as the ones returned by `reaction`, `autorun`, etc.) is automatically executed as part of the componentWillUnmount lifecycle event. 427 | 428 | ```javascript 429 | import { disposeOnUnmount } from "mobx-react" 430 | 431 | class SomeComponent extends React.Component { 432 | // decorator version 433 | @disposeOnUnmount 434 | someReactionDisposer = reaction(...) 435 | 436 | // function version over properties 437 | someReactionDisposer = disposeOnUnmount(this, reaction(...)) 438 | 439 | // function version inside methods 440 | componentDidMount() { 441 | // single function 442 | disposeOnUnmount(this, reaction(...)) 443 | 444 | // or function array 445 | disposeOnUnmount(this, [ 446 | reaction(...), 447 | reaction(...) 448 | ]) 449 | } 450 | } 451 | ``` 452 | 453 | ## FAQ 454 | 455 | **Should I use `observer` for each component?** 456 | 457 | You should use `observer` on every component that displays observable data. 458 | Even the small ones. `observer` allows components to render independently from their parent and in general this means that 459 | the more you use `observer`, the better the performance become. 460 | The overhead of `observer` itself is negligible. 461 | See also [Do child components need `@observer`?](https://github.com/mobxjs/mobx/issues/101) 462 | 463 | **I see React warnings about `forceUpdate` / `setState` from React** 464 | 465 | The following warning will appear if you trigger a re-rendering between instantiating and rendering a component: 466 | 467 | ``` 468 | Warning: forceUpdate(...): Cannot update during an existing state transition (such as within `render`). Render methods should be a pure function of props and state.` 469 | ``` 470 | 471 | -- or -- 472 | 473 | ``` 474 | Warning: setState(...): Cannot update during an existing state transition (such as within `render` or another component's constructor). Render methods should be a pure function of props and state; constructor side-effects are an anti-pattern, but can be moved to `componentWillMount`. 475 | ``` 476 | 477 | Usually this means that (another) component is trying to modify observables used by this components in their `constructor` or `getInitialState` methods. 478 | This violates the React Lifecycle, `componentWillMount` should be used instead if state needs to be modified before mounting. 479 | 480 | ## Internal DevTools Api 481 | 482 | ### trackComponents() 483 | 484 | Enables the tracking from components. Each rendered reactive component will be added to the `componentByNodeRegistery` and its renderings will be reported through the `renderReporter` event emitter. 485 | 486 | ### renderReporter 487 | 488 | Event emitter that reports render timings and component destructions. Only available after invoking `trackComponents()`. 489 | New listeners can be added through `renderReporter.on(function(data) { /* */ })`. 490 | 491 | Data will have one of the following formats: 492 | 493 | ```javascript 494 | { 495 | event: 'render', 496 | renderTime: /* time spend in the .render function of a component, in ms. */, 497 | totalTime: /* time between starting a .render and flushing the changes to the DOM, in ms. */, 498 | component: /* component instance */, 499 | node: /* DOM node */ 500 | } 501 | ``` 502 | 503 | ```javascript 504 | { 505 | event: 'destroy', 506 | component: /* component instance */, 507 | node: /* DOM Node */ 508 | } 509 | ``` 510 | 511 | ### componentByNodeRegistery 512 | 513 | WeakMap. Its `get` function returns the associated reactive component of the given node. The node needs to be precisely the root node of the component. 514 | This map is only available after invoking `trackComponents`. 515 | 516 | ### Debugging reactions with trace 517 | 518 | Using Mobx.trace() inside a React render function will print out the observable that triggered the change. See [the mobx trace docs](https://mobx.js.org/best/trace.html) for more information. 519 | -------------------------------------------------------------------------------- /__mocks__/react-native.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mobxjs/mobx-react/baa737e4faf458e3f4c89edebacfb8774b64353d/__mocks__/react-native.js -------------------------------------------------------------------------------- /batchingForReactDom.js: -------------------------------------------------------------------------------- 1 | require("mobx-react-lite/batchingForReactDom") 2 | -------------------------------------------------------------------------------- /batchingForReactNative.js: -------------------------------------------------------------------------------- 1 | require("mobx-react-lite/batchingForReactNative") 2 | -------------------------------------------------------------------------------- /batchingOptOut.js: -------------------------------------------------------------------------------- 1 | require("mobx-react-lite/batchingOptOut") 2 | -------------------------------------------------------------------------------- /hooks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mobxjs/mobx-react/baa737e4faf458e3f4c89edebacfb8774b64353d/hooks.png -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom/extend-expect" 2 | import {configure} from "mobx"; 3 | 4 | configure({enforceActions: "never"}) 5 | 6 | // @ts-ignore 7 | global.__DEV__ = true 8 | 9 | // Uglyness to find missing 'act' more easily 10 | // 14-2-19 / React 16.8.1, temporarily work around, as error message misses a stack-trace 11 | Error.stackTraceLimit = Infinity 12 | const origError = console.error 13 | console.error = function(msg) { 14 | if (/react-wrap-tests-with-act/.test("" + msg)) throw new Error("missing act") 15 | return origError.apply(this, arguments as any) 16 | } 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mobx-react", 3 | "version": "6.3.1", 4 | "description": "React bindings for MobX. Create fully reactive components.", 5 | "source": "src/index.ts", 6 | "main": "dist/index.js", 7 | "umd:main": "dist/mobxreact.umd.production.min.js", 8 | "unpkg": "dist/mobxreact.umd.production.min.js", 9 | "jsnext:main": "dist/mobxreact.esm.js", 10 | "module": "dist/mobxreact.esm.js", 11 | "react-native": "dist/mobxreact.esm.js", 12 | "types": "dist/index.d.ts", 13 | "sideEffects": [ 14 | "batching*" 15 | ], 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/mobxjs/mobx-react.git" 19 | }, 20 | "scripts": { 21 | "test": "jest", 22 | "watch": "jest --watch", 23 | "lint": "eslint .", 24 | "test:types": "yarn tsc --noEmit", 25 | "test:check": "yarn test:types && yarn lint", 26 | "test:ts": "tsc -p test", 27 | "test:coverage": "jest -i --coverage", 28 | "test:size": "size-limit", 29 | "prettier": "prettier --write \"**/*.js\" \"**/*.ts\" \"**/*.tsx\"", 30 | "release": "yarn build && yarn changeset publish", 31 | "build": "tsdx build --name mobxReact --format cjs,esm,umd", 32 | "postbuild": "yarn v6compat", 33 | "v6compat": "shx cp dist/mobxreact.umd.production.min.js dist/mobx-react.umd.js" 34 | }, 35 | "author": "Michel Weststrate", 36 | "license": "MIT", 37 | "bugs": { 38 | "url": "https://github.com/mobxjs/mobx/issues" 39 | }, 40 | "homepage": "https://mobxjs.github.io/mobx", 41 | "resolutions": { 42 | "@types/yargs": "12.0.1" 43 | }, 44 | "peerDependencies": { 45 | "mobx": "^6.0.0", 46 | "react": "^16.8.0 || 16.9.0-alpha.0" 47 | }, 48 | "devDependencies": { 49 | "@babel/core": "^7.1.0", 50 | "@babel/plugin-proposal-class-properties": "^7.1.0", 51 | "@babel/plugin-proposal-decorators": "^7.1.0", 52 | "@babel/plugin-transform-react-jsx": "^7.0.0", 53 | "@babel/preset-env": "^7.1.0", 54 | "@changesets/changelog-github": "^0.2.7", 55 | "@changesets/cli": "^2.11.0", 56 | "@testing-library/jest-dom": "^5.1.1", 57 | "@testing-library/react": "^9.4.0", 58 | "@types/create-react-class": "^15.6.0", 59 | "@types/jest": "^25.1.1", 60 | "@types/node": "^10.0.0", 61 | "@types/prop-types": "^15.5.2", 62 | "@types/react": "^16.0.13", 63 | "@types/react-dom": "^16.0.1", 64 | "@typescript-eslint/eslint-plugin": "^2.12.0", 65 | "@typescript-eslint/parser": "^2.12.0", 66 | "babel-eslint": "^10.0.2", 67 | "babel-jest": "^25.1.0", 68 | "coveralls": "^3.0.3", 69 | "eslint": "^6.1.0", 70 | "eslint-config-prettier": "^6.0.0", 71 | "eslint-plugin-prettier": "^3.1.0", 72 | "eslint-plugin-react": "^7.14.3", 73 | "husky": "^1.0.0", 74 | "jest": "^25.1.0", 75 | "jest-environment-jsdom": "^25.1.0", 76 | "jest-mock-console": "^1.0.0", 77 | "lint-staged": "^7.0.5", 78 | "lodash": "^4.17.4", 79 | "mobx": "^6.0.0", 80 | "prettier": "^1.7.2", 81 | "prop-types": "^15.7.2", 82 | "react": "^16.9.0", 83 | "react-dom": "^16.9.0", 84 | "replace": "^1.1.0", 85 | "request": "^2.83.0", 86 | "shelljs": "^0.8.3", 87 | "shx": "^0.3.2", 88 | "size-limit": "^1.3.2", 89 | "ts-jest": "^25.2.0", 90 | "tsdx": "0.12.3", 91 | "tslib": "1.10.0", 92 | "typescript": "^3.7.0" 93 | }, 94 | "dependencies": { 95 | "mobx-react-lite": "^3.0.0" 96 | }, 97 | "files": [ 98 | "dist", 99 | "batching*" 100 | ], 101 | "keywords": [ 102 | "mobx", 103 | "mobservable", 104 | "react-component", 105 | "react", 106 | "reactjs", 107 | "reactive" 108 | ], 109 | "lint-staged": { 110 | "*.{ts,tsx,js}": [ 111 | "prettier --write", 112 | "eslint --fix", 113 | "git add" 114 | ] 115 | }, 116 | "jest": { 117 | "globals": { 118 | "ts-jest": { 119 | "tsConfig": "tsconfig.test.json" 120 | } 121 | }, 122 | "transform": { 123 | "^.+\\.tsx?$": "ts-jest", 124 | "^.+\\.jsx?$": "babel-jest" 125 | }, 126 | "moduleFileExtensions": [ 127 | "ts", 128 | "tsx", 129 | "js", 130 | "jsx", 131 | "json" 132 | ], 133 | "testPathIgnorePatterns": [ 134 | "/node_modules/", 135 | "/\\./" 136 | ], 137 | "watchPathIgnorePatterns": [ 138 | "/node_modules/" 139 | ], 140 | "setupFilesAfterEnv": [ 141 | "/jest.setup.ts" 142 | ], 143 | "testURL": "http://127.0.0.1/" 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/Provider.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { shallowEqual } from "./utils/utils" 3 | import { IValueMap } from "./types/IValueMap" 4 | 5 | export const MobXProviderContext = React.createContext({}) 6 | 7 | export interface ProviderProps extends IValueMap { 8 | children: React.ReactNode 9 | } 10 | 11 | export function Provider(props: ProviderProps) { 12 | const { children, ...stores } = props 13 | const parentValue = React.useContext(MobXProviderContext) 14 | const mutableProviderRef = React.useRef({ ...parentValue, ...stores }) 15 | const value = mutableProviderRef.current 16 | 17 | if (__DEV__) { 18 | const newValue = { ...value, ...stores } // spread in previous state for the context based stores 19 | if (!shallowEqual(value, newValue)) { 20 | throw new Error( 21 | "MobX Provider: The set of provided stores has changed. See: https://github.com/mobxjs/mobx-react#the-set-of-provided-stores-has-changed-error." 22 | ) 23 | } 24 | } 25 | 26 | return {children} 27 | } 28 | 29 | Provider.displayName = "MobXProvider" 30 | -------------------------------------------------------------------------------- /src/disposeOnUnmount.ts: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { patch, newSymbol } from "./utils/utils" 3 | 4 | type Disposer = () => void 5 | 6 | const protoStoreKey = newSymbol("disposeOnUnmountProto") 7 | const instStoreKey = newSymbol("disposeOnUnmountInst") 8 | 9 | function runDisposersOnWillUnmount() { 10 | ;[...(this[protoStoreKey] || []), ...(this[instStoreKey] || [])].forEach(propKeyOrFunction => { 11 | const prop = 12 | typeof propKeyOrFunction === "string" ? this[propKeyOrFunction] : propKeyOrFunction 13 | if (prop !== undefined && prop !== null) { 14 | if (Array.isArray(prop)) prop.map(f => f()) 15 | else prop() 16 | } 17 | }) 18 | } 19 | 20 | export function disposeOnUnmount(target: React.Component, propertyKey: PropertyKey): void 21 | export function disposeOnUnmount>( 22 | target: React.Component, 23 | fn: TF 24 | ): TF 25 | 26 | export function disposeOnUnmount( 27 | target: React.Component, 28 | propertyKeyOrFunction: PropertyKey | Disposer | Array 29 | ): PropertyKey | Disposer | Array | void { 30 | if (Array.isArray(propertyKeyOrFunction)) { 31 | return propertyKeyOrFunction.map(fn => disposeOnUnmount(target, fn)) 32 | } 33 | 34 | const c = Object.getPrototypeOf(target).constructor 35 | const c2 = Object.getPrototypeOf(target.constructor) 36 | // Special case for react-hot-loader 37 | const c3 = Object.getPrototypeOf(Object.getPrototypeOf(target)) 38 | if ( 39 | !( 40 | c === React.Component || 41 | c === React.PureComponent || 42 | c2 === React.Component || 43 | c2 === React.PureComponent || 44 | c3 === React.Component || 45 | c3 === React.PureComponent 46 | ) 47 | ) { 48 | throw new Error( 49 | "[mobx-react] disposeOnUnmount only supports direct subclasses of React.Component or React.PureComponent." 50 | ) 51 | } 52 | 53 | if ( 54 | typeof propertyKeyOrFunction !== "string" && 55 | typeof propertyKeyOrFunction !== "function" && 56 | !Array.isArray(propertyKeyOrFunction) 57 | ) { 58 | throw new Error( 59 | "[mobx-react] disposeOnUnmount only works if the parameter is either a property key or a function." 60 | ) 61 | } 62 | 63 | // decorator's target is the prototype, so it doesn't have any instance properties like props 64 | const isDecorator = typeof propertyKeyOrFunction === "string" 65 | 66 | // add property key / function we want run (disposed) to the store 67 | const componentWasAlreadyModified = !!target[protoStoreKey] || !!target[instStoreKey] 68 | const store = isDecorator 69 | ? // decorators are added to the prototype store 70 | target[protoStoreKey] || (target[protoStoreKey] = []) 71 | : // functions are added to the instance store 72 | target[instStoreKey] || (target[instStoreKey] = []) 73 | 74 | store.push(propertyKeyOrFunction) 75 | 76 | // tweak the component class componentWillUnmount if not done already 77 | if (!componentWasAlreadyModified) { 78 | patch(target, "componentWillUnmount", runDisposersOnWillUnmount) 79 | } 80 | 81 | // return the disposer as is if invoked as a non decorator 82 | if (typeof propertyKeyOrFunction !== "string") { 83 | return propertyKeyOrFunction 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare const __DEV__: boolean 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { observable } from "mobx" 2 | import { Component } from "react" 3 | 4 | if (!Component) throw new Error("mobx-react requires React to be available") 5 | if (!observable) throw new Error("mobx-react requires mobx to be available") 6 | 7 | export { 8 | Observer, 9 | useObserver, 10 | useAsObservableSource, 11 | useLocalStore, 12 | isUsingStaticRendering, 13 | useStaticRendering, 14 | enableStaticRendering, 15 | observerBatching, 16 | useLocalObservable 17 | } from "mobx-react-lite" 18 | 19 | export { observer } from "./observer" 20 | 21 | export { MobXProviderContext, Provider, ProviderProps } from "./Provider" 22 | export { inject } from "./inject" 23 | export { disposeOnUnmount } from "./disposeOnUnmount" 24 | export { PropTypes } from "./propTypes" 25 | export { IWrappedComponent } from "./types/IWrappedComponent" 26 | -------------------------------------------------------------------------------- /src/inject.ts: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { observer } from "./observer" 3 | import { copyStaticProperties } from "./utils/utils" 4 | import { MobXProviderContext } from "./Provider" 5 | import { IReactComponent } from "./types/IReactComponent" 6 | import { IValueMap } from "./types/IValueMap" 7 | import { IWrappedComponent } from "./types/IWrappedComponent" 8 | import { IStoresToProps } from "./types/IStoresToProps" 9 | 10 | /** 11 | * Store Injection 12 | */ 13 | function createStoreInjector( 14 | grabStoresFn: IStoresToProps, 15 | component: IReactComponent, 16 | injectNames: string, 17 | makeReactive: boolean 18 | ): IReactComponent { 19 | // Support forward refs 20 | let Injector: IReactComponent = React.forwardRef((props, ref) => { 21 | const newProps = { ...props } 22 | const context = React.useContext(MobXProviderContext) 23 | Object.assign(newProps, grabStoresFn(context || {}, newProps) || {}) 24 | 25 | if (ref) { 26 | newProps.ref = ref 27 | } 28 | 29 | return React.createElement(component, newProps) 30 | }) 31 | 32 | if (makeReactive) Injector = observer(Injector) 33 | Injector["isMobxInjector"] = true // assigned late to suppress observer warning 34 | 35 | // Static fields from component should be visible on the generated Injector 36 | copyStaticProperties(component, Injector) 37 | Injector["wrappedComponent"] = component 38 | Injector.displayName = getInjectName(component, injectNames) 39 | return Injector 40 | } 41 | 42 | function getInjectName(component: IReactComponent, injectNames: string): string { 43 | let displayName 44 | const componentName = 45 | component.displayName || 46 | component.name || 47 | (component.constructor && component.constructor.name) || 48 | "Component" 49 | if (injectNames) displayName = "inject-with-" + injectNames + "(" + componentName + ")" 50 | else displayName = "inject(" + componentName + ")" 51 | return displayName 52 | } 53 | 54 | function grabStoresByName( 55 | storeNames: Array 56 | ): (baseStores: IValueMap, nextProps: React.Props) => React.PropsWithRef | undefined { 57 | return function(baseStores, nextProps) { 58 | storeNames.forEach(function(storeName) { 59 | if ( 60 | storeName in nextProps // prefer props over stores 61 | ) 62 | return 63 | if (!(storeName in baseStores)) 64 | throw new Error( 65 | "MobX injector: Store '" + 66 | storeName + 67 | "' is not available! Make sure it is provided by some Provider" 68 | ) 69 | nextProps[storeName] = baseStores[storeName] 70 | }) 71 | return nextProps 72 | } 73 | } 74 | 75 | export function inject( 76 | ...stores: Array 77 | ): >( 78 | target: T 79 | ) => T & (T extends IReactComponent ? IWrappedComponent

: never) 80 | export function inject( 81 | fn: IStoresToProps 82 | ): (target: T) => T & IWrappedComponent

83 | 84 | /** 85 | * higher order component that injects stores to a child. 86 | * takes either a varargs list of strings, which are stores read from the context, 87 | * or a function that manually maps the available stores from the context to props: 88 | * storesToProps(mobxStores, props, context) => newProps 89 | */ 90 | export function inject(/* fn(stores, nextProps) or ...storeNames */ ...storeNames: Array) { 91 | if (typeof arguments[0] === "function") { 92 | let grabStoresFn = arguments[0] 93 | return (componentClass: React.ComponentClass) => 94 | createStoreInjector(grabStoresFn, componentClass, grabStoresFn.name, true) 95 | } else { 96 | return (componentClass: React.ComponentClass) => 97 | createStoreInjector( 98 | grabStoresByName(storeNames), 99 | componentClass, 100 | storeNames.join("-"), 101 | false 102 | ) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/observer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { observer as observerLite, Observer } from "mobx-react-lite" 3 | 4 | import { makeClassComponentObserver } from "./observerClass" 5 | import { IReactComponent } from "./types/IReactComponent" 6 | 7 | const hasSymbol = typeof Symbol === "function" && Symbol.for 8 | 9 | // Using react-is had some issues (and operates on elements, not on types), see #608 / #609 10 | const ReactForwardRefSymbol = hasSymbol 11 | ? Symbol.for("react.forward_ref") 12 | : typeof React.forwardRef === "function" && React.forwardRef((props: any) => null)["$$typeof"] 13 | 14 | const ReactMemoSymbol = hasSymbol 15 | ? Symbol.for("react.memo") 16 | : typeof React.memo === "function" && React.memo((props: any) => null)["$$typeof"] 17 | 18 | /** 19 | * Observer function / decorator 20 | */ 21 | export function observer(component: T): T { 22 | if (component["isMobxInjector"] === true) { 23 | console.warn( 24 | "Mobx observer: You are trying to use 'observer' on a component that already has 'inject'. Please apply 'observer' before applying 'inject'" 25 | ) 26 | } 27 | 28 | if (ReactMemoSymbol && component["$$typeof"] === ReactMemoSymbol) { 29 | throw new Error( 30 | "Mobx observer: You are trying to use 'observer' on a function component wrapped in either another observer or 'React.memo'. The observer already applies 'React.memo' for you." 31 | ) 32 | } 33 | 34 | // Unwrap forward refs into `` component 35 | // we need to unwrap the render, because it is the inner render that needs to be tracked, 36 | // not the ForwardRef HoC 37 | if (ReactForwardRefSymbol && component["$$typeof"] === ReactForwardRefSymbol) { 38 | const baseRender = component["render"] 39 | if (typeof baseRender !== "function") 40 | throw new Error("render property of ForwardRef was not a function") 41 | return React.forwardRef(function ObserverForwardRef() { 42 | const args = arguments 43 | return {() => baseRender.apply(undefined, args)} 44 | }) as T 45 | } 46 | 47 | // Function component 48 | if ( 49 | typeof component === "function" && 50 | (!component.prototype || !component.prototype.render) && 51 | !component["isReactClass"] && 52 | !Object.prototype.isPrototypeOf.call(React.Component, component) 53 | ) { 54 | return observerLite(component as React.StatelessComponent) as T 55 | } 56 | 57 | return makeClassComponentObserver(component as React.ComponentClass) as T 58 | } 59 | -------------------------------------------------------------------------------- /src/observerClass.ts: -------------------------------------------------------------------------------- 1 | import { PureComponent, Component } from "react" 2 | import { 3 | createAtom, 4 | _allowStateChanges, 5 | Reaction, 6 | $mobx, 7 | _allowStateReadsStart, 8 | _allowStateReadsEnd 9 | } from "mobx" 10 | import { isUsingStaticRendering } from "mobx-react-lite" 11 | 12 | import { newSymbol, shallowEqual, setHiddenProp, patch } from "./utils/utils" 13 | 14 | const mobxAdminProperty = $mobx || "$mobx" 15 | const mobxObserverProperty = newSymbol("isMobXReactObserver") 16 | const mobxIsUnmounted = newSymbol("isUnmounted") 17 | const skipRenderKey = newSymbol("skipRender") 18 | const isForcingUpdateKey = newSymbol("isForcingUpdate") 19 | 20 | export function makeClassComponentObserver( 21 | componentClass: React.ComponentClass 22 | ): React.ComponentClass { 23 | const target = componentClass.prototype 24 | 25 | if (componentClass[mobxObserverProperty]) { 26 | const displayName = getDisplayName(target) 27 | console.warn( 28 | `The provided component class (${displayName}) 29 | has already been declared as an observer component.` 30 | ) 31 | } else { 32 | componentClass[mobxObserverProperty] = true 33 | } 34 | 35 | if (target.componentWillReact) 36 | throw new Error("The componentWillReact life-cycle event is no longer supported") 37 | if (componentClass["__proto__"] !== PureComponent) { 38 | if (!target.shouldComponentUpdate) target.shouldComponentUpdate = observerSCU 39 | else if (target.shouldComponentUpdate !== observerSCU) 40 | // n.b. unequal check, instead of existence check, as @observer might be on superclass as well 41 | throw new Error( 42 | "It is not allowed to use shouldComponentUpdate in observer based components." 43 | ) 44 | } 45 | 46 | // this.props and this.state are made observable, just to make sure @computed fields that 47 | // are defined inside the component, and which rely on state or props, re-compute if state or props change 48 | // (otherwise the computed wouldn't update and become stale on props change, since props are not observable) 49 | // However, this solution is not without it's own problems: https://github.com/mobxjs/mobx-react/issues?utf8=%E2%9C%93&q=is%3Aissue+label%3Aobservable-props-or-not+ 50 | makeObservableProp(target, "props") 51 | makeObservableProp(target, "state") 52 | 53 | const baseRender = target.render 54 | target.render = function() { 55 | return makeComponentReactive.call(this, baseRender) 56 | } 57 | patch(target, "componentWillUnmount", function() { 58 | if (isUsingStaticRendering() === true) return 59 | this.render[mobxAdminProperty]?.dispose() 60 | this[mobxIsUnmounted] = true 61 | 62 | if (!this.render[mobxAdminProperty]) { 63 | // Render may have been hot-swapped and/or overriden by a subclass. 64 | const displayName = getDisplayName(this) 65 | console.warn( 66 | `The reactive render of an observer class component (${displayName}) 67 | was overriden after MobX attached. This may result in a memory leak if the 68 | overriden reactive render was not properly disposed.` 69 | ) 70 | } 71 | }) 72 | return componentClass 73 | } 74 | 75 | // Generates a friendly name for debugging 76 | function getDisplayName(comp: any) { 77 | return ( 78 | comp.displayName || 79 | comp.name || 80 | (comp.constructor && (comp.constructor.displayName || comp.constructor.name)) || 81 | "" 82 | ) 83 | } 84 | 85 | function makeComponentReactive(render: any) { 86 | if (isUsingStaticRendering() === true) return render.call(this) 87 | 88 | /** 89 | * If props are shallowly modified, react will render anyway, 90 | * so atom.reportChanged() should not result in yet another re-render 91 | */ 92 | setHiddenProp(this, skipRenderKey, false) 93 | /** 94 | * forceUpdate will re-assign this.props. We don't want that to cause a loop, 95 | * so detect these changes 96 | */ 97 | setHiddenProp(this, isForcingUpdateKey, false) 98 | 99 | const initialName = getDisplayName(this) 100 | const baseRender = render.bind(this) 101 | 102 | let isRenderingPending = false 103 | 104 | const reaction = new Reaction(`${initialName}.render()`, () => { 105 | if (!isRenderingPending) { 106 | // N.B. Getting here *before mounting* means that a component constructor has side effects (see the relevant test in misc.js) 107 | // This unidiomatic React usage but React will correctly warn about this so we continue as usual 108 | // See #85 / Pull #44 109 | isRenderingPending = true 110 | if (this[mobxIsUnmounted] !== true) { 111 | let hasError = true 112 | try { 113 | setHiddenProp(this, isForcingUpdateKey, true) 114 | if (!this[skipRenderKey]) Component.prototype.forceUpdate.call(this) 115 | hasError = false 116 | } finally { 117 | setHiddenProp(this, isForcingUpdateKey, false) 118 | if (hasError) reaction.dispose() 119 | } 120 | } 121 | } 122 | }) 123 | 124 | reaction["reactComponent"] = this 125 | reactiveRender[mobxAdminProperty] = reaction 126 | this.render = reactiveRender 127 | 128 | function reactiveRender() { 129 | isRenderingPending = false 130 | let exception = undefined 131 | let rendering = undefined 132 | reaction.track(() => { 133 | try { 134 | rendering = _allowStateChanges(false, baseRender) 135 | } catch (e) { 136 | exception = e 137 | } 138 | }) 139 | if (exception) { 140 | throw exception 141 | } 142 | return rendering 143 | } 144 | 145 | return reactiveRender.call(this) 146 | } 147 | 148 | function observerSCU(nextProps: React.Props, nextState: any): boolean { 149 | if (isUsingStaticRendering()) { 150 | console.warn( 151 | "[mobx-react] It seems that a re-rendering of a React component is triggered while in static (server-side) mode. Please make sure components are rendered only once server-side." 152 | ) 153 | } 154 | // update on any state changes (as is the default) 155 | if (this.state !== nextState) { 156 | return true 157 | } 158 | // update if props are shallowly not equal, inspired by PureRenderMixin 159 | // we could return just 'false' here, and avoid the `skipRender` checks etc 160 | // however, it is nicer if lifecycle events are triggered like usually, 161 | // so we return true here if props are shallowly modified. 162 | return !shallowEqual(this.props, nextProps) 163 | } 164 | 165 | function makeObservableProp(target: any, propName: string): void { 166 | const valueHolderKey = newSymbol(`reactProp_${propName}_valueHolder`) 167 | const atomHolderKey = newSymbol(`reactProp_${propName}_atomHolder`) 168 | function getAtom() { 169 | if (!this[atomHolderKey]) { 170 | setHiddenProp(this, atomHolderKey, createAtom("reactive " + propName)) 171 | } 172 | return this[atomHolderKey] 173 | } 174 | Object.defineProperty(target, propName, { 175 | configurable: true, 176 | enumerable: true, 177 | get: function() { 178 | let prevReadState = false 179 | 180 | if (_allowStateReadsStart && _allowStateReadsEnd) { 181 | prevReadState = _allowStateReadsStart(true) 182 | } 183 | getAtom.call(this).reportObserved() 184 | 185 | if (_allowStateReadsStart && _allowStateReadsEnd) { 186 | _allowStateReadsEnd(prevReadState) 187 | } 188 | 189 | return this[valueHolderKey] 190 | }, 191 | set: function set(v) { 192 | if (!this[isForcingUpdateKey] && !shallowEqual(this[valueHolderKey], v)) { 193 | setHiddenProp(this, valueHolderKey, v) 194 | setHiddenProp(this, skipRenderKey, true) 195 | getAtom.call(this).reportChanged() 196 | setHiddenProp(this, skipRenderKey, false) 197 | } else { 198 | setHiddenProp(this, valueHolderKey, v) 199 | } 200 | } 201 | }) 202 | } 203 | -------------------------------------------------------------------------------- /src/propTypes.ts: -------------------------------------------------------------------------------- 1 | import { isObservableArray, isObservableObject, isObservableMap, untracked } from "mobx" 2 | 3 | // Copied from React.PropTypes 4 | function createChainableTypeChecker(validator: React.Validator): React.Requireable { 5 | function checkType( 6 | isRequired: boolean, 7 | props: any, 8 | propName: string, 9 | componentName: string, 10 | location: string, 11 | propFullName: string, 12 | ...rest: any[] 13 | ) { 14 | return untracked(() => { 15 | componentName = componentName || "<>" 16 | propFullName = propFullName || propName 17 | if (props[propName] == null) { 18 | if (isRequired) { 19 | const actual = props[propName] === null ? "null" : "undefined" 20 | return new Error( 21 | "The " + 22 | location + 23 | " `" + 24 | propFullName + 25 | "` is marked as required " + 26 | "in `" + 27 | componentName + 28 | "`, but its value is `" + 29 | actual + 30 | "`." 31 | ) 32 | } 33 | return null 34 | } else { 35 | // @ts-ignore rest arg is necessary for some React internals - fails tests otherwise 36 | return validator(props, propName, componentName, location, propFullName, ...rest) 37 | } 38 | }) 39 | } 40 | 41 | const chainedCheckType: any = checkType.bind(null, false) 42 | // Add isRequired to satisfy Requirable 43 | chainedCheckType.isRequired = checkType.bind(null, true) 44 | return chainedCheckType 45 | } 46 | 47 | // Copied from React.PropTypes 48 | function isSymbol(propType: any, propValue: any): boolean { 49 | // Native Symbol. 50 | if (propType === "symbol") { 51 | return true 52 | } 53 | 54 | // 19.4.3.5 Symbol.prototype[@@toStringTag] === 'Symbol' 55 | if (propValue["@@toStringTag"] === "Symbol") { 56 | return true 57 | } 58 | 59 | // Fallback for non-spec compliant Symbols which are polyfilled. 60 | if (typeof Symbol === "function" && propValue instanceof Symbol) { 61 | return true 62 | } 63 | 64 | return false 65 | } 66 | 67 | // Copied from React.PropTypes 68 | function getPropType(propValue: any): string { 69 | const propType = typeof propValue 70 | if (Array.isArray(propValue)) { 71 | return "array" 72 | } 73 | if (propValue instanceof RegExp) { 74 | // Old webkits (at least until Android 4.0) return 'function' rather than 75 | // 'object' for typeof a RegExp. We'll normalize this here so that /bla/ 76 | // passes PropTypes.object. 77 | return "object" 78 | } 79 | if (isSymbol(propType, propValue)) { 80 | return "symbol" 81 | } 82 | return propType 83 | } 84 | 85 | // This handles more types than `getPropType`. Only used for error messages. 86 | // Copied from React.PropTypes 87 | function getPreciseType(propValue: any): string { 88 | const propType = getPropType(propValue) 89 | if (propType === "object") { 90 | if (propValue instanceof Date) { 91 | return "date" 92 | } else if (propValue instanceof RegExp) { 93 | return "regexp" 94 | } 95 | } 96 | return propType 97 | } 98 | 99 | function createObservableTypeCheckerCreator( 100 | allowNativeType: any, 101 | mobxType: any 102 | ): React.Requireable { 103 | return createChainableTypeChecker((props, propName, componentName, location, propFullName) => { 104 | return untracked(() => { 105 | if (allowNativeType) { 106 | if (getPropType(props[propName]) === mobxType.toLowerCase()) return null 107 | } 108 | let mobxChecker 109 | switch (mobxType) { 110 | case "Array": 111 | mobxChecker = isObservableArray 112 | break 113 | case "Object": 114 | mobxChecker = isObservableObject 115 | break 116 | case "Map": 117 | mobxChecker = isObservableMap 118 | break 119 | default: 120 | throw new Error(`Unexpected mobxType: ${mobxType}`) 121 | } 122 | const propValue = props[propName] 123 | if (!mobxChecker(propValue)) { 124 | const preciseType = getPreciseType(propValue) 125 | const nativeTypeExpectationMessage = allowNativeType 126 | ? " or javascript `" + mobxType.toLowerCase() + "`" 127 | : "" 128 | return new Error( 129 | "Invalid prop `" + 130 | propFullName + 131 | "` of type `" + 132 | preciseType + 133 | "` supplied to" + 134 | " `" + 135 | componentName + 136 | "`, expected `mobx.Observable" + 137 | mobxType + 138 | "`" + 139 | nativeTypeExpectationMessage + 140 | "." 141 | ) 142 | } 143 | return null 144 | }) 145 | }) 146 | } 147 | 148 | function createObservableArrayOfTypeChecker( 149 | allowNativeType: boolean, 150 | typeChecker: React.Validator 151 | ) { 152 | return createChainableTypeChecker( 153 | (props, propName, componentName, location, propFullName, ...rest) => { 154 | return untracked(() => { 155 | if (typeof typeChecker !== "function") { 156 | return new Error( 157 | "Property `" + 158 | propFullName + 159 | "` of component `" + 160 | componentName + 161 | "` has " + 162 | "invalid PropType notation." 163 | ) 164 | } else { 165 | let error = createObservableTypeCheckerCreator(allowNativeType, "Array")( 166 | props, 167 | propName, 168 | componentName, 169 | location, 170 | propFullName 171 | ) 172 | 173 | if (error instanceof Error) return error 174 | const propValue = props[propName] 175 | for (let i = 0; i < propValue.length; i++) { 176 | error = (typeChecker as React.Validator)( 177 | propValue, 178 | i as any, 179 | componentName, 180 | location, 181 | propFullName + "[" + i + "]", 182 | ...rest 183 | ) 184 | if (error instanceof Error) return error 185 | } 186 | 187 | return null 188 | } 189 | }) 190 | } 191 | ) 192 | } 193 | 194 | const observableArray = createObservableTypeCheckerCreator(false, "Array") 195 | const observableArrayOf = createObservableArrayOfTypeChecker.bind(null, false) 196 | const observableMap = createObservableTypeCheckerCreator(false, "Map") 197 | const observableObject = createObservableTypeCheckerCreator(false, "Object") 198 | const arrayOrObservableArray = createObservableTypeCheckerCreator(true, "Array") 199 | const arrayOrObservableArrayOf = createObservableArrayOfTypeChecker.bind(null, true) 200 | const objectOrObservableObject = createObservableTypeCheckerCreator(true, "Object") 201 | 202 | export const PropTypes = { 203 | observableArray, 204 | observableArrayOf, 205 | observableMap, 206 | observableObject, 207 | arrayOrObservableArray, 208 | arrayOrObservableArrayOf, 209 | objectOrObservableObject 210 | } 211 | -------------------------------------------------------------------------------- /src/types/IReactComponent.ts: -------------------------------------------------------------------------------- 1 | export type IReactComponent

= 2 | | React.ClassicComponentClass

3 | | React.ComponentClass

4 | | React.FunctionComponent

5 | | React.ForwardRefExoticComponent

6 | -------------------------------------------------------------------------------- /src/types/IStoresToProps.ts: -------------------------------------------------------------------------------- 1 | import { IValueMap } from "./IValueMap" 2 | export type IStoresToProps< 3 | S extends IValueMap = {}, 4 | P extends IValueMap = {}, 5 | I extends IValueMap = {}, 6 | C extends IValueMap = {} 7 | > = (stores: S, nextProps: P, context?: C) => I 8 | -------------------------------------------------------------------------------- /src/types/IValueMap.ts: -------------------------------------------------------------------------------- 1 | export type IValueMap = Record 2 | -------------------------------------------------------------------------------- /src/types/IWrappedComponent.ts: -------------------------------------------------------------------------------- 1 | import { IReactComponent } from "./IReactComponent" 2 | export type IWrappedComponent

= { 3 | wrappedComponent: IReactComponent

4 | } 5 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | let symbolId = 0 2 | function createSymbol(name: string): symbol | string { 3 | if (typeof Symbol === "function") { 4 | return Symbol(name) 5 | } 6 | const symbol = `__$mobx-react ${name} (${symbolId})` 7 | symbolId++ 8 | return symbol 9 | } 10 | 11 | const createdSymbols = {} 12 | export function newSymbol(name: string): symbol | string { 13 | if (!createdSymbols[name]) { 14 | createdSymbols[name] = createSymbol(name) 15 | } 16 | return createdSymbols[name] 17 | } 18 | 19 | export function shallowEqual(objA: any, objB: any): boolean { 20 | //From: https://github.com/facebook/fbjs/blob/c69904a511b900266935168223063dd8772dfc40/packages/fbjs/src/core/shallowEqual.js 21 | if (is(objA, objB)) return true 22 | if (typeof objA !== "object" || objA === null || typeof objB !== "object" || objB === null) { 23 | return false 24 | } 25 | const keysA = Object.keys(objA) 26 | const keysB = Object.keys(objB) 27 | if (keysA.length !== keysB.length) return false 28 | for (let i = 0; i < keysA.length; i++) { 29 | if (!Object.hasOwnProperty.call(objB, keysA[i]) || !is(objA[keysA[i]], objB[keysA[i]])) { 30 | return false 31 | } 32 | } 33 | return true 34 | } 35 | 36 | function is(x: any, y: any): boolean { 37 | // From: https://github.com/facebook/fbjs/blob/c69904a511b900266935168223063dd8772dfc40/packages/fbjs/src/core/shallowEqual.js 38 | if (x === y) { 39 | return x !== 0 || 1 / x === 1 / y 40 | } else { 41 | return x !== x && y !== y 42 | } 43 | } 44 | 45 | // based on https://github.com/mridgway/hoist-non-react-statics/blob/master/src/index.js 46 | const hoistBlackList = { 47 | $$typeof: 1, 48 | render: 1, 49 | compare: 1, 50 | type: 1, 51 | childContextTypes: 1, 52 | contextType: 1, 53 | contextTypes: 1, 54 | defaultProps: 1, 55 | getDefaultProps: 1, 56 | getDerivedStateFromError: 1, 57 | getDerivedStateFromProps: 1, 58 | mixins: 1, 59 | propTypes: 1 60 | } 61 | 62 | export function copyStaticProperties(base: object, target: object): void { 63 | const protoProps = Object.getOwnPropertyNames(Object.getPrototypeOf(base)) 64 | Object.getOwnPropertyNames(base).forEach(key => { 65 | if (!hoistBlackList[key] && protoProps.indexOf(key) === -1) { 66 | Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(base, key)!) 67 | } 68 | }) 69 | } 70 | 71 | /** 72 | * Helper to set `prop` to `this` as non-enumerable (hidden prop) 73 | * @param target 74 | * @param prop 75 | * @param value 76 | */ 77 | export function setHiddenProp(target: object, prop: any, value: any): void { 78 | if (!Object.hasOwnProperty.call(target, prop)) { 79 | Object.defineProperty(target, prop, { 80 | enumerable: false, 81 | configurable: true, 82 | writable: true, 83 | value 84 | }) 85 | } else { 86 | target[prop] = value 87 | } 88 | } 89 | 90 | /** 91 | * Utilities for patching componentWillUnmount, to make sure @disposeOnUnmount works correctly icm with user defined hooks 92 | * and the handler provided by mobx-react 93 | */ 94 | const mobxMixins = newSymbol("patchMixins") 95 | const mobxPatchedDefinition = newSymbol("patchedDefinition") 96 | 97 | export interface Mixins extends Record { 98 | locks: number 99 | methods: Array 100 | } 101 | 102 | function getMixins(target: object, methodName: string): Mixins { 103 | const mixins = (target[mobxMixins] = target[mobxMixins] || {}) 104 | const methodMixins = (mixins[methodName] = mixins[methodName] || {}) 105 | methodMixins.locks = methodMixins.locks || 0 106 | methodMixins.methods = methodMixins.methods || [] 107 | return methodMixins 108 | } 109 | 110 | function wrapper(realMethod: Function, mixins: Mixins, ...args: Array) { 111 | // locks are used to ensure that mixins are invoked only once per invocation, even on recursive calls 112 | mixins.locks++ 113 | 114 | try { 115 | let retVal 116 | if (realMethod !== undefined && realMethod !== null) { 117 | retVal = realMethod.apply(this, args) 118 | } 119 | 120 | return retVal 121 | } finally { 122 | mixins.locks-- 123 | if (mixins.locks === 0) { 124 | mixins.methods.forEach(mx => { 125 | mx.apply(this, args) 126 | }) 127 | } 128 | } 129 | } 130 | 131 | function wrapFunction(realMethod: Function, mixins: Mixins): (...args: Array) => any { 132 | const fn = function(...args: Array) { 133 | wrapper.call(this, realMethod, mixins, ...args) 134 | } 135 | return fn 136 | } 137 | 138 | export function patch(target: object, methodName: string, mixinMethod: Function): void { 139 | const mixins = getMixins(target, methodName) 140 | 141 | if (mixins.methods.indexOf(mixinMethod) < 0) { 142 | mixins.methods.push(mixinMethod) 143 | } 144 | 145 | const oldDefinition = Object.getOwnPropertyDescriptor(target, methodName) 146 | if (oldDefinition && oldDefinition[mobxPatchedDefinition]) { 147 | // already patched definition, do not repatch 148 | return 149 | } 150 | 151 | const originalMethod = target[methodName] 152 | const newDefinition = createDefinition( 153 | target, 154 | methodName, 155 | oldDefinition ? oldDefinition.enumerable : undefined, 156 | mixins, 157 | originalMethod 158 | ) 159 | 160 | Object.defineProperty(target, methodName, newDefinition) 161 | } 162 | 163 | function createDefinition( 164 | target: object, 165 | methodName: string, 166 | enumerable: any, 167 | mixins: Mixins, 168 | originalMethod: Function 169 | ): PropertyDescriptor { 170 | let wrappedFunc = wrapFunction(originalMethod, mixins) 171 | 172 | return { 173 | [mobxPatchedDefinition]: true, 174 | get: function() { 175 | return wrappedFunc 176 | }, 177 | set: function(value) { 178 | if (this === target) { 179 | wrappedFunc = wrapFunction(value, mixins) 180 | } else { 181 | // when it is an instance of the prototype/a child prototype patch that particular case again separately 182 | // since we need to store separate values depending on wether it is the actual instance, the prototype, etc 183 | // e.g. the method for super might not be the same as the method for the prototype which might be not the same 184 | // as the method for the instance 185 | const newDefinition = createDefinition(this, methodName, enumerable, mixins, value) 186 | Object.defineProperty(this, methodName, newDefinition) 187 | } 188 | }, 189 | configurable: true, 190 | enumerable: enumerable 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /test/.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | env: 2 | jest: true 3 | rules: 4 | "react/display-name": "off" 5 | "react/prop-types": "off" 6 | -------------------------------------------------------------------------------- /test/ErrorCatcher.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | interface ErrorCatcherState { 4 | hasError: boolean 5 | } 6 | 7 | // FIXME: saddly, this does not work as hoped, see: https://github.com/facebook/react/issues/10474#issuecomment-332810203 8 | export default class ErrorCatcher extends React.Component> { 9 | static lastError 10 | static getError 11 | constructor(props) { 12 | super(props) 13 | this.state = { hasError: false } 14 | } 15 | 16 | componentDidCatch(error, info) { 17 | console.error("Caught react error", error, info) 18 | ErrorCatcher.lastError = "" + error 19 | this.setState({ hasError: true }) 20 | } 21 | 22 | render() { 23 | if (this.state.hasError) { 24 | return null 25 | } 26 | return this.props.children 27 | } 28 | } 29 | 30 | ErrorCatcher.lastError = "" 31 | ErrorCatcher.getError = function() { 32 | const res = ErrorCatcher.lastError 33 | ErrorCatcher.lastError = "" 34 | return res 35 | } 36 | -------------------------------------------------------------------------------- /test/Provider.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Provider } from "../src" 3 | import { render } from "@testing-library/react" 4 | import { MobXProviderContext } from "../src/Provider" 5 | import { withConsole } from "./utils/withConsole" 6 | 7 | describe("Provider", () => { 8 | it("should work in a simple case", () => { 9 | function A() { 10 | return ( 11 | 12 | {({ foo }) => foo} 13 | 14 | ) 15 | } 16 | 17 | const { container } = render() 18 | expect(container).toHaveTextContent("bar") 19 | }) 20 | 21 | it("should not provide the children prop", () => { 22 | function A() { 23 | return ( 24 | 25 | 26 | {stores => 27 | Reflect.has(stores, "children") 28 | ? "children was provided" 29 | : "children was not provided" 30 | } 31 | 32 | 33 | ) 34 | } 35 | 36 | const { container } = render() 37 | expect(container).toHaveTextContent("children was not provided") 38 | }) 39 | 40 | it("supports overriding stores", () => { 41 | function B() { 42 | return ( 43 | 44 | {({ overridable, nonOverridable }) => `${overridable} ${nonOverridable}`} 45 | 46 | ) 47 | } 48 | 49 | function A() { 50 | return ( 51 | 52 | 53 | 54 | 55 | 56 | 57 | ) 58 | } 59 | const { container } = render() 60 | expect(container).toMatchInlineSnapshot(` 61 |

65 | `) 66 | }) 67 | 68 | it("should throw an error when changing stores", () => { 69 | function A({ foo }) { 70 | return ( 71 | 72 | {({ foo }) => foo} 73 | 74 | ) 75 | } 76 | 77 | const { rerender } = render() 78 | 79 | withConsole(() => { 80 | expect(() => { 81 | rerender() 82 | }).toThrow("The set of provided stores has changed.") 83 | }) 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /test/__snapshots__/observer.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`issue 12 1`] = ` 4 |
5 |
6 | 7 | coffee 8 | ! 9 | 10 | 11 | tea 12 | 13 | 14 |
15 |
16 | `; 17 | 18 | exports[`issue 12 2`] = ` 19 |
20 |
21 | 22 | soup 23 | 24 | 25 |
26 |
27 | `; 28 | 29 | exports[`should stop updating if error was thrown in render (#134) 1`] = ` 30 | Array [ 31 | "Error: Hello", 32 | Object { 33 | "componentStack": " 34 | in X 35 | in div (created by Outer) 36 | in Outer", 37 | }, 38 | ] 39 | `; 40 | -------------------------------------------------------------------------------- /test/__snapshots__/stateless.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`stateless component with forwardRef is reactive 1`] = ` 4 |
5 |
6 | result: 7 | hello world 8 | , 9 | got ref 10 | , a.x: 11 | 2 12 |
13 |
14 | `; 15 | 16 | exports[`stateless component with forwardRef render test correct 1`] = ` 17 |
18 |
19 | result: 20 | hello world 21 | , 22 | got ref 23 | , a.x: 24 | 1 25 |
26 |
27 | `; 28 | -------------------------------------------------------------------------------- /test/compile-ts.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom" 3 | import PropTypes from "prop-types" 4 | import { 5 | observer, 6 | Provider, 7 | inject, 8 | Observer, 9 | disposeOnUnmount, 10 | PropTypes as MRPropTypes, 11 | useLocalStore 12 | } from "../src" 13 | 14 | @observer 15 | class T1 extends React.Component<{ pizza: number }, {}> { 16 | render() { 17 | return
{this.props.pizza}
18 | } 19 | } 20 | 21 | const T2 = observer( 22 | class T2 extends React.Component<{ cake: number; zoem: any[] }> { 23 | defaultProps = { cake: 7 } 24 | render() { 25 | return ( 26 |
27 | 28 |
29 | ) 30 | } 31 | static propTypes = { 32 | zoem: MRPropTypes.arrayOrObservableArray 33 | } 34 | } 35 | ) 36 | 37 | const T3 = observer((props: { hamburger: number }) => { 38 | return 39 | }) 40 | 41 | const T4 = ({ sandwich }: { sandwich: number }) => ( 42 |
43 | 44 |
45 | ) 46 | 47 | const T5 = observer(() => { 48 | return 49 | }) 50 | 51 | @observer 52 | class T6 extends React.Component<{}, {}> { 53 | render() { 54 | return ( 55 | 56 | 57 | {/* doesn't work with tsc 1.7.5: https://github.com/Microsoft/TypeScript/issues/5675 */} 58 | {/**/} 59 | 60 | 61 | ) 62 | } 63 | } 64 | 65 | const x = React.createElement(T3, { hamburger: 4 }) 66 | 67 | class T7 extends React.Component<{ pizza: number }, {}> { 68 | render() { 69 | return
{this.props.pizza}
70 | } 71 | } 72 | React.createElement(observer(T7), { pizza: 4 }) 73 | 74 | ReactDOM.render(, document.body) 75 | 76 | class ProviderTest extends React.Component { 77 | render() { 78 | return ( 79 | 80 |
hi
81 |
82 | ) 83 | } 84 | } 85 | 86 | @inject(() => ({ x: 3 })) 87 | class T11 extends React.Component<{ pizza: number; x?: number }, {}> { 88 | render() { 89 | return ( 90 |
91 | {this.props.pizza} 92 | {this.props.x} 93 |
94 | ) 95 | } 96 | } 97 | 98 | class T15 extends React.Component<{ pizza: number; x?: number }, {}> { 99 | render() { 100 | return ( 101 |
102 | {this.props.pizza} 103 | {this.props.x} 104 |
105 | ) 106 | } 107 | } 108 | const T16 = inject(() => ({ x: 3 }))(T15) 109 | 110 | class T17 extends React.Component<{}, {}> { 111 | render() { 112 | return ( 113 |
114 | 115 | 116 | 117 | 118 | 119 | 120 |
121 | ) 122 | } 123 | } 124 | 125 | @inject("a", "b") 126 | class T12 extends React.Component<{ pizza: number }, {}> { 127 | render() { 128 | return
{this.props.pizza}
129 | } 130 | } 131 | 132 | @inject("a", "b") 133 | @observer 134 | class T13 extends React.Component<{ pizza: number }, {}> { 135 | render() { 136 | return
{this.props.pizza}
137 | } 138 | } 139 | 140 | const LoginContainer = inject((allStores, props) => ({ 141 | store: { y: true, z: 2 }, 142 | z: 7 143 | }))( 144 | observer( 145 | class _LoginContainer extends React.Component< 146 | { 147 | x: string 148 | store?: { y: boolean; z: number } 149 | }, 150 | {} 151 | > { 152 | static contextTypes: React.ValidationMap = { 153 | router: PropTypes.func.isRequired 154 | } 155 | 156 | render() { 157 | return ( 158 |
159 | Hello! 160 | {this.props.x} 161 | {this.props.store!.y} 162 |
163 | ) 164 | } 165 | } 166 | ) 167 | ) 168 | ReactDOM.render(, document.body) 169 | 170 | @inject(allStores => ({ 171 | store: { y: true, z: 2 } 172 | })) 173 | @observer 174 | class LoginContainer2 extends React.Component< 175 | { 176 | x: string 177 | store?: { y: boolean } 178 | }, 179 | {} 180 | > { 181 | static contextTypes: React.ValidationMap = { 182 | router: PropTypes.func.isRequired 183 | } 184 | 185 | render() { 186 | return ( 187 |
188 | Hello! 189 | {this.props.x} 190 | {this.props.store!.y} 191 |
192 | ) 193 | } 194 | } 195 | 196 | ReactDOM.render(, document.body) 197 | 198 | class ObserverTest extends React.Component { 199 | render() { 200 | return {() =>
test
}
201 | } 202 | } 203 | 204 | class ObserverTest2 extends React.Component { 205 | render() { 206 | return
test
} /> 207 | } 208 | } 209 | 210 | @observer 211 | class ComponentWithoutPropsAndState extends React.Component<{}, {}> { 212 | componentDidUpdate() {} 213 | 214 | render() { 215 | return
Hello!
216 | } 217 | } 218 | 219 | const AppInner = observer((props: { a: number }) => { 220 | return ( 221 |
222 |

Hello

223 | {props.a} 224 |
225 | ) 226 | }) 227 | 228 | const App = inject("store")(AppInner) 229 | 230 | App.wrappedComponent 231 | 232 | @inject("store") 233 | @observer 234 | class App2 extends React.Component<{ a: number }, {}> {} 235 | 236 | class InjectSomeStores extends React.Component<{ x: any }, {}> { 237 | render() { 238 | return
Hello World
239 | } 240 | } 241 | 242 | inject(({ x }) => ({ x }))(InjectSomeStores) 243 | 244 | { 245 | class T extends React.Component<{ x: number }> { 246 | render() { 247 | return
248 | } 249 | } 250 | 251 | const Injected = inject("test")(T) 252 | ; 253 | } 254 | 255 | { 256 | // just to make sure it compiles 257 | class DisposeOnUnmountComponent extends React.Component<{}> { 258 | @disposeOnUnmount 259 | methodA = () => {} 260 | 261 | methodB = disposeOnUnmount(this, () => {}) 262 | manyMethods = disposeOnUnmount(this, [() => {}, () => {}]) 263 | } 264 | 265 | // manual tests: this should not compile when the decorator is not applied over a react component class 266 | /* 267 | class DisposeOnUnmountNotAComponent { 268 | @disposeOnUnmount 269 | methodA = () => {} 270 | 271 | methodB = disposeOnUnmount(this, () => {}) 272 | } 273 | */ 274 | } 275 | 276 | { 277 | const TestComponent = () => { 278 | const observable = useLocalStore(() => ({ 279 | test: 3 280 | })) 281 | 282 | return

{observable.test * 2}

283 | } 284 | ; 285 | } 286 | -------------------------------------------------------------------------------- /test/context.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { observable } from "mobx" 3 | import { Provider, observer, inject } from "../src" 4 | import { withConsole } from "./utils/withConsole" 5 | import { render, act } from "@testing-library/react" 6 | import { any } from "prop-types" 7 | 8 | test("no warnings in modern react", () => { 9 | const box = observable.box(3) 10 | const Child = inject("store")( 11 | observer( 12 | class Child extends React.Component { 13 | render() { 14 | return ( 15 |
16 | {this.props.store} + {box.get()} 17 |
18 | ) 19 | } 20 | } 21 | ) 22 | ) 23 | 24 | class App extends React.Component { 25 | render() { 26 | return ( 27 |
28 | 29 | 30 | 31 | 32 | 33 |
34 | ) 35 | } 36 | } 37 | 38 | const { container } = render() 39 | expect(container).toHaveTextContent("42 + 3") 40 | 41 | withConsole(["info", "warn", "error"], () => { 42 | act(() => { 43 | box.set(4) 44 | }) 45 | expect(container).toHaveTextContent("42 + 4") 46 | 47 | expect(console.info).not.toHaveBeenCalled() 48 | expect(console.warn).not.toHaveBeenCalled() 49 | expect(console.error).not.toHaveBeenCalled() 50 | }) 51 | }) 52 | 53 | test("getDerivedStateFromProps works #447", () => { 54 | class Main extends React.Component { 55 | static getDerivedStateFromProps(nextProps, prevState) { 56 | return { 57 | count: prevState.count + 1 58 | } 59 | } 60 | 61 | state = { 62 | count: 0 63 | } 64 | 65 | render() { 66 | return ( 67 |
68 |

{`${this.state.count ? "One " : "No "}${this.props.thing}`}

69 |
70 | ) 71 | } 72 | } 73 | 74 | const MainInjected = inject(({ store }) => ({ thing: store.thing }))(Main) 75 | 76 | const store = { thing: 3 } 77 | 78 | const App = () => ( 79 | 80 | 81 | 82 | ) 83 | 84 | const { container } = render() 85 | expect(container).toHaveTextContent("One 3") 86 | }) 87 | 88 | test("no double runs for getDerivedStateFromProps", () => { 89 | let derived = 0 90 | @observer 91 | class Main extends React.Component { 92 | state = { 93 | activePropertyElementMap: {} 94 | } 95 | 96 | constructor(props) { 97 | // console.log("CONSTRUCTOR") 98 | super(props) 99 | } 100 | 101 | static getDerivedStateFromProps() { 102 | derived++ 103 | // console.log("PREVSTATE", nextProps) 104 | return null 105 | } 106 | 107 | render() { 108 | return
Test-content
109 | } 110 | } 111 | // This results in 112 | //PREVSTATE 113 | //CONSTRUCTOR 114 | //PREVSTATE 115 | let MainInjected = inject(() => ({ 116 | componentProp: "def" 117 | }))(Main) 118 | // Uncomment the following line to see default behaviour (without inject) 119 | //CONSTRUCTOR 120 | //PREVSTATE 121 | //MainInjected = Main; 122 | 123 | const store = {} 124 | 125 | const App = () => ( 126 | 127 | 128 | 129 | ) 130 | 131 | const { container } = render() 132 | expect(container).toHaveTextContent("Test-content") 133 | expect(derived).toBe(1) 134 | }) 135 | -------------------------------------------------------------------------------- /test/disposeOnUnmount.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { disposeOnUnmount, observer } from "../src" 3 | import { render } from "@testing-library/react" 4 | import { MockedComponentClass } from "react-dom/test-utils" 5 | 6 | interface ClassC extends MockedComponentClass { 7 | methodA?: any 8 | methodB?: any 9 | methodC?: any 10 | methodD?: any 11 | } 12 | 13 | function testComponent(C: ClassC, afterMount?: Function, afterUnmount?: Function) { 14 | const ref = React.createRef() 15 | const { unmount } = render() 16 | 17 | let cref = ref.current 18 | expect(cref?.methodA).not.toHaveBeenCalled() 19 | expect(cref?.methodB).not.toHaveBeenCalled() 20 | if (afterMount) { 21 | afterMount(cref) 22 | } 23 | 24 | unmount() 25 | 26 | expect(cref?.methodA).toHaveBeenCalledTimes(1) 27 | expect(cref?.methodB).toHaveBeenCalledTimes(1) 28 | if (afterUnmount) { 29 | afterUnmount(cref) 30 | } 31 | } 32 | 33 | describe("without observer", () => { 34 | test("class without componentWillUnmount", async () => { 35 | class C extends React.Component { 36 | @disposeOnUnmount 37 | methodA = jest.fn() 38 | @disposeOnUnmount 39 | methodB = jest.fn() 40 | @disposeOnUnmount 41 | methodC = null 42 | @disposeOnUnmount 43 | methodD = undefined 44 | 45 | render() { 46 | return null 47 | } 48 | } 49 | 50 | testComponent(C) 51 | }) 52 | 53 | test("class with componentWillUnmount in the prototype", () => { 54 | let called = 0 55 | 56 | class C extends React.Component { 57 | @disposeOnUnmount 58 | methodA = jest.fn() 59 | @disposeOnUnmount 60 | methodB = jest.fn() 61 | @disposeOnUnmount 62 | methodC = null 63 | @disposeOnUnmount 64 | methodD = undefined 65 | 66 | render() { 67 | return null 68 | } 69 | 70 | componentWillUnmount() { 71 | called++ 72 | } 73 | } 74 | 75 | testComponent( 76 | C, 77 | () => { 78 | expect(called).toBe(0) 79 | }, 80 | () => { 81 | expect(called).toBe(1) 82 | } 83 | ) 84 | }) 85 | 86 | test("class with componentWillUnmount as an arrow function", () => { 87 | let called = 0 88 | 89 | class C extends React.Component { 90 | @disposeOnUnmount 91 | methodA = jest.fn() 92 | @disposeOnUnmount 93 | methodB = jest.fn() 94 | @disposeOnUnmount 95 | methodC = null 96 | @disposeOnUnmount 97 | methodD = undefined 98 | 99 | render() { 100 | return null 101 | } 102 | 103 | componentWillUnmount = () => { 104 | called++ 105 | } 106 | } 107 | 108 | testComponent( 109 | C, 110 | () => { 111 | expect(called).toBe(0) 112 | }, 113 | () => { 114 | expect(called).toBe(1) 115 | } 116 | ) 117 | }) 118 | 119 | test("class without componentWillUnmount using non decorator version", () => { 120 | let methodC = jest.fn() 121 | let methodD = jest.fn() 122 | class C extends React.Component { 123 | render() { 124 | return null 125 | } 126 | 127 | methodA = disposeOnUnmount(this, jest.fn()) 128 | methodB = disposeOnUnmount(this, jest.fn()) 129 | 130 | constructor(props) { 131 | super(props) 132 | disposeOnUnmount(this, [methodC, methodD]) 133 | } 134 | } 135 | 136 | testComponent( 137 | C, 138 | () => { 139 | expect(methodC).not.toHaveBeenCalled() 140 | expect(methodD).not.toHaveBeenCalled() 141 | }, 142 | () => { 143 | expect(methodC).toHaveBeenCalledTimes(1) 144 | expect(methodD).toHaveBeenCalledTimes(1) 145 | } 146 | ) 147 | }) 148 | }) 149 | 150 | describe("with observer", () => { 151 | test("class without componentWillUnmount", () => { 152 | @observer 153 | class C extends React.Component { 154 | @disposeOnUnmount 155 | methodA = jest.fn() 156 | @disposeOnUnmount 157 | methodB = jest.fn() 158 | @disposeOnUnmount 159 | methodC = null 160 | @disposeOnUnmount 161 | methodD = undefined 162 | 163 | render() { 164 | return null 165 | } 166 | } 167 | 168 | testComponent(C) 169 | }) 170 | 171 | test("class with componentWillUnmount in the prototype", () => { 172 | let called = 0 173 | 174 | @observer 175 | class C extends React.Component { 176 | @disposeOnUnmount 177 | methodA = jest.fn() 178 | @disposeOnUnmount 179 | methodB = jest.fn() 180 | @disposeOnUnmount 181 | methodC = null 182 | @disposeOnUnmount 183 | methodD = undefined 184 | 185 | render() { 186 | return null 187 | } 188 | 189 | componentWillUnmount() { 190 | called++ 191 | } 192 | } 193 | 194 | testComponent( 195 | C, 196 | () => { 197 | expect(called).toBe(0) 198 | }, 199 | () => { 200 | expect(called).toBe(1) 201 | } 202 | ) 203 | }) 204 | 205 | test("class with componentWillUnmount as an arrow function", () => { 206 | let called = 0 207 | 208 | @observer 209 | class C extends React.Component { 210 | @disposeOnUnmount 211 | methodA = jest.fn() 212 | @disposeOnUnmount 213 | methodB = jest.fn() 214 | @disposeOnUnmount 215 | methodC = null 216 | @disposeOnUnmount 217 | methodD = undefined 218 | 219 | render() { 220 | return null 221 | } 222 | 223 | componentWillUnmount = () => { 224 | called++ 225 | } 226 | } 227 | 228 | testComponent( 229 | C, 230 | () => { 231 | expect(called).toBe(0) 232 | }, 233 | () => { 234 | expect(called).toBe(1) 235 | } 236 | ) 237 | }) 238 | 239 | test("class without componentWillUnmount using non decorator version", () => { 240 | let methodC = jest.fn() 241 | let methodD = jest.fn() 242 | 243 | @observer 244 | class C extends React.Component { 245 | render() { 246 | return null 247 | } 248 | 249 | methodA = disposeOnUnmount(this, jest.fn()) 250 | methodB = disposeOnUnmount(this, jest.fn()) 251 | 252 | constructor(props) { 253 | super(props) 254 | disposeOnUnmount(this, [methodC, methodD]) 255 | } 256 | } 257 | 258 | testComponent( 259 | C, 260 | () => { 261 | expect(methodC).not.toHaveBeenCalled() 262 | expect(methodD).not.toHaveBeenCalled() 263 | }, 264 | () => { 265 | expect(methodC).toHaveBeenCalledTimes(1) 266 | expect(methodD).toHaveBeenCalledTimes(1) 267 | } 268 | ) 269 | }) 270 | }) 271 | 272 | it("componentDidMount should be different between components", () => { 273 | function doTest(withObserver) { 274 | const events: Array = [] 275 | 276 | class A extends React.Component { 277 | didMount 278 | willUnmount 279 | 280 | componentDidMount() { 281 | this.didMount = "A" 282 | events.push("mountA") 283 | } 284 | 285 | componentWillUnmount() { 286 | this.willUnmount = "A" 287 | events.push("unmountA") 288 | } 289 | 290 | render() { 291 | return null 292 | } 293 | } 294 | 295 | class B extends React.Component { 296 | didMount 297 | willUnmount 298 | 299 | componentDidMount() { 300 | this.didMount = "B" 301 | events.push("mountB") 302 | } 303 | 304 | componentWillUnmount() { 305 | this.willUnmount = "B" 306 | events.push("unmountB") 307 | } 308 | 309 | render() { 310 | return null 311 | } 312 | } 313 | 314 | if (withObserver) { 315 | // @ts-ignore 316 | // eslint-disable-next-line no-class-assign 317 | A = observer(A) 318 | // @ts-ignore 319 | // eslint-disable-next-line no-class-assign 320 | B = observer(B) 321 | } 322 | 323 | const aRef = React.createRef
() 324 | const { rerender, unmount } = render() 325 | const caRef = aRef.current 326 | 327 | expect(caRef?.didMount).toBe("A") 328 | expect(caRef?.willUnmount).toBeUndefined() 329 | expect(events).toEqual(["mountA"]) 330 | 331 | const bRef = React.createRef() 332 | rerender() 333 | const cbRef = bRef.current 334 | 335 | expect(caRef?.didMount).toBe("A") 336 | expect(caRef?.willUnmount).toBe("A") 337 | 338 | expect(cbRef?.didMount).toBe("B") 339 | expect(cbRef?.willUnmount).toBeUndefined() 340 | expect(events).toEqual(["mountA", "unmountA", "mountB"]) 341 | 342 | unmount() 343 | 344 | expect(caRef?.didMount).toBe("A") 345 | expect(caRef?.willUnmount).toBe("A") 346 | 347 | expect(cbRef?.didMount).toBe("B") 348 | expect(cbRef?.willUnmount).toBe("B") 349 | expect(events).toEqual(["mountA", "unmountA", "mountB", "unmountB"]) 350 | } 351 | 352 | doTest(true) 353 | doTest(false) 354 | }) 355 | 356 | test("base cWU should not be called if overriden", () => { 357 | let baseCalled = 0 358 | let dCalled = 0 359 | let oCalled = 0 360 | 361 | class C extends React.Component { 362 | componentWillUnmount() { 363 | baseCalled++ 364 | } 365 | 366 | constructor(props) { 367 | super(props) 368 | this.componentWillUnmount = () => { 369 | oCalled++ 370 | } 371 | } 372 | 373 | render() { 374 | return null 375 | } 376 | 377 | @disposeOnUnmount 378 | fn() { 379 | dCalled++ 380 | } 381 | } 382 | const { unmount } = render() 383 | unmount() 384 | expect(dCalled).toBe(1) 385 | expect(oCalled).toBe(1) 386 | expect(baseCalled).toBe(0) 387 | }) 388 | 389 | test("should error on inheritance", () => { 390 | class C extends React.Component { 391 | render() { 392 | return null 393 | } 394 | } 395 | 396 | expect(() => { 397 | // eslint-disable-next-line no-unused-vars 398 | class B extends C { 399 | @disposeOnUnmount 400 | fn() {} 401 | } 402 | }).toThrow("disposeOnUnmount only supports direct subclasses") 403 | }) 404 | 405 | test("should error on inheritance - 2", () => { 406 | class C extends React.Component { 407 | render() { 408 | return null 409 | } 410 | } 411 | 412 | class B extends C { 413 | fn 414 | constructor(props) { 415 | super(props) 416 | expect(() => { 417 | this.fn = disposeOnUnmount(this, function() {}) 418 | }).toThrow("disposeOnUnmount only supports direct subclasses") 419 | } 420 | } 421 | 422 | render() 423 | }) 424 | 425 | describe("should work with arrays", () => { 426 | test("as a function", () => { 427 | class C extends React.Component { 428 | methodA = jest.fn() 429 | methodB = jest.fn() 430 | 431 | componentDidMount() { 432 | disposeOnUnmount(this, [this.methodA, this.methodB]) 433 | } 434 | 435 | render() { 436 | return null 437 | } 438 | } 439 | 440 | testComponent(C) 441 | }) 442 | 443 | test("as a decorator", () => { 444 | class C extends React.Component { 445 | methodA = jest.fn() 446 | methodB = jest.fn() 447 | 448 | @disposeOnUnmount 449 | disposers = [this.methodA, this.methodB] 450 | 451 | render() { 452 | return null 453 | } 454 | } 455 | 456 | testComponent(C) 457 | }) 458 | }) 459 | 460 | it("runDisposersOnUnmount only runs disposers from the declaring instance", () => { 461 | class A extends React.Component { 462 | @disposeOnUnmount 463 | a = jest.fn() 464 | 465 | b = jest.fn() 466 | 467 | constructor(props) { 468 | super(props) 469 | disposeOnUnmount(this, this.b) 470 | } 471 | 472 | render() { 473 | return null 474 | } 475 | } 476 | 477 | const ref1 = React.createRef() 478 | const ref2 = React.createRef() 479 | const { unmount } = render() 480 | render() 481 | const inst1 = ref1.current 482 | const inst2 = ref2.current 483 | unmount() 484 | 485 | expect(inst1?.a).toHaveBeenCalledTimes(1) 486 | expect(inst1?.b).toHaveBeenCalledTimes(1) 487 | expect(inst2?.a).toHaveBeenCalledTimes(0) 488 | expect(inst2?.b).toHaveBeenCalledTimes(0) 489 | }) 490 | -------------------------------------------------------------------------------- /test/hooks.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { observer, Observer, useLocalStore, useAsObservableSource } from "../src" 3 | import { render, act } from "@testing-library/react" 4 | 5 | afterEach(() => { 6 | jest.useRealTimers() 7 | }) 8 | 9 | test("computed properties react to props when using hooks", async () => { 10 | jest.useFakeTimers() 11 | 12 | const seen: Array = [] 13 | 14 | const Child = ({ x }) => { 15 | const props = useAsObservableSource({ x }) 16 | const store = useLocalStore(() => ({ 17 | get getPropX() { 18 | return props.x 19 | } 20 | })) 21 | 22 | return ( 23 | {() => (seen.push(store.getPropX), (
{store.getPropX}
))}
24 | ) 25 | } 26 | 27 | const Parent = () => { 28 | const [state, setState] = React.useState({ x: 0 }) 29 | seen.push("parent") 30 | React.useEffect(() => { 31 | setTimeout(() => { 32 | act(() => { 33 | setState({ x: 2 }) 34 | }) 35 | }) 36 | }, []) 37 | return 38 | } 39 | 40 | const { container } = render() 41 | expect(container).toHaveTextContent("0") 42 | 43 | jest.runAllTimers() 44 | expect(seen).toEqual(["parent", 0, "parent", 2]) 45 | expect(container).toHaveTextContent("2") 46 | }) 47 | 48 | test("computed properties result in double render when using observer instead of Observer", async () => { 49 | jest.useFakeTimers() 50 | 51 | const seen: Array = [] 52 | 53 | const Child = observer(({ x }) => { 54 | const props = useAsObservableSource({ x }) 55 | const store = useLocalStore(() => ({ 56 | get getPropX() { 57 | return props.x 58 | } 59 | })) 60 | 61 | seen.push(store.getPropX) 62 | return
{store.getPropX}
63 | }) 64 | 65 | const Parent = () => { 66 | const [state, setState] = React.useState({ x: 0 }) 67 | seen.push("parent") 68 | React.useEffect(() => { 69 | setTimeout(() => { 70 | act(() => { 71 | setState({ x: 2 }) 72 | }) 73 | }, 100) 74 | }, []) 75 | return 76 | } 77 | 78 | const { container } = render() 79 | expect(container).toHaveTextContent("0") 80 | 81 | jest.runAllTimers() 82 | expect(seen).toEqual([ 83 | "parent", 84 | 0, 85 | "parent", 86 | 2, 87 | 2 // should contain "2" only once! But with hooks, one update is scheduled based the fact that props change, the other because the observable source changed. 88 | ]) 89 | expect(container).toHaveTextContent("2") 90 | }) 91 | -------------------------------------------------------------------------------- /test/inject.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from "prop-types" 3 | import { action, observable, makeObservable } from "mobx" 4 | import { observer, inject, Provider } from "../src" 5 | import { IValueMap } from "../src/types/IValueMap" 6 | import { render, act } from "@testing-library/react" 7 | import { withConsole } from "./utils/withConsole" 8 | import { IReactComponent } from "../src/types/IReactComponent" 9 | 10 | describe("inject based context", () => { 11 | test("basic context", () => { 12 | const C = inject("foo")( 13 | observer( 14 | class X extends React.Component { 15 | render() { 16 | return ( 17 |
18 | context: 19 | {this.props.foo} 20 |
21 | ) 22 | } 23 | } 24 | ) 25 | ) 26 | const B = () => 27 | const A = () => ( 28 | 29 | 30 | 31 | ) 32 | const { container } = render(
) 33 | expect(container).toHaveTextContent("context:bar") 34 | }) 35 | 36 | test("props override context", () => { 37 | const C = inject("foo")( 38 | class T extends React.Component { 39 | render() { 40 | return ( 41 |
42 | context: 43 | {this.props.foo} 44 |
45 | ) 46 | } 47 | } 48 | ) 49 | const B = () => 50 | const A = class T extends React.Component { 51 | render() { 52 | return ( 53 | 54 | 55 | 56 | ) 57 | } 58 | } 59 | const { container } = render(
) 60 | expect(container).toHaveTextContent("context:42") 61 | }) 62 | 63 | test("wraps displayName of original component", () => { 64 | const A: React.ComponentClass = inject("foo")( 65 | class ComponentA extends React.Component { 66 | render() { 67 | return ( 68 |
69 | context: 70 | {this.props.foo} 71 |
72 | ) 73 | } 74 | } 75 | ) 76 | const B: React.ComponentClass = inject()( 77 | class ComponentB extends React.Component { 78 | render() { 79 | return ( 80 |
81 | context: 82 | {this.props.foo} 83 |
84 | ) 85 | } 86 | } 87 | ) 88 | const C: React.ComponentClass = inject(() => ({}))( 89 | class ComponentC extends React.Component { 90 | render() { 91 | return ( 92 |
93 | context: 94 | {this.props.foo} 95 |
96 | ) 97 | } 98 | } 99 | ) 100 | expect(A.displayName).toBe("inject-with-foo(ComponentA)") 101 | expect(B.displayName).toBe("inject(ComponentB)") 102 | expect(C.displayName).toBe("inject(ComponentC)") 103 | }) 104 | 105 | // FIXME: see other comments related to error catching in React 106 | // test does work as expected when running manually 107 | test("store should be available", () => { 108 | const C = inject("foo")( 109 | observer( 110 | class C extends React.Component { 111 | render() { 112 | return ( 113 |
114 | context: 115 | {this.props.foo} 116 |
117 | ) 118 | } 119 | } 120 | ) 121 | ) 122 | const B = () => 123 | const A = class A extends React.Component { 124 | render() { 125 | return ( 126 | 127 | 128 | 129 | ) 130 | } 131 | } 132 | 133 | withConsole(() => { 134 | expect(() => render(
)).toThrow( 135 | /Store 'foo' is not available! Make sure it is provided by some Provider/ 136 | ) 137 | }) 138 | }) 139 | 140 | test("store is not required if prop is available", () => { 141 | const C = inject("foo")( 142 | observer( 143 | class C extends React.Component { 144 | render() { 145 | return ( 146 |
147 | context: 148 | {this.props.foo} 149 |
150 | ) 151 | } 152 | } 153 | ) 154 | ) 155 | const B = () => 156 | const { container } = render() 157 | expect(container).toHaveTextContent("context:bar") 158 | }) 159 | 160 | test("inject merges (and overrides) props", () => { 161 | const C = inject(() => ({ a: 1 }))( 162 | observer( 163 | class C extends React.Component { 164 | render() { 165 | expect(this.props).toEqual({ a: 1, b: 2 }) 166 | return null 167 | } 168 | } 169 | ) 170 | ) 171 | const B = () => 172 | render() 173 | }) 174 | 175 | test("custom storesToProps", () => { 176 | const C = inject((stores: IValueMap, props: any) => { 177 | expect(stores).toEqual({ foo: "bar" }) 178 | expect(props).toEqual({ baz: 42 }) 179 | return { 180 | zoom: stores.foo, 181 | baz: props.baz * 2 182 | } 183 | })( 184 | observer( 185 | class C extends React.Component { 186 | render() { 187 | return ( 188 |
189 | context: 190 | {this.props.zoom} 191 | {this.props.baz} 192 |
193 | ) 194 | } 195 | } 196 | ) 197 | ) 198 | const B = class B extends React.Component { 199 | render() { 200 | return 201 | } 202 | } 203 | const A = () => ( 204 | 205 | 206 | 207 | ) 208 | const { container } = render(
) 209 | expect(container).toHaveTextContent("context:bar84") 210 | }) 211 | 212 | test("inject forwards ref", () => { 213 | class FancyComp extends React.Component { 214 | didRender 215 | render() { 216 | this.didRender = true 217 | return null 218 | } 219 | 220 | doSomething() {} 221 | } 222 | 223 | const ref = React.createRef() 224 | render() 225 | expect(typeof ref.current?.doSomething).toBe("function") 226 | expect(ref.current?.didRender).toBe(true) 227 | 228 | const InjectedFancyComp = inject("bla")(FancyComp) 229 | const ref2 = React.createRef() 230 | 231 | render( 232 | 233 | 234 | 235 | ) 236 | 237 | expect(typeof ref2.current?.doSomething).toBe("function") 238 | expect(ref2.current?.didRender).toBe(true) 239 | }) 240 | 241 | test("inject should work with components that use forwardRef", () => { 242 | const FancyComp = React.forwardRef((_: any, ref: React.Ref) => { 243 | return
244 | }) 245 | 246 | const InjectedFancyComp = inject("bla")(FancyComp) 247 | const ref = React.createRef() 248 | 249 | render( 250 | 251 | 252 | 253 | ) 254 | 255 | expect(ref.current).not.toBeNull() 256 | expect(ref.current).toBeInstanceOf(HTMLDivElement) 257 | }) 258 | 259 | test("support static hoisting, wrappedComponent and ref forwarding", () => { 260 | class B extends React.Component { 261 | static foo 262 | static bar 263 | testField 264 | 265 | render() { 266 | this.testField = 1 267 | return null 268 | } 269 | } 270 | ;(B as React.ComponentClass).propTypes = { 271 | x: PropTypes.object 272 | } 273 | B.foo = 17 274 | B.bar = {} 275 | const C = inject("booh")(B) 276 | expect(C.wrappedComponent).toBe(B) 277 | expect(B.foo).toBe(17) 278 | expect(C.foo).toBe(17) 279 | expect(C.bar === B.bar).toBeTruthy() 280 | expect(Object.keys(C.wrappedComponent.propTypes!)).toEqual(["x"]) 281 | 282 | const ref = React.createRef() 283 | 284 | render() 285 | expect(ref.current?.testField).toBe(1) 286 | }) 287 | 288 | test("propTypes and defaultProps are forwarded", () => { 289 | const msg: Array = [] 290 | const baseError = console.error 291 | console.error = m => msg.push(m) 292 | 293 | const C: React.ComponentClass = inject("foo")( 294 | class C extends React.Component { 295 | render() { 296 | expect(this.props.y).toEqual(3) 297 | 298 | expect(this.props.x).toBeUndefined() 299 | return null 300 | } 301 | } 302 | ) 303 | C.propTypes = { 304 | x: PropTypes.func.isRequired, 305 | z: PropTypes.string.isRequired 306 | } 307 | // @ts-ignore 308 | C.wrappedComponent.propTypes = { 309 | a: PropTypes.func.isRequired 310 | } 311 | C.defaultProps = { 312 | y: 3 313 | } 314 | const B = () => 315 | const A = () => ( 316 | 317 | 318 | 319 | ) 320 | render() 321 | expect(msg.length).toBe(2) 322 | expect(msg[0].split("\n")[0]).toBe( 323 | "Warning: Failed prop type: The prop `x` is marked as required in `inject-with-foo(C)`, but its value is `undefined`." 324 | ) 325 | expect(msg[1].split("\n")[0]).toBe( 326 | "Warning: Failed prop type: The prop `a` is marked as required in `C`, but its value is `undefined`." 327 | ) 328 | console.error = baseError 329 | }) 330 | 331 | test("warning is not printed when attaching propTypes to injected component", () => { 332 | let msg = [] 333 | const baseWarn = console.warn 334 | console.warn = m => (msg = m) 335 | 336 | const C: React.ComponentClass = inject("foo")( 337 | class C extends React.Component { 338 | render() { 339 | return ( 340 |
341 | context: 342 | {this.props.foo} 343 |
344 | ) 345 | } 346 | } 347 | ) 348 | C.propTypes = {} 349 | 350 | expect(msg.length).toBe(0) 351 | console.warn = baseWarn 352 | }) 353 | 354 | test("warning is not printed when attaching propTypes to wrappedComponent", () => { 355 | let msg = [] 356 | const baseWarn = console.warn 357 | console.warn = m => (msg = m) 358 | const C = inject("foo")( 359 | class C extends React.Component { 360 | render() { 361 | return ( 362 |
363 | context: 364 | {this.props.foo} 365 |
366 | ) 367 | } 368 | } 369 | ) 370 | C.wrappedComponent.propTypes = {} 371 | expect(msg.length).toBe(0) 372 | console.warn = baseWarn 373 | }) 374 | 375 | test("using a custom injector is reactive", () => { 376 | const user = observable({ name: "Noa" }) 377 | const mapper = stores => ({ name: stores.user.name }) 378 | const DisplayName = props =>

{props.name}

379 | const User = inject(mapper)(DisplayName) 380 | const App = () => ( 381 | 382 | 383 | 384 | ) 385 | const { container } = render() 386 | expect(container).toHaveTextContent("Noa") 387 | 388 | act(() => { 389 | user.name = "Veria" 390 | }) 391 | expect(container).toHaveTextContent("Veria") 392 | }) 393 | 394 | test("using a custom injector is not too reactive", () => { 395 | let listRender = 0 396 | let itemRender = 0 397 | let injectRender = 0 398 | 399 | function connect() { 400 | const args = arguments 401 | return (component: IReactComponent) => 402 | // @ts-ignore 403 | inject.apply(this, args)(observer(component)) 404 | } 405 | 406 | class State { 407 | @observable 408 | highlighted = null 409 | isHighlighted(item) { 410 | return this.highlighted == item 411 | } 412 | 413 | @action 414 | highlight = item => { 415 | this.highlighted = item 416 | } 417 | 418 | constructor() { 419 | makeObservable(this) 420 | } 421 | } 422 | 423 | const items = observable([ 424 | { title: "ItemA" }, 425 | { title: "ItemB" }, 426 | { title: "ItemC" }, 427 | { title: "ItemD" }, 428 | { title: "ItemE" }, 429 | { title: "ItemF" } 430 | ]) 431 | 432 | const state = new State() 433 | 434 | class ListComponent extends React.PureComponent { 435 | render() { 436 | listRender++ 437 | const { items } = this.props 438 | 439 | return ( 440 |
    441 | {items.map(item => ( 442 | 443 | ))} 444 |
445 | ) 446 | } 447 | } 448 | 449 | // @ts-ignore 450 | @connect(({ state }, { item }) => { 451 | injectRender++ 452 | if (injectRender > 6) { 453 | // debugger; 454 | } 455 | return { 456 | // Using 457 | // highlighted: expr(() => state.isHighlighted(item)) // seems to fix the problem 458 | highlighted: state.isHighlighted(item), 459 | highlight: state.highlight 460 | } 461 | }) 462 | class ItemComponent extends React.PureComponent { 463 | highlight = () => { 464 | const { item, highlight } = this.props 465 | highlight(item) 466 | } 467 | 468 | render() { 469 | itemRender++ 470 | const { highlighted, item } = this.props 471 | return ( 472 |
  • 473 | {item.title} {highlighted ? "(highlighted)" : ""}{" "} 474 |
  • 475 | ) 476 | } 477 | } 478 | 479 | const { container } = render( 480 | 481 | 482 | 483 | ) 484 | 485 | expect(listRender).toBe(1) 486 | expect(injectRender).toBe(6) 487 | expect(itemRender).toBe(6) 488 | 489 | container.querySelectorAll(".hl_ItemB").forEach((e: Element) => (e as HTMLElement).click()) 490 | expect(listRender).toBe(1) 491 | expect(injectRender).toBe(12) // ideally, 7 492 | expect(itemRender).toBe(7) 493 | 494 | container.querySelectorAll(".hl_ItemF").forEach((e: Element) => (e as HTMLElement).click()) 495 | expect(listRender).toBe(1) 496 | expect(injectRender).toBe(18) // ideally, 9 497 | expect(itemRender).toBe(9) 498 | }) 499 | }) 500 | 501 | test("#612 - mixed context types", () => { 502 | const SomeContext = React.createContext(true) 503 | 504 | class MainCompClass extends React.Component { 505 | static contextType = SomeContext 506 | render() { 507 | let active = this.context 508 | return active ? this.props.value : "Inactive" 509 | } 510 | } 511 | 512 | const MainComp = inject((stores: any) => ({ 513 | value: stores.appState.value 514 | }))(MainCompClass) 515 | 516 | const appState = observable({ 517 | value: "Something" 518 | }) 519 | 520 | function App() { 521 | return ( 522 | 523 | 524 | 525 | 526 | 527 | ) 528 | } 529 | 530 | render() 531 | }) 532 | -------------------------------------------------------------------------------- /test/issue21.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { createElement } from "react" 2 | import { computed, isObservable, observable, reaction, transaction, IReactionDisposer, makeObservable } from "mobx" 3 | import { observer } from "../src" 4 | import _ from "lodash" 5 | import { render } from "@testing-library/react" 6 | 7 | let topRenderCount = 0 8 | 9 | const wizardModel = observable( 10 | { 11 | steps: [ 12 | { 13 | title: "Size", 14 | active: true 15 | }, 16 | { 17 | title: "Fabric", 18 | active: false 19 | }, 20 | { 21 | title: "Finish", 22 | active: false 23 | } 24 | ], 25 | get activeStep() { 26 | return _.find(this.steps, "active") 27 | }, 28 | activateNextStep: function() { 29 | const nextStep = this.steps[_.findIndex(this.steps, "active") + 1] 30 | if (!nextStep) { 31 | return false 32 | } 33 | this.setActiveStep(nextStep) 34 | return true 35 | }, 36 | setActiveStep(modeToActivate) { 37 | const self = this 38 | transaction(() => { 39 | _.find(self.steps, "active").active = false 40 | modeToActivate.active = true 41 | }) 42 | } 43 | } as any, 44 | { 45 | activateNextStep: observable.ref 46 | } 47 | ) 48 | 49 | /** RENDERS **/ 50 | 51 | const Wizard = observer( 52 | class Wizard extends React.Component { 53 | render() { 54 | return createElement( 55 | "div", 56 | null, 57 |
    58 |

    Active Step:

    59 | 60 |
    , 61 |
    62 |

    All Step:

    63 |

    64 | Clicking on these steps will render the active step just once. This is what 65 | I expected. 66 |

    67 | 68 |
    69 | ) 70 | } 71 | } 72 | ) 73 | 74 | const WizardStep = observer( 75 | class WizardStep extends React.Component { 76 | renderCount = 0 77 | componentWillUnmount() { 78 | // console.log("Unmounting!") 79 | } 80 | render() { 81 | // weird test hack: 82 | if (this.props.tester === true) { 83 | topRenderCount++ 84 | } 85 | return createElement( 86 | "div", 87 | { onClick: this.modeClickHandler }, 88 | "RenderCount: " + 89 | this.renderCount++ + 90 | " " + 91 | this.props.step.title + 92 | ": isActive:" + 93 | this.props.step.active 94 | ) 95 | } 96 | modeClickHandler = () => { 97 | var step = this.props.step 98 | wizardModel.setActiveStep(step) 99 | } 100 | } 101 | ) 102 | 103 | /** END RENDERERS **/ 104 | 105 | const changeStep = stepNumber => wizardModel.setActiveStep(wizardModel.steps[stepNumber]) 106 | 107 | test("verify issue 21", () => { 108 | render() 109 | expect(topRenderCount).toBe(1) 110 | changeStep(0) 111 | expect(topRenderCount).toBe(2) 112 | changeStep(2) 113 | expect(topRenderCount).toBe(3) 114 | }) 115 | 116 | test("verify prop changes are picked up", () => { 117 | function createItem(subid, label) { 118 | const res = observable( 119 | { 120 | subid, 121 | id: 1, 122 | label: label, 123 | get text() { 124 | events.push(["compute", this.subid]) 125 | return ( 126 | this.id + 127 | "." + 128 | this.subid + 129 | "." + 130 | this.label + 131 | "." + 132 | data.items.indexOf(this as any) 133 | ) 134 | } 135 | }, 136 | {}, 137 | { proxy: false } 138 | ) 139 | res.subid = subid // non reactive 140 | return res 141 | } 142 | const data = observable({ 143 | items: [createItem(1, "hi")] 144 | }) 145 | const events: Array = [] 146 | const Child = observer( 147 | class Child extends React.Component { 148 | componentDidUpdate(prevProps) { 149 | events.push(["update", prevProps.item.subid, this.props.item.subid]) 150 | } 151 | render() { 152 | events.push(["render", this.props.item.subid, this.props.item.text]) 153 | return {this.props.item.text} 154 | } 155 | } 156 | ) 157 | 158 | const Parent = observer( 159 | class Parent extends React.Component { 160 | render() { 161 | return ( 162 |
    163 | {data.items.map(item => ( 164 | 165 | ))} 166 |
    167 | ) 168 | } 169 | } 170 | ) 171 | 172 | const Wrapper = () => 173 | 174 | function changeStuff() { 175 | transaction(() => { 176 | data.items[0].label = "hello" // schedules state change for Child 177 | data.items[0] = createItem(2, "test") // Child should still receive new prop! 178 | }) 179 | 180 | // @ts-ignore 181 | this.setState({}) // trigger update 182 | } 183 | 184 | const { container } = render() 185 | expect(events.sort()).toEqual( 186 | [ 187 | ["compute", 1], 188 | ["render", 1, "1.1.hi.0"] 189 | ].sort() 190 | ) 191 | events.splice(0) 192 | let testDiv = container.querySelector("#testDiv")! as HTMLElement 193 | testDiv.click() 194 | expect(events.sort()).toEqual( 195 | [ 196 | ["compute", 1], 197 | ["update", 1, 2], 198 | ["compute", 2], 199 | ["render", 2, "1.2.test.0"] 200 | ].sort() 201 | ) 202 | }) 203 | 204 | test("verify props is reactive", () => { 205 | function createItem(subid, label) { 206 | const res = observable( 207 | { 208 | subid, 209 | id: 1, 210 | label: label, 211 | get text() { 212 | events.push(["compute", this.subid]) 213 | return ( 214 | this.id + 215 | "." + 216 | this.subid + 217 | "." + 218 | this.label + 219 | "." + 220 | data.items.indexOf(this as any) 221 | ) 222 | } 223 | }, 224 | {}, 225 | { proxy: false } 226 | ) 227 | res.subid = subid // non reactive 228 | return res 229 | } 230 | 231 | const data = observable({ 232 | items: [createItem(1, "hi")] 233 | }) 234 | const events: Array = [] 235 | 236 | class Child extends React.Component { 237 | constructor(p) { 238 | super(p) 239 | makeObservable(this) 240 | } 241 | 242 | @computed 243 | get computedLabel() { 244 | events.push(["computed label", this.props.item.subid]) 245 | return this.props.item.label 246 | } 247 | componentDidMount() { 248 | events.push(["mount"]) 249 | } 250 | componentDidUpdate(prevProps) { 251 | events.push(["update", prevProps.item.subid, this.props.item.subid]) 252 | } 253 | render() { 254 | events.push(["render", this.props.item.subid, this.props.item.text, this.computedLabel]) 255 | return ( 256 | 257 | {this.props.item.text} 258 | {this.computedLabel} 259 | 260 | ) 261 | } 262 | } 263 | 264 | const ChildAsObserver = observer(Child) 265 | 266 | const Parent = observer( 267 | class Parent extends React.Component { 268 | render() { 269 | return ( 270 |
    271 | {data.items.map(item => ( 272 | 273 | ))} 274 |
    275 | ) 276 | } 277 | } 278 | ) 279 | 280 | const Wrapper = () => 281 | 282 | function changeStuff() { 283 | transaction(() => { 284 | // components start rendeirng a new item, but computed is still based on old value 285 | data.items = [createItem(2, "test")] 286 | }) 287 | } 288 | 289 | const { container } = render() 290 | expect(events.sort()).toEqual( 291 | [["mount"], ["compute", 1], ["computed label", 1], ["render", 1, "1.1.hi.0", "hi"]].sort() 292 | ) 293 | 294 | events.splice(0) 295 | let testDiv = container.querySelector("#testDiv") as HTMLElement 296 | testDiv.click() 297 | 298 | expect(events.sort()).toEqual( 299 | [ 300 | ["compute", 1], 301 | ["update", 1, 2], 302 | ["compute", 2], 303 | ["computed label", 2], 304 | ["render", 2, "1.2.test.0", "test"] 305 | ].sort() 306 | ) 307 | }) 308 | 309 | test("no re-render for shallow equal props", async () => { 310 | function createItem(subid, label) { 311 | const res = observable({ 312 | subid, 313 | id: 1, 314 | label: label 315 | }) 316 | res.subid = subid // non reactive 317 | return res 318 | } 319 | 320 | const data = observable({ 321 | items: [createItem(1, "hi")], 322 | parentValue: 0 323 | }) 324 | const events: Array> = [] 325 | 326 | const Child = observer( 327 | class Child extends React.Component { 328 | componentDidMount() { 329 | events.push(["mount"]) 330 | } 331 | componentDidUpdate(prevProps) { 332 | events.push(["update", prevProps.item.subid, this.props.item.subid]) 333 | } 334 | render() { 335 | events.push(["render", this.props.item.subid, this.props.item.label]) 336 | return {this.props.item.label} 337 | } 338 | } 339 | ) 340 | 341 | const Parent = observer( 342 | class Parent extends React.Component { 343 | render() { 344 | // "object has become observable!" 345 | expect(isObservable(this.props.nonObservable)).toBeFalsy() 346 | events.push(["parent render", data.parentValue]) 347 | return ( 348 |
    349 | {data.items.map(item => ( 350 | 351 | ))} 352 |
    353 | ) 354 | } 355 | } 356 | ) 357 | 358 | const Wrapper = () => 359 | 360 | function changeStuff() { 361 | data.items[0].label = "hi" // no change. 362 | data.parentValue = 1 // rerender parent 363 | } 364 | 365 | const { container } = render() 366 | expect(events.sort()).toEqual([["parent render", 0], ["mount"], ["render", 1, "hi"]].sort()) 367 | events.splice(0) 368 | let testDiv = container.querySelector("#testDiv") as HTMLElement 369 | testDiv.click() 370 | expect(events.sort()).toEqual([["parent render", 1]].sort()) 371 | }) 372 | 373 | test("lifecycle callbacks called with correct arguments", () => { 374 | var Comp = observer( 375 | class Comp extends React.Component { 376 | componentDidUpdate(prevProps) { 377 | expect(prevProps.counter).toBe(0) 378 | expect(this.props.counter).toBe(1) 379 | } 380 | render() { 381 | return ( 382 |
    383 | {[this.props.counter]} 384 |
    386 | ) 387 | } 388 | } 389 | ) 390 | const Root = class T extends React.Component { 391 | state = { counter: 0 } 392 | onButtonClick = () => { 393 | this.setState({ counter: (this.state.counter || 0) + 1 }) 394 | } 395 | render() { 396 | return 397 | } 398 | } 399 | const { container } = render() 400 | let testButton = container.querySelector("#testButton") as HTMLElement 401 | testButton.click() 402 | }) 403 | 404 | test("verify props are reactive in constructor", () => { 405 | const propValues: Array = [] 406 | let constructorCallsCount = 0 407 | 408 | const Component = observer( 409 | class Component extends React.Component { 410 | disposer: IReactionDisposer 411 | constructor(props, context) { 412 | super(props, context) 413 | constructorCallsCount++ 414 | this.disposer = reaction( 415 | () => this.props.prop, 416 | prop => propValues.push(prop), 417 | { 418 | fireImmediately: true 419 | } 420 | ) 421 | } 422 | 423 | componentWillUnmount() { 424 | this.disposer() 425 | } 426 | 427 | render() { 428 | return
    429 | } 430 | } 431 | ) 432 | 433 | const { rerender } = render() 434 | rerender() 435 | rerender() 436 | rerender() 437 | expect(constructorCallsCount).toEqual(1) 438 | expect(propValues).toEqual(["1", "2", "3", "4"]) 439 | }) 440 | -------------------------------------------------------------------------------- /test/issue806.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { configure, observable } from "mobx" 3 | import { observer } from "../src" 4 | import { render } from "@testing-library/react" 5 | import { withConsole } from "./utils/withConsole" 6 | 7 | @observer 8 | class Issue806Component extends React.Component { 9 | render() { 10 | return ( 11 | 12 | {this.props.a} 13 | 14 | 15 | ) 16 | } 17 | } 18 | 19 | @observer 20 | class Issue806Component2 extends React.Component { 21 | render() { 22 | return ( 23 | 24 | {this.props.propA} - {this.props.propB} 25 | 26 | ) 27 | } 28 | } 29 | 30 | test("verify issue 806", () => { 31 | configure({ 32 | observableRequiresReaction: true 33 | }) 34 | 35 | const x = observable({ 36 | a: 1 37 | }) 38 | 39 | withConsole(["warn"], () => { 40 | render() 41 | expect(console.warn).not.toHaveBeenCalled() 42 | }) 43 | 44 | // make sure observableRequiresReaction is still working outside component 45 | withConsole(["warn"], () => { 46 | x.a.toString() 47 | expect(console.warn).toBeCalledTimes(1) 48 | expect(console.warn).toHaveBeenCalledWith( 49 | "[mobx] Observable ObservableObject@1.a being read outside a reactive context" 50 | ) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /test/misc.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { extendObservable, observable } from "mobx" 3 | import { observer } from "../src" 4 | import { render } from "@testing-library/react" 5 | 6 | test("issue mobx 405", () => { 7 | function ExampleState() { 8 | // @ts-ignore 9 | extendObservable(this, { 10 | name: "test", 11 | get greetings() { 12 | return "Hello my name is " + this.name 13 | } 14 | }) 15 | } 16 | 17 | const ExampleView = observer( 18 | class T extends React.Component { 19 | render() { 20 | return ( 21 |
    22 | (this.props.exampleState.name = e.target.value)} 25 | value={this.props.exampleState.name} 26 | /> 27 | {this.props.exampleState.greetings} 28 |
    29 | ) 30 | } 31 | } 32 | ) 33 | 34 | const exampleState = new ExampleState() 35 | const { container } = render() 36 | expect(container).toMatchInlineSnapshot(` 37 |
    38 |
    39 | 43 | 44 | Hello my name is test 45 | 46 |
    47 |
    48 | `) 49 | }) 50 | 51 | test("#85 Should handle state changing in constructors", () => { 52 | const a = observable.box(2) 53 | const Child = observer( 54 | class Child extends React.Component { 55 | constructor(p) { 56 | super(p) 57 | a.set(3) // one shouldn't do this! 58 | this.state = {} 59 | } 60 | render() { 61 | return ( 62 |
    63 | child: 64 | {a.get()} -{" "} 65 |
    66 | ) 67 | } 68 | } 69 | ) 70 | const ParentWrapper = observer(function Parent() { 71 | return ( 72 | 73 | 74 | parent: 75 | {a.get()} 76 | 77 | ) 78 | }) 79 | const { container } = render() 80 | expect(container).toHaveTextContent("child:3 - parent:3") 81 | 82 | a.set(5) 83 | expect(container).toHaveTextContent("child:5 - parent:5") 84 | 85 | a.set(7) 86 | expect(container).toHaveTextContent("child:7 - parent:7") 87 | }) 88 | -------------------------------------------------------------------------------- /test/observer.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { inject, observer, Observer, enableStaticRendering } from "../src" 3 | import { render, act } from "@testing-library/react" 4 | import { 5 | getObserverTree, 6 | _resetGlobalState, 7 | action, 8 | computed, 9 | observable, 10 | transaction, 11 | makeObservable 12 | } from "mobx" 13 | import { withConsole } from "./utils/withConsole" 14 | /** 15 | * some test suite is too tedious 16 | */ 17 | 18 | afterEach(() => { 19 | jest.useRealTimers() 20 | }) 21 | 22 | /* 23 | use TestUtils.renderIntoDocument will re-mounted the component with different props 24 | some misunderstanding will be cause? 25 | */ 26 | describe("nestedRendering", () => { 27 | let store 28 | 29 | let todoItemRenderings 30 | const TodoItem = observer(function TodoItem(props) { 31 | todoItemRenderings++ 32 | return
  • |{props.todo.title}
  • 33 | }) 34 | 35 | let todoListRenderings 36 | const TodoList = observer( 37 | class TodoList extends React.Component { 38 | render() { 39 | todoListRenderings++ 40 | const todos = store.todos 41 | return ( 42 |
    43 | {todos.length} 44 | {todos.map((todo, idx) => ( 45 | 46 | ))} 47 |
    48 | ) 49 | } 50 | } 51 | ) 52 | 53 | beforeEach(() => { 54 | todoItemRenderings = 0 55 | todoListRenderings = 0 56 | store = observable({ 57 | todos: [ 58 | { 59 | title: "a", 60 | completed: false 61 | } 62 | ] 63 | }) 64 | }) 65 | 66 | test("first rendering", () => { 67 | const { container } = render() 68 | 69 | expect(todoListRenderings).toBe(1) 70 | expect(container.querySelectorAll("li").length).toBe(1) 71 | expect(container.querySelector("li")).toHaveTextContent("|a") 72 | expect(todoItemRenderings).toBe(1) 73 | }) 74 | 75 | test("second rendering with inner store changed", () => { 76 | render() 77 | 78 | store.todos[0].title += "a" 79 | 80 | expect(todoListRenderings).toBe(1) 81 | expect(todoItemRenderings).toBe(2) 82 | expect(getObserverTree(store, "todos").observers!.length).toBe(1) 83 | expect(getObserverTree(store.todos[0], "title").observers!.length).toBe(1) 84 | }) 85 | 86 | test("rerendering with outer store added", () => { 87 | const { container } = render() 88 | 89 | store.todos.push({ 90 | title: "b", 91 | completed: true 92 | }) 93 | 94 | expect(container.querySelectorAll("li").length).toBe(2) 95 | expect( 96 | Array.from(container.querySelectorAll("li")) 97 | .map((e: any) => e.innerHTML) 98 | .sort() 99 | ).toEqual(["|a", "|b"].sort()) 100 | expect(todoListRenderings).toBe(2) 101 | expect(todoItemRenderings).toBe(2) 102 | expect(getObserverTree(store.todos[1], "title").observers!.length).toBe(1) 103 | expect(getObserverTree(store.todos[1], "completed").observers).toBe(undefined) 104 | }) 105 | 106 | test("rerendering with outer store pop", () => { 107 | const { container } = render() 108 | 109 | const oldTodo = store.todos.pop() 110 | 111 | expect(todoListRenderings).toBe(2) 112 | expect(todoItemRenderings).toBe(1) 113 | expect(container.querySelectorAll("li").length).toBe(0) 114 | expect(getObserverTree(oldTodo, "title").observers).toBe(undefined) 115 | expect(getObserverTree(oldTodo, "completed").observers).toBe(undefined) 116 | }) 117 | }) 118 | 119 | describe("isObjectShallowModified detects when React will update the component", () => { 120 | const store = observable({ count: 0 }) 121 | let counterRenderings = 0 122 | const Counter: React.FunctionComponent = observer(function TodoItem() { 123 | counterRenderings++ 124 | return
    {store.count}
    125 | }) 126 | 127 | test("does not assume React will update due to NaN prop", () => { 128 | render() 129 | 130 | store.count++ 131 | 132 | expect(counterRenderings).toBe(2) 133 | }) 134 | }) 135 | 136 | describe("keep views alive", () => { 137 | let yCalcCount 138 | let data 139 | const TestComponent = observer(function testComponent() { 140 | return ( 141 |
    142 | {data.z} 143 | {data.y} 144 |
    145 | ) 146 | }) 147 | 148 | beforeEach(() => { 149 | yCalcCount = 0 150 | data = observable({ 151 | x: 3, 152 | get y() { 153 | yCalcCount++ 154 | return this.x * 2 155 | }, 156 | z: "hi" 157 | }) 158 | }) 159 | 160 | test("init state", () => { 161 | const { container } = render() 162 | 163 | expect(yCalcCount).toBe(1) 164 | expect(container).toHaveTextContent("hi6") 165 | }) 166 | 167 | test("rerender should not need a recomputation of data.y", () => { 168 | const { container } = render() 169 | 170 | data.z = "hello" 171 | 172 | expect(yCalcCount).toBe(1) 173 | expect(container).toHaveTextContent("hello6") 174 | }) 175 | }) 176 | 177 | describe("does not views alive when using static rendering", () => { 178 | let renderCount 179 | let data 180 | 181 | const TestComponent = observer(function testComponent() { 182 | renderCount++ 183 | return
    {data.z}
    184 | }) 185 | 186 | beforeAll(() => { 187 | enableStaticRendering(true) 188 | }) 189 | 190 | beforeEach(() => { 191 | renderCount = 0 192 | data = observable({ 193 | z: "hi" 194 | }) 195 | }) 196 | 197 | afterAll(() => { 198 | enableStaticRendering(false) 199 | }) 200 | 201 | test("init state is correct", () => { 202 | const { container } = render() 203 | 204 | expect(renderCount).toBe(1) 205 | expect(container).toHaveTextContent("hi") 206 | }) 207 | 208 | test("no re-rendering on static rendering", () => { 209 | const { container } = render() 210 | 211 | data.z = "hello" 212 | 213 | expect(getObserverTree(data, "z").observers).toBe(undefined) 214 | expect(renderCount).toBe(1) 215 | expect(container).toHaveTextContent("hi") 216 | }) 217 | }) 218 | 219 | test("issue 12", () => { 220 | const events: Array = [] 221 | const data = observable({ 222 | selected: "coffee", 223 | items: [ 224 | { 225 | name: "coffee" 226 | }, 227 | { 228 | name: "tea" 229 | } 230 | ] 231 | }) 232 | 233 | /** Row Class */ 234 | class Row extends React.Component { 235 | constructor(props) { 236 | super(props) 237 | } 238 | 239 | render() { 240 | events.push("row: " + this.props.item.name) 241 | return ( 242 | 243 | {this.props.item.name} 244 | {data.selected === this.props.item.name ? "!" : ""} 245 | 246 | ) 247 | } 248 | } 249 | /** table stateles component */ 250 | const Table = observer(function table() { 251 | events.push("table") 252 | JSON.stringify(data) 253 | return ( 254 |
    255 | {data.items.map(item => ( 256 | 257 | ))} 258 |
    259 | ) 260 | }) 261 | 262 | const { container } = render() 263 | expect(container).toMatchSnapshot() 264 | 265 | act(() => { 266 | transaction(() => { 267 | data.items[1].name = "boe" 268 | data.items.splice(0, 2, { name: "soup" }) 269 | data.selected = "tea" 270 | }) 271 | }) 272 | expect(container).toMatchSnapshot() 273 | expect(events).toEqual(["table", "row: coffee", "row: tea", "table", "row: soup"]) 274 | }) 275 | 276 | test("changing state in render should fail", () => { 277 | const data = observable.box(2) 278 | const Comp = observer(() => { 279 | if (data.get() === 3) { 280 | try { 281 | data.set(4) // wouldn't throw first time for lack of observers.. (could we tighten this?) 282 | } catch (err) { 283 | expect( 284 | /Side effects like changing state are not allowed at this point/.test(err) 285 | ).toBeTruthy() 286 | } 287 | } 288 | return
    {data.get()}
    289 | }) 290 | render() 291 | 292 | data.set(3) 293 | _resetGlobalState() 294 | }) 295 | 296 | test("observer component can be injected", () => { 297 | const msg: Array = [] 298 | const baseWarn = console.warn 299 | console.warn = m => msg.push(m) 300 | 301 | inject("foo")( 302 | observer( 303 | class T extends React.Component { 304 | render() { 305 | return null 306 | } 307 | } 308 | ) 309 | ) 310 | 311 | // N.B, the injected component will be observer since mobx-react 4.0! 312 | inject(() => {})( 313 | observer( 314 | class T extends React.Component { 315 | render() { 316 | return null 317 | } 318 | } 319 | ) 320 | ) 321 | 322 | expect(msg.length).toBe(0) 323 | console.warn = baseWarn 324 | }) 325 | 326 | test("correctly wraps display name of child component", () => { 327 | const A = observer( 328 | class ObserverClass extends React.Component { 329 | render() { 330 | return null 331 | } 332 | } 333 | ) 334 | const B: React.FunctionComponent = observer(function StatelessObserver() { 335 | return null 336 | }) 337 | 338 | expect(A.name).toEqual("ObserverClass") 339 | expect(B.displayName).toEqual("StatelessObserver") 340 | }) 341 | 342 | describe("124 - react to changes in this.props via computed", () => { 343 | class T extends React.Component { 344 | @computed 345 | get computedProp() { 346 | return this.props.x 347 | } 348 | render() { 349 | return ( 350 | 351 | x: 352 | {this.computedProp} 353 | 354 | ) 355 | } 356 | } 357 | 358 | const Comp = observer(T) 359 | 360 | class Parent extends React.Component { 361 | state = { v: 1 } 362 | render() { 363 | return ( 364 |
    this.setState({ v: 2 })}> 365 | 366 |
    367 | ) 368 | } 369 | } 370 | 371 | test("init state is correct", () => { 372 | const { container } = render() 373 | 374 | expect(container).toHaveTextContent("x:1") 375 | }) 376 | 377 | test("change after click", () => { 378 | const { container } = render() 379 | 380 | container.querySelector("div")!.click() 381 | expect(container).toHaveTextContent("x:2") 382 | }) 383 | }) 384 | 385 | // Test on skip: since all reactions are now run in batched updates, the original issues can no longer be reproduced 386 | //this test case should be deprecated? 387 | test("should stop updating if error was thrown in render (#134)", () => { 388 | const data = observable.box(0) 389 | let renderingsCount = 0 390 | let lastOwnRenderCount = 0 391 | const errors: Array = [] 392 | 393 | class Outer extends React.Component { 394 | state = { hasError: false } 395 | 396 | render() { 397 | return this.state.hasError ?
    Error!
    :
    {this.props.children}
    398 | } 399 | 400 | static getDerivedStateFromError() { 401 | return { hasError: true } 402 | } 403 | 404 | componentDidCatch(error, info) { 405 | errors.push(error.toString().split("\n")[0], info) 406 | } 407 | } 408 | 409 | const Comp = observer( 410 | class X extends React.Component { 411 | ownRenderCount = 0 412 | 413 | render() { 414 | lastOwnRenderCount = ++this.ownRenderCount 415 | renderingsCount++ 416 | if (data.get() === 2) { 417 | throw new Error("Hello") 418 | } 419 | return
    420 | } 421 | } 422 | ) 423 | render( 424 | 425 | 426 | 427 | ) 428 | 429 | // Check this 430 | // @ts-ignore 431 | expect(getObserverTree(data).observers!.length).toBe(1) 432 | data.set(1) 433 | expect(renderingsCount).toBe(2) 434 | expect(lastOwnRenderCount).toBe(2) 435 | withConsole(() => { 436 | data.set(2) 437 | }) 438 | 439 | // @ts-ignore 440 | expect(getObserverTree(data).observers).toBe(undefined) 441 | data.set(3) 442 | data.set(4) 443 | data.set(2) 444 | data.set(5) 445 | expect(errors).toMatchSnapshot() 446 | expect(lastOwnRenderCount).toBe(4) 447 | expect(renderingsCount).toBe(4) 448 | }) 449 | 450 | describe("should render component even if setState called with exactly the same props", () => { 451 | let renderCount 452 | const Comp = observer( 453 | class T extends React.Component { 454 | onClick = () => { 455 | this.setState({}) 456 | } 457 | render() { 458 | renderCount++ 459 | return
    460 | } 461 | } 462 | ) 463 | 464 | beforeEach(() => { 465 | renderCount = 0 466 | }) 467 | 468 | test("renderCount === 1", () => { 469 | render() 470 | 471 | expect(renderCount).toBe(1) 472 | }) 473 | 474 | test("after click once renderCount === 2", () => { 475 | const { container } = render() 476 | const clickableDiv = container.querySelector("#clickableDiv") as HTMLElement 477 | 478 | clickableDiv.click() 479 | 480 | expect(renderCount).toBe(2) 481 | }) 482 | 483 | test("after click twice renderCount === 3", () => { 484 | const { container } = render() 485 | const clickableDiv = container.querySelector("#clickableDiv") as HTMLElement 486 | 487 | clickableDiv.click() 488 | clickableDiv.click() 489 | 490 | expect(renderCount).toBe(3) 491 | }) 492 | }) 493 | 494 | test("it rerenders correctly if some props are non-observables - 1", () => { 495 | let odata = observable({ x: 1 }) 496 | let data = { y: 1 } 497 | 498 | @observer 499 | class Comp extends React.Component { 500 | @computed 501 | get computed() { 502 | // n.b: data.y would not rerender! shallowly new equal props are not stored 503 | return this.props.odata.x 504 | } 505 | render() { 506 | return ( 507 | 508 | {this.props.odata.x}-{this.props.data.y}-{this.computed} 509 | 510 | ) 511 | } 512 | } 513 | 514 | const Parent = observer( 515 | class Parent extends React.Component { 516 | render() { 517 | // this.props.odata.x; 518 | return 519 | } 520 | } 521 | ) 522 | 523 | function stuff() { 524 | act(() => { 525 | data.y++ 526 | odata.x++ 527 | }) 528 | } 529 | 530 | const { container } = render() 531 | 532 | expect(container).toHaveTextContent("1-1-1") 533 | stuff() 534 | expect(container).toHaveTextContent("2-2-2") 535 | stuff() 536 | expect(container).toHaveTextContent("3-3-3") 537 | }) 538 | 539 | test("it rerenders correctly if some props are non-observables - 2", () => { 540 | let renderCount = 0 541 | let odata = observable({ x: 1 }) 542 | 543 | @observer 544 | class Component extends React.PureComponent { 545 | @computed 546 | get computed() { 547 | return this.props.data.y // should recompute, since props.data is changed 548 | } 549 | 550 | render() { 551 | renderCount++ 552 | return ( 553 | 554 | {this.props.data.y}-{this.computed} 555 | 556 | ) 557 | } 558 | } 559 | 560 | const Parent = observer(props => { 561 | let data = { y: props.odata.x } 562 | return 563 | }) 564 | 565 | function stuff() { 566 | odata.x++ 567 | } 568 | 569 | const { container } = render() 570 | 571 | expect(renderCount).toBe(1) 572 | expect(container).toHaveTextContent("1-1") 573 | 574 | act(() => stuff()) 575 | expect(renderCount).toBe(2) 576 | expect(container).toHaveTextContent("2-2") 577 | 578 | act(() => stuff()) 579 | expect(renderCount).toBe(3) 580 | expect(container).toHaveTextContent("3-3") 581 | }) 582 | 583 | describe("Observer regions should react", () => { 584 | let data 585 | const Comp = () => ( 586 |
    587 | {() => {data.get()}} 588 | {data.get()} 589 |
    590 | ) 591 | 592 | beforeEach(() => { 593 | data = observable.box("hi") 594 | }) 595 | 596 | test("init state is correct", () => { 597 | const { queryByTestId } = render() 598 | 599 | expect(queryByTestId("inside-of-observer")).toHaveTextContent("hi") 600 | expect(queryByTestId("outside-of-observer")).toHaveTextContent("hi") 601 | }) 602 | 603 | test("set the data to hello", () => { 604 | const { queryByTestId } = render() 605 | 606 | data.set("hello") 607 | 608 | expect(queryByTestId("inside-of-observer")).toHaveTextContent("hello") 609 | expect(queryByTestId("outside-of-observer")).toHaveTextContent("hi") 610 | }) 611 | }) 612 | 613 | test("Observer should not re-render on shallow equal new props", () => { 614 | let childRendering = 0 615 | let parentRendering = 0 616 | const data = { x: 1 } 617 | const odata = observable({ y: 1 }) 618 | 619 | const Child = observer(({ data }) => { 620 | childRendering++ 621 | return {data.x} 622 | }) 623 | const Parent = observer(() => { 624 | parentRendering++ 625 | odata.y /// depend 626 | return 627 | }) 628 | 629 | const { container } = render() 630 | 631 | expect(parentRendering).toBe(1) 632 | expect(childRendering).toBe(1) 633 | expect(container).toHaveTextContent("1") 634 | 635 | act(() => { 636 | odata.y++ 637 | }) 638 | expect(parentRendering).toBe(2) 639 | expect(childRendering).toBe(1) 640 | expect(container).toHaveTextContent("1") 641 | }) 642 | 643 | test("parent / childs render in the right order", () => { 644 | // See: https://jsfiddle.net/gkaemmer/q1kv7hbL/13/ 645 | let events: Array = [] 646 | 647 | class User { 648 | @observable 649 | name = "User's name" 650 | } 651 | 652 | class Store { 653 | @observable 654 | user: User | null = new User() 655 | @action 656 | logout() { 657 | this.user = null 658 | } 659 | constructor() { 660 | makeObservable(this) 661 | } 662 | } 663 | 664 | function tryLogout() { 665 | try { 666 | // ReactDOM.unstable_batchedUpdates(() => { 667 | store.logout() 668 | expect(true).toBeTruthy() 669 | // }); 670 | } catch (e) { 671 | // t.fail(e) 672 | } 673 | } 674 | 675 | const store = new Store() 676 | 677 | const Parent = observer(() => { 678 | events.push("parent") 679 | if (!store.user) return Not logged in. 680 | return ( 681 |
    682 | 683 | 684 |
    685 | ) 686 | }) 687 | 688 | const Child = observer(() => { 689 | events.push("child") 690 | return Logged in as: {store.user?.name} 691 | }) 692 | 693 | render() 694 | 695 | tryLogout() 696 | expect(events).toEqual(["parent", "child", "parent"]) 697 | }) 698 | 699 | describe("use Observer inject and render sugar should work ", () => { 700 | test("use render without inject should be correct", () => { 701 | const Comp = () => ( 702 |
    703 | {123}} /> 704 |
    705 | ) 706 | const { container } = render() 707 | expect(container).toHaveTextContent("123") 708 | }) 709 | 710 | test("use children without inject should be correct", () => { 711 | const Comp = () => ( 712 |
    713 | {() => {123}} 714 |
    715 | ) 716 | const { container } = render() 717 | expect(container).toHaveTextContent("123") 718 | }) 719 | 720 | test("show error when using children and render at same time ", () => { 721 | const msg: Array = [] 722 | const baseError = console.error 723 | console.error = m => msg.push(m) 724 | 725 | const Comp = () => ( 726 |
    727 | {123}}>{() => {123}} 728 |
    729 | ) 730 | 731 | render() 732 | expect(msg.length).toBe(1) 733 | console.error = baseError 734 | }) 735 | }) 736 | 737 | test("use PureComponent", () => { 738 | const msg: Array = [] 739 | const baseWarn = console.warn 740 | console.warn = m => msg.push(m) 741 | 742 | try { 743 | observer( 744 | class X extends React.PureComponent { 745 | return() { 746 | return
    747 | } 748 | } 749 | ) 750 | 751 | expect(msg).toEqual([]) 752 | } finally { 753 | console.warn = baseWarn 754 | } 755 | }) 756 | 757 | test("static on function components are hoisted", () => { 758 | const Comp = () =>
    759 | Comp.foo = 3 760 | 761 | const Comp2 = observer(Comp) 762 | 763 | expect(Comp2.foo).toBe(3) 764 | }) 765 | 766 | test("computed properties react to props", () => { 767 | jest.useFakeTimers() 768 | 769 | const seen: Array = [] 770 | @observer 771 | class Child extends React.Component { 772 | @computed 773 | get getPropX() { 774 | return this.props.x 775 | } 776 | 777 | render() { 778 | seen.push(this.getPropX) 779 | return
    {this.getPropX}
    780 | } 781 | } 782 | 783 | class Parent extends React.Component { 784 | state = { x: 0 } 785 | render() { 786 | seen.push("parent") 787 | return 788 | } 789 | 790 | componentDidMount() { 791 | setTimeout(() => this.setState({ x: 2 }), 100) 792 | } 793 | } 794 | 795 | const { container } = render() 796 | expect(container).toHaveTextContent("0") 797 | 798 | jest.runAllTimers() 799 | expect(container).toHaveTextContent("2") 800 | 801 | expect(seen).toEqual(["parent", 0, "parent", 2]) 802 | }) 803 | 804 | test("#692 - componentDidUpdate is triggered", () => { 805 | jest.useFakeTimers() 806 | 807 | let cDUCount = 0 808 | 809 | @observer 810 | class Test extends React.Component { 811 | @observable 812 | counter = 0 813 | 814 | @action 815 | inc = () => this.counter++ 816 | 817 | constructor(props) { 818 | super(props) 819 | makeObservable(this) 820 | setTimeout(() => this.inc(), 300) 821 | } 822 | 823 | render() { 824 | return

    {this.counter}

    825 | } 826 | 827 | componentDidUpdate() { 828 | cDUCount++ 829 | } 830 | } 831 | render() 832 | expect(cDUCount).toBe(0) 833 | 834 | jest.runAllTimers() 835 | expect(cDUCount).toBe(1) 836 | }) 837 | 838 | // Not possible to properly test error catching (see ErrorCatcher) 839 | test.skip("#709 - applying observer on React.memo component", () => { 840 | const WithMemo = React.memo(() => { 841 | return null 842 | }) 843 | 844 | const Observed = observer(WithMemo) 845 | // @ts-ignore 846 | // eslint-disable-next-line no-undef 847 | render(, { wrapper: ErrorCatcher }) 848 | }) 849 | 850 | test("#797 - replacing this.render should trigger a warning", () => { 851 | const warn = jest.spyOn(global.console, "warn") 852 | @observer 853 | class Component extends React.Component { 854 | render() { 855 | return
    856 | } 857 | swapRenderFunc() { 858 | this.render = () => { 859 | return 860 | } 861 | } 862 | } 863 | 864 | const compRef = React.createRef() 865 | const { unmount } = render() 866 | compRef.current?.swapRenderFunc() 867 | unmount() 868 | 869 | expect(warn).toHaveBeenCalled() 870 | }) 871 | 872 | test("Redeclaring an existing observer component as an observer should log a warning", () => { 873 | const warn = jest.spyOn(global.console, "warn") 874 | @observer 875 | class AlreadyObserver extends React.Component { 876 | render() { 877 | return
    878 | } 879 | } 880 | 881 | observer(AlreadyObserver) 882 | expect(warn).toHaveBeenCalled() 883 | }) 884 | -------------------------------------------------------------------------------- /test/propTypes.test.ts: -------------------------------------------------------------------------------- 1 | import PropTypes from "prop-types" 2 | import { PropTypes as MRPropTypes } from "../src" 3 | import { observable } from "mobx" 4 | 5 | // Cause `checkPropTypes` caches errors and doesn't print them twice.... 6 | // https://github.com/facebook/prop-types/issues/91 7 | let testComponentId = 0 8 | 9 | function typeCheckFail(declaration, value, message) { 10 | const baseError = console.error 11 | let error = "" 12 | console.error = msg => { 13 | error = msg 14 | } 15 | 16 | const props = { testProp: value } 17 | const propTypes = { testProp: declaration } 18 | 19 | const compId = "testComponent" + ++testComponentId 20 | PropTypes.checkPropTypes(propTypes, props, "prop", compId) 21 | 22 | error = error.replace(compId, "testComponent") 23 | expect(error).toBe("Warning: Failed prop type: " + message) 24 | console.error = baseError 25 | } 26 | 27 | function typeCheckFailRequiredValues(declaration) { 28 | const baseError = console.error 29 | let error = "" 30 | console.error = msg => { 31 | error = msg 32 | } 33 | 34 | const propTypes = { testProp: declaration } 35 | const specifiedButIsNullMsg = /but its value is `null`\./ 36 | const unspecifiedMsg = /but its value is `undefined`\./ 37 | 38 | const props1 = { testProp: null } 39 | PropTypes.checkPropTypes(propTypes, props1, "testProp", "testComponent" + ++testComponentId) 40 | expect(specifiedButIsNullMsg.test(error)).toBeTruthy() 41 | 42 | error = "" 43 | const props2 = { testProp: undefined } 44 | PropTypes.checkPropTypes(propTypes, props2, "testProp", "testComponent" + ++testComponentId) 45 | expect(unspecifiedMsg.test(error)).toBeTruthy() 46 | 47 | error = "" 48 | const props3 = {} 49 | PropTypes.checkPropTypes(propTypes, props3, "testProp", "testComponent" + ++testComponentId) 50 | expect(unspecifiedMsg.test(error)).toBeTruthy() 51 | 52 | console.error = baseError 53 | } 54 | 55 | function typeCheckPass(declaration: any, value?: any) { 56 | const props = { testProp: value } 57 | const error = PropTypes.checkPropTypes( 58 | { testProp: declaration }, 59 | props, 60 | "testProp", 61 | "testComponent" + ++testComponentId 62 | ) 63 | expect(error).toBeUndefined() 64 | } 65 | 66 | test("Valid values", () => { 67 | typeCheckPass(MRPropTypes.observableArray, observable([])) 68 | typeCheckPass(MRPropTypes.observableArrayOf(PropTypes.string), observable([""])) 69 | typeCheckPass(MRPropTypes.arrayOrObservableArray, observable([])) 70 | typeCheckPass(MRPropTypes.arrayOrObservableArray, []) 71 | typeCheckPass(MRPropTypes.arrayOrObservableArrayOf(PropTypes.string), observable([""])) 72 | typeCheckPass(MRPropTypes.arrayOrObservableArrayOf(PropTypes.string), [""]) 73 | typeCheckPass(MRPropTypes.observableObject, observable({})) 74 | typeCheckPass(MRPropTypes.objectOrObservableObject, {}) 75 | typeCheckPass(MRPropTypes.objectOrObservableObject, observable({})) 76 | typeCheckPass(MRPropTypes.observableMap, observable(observable.map({}, { deep: false }))) 77 | }) 78 | 79 | test("should be implicitly optional and not warn", () => { 80 | typeCheckPass(MRPropTypes.observableArray) 81 | typeCheckPass(MRPropTypes.observableArrayOf(PropTypes.string)) 82 | typeCheckPass(MRPropTypes.arrayOrObservableArray) 83 | typeCheckPass(MRPropTypes.arrayOrObservableArrayOf(PropTypes.string)) 84 | typeCheckPass(MRPropTypes.observableObject) 85 | typeCheckPass(MRPropTypes.objectOrObservableObject) 86 | typeCheckPass(MRPropTypes.observableMap) 87 | }) 88 | 89 | test("should warn for missing required values, function (test)", () => { 90 | typeCheckFailRequiredValues(MRPropTypes.observableArray.isRequired) 91 | typeCheckFailRequiredValues(MRPropTypes.observableArrayOf(PropTypes.string).isRequired) 92 | typeCheckFailRequiredValues(MRPropTypes.arrayOrObservableArray.isRequired) 93 | typeCheckFailRequiredValues(MRPropTypes.arrayOrObservableArrayOf(PropTypes.string).isRequired) 94 | typeCheckFailRequiredValues(MRPropTypes.observableObject.isRequired) 95 | typeCheckFailRequiredValues(MRPropTypes.objectOrObservableObject.isRequired) 96 | typeCheckFailRequiredValues(MRPropTypes.observableMap.isRequired) 97 | }) 98 | 99 | test("should fail date and regexp correctly", () => { 100 | typeCheckFail( 101 | MRPropTypes.observableObject, 102 | new Date(), 103 | "Invalid prop `testProp` of type `date` supplied to " + 104 | "`testComponent`, expected `mobx.ObservableObject`." 105 | ) 106 | typeCheckFail( 107 | MRPropTypes.observableArray, 108 | /please/, 109 | "Invalid prop `testProp` of type `regexp` supplied to " + 110 | "`testComponent`, expected `mobx.ObservableArray`." 111 | ) 112 | }) 113 | 114 | test("observableArray", () => { 115 | typeCheckFail( 116 | MRPropTypes.observableArray, 117 | [], 118 | "Invalid prop `testProp` of type `array` supplied to " + 119 | "`testComponent`, expected `mobx.ObservableArray`." 120 | ) 121 | typeCheckFail( 122 | MRPropTypes.observableArray, 123 | "", 124 | "Invalid prop `testProp` of type `string` supplied to " + 125 | "`testComponent`, expected `mobx.ObservableArray`." 126 | ) 127 | }) 128 | 129 | test("arrayOrObservableArray", () => { 130 | typeCheckFail( 131 | MRPropTypes.arrayOrObservableArray, 132 | "", 133 | "Invalid prop `testProp` of type `string` supplied to " + 134 | "`testComponent`, expected `mobx.ObservableArray` or javascript `array`." 135 | ) 136 | }) 137 | 138 | test("observableObject", () => { 139 | typeCheckFail( 140 | MRPropTypes.observableObject, 141 | {}, 142 | "Invalid prop `testProp` of type `object` supplied to " + 143 | "`testComponent`, expected `mobx.ObservableObject`." 144 | ) 145 | typeCheckFail( 146 | MRPropTypes.observableObject, 147 | "", 148 | "Invalid prop `testProp` of type `string` supplied to " + 149 | "`testComponent`, expected `mobx.ObservableObject`." 150 | ) 151 | }) 152 | 153 | test("objectOrObservableObject", () => { 154 | typeCheckFail( 155 | MRPropTypes.objectOrObservableObject, 156 | "", 157 | "Invalid prop `testProp` of type `string` supplied to " + 158 | "`testComponent`, expected `mobx.ObservableObject` or javascript `object`." 159 | ) 160 | }) 161 | 162 | test("observableMap", () => { 163 | typeCheckFail( 164 | MRPropTypes.observableMap, 165 | {}, 166 | "Invalid prop `testProp` of type `object` supplied to " + 167 | "`testComponent`, expected `mobx.ObservableMap`." 168 | ) 169 | }) 170 | 171 | test("observableArrayOf", () => { 172 | typeCheckFail( 173 | MRPropTypes.observableArrayOf(PropTypes.string), 174 | 2, 175 | "Invalid prop `testProp` of type `number` supplied to " + 176 | "`testComponent`, expected `mobx.ObservableArray`." 177 | ) 178 | typeCheckFail( 179 | MRPropTypes.observableArrayOf(PropTypes.string), 180 | observable([2]), 181 | "Invalid prop `testProp[0]` of type `number` supplied to " + 182 | "`testComponent`, expected `string`." 183 | ) 184 | typeCheckFail( 185 | MRPropTypes.observableArrayOf({ foo: (MRPropTypes as any).string } as any), 186 | { foo: "bar" }, 187 | "Property `testProp` of component `testComponent` has invalid PropType notation." 188 | ) 189 | }) 190 | 191 | test("arrayOrObservableArrayOf", () => { 192 | typeCheckFail( 193 | MRPropTypes.arrayOrObservableArrayOf(PropTypes.string), 194 | 2, 195 | "Invalid prop `testProp` of type `number` supplied to " + 196 | "`testComponent`, expected `mobx.ObservableArray` or javascript `array`." 197 | ) 198 | typeCheckFail( 199 | MRPropTypes.arrayOrObservableArrayOf(PropTypes.string), 200 | observable([2]), 201 | "Invalid prop `testProp[0]` of type `number` supplied to " + 202 | "`testComponent`, expected `string`." 203 | ) 204 | typeCheckFail( 205 | MRPropTypes.arrayOrObservableArrayOf(PropTypes.string), 206 | [2], 207 | "Invalid prop `testProp[0]` of type `number` supplied to " + 208 | "`testComponent`, expected `string`." 209 | ) 210 | // TODO: 211 | typeCheckFail( 212 | MRPropTypes.arrayOrObservableArrayOf({ foo: (MRPropTypes as any).string } as any), 213 | { foo: "bar" }, 214 | "Property `testProp` of component `testComponent` has invalid PropType notation." 215 | ) 216 | }) 217 | -------------------------------------------------------------------------------- /test/stateless.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from "prop-types" 3 | import { observer, PropTypes as MRPropTypes } from "../src" 4 | import { render, act } from "@testing-library/react" 5 | import { observable } from "mobx" 6 | 7 | const StatelessComp = ({ testProp }) =>
    result: {testProp}
    8 | 9 | StatelessComp.propTypes = { 10 | testProp: PropTypes.string 11 | } 12 | StatelessComp.defaultProps = { 13 | testProp: "default value for prop testProp" 14 | } 15 | 16 | describe("stateless component with propTypes", () => { 17 | const StatelessCompObserver: React.FunctionComponent = observer(StatelessComp) 18 | 19 | test("default property value should be propagated", () => { 20 | expect(StatelessComp.defaultProps.testProp).toBe("default value for prop testProp") 21 | expect(StatelessCompObserver.defaultProps!.testProp).toBe("default value for prop testProp") 22 | }) 23 | 24 | const originalConsoleError = console.error 25 | let beenWarned = false 26 | console.error = () => (beenWarned = true) 27 | // eslint-disable-next-line no-unused-vars 28 | const wrapper = 29 | console.error = originalConsoleError 30 | 31 | test("an error should be logged with a property type warning", () => { 32 | expect(beenWarned).toBeTruthy() 33 | }) 34 | 35 | test("render test correct", async () => { 36 | const { container } = render() 37 | expect(container.textContent).toBe("result: hello world") 38 | }) 39 | }) 40 | 41 | test("stateless component with context support", () => { 42 | const C = React.createContext({}) 43 | 44 | const StateLessCompWithContext = () => ( 45 | {value =>
    context: {value.testContext}
    }
    46 | ) 47 | 48 | const StateLessCompWithContextObserver = observer(StateLessCompWithContext) 49 | 50 | const ContextProvider = () => ( 51 | 52 | 53 | 54 | ) 55 | 56 | const { container } = render() 57 | expect(container.textContent).toBe("context: hello world") 58 | }) 59 | 60 | test("component with observable propTypes", () => { 61 | class Comp extends React.Component { 62 | render() { 63 | return null 64 | } 65 | static propTypes = { 66 | a1: MRPropTypes.observableArray, 67 | a2: MRPropTypes.arrayOrObservableArray 68 | } 69 | } 70 | const originalConsoleError = console.error 71 | const warnings: Array = [] 72 | console.error = msg => warnings.push(msg) 73 | // eslint-disable-next-line no-unused-vars 74 | const firstWrapper = 75 | expect(warnings.length).toBe(1) 76 | // eslint-disable-next-line no-unused-vars 77 | const secondWrapper = 78 | expect(warnings.length).toBe(1) 79 | console.error = originalConsoleError 80 | }) 81 | 82 | describe("stateless component with forwardRef", () => { 83 | const a = observable({ 84 | x: 1 85 | }) 86 | const ForwardRefCompObserver: React.ForwardRefExoticComponent = observer( 87 | React.forwardRef(({ testProp }, ref) => { 88 | return ( 89 |
    90 | result: {testProp}, {ref ? "got ref" : "no ref"}, a.x: {a.x} 91 |
    92 | ) 93 | }) 94 | ) 95 | 96 | test("render test correct", () => { 97 | const { container } = render( 98 | 99 | ) 100 | expect(container).toMatchSnapshot() 101 | }) 102 | 103 | test("is reactive", () => { 104 | const { container } = render( 105 | 106 | ) 107 | act(() => { 108 | a.x++ 109 | }) 110 | expect(container).toMatchSnapshot() 111 | }) 112 | }) 113 | -------------------------------------------------------------------------------- /test/symbol.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { observer } from "../src" 3 | import { render } from "@testing-library/react" 4 | import { newSymbol } from "../src/utils/utils" 5 | 6 | // eslint-disable-next-line no-undef 7 | delete global.Symbol 8 | 9 | test("work without Symbol", () => { 10 | const Component1 = observer( 11 | class extends React.Component { 12 | render() { 13 | return null 14 | } 15 | } 16 | ) 17 | render() 18 | }) 19 | 20 | test("cache newSymbol created Symbols", () => { 21 | const symbol1 = newSymbol("name") 22 | const symbol2 = newSymbol("name") 23 | 24 | expect(symbol1).toEqual(symbol2) 25 | }) 26 | -------------------------------------------------------------------------------- /test/transactions.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { autorun, computed, observable, transaction } from "mobx" 3 | import { observer } from "../src" 4 | import { render } from "@testing-library/react" 5 | 6 | test("mobx issue 50", async () => { 7 | const foo = { 8 | a: observable.box(true), 9 | b: observable.box(false), 10 | c: computed(function() { 11 | // console.log("evaluate c") 12 | return foo.b.get() 13 | }) 14 | } 15 | function flipStuff() { 16 | transaction(() => { 17 | foo.a.set(!foo.a.get()) 18 | foo.b.set(!foo.b.get()) 19 | }) 20 | } 21 | let asText = "" 22 | autorun(() => (asText = [foo.a.get(), foo.b.get(), foo.c.get()].join(":"))) 23 | const Test = observer( 24 | class Test extends React.Component { 25 | render() { 26 | return
    {[foo.a.get(), foo.b.get(), foo.c.get()].join(",")}
    27 | } 28 | } 29 | ) 30 | 31 | render() 32 | 33 | // Flip a and b. This will change c. 34 | flipStuff() 35 | 36 | expect(asText).toBe("false:true:true") 37 | expect(document.getElementById("x")!.innerHTML).toBe("false,true,true") 38 | }) 39 | 40 | test("ReactDOM.render should respect transaction", () => { 41 | const a = observable.box(2) 42 | const loaded = observable.box(false) 43 | const valuesSeen: Array = [] 44 | 45 | const Component = observer(() => { 46 | valuesSeen.push(a.get()) 47 | if (loaded.get()) return
    {a.get()}
    48 | else return
    loading
    49 | }) 50 | 51 | const { container } = render() 52 | 53 | transaction(() => { 54 | a.set(3) 55 | a.set(4) 56 | loaded.set(true) 57 | }) 58 | 59 | expect(container.textContent).toBe("4") 60 | expect(valuesSeen.sort()).toEqual([2, 4].sort()) 61 | }) 62 | 63 | test("ReactDOM.render in transaction should succeed", () => { 64 | const a = observable.box(2) 65 | const loaded = observable.box(false) 66 | const valuesSeen: Array = [] 67 | const Component = observer(() => { 68 | valuesSeen.push(a.get()) 69 | if (loaded.get()) return
    {a.get()}
    70 | else return
    loading
    71 | }) 72 | 73 | let container 74 | 75 | transaction(() => { 76 | a.set(3) 77 | container = render().container 78 | a.set(4) 79 | loaded.set(true) 80 | }) 81 | 82 | expect(container.textContent).toBe("4") 83 | expect(valuesSeen.sort()).toEqual([3, 4].sort()) 84 | }) 85 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.7.5", 3 | "compilerOptions": { 4 | "esModuleInterop": true, 5 | "experimentalDecorators": true, 6 | "jsx": "react", 7 | "lib": ["es6", "dom"], 8 | "module": "commonjs", 9 | "noEmit": true, 10 | "noUnusedParameters": true, 11 | "noImplicitAny": false, 12 | "rootDir": "../../", 13 | "strict": true, 14 | "target": "es5", 15 | }, 16 | "exclude": [ 17 | 18 | ] 19 | } -------------------------------------------------------------------------------- /test/utils/withConsole.ts: -------------------------------------------------------------------------------- 1 | import mockConsole, { MockObj } from "jest-mock-console" 2 | 3 | export function withConsole(fn: Function): void 4 | export function withConsole(settings: MockObj, fn: Function): void 5 | export function withConsole(props: Array, fn: Function): void 6 | 7 | export function withConsole(...args: Array): void { 8 | let settings 9 | let fn 10 | if (typeof args[0] === "function") { 11 | fn = args[0] 12 | } else if (Array.isArray(args[0]) || typeof args[0] === "object") { 13 | settings = args[0] 14 | 15 | if (typeof args[1] === "function") { 16 | fn = args[1] 17 | } 18 | } 19 | const restoreConsole = mockConsole(settings) 20 | fn && fn() 21 | restoreConsole() 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "declaration": true, 5 | "downlevelIteration": true, 6 | "emitDecoratorMetadata": true, 7 | "esModuleInterop": true, 8 | "experimentalDecorators": true, 9 | "importHelpers": true, 10 | "jsx": "react", 11 | "lib": ["esnext", "dom"], 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "noImplicitAny": false, 15 | "noImplicitReturns": true, 16 | "noImplicitThis": false, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": false, 19 | "outDir": "dist", 20 | "removeComments": false, 21 | "sourceMap": false, 22 | "strict": true, 23 | "suppressImplicitAnyIndexErrors": true, 24 | "target": "es5" 25 | }, 26 | "include": ["src/**/*.ts", "test/**/*.ts"] 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noUnusedLocals": false 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tsdx.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rollup(config) { 3 | return { 4 | ...config, 5 | output: { 6 | ...config.output, 7 | globals: { 8 | react: "React", 9 | mobx: "mobx", 10 | "react-dom": "ReactDOM", 11 | "mobx-react-lite": "mobxReactLite" 12 | } 13 | } 14 | } 15 | } 16 | } 17 | --------------------------------------------------------------------------------