├── .babelrc ├── .circleci └── config.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── debug ├── index.html └── index.js ├── package-lock.json ├── package.json ├── scripts ├── build.js ├── buildToc.js ├── debug.js ├── test.js └── testBuilds.js ├── src ├── builtIns │ ├── collections.js │ └── index.js ├── handlers.js ├── index.js ├── internals.js ├── observable.js ├── observer.js ├── reactionRunner.js └── store.js ├── tests ├── builtIns │ ├── Map.test.js │ ├── Set.test.js │ ├── WeakMap.test.js │ ├── WeakSet.test.js │ ├── builtIns.test.js │ └── typedArrays.test.js ├── debug.test.js ├── observable.test.js ├── observe.test.js ├── unobserve.test.js └── utils.js └── types └── index.d.ts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2016", "es2017", "stage-0"] 3 | } 4 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: geekykaran/headless-chrome-node-docker 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | 16 | steps: 17 | - checkout 18 | - run: npm install 19 | - run: npm run lint 20 | - run: npm test 21 | - run: npm run coveralls 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Build 34 | dist 35 | dist.js 36 | 37 | # Optional npm cache directory 38 | .npm 39 | 40 | # Optional REPL history 41 | .node_repl_history 42 | 43 | # Local optimization utils 44 | opt 45 | 46 | # reify cache for allowing import export in node 47 | .reify-cache 48 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 RisingStack 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 | # The Observer Utility 2 | 3 | Transparent reactivity with 100% language coverage. Made with :heart: and ES6 Proxies. 4 | 5 | [![Build](https://img.shields.io/circleci/project/github/nx-js/observer-util/master.svg)](https://circleci.com/gh/nx-js/observer-util/tree/master) [![Coverage Status](https://coveralls.io/repos/github/nx-js/observer-util/badge.svg)](https://coveralls.io/github/nx-js/observer-util) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) [![Package size](https://img.shields.io/bundlephobia/minzip/@nx-js/observer-util.svg)](https://bundlephobia.com/result?p=@nx-js/observer-util) [![Version](https://img.shields.io/npm/v/@nx-js/observer-util.svg)](https://www.npmjs.com/package/@nx-js/observer-util) [![dependencies Status](https://david-dm.org/nx-js/observer-util/status.svg)](https://david-dm.org/nx-js/observer-util) [![License](https://img.shields.io/npm/l/@nx-js/observer-util.svg)](https://www.npmjs.com/package/@nx-js/observer-util) 6 | 7 |
8 | Table of Contents 9 | 10 | 11 | 12 | 13 | * [Motivation](#motivation) 14 | * [Bindings](#bindings) 15 | * [Installation](#installation) 16 | * [Usage](#usage) 17 | * [Observables](#observables) 18 | * [Reactions](#reactions) 19 | * [Reaction scheduling](#reaction-scheduling) 20 | * [API](#api) 21 | * [Proxy = observable(object)](#proxy--observableobject) 22 | * [boolean = isObservable(object)](#boolean--isobservableobject) 23 | * [reaction = observe(function, config)](#reaction--observefunction-config) 24 | * [unobserve(reaction)](#unobservereaction) 25 | * [obj = raw(observable)](#obj--rawobservable) 26 | * [Platform support](#platform-support) 27 | * [Alternative builds](#alternative-builds) 28 | * [Contributing](#contributing) 29 | 30 | 31 | 32 |
33 | 34 | ## Motivation 35 | 36 | Popular frontend frameworks - like Angular, React and Vue - use a reactivity system to automatically update the view when the state changes. This is necessary for creating modern web apps and staying sane at the same time. 37 | 38 | The Observer Utililty is a similar reactivity system, with a modern twist. It uses [ES6 Proxies](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) to achieve true transparency and a 100% language coverage. Ideally you would like to manage your state with plain JS code and expect the view to update where needed. In practice some reactivity systems require extra syntax - like React's `setState`. Others have limits on the language features, which they can react on - like dynamic properties or the `delete` keyword. These are small nuisances, but they lead to long hours lost among special docs and related issues. 39 | 40 | The Observer Utility aims to eradicate these edge cases. It comes with a tiny learning curve and with a promise that you won't have to dig up hidden docs and issues later. Give it a try, things will just work. 41 | 42 | ## Bindings 43 | 44 | This is a framework independent library, which powers the reactivity system behind other state management solutions. These are the currently available bindings. 45 | 46 | * [React Easy State](https://github.com/solkimicreb/react-easy-state) is a state management solution for React with a minimal learning curve. 47 | * [preact-ns-observer](https://github.com/mseddon/preact-nx-observer) provides a simple `@observable` decorator that makes Preact components reactive. 48 | 49 | ## Installation 50 | 51 | ``` 52 | $ npm install @nx-js/observer-util 53 | ``` 54 | 55 | ## Usage 56 | 57 | The two building blocks of reactivity are **observables** and **reactions**. Observable objects represent the state and reactions are functions, that react to state changes. In case of transparent reactivity, these reactions are called automatically on relevant state changes. 58 | 59 | ### Observables 60 | 61 | Observables are transparent Proxies, which can be created with the `observable` function. From the outside they behave exactly like plain JS objects. 62 | 63 | ```js 64 | import { observable } from '@nx-js/observer-util'; 65 | 66 | const counter = observable({ num: 0 }); 67 | 68 | // observables behave like plain JS objects 69 | counter.num = 12; 70 | ``` 71 | 72 | ### Reactions 73 | 74 | Reactions are functions, which use observables. They can be created with the `observe` function and they are automatically executed whenever the observables - used by them - change. 75 | 76 | #### Vanilla JavaScript 77 | 78 | ```js 79 | import { observable, observe } from '@nx-js/observer-util'; 80 | 81 | const counter = observable({ num: 0 }); 82 | const countLogger = observe(() => console.log(counter.num)); 83 | 84 | // this calls countLogger and logs 1 85 | counter.num++; 86 | ``` 87 | 88 | #### React Component 89 | 90 | ```js 91 | import { store, view } from 'react-easy-state'; 92 | 93 | // this is an observable store 94 | const counter = store({ 95 | num: 0, 96 | up() { 97 | this.num++; 98 | } 99 | }); 100 | 101 | // this is a reactive component, which re-renders whenever counter.num changes 102 | const UserComp = view(() =>
{counter.num}
); 103 | ``` 104 | 105 | #### Preact Component 106 | ```js 107 | import { observer } from "preact-nx-observer"; 108 | 109 | let store = observable({ title: "This is foo's data"}); 110 | 111 | @observer // Component will now re-render whenever store.title changes. 112 | class Foo extends Component { 113 | render() { 114 | return

{store.title}

115 | } 116 | } 117 | ``` 118 | #### More examples 119 | 120 |
121 | Dynamic properties 122 | 123 | ```js 124 | import { observable, observe } from '@nx-js/observer-util'; 125 | 126 | const profile = observable(); 127 | observe(() => console.log(profile.name)); 128 | 129 | // logs 'Bob' 130 | profile.name = 'Bob'; 131 | ``` 132 | 133 |
134 |
135 | Nested properties 136 | 137 | ```js 138 | import { observable, observe } from '@nx-js/observer-util'; 139 | 140 | const person = observable({ 141 | name: { 142 | first: 'John', 143 | last: 'Smith' 144 | }, 145 | age: 22 146 | }); 147 | 148 | observe(() => console.log(`${person.name.first} ${person.name.last}`)); 149 | 150 | // logs 'Bob Smith' 151 | person.name.first = 'Bob'; 152 | ``` 153 | 154 |
155 |
156 | Getter properties 157 | 158 | ```js 159 | import { observable, observe } from '@nx-js/observer-util'; 160 | 161 | const person = observable({ 162 | firstName: 'Bob', 163 | lastName: 'Smith', 164 | get name() { 165 | return `${this.firstName} ${this.lastName}`; 166 | } 167 | }); 168 | 169 | observe(() => console.log(person.name)); 170 | 171 | // logs 'Ann Smith' 172 | person.firstName = 'Ann'; 173 | ``` 174 | 175 |
176 |
177 | Conditionals 178 | 179 | ```js 180 | import { observable, observe } from '@nx-js/observer-util'; 181 | 182 | const person = observable({ 183 | gender: 'male', 184 | name: 'Potato' 185 | }); 186 | 187 | observe(() => { 188 | if (person.gender === 'male') { 189 | console.log(`Mr. ${person.name}`); 190 | } else { 191 | console.log(`Ms. ${person.name}`); 192 | } 193 | }); 194 | 195 | // logs 'Ms. Potato' 196 | person.gender = 'female'; 197 | ``` 198 | 199 |
200 |
201 | Arrays 202 | 203 | ```js 204 | import { observable, observe } from '@nx-js/observer-util'; 205 | 206 | const users = observable([]); 207 | 208 | observe(() => console.log(users.join(', '))); 209 | 210 | // logs 'Bob' 211 | users.push('Bob'); 212 | 213 | // logs 'Bob, John' 214 | users.push('John'); 215 | 216 | // logs 'Bob' 217 | users.pop(); 218 | ``` 219 | 220 |
221 |
222 | ES6 collections 223 | 224 | ```js 225 | import { observable, observe } from '@nx-js/observer-util'; 226 | 227 | const people = observable(new Map()); 228 | 229 | observe(() => { 230 | for (let [name, age] of people) { 231 | console.log(`${name}, ${age}`); 232 | } 233 | }); 234 | 235 | // logs 'Bob, 22' 236 | people.set('Bob', 22); 237 | 238 | // logs 'Bob, 22' and 'John, 35' 239 | people.set('John', 35); 240 | ``` 241 | 242 |
243 |
244 | Inherited properties 245 | 246 | ```js 247 | import { observable, observe } from '@nx-js/observer-util'; 248 | 249 | const defaultUser = observable({ 250 | name: 'Unknown', 251 | job: 'developer' 252 | }); 253 | const user = observable(Object.create(defaultUser)); 254 | 255 | // logs 'Unknown is a developer' 256 | observe(() => console.log(`${user.name} is a ${user.job}`)); 257 | 258 | // logs 'Bob is a developer' 259 | user.name = 'Bob'; 260 | 261 | // logs 'Bob is a stylist' 262 | user.job = 'stylist'; 263 | 264 | // logs 'Unknown is a stylist' 265 | delete user.name; 266 | ``` 267 | 268 |
269 | 270 | ### Reaction scheduling 271 | 272 | Reactions are scheduled to run whenever the relevant observable state changes. The default scheduler runs the reactions synchronously, but custom schedulers can be passed to change this behavior. Schedulers are usually functions which receive the scheduled reaction as argument. 273 | 274 | ```js 275 | import { observable, observe } from '@nx-js/observer-util'; 276 | 277 | // this scheduler delays reactions by 1 second 278 | const scheduler = reaction => setTimeout(reaction, 1000); 279 | 280 | const person = observable({ name: 'Josh' }); 281 | observe(() => console.log(person.name), { scheduler }); 282 | 283 | // this logs 'Barbie' after a one second delay 284 | person.name = 'Barbie'; 285 | ``` 286 | 287 | Alternatively schedulers can be objects with an `add` and `delete` method. Check out the below examples for more. 288 | 289 | #### More examples 290 | 291 |
292 | React Scheduler 293 | 294 | The React scheduler simply calls `setState` on relevant observable changes. This delegates the render scheduling to React Fiber. It works roughly like this. 295 | 296 | ```js 297 | import { observe } from '@nx-js/observer-util'; 298 | 299 | class ReactiveComp extends BaseComp { 300 | constructor() { 301 | // ... 302 | this.render = observe(this.render, { 303 | scheduler: () => this.setState() 304 | }); 305 | } 306 | } 307 | ``` 308 | 309 |
310 |
311 | Batched updates with ES6 Sets 312 | 313 | Schedulers can be objects with an `add` and `delete` method, which schedule and unschedule reactions. ES6 Sets can be used as a scheduler, that automatically removes duplicate reactions. 314 | 315 | ```js 316 | import { observable, observe } from '@nx-js/observer-util'; 317 | 318 | const reactions = new Set(); 319 | const person = observable({ name: 'Josh' }); 320 | observe(() => console.log(person), { scheduler: reactions }); 321 | 322 | // this throttles reactions to run with a minimal 1 second interval 323 | setInterval(() => { 324 | reactions.forEach(reaction => reaction()); 325 | }, 1000); 326 | 327 | // these will cause { name: 'Barbie', age: 30 } to be logged once 328 | person.name = 'Barbie'; 329 | person.age = 87; 330 | ``` 331 | 332 |
333 |
334 | Batched updates with queues 335 | 336 | Queues from the [Queue Util](https://github.com/nx-js/queue-util) can be used to implement complex scheduling patterns by combining automatic priority based and manual execution. 337 | 338 | ```js 339 | import { observable, observe } from '@nx-js/observer-util'; 340 | import { Queue, priorities } from '@nx-js/queue-util'; 341 | 342 | const scheduler = new Queue(priorities.LOW); 343 | const person = observable({ name: 'Josh' }); 344 | observe(() => console.log(person), { scheduler }); 345 | 346 | // these will cause { name: 'Barbie', age: 30 } to be logged once 347 | // when everything is idle and there is free time to do it 348 | person.name = 'Barbie'; 349 | person.age = 87; 350 | ``` 351 | 352 | Queues are automatically scheduling reactions - based on their priority - but they can also be stopped, started and cleared manually at any time. Learn more about them [here](). 353 | 354 |
355 | 356 | ## API 357 | 358 | ### Proxy = observable(object) 359 | 360 | Creates and returns a proxied observable object, which behaves just like the originally passed object. The original object is **not modified**. 361 | 362 | * If no argument is provided, it returns an empty observable object. 363 | * If an object is passed as argument, it wraps the passed object in an observable. 364 | * If an observable object is passed, it returns the passed observable object. 365 | 366 | ### boolean = isObservable(object) 367 | 368 | Returns true if the passed object is an observable, returns false otherwise. 369 | 370 | ### reaction = observe(function, config) 371 | 372 | Wraps the passed function with a reaction, which behaves just like the original function. The reaction is automatically scheduled to run whenever an observable - used by it - changes. The original function is **not modified**. 373 | 374 | `observe` also accepts an optional config object with the following options. 375 | 376 | * `lazy`: A boolean, which controls if the reaction is executed when it is created or not. If it is true, the reaction has to be called once manually to trigger the reactivity process. Defaults to false. 377 | 378 | * `scheduler`: A function, which is called with the reaction when it is scheduled to run. It can also be an object with an `add` and `delete` method - which schedule and unschedule reactions. The default scheduler runs the reaction synchronously on observable mutations. You can learn more about reaction scheduling in the [related docs section](#reaction-scheduling). 379 | 380 | * `debugger`: An optional function. It is called with contextual metadata object on basic operations - like set, get, delete, etc. The metadata object can be used to determine why the operation wired or scheduled the reaction and it always has enough data to reverse the operation. The debugger is always called before the scheduler. 381 | 382 | ### unobserve(reaction) 383 | 384 | Unobserves the passed reaction. Unobserved reactions won't be automatically run anymore. 385 | 386 | ```js 387 | import { observable, observe, unobserve } from '@nx-js/observer-util'; 388 | 389 | const counter = observable({ num: 0 }); 390 | const logger = observe(() => console.log(counter.num)); 391 | 392 | // after this the logger won't be automatically called on counter.num changes 393 | unobserve(logger); 394 | ``` 395 | 396 | ### obj = raw(observable) 397 | 398 | Original objects are never modified, but transparently wrapped by observable proxies. `raw` can access the original non-reactive object. Modifying and accessing properties on the raw object doesn't trigger reactions. 399 | 400 | #### Using `raw` at property access 401 | 402 | ```js 403 | import { observable, observe, raw } from '@nx-js/observer-util'; 404 | 405 | const person = observable(); 406 | const logger = observe(() => console.log(person.name)); 407 | 408 | // this logs 'Bob' 409 | person.name = 'Bob'; 410 | 411 | // `name` is used from the raw non-reactive object, this won't log anything 412 | raw(person).name = 'John'; 413 | ``` 414 | 415 | #### Using `raw` at property mutation 416 | 417 | ```js 418 | import { observable, observe, raw } from '@nx-js/observer-util'; 419 | 420 | const person = observable({ age: 20 }); 421 | observe(() => console.log(`${person.name}: ${raw(person).age}`)); 422 | 423 | // this logs 'Bob: 20' 424 | person.name = 'Bob'; 425 | 426 | // `age` is used from the raw non-reactive object, this won't log anything 427 | person.age = 33; 428 | ``` 429 | 430 | ## Platform support 431 | 432 | * Node: 6.5 and above 433 | * Chrome: 49 and above 434 | * Firefox: 38 and above 435 | * Safari: 10 and above 436 | * Edge: 12 and above 437 | * Opera: 36 and above 438 | * IE is not supported 439 | 440 | ## Alternative builds 441 | 442 | This library detects if you use ES6 or commonJS modules and serve the right format to you. The exposed bundles are transpiled to ES5 to support common tools - like UglifyJS minifying. If you would like a finer control over the provided build, you can specify them in your imports. 443 | 444 | * `@nx-js/observer-util/dist/es.es6.js` exposes an ES6 build with ES6 modules. 445 | * `@nx-js/observer-util/dist/es.es5.js` exposes an ES5 build with ES6 modules. 446 | * `@nx-js/observer-util/dist/cjs.es6.js` exposes an ES6 build with commonJS modules. 447 | * `@nx-js/observer-util/dist/cjs.es5.js` exposes an ES5 build with commonJS modules. 448 | 449 | If you use a bundler, set up an alias for `@nx-js/observer-util` to point to your desired build. You can learn how to do it with webpack [here](https://webpack.js.org/configuration/resolve/#resolve-alias) and with rollup [here](https://github.com/rollup/rollup-plugin-alias#usage). 450 | 451 | ## Contributing 452 | 453 | Contributions are always welcomed! Just send a PR for fixes and doc updates and open issues for new features beforehand. Make sure that the tests and the linter pass and that 454 | the coverage remains high. Thanks! 455 | -------------------------------------------------------------------------------- /debug/index.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /debug/index.js: -------------------------------------------------------------------------------- 1 | import { observable, observe, unobserve } from '@nx-js/observer-util' 2 | 3 | const data = observable({ array: [1, 2, 3, 4], num: 12 }) 4 | let reactions = [] 5 | 6 | const interval = setInterval(() => { 7 | data.array = [1, 2, 3, 4] 8 | }, 500) 9 | 10 | function observeMany () { 11 | for (let i = 0; i < 10000; i++) { 12 | const reaction = observe(() => { 13 | return data.num + data.array.reduce((sum, num) => sum + num, 0) 14 | }) 15 | reactions.push(reaction) 16 | } 17 | } 18 | 19 | function unobserveMany () { 20 | for (let reaction of reactions) { 21 | unobserve(reaction) 22 | } 23 | reactions = [] 24 | // this makes it not clean up properly! 25 | clearInterval(interval) 26 | } 27 | 28 | window.observeMany = observeMany 29 | window.unobserveMany = unobserveMany 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nx-js/observer-util", 3 | "version": "4.2.2", 4 | "description": "Simple transparent reactivity with 100% language coverage. Made with ES6 Proxies.", 5 | "main": "dist/cjs.es5.js", 6 | "module": "dist/es.es5.js", 7 | "types": "types/index.d.ts", 8 | "files": [ 9 | "dist", 10 | "types" 11 | ], 12 | "scripts": { 13 | "test": "node ./scripts/test.js", 14 | "test-builds": "node ./scripts/testBuilds.js", 15 | "debug": "node ./scripts/debug.js", 16 | "lint": "standard", 17 | "lint-fix": "prettier --ignore-path '.gitignore' --write '**/!(bundle).js' && standard --fix", 18 | "coveralls": "cat ./coverage/lcov.info | ./node_modules/.bin/coveralls", 19 | "build": "node ./scripts/build.js", 20 | "build-toc": "node ./scripts/buildToc.js" 21 | }, 22 | "author": { 23 | "name": "Miklos Bertalan", 24 | "email": "miklos.bertalan@risingstack.com" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git@github.com:nx-js/observer-util.git" 29 | }, 30 | "bugs": { 31 | "url": "https://github.com/nx-js/observer-util/issues" 32 | }, 33 | "homepage": "https://github.com/nx-js/observer-util#readme", 34 | "license": "MIT", 35 | "keywords": [ 36 | "nx", 37 | "observe", 38 | "observable", 39 | "data", 40 | "binding", 41 | "proxy", 42 | "ES6", 43 | "reactive" 44 | ], 45 | "devDependencies": { 46 | "babel-core": "6.26.3", 47 | "babel-preset-es2016": "^6.24.1", 48 | "babel-preset-es2017": "^6.24.1", 49 | "babel-preset-react": "^6.24.1", 50 | "babel-preset-stage-0": "^6.24.1", 51 | "buble": "^0.15.2", 52 | "chai": "^4.1.2", 53 | "coveralls": "^3.0.1", 54 | "karma": "^2.0.2", 55 | "karma-chai": "^0.1.0", 56 | "karma-chrome-launcher": "^2.2.0", 57 | "karma-coverage": "^1.1.2", 58 | "karma-mocha": "^1.3.0", 59 | "karma-mocha-reporter": "^2.2.5", 60 | "karma-rollup-preprocessor": "^6.0.0", 61 | "karma-source-map-support": "^1.3.0", 62 | "markdown-toc": "^1.2.0", 63 | "mocha": "^5.2.0", 64 | "nyc": "12.0.2", 65 | "pre-push": "^0.1.1", 66 | "prettier": "^1.13.5", 67 | "rollup": "^0.60.1", 68 | "rollup-plugin-alias": "^1.4.0", 69 | "rollup-plugin-auto-external": "^1.2.0", 70 | "rollup-plugin-babel": "^3.0.4", 71 | "rollup-plugin-commonjs": "^9.1.3", 72 | "rollup-plugin-coverage": "^0.1.4", 73 | "rollup-plugin-node-resolve": "^3.3.0", 74 | "standard": "^11.0.1" 75 | }, 76 | "engines": { 77 | "node": ">=6.5.0" 78 | }, 79 | "standard": { 80 | "env": [ 81 | "browser", 82 | "mocha" 83 | ] 84 | }, 85 | "pre-push": [ 86 | "lint", 87 | "test" 88 | ] 89 | } 90 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const del = require('del') 4 | const buble = require('buble') 5 | const rollup = require('rollup') 6 | const resolvePlugin = require('rollup-plugin-node-resolve') 7 | const babelPlugin = require('rollup-plugin-babel') 8 | const externalsPlugin = require('rollup-plugin-auto-external') 9 | 10 | const input = { 11 | input: path.resolve('src/index.js'), 12 | plugins: [ 13 | babelPlugin({ 14 | exclude: 'node_modules/**' 15 | }), 16 | resolvePlugin(), 17 | externalsPlugin({ dependencies: true, peerDependecies: true }) 18 | ] 19 | } 20 | 21 | const bundles = [ 22 | { 23 | input, 24 | output: { format: 'es' } 25 | }, 26 | { 27 | input, 28 | output: { format: 'cjs' } 29 | } 30 | ] 31 | 32 | async function build () { 33 | // Clean up the output directory 34 | await del(path.resolve('dist')) 35 | fs.mkdirSync(path.resolve('dist')) 36 | 37 | // Compile source code into a distributable format with Babel and Rollup 38 | for (const config of bundles) { 39 | const es6Path = path.resolve('dist', `${config.output.format}.es6.js`) 40 | const bundle = await rollup.rollup(config.input) 41 | const { code: es6Code } = await bundle.generate(config.output) 42 | fs.writeFileSync(es6Path, es6Code, 'utf-8') 43 | 44 | const es5Path = path.resolve('dist', `${config.output.format}.es5.js`) 45 | const { code: es5Code } = buble.transform(es6Code, { 46 | transforms: { 47 | dangerousForOf: true, 48 | modules: false 49 | } 50 | }) 51 | fs.writeFileSync(es5Path, es5Code, 'utf-8') 52 | } 53 | } 54 | 55 | build() 56 | -------------------------------------------------------------------------------- /scripts/buildToc.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const toc = require('markdown-toc') 4 | 5 | const readmePath = path.resolve('README.md') 6 | const oldReadme = fs.readFileSync(readmePath, 'utf8') 7 | const newReadme = toc.insert(oldReadme, { maxdepth: 3, bullets: ['*', '+'] }) 8 | 9 | fs.writeFileSync(readmePath, newReadme) 10 | -------------------------------------------------------------------------------- /scripts/debug.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const rollup = require('rollup') 4 | const resolvePlugin = require('rollup-plugin-node-resolve') 5 | const babelPlugin = require('rollup-plugin-babel') 6 | const alias = require('rollup-plugin-alias') 7 | 8 | const bundleType = process.env.BUNDLE 9 | const bundlePath = bundleType ? `dist/${bundleType}.js` : 'src/index.js' 10 | 11 | const config = { 12 | input: { 13 | input: path.resolve('debug/index.js'), 14 | plugins: [ 15 | babelPlugin({ 16 | exclude: 'node_modules/**' 17 | }), 18 | resolvePlugin(), 19 | alias({ 20 | '@nx-js/observer-util': path.resolve(bundlePath) 21 | }) 22 | ] 23 | }, 24 | output: { 25 | format: 'iife' 26 | } 27 | } 28 | 29 | async function build () { 30 | // Compile source code into a distributable format with Babel and Rollup 31 | const bundle = await rollup.rollup(config.input) 32 | const { code } = await bundle.generate(config.output) 33 | const bundlePath = path.resolve('debug', 'dist.js') 34 | fs.writeFileSync(bundlePath, code, 'utf-8') 35 | } 36 | 37 | build() 38 | -------------------------------------------------------------------------------- /scripts/test.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const resolve = require('rollup-plugin-node-resolve') 3 | const commonjs = require('rollup-plugin-commonjs') 4 | const babel = require('rollup-plugin-babel') 5 | const coverage = require('rollup-plugin-coverage') 6 | const alias = require('rollup-plugin-alias') 7 | const TestServer = require('karma').Server 8 | 9 | const bundleName = process.env.BUNDLE 10 | const bundlePath = bundleName ? `dist/${bundleName}` : 'src/index.js' 11 | 12 | const config = { 13 | frameworks: ['mocha', 'chai', 'source-map-support'], 14 | reporters: ['mocha', 'coverage'], 15 | files: ['tests/**/*.test.js'], 16 | preprocessors: { 17 | 'tests/**/*.test.js': ['rollup'] 18 | }, 19 | rollupPreprocessor: { 20 | plugins: [ 21 | alias({ 22 | '@nx-js/observer-util': path.resolve(bundlePath) 23 | }), 24 | babel({ 25 | exclude: 'node_modules/**' 26 | }), 27 | resolve(), 28 | commonjs({ 29 | namedExports: { 30 | 'node_modules/chai/index.js': ['expect'] 31 | } 32 | }), 33 | coverage({ 34 | include: ['src/**/*.js'] 35 | }) 36 | ], 37 | output: { 38 | format: 'iife', 39 | name: 'observer', 40 | sourcemap: 'inline' 41 | } 42 | }, 43 | coverageReporter: { 44 | dir: 'coverage', 45 | reporters: [{ type: 'lcov', subdir: '.' }, { type: 'text-summary' }] 46 | }, 47 | port: 9876, 48 | colors: true, 49 | autoWatch: false, 50 | concurrency: Infinity, 51 | singleRun: true, 52 | browsers: ['ChromeHeadlessNoSandbox'], 53 | customLaunchers: { 54 | ChromeHeadlessNoSandbox: { 55 | base: 'ChromeHeadless', 56 | flags: ['--no-sandbox'] 57 | } 58 | } 59 | } 60 | 61 | const testServer = new TestServer(config, exitCode => { 62 | console.log(`Karma has exited with ${exitCode}`) 63 | process.exit(exitCode) 64 | }) 65 | testServer.start() 66 | -------------------------------------------------------------------------------- /scripts/testBuilds.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const { exec } = require('child_process') 4 | 5 | const distPath = path.resolve('dist') 6 | const files = fs.readdirSync(distPath) 7 | 8 | async function testBuilds () { 9 | for (let file of files) { 10 | const err = await execPromise(`BUNDLE=${file} npm run test`) 11 | if (err) { 12 | console.error('\x1b[31m', `Error in ${file}`, '\x1b[30m') 13 | } else { 14 | console.log(`${file} works as expected`) 15 | } 16 | } 17 | } 18 | 19 | function execPromise (cmd) { 20 | return new Promise(resolve => exec(cmd, resolve)) 21 | } 22 | 23 | testBuilds() 24 | -------------------------------------------------------------------------------- /src/builtIns/collections.js: -------------------------------------------------------------------------------- 1 | import { observable } from '../observable' 2 | import { 3 | registerRunningReactionForOperation, 4 | queueReactionsForOperation, 5 | hasRunningReaction 6 | } from '../reactionRunner' 7 | import { proxyToRaw, rawToProxy } from '../internals' 8 | 9 | const hasOwnProperty = Object.prototype.hasOwnProperty 10 | 11 | function findObservable (obj) { 12 | const observableObj = rawToProxy.get(obj) 13 | if (hasRunningReaction() && typeof obj === 'object' && obj !== null) { 14 | if (observableObj) { 15 | return observableObj 16 | } 17 | return observable(obj) 18 | } 19 | return observableObj || obj 20 | } 21 | 22 | function patchIterator (iterator, isEntries) { 23 | const originalNext = iterator.next 24 | iterator.next = () => { 25 | let { done, value } = originalNext.call(iterator) 26 | if (!done) { 27 | if (isEntries) { 28 | value[1] = findObservable(value[1]) 29 | } else { 30 | value = findObservable(value) 31 | } 32 | } 33 | return { done, value } 34 | } 35 | return iterator 36 | } 37 | 38 | const instrumentations = { 39 | has (key) { 40 | const target = proxyToRaw.get(this) 41 | const proto = Reflect.getPrototypeOf(this) 42 | registerRunningReactionForOperation({ target, key, type: 'has' }) 43 | return proto.has.apply(target, arguments) 44 | }, 45 | get (key) { 46 | const target = proxyToRaw.get(this) 47 | const proto = Reflect.getPrototypeOf(this) 48 | registerRunningReactionForOperation({ target, key, type: 'get' }) 49 | return findObservable(proto.get.apply(target, arguments)) 50 | }, 51 | add (key) { 52 | const target = proxyToRaw.get(this) 53 | const proto = Reflect.getPrototypeOf(this) 54 | const hadKey = proto.has.call(target, key) 55 | // forward the operation before queueing reactions 56 | const result = proto.add.apply(target, arguments) 57 | if (!hadKey) { 58 | queueReactionsForOperation({ target, key, value: key, type: 'add' }) 59 | } 60 | return result 61 | }, 62 | set (key, value) { 63 | const target = proxyToRaw.get(this) 64 | const proto = Reflect.getPrototypeOf(this) 65 | const hadKey = proto.has.call(target, key) 66 | const oldValue = proto.get.call(target, key) 67 | // forward the operation before queueing reactions 68 | const result = proto.set.apply(target, arguments) 69 | if (!hadKey) { 70 | queueReactionsForOperation({ target, key, value, type: 'add' }) 71 | } else if (value !== oldValue) { 72 | queueReactionsForOperation({ target, key, value, oldValue, type: 'set' }) 73 | } 74 | return result 75 | }, 76 | delete (key) { 77 | const target = proxyToRaw.get(this) 78 | const proto = Reflect.getPrototypeOf(this) 79 | const hadKey = proto.has.call(target, key) 80 | const oldValue = proto.get ? proto.get.call(target, key) : undefined 81 | // forward the operation before queueing reactions 82 | const result = proto.delete.apply(target, arguments) 83 | if (hadKey) { 84 | queueReactionsForOperation({ target, key, oldValue, type: 'delete' }) 85 | } 86 | return result 87 | }, 88 | clear () { 89 | const target = proxyToRaw.get(this) 90 | const proto = Reflect.getPrototypeOf(this) 91 | const hadItems = target.size !== 0 92 | const oldTarget = target instanceof Map ? new Map(target) : new Set(target) 93 | // forward the operation before queueing reactions 94 | const result = proto.clear.apply(target, arguments) 95 | if (hadItems) { 96 | queueReactionsForOperation({ target, oldTarget, type: 'clear' }) 97 | } 98 | return result 99 | }, 100 | forEach (cb, ...args) { 101 | const target = proxyToRaw.get(this) 102 | const proto = Reflect.getPrototypeOf(this) 103 | registerRunningReactionForOperation({ target, type: 'iterate' }) 104 | // swap out the raw values with their observable pairs 105 | // before passing them to the callback 106 | const wrappedCb = (value, ...rest) => cb(findObservable(value), ...rest) 107 | return proto.forEach.call(target, wrappedCb, ...args) 108 | }, 109 | keys () { 110 | const target = proxyToRaw.get(this) 111 | const proto = Reflect.getPrototypeOf(this) 112 | registerRunningReactionForOperation({ target, type: 'iterate' }) 113 | return proto.keys.apply(target, arguments) 114 | }, 115 | values () { 116 | const target = proxyToRaw.get(this) 117 | const proto = Reflect.getPrototypeOf(this) 118 | registerRunningReactionForOperation({ target, type: 'iterate' }) 119 | const iterator = proto.values.apply(target, arguments) 120 | return patchIterator(iterator, false) 121 | }, 122 | entries () { 123 | const target = proxyToRaw.get(this) 124 | const proto = Reflect.getPrototypeOf(this) 125 | registerRunningReactionForOperation({ target, type: 'iterate' }) 126 | const iterator = proto.entries.apply(target, arguments) 127 | return patchIterator(iterator, true) 128 | }, 129 | [Symbol.iterator] () { 130 | const target = proxyToRaw.get(this) 131 | const proto = Reflect.getPrototypeOf(this) 132 | registerRunningReactionForOperation({ target, type: 'iterate' }) 133 | const iterator = proto[Symbol.iterator].apply(target, arguments) 134 | return patchIterator(iterator, target instanceof Map) 135 | }, 136 | get size () { 137 | const target = proxyToRaw.get(this) 138 | const proto = Reflect.getPrototypeOf(this) 139 | registerRunningReactionForOperation({ target, type: 'iterate' }) 140 | return Reflect.get(proto, 'size', target) 141 | } 142 | } 143 | 144 | export default { 145 | get (target, key, receiver) { 146 | // instrument methods and property accessors to be reactive 147 | target = hasOwnProperty.call(instrumentations, key) 148 | ? instrumentations 149 | : target 150 | return Reflect.get(target, key, receiver) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/builtIns/index.js: -------------------------------------------------------------------------------- 1 | import collectionHandlers from './collections' 2 | 3 | // eslint-disable-next-line 4 | const globalObj = typeof window === 'object' ? window : Function('return this')(); 5 | 6 | // built-in object can not be wrapped by Proxies 7 | // their methods expect the object instance as the 'this' instead of the Proxy wrapper 8 | // complex objects are wrapped with a Proxy of instrumented methods 9 | // which switch the proxy to the raw object and to add reactive wiring 10 | const handlers = new Map([ 11 | [Map, collectionHandlers], 12 | [Set, collectionHandlers], 13 | [WeakMap, collectionHandlers], 14 | [WeakSet, collectionHandlers], 15 | [Object, false], 16 | [Array, false], 17 | [Int8Array, false], 18 | [Uint8Array, false], 19 | [Uint8ClampedArray, false], 20 | [Int16Array, false], 21 | [Uint16Array, false], 22 | [Int32Array, false], 23 | [Uint32Array, false], 24 | [Float32Array, false], 25 | [Float64Array, false] 26 | ]) 27 | 28 | export function shouldInstrument ({ constructor }) { 29 | const isBuiltIn = 30 | typeof constructor === 'function' && 31 | constructor.name in globalObj && 32 | globalObj[constructor.name] === constructor 33 | return !isBuiltIn || handlers.has(constructor) 34 | } 35 | 36 | export function getHandlers (obj) { 37 | return handlers.get(obj.constructor) 38 | } 39 | -------------------------------------------------------------------------------- /src/handlers.js: -------------------------------------------------------------------------------- 1 | import { observable } from './observable' 2 | import { proxyToRaw, rawToProxy } from './internals' 3 | import { 4 | registerRunningReactionForOperation, 5 | queueReactionsForOperation, 6 | hasRunningReaction 7 | } from './reactionRunner' 8 | 9 | const hasOwnProperty = Object.prototype.hasOwnProperty 10 | const wellKnownSymbols = new Set( 11 | Object.getOwnPropertyNames(Symbol) 12 | .map(key => Symbol[key]) 13 | .filter(value => typeof value === 'symbol') 14 | ) 15 | 16 | // intercept get operations on observables to know which reaction uses their properties 17 | function get (target, key, receiver) { 18 | const result = Reflect.get(target, key, receiver) 19 | // do not register (observable.prop -> reaction) pairs for well known symbols 20 | // these symbols are frequently retrieved in low level JavaScript under the hood 21 | if (typeof key === 'symbol' && wellKnownSymbols.has(key)) { 22 | return result 23 | } 24 | // register and save (observable.prop -> runningReaction) 25 | registerRunningReactionForOperation({ target, key, receiver, type: 'get' }) 26 | // if we are inside a reaction and observable.prop is an object wrap it in an observable too 27 | // this is needed to intercept property access on that object too (dynamic observable tree) 28 | const observableResult = rawToProxy.get(result) 29 | if (hasRunningReaction() && typeof result === 'object' && result !== null) { 30 | if (observableResult) { 31 | return observableResult 32 | } 33 | // do not violate the none-configurable none-writable prop get handler invariant 34 | // fall back to none reactive mode in this case, instead of letting the Proxy throw a TypeError 35 | const descriptor = Reflect.getOwnPropertyDescriptor(target, key) 36 | if ( 37 | !descriptor || 38 | !(descriptor.writable === false && descriptor.configurable === false) 39 | ) { 40 | return observable(result) 41 | } 42 | } 43 | // otherwise return the observable wrapper if it is already created and cached or the raw object 44 | return observableResult || result 45 | } 46 | 47 | function has (target, key) { 48 | const result = Reflect.has(target, key) 49 | // register and save (observable.prop -> runningReaction) 50 | registerRunningReactionForOperation({ target, key, type: 'has' }) 51 | return result 52 | } 53 | 54 | function ownKeys (target) { 55 | registerRunningReactionForOperation({ target, type: 'iterate' }) 56 | return Reflect.ownKeys(target) 57 | } 58 | 59 | // intercept set operations on observables to know when to trigger reactions 60 | function set (target, key, value, receiver) { 61 | // make sure to do not pollute the raw object with observables 62 | if (typeof value === 'object' && value !== null) { 63 | value = proxyToRaw.get(value) || value 64 | } 65 | // save if the object had a descriptor for this key 66 | const hadKey = hasOwnProperty.call(target, key) 67 | // save if the value changed because of this set operation 68 | const oldValue = target[key] 69 | // execute the set operation before running any reaction 70 | const result = Reflect.set(target, key, value, receiver) 71 | // do not queue reactions if the target of the operation is not the raw receiver 72 | // (possible because of prototypal inheritance) 73 | if (target !== proxyToRaw.get(receiver)) { 74 | return result 75 | } 76 | // queue a reaction if it's a new property or its value changed 77 | if (!hadKey) { 78 | queueReactionsForOperation({ target, key, value, receiver, type: 'add' }) 79 | } else if (value !== oldValue) { 80 | queueReactionsForOperation({ 81 | target, 82 | key, 83 | value, 84 | oldValue, 85 | receiver, 86 | type: 'set' 87 | }) 88 | } 89 | return result 90 | } 91 | 92 | function deleteProperty (target, key) { 93 | // save if the object had the key 94 | const hadKey = hasOwnProperty.call(target, key) 95 | const oldValue = target[key] 96 | // execute the delete operation before running any reaction 97 | const result = Reflect.deleteProperty(target, key) 98 | // only queue reactions for delete operations which resulted in an actual change 99 | if (hadKey) { 100 | queueReactionsForOperation({ target, key, oldValue, type: 'delete' }) 101 | } 102 | return result 103 | } 104 | 105 | export default { get, has, ownKeys, set, deleteProperty } 106 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { observe, unobserve } from './observer' 2 | export { observable, isObservable, raw } from './observable' 3 | -------------------------------------------------------------------------------- /src/internals.js: -------------------------------------------------------------------------------- 1 | export const proxyToRaw = new WeakMap() 2 | export const rawToProxy = new WeakMap() 3 | -------------------------------------------------------------------------------- /src/observable.js: -------------------------------------------------------------------------------- 1 | import { proxyToRaw, rawToProxy } from './internals' 2 | import { storeObservable } from './store' 3 | import * as builtIns from './builtIns' 4 | import baseHandlers from './handlers' 5 | 6 | export function observable (obj = {}) { 7 | // if it is already an observable or it should not be wrapped, return it 8 | if (proxyToRaw.has(obj) || !builtIns.shouldInstrument(obj)) { 9 | return obj 10 | } 11 | // if it already has a cached observable wrapper, return it 12 | // otherwise create a new observable 13 | return rawToProxy.get(obj) || createObservable(obj) 14 | } 15 | 16 | function createObservable (obj) { 17 | // if it is a complex built-in object or a normal object, wrap it 18 | const handlers = builtIns.getHandlers(obj) || baseHandlers 19 | const observable = new Proxy(obj, handlers) 20 | // save these to switch between the raw object and the wrapped object with ease later 21 | rawToProxy.set(obj, observable) 22 | proxyToRaw.set(observable, obj) 23 | // init basic data structures to save and cleanup later (observable.prop -> reaction) connections 24 | storeObservable(obj) 25 | return observable 26 | } 27 | 28 | export function isObservable (obj) { 29 | return proxyToRaw.has(obj) 30 | } 31 | 32 | export function raw (obj) { 33 | return proxyToRaw.get(obj) || obj 34 | } 35 | -------------------------------------------------------------------------------- /src/observer.js: -------------------------------------------------------------------------------- 1 | import { runAsReaction } from './reactionRunner' 2 | import { releaseReaction } from './store' 3 | 4 | const IS_REACTION = Symbol('is reaction') 5 | 6 | export function observe (fn, options = {}) { 7 | // wrap the passed function in a reaction, if it is not already one 8 | const reaction = fn[IS_REACTION] 9 | ? fn 10 | : function reaction () { 11 | return runAsReaction(reaction, fn, this, arguments) 12 | } 13 | // save the scheduler and debugger on the reaction 14 | reaction.scheduler = options.scheduler 15 | reaction.debugger = options.debugger 16 | // save the fact that this is a reaction 17 | reaction[IS_REACTION] = true 18 | // run the reaction once if it is not a lazy one 19 | if (!options.lazy) { 20 | reaction() 21 | } 22 | return reaction 23 | } 24 | 25 | export function unobserve (reaction) { 26 | // do nothing, if the reaction is already unobserved 27 | if (!reaction.unobserved) { 28 | // indicate that the reaction should not be triggered any more 29 | reaction.unobserved = true 30 | // release (obj -> key -> reaction) connections 31 | releaseReaction(reaction) 32 | } 33 | // unschedule the reaction, if it is scheduled 34 | if (typeof reaction.scheduler === 'object') { 35 | reaction.scheduler.delete(reaction) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/reactionRunner.js: -------------------------------------------------------------------------------- 1 | import { 2 | registerReactionForOperation, 3 | getReactionsForOperation, 4 | releaseReaction 5 | } from './store' 6 | 7 | // reactions can call each other and form a call stack 8 | const reactionStack = [] 9 | let isDebugging = false 10 | 11 | export function runAsReaction (reaction, fn, context, args) { 12 | // do not build reactive relations, if the reaction is unobserved 13 | if (reaction.unobserved) { 14 | return Reflect.apply(fn, context, args) 15 | } 16 | 17 | // only run the reaction if it is not already in the reaction stack 18 | // TODO: improve this to allow explicitly recursive reactions 19 | if (reactionStack.indexOf(reaction) === -1) { 20 | // release the (obj -> key -> reactions) connections 21 | // and reset the cleaner connections 22 | releaseReaction(reaction) 23 | 24 | try { 25 | // set the reaction as the currently running one 26 | // this is required so that we can create (observable.prop -> reaction) pairs in the get trap 27 | reactionStack.push(reaction) 28 | return Reflect.apply(fn, context, args) 29 | } finally { 30 | // always remove the currently running flag from the reaction when it stops execution 31 | reactionStack.pop() 32 | } 33 | } 34 | } 35 | 36 | // register the currently running reaction to be queued again on obj.key mutations 37 | export function registerRunningReactionForOperation (operation) { 38 | // get the current reaction from the top of the stack 39 | const runningReaction = reactionStack[reactionStack.length - 1] 40 | if (runningReaction) { 41 | debugOperation(runningReaction, operation) 42 | registerReactionForOperation(runningReaction, operation) 43 | } 44 | } 45 | 46 | export function queueReactionsForOperation (operation) { 47 | // iterate and queue every reaction, which is triggered by obj.key mutation 48 | getReactionsForOperation(operation).forEach(queueReaction, operation) 49 | } 50 | 51 | function queueReaction (reaction) { 52 | debugOperation(reaction, this) 53 | // queue the reaction for later execution or run it immediately 54 | if (typeof reaction.scheduler === 'function') { 55 | reaction.scheduler(reaction) 56 | } else if (typeof reaction.scheduler === 'object') { 57 | reaction.scheduler.add(reaction) 58 | } else { 59 | reaction() 60 | } 61 | } 62 | 63 | function debugOperation (reaction, operation) { 64 | if (reaction.debugger && !isDebugging) { 65 | try { 66 | isDebugging = true 67 | reaction.debugger(operation) 68 | } finally { 69 | isDebugging = false 70 | } 71 | } 72 | } 73 | 74 | export function hasRunningReaction () { 75 | return reactionStack.length > 0 76 | } 77 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | const connectionStore = new WeakMap() 2 | const ITERATION_KEY = Symbol('iteration key') 3 | 4 | export function storeObservable (obj) { 5 | // this will be used to save (obj.key -> reaction) connections later 6 | connectionStore.set(obj, new Map()) 7 | } 8 | 9 | export function registerReactionForOperation (reaction, { target, key, type }) { 10 | if (type === 'iterate') { 11 | key = ITERATION_KEY 12 | } 13 | 14 | const reactionsForObj = connectionStore.get(target) 15 | let reactionsForKey = reactionsForObj.get(key) 16 | if (!reactionsForKey) { 17 | reactionsForKey = new Set() 18 | reactionsForObj.set(key, reactionsForKey) 19 | } 20 | // save the fact that the key is used by the reaction during its current run 21 | if (!reactionsForKey.has(reaction)) { 22 | reactionsForKey.add(reaction) 23 | reaction.cleaners.push(reactionsForKey) 24 | } 25 | } 26 | 27 | export function getReactionsForOperation ({ target, key, type }) { 28 | const reactionsForTarget = connectionStore.get(target) 29 | const reactionsForKey = new Set() 30 | 31 | if (type === 'clear') { 32 | reactionsForTarget.forEach((_, key) => { 33 | addReactionsForKey(reactionsForKey, reactionsForTarget, key) 34 | }) 35 | } else { 36 | addReactionsForKey(reactionsForKey, reactionsForTarget, key) 37 | } 38 | 39 | if (type === 'add' || type === 'delete' || type === 'clear') { 40 | const iterationKey = Array.isArray(target) ? 'length' : ITERATION_KEY 41 | addReactionsForKey(reactionsForKey, reactionsForTarget, iterationKey) 42 | } 43 | 44 | return reactionsForKey 45 | } 46 | 47 | function addReactionsForKey (reactionsForKey, reactionsForTarget, key) { 48 | const reactions = reactionsForTarget.get(key) 49 | reactions && reactions.forEach(reactionsForKey.add, reactionsForKey) 50 | } 51 | 52 | export function releaseReaction (reaction) { 53 | if (reaction.cleaners) { 54 | reaction.cleaners.forEach(releaseReactionKeyConnection, reaction) 55 | } 56 | reaction.cleaners = [] 57 | } 58 | 59 | function releaseReactionKeyConnection (reactionsForKey) { 60 | reactionsForKey.delete(this) 61 | } 62 | -------------------------------------------------------------------------------- /tests/builtIns/Map.test.js: -------------------------------------------------------------------------------- 1 | /* eslint no-unused-expressions: 0, no-unused-vars: 0 */ 2 | 3 | import { expect } from 'chai' 4 | import { observable, isObservable, observe, raw } from '@nx-js/observer-util' 5 | import { spy } from '../utils' 6 | 7 | describe('Map', () => { 8 | it('should be a proper JS Map', () => { 9 | const map = observable(new Map()) 10 | expect(map).to.be.instanceOf(Map) 11 | expect(raw(map)).to.be.instanceOf(Map) 12 | }) 13 | 14 | it('should observe mutations', () => { 15 | let dummy 16 | const map = observable(new Map()) 17 | observe(() => (dummy = map.get('key'))) 18 | 19 | expect(dummy).to.equal(undefined) 20 | map.set('key', 'value') 21 | expect(dummy).to.equal('value') 22 | map.set('key', 'value2') 23 | expect(dummy).to.equal('value2') 24 | map.delete('key') 25 | expect(dummy).to.equal(undefined) 26 | }) 27 | 28 | it('should observe size mutations', () => { 29 | let dummy 30 | const map = observable(new Map()) 31 | observe(() => (dummy = map.size)) 32 | 33 | expect(dummy).to.equal(0) 34 | map.set('key1', 'value') 35 | map.set('key2', 'value2') 36 | expect(dummy).to.equal(2) 37 | map.delete('key1') 38 | expect(dummy).to.equal(1) 39 | map.clear() 40 | expect(dummy).to.equal(0) 41 | }) 42 | 43 | it('should observe for of iteration', () => { 44 | let dummy 45 | const map = observable(new Map()) 46 | observe(() => { 47 | dummy = 0 48 | // eslint-disable-next-line no-unused-vars 49 | for (let [key, num] of map) { 50 | dummy += num 51 | } 52 | }) 53 | 54 | expect(dummy).to.equal(0) 55 | map.set('key0', 3) 56 | expect(dummy).to.equal(3) 57 | map.set('key1', 2) 58 | expect(dummy).to.equal(5) 59 | map.delete('key0') 60 | expect(dummy).to.equal(2) 61 | map.clear() 62 | expect(dummy).to.equal(0) 63 | }) 64 | 65 | it('should observe forEach iteration', () => { 66 | let dummy 67 | const map = observable(new Map()) 68 | observe(() => { 69 | dummy = 0 70 | map.forEach(num => (dummy += num)) 71 | }) 72 | 73 | expect(dummy).to.equal(0) 74 | map.set('key0', 3) 75 | expect(dummy).to.equal(3) 76 | map.set('key1', 2) 77 | expect(dummy).to.equal(5) 78 | map.delete('key0') 79 | expect(dummy).to.equal(2) 80 | map.clear() 81 | expect(dummy).to.equal(0) 82 | }) 83 | 84 | it('should observe keys iteration', () => { 85 | let dummy 86 | const map = observable(new Map()) 87 | observe(() => { 88 | dummy = 0 89 | for (let key of map.keys()) { 90 | dummy += key 91 | } 92 | }) 93 | 94 | expect(dummy).to.equal(0) 95 | map.set(3, 3) 96 | expect(dummy).to.equal(3) 97 | map.set(2, 2) 98 | expect(dummy).to.equal(5) 99 | map.delete(3) 100 | expect(dummy).to.equal(2) 101 | map.clear() 102 | expect(dummy).to.equal(0) 103 | }) 104 | 105 | it('should observe values iteration', () => { 106 | let dummy 107 | const map = observable(new Map()) 108 | observe(() => { 109 | dummy = 0 110 | for (let num of map.values()) { 111 | dummy += num 112 | } 113 | }) 114 | 115 | expect(dummy).to.equal(0) 116 | map.set('key0', 3) 117 | expect(dummy).to.equal(3) 118 | map.set('key1', 2) 119 | expect(dummy).to.equal(5) 120 | map.delete('key0') 121 | expect(dummy).to.equal(2) 122 | map.clear() 123 | expect(dummy).to.equal(0) 124 | }) 125 | 126 | it('should observe entries iteration', () => { 127 | let dummy 128 | const map = observable(new Map()) 129 | observe(() => { 130 | dummy = 0 131 | // eslint-disable-next-line no-unused-vars 132 | for (let [key, num] of map.entries()) { 133 | dummy += num 134 | } 135 | }) 136 | 137 | expect(dummy).to.equal(0) 138 | map.set('key0', 3) 139 | expect(dummy).to.equal(3) 140 | map.set('key1', 2) 141 | expect(dummy).to.equal(5) 142 | map.delete('key0') 143 | expect(dummy).to.equal(2) 144 | map.clear() 145 | expect(dummy).to.equal(0) 146 | }) 147 | 148 | it('should be triggered by clearing', () => { 149 | let dummy 150 | const map = observable(new Map()) 151 | observe(() => (dummy = map.get('key'))) 152 | 153 | expect(dummy).to.equal(undefined) 154 | map.set('key', 3) 155 | expect(dummy).to.equal(3) 156 | map.clear() 157 | expect(dummy).to.equal(undefined) 158 | }) 159 | 160 | it('should not observe custom property mutations', () => { 161 | let dummy 162 | const map = observable(new Map()) 163 | observe(() => (dummy = map.customProp)) 164 | 165 | expect(dummy).to.equal(undefined) 166 | map.customProp = 'Hello World' 167 | expect(dummy).to.equal(undefined) 168 | }) 169 | 170 | it('should not observe non value changing mutations', () => { 171 | let dummy 172 | const map = observable(new Map()) 173 | const mapSpy = spy(() => (dummy = map.get('key'))) 174 | observe(mapSpy) 175 | 176 | expect(dummy).to.equal(undefined) 177 | expect(mapSpy.callCount).to.equal(1) 178 | map.set('key', 'value') 179 | expect(dummy).to.equal('value') 180 | expect(mapSpy.callCount).to.equal(2) 181 | map.set('key', 'value') 182 | expect(dummy).to.equal('value') 183 | expect(mapSpy.callCount).to.equal(2) 184 | map.delete('key') 185 | expect(dummy).to.equal(undefined) 186 | expect(mapSpy.callCount).to.equal(3) 187 | map.delete('key') 188 | expect(dummy).to.equal(undefined) 189 | expect(mapSpy.callCount).to.equal(3) 190 | map.clear() 191 | expect(dummy).to.equal(undefined) 192 | expect(mapSpy.callCount).to.equal(3) 193 | }) 194 | 195 | it('should not observe raw data', () => { 196 | let dummy 197 | const map = observable(new Map()) 198 | observe(() => (dummy = raw(map).get('key'))) 199 | 200 | expect(dummy).to.equal(undefined) 201 | map.set('key', 'Hello') 202 | expect(dummy).to.equal(undefined) 203 | map.delete('key') 204 | expect(dummy).to.equal(undefined) 205 | }) 206 | 207 | it('should not observe raw iterations', () => { 208 | let dummy = 0 209 | const map = observable(new Map()) 210 | observe(() => { 211 | dummy = 0 212 | // eslint-disable-next-line no-unused-vars 213 | for (let [key, num] of raw(map).entries()) { 214 | dummy += num 215 | } 216 | for (let key of raw(map).keys()) { 217 | dummy += raw(map).get(key) 218 | } 219 | for (let num of raw(map).values()) { 220 | dummy += num 221 | } 222 | raw(map).forEach((num, key) => { 223 | dummy += num 224 | }) 225 | // eslint-disable-next-line no-unused-vars 226 | for (let [key, num] of raw(map)) { 227 | dummy += num 228 | } 229 | }) 230 | 231 | expect(dummy).to.equal(0) 232 | map.set('key1', 2) 233 | map.set('key2', 3) 234 | expect(dummy).to.equal(0) 235 | map.delete('key1') 236 | expect(dummy).to.equal(0) 237 | }) 238 | 239 | it('should not be triggered by raw mutations', () => { 240 | let dummy 241 | const map = observable(new Map()) 242 | observe(() => (dummy = map.get('key'))) 243 | 244 | expect(dummy).to.equal(undefined) 245 | raw(map).set('key', 'Hello') 246 | expect(dummy).to.equal(undefined) 247 | dummy = 'Thing' 248 | raw(map).delete('key') 249 | expect(dummy).to.equal('Thing') 250 | raw(map).clear() 251 | expect(dummy).to.equal('Thing') 252 | }) 253 | 254 | it('should not observe raw size mutations', () => { 255 | let dummy 256 | const map = observable(new Map()) 257 | observe(() => (dummy = raw(map).size)) 258 | 259 | expect(dummy).to.equal(0) 260 | map.set('key', 'value') 261 | expect(dummy).to.equal(0) 262 | }) 263 | 264 | it('should not be triggered by raw size mutations', () => { 265 | let dummy 266 | const map = observable(new Map()) 267 | observe(() => (dummy = map.size)) 268 | 269 | expect(dummy).to.equal(0) 270 | raw(map).set('key', 'value') 271 | expect(dummy).to.equal(0) 272 | }) 273 | 274 | it('should support objects as key', () => { 275 | let dummy 276 | const key = {} 277 | const map = observable(new Map()) 278 | const mapSpy = spy(() => (dummy = map.get(key))) 279 | observe(mapSpy) 280 | 281 | expect(dummy).to.equal(undefined) 282 | expect(mapSpy.callCount).to.equal(1) 283 | 284 | map.set(key, 1) 285 | expect(dummy).to.equal(1) 286 | expect(mapSpy.callCount).to.equal(2) 287 | 288 | map.set({}, 2) 289 | expect(dummy).to.equal(1) 290 | expect(mapSpy.callCount).to.equal(2) 291 | }) 292 | 293 | it('should wrap object values with observables when requested from a reaction', () => { 294 | const map = observable(new Map()) 295 | map.set('key', {}) 296 | map.set('key2', {}) 297 | 298 | expect(isObservable(map.get('key'))).to.be.false 299 | expect(isObservable(map.get('key2'))).to.be.false 300 | observe(() => expect(isObservable(map.get('key'))).to.be.true) 301 | expect(isObservable(map.get('key'))).to.be.true 302 | expect(isObservable(map.get('key2'))).to.be.false 303 | }) 304 | 305 | it('should wrap object values with observables when iterated from a reaction', () => { 306 | const map = observable(new Map()) 307 | map.set('key', {}) 308 | 309 | map.forEach(value => expect(isObservable(value)).to.be.false) 310 | for (let [key, value] of map) { 311 | expect(isObservable(value)).to.be.false 312 | } 313 | for (let [key, value] of map.entries()) { 314 | expect(isObservable(value)).to.be.false 315 | } 316 | for (let value of map.values()) { 317 | expect(isObservable(value)).to.be.false 318 | } 319 | 320 | observe(() => { 321 | map.forEach(value => expect(isObservable(value)).to.be.true) 322 | for (let [key, value] of map) { 323 | expect(isObservable(value)).to.be.true 324 | } 325 | for (let [key, value] of map.entries()) { 326 | expect(isObservable(value)).to.be.true 327 | } 328 | for (let value of map.values()) { 329 | expect(isObservable(value)).to.be.true 330 | } 331 | }) 332 | 333 | map.forEach(value => expect(isObservable(value)).to.be.true) 334 | for (let [key, value] of map) { 335 | expect(isObservable(value)).to.be.true 336 | } 337 | for (let [key, value] of map.entries()) { 338 | expect(isObservable(value)).to.be.true 339 | } 340 | for (let value of map.values()) { 341 | expect(isObservable(value)).to.be.true 342 | } 343 | }) 344 | }) 345 | -------------------------------------------------------------------------------- /tests/builtIns/Set.test.js: -------------------------------------------------------------------------------- 1 | /* eslint no-unused-expressions: 0, no-unused-vars: 0 */ 2 | 3 | import { expect } from 'chai' 4 | import { observable, isObservable, observe, raw } from '@nx-js/observer-util' 5 | import { spy } from '../utils' 6 | 7 | describe('Set', () => { 8 | it('should be a proper JS Set', () => { 9 | const set = observable(new Set()) 10 | expect(set).to.be.instanceOf(Set) 11 | expect(raw(set)).to.be.instanceOf(Set) 12 | }) 13 | 14 | it('should observe mutations', () => { 15 | let dummy 16 | const set = observable(new Set()) 17 | observe(() => (dummy = set.has('value'))) 18 | 19 | expect(dummy).to.equal(false) 20 | set.add('value') 21 | expect(dummy).to.equal(true) 22 | set.delete('value') 23 | expect(dummy).to.equal(false) 24 | }) 25 | 26 | it('should observe for of iteration', () => { 27 | let dummy 28 | const set = observable(new Set()) 29 | observe(() => { 30 | dummy = 0 31 | for (let num of set) { 32 | dummy += num 33 | } 34 | }) 35 | 36 | expect(dummy).to.equal(0) 37 | set.add(2) 38 | set.add(1) 39 | expect(dummy).to.equal(3) 40 | set.delete(2) 41 | expect(dummy).to.equal(1) 42 | set.clear() 43 | expect(dummy).to.equal(0) 44 | }) 45 | 46 | it('should observe forEach iteration', () => { 47 | let dummy 48 | const set = observable(new Set()) 49 | observe(() => { 50 | dummy = 0 51 | set.forEach(num => (dummy += num)) 52 | }) 53 | 54 | expect(dummy).to.equal(0) 55 | set.add(2) 56 | set.add(1) 57 | expect(dummy).to.equal(3) 58 | set.delete(2) 59 | expect(dummy).to.equal(1) 60 | set.clear() 61 | expect(dummy).to.equal(0) 62 | }) 63 | 64 | it('should observe values iteration', () => { 65 | let dummy 66 | const set = observable(new Set()) 67 | observe(() => { 68 | dummy = 0 69 | for (let num of set.values()) { 70 | dummy += num 71 | } 72 | }) 73 | 74 | expect(dummy).to.equal(0) 75 | set.add(2) 76 | set.add(1) 77 | expect(dummy).to.equal(3) 78 | set.delete(2) 79 | expect(dummy).to.equal(1) 80 | set.clear() 81 | expect(dummy).to.equal(0) 82 | }) 83 | 84 | it('should observe keys iteration', () => { 85 | let dummy 86 | const set = observable(new Set()) 87 | observe(() => { 88 | dummy = 0 89 | for (let num of set.keys()) { 90 | dummy += num 91 | } 92 | }) 93 | 94 | expect(dummy).to.equal(0) 95 | set.add(2) 96 | set.add(1) 97 | expect(dummy).to.equal(3) 98 | set.delete(2) 99 | expect(dummy).to.equal(1) 100 | set.clear() 101 | expect(dummy).to.equal(0) 102 | }) 103 | 104 | it('should observe entries iteration', () => { 105 | let dummy 106 | const set = observable(new Set()) 107 | observe(() => { 108 | dummy = 0 109 | // eslint-disable-next-line no-unused-vars 110 | for (let [key, num] of set.entries()) { 111 | dummy += num 112 | } 113 | }) 114 | 115 | expect(dummy).to.equal(0) 116 | set.add(2) 117 | set.add(1) 118 | expect(dummy).to.equal(3) 119 | set.delete(2) 120 | expect(dummy).to.equal(1) 121 | set.clear() 122 | expect(dummy).to.equal(0) 123 | }) 124 | 125 | it('should be triggered by clearing', () => { 126 | let dummy 127 | const set = observable(new Set()) 128 | observe(() => (dummy = set.has('key'))) 129 | 130 | expect(dummy).to.equal(false) 131 | set.add('key') 132 | expect(dummy).to.equal(true) 133 | set.clear() 134 | expect(dummy).to.equal(false) 135 | }) 136 | 137 | it('should not observe custom property mutations', () => { 138 | let dummy 139 | const set = observable(new Set()) 140 | observe(() => (dummy = set.customProp)) 141 | 142 | expect(dummy).to.equal(undefined) 143 | set.customProp = 'Hello World' 144 | expect(dummy).to.equal(undefined) 145 | }) 146 | 147 | it('should observe size mutations', () => { 148 | let dummy 149 | const set = observable(new Set()) 150 | observe(() => (dummy = set.size)) 151 | 152 | expect(dummy).to.equal(0) 153 | set.add('value') 154 | set.add('value2') 155 | expect(dummy).to.equal(2) 156 | set.delete('value') 157 | expect(dummy).to.equal(1) 158 | set.clear() 159 | expect(dummy).to.equal(0) 160 | }) 161 | 162 | it('should not observe non value changing mutations', () => { 163 | let dummy 164 | const set = observable(new Set()) 165 | const setSpy = spy(() => (dummy = set.has('value'))) 166 | observe(setSpy) 167 | 168 | expect(dummy).to.equal(false) 169 | expect(setSpy.callCount).to.equal(1) 170 | set.add('value') 171 | expect(dummy).to.equal(true) 172 | expect(setSpy.callCount).to.equal(2) 173 | set.add('value') 174 | expect(dummy).to.equal(true) 175 | expect(setSpy.callCount).to.equal(2) 176 | set.delete('value') 177 | expect(dummy).to.equal(false) 178 | expect(setSpy.callCount).to.equal(3) 179 | set.delete('value') 180 | expect(dummy).to.equal(false) 181 | expect(setSpy.callCount).to.equal(3) 182 | set.clear() 183 | expect(dummy).to.equal(false) 184 | expect(setSpy.callCount).to.equal(3) 185 | }) 186 | 187 | it('should not observe raw data', () => { 188 | let dummy 189 | const set = observable(new Set()) 190 | observe(() => (dummy = raw(set).has('value'))) 191 | 192 | expect(dummy).to.equal(false) 193 | set.add('value') 194 | expect(dummy).to.equal(false) 195 | }) 196 | 197 | it('should not observe raw iterations', () => { 198 | let dummy = 0 199 | const set = observable(new Set()) 200 | observe(() => { 201 | dummy = 0 202 | for (let [num] of raw(set).entries()) { 203 | dummy += num 204 | } 205 | for (let num of raw(set).keys()) { 206 | dummy += num 207 | } 208 | for (let num of raw(set).values()) { 209 | dummy += num 210 | } 211 | raw(set).forEach(num => { 212 | dummy += num 213 | }) 214 | for (let num of raw(set)) { 215 | dummy += num 216 | } 217 | }) 218 | 219 | expect(dummy).to.equal(0) 220 | set.add(2) 221 | set.add(3) 222 | expect(dummy).to.equal(0) 223 | set.delete(2) 224 | expect(dummy).to.equal(0) 225 | }) 226 | 227 | it('should not be triggered by raw mutations', () => { 228 | let dummy 229 | const set = observable(new Set()) 230 | observe(() => (dummy = set.has('value'))) 231 | 232 | expect(dummy).to.equal(false) 233 | raw(set).add('value') 234 | expect(dummy).to.equal(false) 235 | dummy = true 236 | raw(set).delete('value') 237 | expect(dummy).to.equal(true) 238 | raw(set).clear() 239 | expect(dummy).to.equal(true) 240 | }) 241 | 242 | it('should not observe raw size mutations', () => { 243 | let dummy 244 | const set = observable(new Set()) 245 | observe(() => (dummy = raw(set).size)) 246 | 247 | expect(dummy).to.equal(0) 248 | set.add('value') 249 | expect(dummy).to.equal(0) 250 | }) 251 | 252 | it('should not be triggered by raw size mutations', () => { 253 | let dummy 254 | const set = observable(new Set()) 255 | observe(() => (dummy = set.size)) 256 | 257 | expect(dummy).to.equal(0) 258 | raw(set).add('value') 259 | expect(dummy).to.equal(0) 260 | }) 261 | 262 | it('should support objects as key', () => { 263 | let dummy 264 | const key = {} 265 | const set = observable(new Set()) 266 | const setSpy = spy(() => (dummy = set.has(key))) 267 | observe(setSpy) 268 | 269 | expect(dummy).to.equal(false) 270 | expect(setSpy.callCount).to.equal(1) 271 | 272 | set.add({}) 273 | expect(dummy).to.equal(false) 274 | expect(setSpy.callCount).to.equal(1) 275 | 276 | set.add(key) 277 | expect(dummy).to.equal(true) 278 | expect(setSpy.callCount).to.equal(2) 279 | }) 280 | 281 | it('should wrap object values with observables when iterated from a reaction', () => { 282 | const set = observable(new Set()) 283 | set.add({}) 284 | 285 | set.forEach(value => expect(isObservable(value)).to.be.false) 286 | for (let value of set) { 287 | expect(isObservable(value)).to.be.false 288 | } 289 | for (let [_, value] of set.entries()) { 290 | expect(isObservable(value)).to.be.false 291 | } 292 | for (let value of set.values()) { 293 | expect(isObservable(value)).to.be.false 294 | } 295 | 296 | observe(() => { 297 | set.forEach(value => expect(isObservable(value)).to.be.true) 298 | for (let value of set) { 299 | expect(isObservable(value)).to.be.true 300 | } 301 | for (let [_, value] of set.entries()) { 302 | expect(isObservable(value)).to.be.true 303 | } 304 | for (let value of set.values()) { 305 | expect(isObservable(value)).to.be.true 306 | } 307 | }) 308 | 309 | set.forEach(value => expect(isObservable(value)).to.be.true) 310 | for (let value of set) { 311 | expect(isObservable(value)).to.be.true 312 | } 313 | for (let [_, value] of set.entries()) { 314 | expect(isObservable(value)).to.be.true 315 | } 316 | for (let value of set.values()) { 317 | expect(isObservable(value)).to.be.true 318 | } 319 | }) 320 | }) 321 | -------------------------------------------------------------------------------- /tests/builtIns/WeakMap.test.js: -------------------------------------------------------------------------------- 1 | /* eslint no-unused-expressions: 0, no-unused-vars: 0 */ 2 | 3 | import { expect } from 'chai' 4 | import { observable, isObservable, observe, raw } from '@nx-js/observer-util' 5 | import { spy } from '../utils' 6 | 7 | describe('WeakMap', () => { 8 | it('should be a proper JS WeakMap', () => { 9 | const map = observable(new WeakMap()) 10 | expect(map).to.be.instanceOf(WeakMap) 11 | expect(raw(map)).to.be.instanceOf(WeakMap) 12 | }) 13 | 14 | it('should observe mutations', () => { 15 | let dummy 16 | const key = {} 17 | const map = observable(new WeakMap()) 18 | observe(() => (dummy = map.get(key))) 19 | 20 | expect(dummy).to.equal(undefined) 21 | map.set(key, 'value') 22 | expect(dummy).to.equal('value') 23 | map.set(key, 'value2') 24 | expect(dummy).to.equal('value2') 25 | map.delete(key) 26 | expect(dummy).to.equal(undefined) 27 | }) 28 | 29 | it('should not observe custom property mutations', () => { 30 | let dummy 31 | const map = observable(new WeakMap()) 32 | observe(() => (dummy = map.customProp)) 33 | 34 | expect(dummy).to.equal(undefined) 35 | map.customProp = 'Hello World' 36 | expect(dummy).to.equal(undefined) 37 | }) 38 | 39 | it('should not observe non value changing mutations', () => { 40 | let dummy 41 | const key = {} 42 | const map = observable(new WeakMap()) 43 | const mapSpy = spy(() => (dummy = map.get(key))) 44 | observe(mapSpy) 45 | 46 | expect(dummy).to.equal(undefined) 47 | expect(mapSpy.callCount).to.equal(1) 48 | map.set(key, 'value') 49 | expect(dummy).to.equal('value') 50 | expect(mapSpy.callCount).to.equal(2) 51 | map.set(key, 'value') 52 | expect(dummy).to.equal('value') 53 | expect(mapSpy.callCount).to.equal(2) 54 | map.delete(key) 55 | expect(dummy).to.equal(undefined) 56 | expect(mapSpy.callCount).to.equal(3) 57 | map.delete(key) 58 | expect(dummy).to.equal(undefined) 59 | expect(mapSpy.callCount).to.equal(3) 60 | }) 61 | 62 | it('should not observe raw data', () => { 63 | const key = {} 64 | let dummy 65 | const map = observable(new WeakMap()) 66 | observe(() => (dummy = raw(map).get(key))) 67 | 68 | expect(dummy).to.equal(undefined) 69 | map.set(key, 'Hello') 70 | expect(dummy).to.equal(undefined) 71 | map.delete(key) 72 | expect(dummy).to.equal(undefined) 73 | }) 74 | 75 | it('should not be triggered by raw mutations', () => { 76 | const key = {} 77 | let dummy 78 | const map = observable(new WeakMap()) 79 | observe(() => (dummy = map.get(key))) 80 | 81 | expect(dummy).to.equal(undefined) 82 | raw(map).set(key, 'Hello') 83 | expect(dummy).to.equal(undefined) 84 | raw(map).delete(key) 85 | expect(dummy).to.equal(undefined) 86 | }) 87 | 88 | it('should wrap object values with observables when requested from a reaction', () => { 89 | const key = {} 90 | const key2 = {} 91 | const map = observable(new Map()) 92 | map.set(key, {}) 93 | map.set(key2, {}) 94 | 95 | expect(isObservable(map.get(key))).to.be.false 96 | expect(isObservable(map.get(key2))).to.be.false 97 | observe(() => expect(isObservable(map.get(key))).to.be.true) 98 | expect(isObservable(map.get(key))).to.be.true 99 | expect(isObservable(map.get(key2))).to.be.false 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /tests/builtIns/WeakSet.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { observable, observe, raw } from '@nx-js/observer-util' 3 | import { spy } from '../utils' 4 | 5 | describe('WeakSet', () => { 6 | it('should be a proper JS WeakSet', () => { 7 | const set = observable(new WeakSet()) 8 | expect(set).to.be.instanceOf(WeakSet) 9 | expect(raw(set)).to.be.instanceOf(WeakSet) 10 | }) 11 | 12 | it('should observe mutations', () => { 13 | let dummy 14 | const value = {} 15 | const set = observable(new WeakSet()) 16 | observe(() => (dummy = set.has(value))) 17 | 18 | expect(dummy).to.equal(false) 19 | set.add(value) 20 | expect(dummy).to.equal(true) 21 | set.delete(value) 22 | expect(dummy).to.equal(false) 23 | }) 24 | 25 | it('should not observe custom property mutations', () => { 26 | let dummy 27 | const set = observable(new WeakSet()) 28 | observe(() => (dummy = set.customProp)) 29 | 30 | expect(dummy).to.equal(undefined) 31 | set.customProp = 'Hello World' 32 | expect(dummy).to.equal(undefined) 33 | }) 34 | 35 | it('should not observe non value changing mutations', () => { 36 | let dummy 37 | const value = {} 38 | const set = observable(new WeakSet()) 39 | const setSpy = spy(() => (dummy = set.has(value))) 40 | observe(setSpy) 41 | 42 | expect(dummy).to.equal(false) 43 | expect(setSpy.callCount).to.equal(1) 44 | set.add(value) 45 | expect(dummy).to.equal(true) 46 | expect(setSpy.callCount).to.equal(2) 47 | set.add(value) 48 | expect(dummy).to.equal(true) 49 | expect(setSpy.callCount).to.equal(2) 50 | set.delete(value) 51 | expect(dummy).to.equal(false) 52 | expect(setSpy.callCount).to.equal(3) 53 | set.delete(value) 54 | expect(dummy).to.equal(false) 55 | expect(setSpy.callCount).to.equal(3) 56 | }) 57 | 58 | it('should not observe raw data', () => { 59 | const value = {} 60 | let dummy 61 | const set = observable(new WeakSet()) 62 | observe(() => (dummy = raw(set).has(value))) 63 | 64 | expect(dummy).to.equal(false) 65 | set.add(value) 66 | expect(dummy).to.equal(false) 67 | }) 68 | 69 | it('should not be triggered by raw mutations', () => { 70 | const value = {} 71 | let dummy 72 | const set = observable(new WeakSet()) 73 | observe(() => (dummy = set.has(value))) 74 | 75 | expect(dummy).to.equal(false) 76 | raw(set).add(value) 77 | expect(dummy).to.equal(false) 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /tests/builtIns/builtIns.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { observable, isObservable } from '@nx-js/observer-util' 3 | 4 | describe('none observable built-ins', () => { 5 | it('objects with global constructors should not be converted to observables', () => { 6 | window.MyClass = class MyClass {} 7 | const obj = new window.MyClass() 8 | const obs = observable(obj) 9 | expect(obs).to.equal(obj) 10 | expect(isObservable(obs)).to.equal(false) 11 | }) 12 | 13 | it('objects with local constructors should be converted to observables', () => { 14 | class MyClass {} 15 | const obj = new MyClass() 16 | const obs = observable(obj) 17 | expect(obs).to.not.equal(obj) 18 | expect(isObservable(obs)).to.equal(true) 19 | }) 20 | 21 | it('global objects should be converted to observables', () => { 22 | window.obj = {} 23 | const obs = observable(window.obj) 24 | expect(obs).to.not.equal(window.obj) 25 | expect(isObservable(obs)).to.equal(true) 26 | }) 27 | 28 | it('Date should not be converted to observable', () => { 29 | const date = new Date() 30 | const obsDate = observable(date) 31 | expect(obsDate).to.equal(date) 32 | expect(isObservable(obsDate)).to.equal(false) 33 | }) 34 | 35 | it('RegExp should not be converted to observable', () => { 36 | const regex = new RegExp() 37 | const obsRegex = observable(regex) 38 | expect(obsRegex).to.equal(regex) 39 | expect(isObservable(obsRegex)).to.equal(false) 40 | }) 41 | 42 | it('Node should not be converted to observable', () => { 43 | const node = document 44 | const obsNode = observable(node) 45 | expect(obsNode).to.equal(node) 46 | expect(isObservable(obsNode)).to.equal(false) 47 | }) 48 | 49 | it('WebAudio should not be converted to observable', () => { 50 | const audio = new AudioContext() 51 | const obsAudio = observable(audio) 52 | expect(obsAudio).to.equal(audio) 53 | expect(isObservable(obsAudio)).to.equal(false) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /tests/builtIns/typedArrays.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { observable, isObservable, observe } from '@nx-js/observer-util' 3 | 4 | const TypedArrays = [ 5 | Int8Array, 6 | Uint8Array, 7 | Uint8ClampedArray, 8 | Int16Array, 9 | Uint16Array, 10 | Int32Array, 11 | Uint32Array, 12 | Float32Array, 13 | Float64Array 14 | ] 15 | 16 | describe('typed arrays', () => { 17 | for (const TypedArray of TypedArrays) { 18 | it(`${TypedArray.name} should observe mutations`, () => { 19 | let dummy 20 | const array = observable(new TypedArray(2)) 21 | expect(isObservable(array)).to.equal(true) 22 | 23 | observe(() => (dummy = array[0])) 24 | 25 | expect(dummy).to.equal(0) 26 | array[0] = 12 27 | expect(dummy).to.equal(12) 28 | }) 29 | } 30 | }) 31 | -------------------------------------------------------------------------------- /tests/debug.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { spy } from './utils' 3 | import { observe, observable } from '@nx-js/observer-util' 4 | 5 | describe('debugger', () => { 6 | it('should debug get operations', () => { 7 | let dummy 8 | const rawCounter = { num: 0 } 9 | const counter = observable(rawCounter) 10 | const debugSpy = spy(() => {}) 11 | observe(() => (dummy = counter.num), { 12 | debugger: debugSpy 13 | }) 14 | 15 | expect(dummy).to.equal(0) 16 | expect(debugSpy.callCount).to.equal(1) 17 | expect(debugSpy.lastArgs).to.eql([ 18 | { 19 | type: 'get', 20 | target: rawCounter, 21 | key: 'num', 22 | receiver: counter 23 | } 24 | ]) 25 | }) 26 | 27 | it('should debug has operations', () => { 28 | let dummy 29 | const rawCounter = {} 30 | const counter = observable(rawCounter) 31 | const debugSpy = spy(() => {}) 32 | observe(() => (dummy = 'num' in counter), { 33 | debugger: debugSpy 34 | }) 35 | 36 | expect(dummy).to.equal(false) 37 | expect(debugSpy.callCount).to.equal(1) 38 | expect(debugSpy.lastArgs).to.eql([ 39 | { 40 | type: 'has', 41 | target: rawCounter, 42 | key: 'num' 43 | } 44 | ]) 45 | }) 46 | 47 | it('should debug iteration operations', () => { 48 | let dummy 49 | const rawCounter = { num: 0 } 50 | const counter = observable(rawCounter) 51 | const debugSpy = spy(() => {}) 52 | observe( 53 | () => { 54 | for (const key in counter) { 55 | dummy = key 56 | } 57 | }, 58 | { 59 | debugger: debugSpy 60 | } 61 | ) 62 | 63 | expect(dummy).to.equal('num') 64 | expect(debugSpy.callCount).to.equal(1) 65 | expect(debugSpy.lastArgs).to.eql([ 66 | { 67 | type: 'iterate', 68 | target: rawCounter 69 | } 70 | ]) 71 | }) 72 | 73 | it('should debug add operations', () => { 74 | let dummy 75 | const rawCounter = {} 76 | const counter = observable(rawCounter) 77 | const debugSpy = spy(() => {}) 78 | observe(() => (dummy = counter.num), { 79 | debugger: debugSpy 80 | }) 81 | 82 | expect(dummy).to.equal(undefined) 83 | expect(debugSpy.callCount).to.equal(1) 84 | counter.num = 12 85 | expect(dummy).to.equal(12) 86 | expect(debugSpy.callCount).to.equal(3) 87 | expect(debugSpy.args[1]).to.eql([ 88 | { 89 | type: 'add', 90 | target: rawCounter, 91 | key: 'num', 92 | value: 12, 93 | receiver: counter 94 | } 95 | ]) 96 | }) 97 | 98 | it('should debug set operations', () => { 99 | let dummy 100 | const rawCounter = { num: 0 } 101 | const counter = observable(rawCounter) 102 | const debugSpy = spy(() => {}) 103 | observe(() => (dummy = counter.num), { 104 | debugger: debugSpy 105 | }) 106 | 107 | expect(dummy).to.equal(0) 108 | expect(debugSpy.callCount).to.equal(1) 109 | counter.num = 12 110 | expect(dummy).to.equal(12) 111 | expect(debugSpy.callCount).to.equal(3) 112 | expect(debugSpy.args[1]).to.eql([ 113 | { 114 | type: 'set', 115 | target: rawCounter, 116 | key: 'num', 117 | value: 12, 118 | oldValue: 0, 119 | receiver: counter 120 | } 121 | ]) 122 | }) 123 | 124 | it('should debug delete operations', () => { 125 | let dummy 126 | const rawCounter = { num: 0 } 127 | const counter = observable(rawCounter) 128 | const debugSpy = spy(() => {}) 129 | observe(() => (dummy = counter.num), { 130 | debugger: debugSpy 131 | }) 132 | 133 | expect(dummy).to.equal(0) 134 | expect(debugSpy.callCount).to.equal(1) 135 | delete counter.num 136 | expect(dummy).to.equal(undefined) 137 | expect(debugSpy.callCount).to.equal(3) 138 | expect(debugSpy.args[1]).to.eql([ 139 | { 140 | type: 'delete', 141 | target: rawCounter, 142 | key: 'num', 143 | oldValue: 0 144 | } 145 | ]) 146 | }) 147 | 148 | it('should debug clear operations', () => { 149 | let dummy 150 | const rawMap = new Map() 151 | rawMap.set('key', 'value') 152 | const map = observable(rawMap) 153 | const debugSpy = spy(() => {}) 154 | observe(() => (dummy = map.get('key')), { 155 | debugger: debugSpy 156 | }) 157 | 158 | expect(dummy).to.equal('value') 159 | expect(debugSpy.callCount).to.equal(1) 160 | const oldMap = new Map(rawMap) 161 | map.clear() 162 | expect(dummy).to.equal(undefined) 163 | expect(debugSpy.callCount).to.equal(3) 164 | expect(debugSpy.args[1]).to.eql([ 165 | { 166 | type: 'clear', 167 | target: rawMap, 168 | oldTarget: oldMap 169 | } 170 | ]) 171 | }) 172 | 173 | it('should not cause infinite loops', () => { 174 | let receiverDummy 175 | const rawCounter = { num: 0 } 176 | const counter = observable(rawCounter) 177 | const debugSpy = spy(({ receiver }) => (receiverDummy = receiver.num)) 178 | observe(() => counter.num, { 179 | debugger: debugSpy 180 | }) 181 | 182 | expect(receiverDummy).to.equal(0) 183 | expect(debugSpy.callCount).to.equal(1) 184 | }) 185 | }) 186 | -------------------------------------------------------------------------------- /tests/observable.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { observable, observe, isObservable, raw } from '@nx-js/observer-util' 3 | 4 | describe('observable', () => { 5 | it('should return a new observable when no argument is provided', () => { 6 | const obs = observable() 7 | expect(isObservable(obs)).to.equal(true) 8 | }) 9 | 10 | it('should return an observable wrapping of an object argument', () => { 11 | const obj = { prop: 'value' } 12 | const obs = observable(obj) 13 | expect(obs).to.not.equal(obj) 14 | expect(isObservable(obs)).to.equal(true) 15 | }) 16 | 17 | it('should return the argument if it is already an observable', () => { 18 | const obs1 = observable() 19 | const obs2 = observable(obs1) 20 | expect(obs1).to.equal(obs2) 21 | }) 22 | 23 | it('should return the same observable wrapper when called repeatedly with the same argument', () => { 24 | const obj = { prop: 'value' } 25 | const obs1 = observable(obj) 26 | const obs2 = observable(obj) 27 | expect(obs1).to.equal(obs2) 28 | }) 29 | 30 | it('should not throw on none writable nested objects, should simply not observe them instead', () => { 31 | let dummy 32 | const obj = {} 33 | Object.defineProperty(obj, 'prop', { 34 | value: { num: 12 }, 35 | writable: false, 36 | configurable: false 37 | }) 38 | const obs = observable(obj) 39 | expect(() => observe(() => (dummy = obs.prop.num))).to.not.throw() 40 | expect(dummy).to.eql(12) 41 | obj.prop.num = 13 42 | expect(dummy).to.eql(12) 43 | }) 44 | 45 | it('should never let observables leak into the underlying raw object', () => { 46 | const obj = {} 47 | const obs = observable(obj) 48 | obs.nested1 = {} 49 | obs.nested2 = observable() 50 | expect(isObservable(obj.nested1)).to.equal(false) 51 | expect(isObservable(obj.nested2)).to.equal(false) 52 | expect(isObservable(obs.nested1)).to.equal(false) 53 | expect(isObservable(obs.nested2)).to.equal(true) 54 | }) 55 | }) 56 | 57 | describe('isObservable', () => { 58 | it('should return true if an observable is passed as argument', () => { 59 | const obs = observable() 60 | const isObs = isObservable(obs) 61 | expect(isObs).to.equal(true) 62 | }) 63 | 64 | it('should return false if a non observable is passed as argument', () => { 65 | const obj1 = { prop: 'value' } 66 | const obj2 = new Proxy({}, {}) 67 | const isObs1 = isObservable(obj1) 68 | const isObs2 = isObservable(obj2) 69 | expect(isObs1).to.equal(false) 70 | expect(isObs2).to.equal(false) 71 | }) 72 | 73 | it('should return false if a primitive is passed as argument', () => { 74 | expect(isObservable(12)).to.equal(false) 75 | }) 76 | }) 77 | 78 | describe('raw', () => { 79 | it('should return the raw non-reactive object', () => { 80 | const obj = {} 81 | const obs = observable(obj) 82 | expect(raw(obs)).to.eql(obj) 83 | expect(raw(obj)).to.eql(obj) 84 | }) 85 | 86 | it('should work with plain primitives', () => { 87 | expect(raw(12)).to.eql(12) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /tests/observe.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { spy } from './utils' 3 | import { observe, observable, raw } from '@nx-js/observer-util' 4 | 5 | describe('observe', () => { 6 | it('should run the passed function once (wrapped by a reaction)', () => { 7 | const fnSpy = spy(() => {}) 8 | observe(fnSpy) 9 | expect(fnSpy.callCount).to.equal(1) 10 | }) 11 | 12 | it('should observe basic properties', () => { 13 | let dummy 14 | const counter = observable({ num: 0 }) 15 | observe(() => (dummy = counter.num)) 16 | 17 | expect(dummy).to.equal(0) 18 | counter.num = 7 19 | expect(dummy).to.equal(7) 20 | }) 21 | 22 | it('should observe multiple properties', () => { 23 | let dummy 24 | const counter = observable({ num1: 0, num2: 0 }) 25 | observe(() => (dummy = counter.num1 + counter.num1 + counter.num2)) 26 | 27 | expect(dummy).to.equal(0) 28 | counter.num1 = counter.num2 = 7 29 | expect(dummy).to.equal(21) 30 | }) 31 | 32 | it('should handle multiple reactions', () => { 33 | let dummy1, dummy2 34 | const counter = observable({ num: 0 }) 35 | observe(() => (dummy1 = counter.num)) 36 | observe(() => (dummy2 = counter.num)) 37 | 38 | expect(dummy1).to.equal(0) 39 | expect(dummy2).to.equal(0) 40 | counter.num++ 41 | expect(dummy1).to.equal(1) 42 | expect(dummy2).to.equal(1) 43 | }) 44 | 45 | it('should observe nested properties', () => { 46 | let dummy 47 | const counter = observable({ nested: { num: 0 } }) 48 | observe(() => (dummy = counter.nested.num)) 49 | 50 | expect(dummy).to.equal(0) 51 | counter.nested.num = 8 52 | expect(dummy).to.equal(8) 53 | }) 54 | 55 | it('should observe delete operations', () => { 56 | let dummy 57 | const obj = observable({ prop: 'value' }) 58 | observe(() => (dummy = obj.prop)) 59 | 60 | expect(dummy).to.equal('value') 61 | delete obj.prop 62 | expect(dummy).to.equal(undefined) 63 | }) 64 | 65 | it('should observe has operations', () => { 66 | let dummy 67 | const obj = observable({ prop: 'value' }) 68 | observe(() => (dummy = 'prop' in obj)) 69 | 70 | expect(dummy).to.equal(true) 71 | delete obj.prop 72 | expect(dummy).to.equal(false) 73 | obj.prop = 12 74 | expect(dummy).to.equal(true) 75 | }) 76 | 77 | it('should observe properties on the prototype chain', () => { 78 | let dummy 79 | const counter = observable({ num: 0 }) 80 | const parentCounter = observable({ num: 2 }) 81 | Object.setPrototypeOf(counter, parentCounter) 82 | observe(() => (dummy = counter.num)) 83 | 84 | expect(dummy).to.equal(0) 85 | delete counter.num 86 | expect(dummy).to.equal(2) 87 | parentCounter.num = 4 88 | expect(dummy).to.equal(4) 89 | counter.num = 3 90 | expect(dummy).to.equal(3) 91 | }) 92 | 93 | it('should observe has operations on the prototype chain', () => { 94 | let dummy 95 | const counter = observable({ num: 0 }) 96 | const parentCounter = observable({ num: 2 }) 97 | Object.setPrototypeOf(counter, parentCounter) 98 | observe(() => (dummy = 'num' in counter)) 99 | 100 | expect(dummy).to.equal(true) 101 | delete counter.num 102 | expect(dummy).to.equal(true) 103 | delete parentCounter.num 104 | expect(dummy).to.equal(false) 105 | counter.num = 3 106 | expect(dummy).to.equal(true) 107 | }) 108 | 109 | it('should observe inherited property accessors', () => { 110 | let dummy, parentDummy, hiddenValue 111 | const obj = observable({}) 112 | const parent = observable({ 113 | set prop (value) { 114 | hiddenValue = value 115 | }, 116 | get prop () { 117 | return hiddenValue 118 | } 119 | }) 120 | Object.setPrototypeOf(obj, parent) 121 | observe(() => (dummy = obj.prop)) 122 | observe(() => (parentDummy = parent.prop)) 123 | 124 | expect(dummy).to.equal(undefined) 125 | expect(parentDummy).to.equal(undefined) 126 | obj.prop = 4 127 | expect(dummy).to.equal(4) 128 | // this doesn't work, should it? 129 | // expect(parentDummy).to.equal(4) 130 | parent.prop = 2 131 | expect(dummy).to.equal(2) 132 | expect(parentDummy).to.equal(2) 133 | }) 134 | 135 | it('should observe function call chains', () => { 136 | let dummy 137 | const counter = observable({ num: 0 }) 138 | observe(() => (dummy = getNum())) 139 | 140 | function getNum () { 141 | return counter.num 142 | } 143 | 144 | expect(dummy).to.equal(0) 145 | counter.num = 2 146 | expect(dummy).to.equal(2) 147 | }) 148 | 149 | it('should observe iteration', () => { 150 | let dummy 151 | const list = observable(['Hello']) 152 | observe(() => (dummy = list.join(' '))) 153 | 154 | expect(dummy).to.equal('Hello') 155 | list.push('World!') 156 | expect(dummy).to.equal('Hello World!') 157 | list.shift() 158 | expect(dummy).to.equal('World!') 159 | }) 160 | 161 | it('should observe implicit array length changes', () => { 162 | let dummy 163 | const list = observable(['Hello']) 164 | observe(() => (dummy = list.join(' '))) 165 | 166 | expect(dummy).to.equal('Hello') 167 | list[1] = 'World!' 168 | expect(dummy).to.equal('Hello World!') 169 | list[3] = 'Hello!' 170 | expect(dummy).to.equal('Hello World! Hello!') 171 | }) 172 | 173 | it('should observe sparse array mutations', () => { 174 | let dummy 175 | const list = observable([]) 176 | list[1] = 'World!' 177 | observe(() => (dummy = list.join(' '))) 178 | 179 | expect(dummy).to.equal(' World!') 180 | list[0] = 'Hello' 181 | expect(dummy).to.equal('Hello World!') 182 | list.pop() 183 | expect(dummy).to.equal('Hello') 184 | }) 185 | 186 | it('should observe enumeration', () => { 187 | let dummy = 0 188 | const numbers = observable({ num1: 3 }) 189 | observe(() => { 190 | dummy = 0 191 | for (let key in numbers) { 192 | dummy += numbers[key] 193 | } 194 | }) 195 | 196 | expect(dummy).to.equal(3) 197 | numbers.num2 = 4 198 | expect(dummy).to.equal(7) 199 | delete numbers.num1 200 | expect(dummy).to.equal(4) 201 | }) 202 | 203 | it('should observe symbol keyed properties', () => { 204 | const key = Symbol('symbol keyed prop') 205 | let dummy, hasDummy 206 | const obj = observable({ [key]: 'value' }) 207 | observe(() => (dummy = obj[key])) 208 | observe(() => (hasDummy = key in obj)) 209 | 210 | expect(dummy).to.equal('value') 211 | expect(hasDummy).to.equal(true) 212 | obj[key] = 'newValue' 213 | expect(dummy).to.equal('newValue') 214 | delete obj[key] 215 | expect(dummy).to.equal(undefined) 216 | expect(hasDummy).to.equal(false) 217 | }) 218 | 219 | it('should not observe well-known symbol keyed properties', () => { 220 | const key = Symbol.isConcatSpreadable 221 | let dummy 222 | const array = observable([]) 223 | observe(() => (dummy = array[key])) 224 | 225 | expect(array[key]).to.equal(undefined) 226 | expect(dummy).to.equal(undefined) 227 | array[key] = true 228 | expect(array[key]).to.equal(true) 229 | expect(dummy).to.equal(undefined) 230 | }) 231 | 232 | it('should observe function valued properties', () => { 233 | const oldFunc = () => {} 234 | const newFunc = () => {} 235 | 236 | let dummy 237 | const obj = observable({ func: oldFunc }) 238 | observe(() => (dummy = obj.func)) 239 | 240 | expect(dummy).to.equal(oldFunc) 241 | obj.func = newFunc 242 | expect(dummy).to.equal(newFunc) 243 | }) 244 | 245 | it('should not observe set operations without a value change', () => { 246 | let hasDummy, getDummy 247 | const obj = observable({ prop: 'value' }) 248 | 249 | const getSpy = spy(() => (getDummy = obj.prop)) 250 | const hasSpy = spy(() => (hasDummy = 'prop' in obj)) 251 | observe(getSpy) 252 | observe(hasSpy) 253 | 254 | expect(getDummy).to.equal('value') 255 | expect(hasDummy).to.equal(true) 256 | obj.prop = 'value' 257 | expect(getSpy.callCount).to.equal(1) 258 | expect(hasSpy.callCount).to.equal(1) 259 | expect(getDummy).to.equal('value') 260 | expect(hasDummy).to.equal(true) 261 | }) 262 | 263 | it('should not observe raw mutations', () => { 264 | let dummy 265 | const obj = observable() 266 | observe(() => (dummy = raw(obj).prop)) 267 | 268 | expect(dummy).to.equal(undefined) 269 | obj.prop = 'value' 270 | expect(dummy).to.equal(undefined) 271 | }) 272 | 273 | it('should not be triggered by raw mutations', () => { 274 | let dummy 275 | const obj = observable() 276 | observe(() => (dummy = obj.prop)) 277 | 278 | expect(dummy).to.equal(undefined) 279 | raw(obj).prop = 'value' 280 | expect(dummy).to.equal(undefined) 281 | }) 282 | 283 | it('should not be triggered by inherited raw setters', () => { 284 | let dummy, parentDummy, hiddenValue 285 | const obj = observable({}) 286 | const parent = observable({ 287 | set prop (value) { 288 | hiddenValue = value 289 | }, 290 | get prop () { 291 | return hiddenValue 292 | } 293 | }) 294 | Object.setPrototypeOf(obj, parent) 295 | observe(() => (dummy = obj.prop)) 296 | observe(() => (parentDummy = parent.prop)) 297 | 298 | expect(dummy).to.equal(undefined) 299 | expect(parentDummy).to.equal(undefined) 300 | raw(obj).prop = 4 301 | expect(dummy).to.equal(undefined) 302 | expect(parentDummy).to.equal(undefined) 303 | }) 304 | 305 | it('should avoid implicit infinite recursive loops with itself', () => { 306 | const counter = observable({ num: 0 }) 307 | 308 | const counterSpy = spy(() => counter.num++) 309 | observe(counterSpy) 310 | expect(counter.num).to.equal(1) 311 | expect(counterSpy.callCount).to.equal(1) 312 | counter.num = 4 313 | expect(counter.num).to.equal(5) 314 | expect(counterSpy.callCount).to.equal(2) 315 | }) 316 | 317 | it('should allow explicitly recursive raw function loops', () => { 318 | const counter = observable({ num: 0 }) 319 | 320 | // TODO: this should be changed to reaction loops, can it be done? 321 | const numSpy = spy(() => { 322 | counter.num++ 323 | if (counter.num < 10) { 324 | numSpy() 325 | } 326 | }) 327 | observe(numSpy) 328 | 329 | expect(counter.num).to.eql(10) 330 | expect(numSpy.callCount).to.equal(10) 331 | }) 332 | 333 | it('should avoid infinite loops with other reactions', () => { 334 | const nums = observable({ num1: 0, num2: 1 }) 335 | 336 | const spy1 = spy(() => (nums.num1 = nums.num2)) 337 | const spy2 = spy(() => (nums.num2 = nums.num1)) 338 | observe(spy1) 339 | observe(spy2) 340 | expect(nums.num1).to.equal(1) 341 | expect(nums.num2).to.equal(1) 342 | expect(spy1.callCount).to.equal(1) 343 | expect(spy2.callCount).to.equal(1) 344 | nums.num2 = 4 345 | expect(nums.num1).to.equal(4) 346 | expect(nums.num2).to.equal(4) 347 | expect(spy1.callCount).to.equal(2) 348 | expect(spy2.callCount).to.equal(2) 349 | nums.num1 = 10 350 | expect(nums.num1).to.equal(10) 351 | expect(nums.num2).to.equal(10) 352 | expect(spy1.callCount).to.equal(3) 353 | expect(spy2.callCount).to.equal(3) 354 | }) 355 | 356 | it('should return a new reactive version of the function', () => { 357 | function greet () { 358 | return 'Hello World' 359 | } 360 | const reaction1 = observe(greet) 361 | const reaction2 = observe(greet) 362 | expect(reaction1).to.be.a('function') 363 | expect(reaction2).to.be.a('function') 364 | expect(reaction1).to.not.equal(greet) 365 | expect(reaction1).to.not.equal(reaction2) 366 | }) 367 | 368 | it('should wrap the passed function seamlessly', () => { 369 | function greet (name) { 370 | return `Hello ${this.prefix} ${name}!` 371 | } 372 | const reaction = observe(greet, { lazy: true }) 373 | expect(reaction.call({ prefix: 'Mr.' }, 'World')).to.eql( 374 | 'Hello Mr. World!' 375 | ) 376 | }) 377 | 378 | it('should discover new branches while running automatically', () => { 379 | let dummy 380 | const obj = observable({ prop: 'value', run: false }) 381 | 382 | const conditionalSpy = spy(() => { 383 | dummy = obj.run ? obj.prop : 'other' 384 | }) 385 | observe(conditionalSpy) 386 | 387 | expect(dummy).to.equal('other') 388 | expect(conditionalSpy.callCount).to.equal(1) 389 | obj.prop = 'Hi' 390 | expect(dummy).to.equal('other') 391 | expect(conditionalSpy.callCount).to.equal(1) 392 | obj.run = true 393 | expect(dummy).to.equal('Hi') 394 | expect(conditionalSpy.callCount).to.equal(2) 395 | obj.prop = 'World' 396 | expect(dummy).to.equal('World') 397 | expect(conditionalSpy.callCount).to.equal(3) 398 | }) 399 | 400 | it('should discover new branches when running manually', () => { 401 | let dummy 402 | let run = false 403 | const obj = observable({ prop: 'value' }) 404 | const reaction = observe(() => { 405 | dummy = run ? obj.prop : 'other' 406 | }) 407 | 408 | expect(dummy).to.equal('other') 409 | reaction() 410 | expect(dummy).to.equal('other') 411 | run = true 412 | reaction() 413 | expect(dummy).to.equal('value') 414 | obj.prop = 'World' 415 | expect(dummy).to.equal('World') 416 | }) 417 | 418 | it('should not be triggered by mutating a property, which is used in an inactive branch', () => { 419 | let dummy 420 | const obj = observable({ prop: 'value', run: true }) 421 | 422 | const conditionalSpy = spy(() => { 423 | dummy = obj.run ? obj.prop : 'other' 424 | }) 425 | observe(conditionalSpy) 426 | 427 | expect(dummy).to.equal('value') 428 | expect(conditionalSpy.callCount).to.equal(1) 429 | obj.run = false 430 | expect(dummy).to.equal('other') 431 | expect(conditionalSpy.callCount).to.equal(2) 432 | obj.prop = 'value2' 433 | expect(dummy).to.equal('other') 434 | expect(conditionalSpy.callCount).to.equal(2) 435 | }) 436 | 437 | it('should not double wrap if the passed function is a reaction', () => { 438 | const reaction = observe(() => {}) 439 | const otherReaction = observe(reaction) 440 | expect(reaction).to.equal(otherReaction) 441 | }) 442 | 443 | it('should not run multiple times for a single mutation', () => { 444 | let dummy 445 | const obj = observable() 446 | const fnSpy = spy(() => { 447 | for (const key in obj) { 448 | dummy = obj[key] 449 | } 450 | dummy = obj.prop 451 | }) 452 | observe(fnSpy) 453 | 454 | expect(fnSpy.callCount).to.equal(1) 455 | obj.prop = 16 456 | expect(dummy).to.equal(16) 457 | expect(fnSpy.callCount).to.equal(2) 458 | }) 459 | 460 | it('should allow nested reactions', () => { 461 | const nums = observable({ num1: 0, num2: 1, num3: 2 }) 462 | const dummy = {} 463 | 464 | const childSpy = spy(() => (dummy.num1 = nums.num1)) 465 | const childReaction = observe(childSpy) 466 | const parentSpy = spy(() => { 467 | dummy.num2 = nums.num2 468 | childReaction() 469 | dummy.num3 = nums.num3 470 | }) 471 | observe(parentSpy) 472 | 473 | expect(dummy).to.eql({ num1: 0, num2: 1, num3: 2 }) 474 | expect(parentSpy.callCount).to.equal(1) 475 | expect(childSpy.callCount).to.equal(2) 476 | // this should only call the childReaction 477 | nums.num1 = 4 478 | expect(dummy).to.eql({ num1: 4, num2: 1, num3: 2 }) 479 | expect(parentSpy.callCount).to.equal(1) 480 | expect(childSpy.callCount).to.equal(3) 481 | // this calls the parentReaction, which calls the childReaction once 482 | nums.num2 = 10 483 | expect(dummy).to.eql({ num1: 4, num2: 10, num3: 2 }) 484 | expect(parentSpy.callCount).to.equal(2) 485 | expect(childSpy.callCount).to.equal(4) 486 | // this calls the parentReaction, which calls the childReaction once 487 | nums.num3 = 7 488 | expect(dummy).to.eql({ num1: 4, num2: 10, num3: 7 }) 489 | expect(parentSpy.callCount).to.equal(3) 490 | expect(childSpy.callCount).to.equal(5) 491 | }) 492 | }) 493 | 494 | describe('options', () => { 495 | describe('lazy', () => { 496 | it('should not run the passed function, if set to true', () => { 497 | const fnSpy = spy(() => {}) 498 | observe(fnSpy, { lazy: true }) 499 | expect(fnSpy.callCount).to.equal(0) 500 | }) 501 | 502 | it('should default to false', () => { 503 | const fnSpy = spy(() => {}) 504 | observe(fnSpy) 505 | expect(fnSpy.callCount).to.equal(1) 506 | }) 507 | }) 508 | 509 | describe('scheduler', () => { 510 | it('should call the scheduler function with the reaction instead of running it sync', () => { 511 | const counter = observable({ num: 0 }) 512 | const fn = spy(() => counter.num) 513 | const scheduler = spy(() => {}) 514 | const reaction = observe(fn, { scheduler }) 515 | 516 | expect(fn.callCount).to.equal(1) 517 | expect(scheduler.callCount).to.equal(0) 518 | counter.num++ 519 | expect(fn.callCount).to.equal(1) 520 | expect(scheduler.callCount).to.eql(1) 521 | expect(scheduler.lastArgs).to.eql([reaction]) 522 | }) 523 | 524 | it('should call scheduler.add with the reaction instead of running it sync', () => { 525 | const counter = observable({ num: 0 }) 526 | const fn = spy(() => counter.num) 527 | const scheduler = { add: spy(() => {}), delete: () => {} } 528 | const reaction = observe(fn, { scheduler }) 529 | 530 | expect(fn.callCount).to.equal(1) 531 | expect(scheduler.add.callCount).to.equal(0) 532 | counter.num++ 533 | expect(fn.callCount).to.equal(1) 534 | expect(scheduler.add.callCount).to.eql(1) 535 | expect(scheduler.add.lastArgs).to.eql([reaction]) 536 | }) 537 | }) 538 | 539 | it('should not error when a DOM element is added', async () => { 540 | let dummy = null 541 | const observed = observable({ obj: null }) 542 | observe(() => (dummy = observed.obj && observed.obj.nodeType)) 543 | 544 | expect(dummy).to.equal(null) 545 | observed.obj = document 546 | expect(dummy).to.equal(9) 547 | }) 548 | }) 549 | -------------------------------------------------------------------------------- /tests/unobserve.test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { spy } from './utils' 3 | import { observable, observe, unobserve } from '@nx-js/observer-util' 4 | 5 | describe('unobserve', () => { 6 | it('should unobserve the observed function', () => { 7 | let dummy 8 | const counter = observable({ num: 0 }) 9 | const counterSpy = spy(() => (dummy = counter.num)) 10 | const reaction = observe(counterSpy) 11 | 12 | expect(counterSpy.callCount).to.equal(1) 13 | counter.num = 'Hello' 14 | expect(counterSpy.callCount).to.equal(2) 15 | expect(dummy).to.equal('Hello') 16 | unobserve(reaction) 17 | counter.num = 'World' 18 | expect(counterSpy.callCount).to.equal(2) 19 | expect(dummy).to.equal('Hello') 20 | }) 21 | 22 | it('should unobserve when the same key is used multiple times', () => { 23 | let dummy 24 | const user = observable({ name: { name: 'Bob' } }) 25 | const nameSpy = spy(() => (dummy = user.name.name)) 26 | const reaction = observe(nameSpy) 27 | 28 | expect(nameSpy.callCount).to.equal(1) 29 | user.name.name = 'Dave' 30 | expect(nameSpy.callCount).to.equal(2) 31 | expect(dummy).to.equal('Dave') 32 | unobserve(reaction) 33 | user.name.name = 'Ann' 34 | expect(nameSpy.callCount).to.equal(2) 35 | expect(dummy).to.equal('Dave') 36 | }) 37 | 38 | it('should unobserve multiple reactions for the same target and key', () => { 39 | let dummy 40 | const counter = observable({ num: 0 }) 41 | 42 | const reaction1 = observe(() => (dummy = counter.num)) 43 | const reaction2 = observe(() => (dummy = counter.num)) 44 | const reaction3 = observe(() => (dummy = counter.num)) 45 | 46 | expect(dummy).to.equal(0) 47 | unobserve(reaction1) 48 | unobserve(reaction2) 49 | unobserve(reaction3) 50 | counter.num++ 51 | expect(dummy).to.equal(0) 52 | }) 53 | 54 | it('should not reobserve unobserved reactions on manual execution', () => { 55 | let dummy 56 | const obj = observable() 57 | const reaction = observe(() => (dummy = obj.prop)) 58 | 59 | expect(dummy).to.equal(undefined) 60 | unobserve(reaction) 61 | reaction() 62 | obj.prop = 12 63 | expect(dummy).to.equal(undefined) 64 | }) 65 | 66 | it('should have the same effect, when called multiple times', () => { 67 | let dummy 68 | const counter = observable({ num: 0 }) 69 | const counterSpy = spy(() => (dummy = counter.num)) 70 | const reaction = observe(counterSpy) 71 | 72 | expect(counterSpy.callCount).to.equal(1) 73 | counter.num = 'Hello' 74 | expect(counterSpy.callCount).to.equal(2) 75 | expect(dummy).to.equal('Hello') 76 | unobserve(reaction) 77 | unobserve(reaction) 78 | unobserve(reaction) 79 | counter.num = 'World' 80 | expect(counterSpy.callCount).to.equal(2) 81 | expect(dummy).to.equal('Hello') 82 | }) 83 | 84 | it('should call scheduler.delete', () => { 85 | const counter = observable({ num: 0 }) 86 | const fn = spy(() => counter.num) 87 | const scheduler = { add: () => {}, delete: spy(() => {}) } 88 | const reaction = observe(fn, { scheduler }) 89 | 90 | counter.num++ 91 | unobserve(reaction) 92 | expect(scheduler.delete.callCount).to.eql(1) 93 | }) 94 | }) 95 | -------------------------------------------------------------------------------- /tests/utils.js: -------------------------------------------------------------------------------- 1 | export function spy (fn) { 2 | function spyFn () { 3 | fn.apply(this, arguments) 4 | spyFn.callCount++ 5 | spyFn.lastArgs = Array.from(arguments) 6 | spyFn.args.push(spyFn.lastArgs) 7 | } 8 | spyFn.callCount = 0 9 | spyFn.args = [] 10 | return spyFn 11 | } 12 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@nx-js/observer-util' { 2 | function observable(obj?: Observable): Observable 3 | function isObservable(obj: object): boolean 4 | function raw(obj: Observable): Observable 5 | 6 | interface Scheduler { 7 | add: Function 8 | delete: Function 9 | } 10 | 11 | interface ObserveOptions { 12 | scheduler?: Scheduler | Function 13 | debugger?: Function 14 | lazy?: boolean 15 | } 16 | 17 | function observe(func: Reaction, options?: ObserveOptions): Reaction 18 | function unobserve(func: Function): void 19 | } 20 | --------------------------------------------------------------------------------