├── .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 | [](https://circleci.com/gh/nx-js/observer-util/tree/master) [](https://coveralls.io/github/nx-js/observer-util) [](https://standardjs.com) [](https://bundlephobia.com/result?p=@nx-js/observer-util) [](https://www.npmjs.com/package/@nx-js/observer-util) [](https://david-dm.org/nx-js/observer-util) [](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 |
--------------------------------------------------------------------------------