├── .editorconfig ├── .gitattributes ├── .gitignore ├── .prettierrc ├── .travis.yml ├── .vscode ├── launch.json └── settings.json ├── .watchmanconfig ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── publish.js ├── rollup.config.js ├── src ├── ObservableGroupMap.ts ├── array.ts ├── chunk-processor.ts ├── computedFn.ts ├── create-transformer.ts ├── create-view-model.ts ├── decorator-utils.ts ├── deepMap.ts ├── deepObserve.ts ├── expr.ts ├── from-promise.ts ├── from-resource.ts ├── keep-alive.ts ├── lazy-observable.ts ├── mobx-utils.ts ├── now.ts ├── observable-stream.ts ├── queue-processor.ts ├── tsconfig.json └── utils.ts ├── test ├── ObservableGroupMap.test.ts ├── __snapshots__ │ ├── array.ts.snap │ ├── computedFn.ts.snap │ ├── create-transformer.ts.snap │ ├── deepMap.ts.snap │ └── deepObserve.ts.snap ├── array.ts ├── chunk-processor.js ├── computedFn.ts ├── create-transformer.ts ├── create-view-model.ts ├── deepMap.ts ├── deepObserve.ts ├── expr.ts ├── from-promise.js ├── from-resource.js ├── keep-alive.js ├── lazy-observable.js ├── now.js ├── observable-stream.ts ├── queue-processor.js └── type-tests.ts ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | indent_style = space 7 | tab_width = 4 8 | 9 | [{package.json}] 10 | indent_style = space 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | CHANGELOG.md coverage lib LICENSE mobx-utils.module.js mobx-utils.umd.js node_modules package.json publish.js README.md src test tsconfig.json yarn.lock text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | lib 4 | coverage 5 | mobx-utils.module.js 6 | mobx-utils.umd.js 7 | .idea 8 | /.test-ts/ 9 | *.iml 10 | *.ipr 11 | *.iws 12 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": false, 4 | "tabWidth": 4, 5 | "singleQuote": false, 6 | "useTabs": false 7 | } 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 10 4 | cache: 5 | directories: 6 | - node_modules 7 | install: 8 | - yarn install 9 | script: 10 | - yarn run coverage 11 | after_success: 12 | - cat ./coverage/lcov.info|./node_modules/coveralls/bin/coveralls.js 13 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Jest Current File", 8 | "program": "${workspaceFolder}/node_modules/jest/bin/jest", 9 | "args": ["--ci", "-i", "${fileBasenameNoExtension}"], 10 | "console": "integratedTerminal", 11 | "internalConsoleOptions": "neverOpen", 12 | "disableOptimisticBPs": true, 13 | "windows": { 14 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 15 | }, 16 | "skipFiles": ["${workspaceFolder}/node_modules/**/*.js", "/**/*.js"], 17 | "cwd": "${workspaceFolder}" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 6.0.8 2 | 3 | * [Fix now() sharing global state between tests #316](https://github.com/mobxjs/mobx-utils/pull/316), fixes [#306](https://github.com/mobxjs/mobx-utils/issues/306). 4 | 5 | # 6.0.7 6 | 7 | * [fix fromPromise typing #315](https://github.com/mobxjs/mobx-utils/pull/315) 8 | * [Update computedFn warning to respect mobx global computedRequiresReaction #318](https://github.com/mobxjs/mobx-utils/pull/318), fixes [#268](https://github.com/mobxjs/mobx-utils/issues/268). 9 | 10 | # 6.0.6 11 | 12 | * [fromPromise carries forward old value from pending observable](https://github.com/mobxjs/mobx-utils/pull/311) 13 | 14 | # 6.0.5 15 | 16 | # 6.0.4 17 | 18 | * [ObservableGroupMap.ts: remove console.log #289](https://github.com/mobxjs/mobx-utils/pull/289) 19 | * [Make observable promise's values safer type wise #295](https://github.com/mobxjs/mobx-utils/pull/295) Closes [Typing IRejectedPromise and IPendingPromise to hold an unknown value](https://github.com/mobxjs/mobx-utils/issues/291) Users of fromPromise may get new compile errors for invalid code. See PR for details. 20 | 21 | # 6.0.3 22 | 23 | * Fixed [#214](https://github.com/mobxjs/mobx-utils/issues/214) createViewModel doesn't work correctly with setters for computed values. ([#286](https://github.com/mobxjs/mobx-utils/pull/286)) 24 | * Use computedFn name if explicitly set in options [#277](https://github.com/mobxjs/mobx-utils/pull/277). 25 | 26 | # 6.0.2 27 | 28 | * skipped 29 | 30 | # 6.0.1 31 | 32 | * Fixed build issue causing decorators in the final build version not to be picked up correctly. Fixes [#279](https://github.com/mobxjs/mobx-utils/issues/279) 33 | 34 | # 6.0.0 35 | 36 | * [BREAKING] Dropped previously deprecated `asyncAction`. Use `mobx.flow` instead. 37 | * [BREAKING] Dropped previously deprecated `actionAsync`. Use `mobx.flow` + `mobx.flowResult` instead. 38 | * [BREAKING] Dropped previously deprecated `whenAsync`. Use `mobx.when` instead. 39 | * [BREAKING] Dropped previously deprecated `whenWithTimeout`. Use `mobx.when` instead. 40 | * [BREAKING] Added support for MobX 6.0.0. Minimim required MobX version is 6.0.0. 41 | 42 | # 5.6.1 43 | 44 | * [#256](https://github.com/mobxjs/mobx-utils/pull/256) Fix [#255](https://github.com/mobxjs/mobx-utils/issues/255) 45 | 46 | # 5.6.0 47 | 48 | * [#245](https://github.com/mobxjs/mobx-utils/pull/245) Add [ObservableGroupMap](https://github.com/mobxjs/mobx-utils#observablegroupmap). 49 | * [#250](https://github.com/mobxjs/mobx-utils/pull/250) Fix [#249](https://github.com/mobxjs/mobx-utils/issues/249): lazyObservable: pending.set not wrapped in allowStateChanges. 50 | * [#251](https://github.com/mobxjs/mobx-utils/pull/251) Fix fromStream initialValue not typed correctly. 51 | 52 | # 5.5.7 53 | 54 | * Another fix for invalid `actionAsync` context through [#246](https://github.com/mobxjs/mobx-utils/pull/246) by [xaviergonz](https://github.com/xaviergonz) 55 | 56 | # 5.5.6 57 | 58 | * Another fix for invalid `actionAsync` context when promises resolve at the same time in different actionAsync calls through [#244](https://github.com/mobxjs/mobx-utils/pull/244) by [xaviergonz](https://github.com/xaviergonz) 59 | 60 | # 5.5.5 61 | 62 | * Fixed tree-shaking mobx-utils, see [#238](https://github.com/mobxjs/mobx-utils/pull/238) by [IgorBabkin](https://github.com/IgorBabkin) 63 | 64 | # 5.5.4 65 | 66 | * Fix invalid `actionAsync` context when promises resolve at the same time in different actionAsync calls, by [xaviergonz](https://github.com/xaviergonz) through [#240](https://github.com/mobxjs/mobx-utils/pull/240) 67 | 68 | # 5.5.3 69 | 70 | * Support all `IComputedOptions` in `createTransformer`, by [@samdroid-apps](https://github.com/samdroid-apps) through [#224](https://github.com/mobxjs/mobx-utils/pull/224) 71 | * Make sure that transformers don't memorize when used outside a reactive context. Fixes [#116](https://github.com/mobxjs/mobx-utils/issues/116) through [#228](https://github.com/mobxjs/mobx-utils/pull/228) by [@upsuper](https://github.com/upsuper) 72 | 73 | # 5.5.2 74 | 75 | * Fix for `actionAsync` when awaiting promises that resolve immediately. 76 | 77 | # 5.5.1 78 | 79 | * Fix for `actionAsync` giving errors when it didn't await a task inside. 80 | * `task` now supports plain values as well. 81 | 82 | # 5.5.0 83 | 84 | _Note: the minimum required MobX version for this release has been bumped to `"mobx": "^4.13.1 || ^5.13.1"`_ 85 | 86 | * Added `actionAsync` (not to be confused with `asyncAction`) as an alternative to flows, see [#217](https://github.com/mobxjs/mobx-utils/pull/217) by [xaviergonz](https://github.com/xaviergonz) 87 | * Fixed a typing issue for the pending handler of `fromPromise`, see [#208](https://github.com/mobxjs/mobx-utils/pull/208) by [Ricardo-Marques](https://github.com/Ricardo-Marques) 88 | * `computedFn` now supports the standard options accepted by classic `computed`, see [#215](https://github.com/mobxjs/mobx-utils/pull/215) by [hearnden](https://github.com/hearnden) 89 | * Fixed [#205](https://github.com/mobxjs/mobx-utils/issues/205), something with to unobserved properties and `createViewModel`. See [#216](https://github.com/mobxjs/mobx-utils/pull/216) by [wrench7](https://github.com/wrench7) 90 | 91 | # 5.4.1 92 | 93 | * Fixed `cannot read property enumerable of undefined` error, [#191](https://github.com/mobxjs/mobx-utils/issues/191) through [#198](https://github.com/mobxjs/mobx-utils/pull/198) by [@dr0p](https://github.com/dr0p) 94 | * Improved typings of `createViewModel` trough [#195](https://github.com/mobxjs/mobx-utils/pull/195) by [@jordansexton](https://github.com/jordansexton) 95 | 96 | # 5.4.0 97 | 98 | Introduced `computedFn`, to support using arbitrary functions as computed! Implements [#184](https://github.com/mobxjs/mobx-utils/issues/184) through [#190](https://github.com/mobxjs/mobx-utils/pull/190) 99 | 100 | # 5.3.0 101 | 102 | * Observable getters defined on prototype are now included in the view model. Fixes [#100](https://github.com/mobxjs/mobx-utils/issues/100#issuecomment-401765101) through [#188](https://github.com/mobxjs/mobx-utils/pull/188) by [wrench7](https://github.com/wrench7) 103 | 104 | # 5.2.0 105 | 106 | * `createViewModel` now has an additional field `changedValues` on the returned viewmodel, that returns a map with all the pending changes. See [#172](https://github.com/mobxjs/mobx-utils/pull/172) by [@ItamarShDev](https://github.com/ItamarShDev). Fixes [#171](https://github.com/mobxjs/mobx-utils/issues/171) and [#173](https://github.com/mobxjs/mobx-utils/issues/173) 107 | * `fromPromise().case`: if the `onFulfilled` handler is omitted, `case` will now return the resolved value, rather than `undefined`. See [#167](https://github.com/mobxjs/mobx-utils/pull/167/) by [@JefHellemans](https://github.com/JefHellemans) 108 | * `createViewModel` will now respect the enumerability of properties. See [#169](https://github.com/mobxjs/mobx-utils/pull/169) by [dr0p](https://github.com/dr0p) 109 | 110 | # 5.1.0 111 | 112 | * `fromPromise` now accepts a second argument, a previous obsevable promise, that can be used to temporarily show old values until the new promise has resolved. See [#160](https://github.com/mobxjs/mobx-utils/pull/160) by [@ItamarShDev](https://github.com/ItamarShDev) 113 | * `createTransformer` can now also memoize on function arguments, see [#159](https://github.com/mobxjs/mobx-utils/pull/159) by [@hc-12](https://github.com/hc-12) 114 | 115 | 116 | # 5.0.4 117 | 118 | * Fixed [#158](https://github.com/mobxjs/mobx-utils/issues/158), `deepObserve` not being published 119 | 120 | # 5.0.3 121 | 122 | * Introduced `deepObserve` utility, through [#154](https://github.com/mobxjs/mobx-utils/pull/154) 123 | 124 | # 5.0.2 125 | 126 | * Improved typings of `toStream`, by [@pelotom](https://github.com/pelotom) through [#147](https://github.com/mobxjs/mobx-utils/pull/147) 127 | * Improved typings of `fromStream`, `IStreamListener` is now an explicit interface. Fixes [#143](https://github.com/mobxjs/mobx-utils/issues/143) 128 | 129 | # 5.0.1 130 | 131 | * Add `sideEffects: false` field in package.json to enable maximal tree shaking for webpack. 132 | * Fixed #134, prevent primitive key id collisions in createTransformer 133 | * Fixed typing issue where the `.value` field is not available without having a type assertion of the state first 134 | 135 | # 5.0.0 136 | 137 | * Added MobX 5 compatibility. The package is also compatible with MobX 4.3.1+. 138 | * `createViewModel` now also copies computed properties to the view Model. Implements [#100].(https://github.com/mobxjs/mobx-utils/issues/100). Implemented through [#126](https://github.com/mobxjs/mobx-utils/pull/126) by [@RafalFilipek](https://github.com/RafalFilipek). 139 | 140 | # 4.0.1 141 | 142 | * passing a `fromPromise` based promise to `fromPromise` no longer throws an exception. Fixes [#119](https://github.com/mobxjs/mobx-utils/issues/119) 143 | * added viewModel `resetProperty` to typescript typings, fixes [#117](https://github.com/mobxjs/mobx-utils/issues/117) through [#118](https://github.com/mobxjs/mobx-utils/pull/118) by @navidjh 144 | * Added `moveItem(array, fromIndex, toIndex)` utility, as replacement for the dropped `ObservableArray.move` in MobX 4. Trough [#121](https://github.com/mobxjs/mobx-utils/pull/121) by @jeffijoe 145 | * Fixed incorrect peer dependency, [#115](https://github.com/mobxjs/mobx-utils/pull/115) by @xaviergonz 146 | 147 | # 4.0.0 148 | 149 | Updated mobx-utils to use MobX 4. No futher changes 150 | 151 | # 3.2.2 152 | 153 | * `toStream` now accepts a second argument, `fireImmediately=false`, which, when `true`, immediately pushes the current value to the stream. Fixes [#82](https://github.com/mobxjs/mobx-utils/issues/82) 154 | 155 | # 3.2.1 156 | 157 | * Fixed issue where `whenAsync` was not exposed correctly. 158 | * Added `timeout` parameter to `whenAsync` 159 | 160 | # 3.2.0 161 | 162 | * Switched to rollup for bundling, bundle non-minified and include a es module based build. See [#81](https://github.com/mobxjs/mobx-utils/pull/81) by [@mijay](https://github.com/mijay) 163 | 164 | # 3.1.1 165 | * Introduced `whenAsync`, which is like normal `when`, except that this `when` will return a promise that resolves when the expression becomes truthy. See #66 and #68, by @daedalus28 166 | 167 | # 3.0.0 168 | 169 | ### Revamped `fromPromise`: 170 | 171 | * It is now possible to directly pass a `(resolve, reject) => {}` function to `fromPromise`, instead of a promise object 172 | * **BREAKING** `fromPromise` no longer creates a wrapping object, but rather extends the given promise, #45 173 | * **BREAKING** Fixed #54, the resolved value of a promise is no longer deeply converted to an observable 174 | * **BREAKING** Dropped `fromPromise().reason` 175 | * **BREAKING** Improved typings of `fromPromise`. For example, the `value` property is now only available if `.state === "resolved"` (#41) 176 | * **BREAKING** Dropped optional `initialvalue` param from `fromPromise`. use `fromPromise.fulfilled(value)` instead to create a promise in some ready state 177 | * Introduced `fromPromise.reject(reason)` and `fromPromise.resolve(value?)` to create a promise based observable in a certain state, see #39 178 | * Fixed #56, observable promises attributes `state` and `value` are now explicit observables 179 | 180 | ### Introduced `asyncAction` 181 | 182 | See the [docs](https://github.com/mobxjs/mobx-utils#asyncaction) for details, but the gist of it: 183 | 184 | ```javascript 185 | import {asyncAction} from "mobx-utils" 186 | 187 | mobx.configure({ enforceActions: "observed" }) // don't allow state modifications outside actions 188 | 189 | class Store { 190 | @observable githubProjects = [] 191 | @state = "pending" // "pending" / "done" / "error" 192 | 193 | @asyncAction 194 | *fetchProjects() { // <- note the star, this a generator function! 195 | this.githubProjects = [] 196 | this.state = "pending" 197 | try { 198 | const projects = yield fetchGithubProjectsSomehow() // yield instead of await 199 | const filteredProjects = somePreprocessing(projects) 200 | // the asynchronous blocks will automatically be wrapped actions 201 | this.state = "done" 202 | this.githubProjects = filteredProjects 203 | } catch (error) { 204 | this.state = "error" 205 | } 206 | } 207 | } 208 | ``` 209 | 210 | 211 | ### Other 212 | 213 | * Fixed #40, `now()` now returns current date time if invoked from outside a reactive context 214 | 215 | # 2.0.2 216 | 217 | * Fixed #44, lazyObservable not accepting an array as initial value. 218 | * ViewModel methods are now automatically bound, see #59, by @tekacs 219 | * Fixed stream issue regarding disposing already completed streams, see #57, by @rkorohu 220 | * Improved typings of lazy observables, see #38 by @jamiewinder 221 | 222 | # 2.0.1 223 | 224 | * Fixed several deprecation messages related to MobX 3 upgrade (see #36 by RainerAtSpirit) 225 | * Fixed #26: Rejected promises not playing nicely with JQuery 226 | * Fixed #25: Refreshing a lazy observable should not accidentally refresh it if it didn't start yet 227 | 228 | # 2.0.0 229 | 230 | * Upgraded to MobX 3 231 | 232 | # 1.1.6 233 | 234 | * Fixed #34: fromStream threw when being used in strict mode 235 | * Introduced `reset()` on lazyObservable, see #28 by @daitr92 236 | 237 | # 1.1.5 238 | 239 | * Fixed #32: make sure lazyObservable and fromResources can be initiated from computed values 240 | 241 | # 1.1.4 242 | 243 | * Introduced `now(interval?)`, to get an observable that returns the current time at a specified interval 244 | 245 | # 1.1.3 246 | 247 | * Introduced `fromStream` and `toStream` for interoperability with TC 39 / RxJS observable streams, see [Mobx #677](https://github.com/mobxjs/mobx/issues/677) 248 | 249 | # 1.1.2 250 | 251 | * Introduced `refresh()` to lazy observables. By @voxuanthinh, see [#20](https://github.com/mobxjs/mobx-utils/pull/20) 252 | 253 | # 1.1.1 254 | 255 | * Introduced `chunkProcessor` by Benjamin Bock, see [#19](https://github.com/mobxjs/mobx-utils/pull/19) 256 | * Introduced `resetProperty(propName)` for ViewModels, by Vojtech Novak, see [#17](https://github.com/mobxjs/mobx-utils/pull/17) 257 | 258 | # 1.1.0 259 | 260 | * observable promises now support a `.case()` method to easily switch over different promise states. See [#13](https://github.com/mobxjs/mobx-utils/pull/13) by @spion 261 | * `createViewModel` now supports arrays and maps as well, see [#12](https://github.com/mobxjs/mobx-utils/pull/12) by @vonovak 262 | 263 | # 1.0.1 264 | 265 | * Implemented #4: Expose constants for promise states: `PENDING`, `REJECTED` and `FULFILLED`. 266 | * Implemented #6: the rejection reason of `fromPromise` is now stored in `.value` instead of `.reason` (which has been deprecated). 267 | * Improved typings of `fromPromise`, fixes #8 268 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 MobX 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-utils 2 | 3 | _Utility functions and common patterns for MobX_ 4 | 5 | [![Build Status](https://travis-ci.org/mobxjs/mobx-utils.svg?branch=master)](https://travis-ci.org/mobxjs/mobx-utils) 6 | [![Coverage Status](https://coveralls.io/repos/github/mobxjs/mobx-utils/badge.svg?branch=master)](https://coveralls.io/github/mobxjs/mobx-utils?branch=master) 7 | [![Join the chat at https://gitter.im/mobxjs/mobx](https://badges.gitter.im/mobxjs/mobx.svg)](https://gitter.im/mobxjs/mobx?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 8 | [![npm](https://img.shields.io/npm/v/mobx-utils)](https://www.npmjs.com/package/mobx-utils) 9 | 10 | This package provides utility functions and common MobX patterns build on top of MobX. 11 | It is encouraged to take a peek under the hood and read the sources of these utilities. 12 | Feel free to open a PR with your own utilities. For large new features, please open an issue first. 13 | 14 | # Installation & Usage 15 | 16 | NPM: `npm install mobx-utils --save` 17 | 18 | CDN: 19 | 20 | `import {function_name} from 'mobx-utils'` 21 | 22 | # API 23 | 24 | 25 | 26 | ### Table of Contents 27 | 28 | - [fromPromise](#frompromise) 29 | - [Parameters](#parameters) 30 | - [Examples](#examples) 31 | - [isPromiseBasedObservable](#ispromisebasedobservable) 32 | - [Parameters](#parameters-1) 33 | - [moveItem](#moveitem) 34 | - [Parameters](#parameters-2) 35 | - [Examples](#examples-1) 36 | - [lazyObservable](#lazyobservable) 37 | - [Parameters](#parameters-3) 38 | - [Examples](#examples-2) 39 | - [fromResource](#fromresource) 40 | - [Parameters](#parameters-4) 41 | - [Examples](#examples-3) 42 | - [toStream](#tostream) 43 | - [Parameters](#parameters-5) 44 | - [Examples](#examples-4) 45 | - [StreamListener](#streamlistener) 46 | - [ViewModel](#viewmodel) 47 | - [createViewModel](#createviewmodel) 48 | - [Parameters](#parameters-6) 49 | - [Examples](#examples-5) 50 | - [keepAlive](#keepalive) 51 | - [Parameters](#parameters-7) 52 | - [Examples](#examples-6) 53 | - [keepAlive](#keepalive-1) 54 | - [Parameters](#parameters-8) 55 | - [Examples](#examples-7) 56 | - [queueProcessor](#queueprocessor) 57 | - [Parameters](#parameters-9) 58 | - [Examples](#examples-8) 59 | - [chunkProcessor](#chunkprocessor) 60 | - [Parameters](#parameters-10) 61 | - [Examples](#examples-9) 62 | - [resetNowInternalState](#resetnowinternalstate) 63 | - [Examples](#examples-10) 64 | - [now](#now) 65 | - [Parameters](#parameters-11) 66 | - [Examples](#examples-11) 67 | - [expr](#expr) 68 | - [Parameters](#parameters-12) 69 | - [Examples](#examples-12) 70 | - [createTransformer](#createtransformer) 71 | - [Parameters](#parameters-13) 72 | - [deepObserve](#deepobserve) 73 | - [Parameters](#parameters-14) 74 | - [Examples](#examples-13) 75 | - [ObservableGroupMap](#observablegroupmap) 76 | - [Parameters](#parameters-15) 77 | - [Examples](#examples-14) 78 | - [ObservableMap](#observablemap) 79 | - [defineProperty](#defineproperty) 80 | - [defineProperty](#defineproperty-1) 81 | - [defineProperty](#defineproperty-2) 82 | - [defineProperty](#defineproperty-3) 83 | - [defineProperty](#defineproperty-4) 84 | - [computedFn](#computedfn) 85 | - [Parameters](#parameters-16) 86 | - [Examples](#examples-15) 87 | - [DeepMapEntry](#deepmapentry) 88 | - [DeepMap](#deepmap) 89 | 90 | ## fromPromise 91 | 92 | `fromPromise` takes a Promise, extends it with 2 observable properties that track 93 | the status of the promise and returns it. The returned object has the following observable properties: 94 | 95 | - `value`: either the initial value, the value the Promise resolved to, or the value the Promise was rejected with. use `.state` if you need to be able to tell the difference. 96 | - `state`: one of `"pending"`, `"fulfilled"` or `"rejected"` 97 | 98 | And the following methods: 99 | 100 | - `case({fulfilled, rejected, pending})`: maps over the result using the provided handlers, or returns `undefined` if a handler isn't available for the current promise state. 101 | 102 | The returned object implements `PromiseLike`, so you can chain additional `Promise` handlers using `then`. You may also use it with `await` in `async` functions. 103 | 104 | Note that the status strings are available as constants: 105 | `mobxUtils.PENDING`, `mobxUtils.REJECTED`, `mobxUtil.FULFILLED` 106 | 107 | fromPromise takes an optional second argument, a previously created `fromPromise` based observable. 108 | This is useful to replace one promise based observable with another, without going back to an intermediate 109 | "pending" promise state while fetching data. For example: 110 | 111 | ### Parameters 112 | 113 | - `origPromise` The promise which will be observed 114 | - `oldPromise` The previously observed promise 115 | 116 | ### Examples 117 | 118 | ```javascript 119 | @observer 120 | class SearchResults extends React.Component { 121 | @observable.ref searchResults 122 | 123 | componentDidUpdate(nextProps) { 124 | if (nextProps.query !== this.props.query) 125 | this.searchResults = fromPromise( 126 | window.fetch("/search?q=" + nextProps.query), 127 | // by passing, we won't render a pending state if we had a successful search query before 128 | // rather, we will keep showing the previous search results, until the new promise resolves (or rejects) 129 | this.searchResults 130 | ) 131 | } 132 | 133 | render() { 134 | return this.searchResults.case({ 135 | pending: (staleValue) => { 136 | return staleValue || "searching" // <- value might set to previous results while the promise is still pending 137 | }, 138 | fulfilled: (value) => { 139 | return value // the fresh results 140 | }, 141 | rejected: (error) => { 142 | return "Oops: " + error 143 | } 144 | }) 145 | } 146 | } 147 | 148 | Observable promises can be created immediately in a certain state using 149 | `fromPromise.reject(reason)` or `fromPromise.resolve(value?)`. 150 | The main advantage of `fromPromise.resolve(value)` over `fromPromise(Promise.resolve(value))` is that the first _synchronously_ starts in the desired state. 151 | 152 | It is possible to directly create a promise using a resolve, reject function: 153 | `fromPromise((resolve, reject) => setTimeout(() => resolve(true), 1000))` 154 | ``` 155 | 156 | ```javascript 157 | const fetchResult = fromPromise(fetch("http://someurl")) 158 | 159 | // combine with when.. 160 | when( 161 | () => fetchResult.state !== "pending", 162 | () => { 163 | console.log("Got ", fetchResult.value) 164 | } 165 | ) 166 | 167 | // or a mobx-react component.. 168 | const myComponent = observer(({ fetchResult }) => { 169 | switch(fetchResult.state) { 170 | case "pending": return
Loading...
171 | case "rejected": return
Ooops... {fetchResult.value}
172 | case "fulfilled": return
Gotcha: {fetchResult.value}
173 | } 174 | }) 175 | 176 | // or using the case method instead of switch: 177 | 178 | const myComponent = observer(({ fetchResult }) => 179 | fetchResult.case({ 180 | pending: () =>
Loading...
, 181 | rejected: error =>
Ooops.. {error}
, 182 | fulfilled: value =>
Gotcha: {value}
, 183 | })) 184 | 185 | // chain additional handler(s) to the resolve/reject: 186 | 187 | fetchResult.then( 188 | (result) => doSomeTransformation(result), 189 | (rejectReason) => console.error('fetchResult was rejected, reason: ' + rejectReason) 190 | ).then( 191 | (transformedResult) => console.log('transformed fetchResult: ' + transformedResult) 192 | ) 193 | ``` 194 | 195 | Returns **any** origPromise with added properties and methods described above. 196 | 197 | ## isPromiseBasedObservable 198 | 199 | Returns true if the provided value is a promise-based observable. 200 | 201 | ### Parameters 202 | 203 | - `value` any 204 | 205 | Returns **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** 206 | 207 | ## moveItem 208 | 209 | Moves an item from one position to another, checking that the indexes given are within bounds. 210 | 211 | ### Parameters 212 | 213 | - `target` **ObservableArray<T>** 214 | - `fromIndex` **[number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)** 215 | - `toIndex` **[number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)** 216 | 217 | ### Examples 218 | 219 | ```javascript 220 | const source = observable([1, 2, 3]) 221 | moveItem(source, 0, 1) 222 | console.log(source.map(x => x)) // [2, 1, 3] 223 | ``` 224 | 225 | Returns **ObservableArray<T>** 226 | 227 | ## lazyObservable 228 | 229 | `lazyObservable` creates an observable around a `fetch` method that will not be invoked 230 | until the observable is needed the first time. 231 | The fetch method receives a `sink` callback which can be used to replace the 232 | current value of the lazyObservable. It is allowed to call `sink` multiple times 233 | to keep the lazyObservable up to date with some external resource. 234 | 235 | Note that it is the `current()` call itself which is being tracked by MobX, 236 | so make sure that you don't dereference to early. 237 | 238 | ### Parameters 239 | 240 | - `fetch` 241 | - `initialValue` **T** optional initialValue that will be returned from `current` as long as the `sink` has not been called at least once (optional, default `undefined`) 242 | 243 | ### Examples 244 | 245 | ```javascript 246 | const userProfile = lazyObservable( 247 | sink => fetch("/myprofile").then(profile => sink(profile)) 248 | ) 249 | 250 | // use the userProfile in a React component: 251 | const Profile = observer(({ userProfile }) => 252 | userProfile.current() === undefined 253 | ?
Loading user profile...
254 | :
{userProfile.current().displayName}
255 | ) 256 | 257 | // triggers refresh the userProfile 258 | userProfile.refresh() 259 | ``` 260 | 261 | ## fromResource 262 | 263 | `fromResource` creates an observable whose current state can be inspected using `.current()`, 264 | and which can be kept in sync with some external datasource that can be subscribed to. 265 | 266 | The created observable will only subscribe to the datasource if it is in use somewhere, 267 | (un)subscribing when needed. To enable `fromResource` to do that two callbacks need to be provided, 268 | one to subscribe, and one to unsubscribe. The subscribe callback itself will receive a `sink` callback, which can be used 269 | to update the current state of the observable, allowing observes to react. 270 | 271 | Whatever is passed to `sink` will be returned by `current()`. The values passed to the sink will not be converted to 272 | observables automatically, but feel free to do so. 273 | It is the `current()` call itself which is being tracked, 274 | so make sure that you don't dereference to early. 275 | 276 | For inspiration, an example integration with the apollo-client on [github](https://github.com/apollostack/apollo-client/issues/503#issuecomment-241101379), 277 | or the [implementation](https://github.com/mobxjs/mobx-utils/blob/1d17cf7f7f5200937f68cc0b5e7ec7f3f71dccba/src/now.ts#L43-L57) of `mobxUtils.now` 278 | 279 | The following example code creates an observable that connects to a `dbUserRecord`, 280 | which comes from an imaginary database and notifies when it has changed. 281 | 282 | ### Parameters 283 | 284 | - `subscriber` 285 | - `unsubscriber` **IDisposer** (optional, default `NOOP`) 286 | - `initialValue` **T** the data that will be returned by `get()` until the `sink` has emitted its first data (optional, default `undefined`) 287 | 288 | ### Examples 289 | 290 | ```javascript 291 | function createObservableUser(dbUserRecord) { 292 | let currentSubscription; 293 | return fromResource( 294 | (sink) => { 295 | // sink the current state 296 | sink(dbUserRecord.fields) 297 | // subscribe to the record, invoke the sink callback whenever new data arrives 298 | currentSubscription = dbUserRecord.onUpdated(() => { 299 | sink(dbUserRecord.fields) 300 | }) 301 | }, 302 | () => { 303 | // the user observable is not in use at the moment, unsubscribe (for now) 304 | dbUserRecord.unsubscribe(currentSubscription) 305 | } 306 | ) 307 | } 308 | 309 | // usage: 310 | const myUserObservable = createObservableUser(myDatabaseConnector.query("name = 'Michel'")) 311 | 312 | // use the observable in autorun 313 | autorun(() => { 314 | // printed everytime the database updates its records 315 | console.log(myUserObservable.current().displayName) 316 | }) 317 | 318 | // ... or a component 319 | const userComponent = observer(({ user }) => 320 |
{user.current().displayName}
321 | ) 322 | ``` 323 | 324 | ## toStream 325 | 326 | Converts an expression to an observable stream (a.k.a. TC 39 Observable / RxJS observable). 327 | The provided expression is tracked by mobx as long as there are subscribers, automatically 328 | emitting when new values become available. The expressions respect (trans)actions. 329 | 330 | ### Parameters 331 | 332 | - `expression` 333 | - `fireImmediately` **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** (by default false) 334 | 335 | ### Examples 336 | 337 | ```javascript 338 | const user = observable({ 339 | firstName: "C.S", 340 | lastName: "Lewis" 341 | }) 342 | 343 | Rx.Observable 344 | .from(mobxUtils.toStream(() => user.firstname + user.lastName)) 345 | .scan(nameChanges => nameChanges + 1, 0) 346 | .subscribe(nameChanges => console.log("Changed name ", nameChanges, "times")) 347 | ``` 348 | 349 | Returns **IObservableStream<T>** 350 | 351 | ## StreamListener 352 | 353 | ## ViewModel 354 | 355 | ## createViewModel 356 | 357 | `createViewModel` takes an object with observable properties (model) 358 | and wraps a viewmodel around it. The viewmodel proxies all enumerable properties of the original model with the following behavior: 359 | 360 | - as long as no new value has been assigned to the viewmodel property, the original property will be returned. 361 | - any future change in the model will be visible in the viewmodel as well unless the viewmodel property was dirty at the time of the attempted change. 362 | - once a new value has been assigned to a property of the viewmodel, that value will be returned during a read of that property in the future. However, the original model remain untouched until `submit()` is called. 363 | 364 | The viewmodel exposes the following additional methods, besides all the enumerable properties of the model: 365 | 366 | - `submit()`: copies all the values of the viewmodel to the model and resets the state 367 | - `reset()`: resets the state of the viewmodel, abandoning all local modifications 368 | - `resetProperty(propName)`: resets the specified property of the viewmodel 369 | - `isDirty`: observable property indicating if the viewModel contains any modifications 370 | - `isPropertyDirty(propName)`: returns true if the specified property is dirty 371 | - `changedValues`: returns a key / value map with the properties that have been changed in the model so far 372 | - `model`: The original model object for which this viewModel was created 373 | 374 | You may use observable arrays, maps and objects with `createViewModel` but keep in mind to assign fresh instances of those to the viewmodel's properties, otherwise you would end up modifying the properties of the original model. 375 | Note that if you read a non-dirty property, viewmodel only proxies the read to the model. You therefore need to assign a fresh instance not only the first time you make the assignment but also after calling `reset()` or `submit()`. 376 | 377 | ### Parameters 378 | 379 | - `model` **T** 380 | 381 | ### Examples 382 | 383 | ```javascript 384 | class Todo { 385 | @observable title = "Test" 386 | } 387 | 388 | const model = new Todo() 389 | const viewModel = createViewModel(model); 390 | 391 | autorun(() => console.log(viewModel.model.title, ",", viewModel.title)) 392 | // prints "Test, Test" 393 | model.title = "Get coffee" 394 | // prints "Get coffee, Get coffee", viewModel just proxies to model 395 | viewModel.title = "Get tea" 396 | // prints "Get coffee, Get tea", viewModel's title is now dirty, and the local value will be printed 397 | viewModel.submit() 398 | // prints "Get tea, Get tea", changes submitted from the viewModel to the model, viewModel is proxying again 399 | viewModel.title = "Get cookie" 400 | // prints "Get tea, Get cookie" // viewModel has diverged again 401 | viewModel.reset() 402 | // prints "Get tea, Get tea", changes of the viewModel have been abandoned 403 | ``` 404 | 405 | ## keepAlive 406 | 407 | MobX normally suspends any computed value that is not in use by any reaction, 408 | and lazily re-evaluates the expression if needed outside a reaction while not in use. 409 | `keepAlive` marks a computed value as always in use, meaning that it will always fresh, but never disposed automatically. 410 | 411 | ### Parameters 412 | 413 | - `_1` 414 | - `_2` 415 | - `target` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** an object that has a computed property, created by `@computed` or `extendObservable` 416 | - `property` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** the name of the property to keep alive 417 | 418 | ### Examples 419 | 420 | ```javascript 421 | const obj = observable({ 422 | number: 3, 423 | doubler: function() { return this.number * 2 } 424 | }) 425 | const stop = keepAlive(obj, "doubler") 426 | ``` 427 | 428 | Returns **IDisposer** stops this keep alive so that the computed value goes back to normal behavior 429 | 430 | ## keepAlive 431 | 432 | ### Parameters 433 | 434 | - `_1` 435 | - `_2` 436 | - `computedValue` **IComputedValue<any>** created using the `computed` function 437 | 438 | ### Examples 439 | 440 | ```javascript 441 | const number = observable(3) 442 | const doubler = computed(() => number.get() * 2) 443 | const stop = keepAlive(doubler) 444 | // doubler will now stay in sync reactively even when there are no further observers 445 | stop() 446 | // normal behavior, doubler results will be recomputed if not observed but needed, but lazily 447 | ``` 448 | 449 | Returns **IDisposer** stops this keep alive so that the computed value goes back to normal behavior 450 | 451 | ## queueProcessor 452 | 453 | `queueProcessor` takes an observable array, observes it and calls `processor` 454 | once for each item added to the observable array, optionally debouncing the action 455 | 456 | ### Parameters 457 | 458 | - `observableArray` **[Array](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array)<T>** observable array instance to track 459 | - `processor` 460 | - `debounce` **[number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)** optional debounce time in ms. With debounce 0 the processor will run synchronously (optional, default `0`) 461 | 462 | ### Examples 463 | 464 | ```javascript 465 | const pendingNotifications = observable([]) 466 | const stop = queueProcessor(pendingNotifications, msg => { 467 | // show Desktop notification 468 | new Notification(msg); 469 | }) 470 | 471 | // usage: 472 | pendingNotifications.push("test!") 473 | ``` 474 | 475 | Returns **IDisposer** stops the processor 476 | 477 | ## chunkProcessor 478 | 479 | `chunkProcessor` takes an observable array, observes it and calls `processor` 480 | once for a chunk of items added to the observable array, optionally deboucing the action. 481 | The maximum chunk size can be limited by number. 482 | This allows both, splitting larger into smaller chunks or (when debounced) combining smaller 483 | chunks and/or single items into reasonable chunks of work. 484 | 485 | ### Parameters 486 | 487 | - `observableArray` **[Array](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array)<T>** observable array instance to track 488 | - `processor` 489 | - `debounce` **[number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)** optional debounce time in ms. With debounce 0 the processor will run synchronously (optional, default `0`) 490 | - `maxChunkSize` **[number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)** optionally do not call on full array but smaller chunks. With 0 it will process the full array. (optional, default `0`) 491 | 492 | ### Examples 493 | 494 | ```javascript 495 | const trackedActions = observable([]) 496 | const stop = chunkProcessor(trackedActions, chunkOfMax10Items => { 497 | sendTrackedActionsToServer(chunkOfMax10Items); 498 | }, 100, 10) 499 | 500 | // usage: 501 | trackedActions.push("scrolled") 502 | trackedActions.push("hoveredButton") 503 | // when both pushes happen within 100ms, there will be only one call to server 504 | ``` 505 | 506 | Returns **IDisposer** stops the processor 507 | 508 | ## resetNowInternalState 509 | 510 | Disposes of all the internal Observables created by invocations of `now()`. 511 | 512 | The use case for this is to ensure that unit tests can run independent of each other. 513 | You should not call this in regular application code. 514 | 515 | ### Examples 516 | 517 | ```javascript 518 | afterEach(() => { 519 | utils.resetNowInternalState() 520 | }) 521 | ``` 522 | 523 | ## now 524 | 525 | Returns the current date time as epoch number. 526 | The date time is read from an observable which is updated automatically after the given interval. 527 | So basically it treats time as an observable. 528 | 529 | The function takes an interval as parameter, which indicates how often `now()` will return a new value. 530 | If no interval is given, it will update each second. If "frame" is specified, it will update each time a 531 | `requestAnimationFrame` is available. 532 | 533 | Multiple clocks with the same interval will automatically be synchronized. 534 | 535 | Countdown example: 536 | 537 | ### Parameters 538 | 539 | - `interval` **([number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number) \| `"frame"`)** interval in milliseconds about how often the interval should update (optional, default `1000`) 540 | 541 | ### Examples 542 | 543 | ```javascript 544 | const start = Date.now() 545 | 546 | autorun(() => { 547 | console.log("Seconds elapsed: ", (mobxUtils.now() - start) / 1000) 548 | }) 549 | ``` 550 | 551 | ## expr 552 | 553 | `expr` can be used to create temporary computed values inside computed values. 554 | Nesting computed values is useful to create cheap computations in order to prevent expensive computations from needing to run. 555 | In the following example the expression prevents that a component is rerender _each time_ the selection changes; 556 | instead it will only rerenders when the current todo is (de)selected. 557 | 558 | `expr(func)` is an alias for `computed(func).get()`. 559 | Please note that the function given to `expr` is evaluated _twice_ in the scenario that the overall expression value changes. 560 | It is evaluated the first time when any observables it depends on change. 561 | It is evaluated a second time when a change in its value triggers the outer computed or reaction to evaluate, which recreates and reevaluates the expression. 562 | 563 | In the following example, the expression prevents the `TodoView` component from being re-rendered if the selection changes elsewhere. 564 | Instead, the component will only re-render when the relevant todo is (de)selected, which happens much less frequently. 565 | 566 | ### Parameters 567 | 568 | - `expr` 569 | 570 | ### Examples 571 | 572 | ```javascript 573 | const TodoView = observer(({ todo, editorState }) => { 574 | const isSelected = mobxUtils.expr(() => editorState.selection === todo) 575 | return
{todo.title}
576 | }) 577 | ``` 578 | 579 | ## createTransformer 580 | 581 | Creates a function that maps an object to a view. 582 | The mapping is memoized. 583 | 584 | See the [transformer](#createtransformer-in-detail) section for more details. 585 | 586 | ### Parameters 587 | 588 | - `transformer` A function which transforms instances of A into instances of B 589 | - `arg2` An optional cleanup function which is called when the transformation is no longer 590 | observed from a reactive context, or config options 591 | 592 | Returns **any** The memoized transformer function 593 | 594 | ## deepObserve 595 | 596 | Given an object, deeply observes the given object. 597 | It is like `observe` from mobx, but applied recursively, including all future children. 598 | 599 | Note that the given object cannot ever contain cycles and should be a tree. 600 | 601 | As benefit: path and root will be provided in the callback, so the signature of the listener is 602 | (change, path, root) => void 603 | 604 | The returned disposer can be invoked to clean up the listener 605 | 606 | deepObserve cannot be used on computed values. 607 | 608 | ### Parameters 609 | 610 | - `target` 611 | - `listener` 612 | 613 | ### Examples 614 | 615 | ```javascript 616 | const disposer = deepObserve(target, (change, path) => { 617 | console.dir(change) 618 | }) 619 | ``` 620 | 621 | ## ObservableGroupMap 622 | 623 | Reactively sorts a base observable array into multiple observable arrays based on the value of a 624 | `groupBy: (item: T) => G` function. 625 | 626 | This observes the individual computed groupBy values and only updates the source and dest arrays 627 | when there is an actual change, so this is far more efficient than, for example 628 | `base.filter(i => groupBy(i) === 'we')`. Call #dispose() to stop tracking. 629 | 630 | No guarantees are made about the order of items in the grouped arrays. 631 | 632 | The resulting map of arrays is read-only. clear(), set(), delete() are not supported and 633 | modifying the group arrays will lead to undefined behavior. 634 | 635 | NB: ObservableGroupMap relies on `Symbol`s. If you are targeting a platform which doesn't 636 | support these natively, you will need to provide a polyfill. 637 | 638 | ### Parameters 639 | 640 | - `base` **[array](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array)** The array to sort into groups. 641 | - `groupBy` **[function](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function)** The function used for grouping. 642 | - `options` Object with properties: 643 | `name`: Debug name of this ObservableGroupMap. 644 | `keyToName`: Function to create the debug names of the observable group arrays. 645 | 646 | ### Examples 647 | 648 | ```javascript 649 | const slices = observable([ 650 | { day: "mo", hours: 12 }, 651 | { day: "tu", hours: 2 }, 652 | ]) 653 | const slicesByDay = new ObservableGroupMap(slices, (slice) => slice.day) 654 | autorun(() => console.log( 655 | slicesByDay.get("mo")?.length ?? 0, 656 | slicesByDay.get("we"))) // outputs 1, undefined 657 | slices[0].day = "we" // outputs 0, [{ day: "we", hours: 12 }] 658 | ``` 659 | 660 | ## ObservableMap 661 | 662 | ## defineProperty 663 | 664 | Base observable array which is being sorted into groups. 665 | 666 | ## defineProperty 667 | 668 | The ObservableGroupMap needs to track some state per-item. This is the name/symbol of the 669 | property used to attach the state. 670 | 671 | ## defineProperty 672 | 673 | The function used to group the items. 674 | 675 | ## defineProperty 676 | 677 | This function is used to generate the mobx debug names of the observable group arrays. 678 | 679 | ## defineProperty 680 | 681 | Disposes all observers created during construction and removes state added to base array 682 | items. 683 | 684 | ## computedFn 685 | 686 | computedFn takes a function with an arbitrary amount of arguments, 687 | and memoizes the output of the function based on the arguments passed in. 688 | 689 | computedFn(fn) returns a function with the very same signature. There is no limit on the amount of arguments 690 | that is accepted. However, the amount of arguments must be constant and default arguments are not supported. 691 | 692 | By default the output of a function call will only be memoized as long as the 693 | output is being observed. 694 | 695 | The function passes into `computedFn` should be pure, not be an action and only be relying on 696 | observables. 697 | 698 | Setting `keepAlive` to `true` will cause the output to be forcefully cached forever. 699 | Note that this might introduce memory leaks! 700 | 701 | ### Parameters 702 | 703 | - `fn` 704 | - `keepAliveOrOptions` 705 | 706 | ### Examples 707 | 708 | ```javascript 709 | const store = observable({ 710 | a: 1, 711 | b: 2, 712 | c: 3, 713 | m: computedFn(function(x) { 714 | return this.a * this.b * x 715 | }) 716 | }) 717 | 718 | const d = autorun(() => { 719 | // store.m(3) will be cached as long as this autorun is running 720 | console.log(store.m(3) * store.c) 721 | }) 722 | ``` 723 | 724 | ## DeepMapEntry 725 | 726 | ## DeepMap 727 | 728 | # Details 729 | 730 | ## createTransformer in detail 731 | 732 | With `createTransformer` it is very easy to transform a complete data graph into another data graph. 733 | Transformation functions can be composed so that you can build a tree using lots of small transformations. 734 | The resulting data graph will never be stale, it will be kept in sync with the source by applying small patches to the result graph. 735 | This makes it very easy to achieve powerful patterns similar to sideways data loading, map-reduce, tracking state history using immutable data structures etc. 736 | 737 | `createTransformer` turns a function (that should transform value `A` into another value `B`) into a reactive and memoizing function. 738 | In other words, if the `transformation` function computes `B` given a specific `A`, the same `B` will be returned for all other future invocations of the transformation with the same `A`. 739 | However, if `A` changes, or any derivation accessed in the transformer function body gets invalidated, the transformation will be re-applied so that `B` is updated accordingly. 740 | And last but not least, if nobody is using the transformation of a specific A anymore, its entry will be removed from the memoization table. 741 | 742 | The optional `onCleanup` function can be used to get a notification when a transformation of an object is no longer needed. 743 | This can be used to dispose resources attached to the result object if needed. 744 | 745 | Always use transformations inside a reaction like `observer` or `autorun`. 746 | 747 | Transformations will, like any other computed value, fall back to lazy evaluation if not observed by something, which sort of defeats their purpose. 748 | 749 | ### Parameters 750 | 751 | - \`transformation: (value: A) => B 752 | - `onCleanup?: (result: B, value?: A) => void)` 753 | - 754 | 755 | `createTransformer(transformation: (value: A) => B, onCleanup?: (result: B, value?: A) => void): (value: A) => B` 756 | 757 | ## Examples 758 | 759 | This all might still be a bit vague, so here are two examples that explain this whole idea of transforming one data structure into another by using small, reactive functions: 760 | 761 | ### Tracking mutable state using immutable, shared data structures. 762 | 763 | This example is taken from the [Reactive2015 conference demo](https://github.com/mobxjs/mobx-reactive2015-demo): 764 | 765 | ```javascript 766 | /* 767 | The store that holds our domain: boxes and arrows 768 | */ 769 | const store = observable({ 770 | boxes: [], 771 | arrows: [], 772 | selection: null, 773 | }) 774 | 775 | /** 776 | Serialize store to json upon each change and push it onto the states list 777 | */ 778 | const states = [] 779 | 780 | autorun(() => { 781 | states.push(serializeState(store)) 782 | }) 783 | 784 | const serializeState = createTransformer((store) => ({ 785 | boxes: store.boxes.map(serializeBox), 786 | arrows: store.arrows.map(serializeArrow), 787 | selection: store.selection ? store.selection.id : null, 788 | })) 789 | 790 | const serializeBox = createTransformer((box) => ({ ...box })) 791 | 792 | const serializeArrow = createTransformer((arrow) => ({ 793 | id: arrow.id, 794 | to: arrow.to.id, 795 | from: arrow.from.id, 796 | })) 797 | ``` 798 | 799 | In this example the state is serialized by composing three different transformation functions. 800 | The autorunner triggers the serialization of the `store` object, which in turn serializes all boxes and arrows. 801 | Let's take closer look at the life of an imaginary example box#3. 802 | 803 | 1. The first time box#3 is passed by `map` to `serializeBox`, 804 | the serializeBox transformation is executed and an entry containing box#3 and its serialized representation is added to the internal memoization table of `serializeBox`. 805 | 2. Imagine that another box is added to the `store.boxes` list. 806 | This would cause the `serializeState` function to re-compute, resulting in a complete remapping of all the boxes. 807 | However, all the invocations of `serializeBox` will now return their old values from the memoization tables since their transformation functions didn't (need to) run again. 808 | 3. Secondly, if somebody changes a property of box#3 this will cause the application of the `serializeBox` to box#3 to re-compute, just like any other reactive function in MobX. 809 | Since the transformation will now produce a new Json object based on box#3, all observers of that specific transformation will be forced to run again as well. 810 | That's the `serializeState` transformation in this case. 811 | `serializeState` will now produce a new value in turn and map all the boxes again. But except for box#3, all other boxes will be returned from the memoization table. 812 | 4. Finally, if box#3 is removed from `store.boxes`, `serializeState` will compute again. 813 | But since it will no longer be using the application of `serializeBox` to box#3, 814 | that reactive function will go back to non-reactive mode. 815 | This signals the memoization table that the entry can be removed so that it is ready for GC. 816 | 817 | So effectively we have achieved state tracking using immutable, shared datas structures here. 818 | All boxes and arrows are mapped and reduced into single state tree. 819 | Each change will result in a new entry in the `states` array, but the different entries will share almost all of their box and arrow representations. 820 | 821 | ### Transforming a datagraph into another reactive data graph 822 | 823 | Instead of returning plain values from a transformation function, it is also possible to return observable objects. 824 | This can be used to transform an observable data graph into a another observable data graph, which can be used to transform... you get the idea. 825 | 826 | Here is a small example that encodes a reactive file explorer that will update its representation upon each change. 827 | Data graphs that are built this way will in general react a lot faster and will consist of much more straight-forward code, 828 | compared to derived data graph that are updated using your own code. See the [performance tests](https://github.com/mobxjs/mobx/blob/3ea1f4af20a51a1cb30be3e4a55ec8f964a8c495/test/perf/transform-perf.js#L4) for some examples. 829 | 830 | Unlike the previous example, the `transformFolder` will only run once as long as a folder remains visible; 831 | the `DisplayFolder` objects track the associated `Folder` objects themselves. 832 | 833 | In the following example all mutations to the `state` graph will be processed automatically. 834 | Some examples: 835 | 836 | 1. Changing the name of a folder will update its own `path` property and the `path` property of all its descendants. 837 | 2. Collapsing a folder will remove all descendant `DisplayFolders` from the tree. 838 | 3. Expanding a folder will restore them again. 839 | 4. Setting a search filter will remove all nodes that do not match the filter, unless they have a descendant that matches the filter. 840 | 5. Etc. 841 | 842 | ```javascript 843 | import {extendObservable, observable, createTransformer, autorun} from "mobx" 844 | 845 | function Folder(parent, name) { 846 | this.parent = parent; 847 | extendObservable(this, { 848 | name: name, 849 | children: observable.shallow([]), 850 | }); 851 | } 852 | 853 | function DisplayFolder(folder, state) { 854 | this.state = state; 855 | this.folder = folder; 856 | extendObservable(this, { 857 | collapsed: false, 858 | get name() { 859 | return this.folder.name; 860 | }, 861 | get isVisible() { 862 | return !this.state.filter || this.name.indexOf(this.state.filter) !== -1 || this.children.some(child => child.isVisible); 863 | }, 864 | get children() { 865 | if (this.collapsed) 866 | return []; 867 | return this.folder.children.map(transformFolder).filter(function(child) { 868 | return child.isVisible; 869 | }) 870 | }, 871 | get path() { 872 | return this.folder.parent === null ? this.name : transformFolder(this.folder.parent).path + "/" + this.name; 873 | }) 874 | }); 875 | } 876 | 877 | var state = observable({ 878 | root: new Folder(null, "root"), 879 | filter: null, 880 | displayRoot: null 881 | }); 882 | 883 | var transformFolder = createTransformer(function (folder) { 884 | return new DisplayFolder(folder, state); 885 | }); 886 | 887 | 888 | // returns list of strings per folder 889 | var stringTransformer = createTransformer(function (displayFolder) { 890 | var path = displayFolder.path; 891 | return path + "\n" + 892 | displayFolder.children.filter(function(child) { 893 | return child.isVisible; 894 | }).map(stringTransformer).join(''); 895 | }); 896 | 897 | function createFolders(parent, recursion) { 898 | if (recursion === 0) 899 | return; 900 | for (var i = 0; i < 3; i++) { 901 | var folder = new Folder(parent, i + ''); 902 | parent.children.push(folder); 903 | createFolders(folder, recursion - 1); 904 | } 905 | } 906 | 907 | createFolders(state.root, 2); // 3^2 908 | 909 | autorun(function() { 910 | state.displayRoot = transformFolder(state.root); 911 | state.text = stringTransformer(state.displayRoot) 912 | console.log(state.text) 913 | }); 914 | 915 | state.root.name = 'wow'; // change folder name 916 | state.displayRoot.children[1].collapsed = true; // collapse folder 917 | state.filter = "2"; // search 918 | state.filter = null; // unsearch 919 | ``` 920 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mobx-utils", 3 | "version": "6.1.0", 4 | "description": "Utility functions and common patterns for MobX", 5 | "main": "mobx-utils.umd.js", 6 | "module": "mobx-utils.module.js", 7 | "jsnext:main": "mobx-utils.module.js", 8 | "react-native": "mobx-utils.module.js", 9 | "typings": "lib/mobx-utils.d.ts", 10 | "sideEffects": false, 11 | "scripts": { 12 | "prettier": "prettier --write \"**/*.js\" \"**/*.jsx\" \"**/*.tsx\" \"**/*.ts\"", 13 | "build": "tsc -p src && rollup -c", 14 | "watch": "jest --watch", 15 | "test": "jest", 16 | "prepublishOnly": "npm run build && npm run build-docs", 17 | "coverage": "jest --coverage", 18 | "build-docs": "npm run build && documentation readme lib/mobx-utils.js --section API" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/mobxjs/mobx-utils.git" 23 | }, 24 | "author": "Michel Weststrate", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/mobxjs/mobx-utils/issues" 28 | }, 29 | "files": [ 30 | "lib/", 31 | "mobx-utils.umd.js", 32 | "mobx-utils.module.js" 33 | ], 34 | "devDependencies": { 35 | "@types/jest": "^25.2.3", 36 | "coveralls": "^3.1.0", 37 | "documentation": "^13.0.0", 38 | "faucet": "*", 39 | "husky": "^4.2.5", 40 | "jest": "^26.0.1", 41 | "lint-staged": "^10.2.6", 42 | "lodash.clonedeep": "*", 43 | "lodash.clonedeepwith": "*", 44 | "lodash.intersection": "*", 45 | "mobx": "^6.0.0", 46 | "prettier": "^2.0.5", 47 | "rollup": "^2.10.8", 48 | "rxjs": "^6.6.3", 49 | "shelljs": "^0.8.4", 50 | "ts-jest": "^26.0.0", 51 | "typescript": "^4.0.3" 52 | }, 53 | "dependencies": {}, 54 | "peerDependencies": { 55 | "mobx": "^6.0.0" 56 | }, 57 | "keywords": [ 58 | "mobx", 59 | "mobx-utils", 60 | "promise", 61 | "reactive", 62 | "frp", 63 | "functional-reactive-programming", 64 | "state management" 65 | ], 66 | "jest": { 67 | "preset": "ts-jest", 68 | "testRegex": "test/.*\\.(t|j)sx?$", 69 | "moduleFileExtensions": [ 70 | "ts", 71 | "tsx", 72 | "js", 73 | "jsx", 74 | "json" 75 | ], 76 | "testPathIgnorePatterns": [ 77 | "/node_modules/", 78 | "/lib/", 79 | "/coverage/", 80 | "/\\./" 81 | ], 82 | "watchPathIgnorePatterns": [ 83 | "/node_modules/" 84 | ] 85 | }, 86 | "lint-staged": { 87 | "*.{ts,tsx,js,jsx}": "prettier --write" 88 | }, 89 | "husky": { 90 | "hooks": { 91 | "pre-commit": "lint-staged" 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /publish.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* Publish.js, publish a new version of the npm package as found in the current directory */ 3 | /* Run this file from the root of the repository */ 4 | 5 | const shell = require("shelljs") 6 | const fs = require("fs") 7 | const readline = require("readline") 8 | 9 | const rl = readline.createInterface({ 10 | input: process.stdin, 11 | output: process.stdout, 12 | }) 13 | 14 | function run(command, options) { 15 | const continueOnErrors = options && options.continueOnErrors 16 | const ret = shell.exec(command, options) 17 | if (!continueOnErrors && ret.code !== 0) { 18 | shell.exit(1) 19 | } 20 | return ret 21 | } 22 | 23 | function exit(code, msg) { 24 | console.error(msg) 25 | shell.exit(code) 26 | } 27 | 28 | async function prompt(question, defaultValue) { 29 | return new Promise((resolve) => { 30 | rl.question(`${question} [${defaultValue}]: `, (answer) => { 31 | answer = answer && answer.trim() 32 | resolve(answer ? answer : defaultValue) 33 | }) 34 | }) 35 | } 36 | 37 | async function main() { 38 | const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")) 39 | 40 | // Bump version number 41 | let nrs = pkg.version.split(".") 42 | nrs[2] = 1 + parseInt(nrs[2], 10) 43 | const version = (pkg.version = await prompt( 44 | "Please specify the new package version of '" + pkg.name + "' (Ctrl^C to abort)", 45 | nrs.join(".") 46 | )) 47 | if (!version.match(/^\d+\.\d+\.\d+$/)) { 48 | exit(1, "Invalid semantic version: " + version) 49 | } 50 | 51 | // Check registry data 52 | const npmInfoRet = run(`npm info ${pkg.name} --json`, { 53 | continueOnErrors: true, 54 | silent: true, 55 | }) 56 | if (npmInfoRet.code === 0) { 57 | //package is registered in npm? 58 | var publishedPackageInfo = JSON.parse(npmInfoRet.stdout) 59 | if ( 60 | publishedPackageInfo.versions == version || 61 | publishedPackageInfo.versions.includes(version) 62 | ) { 63 | exit(2, "Version " + pkg.version + " is already published to npm") 64 | } 65 | 66 | fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2), "utf8") 67 | 68 | // build 69 | run("npm run prepublishOnly") 70 | // Finally, commit and publish! 71 | run("npm publish") 72 | run(`git commit -am "Published version ${version}"`) 73 | run(`git tag ${version}`) 74 | 75 | run("git push") 76 | run("git push --tags") 77 | console.log("Published!") 78 | exit(0) 79 | } else { 80 | exit(1, pkg.name + " is not an existing npm package") 81 | } 82 | } 83 | 84 | main().catch((e) => { 85 | throw e 86 | }) 87 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | input: "lib/mobx-utils.js", 3 | output: [ 4 | { 5 | format: "umd", 6 | file: "mobx-utils.umd.js", 7 | name: "mobxUtils", 8 | globals: { 9 | mobx: "mobx", 10 | }, 11 | }, 12 | { 13 | format: "es", 14 | file: "mobx-utils.module.js", 15 | }, 16 | ], 17 | external: ["mobx"], 18 | onwarn: function (warning, warn) { 19 | // https://github.com/rollup/rollup/wiki/Troubleshooting#this-is-undefined 20 | if ("THIS_IS_UNDEFINED" === warning.code) return 21 | 22 | warn(warning) 23 | }, 24 | } 25 | -------------------------------------------------------------------------------- /src/ObservableGroupMap.ts: -------------------------------------------------------------------------------- 1 | import { 2 | observable, 3 | IReactionDisposer, 4 | reaction, 5 | observe, 6 | IObservableArray, 7 | transaction, 8 | ObservableMap, 9 | Lambda, 10 | } from "mobx" 11 | 12 | // ObservableGroupMaps actually each use their own symbol, so that an item can be tracked by 13 | // multiple OGMs. We declare this here so we can type the arrays properly. 14 | declare const OGM_INFO_KEY: unique symbol 15 | 16 | interface GroupItem { 17 | [OGM_INFO_KEY]?: GroupItemInfo 18 | } 19 | 20 | interface GroupItemInfo { 21 | groupByValue: any 22 | reaction: IReactionDisposer 23 | groupArrIndex: number 24 | } 25 | 26 | /** 27 | * Reactively sorts a base observable array into multiple observable arrays based on the value of a 28 | * `groupBy: (item: T) => G` function. 29 | * 30 | * This observes the individual computed groupBy values and only updates the source and dest arrays 31 | * when there is an actual change, so this is far more efficient than, for example 32 | * `base.filter(i => groupBy(i) === 'we')`. Call #dispose() to stop tracking. 33 | * 34 | * No guarantees are made about the order of items in the grouped arrays. 35 | * 36 | * The resulting map of arrays is read-only. clear(), set(), delete() are not supported and 37 | * modifying the group arrays will lead to undefined behavior. 38 | * 39 | * NB: ObservableGroupMap relies on `Symbol`s. If you are targeting a platform which doesn't 40 | * support these natively, you will need to provide a polyfill. 41 | * 42 | * @param {array} base The array to sort into groups. 43 | * @param {function} groupBy The function used for grouping. 44 | * @param options Object with properties: 45 | * `name`: Debug name of this ObservableGroupMap. 46 | * `keyToName`: Function to create the debug names of the observable group arrays. 47 | * 48 | * @example 49 | * const slices = observable([ 50 | * { day: "mo", hours: 12 }, 51 | * { day: "tu", hours: 2 }, 52 | * ]) 53 | * const slicesByDay = new ObservableGroupMap(slices, (slice) => slice.day) 54 | * autorun(() => console.log( 55 | * slicesByDay.get("mo")?.length ?? 0, 56 | * slicesByDay.get("we"))) // outputs 1, undefined 57 | * slices[0].day = "we" // outputs 0, [{ day: "we", hours: 12 }] 58 | */ 59 | export class ObservableGroupMap extends ObservableMap> { 60 | /** 61 | * Base observable array which is being sorted into groups. 62 | */ 63 | private readonly _base: IObservableArray 64 | 65 | /** 66 | * The ObservableGroupMap needs to track some state per-item. This is the name/symbol of the 67 | * property used to attach the state. 68 | */ 69 | private readonly _ogmInfoKey: typeof OGM_INFO_KEY 70 | 71 | /** 72 | * The function used to group the items. 73 | */ 74 | private readonly _groupBy: (x: T) => G 75 | 76 | /** 77 | * This function is used to generate the mobx debug names of the observable group arrays. 78 | */ 79 | private readonly _keyToName: (group: G) => string 80 | 81 | private readonly _disposeBaseObserver: Lambda 82 | 83 | constructor( 84 | base: IObservableArray, 85 | groupBy: (x: T) => G, 86 | { 87 | name = "ogm" + ((Math.random() * 1000) | 0), 88 | keyToName = (x) => "" + x, 89 | }: { name?: string; keyToName?: (group: G) => string } = {} 90 | ) { 91 | super() 92 | this._keyToName = keyToName 93 | this._groupBy = groupBy 94 | this._ogmInfoKey = Symbol("ogmInfo" + name) as any 95 | this._base = base 96 | 97 | for (let i = 0; i < base.length; i++) { 98 | this._addItem(base[i]) 99 | } 100 | 101 | this._disposeBaseObserver = observe(this._base, (change) => { 102 | if ("splice" === change.type) { 103 | transaction(() => { 104 | for (const removed of change.removed) { 105 | this._removeItem(removed) 106 | } 107 | for (const added of change.added) { 108 | this._addItem(added) 109 | } 110 | }) 111 | } else if ("update" === change.type) { 112 | transaction(() => { 113 | this._removeItem(change.oldValue) 114 | this._addItem(change.newValue) 115 | }) 116 | } else { 117 | throw new Error("illegal state") 118 | } 119 | }) 120 | } 121 | 122 | public clear(): void { 123 | throw new Error("not supported") 124 | } 125 | 126 | public delete(_key: G): boolean { 127 | throw new Error("not supported") 128 | } 129 | 130 | public set(_key: G, _value: IObservableArray): this { 131 | throw new Error("not supported") 132 | } 133 | 134 | /** 135 | * Disposes all observers created during construction and removes state added to base array 136 | * items. 137 | */ 138 | public dispose() { 139 | this._disposeBaseObserver() 140 | for (let i = 0; i < this._base.length; i++) { 141 | const item = this._base[i] 142 | const grouperItemInfo: GroupItemInfo = item[this._ogmInfoKey]! 143 | grouperItemInfo.reaction() 144 | 145 | delete item[this._ogmInfoKey] 146 | } 147 | } 148 | 149 | private _getGroupArr(key: G) { 150 | let result = super.get(key) 151 | if (undefined === result) { 152 | result = observable([], { name: `GroupArray[${this._keyToName(key)}]`, deep: false }) 153 | super.set(key, result) 154 | } 155 | return result 156 | } 157 | 158 | private _removeFromGroupArr(key: G, itemIndex: number) { 159 | const arr: IObservableArray = super.get(key)! 160 | if (1 === arr.length) { 161 | super.delete(key) 162 | } else if (itemIndex === arr.length - 1) { 163 | // last position in array 164 | arr.length-- 165 | } else { 166 | arr[itemIndex] = arr[arr.length - 1] 167 | arr[itemIndex][this._ogmInfoKey]!.groupArrIndex = itemIndex 168 | arr.length-- 169 | } 170 | } 171 | 172 | private _addItem(item: T & GroupItem) { 173 | const groupByValue = this._groupBy(item) 174 | const groupArr = this._getGroupArr(groupByValue) 175 | const value: GroupItemInfo = { 176 | groupByValue: groupByValue, 177 | groupArrIndex: groupArr.length, 178 | reaction: reaction( 179 | () => this._groupBy(item), 180 | (newGroupByValue, _r) => { 181 | const grouperItemInfo = item[this._ogmInfoKey]! 182 | this._removeFromGroupArr( 183 | grouperItemInfo.groupByValue, 184 | grouperItemInfo.groupArrIndex 185 | ) 186 | 187 | const newGroupArr = this._getGroupArr(newGroupByValue) 188 | const newGroupArrIndex = newGroupArr.length 189 | newGroupArr.push(item) 190 | grouperItemInfo.groupByValue = newGroupByValue 191 | grouperItemInfo.groupArrIndex = newGroupArrIndex 192 | } 193 | ), 194 | } 195 | Object.defineProperty(item, this._ogmInfoKey, { 196 | configurable: true, 197 | enumerable: false, 198 | value, 199 | }) 200 | groupArr.push(item) 201 | } 202 | 203 | private _removeItem(item: GroupItem) { 204 | const grouperItemInfo = item[this._ogmInfoKey]! 205 | this._removeFromGroupArr(grouperItemInfo.groupByValue, grouperItemInfo.groupArrIndex) 206 | grouperItemInfo.reaction() 207 | 208 | delete item[this._ogmInfoKey] 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/array.ts: -------------------------------------------------------------------------------- 1 | import { IObservableArray, $mobx } from "mobx" 2 | 3 | /** 4 | * Moves an item from one position to another, checking that the indexes given are within bounds. 5 | * 6 | * @example 7 | * const source = observable([1, 2, 3]) 8 | * moveItem(source, 0, 1) 9 | * console.log(source.map(x => x)) // [2, 1, 3] 10 | * 11 | * @export 12 | * @param {ObservableArray} target 13 | * @param {number} fromIndex 14 | * @param {number} toIndex 15 | * @returns {ObservableArray} 16 | */ 17 | export function moveItem(target: IObservableArray, fromIndex: number, toIndex: number) { 18 | checkIndex(target, fromIndex) 19 | checkIndex(target, toIndex) 20 | if (fromIndex === toIndex) { 21 | return 22 | } 23 | const oldItems = target.slice() 24 | let newItems: T[] 25 | if (fromIndex < toIndex) { 26 | newItems = [ 27 | ...oldItems.slice(0, fromIndex), 28 | ...oldItems.slice(fromIndex + 1, toIndex + 1), 29 | oldItems[fromIndex], 30 | ...oldItems.slice(toIndex + 1), 31 | ] 32 | } else { 33 | // toIndex < fromIndex 34 | newItems = [ 35 | ...oldItems.slice(0, toIndex), 36 | oldItems[fromIndex], 37 | ...oldItems.slice(toIndex, fromIndex), 38 | ...oldItems.slice(fromIndex + 1), 39 | ] 40 | } 41 | target.replace(newItems) 42 | return target 43 | } 44 | 45 | /** 46 | * Checks whether the specified index is within bounds. Throws if not. 47 | * 48 | * @private 49 | * @param {ObservableArray} target 50 | * @param {number }index 51 | */ 52 | function checkIndex(target: IObservableArray, index: number) { 53 | if (index < 0) { 54 | throw new Error(`[mobx.array] Index out of bounds: ${index} is negative`) 55 | } 56 | const length = (target as any).length 57 | if (index >= length) { 58 | throw new Error(`[mobx.array] Index out of bounds: ${index} is not smaller than ${length}`) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/chunk-processor.ts: -------------------------------------------------------------------------------- 1 | import { isAction, autorun, action, isObservableArray, runInAction } from "mobx" 2 | import { IDisposer } from "./utils" 3 | 4 | /** 5 | * `chunkProcessor` takes an observable array, observes it and calls `processor` 6 | * once for a chunk of items added to the observable array, optionally deboucing the action. 7 | * The maximum chunk size can be limited by number. 8 | * This allows both, splitting larger into smaller chunks or (when debounced) combining smaller 9 | * chunks and/or single items into reasonable chunks of work. 10 | * 11 | * @example 12 | * const trackedActions = observable([]) 13 | * const stop = chunkProcessor(trackedActions, chunkOfMax10Items => { 14 | * sendTrackedActionsToServer(chunkOfMax10Items); 15 | * }, 100, 10) 16 | * 17 | * // usage: 18 | * trackedActions.push("scrolled") 19 | * trackedActions.push("hoveredButton") 20 | * // when both pushes happen within 100ms, there will be only one call to server 21 | * 22 | * @param {T[]} observableArray observable array instance to track 23 | * @param {(item: T[]) => void} processor action to call per item 24 | * @param {number} [debounce=0] optional debounce time in ms. With debounce 0 the processor will run synchronously 25 | * @param {number} [maxChunkSize=0] optionally do not call on full array but smaller chunks. With 0 it will process the full array. 26 | * @returns {IDisposer} stops the processor 27 | */ 28 | export function chunkProcessor( 29 | observableArray: T[], 30 | processor: (item: T[]) => void, 31 | debounce = 0, 32 | maxChunkSize = 0 33 | ): IDisposer { 34 | if (!isObservableArray(observableArray)) 35 | throw new Error("Expected observable array as first argument") 36 | if (!isAction(processor)) processor = action("chunkProcessor", processor) 37 | 38 | const runner = () => { 39 | while (observableArray.length > 0) { 40 | let chunkSize = 41 | maxChunkSize === 0 42 | ? observableArray.length 43 | : Math.min(observableArray.length, maxChunkSize) 44 | // construct a final set 45 | const items = observableArray.slice(0, chunkSize) 46 | // clear the slice for next iteration 47 | runInAction(() => observableArray.splice(0, chunkSize)) 48 | // fire processor 49 | processor(items) 50 | } 51 | } 52 | if (debounce > 0) return autorun(runner, { delay: debounce }) 53 | else return autorun(runner) 54 | } 55 | -------------------------------------------------------------------------------- /src/computedFn.ts: -------------------------------------------------------------------------------- 1 | import { DeepMap } from "./deepMap" 2 | import { 3 | IComputedValue, 4 | IComputedValueOptions, 5 | computed, 6 | onBecomeUnobserved, 7 | _isComputingDerivation, 8 | isAction, 9 | _getGlobalState, 10 | } from "mobx" 11 | 12 | export type IComputedFnOptions any> = { 13 | onCleanup?: (result: ReturnType | undefined, ...args: Parameters) => void 14 | } & IComputedValueOptions> 15 | 16 | /** 17 | * computedFn takes a function with an arbitrary amount of arguments, 18 | * and memoizes the output of the function based on the arguments passed in. 19 | * 20 | * computedFn(fn) returns a function with the very same signature. There is no limit on the amount of arguments 21 | * that is accepted. However, the amount of arguments must be constant and default arguments are not supported. 22 | * 23 | * By default the output of a function call will only be memoized as long as the 24 | * output is being observed. 25 | * 26 | * The function passes into `computedFn` should be pure, not be an action and only be relying on 27 | * observables. 28 | * 29 | * Setting `keepAlive` to `true` will cause the output to be forcefully cached forever. 30 | * Note that this might introduce memory leaks! 31 | * 32 | * @example 33 | * const store = observable({ 34 | a: 1, 35 | b: 2, 36 | c: 3, 37 | m: computedFn(function(x) { 38 | return this.a * this.b * x 39 | }) 40 | }) 41 | 42 | const d = autorun(() => { 43 | // store.m(3) will be cached as long as this autorun is running 44 | console.log(store.m(3) * store.c) 45 | }) 46 | * 47 | * @param fn 48 | * @param keepAliveOrOptions 49 | */ 50 | export function computedFn any>( 51 | fn: T, 52 | keepAliveOrOptions: IComputedFnOptions | boolean = false 53 | ): T { 54 | if (isAction(fn)) throw new Error("computedFn shouldn't be used on actions") 55 | 56 | let memoWarned = false 57 | let i = 0 58 | const opts = 59 | typeof keepAliveOrOptions === "boolean" 60 | ? { keepAlive: keepAliveOrOptions } 61 | : keepAliveOrOptions 62 | const d = new DeepMap>() 63 | 64 | return function (this: any, ...args: Parameters): ReturnType { 65 | const entry = d.entry(args) 66 | // cache hit, return 67 | if (entry.exists()) return entry.get().get() 68 | // if function is invoked, and its a cache miss without reactive, there is no point in caching... 69 | if (!opts.keepAlive && !_isComputingDerivation()) { 70 | if (!memoWarned && _getGlobalState().computedRequiresReaction) { 71 | console.warn( 72 | "Invoking a computedFn from outside a reactive context won't be memoized unless keepAlive is set." 73 | ) 74 | memoWarned = true 75 | } 76 | return fn.apply(this, args) 77 | } 78 | // create new entry 79 | let latestValue: ReturnType | undefined 80 | const c = computed( 81 | () => { 82 | return (latestValue = fn.apply(this, args)) 83 | }, 84 | { 85 | ...opts, 86 | name: `computedFn(${opts.name || fn.name}#${++i})`, 87 | } 88 | ) 89 | entry.set(c) 90 | // clean up if no longer observed 91 | if (!opts.keepAlive) 92 | onBecomeUnobserved(c, () => { 93 | d.entry(args).delete() 94 | if (opts.onCleanup) opts.onCleanup(latestValue, ...args) 95 | latestValue = undefined 96 | }) 97 | // return current val 98 | return c.get() 99 | } as any 100 | } 101 | -------------------------------------------------------------------------------- /src/create-transformer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | computed, 3 | onBecomeUnobserved, 4 | IComputedValue, 5 | _isComputingDerivation, 6 | IComputedValueOptions, 7 | } from "mobx" 8 | import { invariant } from "./utils" 9 | 10 | export type ITransformer = (object: A) => B 11 | 12 | export type ITransformerCleanup = (resultObject: B | undefined, sourceObject?: A) => void 13 | 14 | export type ITransformerParams = { 15 | onCleanup?: ITransformerCleanup 16 | debugNameGenerator?: (sourceObject?: A) => string 17 | keepAlive?: boolean 18 | } & Omit, "name"> 19 | 20 | export function createTransformer( 21 | transformer: ITransformer, 22 | onCleanup?: ITransformerCleanup 23 | ): ITransformer 24 | export function createTransformer( 25 | transformer: ITransformer, 26 | arg2?: ITransformerParams 27 | ): ITransformer 28 | /** 29 | * Creates a function that maps an object to a view. 30 | * The mapping is memoized. 31 | * 32 | * See the [transformer](#createtransformer-in-detail) section for more details. 33 | * 34 | * @param transformer A function which transforms instances of A into instances of B 35 | * @param arg2 An optional cleanup function which is called when the transformation is no longer 36 | * observed from a reactive context, or config options 37 | * @returns The memoized transformer function 38 | */ 39 | export function createTransformer( 40 | transformer: ITransformer, 41 | arg2?: ITransformerParams | ITransformerCleanup 42 | ): ITransformer { 43 | invariant( 44 | typeof transformer === "function" && transformer.length < 2, 45 | "createTransformer expects a function that accepts one argument" 46 | ) 47 | 48 | // Memoizes: object -> reactive view that applies transformer to the object 49 | const views = new Map>() 50 | let onCleanup: Function | undefined = undefined 51 | let keepAlive: boolean = false 52 | let debugNameGenerator: Function | undefined = undefined 53 | if (typeof arg2 === "object") { 54 | onCleanup = arg2.onCleanup 55 | keepAlive = arg2.keepAlive !== undefined ? arg2.keepAlive : false 56 | debugNameGenerator = arg2.debugNameGenerator 57 | } else if (typeof arg2 === "function") { 58 | onCleanup = arg2 59 | } 60 | 61 | function createView(sourceObject: A) { 62 | let latestValue: B 63 | let computedValueOptions = {} 64 | if (typeof arg2 === "object") { 65 | onCleanup = arg2.onCleanup 66 | debugNameGenerator = arg2.debugNameGenerator 67 | computedValueOptions = arg2 68 | } else if (typeof arg2 === "function") { 69 | onCleanup = arg2 70 | } else { 71 | onCleanup = undefined 72 | debugNameGenerator = undefined 73 | } 74 | const sourceType = typeof sourceObject 75 | const prettifiedName = debugNameGenerator 76 | ? debugNameGenerator(sourceObject) 77 | : `Transformer-${(transformer).name}-${ 78 | sourceType === "string" || sourceType === "number" ? sourceObject : "object" 79 | }` 80 | const expr = computed( 81 | () => { 82 | return (latestValue = transformer(sourceObject)) 83 | }, 84 | { 85 | ...computedValueOptions, 86 | name: prettifiedName, 87 | } 88 | ) 89 | if (!keepAlive) { 90 | const disposer = onBecomeUnobserved(expr, () => { 91 | views.delete(sourceObject) 92 | disposer() 93 | if (onCleanup) onCleanup(latestValue, sourceObject) 94 | }) 95 | } 96 | return expr 97 | } 98 | 99 | let memoWarned = false 100 | return (object: A) => { 101 | checkTransformableObject(object) 102 | let reactiveView = views.get(object) 103 | if (reactiveView) return reactiveView.get() 104 | if (!keepAlive && !_isComputingDerivation()) { 105 | if (!memoWarned) { 106 | console.warn( 107 | "invoking a transformer from outside a reactive context won't memorized " + 108 | "and is cleaned up immediately, unless keepAlive is set" 109 | ) 110 | memoWarned = true 111 | } 112 | const value = transformer(object) 113 | if (onCleanup) onCleanup(value, object) 114 | return value 115 | } 116 | // Not in cache; create a reactive view 117 | reactiveView = createView(object) 118 | views.set(object, reactiveView) 119 | return reactiveView.get() 120 | } 121 | } 122 | 123 | function checkTransformableObject(object: any) { 124 | const objectType = typeof object 125 | if ( 126 | object === null || 127 | (objectType !== "object" && 128 | objectType !== "function" && 129 | objectType !== "string" && 130 | objectType !== "number") 131 | ) 132 | throw new Error( 133 | `[mobx-utils] transform expected an object, function, string or number, got: ${String( 134 | object 135 | )}` 136 | ) 137 | } 138 | -------------------------------------------------------------------------------- /src/create-view-model.ts: -------------------------------------------------------------------------------- 1 | import { 2 | action, 3 | ObservableMap, 4 | IComputedValue, 5 | observable, 6 | isObservableObject, 7 | isObservableArray, 8 | isObservableMap, 9 | isComputedProp, 10 | isComputed, 11 | computed, 12 | keys, 13 | _getAdministration, 14 | $mobx, 15 | makeObservable, 16 | } from "mobx" 17 | import { ComputedValue } from "mobx/dist/internal" 18 | import { invariant, getAllMethodsAndProperties } from "./utils" 19 | 20 | export interface IViewModel { 21 | model: T 22 | reset(): void 23 | submit(): void 24 | isDirty: boolean 25 | changedValues: Map 26 | isPropertyDirty(key: keyof T): boolean 27 | resetProperty(key: keyof T): void 28 | } 29 | 30 | const RESERVED_NAMES = ["model", "reset", "submit", "isDirty", "isPropertyDirty", "resetProperty"] 31 | 32 | export class ViewModel implements IViewModel { 33 | localValues: ObservableMap = observable.map({}) 34 | localComputedValues: ObservableMap> = observable.map({}) 35 | 36 | @computed 37 | get isDirty() { 38 | return this.localValues.size > 0 39 | } 40 | 41 | @computed 42 | get changedValues() { 43 | return new Map(this.localValues) 44 | } 45 | 46 | constructor(public model: T) { 47 | makeObservable(this) 48 | invariant(isObservableObject(model), "createViewModel expects an observable object") 49 | const ownMethodsAndProperties = getAllMethodsAndProperties(this) 50 | 51 | // use this helper as Object.getOwnPropertyNames doesn't return getters 52 | getAllMethodsAndProperties(model).forEach((key: any) => { 53 | if (ownMethodsAndProperties.includes(key)) { 54 | return 55 | } 56 | if (key === ($mobx as any) || key === "__mobxDidRunLazyInitializers") { 57 | return 58 | } 59 | invariant( 60 | RESERVED_NAMES.indexOf(key) === -1, 61 | `The propertyname ${key} is reserved and cannot be used with viewModels` 62 | ) 63 | if (isComputedProp(model, key)) { 64 | const computedBox = _getAdministration(model, key) as ComputedValue // Fixme: there is no clear api to get the derivation 65 | const get = computedBox.derivation.bind(this) 66 | const set = computedBox.setter_?.bind(this) 67 | this.localComputedValues.set(key, computed(get, { set })) 68 | } 69 | 70 | const descriptor = Object.getOwnPropertyDescriptor(model, key) 71 | const additionalDescriptor = descriptor ? { enumerable: descriptor.enumerable } : {} 72 | 73 | Object.defineProperty(this, key, { 74 | ...additionalDescriptor, 75 | configurable: true, 76 | get: () => { 77 | if (isComputedProp(model, key)) return this.localComputedValues.get(key)!.get() 78 | if (this.isPropertyDirty(key)) return this.localValues.get(key) 79 | else return this.model[key as keyof T] 80 | }, 81 | set: action((value: any) => { 82 | if (isComputedProp(model, key)) { 83 | this.localComputedValues.get(key)!.set(value) 84 | } else if (value !== this.model[key as keyof T]) { 85 | this.localValues.set(key, value) 86 | } else { 87 | this.localValues.delete(key) 88 | } 89 | }), 90 | }) 91 | }) 92 | } 93 | 94 | isPropertyDirty = (key: keyof T): boolean => { 95 | return this.localValues.has(key) 96 | } 97 | 98 | @action.bound 99 | submit() { 100 | keys(this.localValues).forEach((key: keyof T) => { 101 | const source = this.localValues.get(key)! 102 | const destination = this.model[key] 103 | if (isObservableArray(destination)) { 104 | destination.replace(source as any) 105 | } else if (isObservableMap(destination)) { 106 | destination.clear() 107 | destination.merge(source) 108 | } else if (!isComputed(source)) { 109 | this.model[key] = source 110 | } 111 | }) 112 | this.localValues.clear() 113 | } 114 | 115 | @action.bound 116 | reset() { 117 | this.localValues.clear() 118 | } 119 | 120 | @action.bound 121 | resetProperty(key: keyof T) { 122 | this.localValues.delete(key) 123 | } 124 | } 125 | 126 | /** 127 | * `createViewModel` takes an object with observable properties (model) 128 | * and wraps a viewmodel around it. The viewmodel proxies all enumerable properties of the original model with the following behavior: 129 | * - as long as no new value has been assigned to the viewmodel property, the original property will be returned. 130 | * - any future change in the model will be visible in the viewmodel as well unless the viewmodel property was dirty at the time of the attempted change. 131 | * - once a new value has been assigned to a property of the viewmodel, that value will be returned during a read of that property in the future. However, the original model remain untouched until `submit()` is called. 132 | * 133 | * The viewmodel exposes the following additional methods, besides all the enumerable properties of the model: 134 | * - `submit()`: copies all the values of the viewmodel to the model and resets the state 135 | * - `reset()`: resets the state of the viewmodel, abandoning all local modifications 136 | * - `resetProperty(propName)`: resets the specified property of the viewmodel 137 | * - `isDirty`: observable property indicating if the viewModel contains any modifications 138 | * - `isPropertyDirty(propName)`: returns true if the specified property is dirty 139 | * - `changedValues`: returns a key / value map with the properties that have been changed in the model so far 140 | * - `model`: The original model object for which this viewModel was created 141 | * 142 | * You may use observable arrays, maps and objects with `createViewModel` but keep in mind to assign fresh instances of those to the viewmodel's properties, otherwise you would end up modifying the properties of the original model. 143 | * Note that if you read a non-dirty property, viewmodel only proxies the read to the model. You therefore need to assign a fresh instance not only the first time you make the assignment but also after calling `reset()` or `submit()`. 144 | * 145 | * @example 146 | * class Todo { 147 | * \@observable title = "Test" 148 | * } 149 | * 150 | * const model = new Todo() 151 | * const viewModel = createViewModel(model); 152 | * 153 | * autorun(() => console.log(viewModel.model.title, ",", viewModel.title)) 154 | * // prints "Test, Test" 155 | * model.title = "Get coffee" 156 | * // prints "Get coffee, Get coffee", viewModel just proxies to model 157 | * viewModel.title = "Get tea" 158 | * // prints "Get coffee, Get tea", viewModel's title is now dirty, and the local value will be printed 159 | * viewModel.submit() 160 | * // prints "Get tea, Get tea", changes submitted from the viewModel to the model, viewModel is proxying again 161 | * viewModel.title = "Get cookie" 162 | * // prints "Get tea, Get cookie" // viewModel has diverged again 163 | * viewModel.reset() 164 | * // prints "Get tea, Get tea", changes of the viewModel have been abandoned 165 | * 166 | * @param {T} model 167 | * @returns {(T & IViewModel)} 168 | * ``` 169 | */ 170 | export function createViewModel(model: T): T & IViewModel { 171 | return new ViewModel(model) as any 172 | } 173 | -------------------------------------------------------------------------------- /src/decorator-utils.ts: -------------------------------------------------------------------------------- 1 | import { addHiddenProp } from "./utils" 2 | 3 | type BabelDescriptor = PropertyDescriptor & { initializer?: () => any } 4 | 5 | export function decorateMethodOrField( 6 | decoratorName: string, 7 | decorateFn: (pname: string, v: any) => any, 8 | target: object, 9 | prop: string, 10 | descriptor?: BabelDescriptor 11 | ) { 12 | if (descriptor) { 13 | return decorateMethod(decoratorName, decorateFn, prop, descriptor) 14 | } else { 15 | decorateField(decorateFn, target, prop) 16 | } 17 | } 18 | 19 | export function decorateMethod( 20 | decoratorName: string, 21 | decorateFn: (pname: string, v: any) => any, 22 | prop: string, 23 | descriptor: BabelDescriptor 24 | ) { 25 | if (descriptor.get !== undefined) { 26 | return fail(`${decoratorName} cannot be used with getters`) 27 | } 28 | 29 | // babel / typescript 30 | // @action method() { } 31 | if (descriptor.value) { 32 | // typescript 33 | return { 34 | value: decorateFn(prop, descriptor.value), 35 | enumerable: false, 36 | configurable: true, // See #1477 37 | writable: true, // for typescript, this must be writable, otherwise it cannot inherit :/ (see inheritable actions test) 38 | } 39 | } 40 | 41 | // babel only: @action method = () => {} 42 | const { initializer } = descriptor 43 | return { 44 | enumerable: false, 45 | configurable: true, // See #1477 46 | writable: true, // See #1398 47 | initializer() { 48 | // N.B: we can't immediately invoke initializer; this would be wrong 49 | return decorateFn(prop, initializer!.call(this)) 50 | }, 51 | } 52 | } 53 | 54 | export function decorateField( 55 | decorateFn: (pname: string, v: any) => any, 56 | target: object, 57 | prop: string 58 | ) { 59 | // Simple property that writes on first invocation to the current instance 60 | Object.defineProperty(target, prop, { 61 | configurable: true, 62 | enumerable: false, 63 | get() { 64 | return undefined 65 | }, 66 | set(value) { 67 | addHiddenProp(this, prop, decorateFn(prop, value)) 68 | }, 69 | }) 70 | } 71 | -------------------------------------------------------------------------------- /src/deepMap.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @private 3 | */ 4 | export class DeepMapEntry { 5 | private root: Map 6 | private closest: Map 7 | private closestIdx: number = 0 8 | isDisposed = false 9 | 10 | constructor(private base: Map, private args: any[]) { 11 | let current: undefined | Map = (this.closest = this.root = base) 12 | let i = 0 13 | for (; i < this.args.length - 1; i++) { 14 | current = current!.get(args[i]) 15 | if (current) this.closest = current 16 | else break 17 | } 18 | this.closestIdx = i 19 | } 20 | 21 | exists(): boolean { 22 | this.assertNotDisposed() 23 | const l = this.args.length 24 | return this.closestIdx >= l - 1 && this.closest.has(this.args[l - 1]) 25 | } 26 | 27 | get(): T { 28 | this.assertNotDisposed() 29 | if (!this.exists()) throw new Error("Entry doesn't exist") 30 | return this.closest.get(this.args[this.args.length - 1]) 31 | } 32 | 33 | set(value: T) { 34 | this.assertNotDisposed() 35 | const l = this.args.length 36 | let current: Map = this.closest 37 | // create remaining maps 38 | for (let i = this.closestIdx; i < l - 1; i++) { 39 | const m = new Map() 40 | current.set(this.args[i], m) 41 | current = m 42 | } 43 | this.closestIdx = l - 1 44 | this.closest = current 45 | current.set(this.args[l - 1], value) 46 | } 47 | 48 | delete() { 49 | this.assertNotDisposed() 50 | if (!this.exists()) throw new Error("Entry doesn't exist") 51 | const l = this.args.length 52 | this.closest.delete(this.args[l - 1]) 53 | // clean up remaining maps if needed (reconstruct stack first) 54 | let c = this.root 55 | const maps: Map[] = [c] 56 | for (let i = 0; i < l - 1; i++) { 57 | c = c.get(this.args[i])! 58 | maps.push(c) 59 | } 60 | for (let i = maps.length - 1; i > 0; i--) { 61 | if (maps[i].size === 0) maps[i - 1].delete(this.args[i - 1]) 62 | } 63 | this.isDisposed = true 64 | } 65 | 66 | private assertNotDisposed() { 67 | // TODO: once this becomes annoying, we should introduce a reset method to re-run the constructor logic 68 | if (this.isDisposed) throw new Error("Concurrent modification exception") 69 | } 70 | } 71 | 72 | /** 73 | * @private 74 | */ 75 | export class DeepMap { 76 | private store = new Map() 77 | private argsLength = -1 78 | private last: DeepMapEntry | undefined 79 | 80 | entry(args: any[]): DeepMapEntry { 81 | if (this.argsLength === -1) this.argsLength = args.length 82 | else if (this.argsLength !== args.length) 83 | throw new Error( 84 | `DeepMap should be used with functions with a consistent length, expected: ${this.argsLength}, got: ${args.length}` 85 | ) 86 | if (this.last) this.last.isDisposed = true 87 | 88 | return (this.last = new DeepMapEntry(this.store, args)) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/deepObserve.ts: -------------------------------------------------------------------------------- 1 | import { 2 | observe, 3 | isObservableMap, 4 | isObservableObject, 5 | isObservableArray, 6 | IObjectDidChange, 7 | IArrayDidChange, 8 | IMapDidChange, 9 | values, 10 | entries, 11 | } from "mobx" 12 | import { IDisposer } from "./utils" 13 | 14 | type IChange = IObjectDidChange | IArrayDidChange | IMapDidChange 15 | 16 | type Entry = { 17 | dispose: IDisposer 18 | path: string 19 | parent: Entry | undefined 20 | } 21 | 22 | function buildPath(entry: Entry | undefined): string { 23 | if (!entry) return "ROOT" 24 | const res: string[] = [] 25 | while (entry.parent) { 26 | res.push(entry.path) 27 | entry = entry.parent 28 | } 29 | return res.reverse().join("/") 30 | } 31 | 32 | function isRecursivelyObservable(thing: any) { 33 | return isObservableObject(thing) || isObservableArray(thing) || isObservableMap(thing) 34 | } 35 | 36 | /** 37 | * Given an object, deeply observes the given object. 38 | * It is like `observe` from mobx, but applied recursively, including all future children. 39 | * 40 | * Note that the given object cannot ever contain cycles and should be a tree. 41 | * 42 | * As benefit: path and root will be provided in the callback, so the signature of the listener is 43 | * (change, path, root) => void 44 | * 45 | * The returned disposer can be invoked to clean up the listener 46 | * 47 | * deepObserve cannot be used on computed values. 48 | * 49 | * @example 50 | * const disposer = deepObserve(target, (change, path) => { 51 | * console.dir(change) 52 | * }) 53 | */ 54 | export function deepObserve( 55 | target: T, 56 | listener: (change: IChange, path: string, root: T) => void 57 | ): IDisposer { 58 | const entrySet = new WeakMap() 59 | 60 | function genericListener(change: IChange) { 61 | const entry = entrySet.get(change.object)! 62 | processChange(change, entry) 63 | listener(change, buildPath(entry), target) 64 | } 65 | 66 | function processChange(change: IChange, parent: Entry) { 67 | switch (change.type) { 68 | // Object changes 69 | case "add": // also for map 70 | observeRecursively(change.newValue, parent, change.name) 71 | break 72 | case "update": // also for array and map 73 | unobserveRecursively(change.oldValue) 74 | observeRecursively( 75 | change.newValue, 76 | parent, 77 | (change as any).name || "" + (change as any).index 78 | ) 79 | break 80 | case "remove": // object 81 | case "delete": // map 82 | unobserveRecursively(change.oldValue) 83 | break 84 | // Array changes 85 | case "splice": 86 | change.removed.map(unobserveRecursively) 87 | change.added.forEach((value, idx) => 88 | observeRecursively(value, parent, "" + (change.index + idx)) 89 | ) 90 | // update paths 91 | for (let i = change.index + change.addedCount; i < change.object.length; i++) { 92 | if (isRecursivelyObservable(change.object[i])) { 93 | const entry = entrySet.get(change.object[i]) 94 | if (entry) entry.path = "" + i 95 | } 96 | } 97 | break 98 | } 99 | } 100 | 101 | function observeRecursively(thing: any, parent: Entry | undefined, path: string) { 102 | if (isRecursivelyObservable(thing)) { 103 | const entry = entrySet.get(thing) 104 | if (entry) { 105 | if (entry.parent !== parent || entry.path !== path) 106 | // MWE: this constraint is artificial, and this tool could be made to work with cycles, 107 | // but it increases administration complexity, has tricky edge cases and the meaning of 'path' 108 | // would become less clear. So doesn't seem to be needed for now 109 | throw new Error( 110 | `The same observable object cannot appear twice in the same tree,` + 111 | ` trying to assign it to '${buildPath(parent)}/${path}',` + 112 | ` but it already exists at '${buildPath(entry.parent)}/${entry.path}'` 113 | ) 114 | } else { 115 | const entry = { 116 | parent, 117 | path, 118 | dispose: observe(thing, genericListener), 119 | } 120 | entrySet.set(thing, entry) 121 | entries(thing).forEach(([key, value]) => observeRecursively(value, entry, "" + key)) 122 | } 123 | } 124 | } 125 | 126 | function unobserveRecursively(thing: any) { 127 | if (isRecursivelyObservable(thing)) { 128 | const entry = entrySet.get(thing) 129 | if (!entry) return 130 | entrySet.delete(thing) 131 | entry.dispose() 132 | values(thing).forEach(unobserveRecursively) 133 | } 134 | } 135 | 136 | observeRecursively(target, undefined, "") 137 | 138 | return () => { 139 | unobserveRecursively(target) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/expr.ts: -------------------------------------------------------------------------------- 1 | import { computed, _isComputingDerivation } from "mobx" 2 | 3 | /** 4 | *`expr` can be used to create temporary computed values inside computed values. 5 | * Nesting computed values is useful to create cheap computations in order to prevent expensive computations from needing to run. 6 | * In the following example the expression prevents that a component is rerender _each time_ the selection changes; 7 | * instead it will only rerenders when the current todo is (de)selected. 8 | * 9 | * `expr(func)` is an alias for `computed(func).get()`. 10 | * Please note that the function given to `expr` is evaluated _twice_ in the scenario that the overall expression value changes. 11 | * It is evaluated the first time when any observables it depends on change. 12 | * It is evaluated a second time when a change in its value triggers the outer computed or reaction to evaluate, which recreates and reevaluates the expression. 13 | * 14 | * In the following example, the expression prevents the `TodoView` component from being re-rendered if the selection changes elsewhere. 15 | * Instead, the component will only re-render when the relevant todo is (de)selected, which happens much less frequently. 16 | * 17 | * @example 18 | * const TodoView = observer(({ todo, editorState }) => { 19 | * const isSelected = mobxUtils.expr(() => editorState.selection === todo) 20 | * return
{todo.title}
21 | * }) 22 | */ 23 | export function expr(expr: () => T): T { 24 | if (!_isComputingDerivation()) 25 | console.warn("'expr' should only be used inside other reactive functions.") 26 | // optimization: would be more efficient if the expr itself wouldn't be evaluated first on the next change, but just a 'changed' signal would be fired 27 | return computed(expr).get() 28 | } 29 | -------------------------------------------------------------------------------- /src/from-promise.ts: -------------------------------------------------------------------------------- 1 | import { action, extendObservable } from "mobx" 2 | import { invariant } from "./utils" 3 | 4 | export type PromiseState = "pending" | "fulfilled" | "rejected" 5 | 6 | export const PENDING = "pending" 7 | export const FULFILLED = "fulfilled" 8 | export const REJECTED = "rejected" 9 | 10 | type CaseHandlers = { 11 | pending?: (t?: T) => U 12 | fulfilled?: (t: T) => U 13 | rejected?: (e: any) => U 14 | } 15 | 16 | export interface IBasePromiseBasedObservable extends PromiseLike { 17 | isPromiseBasedObservable: true 18 | case(handlers: CaseHandlers, defaultFulfilled?: boolean): U 19 | } 20 | 21 | export type IPendingPromise = { 22 | readonly state: "pending" 23 | readonly value: T | undefined 24 | } 25 | 26 | export type IFulfilledPromise = { 27 | readonly state: "fulfilled" 28 | readonly value: T 29 | } 30 | 31 | export type IRejectedPromise = { 32 | readonly state: "rejected" 33 | readonly value: unknown 34 | } 35 | 36 | export type IPromiseBasedObservable = IBasePromiseBasedObservable & 37 | (IPendingPromise | IFulfilledPromise | IRejectedPromise) 38 | 39 | function caseImpl( 40 | this: IPromiseBasedObservable, 41 | handlers: CaseHandlers 42 | ): U | T | undefined { 43 | switch (this.state) { 44 | case PENDING: 45 | return handlers.pending && handlers.pending(this.value) 46 | case REJECTED: 47 | return handlers.rejected && handlers.rejected(this.value) 48 | case FULFILLED: 49 | return handlers.fulfilled ? handlers.fulfilled(this.value) : this.value 50 | } 51 | } 52 | 53 | /** 54 | * `fromPromise` takes a Promise, extends it with 2 observable properties that track 55 | * the status of the promise and returns it. The returned object has the following observable properties: 56 | * - `value`: either the initial value, the value the Promise resolved to, or the value the Promise was rejected with. use `.state` if you need to be able to tell the difference. 57 | * - `state`: one of `"pending"`, `"fulfilled"` or `"rejected"` 58 | * 59 | * And the following methods: 60 | * - `case({fulfilled, rejected, pending})`: maps over the result using the provided handlers, or returns `undefined` if a handler isn't available for the current promise state. 61 | * 62 | * The returned object implements `PromiseLike`, so you can chain additional `Promise` handlers using `then`. You may also use it with `await` in `async` functions. 63 | * 64 | * Note that the status strings are available as constants: 65 | * `mobxUtils.PENDING`, `mobxUtils.REJECTED`, `mobxUtil.FULFILLED` 66 | * 67 | * fromPromise takes an optional second argument, a previously created `fromPromise` based observable. 68 | * This is useful to replace one promise based observable with another, without going back to an intermediate 69 | * "pending" promise state while fetching data. For example: 70 | * 71 | * @example 72 | * \@observer 73 | * class SearchResults extends React.Component { 74 | * \@observable.ref searchResults 75 | * 76 | * componentDidUpdate(nextProps) { 77 | * if (nextProps.query !== this.props.query) 78 | * this.searchResults = fromPromise( 79 | * window.fetch("/search?q=" + nextProps.query), 80 | * // by passing, we won't render a pending state if we had a successful search query before 81 | * // rather, we will keep showing the previous search results, until the new promise resolves (or rejects) 82 | * this.searchResults 83 | * ) 84 | * } 85 | * 86 | * render() { 87 | * return this.searchResults.case({ 88 | * pending: (staleValue) => { 89 | * return staleValue || "searching" // <- value might set to previous results while the promise is still pending 90 | * }, 91 | * fulfilled: (value) => { 92 | * return value // the fresh results 93 | * }, 94 | * rejected: (error) => { 95 | * return "Oops: " + error 96 | * } 97 | * }) 98 | * } 99 | * } 100 | * 101 | * Observable promises can be created immediately in a certain state using 102 | * `fromPromise.reject(reason)` or `fromPromise.resolve(value?)`. 103 | * The main advantage of `fromPromise.resolve(value)` over `fromPromise(Promise.resolve(value))` is that the first _synchronously_ starts in the desired state. 104 | * 105 | * It is possible to directly create a promise using a resolve, reject function: 106 | * `fromPromise((resolve, reject) => setTimeout(() => resolve(true), 1000))` 107 | * 108 | * @example 109 | * const fetchResult = fromPromise(fetch("http://someurl")) 110 | * 111 | * // combine with when.. 112 | * when( 113 | * () => fetchResult.state !== "pending", 114 | * () => { 115 | * console.log("Got ", fetchResult.value) 116 | * } 117 | * ) 118 | * 119 | * // or a mobx-react component.. 120 | * const myComponent = observer(({ fetchResult }) => { 121 | * switch(fetchResult.state) { 122 | * case "pending": return
Loading...
123 | * case "rejected": return
Ooops... {fetchResult.value}
124 | * case "fulfilled": return
Gotcha: {fetchResult.value}
125 | * } 126 | * }) 127 | * 128 | * // or using the case method instead of switch: 129 | * 130 | * const myComponent = observer(({ fetchResult }) => 131 | * fetchResult.case({ 132 | * pending: () =>
Loading...
, 133 | * rejected: error =>
Ooops.. {error}
, 134 | * fulfilled: value =>
Gotcha: {value}
, 135 | * })) 136 | * 137 | * // chain additional handler(s) to the resolve/reject: 138 | * 139 | * fetchResult.then( 140 | * (result) => doSomeTransformation(result), 141 | * (rejectReason) => console.error('fetchResult was rejected, reason: ' + rejectReason) 142 | * ).then( 143 | * (transformedResult) => console.log('transformed fetchResult: ' + transformedResult) 144 | * ) 145 | * 146 | * @param origPromise The promise which will be observed 147 | * @param oldPromise The previously observed promise 148 | * @returns origPromise with added properties and methods described above. 149 | */ 150 | export function fromPromise( 151 | origPromise: PromiseLike, 152 | oldPromise?: IPromiseBasedObservable 153 | ): IPromiseBasedObservable { 154 | invariant(arguments.length <= 2, "fromPromise expects up to two arguments") 155 | invariant( 156 | typeof origPromise === "function" || 157 | (typeof origPromise === "object" && 158 | origPromise && 159 | typeof origPromise.then === "function"), 160 | "Please pass a promise or function to fromPromise" 161 | ) 162 | if ((origPromise as any).isPromiseBasedObservable === true) return origPromise as any 163 | 164 | if (typeof origPromise === "function") { 165 | // If it is a (reject, resolve function, wrap it) 166 | origPromise = new Promise(origPromise as any) 167 | } 168 | 169 | const promise = origPromise as any 170 | origPromise.then( 171 | action("observableFromPromise-resolve", (value: any) => { 172 | promise.value = value 173 | promise.state = FULFILLED 174 | }), 175 | action("observableFromPromise-reject", (reason: any) => { 176 | promise.value = reason 177 | promise.state = REJECTED 178 | }) 179 | ) 180 | 181 | promise.isPromiseBasedObservable = true 182 | promise.case = caseImpl 183 | const oldData = 184 | oldPromise && (oldPromise.state === FULFILLED || oldPromise.state === PENDING) 185 | ? oldPromise.value 186 | : undefined 187 | extendObservable( 188 | promise, 189 | { 190 | value: oldData, 191 | state: PENDING, 192 | }, 193 | {}, 194 | { deep: false } 195 | ) 196 | 197 | return promise 198 | } 199 | export namespace fromPromise { 200 | export const reject = action("fromPromise.reject", function ( 201 | reason: any 202 | ): IRejectedPromise & IBasePromiseBasedObservable { 203 | const p: any = fromPromise(Promise.reject(reason)) 204 | p.state = REJECTED 205 | p.value = reason 206 | return p 207 | }) 208 | 209 | function resolveBase(value: T): IFulfilledPromise & IBasePromiseBasedObservable 210 | function resolveBase( 211 | value?: T 212 | ): IFulfilledPromise & IBasePromiseBasedObservable 213 | function resolveBase( 214 | value: T | undefined = undefined 215 | ): IFulfilledPromise & IBasePromiseBasedObservable { 216 | const p: any = fromPromise(Promise.resolve(value)) 217 | p.state = FULFILLED 218 | p.value = value 219 | return p 220 | } 221 | 222 | export const resolve = action("fromPromise.resolve", resolveBase) 223 | } 224 | 225 | /** 226 | * Returns true if the provided value is a promise-based observable. 227 | * @param value any 228 | * @returns {boolean} 229 | */ 230 | export function isPromiseBasedObservable(value: any): value is IPromiseBasedObservable { 231 | return value && value.isPromiseBasedObservable === true 232 | } 233 | -------------------------------------------------------------------------------- /src/from-resource.ts: -------------------------------------------------------------------------------- 1 | import { createAtom, _allowStateChanges } from "mobx" 2 | import { NOOP, IDisposer, invariant } from "./utils" 3 | 4 | export interface IResource { 5 | current(): T 6 | dispose(): void 7 | isAlive(): boolean 8 | } 9 | 10 | export function fromResource( 11 | subscriber: (sink: (newValue: T) => void) => void, 12 | unsubscriber?: IDisposer 13 | ): IResource 14 | export function fromResource( 15 | subscriber: (sink: (newValue: T) => void) => void, 16 | unsubscriber: IDisposer | undefined, 17 | initialValue: T 18 | ): IResource 19 | /** 20 | * `fromResource` creates an observable whose current state can be inspected using `.current()`, 21 | * and which can be kept in sync with some external datasource that can be subscribed to. 22 | * 23 | * The created observable will only subscribe to the datasource if it is in use somewhere, 24 | * (un)subscribing when needed. To enable `fromResource` to do that two callbacks need to be provided, 25 | * one to subscribe, and one to unsubscribe. The subscribe callback itself will receive a `sink` callback, which can be used 26 | * to update the current state of the observable, allowing observes to react. 27 | * 28 | * Whatever is passed to `sink` will be returned by `current()`. The values passed to the sink will not be converted to 29 | * observables automatically, but feel free to do so. 30 | * It is the `current()` call itself which is being tracked, 31 | * so make sure that you don't dereference to early. 32 | * 33 | * For inspiration, an example integration with the apollo-client on [github](https://github.com/apollostack/apollo-client/issues/503#issuecomment-241101379), 34 | * or the [implementation](https://github.com/mobxjs/mobx-utils/blob/1d17cf7f7f5200937f68cc0b5e7ec7f3f71dccba/src/now.ts#L43-L57) of `mobxUtils.now` 35 | * 36 | * The following example code creates an observable that connects to a `dbUserRecord`, 37 | * which comes from an imaginary database and notifies when it has changed. 38 | * 39 | * @example 40 | * function createObservableUser(dbUserRecord) { 41 | * let currentSubscription; 42 | * return fromResource( 43 | * (sink) => { 44 | * // sink the current state 45 | * sink(dbUserRecord.fields) 46 | * // subscribe to the record, invoke the sink callback whenever new data arrives 47 | * currentSubscription = dbUserRecord.onUpdated(() => { 48 | * sink(dbUserRecord.fields) 49 | * }) 50 | * }, 51 | * () => { 52 | * // the user observable is not in use at the moment, unsubscribe (for now) 53 | * dbUserRecord.unsubscribe(currentSubscription) 54 | * } 55 | * ) 56 | * } 57 | * 58 | * // usage: 59 | * const myUserObservable = createObservableUser(myDatabaseConnector.query("name = 'Michel'")) 60 | * 61 | * // use the observable in autorun 62 | * autorun(() => { 63 | * // printed everytime the database updates its records 64 | * console.log(myUserObservable.current().displayName) 65 | * }) 66 | * 67 | * // ... or a component 68 | * const userComponent = observer(({ user }) => 69 | *
{user.current().displayName}
70 | * ) 71 | * 72 | * @export 73 | * @template T 74 | * @param {(sink: (newValue: T) => void) => void} subscriber 75 | * @param {IDisposer} [unsubscriber=NOOP] 76 | * @param {T} [initialValue=undefined] the data that will be returned by `get()` until the `sink` has emitted its first data 77 | * @returns {{ 78 | * current(): T; 79 | * dispose(): void; 80 | * isAlive(): boolean; 81 | * }} 82 | */ 83 | export function fromResource( 84 | subscriber: (sink: (newValue: T) => void) => void, 85 | unsubscriber: IDisposer = NOOP, 86 | initialValue: T | undefined = undefined 87 | ): IResource { 88 | let isActive = false 89 | let isDisposed = false 90 | let value = initialValue 91 | 92 | const suspender = () => { 93 | if (isActive) { 94 | isActive = false 95 | unsubscriber() 96 | } 97 | } 98 | 99 | const atom = createAtom( 100 | "ResourceBasedObservable", 101 | () => { 102 | invariant(!isActive && !isDisposed) 103 | isActive = true 104 | subscriber((newValue: T) => { 105 | _allowStateChanges(true, () => { 106 | value = newValue 107 | atom.reportChanged() 108 | }) 109 | }) 110 | }, 111 | suspender 112 | ) 113 | 114 | return { 115 | current: () => { 116 | invariant(!isDisposed, "subscribingObservable has already been disposed") 117 | const isBeingTracked = atom.reportObserved() 118 | if (!isBeingTracked && !isActive) 119 | console.warn( 120 | "Called `get` of a subscribingObservable outside a reaction. Current value will be returned but no new subscription has started" 121 | ) 122 | return value 123 | }, 124 | dispose: () => { 125 | isDisposed = true 126 | suspender() 127 | }, 128 | isAlive: () => isActive, 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/keep-alive.ts: -------------------------------------------------------------------------------- 1 | import { IComputedValue, getAtom, observe } from "mobx" 2 | import { IDisposer } from "./utils" 3 | 4 | export function keepAlive(target: Object, property: string): IDisposer 5 | export function keepAlive(computedValue: IComputedValue): IDisposer 6 | /** 7 | * MobX normally suspends any computed value that is not in use by any reaction, 8 | * and lazily re-evaluates the expression if needed outside a reaction while not in use. 9 | * `keepAlive` marks a computed value as always in use, meaning that it will always fresh, but never disposed automatically. 10 | * 11 | * @example 12 | * const obj = observable({ 13 | * number: 3, 14 | * doubler: function() { return this.number * 2 } 15 | * }) 16 | * const stop = keepAlive(obj, "doubler") 17 | * 18 | * @param {Object} target an object that has a computed property, created by `@computed` or `extendObservable` 19 | * @param {string} property the name of the property to keep alive 20 | * @returns {IDisposer} stops this keep alive so that the computed value goes back to normal behavior 21 | */ 22 | /** 23 | * @example 24 | * const number = observable(3) 25 | * const doubler = computed(() => number.get() * 2) 26 | * const stop = keepAlive(doubler) 27 | * // doubler will now stay in sync reactively even when there are no further observers 28 | * stop() 29 | * // normal behavior, doubler results will be recomputed if not observed but needed, but lazily 30 | * 31 | * @param {IComputedValue} computedValue created using the `computed` function 32 | * @returns {IDisposer} stops this keep alive so that the computed value goes back to normal behavior 33 | */ 34 | export function keepAlive(_1: any, _2?: string) { 35 | const computed = (getAtom(_1, _2) as any) as IComputedValue 36 | if (!computed) 37 | throw new Error( 38 | "No computed provided, please provide an object created with `computed(() => expr)` or an object + property name" 39 | ) 40 | return observe(computed, () => {}) 41 | } 42 | -------------------------------------------------------------------------------- /src/lazy-observable.ts: -------------------------------------------------------------------------------- 1 | import { observable, action, _allowStateChanges } from "mobx" 2 | 3 | export interface ILazyObservable { 4 | current(): T 5 | refresh(): T 6 | reset(): T 7 | pending: boolean 8 | } 9 | 10 | export function lazyObservable( 11 | fetch: (sink: (newValue: T) => void) => void 12 | ): ILazyObservable 13 | export function lazyObservable( 14 | fetch: (sink: (newValue: T) => void) => void, 15 | initialValue: T 16 | ): ILazyObservable 17 | /** 18 | * `lazyObservable` creates an observable around a `fetch` method that will not be invoked 19 | * until the observable is needed the first time. 20 | * The fetch method receives a `sink` callback which can be used to replace the 21 | * current value of the lazyObservable. It is allowed to call `sink` multiple times 22 | * to keep the lazyObservable up to date with some external resource. 23 | * 24 | * Note that it is the `current()` call itself which is being tracked by MobX, 25 | * so make sure that you don't dereference to early. 26 | * 27 | * @example 28 | * const userProfile = lazyObservable( 29 | * sink => fetch("/myprofile").then(profile => sink(profile)) 30 | * ) 31 | * 32 | * // use the userProfile in a React component: 33 | * const Profile = observer(({ userProfile }) => 34 | * userProfile.current() === undefined 35 | * ?
Loading user profile...
36 | * :
{userProfile.current().displayName}
37 | * ) 38 | * 39 | * // triggers refresh the userProfile 40 | * userProfile.refresh() 41 | * 42 | * @param {(sink: (newValue: T) => void) => void} fetch method that will be called the first time the value of this observable is accessed. The provided sink can be used to produce a new value, synchronously or asynchronously 43 | * @param {T} [initialValue=undefined] optional initialValue that will be returned from `current` as long as the `sink` has not been called at least once 44 | * @returns {{ 45 | * current(): T, 46 | * refresh(): T, 47 | * reset(): T 48 | * pending: boolean 49 | * }} 50 | */ 51 | export function lazyObservable( 52 | fetch: (sink: (newValue: T) => void) => void, 53 | initialValue: T | undefined = undefined 54 | ): ILazyObservable { 55 | let started = false 56 | const value = observable.box(initialValue, { deep: false }) 57 | const pending = observable.box(false) 58 | let currentFnc = () => { 59 | if (!started) { 60 | started = true 61 | _allowStateChanges(true, () => { 62 | pending.set(true) 63 | }) 64 | fetch((newValue: T) => { 65 | _allowStateChanges(true, () => { 66 | value.set(newValue) 67 | pending.set(false) 68 | }) 69 | }) 70 | } 71 | return value.get() 72 | } 73 | let resetFnc = action("lazyObservable-reset", () => { 74 | started = false 75 | value.set(initialValue) 76 | return value.get() 77 | }) 78 | return { 79 | current: currentFnc, 80 | refresh: () => { 81 | if (started) { 82 | started = false 83 | return currentFnc() 84 | } else { 85 | return value.get() 86 | } 87 | }, 88 | reset: () => { 89 | return resetFnc() 90 | }, 91 | get pending() { 92 | return pending.get() 93 | }, 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/mobx-utils.ts: -------------------------------------------------------------------------------- 1 | export * from "./from-promise" 2 | export * from "./array" 3 | export * from "./lazy-observable" 4 | export * from "./from-resource" 5 | export * from "./observable-stream" 6 | export * from "./create-view-model" 7 | export * from "./keep-alive" 8 | export * from "./queue-processor" 9 | export * from "./chunk-processor" 10 | export * from "./now" 11 | export * from "./utils" 12 | export * from "./expr" 13 | export * from "./create-transformer" 14 | export * from "./deepObserve" 15 | export { ObservableGroupMap } from "./ObservableGroupMap" 16 | export { computedFn } from "./computedFn" 17 | -------------------------------------------------------------------------------- /src/now.ts: -------------------------------------------------------------------------------- 1 | import { _isComputingDerivation } from "mobx" 2 | import { fromResource, IResource } from "./from-resource" 3 | 4 | const tickers: { 5 | [interval: string]: IResource 6 | } = {} 7 | 8 | /** 9 | * Disposes of all the internal Observables created by invocations of `now()`. 10 | * 11 | * The use case for this is to ensure that unit tests can run independent of each other. 12 | * You should not call this in regular application code. 13 | * 14 | * @example 15 | * afterEach(() => { 16 | * utils.resetNowInternalState() 17 | * }) 18 | */ 19 | export function resetNowInternalState() { 20 | for (const key of Object.getOwnPropertyNames(tickers)) { 21 | tickers[key].dispose() 22 | delete tickers[key] 23 | } 24 | } 25 | 26 | /** 27 | * Returns the current date time as epoch number. 28 | * The date time is read from an observable which is updated automatically after the given interval. 29 | * So basically it treats time as an observable. 30 | * 31 | * The function takes an interval as parameter, which indicates how often `now()` will return a new value. 32 | * If no interval is given, it will update each second. If "frame" is specified, it will update each time a 33 | * `requestAnimationFrame` is available. 34 | * 35 | * Multiple clocks with the same interval will automatically be synchronized. 36 | * 37 | * Countdown example: https://jsfiddle.net/mweststrate/na0qdmkw/ 38 | * 39 | * @example 40 | * 41 | * const start = Date.now() 42 | * 43 | * autorun(() => { 44 | * console.log("Seconds elapsed: ", (mobxUtils.now() - start) / 1000) 45 | * }) 46 | * 47 | * 48 | * @export 49 | * @param {(number | "frame")} [interval=1000] interval in milliseconds about how often the interval should update 50 | * @returns 51 | */ 52 | export function now(interval: number | "frame" = 1000) { 53 | if (!_isComputingDerivation()) { 54 | // See #40 55 | return Date.now() 56 | } 57 | 58 | if (!tickers[interval]) { 59 | if (typeof interval === "number") tickers[interval] = createIntervalTicker(interval) 60 | else tickers[interval] = createAnimationFrameTicker() 61 | } 62 | return tickers[interval].current() 63 | } 64 | 65 | function createIntervalTicker(interval: number): IResource { 66 | let subscriptionHandle: any 67 | return fromResource( 68 | (sink) => { 69 | sink(Date.now()) 70 | subscriptionHandle = setInterval(() => sink(Date.now()), interval) 71 | }, 72 | () => { 73 | clearInterval(subscriptionHandle) 74 | }, 75 | Date.now() 76 | ) 77 | } 78 | 79 | function createAnimationFrameTicker(): IResource { 80 | const frameBasedTicker = fromResource( 81 | (sink) => { 82 | sink(Date.now()) 83 | 84 | function scheduleTick() { 85 | window.requestAnimationFrame(() => { 86 | sink(Date.now()) 87 | if (frameBasedTicker.isAlive()) scheduleTick() 88 | }) 89 | } 90 | scheduleTick() 91 | }, 92 | () => {}, 93 | Date.now() 94 | ) 95 | return frameBasedTicker 96 | } 97 | -------------------------------------------------------------------------------- /src/observable-stream.ts: -------------------------------------------------------------------------------- 1 | import { computed, observable, action, runInAction, observe, makeObservable } from "mobx" 2 | 3 | declare var Symbol: any 4 | 5 | function observableSymbol() { 6 | return (typeof Symbol === "function" && Symbol.observable) || "@@observable" 7 | } 8 | 9 | export interface IStreamObserver { 10 | next?(value: T): void 11 | error?(error: any): void 12 | complete?(): void 13 | } 14 | 15 | export interface ISubscription { 16 | unsubscribe(): void 17 | } 18 | 19 | export interface IObservableStream { 20 | subscribe(observer?: IStreamObserver | null): ISubscription 21 | subscribe(observer?: ((value: T) => void) | null): ISubscription 22 | // [Symbol.observable](): IObservable; 23 | } 24 | 25 | /** 26 | * Converts an expression to an observable stream (a.k.a. TC 39 Observable / RxJS observable). 27 | * The provided expression is tracked by mobx as long as there are subscribers, automatically 28 | * emitting when new values become available. The expressions respect (trans)actions. 29 | * 30 | * @example 31 | * 32 | * const user = observable({ 33 | * firstName: "C.S", 34 | * lastName: "Lewis" 35 | * }) 36 | * 37 | * Rx.Observable 38 | * .from(mobxUtils.toStream(() => user.firstname + user.lastName)) 39 | * .scan(nameChanges => nameChanges + 1, 0) 40 | * .subscribe(nameChanges => console.log("Changed name ", nameChanges, "times")) 41 | * 42 | * @export 43 | * @template T 44 | * @param {() => T} expression 45 | * @param {boolean} fireImmediately (by default false) 46 | * @returns {IObservableStream} 47 | */ 48 | export function toStream( 49 | expression: () => T, 50 | fireImmediately: boolean = false 51 | ): IObservableStream { 52 | const computedValue = computed(expression) 53 | return { 54 | subscribe(observer?: IStreamObserver | ((value: T) => void) | null): ISubscription { 55 | if ("function" === typeof observer) { 56 | return { 57 | unsubscribe: observe( 58 | computedValue, 59 | ({ newValue }: { newValue: any }) => observer(newValue), 60 | fireImmediately 61 | ), 62 | } 63 | } 64 | if (observer && "object" === typeof observer && observer.next) { 65 | return { 66 | unsubscribe: observe( 67 | computedValue, 68 | ({ newValue }: { newValue: any }) => observer.next!(newValue), 69 | fireImmediately 70 | ), 71 | } 72 | } 73 | return { 74 | unsubscribe: () => {}, 75 | } 76 | }, 77 | [observableSymbol()]: function (this: any) { 78 | return this 79 | }, 80 | } 81 | } 82 | 83 | class StreamListener implements IStreamObserver { 84 | @observable.ref current!: T 85 | subscription!: ISubscription 86 | 87 | constructor(observable: IObservableStream, initialValue: T) { 88 | makeObservable(this) 89 | runInAction(() => { 90 | this.current = initialValue 91 | this.subscription = observable.subscribe(this) 92 | }) 93 | } 94 | 95 | dispose() { 96 | if (this.subscription) { 97 | this.subscription.unsubscribe() 98 | } 99 | } 100 | 101 | @action.bound 102 | next(value: T) { 103 | this.current = value 104 | } 105 | 106 | @action.bound 107 | complete() { 108 | this.dispose() 109 | } 110 | 111 | @action.bound 112 | error(value: T) { 113 | this.current = value 114 | this.dispose() 115 | } 116 | } 117 | 118 | /** 119 | * Converts a subscribable, observable stream (TC 39 observable / RxJS stream) 120 | * into an object which stores the current value (as `current`). The subscription can be cancelled through the `dispose` method. 121 | * Takes an initial value as second optional argument 122 | * 123 | * @example 124 | * const debouncedClickDelta = MobxUtils.fromStream(Rx.Observable.fromEvent(button, 'click') 125 | * .throttleTime(1000) 126 | * .map(event => event.clientX) 127 | * .scan((count, clientX) => count + clientX, 0) 128 | * ) 129 | * 130 | * autorun(() => { 131 | * console.log("distance moved", debouncedClickDelta.current) 132 | * }) 133 | */ 134 | export function fromStream(observable: IObservableStream): IStreamListener 135 | export function fromStream( 136 | observable: IObservableStream, 137 | initialValue: I 138 | ): IStreamListener 139 | export function fromStream( 140 | observable: IObservableStream, 141 | initialValue: T = undefined as any 142 | ): IStreamListener { 143 | return new StreamListener(observable, initialValue) 144 | } 145 | 146 | export interface IStreamListener { 147 | current: T 148 | dispose(): void 149 | } 150 | -------------------------------------------------------------------------------- /src/queue-processor.ts: -------------------------------------------------------------------------------- 1 | import { isAction, autorun, action, isObservableArray, runInAction } from "mobx" 2 | import { IDisposer } from "./utils" 3 | 4 | /** 5 | * `queueProcessor` takes an observable array, observes it and calls `processor` 6 | * once for each item added to the observable array, optionally debouncing the action 7 | * 8 | * @example 9 | * const pendingNotifications = observable([]) 10 | * const stop = queueProcessor(pendingNotifications, msg => { 11 | * // show Desktop notification 12 | * new Notification(msg); 13 | * }) 14 | * 15 | * // usage: 16 | * pendingNotifications.push("test!") 17 | * 18 | * @param {T[]} observableArray observable array instance to track 19 | * @param {(item: T) => void} processor action to call per item 20 | * @param {number} [debounce=0] optional debounce time in ms. With debounce 0 the processor will run synchronously 21 | * @returns {IDisposer} stops the processor 22 | */ 23 | export function queueProcessor( 24 | observableArray: T[], 25 | processor: (item: T) => void, 26 | debounce = 0 27 | ): IDisposer { 28 | if (!isObservableArray(observableArray)) 29 | throw new Error("Expected observable array as first argument") 30 | if (!isAction(processor)) processor = action("queueProcessor", processor) 31 | 32 | const runner = () => { 33 | // construct a final set 34 | const items = observableArray.slice(0) 35 | // clear the queue for next iteration 36 | runInAction(() => observableArray.splice(0)) 37 | // fire processor 38 | items.forEach(processor) 39 | } 40 | if (debounce > 0) return autorun(runner, { delay: debounce }) 41 | else return autorun(runner) 42 | } 43 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.8.10", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "sourceMap": false, 6 | "declaration": true, 7 | "module": "es6", 8 | "removeComments": false, 9 | "outDir": "../lib/", 10 | "noImplicitAny": true, 11 | "strict": true, 12 | "moduleResolution": "node", 13 | "experimentalDecorators": true, 14 | "useDefineForClassFields": true, 15 | "lib": [ 16 | "dom", 17 | "es2015", 18 | "scripthost", 19 | "es2015.promise", 20 | "es2015.generator", 21 | "es2015.iterable" 22 | ] 23 | }, 24 | "exclude": ["node_modules"] 25 | } 26 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export type IDisposer = () => void 2 | 3 | export const NOOP = () => {} 4 | 5 | export const IDENTITY = (_: any) => _ 6 | 7 | export function fail(message: string): never { 8 | throw new Error("[mobx-utils] " + message) 9 | } 10 | 11 | export function invariant(cond: boolean, message = "Illegal state") { 12 | if (!cond) fail(message) 13 | } 14 | 15 | export function addHiddenProp(object: any, propName: string, value: any) { 16 | Object.defineProperty(object, propName, { 17 | enumerable: false, 18 | writable: true, 19 | configurable: true, 20 | value, 21 | }) 22 | } 23 | 24 | const deepFields = (x: any): any => { 25 | return ( 26 | x && 27 | x !== Object.prototype && 28 | Object.getOwnPropertyNames(x).concat(deepFields(Object.getPrototypeOf(x)) || []) 29 | ) 30 | } 31 | const distinctDeepFields = (x: any) => { 32 | const deepFieldsIndistinct = deepFields(x) 33 | const deepFieldsDistinct = deepFieldsIndistinct.filter( 34 | (item: any, index: number) => deepFieldsIndistinct.indexOf(item) === index 35 | ) 36 | return deepFieldsDistinct 37 | } 38 | export const getAllMethodsAndProperties = (x: any): any => 39 | distinctDeepFields(x).filter((name: string) => name !== "constructor" && !~name.indexOf("__")) 40 | -------------------------------------------------------------------------------- /test/ObservableGroupMap.test.ts: -------------------------------------------------------------------------------- 1 | import { observable, IObservableArray } from "mobx" 2 | import * as assert from "assert" 3 | 4 | import { ObservableGroupMap } from "../src/mobx-utils" 5 | 6 | const json = (ogm: ObservableGroupMap): { [k: string]: G } => 7 | Array.from(ogm.keys()).reduce((r, k) => ((r[k] = ogm.get(k)?.slice()), r), {} as any) 8 | 9 | describe("ObservableGroupMap", () => { 10 | type Slice = { day: string; hours: number } 11 | let base: IObservableArray 12 | let ogm: ObservableGroupMap 13 | 14 | beforeEach((done) => { 15 | base = observable([ 16 | { day: "mo", hours: 12 }, 17 | { day: "tu", hours: 2 }, 18 | ]) 19 | ogm = new ObservableGroupMap(base, (x) => x.day) 20 | done() 21 | }) 22 | 23 | it("initializes correctly", (done) => { 24 | assert.deepEqual(json(ogm), { 25 | mo: [{ day: "mo", hours: 12 }], 26 | tu: [{ day: "tu", hours: 2 }], 27 | }) 28 | done() 29 | }) 30 | 31 | it("updates groups correctly when an item is removed from the base", (done) => { 32 | base[0] = base.pop()! 33 | assert.deepEqual(json(ogm), { 34 | tu: [{ day: "tu", hours: 2 }], 35 | }) 36 | done() 37 | }) 38 | 39 | it("updates groups correctly when an item is added to the base (new group)", (done) => { 40 | base.push({ day: "we", hours: 3 }) 41 | assert.deepEqual(json(ogm), { 42 | mo: [{ day: "mo", hours: 12 }], 43 | tu: [{ day: "tu", hours: 2 }], 44 | we: [{ day: "we", hours: 3 }], 45 | }) 46 | done() 47 | }) 48 | 49 | it("updates groups correctly when an item is added to the base (existing group)", (done) => { 50 | base.push({ day: "tu", hours: 3 }) 51 | assert.deepEqual(json(ogm), { 52 | mo: [{ day: "mo", hours: 12 }], 53 | tu: [ 54 | { day: "tu", hours: 2 }, 55 | { day: "tu", hours: 3 }, 56 | ], 57 | }) 58 | done() 59 | }) 60 | 61 | it("moves item from group array to new one when groupBy value changes to a new one", (done) => { 62 | base[1].day = "we" 63 | assert.deepEqual(json(ogm), { 64 | mo: [{ day: "mo", hours: 12 }], 65 | we: [{ day: "we", hours: 2 }], 66 | }) 67 | done() 68 | }) 69 | 70 | it("works correctly with example from #255", (done) => { 71 | const data = observable([ 72 | { id: 1, a: "x" }, 73 | { id: 2, a: "x" }, 74 | { id: 3, a: "x" }, 75 | { id: 4, a: "x" }, 76 | ]) 77 | 78 | const ogm = new ObservableGroupMap(data, (e) => e.a) 79 | 80 | data.replace([{ id: 5, a: "x" }]) 81 | assert.deepEqual(json(ogm), { 82 | x: [{ id: 5, a: "x" }], 83 | }) 84 | done() 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /test/__snapshots__/array.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`it throws when index is out of bounds 1`] = `"[mobx.array] Index out of bounds: -1 is negative"`; 4 | 5 | exports[`it throws when index is out of bounds 2`] = `"[mobx.array] Index out of bounds: 3 is not smaller than 3"`; 6 | -------------------------------------------------------------------------------- /test/__snapshots__/computedFn.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`make sure the fn is cached 1`] = ` 4 | Object { 5 | "dependencies": Array [ 6 | Object { 7 | "name": "ObservableObject@10.m?", 8 | }, 9 | Object { 10 | "dependencies": Array [ 11 | Object { 12 | "name": "ObservableObject@10.a", 13 | }, 14 | Object { 15 | "name": "ObservableObject@10.b", 16 | }, 17 | ], 18 | "name": "computedFn(m#1)", 19 | }, 20 | Object { 21 | "name": "ObservableObject@10.c", 22 | }, 23 | ], 24 | "name": "Autorun@11", 25 | } 26 | `; 27 | -------------------------------------------------------------------------------- /test/__snapshots__/create-transformer.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should throw error when passed invalid param type 1`] = `"[mobx-utils] transform expected an object, function, string or number, got: null"`; 4 | 5 | exports[`should throw error when passed invalid param type 2`] = `"[mobx-utils] transform expected an object, function, string or number, got: undefined"`; 6 | 7 | exports[`should throw error when passed invalid param type 3`] = `"[mobx-utils] transform expected an object, function, string or number, got: Symbol(A)"`; 8 | 9 | exports[`should throw error when passed invalid param type 4`] = `"[mobx-utils] transform expected an object, function, string or number, got: true"`; 10 | -------------------------------------------------------------------------------- /test/__snapshots__/deepMap.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`args length 2 1`] = `"Entry doesn't exist"`; 4 | 5 | exports[`args length 2 2`] = `"Concurrent modification exception"`; 6 | 7 | exports[`args length 2 3`] = `"DeepMap should be used with functions with a consistent length, expected: 2, got: 1"`; 8 | -------------------------------------------------------------------------------- /test/__snapshots__/deepObserve.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`add 1`] = ` 4 | Array [ 5 | Array [ 6 | "", 7 | Object { 8 | "debugObjectName": "ObservableObject@4", 9 | "name": "a", 10 | "newValue": 3, 11 | "object": null, 12 | "observableKind": "object", 13 | "type": "add", 14 | }, 15 | ], 16 | ] 17 | `; 18 | 19 | exports[`array 1`] = ` 20 | Array [ 21 | Array [ 22 | "", 23 | Object { 24 | "added": Array [ 25 | Object { 26 | "x": 1, 27 | Symbol(mobx administration): null, 28 | }, 29 | Object { 30 | "x": 2, 31 | Symbol(mobx administration): null, 32 | }, 33 | ], 34 | "addedCount": 2, 35 | "debugObjectName": "ObservableArray@10", 36 | "index": 1, 37 | "object": null, 38 | "observableKind": "array", 39 | "removed": Array [ 40 | 2, 41 | ], 42 | "removedCount": 1, 43 | "type": "splice", 44 | }, 45 | ], 46 | Array [ 47 | "1", 48 | Object { 49 | "debugObjectName": "ObservableArray@10[..]", 50 | "name": "x", 51 | "newValue": "a", 52 | "object": null, 53 | "observableKind": "object", 54 | "oldValue": 1, 55 | "type": "update", 56 | }, 57 | ], 58 | Array [ 59 | "2", 60 | Object { 61 | "debugObjectName": "ObservableArray@10[..]", 62 | "name": "x", 63 | "newValue": "b", 64 | "object": null, 65 | "observableKind": "object", 66 | "oldValue": 2, 67 | "type": "update", 68 | }, 69 | ], 70 | Array [ 71 | "3", 72 | Object { 73 | "debugObjectName": "ObservableArray@10[..]", 74 | "name": "x", 75 | "newValue": "c", 76 | "object": null, 77 | "observableKind": "object", 78 | "oldValue": 3, 79 | "type": "update", 80 | }, 81 | ], 82 | Array [ 83 | "", 84 | Object { 85 | "added": Array [], 86 | "addedCount": 0, 87 | "debugObjectName": "ObservableArray@10", 88 | "index": 0, 89 | "object": null, 90 | "observableKind": "array", 91 | "removed": Array [ 92 | 1, 93 | Object { 94 | "x": "a", 95 | Symbol(mobx administration): null, 96 | }, 97 | Object { 98 | "x": "b", 99 | Symbol(mobx administration): null, 100 | }, 101 | ], 102 | "removedCount": 3, 103 | "type": "splice", 104 | }, 105 | ], 106 | Array [ 107 | "", 108 | Object { 109 | "added": Array [ 110 | Object { 111 | "x": "B", 112 | Symbol(mobx administration): null, 113 | }, 114 | ], 115 | "addedCount": 1, 116 | "debugObjectName": "ObservableArray@10", 117 | "index": 1, 118 | "object": null, 119 | "observableKind": "array", 120 | "removed": Array [], 121 | "removedCount": 0, 122 | "type": "splice", 123 | }, 124 | ], 125 | Array [ 126 | "0", 127 | Object { 128 | "debugObjectName": "ObservableArray@10[..]", 129 | "name": "x", 130 | "newValue": "A", 131 | "object": null, 132 | "observableKind": "object", 133 | "oldValue": "c", 134 | "type": "update", 135 | }, 136 | ], 137 | ] 138 | `; 139 | 140 | exports[`basic & dispose 1`] = ` 141 | Array [ 142 | Array [ 143 | "", 144 | Object { 145 | "debugObjectName": "ObservableObject@2", 146 | "name": "a", 147 | "newValue": 2, 148 | "object": Object { 149 | "a": 2, 150 | "b": Object { 151 | "z": 3, 152 | Symbol(mobx administration): null, 153 | }, 154 | Symbol(mobx administration): null, 155 | }, 156 | "observableKind": "object", 157 | "oldValue": 1, 158 | "type": "update", 159 | }, 160 | ], 161 | Array [ 162 | "b", 163 | Object { 164 | "debugObjectName": "ObservableObject@2.b", 165 | "name": "z", 166 | "newValue": 4, 167 | "object": Object { 168 | "z": 4, 169 | Symbol(mobx administration): null, 170 | }, 171 | "observableKind": "object", 172 | "oldValue": 3, 173 | "type": "update", 174 | }, 175 | ], 176 | ] 177 | `; 178 | 179 | exports[`cleanup 1`] = ` 180 | Array [ 181 | Array [ 182 | "a", 183 | Object { 184 | "debugObjectName": "ObservableObject@6", 185 | "name": "b", 186 | "newValue": 2, 187 | "object": Object { 188 | "b": 2, 189 | Symbol(mobx administration): null, 190 | }, 191 | "observableKind": "object", 192 | "oldValue": 1, 193 | "type": "update", 194 | }, 195 | ], 196 | Array [ 197 | "", 198 | Object { 199 | "debugObjectName": "ObservableObject@7", 200 | "name": "a", 201 | "object": Object { 202 | Symbol(mobx administration): null, 203 | }, 204 | "observableKind": "object", 205 | "oldValue": Object { 206 | "b": 2, 207 | Symbol(mobx administration): null, 208 | }, 209 | "type": "remove", 210 | }, 211 | ], 212 | ] 213 | `; 214 | 215 | exports[`deep 1`] = ` 216 | Array [ 217 | Array [ 218 | "a/b", 219 | Object { 220 | "debugObjectName": "ObservableObject@3.a.b", 221 | "name": "c", 222 | "newValue": 4, 223 | "object": null, 224 | "observableKind": "object", 225 | "oldValue": 3, 226 | "type": "update", 227 | }, 228 | ], 229 | ] 230 | `; 231 | 232 | exports[`delete 1`] = ` 233 | Array [ 234 | Array [ 235 | "", 236 | Object { 237 | "debugObjectName": "ObservableObject@5", 238 | "name": "x", 239 | "object": null, 240 | "observableKind": "object", 241 | "oldValue": 1, 242 | "type": "remove", 243 | }, 244 | ], 245 | ] 246 | `; 247 | 248 | exports[`map 1`] = ` 249 | Array [ 250 | Array [ 251 | "", 252 | Object { 253 | "debugObjectName": "ObservableObject@11", 254 | "name": "x", 255 | "newValue": Array [], 256 | "object": null, 257 | "observableKind": "object", 258 | "type": "add", 259 | }, 260 | ], 261 | Array [ 262 | "x", 263 | Object { 264 | "debugObjectName": "ObservableMap@12", 265 | "name": "a", 266 | "newValue": Object { 267 | "a": 1, 268 | Symbol(mobx administration): null, 269 | }, 270 | "object": null, 271 | "observableKind": "map", 272 | "type": "add", 273 | }, 274 | ], 275 | Array [ 276 | "x/a", 277 | Object { 278 | "debugObjectName": "ObservableMap@12.a", 279 | "name": "a", 280 | "newValue": 2, 281 | "object": null, 282 | "observableKind": "object", 283 | "oldValue": 1, 284 | "type": "update", 285 | }, 286 | ], 287 | Array [ 288 | "x", 289 | Object { 290 | "debugObjectName": "ObservableMap@12", 291 | "name": "a", 292 | "newValue": 3, 293 | "object": null, 294 | "observableKind": "map", 295 | "oldValue": Object { 296 | "a": 2, 297 | Symbol(mobx administration): null, 298 | }, 299 | "type": "update", 300 | }, 301 | ], 302 | Array [ 303 | "x", 304 | Object { 305 | "debugObjectName": "ObservableMap@12", 306 | "name": "a", 307 | "object": null, 308 | "observableKind": "map", 309 | "oldValue": 3, 310 | "type": "delete", 311 | }, 312 | ], 313 | ] 314 | `; 315 | -------------------------------------------------------------------------------- /test/array.ts: -------------------------------------------------------------------------------- 1 | import * as utils from "../src/mobx-utils" 2 | import { observable } from "mobx" 3 | import { moveItem } from "../src/mobx-utils" 4 | 5 | test("it should move the item as expected", () => { 6 | const source = observable([1, 2, 3]) 7 | expect(moveItem(source, 0, 1)).toBe(source) 8 | expect(source[0]).toBe(2) 9 | expect(source[1]).toBe(1) 10 | expect(source[2]).toBe(3) 11 | 12 | moveItem(source, 1, 0) 13 | 14 | expect(source[0]).toBe(1) 15 | expect(source[1]).toBe(2) 16 | expect(source[2]).toBe(3) 17 | }) 18 | test("it throws when index is out of bounds", () => { 19 | const source = observable([1, 2, 3]) 20 | expect(moveItem(source, 0, 0)).toBeUndefined() 21 | expect(moveItem(source, 2, 2)).toBeUndefined() 22 | }) 23 | 24 | test("it throws when index is out of bounds", () => { 25 | const source = observable([1, 2, 3]) 26 | expect(() => moveItem(source, 0, -1)).toThrowErrorMatchingSnapshot() 27 | expect(() => moveItem(source, 0, 3)).toThrowErrorMatchingSnapshot() 28 | }) 29 | -------------------------------------------------------------------------------- /test/chunk-processor.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | const utils = require("../src/mobx-utils") 4 | const mobx = require("mobx") 5 | 6 | mobx.configure({ enforceActions: "observed" }) 7 | 8 | test("sync processor should work with max", () => { 9 | const q = mobx.observable([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 10 | const res = [] 11 | 12 | const stop = utils.chunkProcessor(q, (v) => res.push(v), 0, 3) 13 | 14 | expect(res).toEqual([[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]]) 15 | expect(q.length).toBe(0) 16 | 17 | mobx.runInAction(() => q.push(1, 2, 3, 4, 5)) 18 | expect(res).toEqual([[1, 2, 3], [4, 5, 6], [7, 8, 9], [10], [1, 2, 3], [4, 5]]) 19 | expect(q.length).toBe(0) 20 | 21 | mobx.runInAction(() => q.push(3)) 22 | expect(res).toEqual([[1, 2, 3], [4, 5, 6], [7, 8, 9], [10], [1, 2, 3], [4, 5], [3]]) 23 | expect(q.length).toBe(0) 24 | 25 | mobx.runInAction(() => q.push(8, 9)) 26 | expect(res).toEqual([[1, 2, 3], [4, 5, 6], [7, 8, 9], [10], [1, 2, 3], [4, 5], [3], [8, 9]]) 27 | expect(q.length).toBe(0) 28 | 29 | mobx.runInAction(() => { 30 | q.unshift(6, 7) 31 | expect(q.length).toBe(2) 32 | expect(res).toEqual([[1, 2, 3], [4, 5, 6], [7, 8, 9], [10], [1, 2, 3], [4, 5], [3], [8, 9]]) 33 | }) 34 | expect(q.length).toBe(0) 35 | expect(res).toEqual([ 36 | [1, 2, 3], 37 | [4, 5, 6], 38 | [7, 8, 9], 39 | [10], 40 | [1, 2, 3], 41 | [4, 5], 42 | [3], 43 | [8, 9], 44 | [6, 7], 45 | ]) 46 | 47 | stop() 48 | mobx.runInAction(() => q.push(42)) 49 | expect(q.length).toBe(1) 50 | expect(res).toEqual([ 51 | [1, 2, 3], 52 | [4, 5, 6], 53 | [7, 8, 9], 54 | [10], 55 | [1, 2, 3], 56 | [4, 5], 57 | [3], 58 | [8, 9], 59 | [6, 7], 60 | ]) 61 | }) 62 | 63 | test("sync processor should work with default max", () => { 64 | const q = mobx.observable([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 65 | const res = [] 66 | 67 | utils.chunkProcessor(q, (v) => res.push(v)) 68 | 69 | expect(res).toEqual([[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]]) 70 | expect(q.length).toBe(0) 71 | 72 | mobx.runInAction(() => q.push(1, 2, 3, 4, 5)) 73 | expect(res).toEqual([ 74 | [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 75 | [1, 2, 3, 4, 5], 76 | ]) 77 | expect(q.length).toBe(0) 78 | }) 79 | 80 | test("async processor should work", (done) => { 81 | const q = mobx.observable([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 82 | const res = [] 83 | 84 | const stop = utils.chunkProcessor(q, (v) => res.push(v), 10, 3) 85 | 86 | expect(res.length).toBe(0) 87 | expect(q.length).toBe(10) 88 | 89 | setTimeout(() => { 90 | expect(res).toEqual([[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]]) 91 | expect(q.length).toBe(0) 92 | 93 | mobx.runInAction(() => q.push(3)) 94 | expect(q.length).toBe(1) 95 | expect(res).toEqual([[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]]) 96 | 97 | setTimeout(() => { 98 | expect(q.length).toBe(0) 99 | expect(res).toEqual([[1, 2, 3], [4, 5, 6], [7, 8, 9], [10], [3]]) 100 | 101 | stop() 102 | done() 103 | }, 50) 104 | }, 50) 105 | }) 106 | 107 | test("async processor should combine smaller chunks to max size", (done) => { 108 | const q = mobx.observable([1, 2]) 109 | const res = [] 110 | 111 | const stop = utils.chunkProcessor(q, (v) => res.push(v), 10, 3) 112 | 113 | expect(res.length).toBe(0) 114 | expect(q.length).toBe(2) 115 | mobx.runInAction(() => q.push(3)) 116 | mobx.runInAction(() => q.push(4)) 117 | mobx.runInAction(() => q.push(5)) 118 | mobx.runInAction(() => q.push(6)) 119 | mobx.runInAction(() => q.push(7)) 120 | 121 | setTimeout(() => { 122 | expect(res).toEqual([[1, 2, 3], [4, 5, 6], [7]]) 123 | expect(q.length).toBe(0) 124 | 125 | mobx.runInAction(() => q.push(8, 9)) 126 | setTimeout(() => { 127 | mobx.runInAction(() => q.push(10, 11)) 128 | expect(q.length).toBe(4) 129 | expect(res).toEqual([[1, 2, 3], [4, 5, 6], [7]]) 130 | }, 2) 131 | setTimeout(() => { 132 | mobx.runInAction(() => q.push(12, 13)) 133 | expect(q.length).toBe(6) 134 | expect(res).toEqual([[1, 2, 3], [4, 5, 6], [7]]) 135 | }, 4) 136 | 137 | expect(q.length).toBe(2) 138 | expect(res).toEqual([[1, 2, 3], [4, 5, 6], [7]]) 139 | 140 | setTimeout(() => { 141 | expect(q.length).toBe(0) 142 | expect(res).toEqual([[1, 2, 3], [4, 5, 6], [7], [8, 9, 10], [11, 12, 13]]) 143 | 144 | stop() 145 | done() 146 | }, 50) 147 | }, 50) 148 | }) 149 | -------------------------------------------------------------------------------- /test/computedFn.ts: -------------------------------------------------------------------------------- 1 | import { computedFn } from "../src/computedFn" 2 | import { 3 | observable, 4 | autorun, 5 | onBecomeUnobserved, 6 | action, 7 | getDependencyTree, 8 | comparer, 9 | getObserverTree, 10 | } from "mobx" 11 | 12 | const john = { 13 | name: "john", 14 | age: 15, 15 | } 16 | const jane = { 17 | name: "jane", 18 | age: 45, 19 | } 20 | const able = { 21 | name: "able", 22 | age: 12, 23 | } 24 | 25 | class Store { 26 | persons = observable([john, jane, able]) 27 | 28 | constructor(public events: string[]) {} 29 | 30 | filter(age: number, firstLetter: string) { 31 | this.events.push(`f ${age} ${firstLetter}`) 32 | return this.persons.filter((p) => { 33 | return p.age > age && (!firstLetter || p.name[0] === firstLetter) 34 | }) 35 | } 36 | } 37 | 38 | test("basics - kept alive", () => { 39 | const events: string[] = [] 40 | const s = new Store(events) 41 | 42 | onBecomeUnobserved(s.persons, () => { 43 | events.push("unobserved persons") 44 | }) 45 | 46 | s.filter = computedFn(s.filter, true) 47 | 48 | expect(s.filter(20, "")).toEqual([jane]) 49 | expect(s.filter(1, "j")).toEqual([john, jane]) 50 | expect(s.filter(20, "")).toEqual([jane]) 51 | 52 | expect(events.splice(0)).toEqual(["f 20 ", "f 1 j"]) 53 | 54 | const d = autorun(() => { 55 | events.push( 56 | s 57 | .filter(1, "j") 58 | .map((p) => p.name) 59 | .join("-") 60 | ) 61 | }) 62 | 63 | s.persons[2].name = "jable" 64 | s.persons[1].name = "ane" 65 | 66 | s.persons[2].age = 0 67 | 68 | d() 69 | 70 | expect(s.filter(20, "")).toEqual([{ name: "ane", age: 45 }]) 71 | expect(s.filter(20, "")).toEqual([{ name: "ane", age: 45 }]) 72 | 73 | expect(events.splice(0)).toEqual([ 74 | "john-jane", 75 | "f 1 j", 76 | "john-jane-jable", 77 | "f 1 j", 78 | "john-jable", 79 | "f 1 j", 80 | "john", 81 | "f 20 ", 82 | ]) 83 | }) 84 | 85 | test("basics - auto suspend", () => { 86 | const events: string[] = [] 87 | const s = new Store(events) 88 | 89 | onBecomeUnobserved(s.persons, () => { 90 | events.push("unobserved persons") 91 | }) 92 | 93 | s.filter = computedFn(s.filter, false) 94 | 95 | expect(s.filter(20, "")).toEqual([jane]) 96 | expect(s.filter(1, "j")).toEqual([john, jane]) 97 | expect(s.filter(20, "")).toEqual([jane]) 98 | 99 | expect(events.splice(0)).toEqual([ 100 | "f 20 ", 101 | "f 1 j", 102 | "f 20 ", //suspended 103 | ]) 104 | 105 | const d = autorun(() => { 106 | events.push( 107 | s 108 | .filter(1, "j") 109 | .map((p) => p.name) 110 | .join("-") 111 | ) 112 | }) 113 | 114 | s.persons[2].name = "jable" 115 | s.persons[1].name = "ane" 116 | 117 | s.persons[2].age = 0 118 | 119 | d() 120 | 121 | expect(s.filter(20, "")).toEqual([{ name: "ane", age: 45 }]) 122 | expect(s.filter(20, "")).toEqual([{ name: "ane", age: 45 }]) 123 | 124 | expect(events.splice(0)).toEqual([ 125 | "f 1 j", // was suspended 126 | "john-jane", 127 | "f 1 j", 128 | "john-jane-jable", 129 | "f 1 j", 130 | "john-jable", 131 | "f 1 j", 132 | "john", 133 | "unobserved persons", // all suspended! 134 | "f 20 ", 135 | "f 20 ", 136 | ]) 137 | }) 138 | 139 | test("make sure the fn is cached", () => { 140 | const events: string[] = [] 141 | 142 | const store = observable({ 143 | a: 1, 144 | b: 2, 145 | c: 3, 146 | m: computedFn(function m(x) { 147 | expect(this).toBe(store) 148 | events.push("calc " + x) 149 | return this.a * this.b * x 150 | }), 151 | }) 152 | 153 | const d = autorun(() => { 154 | events.push("autorun " + store.m(3) * store.c) 155 | }) 156 | 157 | expect(getDependencyTree(d)).toMatchSnapshot() 158 | 159 | store.b = 3 160 | store.c = 4 161 | 162 | expect(events).toEqual(["calc 3", "autorun 18", "calc 3", "autorun 27", "autorun 36"]) 163 | }) 164 | 165 | test("supports options", () => { 166 | const events: number[][] = [] 167 | const xs = observable([1, 2, 3]) 168 | const xsLessThan = computedFn((n) => xs.filter((x) => x < n), { equals: comparer.structural }) 169 | 170 | autorun(() => events.push(xsLessThan(3))) 171 | expect(events).toEqual([[1, 2]]) 172 | 173 | events.length = 0 174 | xs.push(4) 175 | expect(events).toEqual([]) 176 | }) 177 | 178 | test("supports onCleanup", () => { 179 | const sep = observable.box(".") 180 | const unloaded: unknown[] = [] 181 | const joinedStr = computedFn((sep) => [1, 2, 3].join(sep), { 182 | onCleanup: (result, sep) => unloaded.push([result, sep]), 183 | }) 184 | autorun(() => joinedStr(sep.get())) 185 | sep.set(",") 186 | expect(unloaded.length).toBe(1) 187 | sep.set(" ") 188 | expect(unloaded).toEqual([ 189 | ["1.2.3", "."], 190 | ["1,2,3", ","], 191 | ]) 192 | }) 193 | 194 | test("should not allow actions", () => { 195 | expect(() => computedFn(action(() => {}))).toThrow("action") 196 | }) 197 | -------------------------------------------------------------------------------- /test/create-view-model.ts: -------------------------------------------------------------------------------- 1 | import * as utils from "../src/mobx-utils" 2 | import * as mobx from "mobx" 3 | import { ViewModel } from "../src/create-view-model" 4 | 5 | mobx.configure({ enforceActions: "observed" }) 6 | 7 | class TodoClass { 8 | @mobx.observable title: string 9 | @mobx.observable done: boolean 10 | @mobx.observable usersInterested: string[] 11 | unobservedProp: string 12 | @mobx.computed 13 | get usersCount(): number { 14 | return this.usersInterested.length 15 | } 16 | @mobx.computed 17 | get prefixedTitle() { 18 | return "Strong" + this.title 19 | } 20 | set prefixedTitle(value) { 21 | this.title = value.substr(6) 22 | } 23 | constructor() { 24 | mobx.makeObservable(this) 25 | } 26 | } 27 | 28 | class TodoViewModel extends ViewModel { 29 | get prefixedTitle() { 30 | return "Overriden " + this.model.title 31 | } 32 | } 33 | 34 | function Todo(title, done, usersInterested, unobservedProp) { 35 | this.unobservedProp = unobservedProp 36 | mobx.extendObservable(this, { 37 | title: title, 38 | done: done, 39 | usersInterested: usersInterested, 40 | get usersCount() { 41 | return this.usersInterested.length 42 | }, 43 | get prefixedTitle() { 44 | return "Strong" + this.title 45 | }, 46 | set prefixedTitle(value) { 47 | this.title = value.substr(6) 48 | }, 49 | }) 50 | } 51 | 52 | test("test NON Class/decorator createViewModel behaviour", () => { 53 | const model = new Todo("coffee", false, ["Vader", "Madonna"], "testing") 54 | tests(model) 55 | }) 56 | 57 | test("test Class/decorator createViewModel behaviour", () => { 58 | const model = new TodoClass() 59 | model.title = "coffee" 60 | model.done = false 61 | model.usersInterested = ["Vader", "Madonna"] 62 | model.unobservedProp = "testing" 63 | tests(model) 64 | }) 65 | 66 | test("test view model overriden properties", () => { 67 | const model = new TodoClass() 68 | model.title = "coffee" 69 | model.done = false 70 | model.usersInterested = ["Vader", "Madonna"] 71 | model.unobservedProp = "testing" 72 | const viewModel = new TodoViewModel(model) 73 | expect(viewModel.prefixedTitle).toBe("Overriden coffee") 74 | }) 75 | 76 | function tests(model) { 77 | const viewModel = utils.createViewModel(model) 78 | let tr 79 | let vr 80 | // original rendering 81 | const d1 = mobx.autorun(() => { 82 | tr = 83 | model.title + 84 | ":" + 85 | model.done + 86 | ",interested:" + 87 | model.usersInterested.slice().toString() + 88 | ",unobservedProp:" + 89 | model.unobservedProp + 90 | ",usersCount:" + 91 | model.usersCount 92 | }) 93 | // view model rendering 94 | const d2 = mobx.autorun(() => { 95 | vr = 96 | viewModel.title + 97 | ":" + 98 | viewModel.done + 99 | ",interested:" + 100 | viewModel.usersInterested.slice().toString() + 101 | ",unobservedProp:" + 102 | viewModel.unobservedProp + 103 | ",usersCount:" + 104 | viewModel.usersCount 105 | }) 106 | 107 | expect(tr).toBe("coffee:false,interested:Vader,Madonna,unobservedProp:testing,usersCount:2") 108 | expect(vr).toBe("coffee:false,interested:Vader,Madonna,unobservedProp:testing,usersCount:2") 109 | 110 | mobx.runInAction(() => (model.title = "tea")) 111 | expect(tr).toBe("tea:false,interested:Vader,Madonna,unobservedProp:testing,usersCount:2") 112 | expect(vr).toBe("tea:false,interested:Vader,Madonna,unobservedProp:testing,usersCount:2") // change reflected in view model 113 | expect(viewModel.isDirty).toBe(false) 114 | 115 | mobx.runInAction(() => model.usersInterested.push("Tarzan")) 116 | expect(tr).toBe("tea:false,interested:Vader,Madonna,Tarzan,unobservedProp:testing,usersCount:3") 117 | expect(vr).toBe("tea:false,interested:Vader,Madonna,Tarzan,unobservedProp:testing,usersCount:3") // change reflected in view model 118 | expect(viewModel.isDirty).toBe(false) 119 | expect(viewModel.changedValues.size).toBe(0) 120 | 121 | mobx.runInAction(() => (viewModel.done = true)) 122 | expect(tr).toBe("tea:false,interested:Vader,Madonna,Tarzan,unobservedProp:testing,usersCount:3") 123 | expect(vr).toBe("tea:true,interested:Vader,Madonna,Tarzan,unobservedProp:testing,usersCount:3") 124 | expect(viewModel.isDirty).toBe(true) 125 | expect(viewModel.isPropertyDirty("title")).toBe(false) 126 | expect(viewModel.isPropertyDirty("done")).toBe(true) 127 | expect(viewModel.isPropertyDirty("usersInterested")).toBe(false) 128 | expect(viewModel.isPropertyDirty("unobservedProp")).toBe(false) 129 | expect(viewModel.isPropertyDirty("usersCount")).toBe(false) 130 | expect(viewModel.changedValues.has("done")).toBe(true) 131 | 132 | mobx.runInAction(() => (model.unobservedProp = "testing testing")) 133 | expect(tr).toBe("tea:false,interested:Vader,Madonna,Tarzan,unobservedProp:testing,usersCount:3") // change NOT reflected in model 134 | expect(vr).toBe("tea:true,interested:Vader,Madonna,Tarzan,unobservedProp:testing,usersCount:3") // change NOT reflected in view model 135 | expect(viewModel.isDirty).toBe(true) 136 | 137 | const newUsers = ["Putin", "Madonna", "Tarzan", "Rocky"] 138 | mobx.runInAction(() => (viewModel.usersInterested = newUsers)) 139 | expect(tr).toBe("tea:false,interested:Vader,Madonna,Tarzan,unobservedProp:testing,usersCount:3") 140 | expect(vr).toBe( 141 | "tea:true,interested:Putin,Madonna,Tarzan,Rocky,unobservedProp:testing testing,usersCount:4" 142 | ) 143 | expect(viewModel.isDirty).toBe(true) 144 | expect(viewModel.isPropertyDirty("title")).toBe(false) 145 | expect(viewModel.isPropertyDirty("done")).toBe(true) 146 | expect(viewModel.isPropertyDirty("usersInterested")).toBe(true) 147 | expect(viewModel.isPropertyDirty("unobservedProp")).toBe(false) 148 | expect(viewModel.isPropertyDirty("usersCount")).toBe(false) 149 | expect(viewModel.changedValues.has("done")).toBe(true) 150 | 151 | mobx.runInAction(() => (viewModel.done = false)) 152 | expect(viewModel.isPropertyDirty("done")).toBe(false) 153 | expect(viewModel.changedValues.has("done")).toBe(false) 154 | 155 | mobx.runInAction(() => model.usersInterested.push("Cersei")) 156 | expect(tr).toBe( 157 | "tea:false,interested:Vader,Madonna,Tarzan,Cersei,unobservedProp:testing testing,usersCount:4" 158 | ) 159 | expect(vr).toBe( 160 | "tea:false,interested:Putin,Madonna,Tarzan,Rocky,unobservedProp:testing testing,usersCount:4" 161 | ) // change NOT reflected in view model bcs users are dirty 162 | expect(viewModel.isDirty).toBe(true) 163 | expect(viewModel.isPropertyDirty("title")).toBe(false) 164 | expect(viewModel.isPropertyDirty("done")).toBe(false) 165 | expect(viewModel.isPropertyDirty("unobservedProp")).toBe(false) 166 | expect(viewModel.isPropertyDirty("usersInterested")).toBe(true) 167 | 168 | // should reset 169 | viewModel.reset() 170 | expect(tr).toBe( 171 | "tea:false,interested:Vader,Madonna,Tarzan,Cersei,unobservedProp:testing testing,usersCount:4" 172 | ) 173 | expect(vr).toBe( 174 | "tea:false,interested:Vader,Madonna,Tarzan,Cersei,unobservedProp:testing testing,usersCount:4" 175 | ) 176 | expect(viewModel.isDirty).toBe(false) 177 | expect(viewModel.isPropertyDirty("title")).toBe(false) 178 | expect(viewModel.isPropertyDirty("done")).toBe(false) 179 | expect(viewModel.isPropertyDirty("usersInterested")).toBe(false) 180 | expect(viewModel.isPropertyDirty("unobservedProp")).toBe(false) 181 | 182 | mobx.runInAction(() => (viewModel.title = "beer")) 183 | expect(tr).toBe( 184 | "tea:false,interested:Vader,Madonna,Tarzan,Cersei,unobservedProp:testing testing,usersCount:4" 185 | ) 186 | expect(vr).toBe( 187 | "beer:false,interested:Vader,Madonna,Tarzan,Cersei,unobservedProp:testing testing,usersCount:4" 188 | ) 189 | expect(viewModel.isDirty).toBe(true) 190 | expect(viewModel.isPropertyDirty("title")).toBe(true) 191 | expect(viewModel.isPropertyDirty("done")).toBe(false) 192 | expect(viewModel.isPropertyDirty("usersInterested")).toBe(false) 193 | expect(viewModel.isPropertyDirty("unobservedProp")).toBe(false) 194 | 195 | mobx.runInAction(() => viewModel.resetProperty("title")) 196 | expect(tr).toBe( 197 | "tea:false,interested:Vader,Madonna,Tarzan,Cersei,unobservedProp:testing testing,usersCount:4" 198 | ) 199 | expect(vr).toBe( 200 | "tea:false,interested:Vader,Madonna,Tarzan,Cersei,unobservedProp:testing testing,usersCount:4" 201 | ) 202 | expect(viewModel.isDirty).toBe(false) 203 | expect(viewModel.isPropertyDirty("title")).toBe(false) 204 | expect(viewModel.isPropertyDirty("done")).toBe(false) 205 | expect(viewModel.isPropertyDirty("usersInterested")).toBe(false) 206 | expect(viewModel.isPropertyDirty("unobservedProp")).toBe(false) 207 | 208 | mobx.runInAction(() => { 209 | model.usersInterested.pop() 210 | model.usersInterested.pop() 211 | }) 212 | expect(tr).toBe( 213 | "tea:false,interested:Vader,Madonna,unobservedProp:testing testing,usersCount:2" 214 | ) 215 | expect(vr).toBe( 216 | "tea:false,interested:Vader,Madonna,unobservedProp:testing testing,usersCount:2" 217 | ) 218 | expect(viewModel.isDirty).toBe(false) 219 | expect(viewModel.isPropertyDirty("title")).toBe(false) 220 | expect(viewModel.isPropertyDirty("done")).toBe(false) 221 | expect(viewModel.isPropertyDirty("usersInterested")).toBe(false) 222 | expect(viewModel.isPropertyDirty("unobservedProp")).toBe(false) 223 | 224 | mobx.runInAction(() => { 225 | viewModel.title = "cola" 226 | viewModel.usersInterested = newUsers 227 | viewModel.unobservedProp = "new value" 228 | }) 229 | expect(tr).toBe( 230 | "tea:false,interested:Vader,Madonna,unobservedProp:testing testing,usersCount:2" 231 | ) 232 | expect(vr).toBe( 233 | "cola:false,interested:Putin,Madonna,Tarzan,Rocky,unobservedProp:new value,usersCount:4" 234 | ) 235 | expect(viewModel.isDirty).toBe(true) 236 | expect(viewModel.isPropertyDirty("done")).toBe(false) 237 | expect(viewModel.isPropertyDirty("title")).toBe(true) 238 | expect(viewModel.isPropertyDirty("usersInterested")).toBe(true) 239 | expect(viewModel.isPropertyDirty("unobservedProp")).toBe(true) 240 | 241 | // model changes should not update view model which is dirty 242 | mobx.runInAction(() => { 243 | model.title = "coffee" 244 | model.unobservedProp = "another new value" 245 | }) 246 | expect(tr).toBe( 247 | "coffee:false,interested:Vader,Madonna,unobservedProp:another new value,usersCount:2" 248 | ) 249 | expect(vr).toBe( 250 | "cola:false,interested:Putin,Madonna,Tarzan,Rocky,unobservedProp:new value,usersCount:4" 251 | ) 252 | 253 | viewModel.submit() 254 | expect(tr).toBe( 255 | "cola:false,interested:Putin,Madonna,Tarzan,Rocky,unobservedProp:new value,usersCount:4" 256 | ) 257 | expect(vr).toBe( 258 | "cola:false,interested:Putin,Madonna,Tarzan,Rocky,unobservedProp:new value,usersCount:4" 259 | ) 260 | expect(viewModel.isDirty).toBe(false) 261 | expect(viewModel.isPropertyDirty("done")).toBe(false) 262 | expect(viewModel.isPropertyDirty("title")).toBe(false) 263 | expect(viewModel.isPropertyDirty("usersInterested")).toBe(false) 264 | expect(viewModel.isPropertyDirty("unobservedProp")).toBe(false) 265 | 266 | // computed setters shall transparently be called on the view model 267 | mobx.runInAction(() => { 268 | viewModel.prefixedTitle = "FooBarCoffee" 269 | }) 270 | expect(tr).toBe( 271 | "cola:false,interested:Putin,Madonna,Tarzan,Rocky,unobservedProp:new value,usersCount:4" 272 | ) 273 | expect(vr).toBe( 274 | "Coffee:false,interested:Putin,Madonna,Tarzan,Rocky,unobservedProp:new value,usersCount:4" 275 | ) 276 | expect(viewModel.title).toBe("Coffee") 277 | expect(viewModel.prefixedTitle).toBe("StrongCoffee") 278 | expect(viewModel.isDirty).toBe(true) 279 | expect(viewModel.isPropertyDirty("title")).toBe(true) 280 | expect(viewModel.isPropertyDirty("prefixedTitle")).toBe(false) 281 | 282 | viewModel.submit() 283 | expect(tr).toBe( 284 | "Coffee:false,interested:Putin,Madonna,Tarzan,Rocky,unobservedProp:new value,usersCount:4" 285 | ) 286 | expect(vr).toBe( 287 | "Coffee:false,interested:Putin,Madonna,Tarzan,Rocky,unobservedProp:new value,usersCount:4" 288 | ) 289 | expect(model.prefixedTitle).toBe("StrongCoffee") 290 | expect(viewModel.isDirty).toBe(false) 291 | expect(viewModel.isPropertyDirty("title")).toBe(false) 292 | expect(viewModel.isPropertyDirty("prefixedTitle")).toBe(false) 293 | 294 | d1() 295 | d2() 296 | } 297 | -------------------------------------------------------------------------------- /test/deepMap.ts: -------------------------------------------------------------------------------- 1 | import { DeepMap } from "../src/deepMap" 2 | 3 | test("args length 2", () => { 4 | const d = new DeepMap() 5 | 6 | const e = d.entry(["hello", "world"]) 7 | 8 | expect(e.exists()).toBe(false) 9 | 10 | expect(() => { 11 | e.get() 12 | }).toThrowErrorMatchingSnapshot() 13 | 14 | e.set(3) 15 | 16 | expect(e.get()).toBe(3) 17 | expect(e.exists()).toBe(true) 18 | 19 | const e2 = d.entry(["hello", "world"]) 20 | expect(e2.exists()).toBe(true) 21 | expect(e2.get()).toBe(3) 22 | 23 | e2.set(4) 24 | expect(() => { 25 | e.get() 26 | }).toThrowErrorMatchingSnapshot() 27 | 28 | expect(d.entry(["hello", "world"]).get()).toBe(4) 29 | 30 | expect(() => d.entry(["bla"])).toThrowErrorMatchingSnapshot() 31 | 32 | d.entry(["coffee", "tea"]).set(100) 33 | 34 | const e3 = d.entry(["hello", "universe"]) 35 | e3.set(42) 36 | expect(e3.exists()).toBe(true) 37 | expect(e3.get()).toBe(42) 38 | 39 | expect(d.entry(["hello", "world"]).get()).toBe(4) 40 | expect(d.entry(["hello", "universe"]).get()).toBe(42) 41 | expect(d.entry(["coffee", "tea"]).get()).toBe(100) 42 | 43 | d.entry(["hello", "world"]).delete() 44 | expect(d.entry(["hello", "world"]).exists()).toBe(false) 45 | expect(d.entry(["hello", "universe"]).get()).toBe(42) 46 | 47 | d.entry(["coffee", "tea"]).delete() 48 | expect((d as any).store.size).toBe(1) 49 | 50 | expect(d.entry(["hello", "universe"]).get()).toBe(42) 51 | d.entry(["hello", "universe"]).delete() 52 | expect((d as any).store.size).toBe(0) 53 | }) 54 | 55 | test("really deep", () => { 56 | const d = new DeepMap() 57 | const path = ["a", "b", "c", "d", "e"] 58 | expect(d.entry(path).exists()).toBe(false) 59 | d.entry(path).set(3) 60 | expect(d.entry(path).exists()).toBe(true) 61 | expect(d.entry(path).get()).toBe(3) 62 | d.entry(path).set(4) 63 | expect(d.entry(path).get()).toBe(4) 64 | 65 | d.entry(path).delete() 66 | expect((d as any).store.size).toBe(0) 67 | }) 68 | 69 | test("really shallow", () => { 70 | const d = new DeepMap() 71 | const path = [] 72 | expect(d.entry(path).exists()).toBe(false) 73 | d.entry(path).set(3) 74 | expect(d.entry(path).exists()).toBe(true) 75 | expect(d.entry(path).get()).toBe(3) 76 | d.entry(path).set(4) 77 | expect(d.entry(path).get()).toBe(4) 78 | 79 | d.entry(path).delete() 80 | expect((d as any).store.size).toBe(0) 81 | }) 82 | -------------------------------------------------------------------------------- /test/deepObserve.ts: -------------------------------------------------------------------------------- 1 | import { deepObserve } from "../src/mobx-utils" 2 | import { observable, $mobx, IObjectDidChange } from "mobx" 3 | import * as cloneDeepWith from "lodash.clonedeepwith" 4 | 5 | function cleanChange(change, includeObject = true) { 6 | return cloneDeepWith(change, (value, key) => { 7 | if (key === $mobx) return null 8 | if (key === "object" && !includeObject) return null 9 | }) 10 | } 11 | 12 | function assertChanges(x: T, fn: (x: T) => void) { 13 | const target = observable(x) 14 | const events: any[] = [] 15 | 16 | const d = deepObserve(target, (change, path) => { 17 | events.push([path, cleanChange(change, false)]) 18 | }) 19 | 20 | fn(target) 21 | 22 | expect(events).toMatchSnapshot() 23 | } 24 | 25 | test("not throwing on primitive value changes", () => { 26 | // deepObserve uses a WeakMap to track what is being observed. 27 | // WeakMaps can only contain objects as their keys, not primitive values. 28 | // Certain JS runtimes throw when passing a non-object to #get, #set or #has. 29 | // deepObserve should not throw on primitive value changes, but still observe them. 30 | expect(() => { 31 | const x = observable({ a: 1 }) 32 | const d = deepObserve(x, (change: any) => { 33 | expect(change.oldValue).toBe(1) 34 | expect(change.newValue).toBe(2) 35 | }) 36 | 37 | x.a = 2 38 | }).not.toThrow() 39 | }) 40 | 41 | test("basic & dispose", () => { 42 | const x = observable({ a: 1, b: { z: 3 } }) 43 | const events: any[] = [] 44 | 45 | const d = deepObserve(x, (change, path) => { 46 | events.push([path, cleanChange(change)]) 47 | }) 48 | 49 | x.a = 2 50 | x.b.z = 4 51 | d() 52 | x.a = 3 53 | x.b.z = 5 54 | expect(events).toMatchSnapshot() 55 | }) 56 | 57 | test("deep", () => { 58 | assertChanges( 59 | { 60 | a: { 61 | b: { 62 | c: 3, 63 | }, 64 | }, 65 | }, 66 | (x) => { 67 | x.a.b.c = 4 68 | } 69 | ) 70 | }) 71 | 72 | test("add", () => { 73 | assertChanges({}, (x: any) => { 74 | x.a = 3 75 | }) 76 | }) 77 | 78 | test("delete", () => { 79 | assertChanges<{ x?: number }>({ x: 1 }, (x) => { 80 | delete x.x 81 | }) 82 | }) 83 | 84 | test("cleanup", () => { 85 | const a = observable({ b: 1 }) 86 | const x = observable<{ a?: { b: number } }>({ a }) 87 | const events: any[] = [] 88 | 89 | const d = deepObserve(x, (change, path) => { 90 | events.push([path, cleanChange(change)]) 91 | }) 92 | 93 | a.b = 2 94 | delete x.a 95 | a.b = 3 // should not be visible 96 | expect(events).toMatchSnapshot() 97 | }) 98 | 99 | test("throw on double entry", () => { 100 | const a = observable({ b: 1 }) 101 | const x = observable({ a }) 102 | const events: any[] = [] 103 | 104 | const d = deepObserve(x, (change, path) => { 105 | events.push([path, cleanChange(change)]) 106 | }) 107 | 108 | expect(() => { 109 | ;(x as any).b = a 110 | }).toThrow("trying to assign it to '/b', but it already exists at '/a'") 111 | }) 112 | 113 | test("array", () => { 114 | assertChanges([1, 2, { x: 3 }], (ar: any) => { 115 | ar.splice(1, 1, { x: 1 }, { x: 2 }) 116 | ar[1].x = "a" 117 | ar[2].x = "b" 118 | ar[3].x = "c" 119 | ar.splice(0, 3) 120 | ar.push({ x: "B" }) 121 | ar[0].x = "A" 122 | }) 123 | }) 124 | 125 | test("map", () => { 126 | assertChanges({}, (o: any) => { 127 | const x = observable.map({}) 128 | o.x = x 129 | x.set("a", { a: 1 }) 130 | x.get("a").a = 2 131 | x.set("a", 3) 132 | x.delete("a") 133 | }) 134 | }) 135 | -------------------------------------------------------------------------------- /test/expr.ts: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import * as utils from "../src/mobx-utils" 4 | import * as mobx from "mobx" 5 | 6 | test("expr", function () { 7 | mobx.configure({ enforceActions: "never" }) 8 | try { 9 | let factor = mobx.observable.box(0) 10 | let price = mobx.observable.box(100) 11 | let totalCalcs = 0 12 | let innerCalcs = 0 13 | 14 | let total = mobx.computed(function () { 15 | totalCalcs += 1 // outer observable shouldn't recalc if inner observable didn't publish a real change 16 | return ( 17 | price.get() * 18 | utils.expr(function () { 19 | innerCalcs += 1 20 | return factor.get() % 2 === 0 ? 1 : 3 21 | }) 22 | ) 23 | }) 24 | 25 | let b: unknown[] = [] 26 | let sub = mobx.observe( 27 | total, 28 | function (x) { 29 | b.push(x.newValue) 30 | }, 31 | true 32 | ) 33 | 34 | price.set(150) 35 | factor.set(7) // triggers innerCalc twice, because changing the outcome triggers the outer calculation which recreates the inner calculation 36 | factor.set(5) // doesn't trigger outer calc 37 | factor.set(3) // doesn't trigger outer calc 38 | factor.set(4) // triggers innerCalc twice 39 | price.set(20) 40 | 41 | expect(b).toEqual([100, 150, 450, 150, 20]) 42 | expect(innerCalcs).toBe(9) 43 | expect(totalCalcs).toBe(5) 44 | } finally { 45 | mobx.configure({ enforceActions: "observed" }) 46 | } 47 | }) 48 | 49 | test("expr2", function () { 50 | mobx.configure({ enforceActions: "never" }) 51 | try { 52 | let factor = mobx.observable.box(0) 53 | let price = mobx.observable.box(100) 54 | let totalCalcs = 0 55 | let innerCalcs = 0 56 | 57 | let total = mobx.computed(function () { 58 | totalCalcs += 1 // outer observable shouldn't recalc if inner observable didn't publish a real change 59 | return ( 60 | price.get() * 61 | utils.expr(function () { 62 | innerCalcs += 1 63 | return factor.get() % 2 === 0 ? 1 : 3 64 | }) 65 | ) 66 | }) 67 | 68 | let b: unknown[] = [] 69 | let sub = mobx.observe( 70 | total, 71 | function (x) { 72 | b.push(x.newValue) 73 | }, 74 | true 75 | ) 76 | 77 | price.set(150) 78 | factor.set(7) // triggers innerCalc twice, because changing the outcome triggers the outer calculation which recreates the inner calculation 79 | factor.set(5) // doesn't trigger outer calc 80 | factor.set(3) // doesn't trigger outer calc 81 | factor.set(4) // triggers innerCalc twice 82 | price.set(20) 83 | 84 | expect(b).toEqual([100, 150, 450, 150, 20]) 85 | expect(innerCalcs).toBe(9) 86 | expect(totalCalcs).toBe(5) 87 | } finally { 88 | mobx.configure({ enforceActions: "observed" }) 89 | } 90 | }) 91 | -------------------------------------------------------------------------------- /test/from-promise.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | const utils = require("../src/mobx-utils") 4 | const mobx = require("mobx") 5 | 6 | mobx.configure({ enforceActions: "observed" }) 7 | 8 | test("resolves", (done) => { 9 | const p = new Promise((resolve) => resolve(7)) 10 | 11 | const obs = utils.fromPromise(p) 12 | expect(obs.value).toBe(undefined) 13 | expect(obs.state).toBe("pending") 14 | 15 | mobx.when( 16 | () => { 17 | return obs.state === "fulfilled" 18 | }, 19 | () => { 20 | expect(obs.value).toBe(7) 21 | done() 22 | } 23 | ) 24 | }) 25 | 26 | test("old state is undefined", (done) => { 27 | const p = new Promise((resolve) => resolve(7)) 28 | const obs = utils.fromPromise(p, undefined) 29 | expect(obs.value).toBe(undefined) 30 | expect(obs.state).toBe("pending") 31 | 32 | mobx.when( 33 | () => obs.state === "fulfilled", 34 | () => { 35 | expect(obs.value).toBe(7) 36 | done() 37 | } 38 | ) 39 | }) 40 | 41 | test("resolves old state", () => { 42 | let obs = utils.fromPromise.resolve(9) 43 | expect(obs.value).toBe(9) 44 | expect(obs.state).toBe("fulfilled") 45 | 46 | // Expect old state to be carried forward from fulfilled observable 47 | obs = utils.fromPromise(Promise.resolve(7), obs) 48 | expect(obs.value).toBe(9) 49 | expect(obs.state).toBe("pending") 50 | 51 | // Expect old state to be carried forward from *pending* observable 52 | obs = utils.fromPromise(Promise.resolve(10), obs) 53 | expect(obs.value).toBe(9) 54 | expect(obs.state).toBe("pending") 55 | }) 56 | 57 | test("resolves new state", (done) => { 58 | const oldP = utils.fromPromise(new Promise((resolve) => resolve(9))) 59 | mobx.when( 60 | () => oldP.state == "fulfilled", 61 | () => { 62 | const p = new Promise((resolve) => resolve(7)) 63 | const obs = utils.fromPromise(p, oldP) 64 | mobx.when( 65 | () => obs.state === "fulfilled", 66 | () => { 67 | expect(obs.value).toBe(7) 68 | done() 69 | } 70 | ) 71 | } 72 | ) 73 | }) 74 | 75 | test("rejects new state", (done) => { 76 | const oldP = utils.fromPromise(new Promise((resolve) => resolve(9))) 77 | mobx.when( 78 | () => oldP.state == "fulfilled", 79 | () => { 80 | const p = new Promise((resolve, reject) => { 81 | reject(7) 82 | }) 83 | const obs = utils.fromPromise(p, oldP) 84 | mobx.when( 85 | () => obs.state === "rejected", 86 | () => { 87 | expect(obs.value).toBe(7) 88 | done() 89 | } 90 | ) 91 | } 92 | ) 93 | }) 94 | 95 | test("resolves value", (done) => { 96 | const p = new Promise((resolve) => resolve(7)) 97 | 98 | const obs = utils.fromPromise(p) 99 | expect(obs.value).toBe(undefined) 100 | expect(obs.state).toBe("pending") 101 | 102 | mobx.when( 103 | () => obs.value === 7, 104 | () => { 105 | expect(obs.state).toBe(utils.FULFILLED) 106 | done() 107 | } 108 | ) 109 | }) 110 | 111 | test("resolves value from promise function", (done) => { 112 | const obs = utils.fromPromise((resolve) => resolve(7)) 113 | expect(obs.value).toBe(undefined) 114 | expect(obs.state).toBe("pending") 115 | 116 | mobx.when( 117 | () => obs.value === 7, 118 | () => { 119 | expect(obs.state).toBe(utils.FULFILLED) 120 | done() 121 | } 122 | ) 123 | }) 124 | 125 | test("rejects with reason value", (done) => { 126 | const p = new Promise((resolve, reject) => { 127 | reject(7) 128 | }) 129 | 130 | p.catch(() => { 131 | /* noop */ 132 | }) 133 | 134 | const obs = utils.fromPromise(p) 135 | expect(obs.value).toBe(undefined) 136 | expect(obs.state).toBe("pending") 137 | 138 | mobx.when( 139 | () => obs.state !== utils.PENDING, 140 | () => { 141 | expect(obs.state).toBe(utils.REJECTED) 142 | expect(obs.value).toBe(7) 143 | done() 144 | } 145 | ) 146 | }) 147 | 148 | test("rejects with reason value from fn", (done) => { 149 | const obs = utils.fromPromise( 150 | new Promise((resolve, reject) => { 151 | reject(undefined) 152 | }) 153 | ) 154 | obs.catch(() => {}) 155 | expect(obs.value).toBe(undefined) 156 | expect(obs.state).toBe("pending") 157 | 158 | mobx.when( 159 | () => obs.state !== utils.PENDING, 160 | () => { 161 | expect(obs.state).toBe(utils.REJECTED) 162 | expect(obs.value).toBe(undefined) 163 | done() 164 | } 165 | ) 166 | }) 167 | 168 | test("rejects when throwing", (done) => { 169 | const p = new Promise(() => { 170 | throw 7 171 | }) 172 | p.catch(() => {}) 173 | 174 | const obs = utils.fromPromise(p) 175 | expect(obs.value).toBe(undefined) 176 | expect(obs.state).toBe("pending") 177 | 178 | mobx.when( 179 | () => obs.state !== "pending", 180 | () => { 181 | expect(obs.state).toBe("rejected") 182 | expect(obs.value).toBe(7) 183 | done() 184 | } 185 | ) 186 | }) 187 | 188 | test("case method, fulfillment", (done) => { 189 | const p = Promise.resolve() 190 | const obs = utils.fromPromise(p) 191 | 192 | let mapping = { 193 | pending: () => 1, 194 | fulfilled: (x) => 2, 195 | rejected: (y) => 3, 196 | } 197 | 198 | let mapped = obs.case(mapping) 199 | expect(mapped).toBe(1) 200 | mobx.when( 201 | () => obs.state !== "pending", 202 | () => { 203 | let mapped = obs.case(mapping) 204 | expect(mapped).toBe(2) 205 | done() 206 | } 207 | ) 208 | }) 209 | 210 | test("case method, rejection", (done) => { 211 | const p = Promise.reject() 212 | p.then( 213 | () => {}, 214 | () => { 215 | expect(true).toBe(true) 216 | } 217 | ) 218 | const obs = utils.fromPromise(p) 219 | 220 | let mapping = { 221 | pending: () => 1, 222 | fulfilled: (x) => 2, 223 | rejected: (y) => 3, 224 | } 225 | 226 | let mapped = obs.case(mapping) 227 | expect(mapped).toBe(1) 228 | mobx.when( 229 | () => obs.state !== "pending", 230 | () => { 231 | let mapped = obs.case(mapping) 232 | expect(mapped).toBe(3) 233 | done() 234 | } 235 | ) 236 | }) 237 | 238 | test("case method, returns fulfilled value by default", (done) => { 239 | const p = Promise.resolve(2) 240 | const obs = utils.fromPromise(p) 241 | 242 | let mapping = { pending: () => 1 } 243 | 244 | let mapped = obs.case(mapping) 245 | expect(mapped).toBe(1) 246 | mobx.when( 247 | () => obs.state === "fulfilled", 248 | () => { 249 | let mapped = obs.case(mapping) 250 | expect(mapped).toBe(2) 251 | done() 252 | } 253 | ) 254 | }) 255 | 256 | test("case method, returns undefined when handler is missing", (done) => { 257 | const p = Promise.resolve() 258 | const obs = utils.fromPromise(p) 259 | 260 | let mapping = { pending: () => 1 } 261 | 262 | let mapped = obs.case(mapping) 263 | expect(mapped).toBe(1) 264 | mobx.when( 265 | () => obs.state !== "pending", 266 | () => { 267 | let mapped = obs.case(mapping) 268 | expect(mapped).toBe(undefined) 269 | done() 270 | } 271 | ) 272 | }) 273 | 274 | test("isPromiseBasedObservable, true", () => { 275 | const obs = utils.fromPromise(Promise.resolve(123)) 276 | expect(utils.isPromiseBasedObservable(obs)).toBeTruthy() 277 | }) 278 | 279 | test("isPromiseBasedObservable, false", () => { 280 | expect(utils.isPromiseBasedObservable({})).toBeFalsy() 281 | }) 282 | 283 | test("state and value are observable, #56", () => { 284 | const obs = utils.fromPromise(Promise.resolve(123)) 285 | expect(mobx.isObservable(obs)).toBeTruthy() 286 | expect(mobx.isObservableProp(obs, "state")).toBeTruthy() 287 | expect(mobx.isObservableProp(obs, "value")).toBeTruthy() 288 | }) 289 | 290 | test("the resolved value of a promise is not convertd to some deep observable, #54", (done) => { 291 | const someObject = { a: 3 } 292 | const obs = utils.fromPromise(Promise.resolve(someObject)) 293 | obs.then((v) => { 294 | expect(obs.state).toBe(utils.FULFILLED) 295 | expect(mobx.isObservable(obs.value)).toBeFalsy() 296 | expect(obs.value === someObject).toBeTruthy() 297 | expect(v === someObject).toBeTruthy() 298 | done() 299 | }) 300 | }) 301 | 302 | test("it is possible to create a promise in a rejected state, #36", (done) => { 303 | const someObject = { a: 3 } 304 | const obs = utils.fromPromise.reject(someObject) 305 | expect(obs.state).toBe(utils.REJECTED) 306 | expect(obs.value).toBe(someObject) 307 | 308 | // still a real promise backing it, which can be thenned... 309 | obs.catch((v) => { 310 | expect(obs.state).toBe(utils.REJECTED) 311 | expect(mobx.isObservable(obs.value)).toBeFalsy() 312 | expect(obs.value === someObject).toBeTruthy() 313 | expect(v === someObject).toBeTruthy() 314 | done() 315 | }) 316 | }) 317 | 318 | test("it is possible to create a promise in a fulfilled state, #36", (done) => { 319 | const someObject = { a: 3 } 320 | const obs = utils.fromPromise.resolve(someObject) 321 | expect(obs.state).toBe(utils.FULFILLED) 322 | expect(obs.value).toBe(someObject) 323 | 324 | // still a real promise backing it, which can be thenned... 325 | obs.then((v) => { 326 | expect(obs.state).toBe(utils.FULFILLED) 327 | expect(mobx.isObservable(obs.value)).toBeFalsy() 328 | expect(obs.value === someObject).toBeTruthy() 329 | expect(v === someObject).toBeTruthy() 330 | done() 331 | }) 332 | }) 333 | 334 | test("when creating a promise in a fulfilled state it should not fire twice, #36", (done) => { 335 | let events = 0 336 | const obs = utils.fromPromise.resolve(3) 337 | 338 | mobx.autorun(() => { 339 | obs.state // track state & value 340 | obs.value 341 | events++ 342 | }) 343 | 344 | obs.then((v) => { 345 | expect(events).toBe(1) // only initial run should have run 346 | done() 347 | }) 348 | }) 349 | 350 | test("it creates a real promise, #45", () => { 351 | return Promise.all([utils.fromPromise.resolve(2), utils.fromPromise(Promise.resolve(3))]).then( 352 | (x) => { 353 | expect(x).toEqual([2, 3]) 354 | } 355 | ) 356 | }) 357 | 358 | test("it can construct new promises from function, #45", () => { 359 | return Promise.all([ 360 | utils.fromPromise((resolve, reject) => { 361 | setTimeout(() => resolve(2), 200) 362 | }), 363 | utils.fromPromise(Promise.resolve(3)), 364 | ]).then((x) => { 365 | expect(x).toEqual([2, 3]) 366 | }) 367 | }) 368 | 369 | test("it can construct a fromPromise from a fromPromise, #119", () => { 370 | expect(() => { 371 | utils.fromPromise(utils.fromPromise(Promise.resolve(3))) 372 | }).not.toThrow() 373 | }) 374 | -------------------------------------------------------------------------------- /test/from-resource.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | const utils = require("../src/mobx-utils") 4 | const mobx = require("mobx") 5 | 6 | mobx.configure({ enforceActions: "observed" }) 7 | 8 | function Record(name) { 9 | this.data = { name: name } 10 | this.subscriptions = [] 11 | } 12 | Record.prototype.updateName = function (newName) { 13 | this.data.name = newName 14 | this.subscriptions.forEach((f) => f()) 15 | } 16 | Record.prototype.subscribe = function (cb) { 17 | this.subscriptions.push(cb) 18 | return () => { 19 | const idx = this.subscriptions.indexOf(cb) 20 | if (idx !== -1); 21 | this.subscriptions.splice(idx, 1) 22 | } 23 | } 24 | 25 | function createObservable(record) { 26 | let subscription 27 | return utils.fromResource( 28 | (sink) => { 29 | sink(record.data) 30 | subscription = record.subscribe(() => { 31 | sink(record.data) 32 | }) 33 | }, 34 | () => subscription() 35 | ) 36 | } 37 | 38 | test("basics", () => { 39 | let base = console.warn // eslint-disable-line no-console 40 | let warn = [] 41 | console.warn = (msg) => warn.push(msg) // eslint-disable-line no-console 42 | 43 | var me = new Record("michel") 44 | var me$ = createObservable(me) 45 | expect(me.subscriptions.length).toBe(0) 46 | 47 | var currentName 48 | var calcs = 0 49 | var disposer = mobx.autorun(() => { 50 | calcs++ 51 | currentName = me$.current().name 52 | }) 53 | 54 | expect(me.subscriptions.length).toBe(1) 55 | expect(currentName).toBe("michel") 56 | me.updateName("veria") 57 | expect(currentName).toBe("veria") 58 | me.updateName("elise") 59 | expect(currentName).toBe("elise") 60 | expect(calcs).toBe(3) 61 | 62 | disposer() 63 | expect(me.subscriptions.length).toBe(0) 64 | 65 | me.updateName("noa") 66 | expect(currentName).toBe("elise") 67 | expect(calcs).toBe(3) 68 | 69 | // test warning 70 | expect(me$.current().name).toBe("noa") // happens to be visible through the data reference, but no autorun tragger 71 | expect(warn).toEqual([ 72 | "Called `get` of a subscribingObservable outside a reaction. Current value will be returned but no new subscription has started", 73 | ]) 74 | 75 | // resubscribe 76 | disposer = mobx.autorun(() => { 77 | calcs++ 78 | currentName = me$.current().name 79 | }) 80 | 81 | expect(currentName).toBe("noa") 82 | expect(calcs).toBe(4) 83 | 84 | setTimeout(() => { 85 | expect(me.subscriptions.length).toBe(1) 86 | me.updateName("jan") 87 | expect(calcs).toBe(5) 88 | 89 | me$.dispose() 90 | expect(me.subscriptions.length).toBe(0) 91 | expect(() => me$.current()).toThrow() 92 | 93 | me.updateName("john") 94 | expect(calcs).toBe(5) 95 | expect(currentName).toBe("jan") 96 | 97 | disposer() // autorun 98 | 99 | expect(warn.length).toBe(1) 100 | console.warn = base // eslint-disable-line no-console 101 | done() 102 | }, 100) 103 | }) 104 | 105 | test("from computed, #32", () => { 106 | var you = new Record("You") 107 | var you$ = createObservable(you) 108 | 109 | var computedName = mobx.computed(() => you$.current().name.toUpperCase()) 110 | var name 111 | var d = mobx.autorun(() => (name = computedName.get())) 112 | expect(name).toBe("YOU") 113 | you.updateName("Me") 114 | expect(name).toBe("ME") 115 | d() 116 | you.updateName("Hi") 117 | expect(name).toBe("ME") 118 | }) 119 | -------------------------------------------------------------------------------- /test/keep-alive.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | const utils = require("../src/mobx-utils") 4 | const mobx = require("mobx") 5 | 6 | mobx.configure({ enforceActions: "observed" }) 7 | 8 | test("keep alive should work for computeds", () => { 9 | const a = mobx.observable.box(1) 10 | let calcs = 0 11 | const doubler = mobx.computed(() => { 12 | calcs++ 13 | return a.get() * 2 14 | }) 15 | 16 | doubler.get() 17 | doubler.get() 18 | expect(calcs).toBe(2) 19 | 20 | mobx.runInAction(() => a.set(2)) 21 | expect(calcs).toBe(2) 22 | 23 | const disposer = utils.keepAlive(doubler) 24 | expect(doubler.get()).toBe(4) 25 | doubler.get() 26 | expect(calcs).toBe(3) 27 | 28 | mobx.runInAction(() => a.set(4)) 29 | expect(calcs).toBe(4) 30 | 31 | expect(doubler.get()).toBe(8) 32 | doubler.get() 33 | 34 | expect(calcs).toBe(4) 35 | 36 | disposer() 37 | 38 | doubler.get() 39 | doubler.get() 40 | 41 | expect(calcs).toBe(6) 42 | }) 43 | 44 | test("keep alive should work for properties", () => { 45 | let calcs = 0 46 | const x = mobx.observable({ 47 | a: 1, 48 | get doubler() { 49 | calcs++ 50 | return this.a * 2 51 | }, 52 | }) 53 | 54 | x.doubler 55 | x.doubler 56 | expect(calcs).toBe(2) 57 | 58 | mobx.runInAction(() => (x.a = 2)) 59 | expect(calcs).toBe(2) 60 | 61 | const disposer = utils.keepAlive(x, "doubler") 62 | x.doubler 63 | expect(x.doubler).toBe(4) 64 | expect(calcs).toBe(3) 65 | 66 | mobx.runInAction(() => (x.a = 4)) 67 | expect(calcs).toBe(4) 68 | 69 | expect(x.doubler).toBe(8) 70 | x.doubler 71 | 72 | expect(calcs).toBe(4) 73 | 74 | disposer() 75 | 76 | x.doubler 77 | x.doubler 78 | 79 | expect(calcs).toBe(6) 80 | }) 81 | -------------------------------------------------------------------------------- /test/lazy-observable.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | const utils = require("../src/mobx-utils") 4 | const mobx = require("mobx") 5 | 6 | mobx.configure({ enforceActions: "observed" }) 7 | 8 | test("lazy observable should work", (done) => { 9 | let started = false 10 | const lo = utils.lazyObservable((sink) => { 11 | started = true 12 | setTimeout(() => sink(4), 50) 13 | setTimeout(() => sink(5), 100) 14 | setTimeout(() => sink(6), 150) 15 | }, 3) 16 | 17 | const values = [] 18 | expect(started).toBe(false) 19 | 20 | lo.refresh() 21 | expect(started).toBe(false) 22 | 23 | mobx.autorun(() => values.push(lo.current())) 24 | 25 | expect(started).toBe(true) 26 | expect(values).toEqual([3]) 27 | expect(lo.current()).toBe(3) 28 | 29 | setTimeout(() => { 30 | expect(lo.current()).toBe(6) 31 | expect(values).toEqual([3, 4, 5, 6]) 32 | done() 33 | }, 200) 34 | }) 35 | 36 | test("lazy observable refresh", (done) => { 37 | let started = 0 38 | let i = 10 39 | 40 | const lo = utils.lazyObservable( 41 | (sink) => 42 | new Promise((resolve) => { 43 | started = started + 1 44 | resolve(i) 45 | i++ 46 | }).then((value) => { 47 | sink(value) 48 | }), 49 | 1 50 | ) 51 | 52 | let values = [] 53 | mobx.autorun(() => values.push(lo.current())) 54 | 55 | expect(started).toBe(1) 56 | expect(values).toEqual([1]) 57 | expect(lo.current()).toBe(1) 58 | 59 | setTimeout(() => lo.refresh(), 50) 60 | 61 | setTimeout(() => { 62 | expect(started).toBe(2) 63 | expect(lo.current()).toBe(11) 64 | expect(values).toEqual([1, 10, 11]) 65 | done() 66 | }, 200) 67 | }) 68 | 69 | test("lazy observable reset", (done) => { 70 | const lo = utils.lazyObservable( 71 | (sink) => 72 | new Promise((resolve) => { 73 | resolve(2) 74 | }).then((value) => { 75 | sink(value) 76 | }), 77 | 1 78 | ) 79 | 80 | lo.current() 81 | 82 | setTimeout(() => { 83 | expect(lo.current()).toBe(2) 84 | }, 50) 85 | 86 | setTimeout(() => lo.reset(), 150) 87 | 88 | setTimeout(() => { 89 | expect(lo.current()).toBe(1) 90 | }, 200) 91 | 92 | setTimeout(() => { 93 | expect(lo.current()).toBe(2) 94 | done() 95 | }, 250) 96 | }) 97 | 98 | test("lazy observable pending", (done) => { 99 | const lo = utils.lazyObservable((sink) => 100 | new Promise((resolve) => { 101 | setTimeout(resolve, 100) 102 | }).then(sink) 103 | ) 104 | 105 | expect(lo.pending).toBeFalsy() 106 | 107 | lo.current() 108 | expect(lo.pending).toBeTruthy() 109 | 110 | setTimeout(() => { 111 | expect(lo.pending).toBeFalsy() 112 | done() 113 | }, 150) 114 | }) 115 | 116 | test("lazy observable pending can be observed", async () => { 117 | const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) 118 | 119 | const lo = utils.lazyObservable((sink) => sleep(100).then(sink)) 120 | 121 | const pendingValues = [] 122 | 123 | mobx.autorun(() => pendingValues.push(lo.pending)) 124 | 125 | lo.current() 126 | 127 | await sleep(150) 128 | 129 | expect(pendingValues).toEqual([false, true, false]) 130 | }) 131 | -------------------------------------------------------------------------------- /test/now.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | const utils = require("../src/mobx-utils") 4 | const mobx = require("mobx") 5 | 6 | test("now should tick", (done) => { 7 | const values = [] 8 | const d = mobx.autorun(() => { 9 | utils.now(100) 10 | utils.now(100) // make sure same ticker is used! 11 | values.push("x") 12 | }) 13 | 14 | setTimeout(d, 250) 15 | 16 | setTimeout(() => { 17 | expect(values).toEqual(["x", "x", "x"]) 18 | done() 19 | }, 500) 20 | }) 21 | 22 | test("now should be up to date outside reaction, #40", (done) => { 23 | const d1 = utils.now(1000) 24 | expect(typeof d1 === "number").toBeTruthy() 25 | setTimeout(() => { 26 | const d2 = utils.now(1000) 27 | expect(typeof d2 === "number").toBeTruthy() 28 | expect(d1).not.toBe(d2) 29 | expect(d2 - d1 > 400).toBeTruthy() 30 | done() 31 | }, 500) 32 | }) 33 | 34 | test("now should be up to date when ticker is reactivated, #271", (done) => { 35 | let d1 36 | mobx.autorun((r) => { 37 | d1 = utils.now(100) 38 | r.dispose() 39 | }) 40 | 41 | let d2 42 | mobx.autorun( 43 | (r) => { 44 | d2 = utils.now(100) 45 | r.dispose() 46 | }, 47 | { 48 | delay: 150, 49 | } 50 | ) 51 | 52 | setTimeout(() => { 53 | expect(d1).toBeLessThan(d2) 54 | done() 55 | }, 200) 56 | }) 57 | 58 | describe("given desynchronization is enabled", () => { 59 | let actual 60 | 61 | beforeEach(() => { 62 | jest.useFakeTimers("modern") 63 | jest.setSystemTime(new Date("2015-10-21T07:28:00Z")) 64 | 65 | const someComputed = mobx.computed(() => { 66 | const currentTimestamp = utils.now(1000) 67 | 68 | return currentTimestamp > new Date("2015-10-21T07:28:00Z").getTime() 69 | }) 70 | 71 | mobx.observe( 72 | someComputed, 73 | (changed) => { 74 | actual = changed.newValue 75 | }, 76 | true 77 | ) 78 | }) 79 | 80 | afterEach(() => { 81 | utils.resetNowInternalState() 82 | }) 83 | 84 | it("given time passes, works", () => { 85 | jest.advanceTimersByTime(1000) 86 | 87 | expect(actual).toBe(true) 88 | }) 89 | 90 | it("does not share the state from previous test", () => { 91 | expect(actual).toBe(false) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /test/observable-stream.ts: -------------------------------------------------------------------------------- 1 | import * as utils from "../src/mobx-utils" 2 | import * as mobx from "mobx" 3 | import { from, interval } from "rxjs" 4 | import { map } from "rxjs/operators" 5 | 6 | test("to observable - should push the initial value by default", () => { 7 | const user = mobx.observable({ 8 | firstName: "C.S", 9 | lastName: "Lewis", 10 | }) 11 | 12 | mobx.configure({ enforceActions: "never" }) 13 | 14 | let values: string[] = [] 15 | 16 | const sub = from(utils.toStream(() => user.firstName + user.lastName, true)) 17 | .pipe(map((x) => x.toUpperCase())) 18 | .subscribe((v) => values.push(v)) 19 | 20 | user.firstName = "John" 21 | 22 | mobx.runInAction(() => { 23 | user.firstName = "Jane" 24 | user.lastName = "Jack" 25 | }) 26 | 27 | sub.unsubscribe() 28 | 29 | user.firstName = "error" 30 | 31 | expect(values).toEqual(["C.SLEWIS", "JOHNLEWIS", "JANEJACK"]) 32 | }) 33 | 34 | test("to observable - should not push the initial value", () => { 35 | const user = mobx.observable({ 36 | firstName: "C.S", 37 | lastName: "Lewis", 38 | }) 39 | 40 | mobx.configure({ enforceActions: "never" }) 41 | 42 | let values: string[] = [] 43 | 44 | const sub = from(utils.toStream(() => user.firstName + user.lastName)) 45 | .pipe(map((x) => x.toUpperCase())) 46 | .subscribe((v) => values.push(v)) 47 | 48 | user.firstName = "John" 49 | 50 | mobx.runInAction(() => { 51 | user.firstName = "Jane" 52 | user.lastName = "Jack" 53 | }) 54 | 55 | sub.unsubscribe() 56 | 57 | user.firstName = "error" 58 | 59 | expect(values).toEqual(["JOHNLEWIS", "JANEJACK"]) 60 | }) 61 | 62 | test("from observable", (done) => { 63 | mobx.configure({ enforceActions: "observed" }) 64 | const fromStream = utils.fromStream(interval(20), -1) 65 | const values: number[] = [] 66 | const d = mobx.autorun(() => { 67 | values.push(fromStream.current) 68 | }) 69 | 70 | setTimeout(() => { 71 | expect(fromStream.current).toBe(-1) 72 | }, 10) 73 | setTimeout(() => { 74 | expect(fromStream.current).toBe(0) 75 | }, 30) 76 | setTimeout(() => { 77 | expect(fromStream.current).toBe(1) 78 | fromStream.dispose() 79 | }, 50) 80 | setTimeout(() => { 81 | expect(fromStream.current).toBe(1) 82 | expect(values).toEqual([-1, 0, 1]) 83 | d() 84 | mobx.configure({ enforceActions: "never" }) 85 | done() 86 | }, 70) 87 | }) 88 | 89 | test("from observable with initialValue of a different type", async () => { 90 | mobx.configure({ enforceActions: "observed" }) 91 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) 92 | 93 | const fromStream = utils.fromStream(interval(20), "start") 94 | const values: (number | string)[] = [] 95 | const stopAutorun = mobx.autorun(() => values.push(fromStream.current)) 96 | 97 | await sleep(70) 98 | expect(fromStream.current).toBe(2) 99 | expect(values).toEqual(["start", 0, 1, 2]) 100 | fromStream.dispose() 101 | stopAutorun() 102 | mobx.configure({ enforceActions: "never" }) 103 | }) 104 | -------------------------------------------------------------------------------- /test/queue-processor.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | const utils = require("../src/mobx-utils") 4 | const mobx = require("mobx") 5 | 6 | mobx.configure({ enforceActions: "observed" }) 7 | 8 | test("sync processor should work", () => { 9 | const q = mobx.observable([1, 2]) 10 | const res = [] 11 | 12 | const stop = utils.queueProcessor(q, (v) => res.push(v * 2)) 13 | 14 | expect(res).toEqual([2, 4]) 15 | expect(q.length).toBe(0) 16 | 17 | mobx.runInAction(() => q.push(3)) 18 | expect(res).toEqual([2, 4, 6]) 19 | 20 | mobx.runInAction(() => q.push(4, 5)) 21 | expect(q.length).toBe(0) 22 | expect(res).toEqual([2, 4, 6, 8, 10]) 23 | 24 | mobx.runInAction(() => { 25 | q.unshift(6, 7) 26 | expect(q.length).toBe(2) 27 | expect(res).toEqual([2, 4, 6, 8, 10]) 28 | }) 29 | 30 | expect(q.length).toBe(0) 31 | expect(res).toEqual([2, 4, 6, 8, 10, 12, 14]) 32 | 33 | stop() 34 | mobx.runInAction(() => q.push(42)) 35 | expect(q.length).toBe(1) 36 | expect(res).toEqual([2, 4, 6, 8, 10, 12, 14]) 37 | }) 38 | 39 | test("async processor should work", (done) => { 40 | const q = mobx.observable([1, 2]) 41 | const res = [] 42 | 43 | const stop = utils.queueProcessor(q, (v) => res.push(v * 2), 10) 44 | 45 | expect(res.length).toBe(0) 46 | expect(q.length).toBe(2) 47 | 48 | setTimeout(() => { 49 | expect(res).toEqual([2, 4]) 50 | expect(q.length).toBe(0) 51 | 52 | mobx.runInAction(() => q.push(3)) 53 | expect(q.length).toBe(1) 54 | expect(res).toEqual([2, 4]) 55 | 56 | setTimeout(() => { 57 | expect(q.length).toBe(0) 58 | expect(res).toEqual([2, 4, 6]) 59 | 60 | stop() 61 | done() 62 | }, 50) 63 | }, 50) 64 | }) 65 | -------------------------------------------------------------------------------- /test/type-tests.ts: -------------------------------------------------------------------------------- 1 | import { fromPromise, FULFILLED } from "../src/mobx-utils" 2 | 3 | test("just some typings", () => { 4 | { 5 | // test typings of fromPromise 6 | const x = { x: 3 } 7 | const p = fromPromise(Promise.resolve(x)) 8 | // p.value // compile error! 9 | if (p.state === FULFILLED) { 10 | p.value.x = 4 // value only available if state is checked! 11 | } 12 | } 13 | 14 | { 15 | // typings: can create a resolved promise 16 | const x = { x: 3 } 17 | const p = fromPromise.resolve(x) 18 | p.value.x = 7 19 | } 20 | 21 | expect(true).toBe(true) 22 | }) 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.8.10", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "experimentalDecorators": true, 8 | "downlevelIteration": true, 9 | "noEmit": true, 10 | "rootDir": ".", 11 | "lib": ["dom", "es2015", "scripthost"], 12 | "useDefineForClassFields": true, 13 | "strictNullChecks": true, 14 | }, 15 | "include": ["**/*.ts"], 16 | "exclude": ["/node_modules"] 17 | } 18 | --------------------------------------------------------------------------------