├── .editorconfig ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .huskyrc.json ├── .lintstagedrc.json ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── configs ├── tsconfig.base.json ├── tsconfig.cjs.json ├── tsconfig.es2015.json ├── tsconfig.esm.json ├── tsconfig.types.json ├── webpack.base.js ├── webpack.build.js ├── webpack.build.min.js └── webpack.dev.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── core.ts └── index.ts ├── tests └── index.test.ts ├── tsconfig.json └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 4 8 | indent_style = space 9 | insert_final_newline = true 10 | max_line_length = 80 11 | trim_trailing_whitespace = true 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [12.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | 26 | - name: Cache Node.js modules 27 | uses: actions/cache@v2 28 | with: 29 | # npm cache files are stored in `~/.npm` on Linux/macOS 30 | path: ~/.npm 31 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 32 | restore-keys: | 33 | ${{ runner.OS }}-node- 34 | ${{ runner.OS }}- 35 | 36 | - run: npm ci 37 | - run: npm test 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # Output 64 | dist 65 | -------------------------------------------------------------------------------- /.huskyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "tsc --noEmit && lint-staged" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.json": "prettier --write ./", 3 | "*.js": "prettier --write ./", 4 | "*.{js,tsx}": "prettier --write ./", 5 | "*.yaml": "prettier --write ./" 6 | } 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /package.json 2 | /package-lock.json 3 | /examples 4 | /dist 5 | *.md 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "proseWrap": "always", 5 | "htmlWhitespaceSensitivity": "ignore" 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 rxjs-autorun 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 |
2 |

3 |
4 | 🧙‍♂️ RxJS️ Autorun 🧙‍♀️ 5 |
6 |
7 | 8 |
9 | Evaluates given expression whenever dependant Observables emit 10 |
11 |
12 | NPM 13 | Bundlephobia 14 | MIT license 15 |

16 |
17 | 18 | ## 📦 Install 19 | 20 | ``` 21 | npm i rxjs-autorun 22 | ``` 23 | 24 | Or **[try it online](https://stackblitz.com/edit/rxjs-autorun-repl?file=index.ts)** 25 | 26 | **⚠️ WARNING:** at this stage it's a very experimental library, use at your own risk! 27 | 28 | ## 💃 Examples 29 | 30 | ### Instant evaluation: 31 | 32 | ```ts 33 | const o = of(1); 34 | const r = combined(() => $(o)); 35 | r.subscribe(console.log); // > 1 36 | ``` 37 | 38 | ### Delayed evaluation: 39 | 40 | _`combined` waits for Observable `o` to emit a value_ 41 | 42 | ```ts 43 | const o = new Subject(); 44 | const r = combined(() => $(o)); 45 | r.subscribe(console.log); 46 | o.next('🐈'); // > 🐈 47 | ``` 48 | 49 | ### Two Observables: 50 | 51 | _recompute `c` with latest `a` and `b`, only when `b` updates_ 52 | 53 | ```ts 54 | const a = new BehaviorSubject('#'); 55 | const b = new BehaviorSubject(1); 56 | const c = combined(() => _(a) + $(b)); 57 | 58 | c.subscribe(observer); // > #1 59 | a.next('💡'); // ~no update~ 60 | b.next(42); // > 💡42 61 | ``` 62 | 63 | ### Filtering: 64 | 65 | _use [NEVER](https://rxjs.dev/api/index/const/NEVER) to suspend emission till `source$` emits again_ 66 | 67 | ```ts 68 | const source$ = timer(0, 1_000); 69 | const even$ = combined(() => $(source$) % 2 == 0 ? _(source$) : _(NEVER)); 70 | ``` 71 | 72 | ### Switchmap: 73 | 74 | _fetch data every second_ 75 | 76 | ```ts 77 | function fetch(x){ 78 | // mock delayed fetching of x 79 | return of('📦' + x).pipe(delay(100)); 80 | } 81 | 82 | const a = timer(0, 1_000); 83 | const b = combined(() => fetch($(a))); 84 | const c = combined(() => $($(b))); 85 | c.subscribe(console.log); 86 | // > 📦 1 87 | // > 📦 2 88 | // > 📦 3 89 | // > … 90 | ``` 91 | 92 | 93 | ## 🔧 API 94 | 95 | To run an expression, you must wrap it in one of these: 96 | 97 | - `combined` returns an Observable that will emit evaluation results 98 | 99 | - `computed` returns an Observable that will emit **distinct** evaluation results with **distinctive updates** 100 | 101 | - `autorun` internally subscribes to `combined` and returns the subscription 102 | 103 | E.g: 104 | 105 | ```ts 106 | combined(() => { … }); 107 | ``` 108 | 109 | ### 👓 Tracking 110 | 111 | You can read values from Observables inside `combined` (or `computed`, or `autorun`) in two ways: 112 | 113 | - `$(O)` tells `combined` that it should be re-evaluated when `O` emits, with it's latest value 114 | 115 | - `_(O)` still provides latest value to `combined`, but doesn't enforce re-evaluation with `O` emission 116 | 117 | Both functions would interrupt mid-flight if `O` has not emitted before and doesn't produce a value synchronously. 118 | 119 | If you don't want interruptions — try Observables that always contain a value, such as `BehaviorSubject`s, `of`, `startWith`, etc. 120 | 121 | Usually this is all one needs when to use `rxjs-autorun` 122 | 123 | ### 💪 Strength 124 | 125 | Some times you need to tweak what to do with **subscription of an Observable that is not currently used**. 126 | 127 | So we provide three levels of subscription strength: 128 | 129 | - `normal` - default - will unsubscribe if the latest run of expression didn't use this Observable: 130 | 131 | ```ts 132 | combined(() => $(a) ? $(b) : 0) 133 | ``` 134 | 135 | when `a` is falsy — `b` is not used and will be **dropped when expression finishes** 136 | 137 | _NOTE: when you use `$(…)` — it applies normal strength, but you can be explicit about that via `$.normal(…)` notation_ 138 | 139 | 140 | - `strong` - will keep the subscription for the life of the expression: 141 | 142 | ```ts 143 | combined(() => $(a) ? $.strong(b) : 0) 144 | ``` 145 | 146 | when `a` is falsy — `b` is not used, but the subscription will be **kept** 147 | 148 | 149 | - `weak` - will unsubscribe eagerly, if waiting for other Observable to emit: 150 | 151 | ```ts 152 | combined(() => $(a) ? $.weak(b) : $.weak(c)); 153 | ``` 154 | 155 | When `a` is truthy — `c` is not used and we'll wait `b` to emit, 156 | meanwhile `c` will be unsubscribed eagerly, even before `b` emits 157 | 158 | And vice versa: 159 | When `a` is falsy — `b` is not used and we'll wait `c` to emit, 160 | meanwhile `b` will be unsubscribed eagerly, even before `c` emits 161 | 162 | Another example: 163 | 164 | ```ts 165 | combined(() => $(a) ? $(b) + $.weak(c) : $.weak(c)) 166 | ``` 167 | 168 | When `a` is falsy — `b` is not used and will be dropped, `c` is used 169 | When `a` becomes truthy - `b` and `c` are used 170 | Although `c` will now have to wait for `b` to emit, which takes indefinite time 171 | And that's when we might want to mark `c` for **eager unsubscription**, until `a` or `b` emits 172 | 173 | 174 | See examples for more use-case details 175 | 176 | ## ⚠️ Precautions 177 | 178 | ### Sub-functions 179 | 180 | `$` and `_` memorize Observables that you pass to them. That is done to keep subscriptions and values and not to re-subscribe to same `$(O)` on each re-run. 181 | 182 | Therefore if you create a new Observable on each run of the expression: 183 | 184 | ```ts 185 | let a = timer(0, 100); 186 | let b = timer(0, 1000); 187 | let c = combined(() => $(a) + $(fetch($(b)))); 188 | 189 | function fetch(): Observable { 190 | return ajax.getJSON('…'); 191 | } 192 | ``` 193 | 194 | It might lead to unexpected fetches with each `a` emission! 195 | 196 | If that's not what we need — we can go two ways: 197 | 198 | - create a separate `combined()` that will call `fetch` only when `b` changes — see [switchMap](#switchmap) example for details 199 | 200 | - use some memoization or caching technique on `fetch` function that would return same Observable, when called with same arguments 201 | 202 | ### Side-effects 203 | 204 | If an Observable doesn't emit a synchronous value when it is subscribed, the expression will be **interrupted mid-flight** until the Observable emits. 205 | So if you must make side-effects inside `combined` — put that after reading from streams: 206 | 207 | ```ts 208 | const o = new Subject(); 209 | combined(() => { 210 | console.log('Hello'); // DANGEROUS: perform a side-effect before reading from stream 211 | return $(o); // will fail here since o has not emitted yet 212 | }).subscribe(console.log); 213 | o.next('World'); 214 | 215 | /** OUTPUT: 216 | * > Hello 217 | * > Hello 218 | * > World 219 | */ 220 | ``` 221 | 222 | While: 223 | 224 | ```ts 225 | const o = new Subject(); 226 | combined(() => { 227 | let value = $(o); // will fail here since o has not emitted yet 228 | console.log('Hello'); // SAFE: perform a side-effect after reading from stream 229 | return value; 230 | }).subscribe(console.log); 231 | o.next('World'); 232 | 233 | /** OUTPUT: 234 | * > Hello 235 | * > World 236 | */ 237 | ``` 238 | 239 | *We might introduce [alternative APIs](https://github.com/kosich/rxjs-autorun/issues/3) to help with this* 240 | 241 | ### Logic branching 242 | 243 | Logic branches might lead to late subscription to a given Observable, because it was not seen on previous runs. And if your Observable doesn't produce a value synchronously when subscribed — then expression will be **interrupted mid-flight** until any visited Observable from this latest run emits a new value. 244 | 245 | *We might introduce [alternative APIs](https://github.com/kosich/rxjs-autorun/issues/3) to help with this* 246 | 247 | Also note that you might want different handling of unused subscriptions, please see [strength](#-strength) section for details. 248 | 249 | ### Synchronous values skipping 250 | 251 | Currently `rxjs-autorun` will skip synchronous emissions and run expression only with latest value emitted, e.g.: 252 | 253 | ```ts 254 | const o = of('a', 'b', 'c'); 255 | 256 | combined(() => $(o)).subscribe(console.log); 257 | 258 | /** OUTPUT: 259 | * > c 260 | */ 261 | ``` 262 | 263 | *This might be fixed in future updates* 264 | 265 | ## 🤝 Want to contribute to this project? 266 | 267 | That will be awesome! 268 | 269 | Please create an issue before submitting a PR — we'll be able to discuss it first! 270 | 271 | Thanks! 272 | 273 | ## Enjoy 🙂 274 | -------------------------------------------------------------------------------- /configs/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": false, 4 | "outDir": "../dist/", 5 | "target": "es5", 6 | "module": "es2015", 7 | "moduleResolution": "node", 8 | "importHelpers": false, 9 | "downlevelIteration": true, 10 | "noImplicitAny": false, 11 | "noUnusedLocals": false, 12 | "lib": ["es5"], 13 | "types": ["node"] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /configs/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/cjs", 5 | "module": "commonjs" 6 | }, 7 | "include": ["../src/index.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /configs/tsconfig.es2015.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/es2015", 5 | "target": "es2015" 6 | }, 7 | "include": ["../src/index.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /configs/tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/esm" 5 | }, 6 | "include": ["../src/index.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /configs/tsconfig.types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/types", 5 | "emitDeclarationOnly": true, 6 | "declaration": true, 7 | "declarationMap": true 8 | }, 9 | "include": ["../src/index.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /configs/webpack.base.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: './src/index.ts', 5 | output: { 6 | filename: 'index.js', 7 | path: path.resolve(__dirname, '../dist'), 8 | library: 'rxjs-autorun', 9 | libraryTarget: 'umd', 10 | publicPath: '/dist/', 11 | umdNamedDefine: true, 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /.ts$/, 17 | exclude: /node_modules/, 18 | use: [ 19 | { 20 | loader: 'ts-loader', 21 | options: { 22 | configFile: 'configs/tsconfig.esm.json', 23 | }, 24 | }, 25 | ], 26 | }, 27 | ], 28 | }, 29 | resolve: { 30 | extensions: ['.ts'], 31 | }, 32 | externals: [ 33 | // externalisation of rxjs 34 | // copied from https://github.com/jayphelps/webpack-rxjs-externals/ 35 | function rxjsExternals(context, request, callback) { 36 | if (request.match(/^rxjs(\/|$)/)) { 37 | const parts = request.split('/'); 38 | 39 | return callback(null, { 40 | root: parts, 41 | commonjs: request, 42 | commonjs2: request, 43 | amd: request, 44 | }); 45 | } 46 | 47 | callback(); 48 | }, 49 | ], 50 | }; 51 | -------------------------------------------------------------------------------- /configs/webpack.build.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require('./webpack.base'); 2 | const { merge } = require('webpack-merge'); 3 | 4 | module.exports = merge(baseConfig, { 5 | mode: 'development', 6 | }); 7 | -------------------------------------------------------------------------------- /configs/webpack.build.min.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require('./webpack.base'); 2 | const { merge } = require('webpack-merge'); 3 | 4 | module.exports = merge(baseConfig, { 5 | mode: 'production', 6 | }); 7 | -------------------------------------------------------------------------------- /configs/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require('./webpack.base'); 2 | const { merge } = require('webpack-merge'); 3 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 4 | 5 | module.exports = merge(baseConfig, { 6 | mode: 'development', 7 | watch: true, 8 | plugins: [new CleanWebpackPlugin()], 9 | }); 10 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rxjs-autorun", 3 | "version": "0.0.2", 4 | "description": "Autorun expressions with RxJS Observables", 5 | "main": "./dist/cjs/index.js", 6 | "module": "./dist/esm/index.js", 7 | "es2015": "./dist/es2015/index.js", 8 | "types": "./dist/types/index.d.ts", 9 | "unpkg": "./dist/rxjs-autorun.min.js", 10 | "sideEffects": false, 11 | "scripts": { 12 | "start": "webpack --config configs/webpack.dev.js", 13 | "clean": "rimraf temp dist", 14 | "build": "npm run build:esm && npm run build:es2015 && npm run build:cjs && npm run build:types && npm run build:umd && npm run build:umd:min", 15 | "build:esm": "tsc -p configs/tsconfig.esm.json", 16 | "build:es2015": "tsc -p configs/tsconfig.es2015.json", 17 | "build:cjs": "tsc -p configs/tsconfig.cjs.json", 18 | "build:types": "tsc -p configs/tsconfig.types.json", 19 | "build:umd": "webpack --config configs/webpack.build.js -o dist/rxjs-autorun.js", 20 | "build:umd:min": "webpack --config configs/webpack.build.min.js -o dist/rxjs-autorun.min.js", 21 | "test": "jest", 22 | "test:watch": "jest --watch", 23 | "test:debug": "node --inspect node_modules/.bin/jest --watch --runInBand", 24 | "np": "npm run clean && npm run build && np" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/kosich/rxjs-autorun.git" 29 | }, 30 | "keywords": [ 31 | "autorun", 32 | "computed", 33 | "rxjs", 34 | "javascript", 35 | "typescript" 36 | ], 37 | "author": "Kostiantyn Palchyk", 38 | "license": "MIT", 39 | "bugs": { 40 | "url": "https://github.com/kosich/rxjs-autorun/issues" 41 | }, 42 | "homepage": "https://github.com/kosich/rxjs-autorun#readme", 43 | "devDependencies": { 44 | "@types/jest": "26.0.15", 45 | "jest": "26.6.3", 46 | "ts-jest": "26.4.4", 47 | "clean-webpack-plugin": "3.0.0", 48 | "prettier": "2.1.2", 49 | "rimraf": "3.0.2", 50 | "rxjs": "6.5.0", 51 | "ts-loader": "8.0.11", 52 | "typescript": "4.0.5", 53 | "webpack": "4.44.2", 54 | "webpack-cli": "3.3.12", 55 | "webpack-merge": "5.1.4", 56 | "np": "7.0.0" 57 | }, 58 | "peerDependencies": { 59 | "rxjs": "^6.5.0" 60 | }, 61 | "files": [ 62 | "dist" 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /src/core.ts: -------------------------------------------------------------------------------- 1 | import { Observable, Subscription } from 'rxjs'; 2 | import { distinctUntilChanged } from 'rxjs/operators'; 3 | 4 | 5 | // an error to make mid-flight interruptions 6 | // when a value is still not available 7 | const HALT_ERROR = Object.create(null); 8 | 9 | // error if tracker is used out of autorun/computed context 10 | export const TrackerError = new Error('$ or _ can only be called within computed or autorun context'); 11 | const errorTracker: Trackers = () => { throw TrackerError; }; 12 | errorTracker.weak = errorTracker; 13 | errorTracker.normal = errorTracker; 14 | errorTracker.strong = errorTracker; 15 | 16 | let context: Context = { 17 | _: errorTracker, 18 | $: errorTracker 19 | }; 20 | 21 | export const forwardTracker = (tracker: keyof Context): Trackers => { 22 | const r = ((o: Observable): T => context[tracker](o)) as Trackers; 23 | r.weak = o => context[tracker].weak(o); 24 | r.normal = o => context[tracker].normal(o); 25 | r.strong = o => context[tracker].strong(o); 26 | return r; 27 | } 28 | 29 | export const runner = (fn: Expression, distinct: boolean = false): Observable => new Observable(observer => { 30 | const deps = new Map, TrackEntry>(); 31 | 32 | // context to be used for running expression 33 | const newCtx = { 34 | $: createTrackers(true), 35 | _: createTrackers(false) 36 | }; 37 | 38 | // on unsubscribe/complete we destroy all subscriptions 39 | const sub = new Subscription(() => { 40 | deps.forEach(dep => { 41 | dep.subscription.unsubscribe(); 42 | }); 43 | }); 44 | 45 | // flag that indicates that current run might've affected completion status 46 | // we'll check completion after the first run 47 | let shouldCheckCompletion = true; 48 | 49 | // initial run 50 | runFn(); 51 | 52 | return sub; 53 | 54 | 55 | function runFn () { 56 | // Mark all deps as untracked and unused, lowering normal to weak 57 | const loweredStrengthDeps: TrackEntry[] = []; 58 | deps.forEach(dep => { 59 | dep.track = false; 60 | dep.used = false; 61 | // setting normal strength to weak, so that if previously `a` was 62 | // tracked as normal and in the latest run we only see `weak(a)` - 63 | // we can mark it as weak. this is restored if halted 64 | if (dep.strength === Strength.Normal) { 65 | dep.strength = Strength.Weak; 66 | loweredStrengthDeps.push(dep); 67 | } 68 | }); 69 | 70 | const prevCtxt = context; 71 | context = newCtx; 72 | try { 73 | const result = fn(); 74 | removeUnusedDeps(Strength.Normal); 75 | observer.next(result); 76 | } catch (e) { 77 | // handling mid-flight interruption error 78 | // NOTE: check requires === strict equality 79 | if (e === HALT_ERROR) { 80 | // restore lowered strength 81 | loweredStrengthDeps.forEach(dep => { 82 | dep.strength = Strength.Normal; 83 | }); 84 | // clean-up weak subscriptions 85 | removeUnusedDeps(Strength.Weak); 86 | } else { 87 | // rethrow original errors 88 | observer.error(e); 89 | // we're errored, no need to check completion 90 | shouldCheckCompletion = false; 91 | } 92 | } finally { 93 | context = prevCtxt; 94 | 95 | // if this run was flagged as potentially completing 96 | if (shouldCheckCompletion) { 97 | checkCompletion(); 98 | } 99 | } 100 | } 101 | 102 | function checkCompletion () { 103 | // reset the flag 104 | shouldCheckCompletion = false; 105 | 106 | // any dep is still running 107 | for (let dep of deps.values()) { 108 | if (dep.track && !dep.completed) { 109 | // one of the $-tracked deps is still running 110 | return; 111 | } 112 | } 113 | 114 | // All $-tracked deps completed 115 | observer.complete(); 116 | } 117 | 118 | function removeUnusedDeps (ofStrength: Strength) { 119 | deps.forEach((dep, key) => { 120 | if (dep.used || dep.strength > ofStrength) { 121 | return; 122 | } 123 | dep.subscription.unsubscribe(); 124 | deps.delete(key); 125 | }); 126 | } 127 | 128 | function createTrackers (track: boolean) { 129 | const r = createTracker(track, Strength.Normal) as Trackers; 130 | r.weak = createTracker(track, Strength.Weak); 131 | r.normal = createTracker(track, Strength.Normal); 132 | r.strong = createTracker(track, Strength.Strong); 133 | return r; 134 | } 135 | 136 | function createTracker(track: boolean, strength: Strength): Tracker { 137 | return function tracker(o: Observable): O { 138 | if (deps.has(o)) { 139 | const v = deps.get(o)!; 140 | v.used = true; 141 | if (track && !v.track) { 142 | // Previously tracked with _, but now also with $. 143 | // So completed state becomes relevant now. 144 | // Happens in case of e.g. computed(() => _(o) + $(o)) 145 | v.track = true; 146 | } 147 | if (strength > v.strength) { 148 | // Previous tracking strength was weaker than it currently 149 | // is. So temporarily use the stronger version. 150 | v.strength = strength; 151 | } 152 | if (v.hasValue) { 153 | return v.value as O; 154 | } else { 155 | throw HALT_ERROR; 156 | } 157 | } 158 | 159 | const v: TrackEntry = { 160 | hasValue: false, 161 | value: void 0, 162 | // Eagerly create subscription that can be destroyed. 163 | subscription: new Subscription(), 164 | strength, 165 | track: true, 166 | used: true, 167 | completed: false 168 | }; 169 | 170 | deps.set(o, v); 171 | 172 | // Sync Code Section {{{ 173 | // NOTE: we will synchronously (immediately) evaluate observables 174 | // that can synchronously emit a value. Such observables as: 175 | // - of(…) 176 | // - o.pipe( startWith(…) ) 177 | // - BehaviorSubject 178 | // - ReplaySubject 179 | // - etc 180 | let isAsync = false; 181 | let hasSyncError = false; 182 | let syncError = void 0; 183 | v.subscription.add( 184 | ( distinct 185 | ? o.pipe(distinctUntilChanged()) 186 | : o 187 | ) 188 | .subscribe({ 189 | next(value) { 190 | const hadValue = v.hasValue; 191 | v.hasValue = true; 192 | v.value = value; 193 | 194 | const isUntrackFirstValue = !hadValue && !track; 195 | 196 | // It could be that all tracked deps already completed. 197 | // So signal that completion state might have changed. 198 | if (isUntrackFirstValue) { 199 | shouldCheckCompletion = true; 200 | } 201 | 202 | if (isAsync && v.track) { 203 | runFn(); 204 | } 205 | 206 | if (isUntrackFirstValue) { 207 | // Untracked dep now has it's first value. So really untrack it. 208 | v.track = false; 209 | } 210 | }, 211 | error(err) { 212 | if (isAsync) { 213 | observer.error(err); 214 | } else { 215 | syncError = err; 216 | hasSyncError = true; 217 | } 218 | }, 219 | complete() { 220 | v.completed = true; 221 | 222 | // if we don't have a value — we interrupt evaluation 223 | // and complete output. See issue #22 224 | if (!v.hasValue) { 225 | observer.complete(); 226 | 227 | // immediately halt the computation 228 | if (!isAsync) { 229 | hasSyncError = true; 230 | syncError = HALT_ERROR; 231 | } 232 | } 233 | 234 | if (isAsync && v.track) { 235 | checkCompletion(); 236 | } 237 | } 238 | }) 239 | ); 240 | if (hasSyncError){ 241 | throw syncError; 242 | } 243 | isAsync = true; 244 | // }}} End Of Sync Section 245 | 246 | if (v.hasValue) { 247 | // Must have value because v.hasValue is true 248 | return v.value!; 249 | } else { 250 | throw HALT_ERROR; 251 | } 252 | }; 253 | } 254 | }); 255 | 256 | // Types 257 | export type Expression = () => T; 258 | 259 | export interface Trackers extends Tracker { 260 | weak: Tracker; 261 | normal: Tracker; 262 | strong: Tracker; 263 | } 264 | 265 | export type Tracker = (o: Observable) => T; 266 | 267 | interface TrackEntry { 268 | hasValue: boolean; 269 | value?: V; 270 | /** subscription to source */ 271 | subscription: Subscription; 272 | /** subscription strength */ 273 | strength: Strength; 274 | /** is tracked $ or untracked _ */ 275 | track: boolean; 276 | /** has been used in latest run */ 277 | used: boolean; 278 | /** source completion status */ 279 | completed: boolean; 280 | } 281 | 282 | const enum Strength { 283 | Weak = 0, 284 | Normal = 1, 285 | Strong = 2 286 | } 287 | 288 | interface Context { 289 | _: Trackers; 290 | $: Trackers; 291 | } 292 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Observable, Subscription } from 'rxjs'; 2 | import { distinctUntilChanged } from 'rxjs/operators'; 3 | import { Expression, forwardTracker, runner, Tracker, Trackers } from './core'; 4 | 5 | 6 | /** 7 | * Function to track Observable inside rxjs-autorun expressions 8 | * 9 | * Also provides `.weak`, `.normal` (default), and `.strong` types of tracking 10 | */ 11 | export const $ = forwardTracker('$'); 12 | 13 | /** 14 | * Function to read latest Observable value (w/o tracking it) inside rxjs-autorun expressions 15 | * 16 | * Also provides `.weak`, `.normal` (default), and `.strong` types of tracking 17 | */ 18 | export const _ = forwardTracker('_'); 19 | 20 | /** 21 | * Automatically run `fn` when tracked inner Observables emit 22 | * 23 | * ```js 24 | * autorun(() => _(a) + $(b)) 25 | * ``` 26 | * 27 | * @param fn Function that uses tracked (`$`) or untracked (`_`) Observables 28 | * @returns RxJS Subscription of distinct execution results 29 | */ 30 | export function autorun(fn: Expression): Subscription { 31 | return combined(fn).subscribe(); 32 | } 33 | 34 | /** 35 | * Automatically run `fn` when tracked inner Observables emit 36 | * 37 | * ```js 38 | * combined(() => _(a) + $(b)) 39 | * ``` 40 | * 41 | * @param fn Function that uses tracked (`$`) or untracked (`_`) Observables 42 | * @returns Observable of execution results 43 | */ 44 | export function combined(fn: Expression): Observable { 45 | return runner(fn); 46 | } 47 | 48 | /** 49 | * Automatically run `fn` when tracked inner Observables emit a **distinct value** 50 | * 51 | * ```js 52 | * computed(() => _(a) + $(b)) 53 | * ``` 54 | * 55 | * @param fn Function that uses tracked (`$`) or untracked (`_`) Observables 56 | * @returns Observable of distinct execution results 57 | */ 58 | export function computed(fn: Expression): Observable { 59 | return runner(fn, true).pipe(distinctUntilChanged()); 60 | } 61 | 62 | // export TS types 63 | export { Expression, Trackers, Tracker }; 64 | -------------------------------------------------------------------------------- /tests/index.test.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject, defer, EMPTY, NEVER, Observable, of, Subject, Subscription, throwError } from 'rxjs'; 2 | import { $, combined, computed, _ } from '../src'; 3 | 4 | describe('autorun', () => { 5 | 6 | type MockObserver = { 7 | next: jest.Mock; 8 | error: jest.Mock; 9 | complete: jest.Mock; 10 | }; 11 | 12 | const makeObserver = (): MockObserver => ({ 13 | next: jest.fn(), 14 | error: jest.fn(), 15 | complete: jest.fn(), 16 | }); 17 | let observer: MockObserver; 18 | let sub: Subscription; 19 | 20 | beforeEach(() => { 21 | observer = makeObserver(); 22 | }); 23 | 24 | afterEach(() => { 25 | if (sub) { 26 | sub.unsubscribe(); 27 | } 28 | }); 29 | 30 | test('Simple instant/cold track', () => { 31 | const o = of(1); 32 | const r = combined(() => $(o)); 33 | sub = r.subscribe(observer); 34 | expect(observer.next.mock.calls).toEqual([[1]]); 35 | }); 36 | 37 | test('Simple hot track', () => { 38 | const o = new Subject(); 39 | const r = combined(() => $(o)); 40 | sub = r.subscribe(observer); 41 | o.next('test'); 42 | expect(observer.next.mock.calls).toEqual([['test']]); 43 | }); 44 | 45 | test('Simple instant/cold untrack', () => { 46 | const o = of(1); 47 | const r = combined(() => _(o)); 48 | sub = r.subscribe(observer); 49 | expect(observer.next.mock.calls.length).toEqual(1); 50 | }); 51 | 52 | test('Simple untrack', () => { 53 | const o = new Subject(); 54 | const r = combined(() => _(o)); 55 | sub = r.subscribe(observer); 56 | o.next('test'); 57 | expect(observer.next).toBeCalledWith('test'); 58 | expect(observer.complete).toBeCalled(); 59 | }); 60 | 61 | test('Dependant runners', () => { 62 | const o = of(1); 63 | const r1 = combined(() => $(o)); 64 | const r2 = combined(() => $(r1)); 65 | sub = r2.subscribe(observer); 66 | expect(observer.next.mock.calls).toEqual([[1]]); 67 | }); 68 | 69 | test('Silent with trackable', () => { 70 | const a = new BehaviorSubject('#'); 71 | const b = new BehaviorSubject(1); 72 | const c = combined(() => _(a) + $(b)); 73 | sub = c.subscribe(observer); // instant update 74 | expect(observer.next.mock.calls.length).toBe(1); 75 | expect(observer.next.mock.calls[0]).toEqual(['#1']); 76 | a.next('💡'); // no update 77 | expect(observer.next.mock.calls.length).toBe(1); 78 | b.next(42); // > 💡42 79 | expect(observer.next.mock.calls.length).toBe(2); 80 | expect(observer.next.mock.calls[1]).toEqual(['💡42']); 81 | }); 82 | 83 | it('should interrupt expression midflight', () => { 84 | const o = new Subject(); 85 | const fn = jest.fn(() => 0); 86 | const r = combined(() => fn() + $(o)); 87 | sub = r.subscribe(observer); 88 | expect(fn.mock.calls.length).toBe(1); 89 | expect(observer.next.mock.calls.length).toBe(0); 90 | o.next(0); 91 | expect(fn.mock.calls.length).toBe(2); 92 | expect(observer.next.mock.calls.length).toBe(1); 93 | }); 94 | 95 | // this might not be desired behavior 96 | it('will skip sync emissions', () => { 97 | const o = of('a', 'b', 'c'); 98 | const r = combined(() => $(o)); 99 | sub = r.subscribe(observer); 100 | expect(observer.next.mock.calls).toEqual([['c']]); 101 | }); 102 | 103 | it('will not accept running $ and _ outside computed', () => { 104 | // Before computed 105 | const e = new Error('$ or _ can only be called within computed or autorun context'); 106 | expect($).toThrow(e); 107 | expect(_).toThrow(e); 108 | expect($.weak).toThrow(e); 109 | expect(_.normal).toThrow(e); 110 | expect($.weak).toThrow(e); 111 | expect(_.normal).toThrow(e); 112 | 113 | const r = combined(() => $(of(1))); 114 | sub = r.subscribe(observer); 115 | 116 | // After computed 117 | expect($).toThrow(e); 118 | expect(_).toThrow(e); 119 | expect($.weak).toThrow(e); 120 | expect(_.normal).toThrow(e); 121 | expect($.weak).toThrow(e); 122 | expect(_.normal).toThrow(e); 123 | }); 124 | 125 | describe('combined — undistinct updates', () => { 126 | it('should react to repeatetive updates', () => { 127 | const o = new Subject(); 128 | const fn = jest.fn(() => 0); 129 | const r = combined(() => $(o) + fn()); 130 | sub = r.subscribe(observer); 131 | o.next(0); 132 | o.next(0); 133 | expect(fn.mock.calls.length).toBe(2); 134 | }); 135 | }); 136 | 137 | describe('computed — distinct updates', () => { 138 | it('should only react to distinctive value changes', () => { 139 | const o = new Subject(); 140 | const fn = jest.fn(() => 0); 141 | const r = computed(() => $(o) + fn()); 142 | sub = r.subscribe(observer); 143 | o.next(0); 144 | o.next(0); 145 | expect(fn.mock.calls.length).toBe(1); 146 | }); 147 | 148 | it('should only emit distinctive results', () => { 149 | const o = new Subject(); 150 | const r = computed(() => $(o) - $(o)); 151 | sub = r.subscribe(observer); 152 | o.next(0); 153 | o.next(1); 154 | o.next(2); 155 | expect(observer.next.mock.calls.length).toBe(1); 156 | }); 157 | }); 158 | 159 | describe('completion', () => { 160 | it('will complete when deps complete', () => { 161 | const o = new BehaviorSubject(1); 162 | const o2 = new BehaviorSubject(2); 163 | const r = combined(() => $(o) + $(o2)); 164 | sub = r.subscribe(observer); 165 | 166 | expect(observer.next).toBeCalledWith(3); 167 | expect(observer.complete).not.toHaveBeenCalled(); 168 | 169 | // 1 of 2 completes. Result doesn't complete. 170 | o2.complete(); 171 | o.next(3); 172 | expect(observer.next).toBeCalledWith(5); 173 | expect(observer.complete).not.toHaveBeenCalled(); 174 | 175 | // Both deps completed. Result completes as well. 176 | o.complete(); 177 | expect(observer.complete).toHaveBeenCalled(); 178 | }); 179 | 180 | it('doesn\'t care about completion of untracked dep', () => { 181 | const o = new BehaviorSubject(1); 182 | const o2 = new BehaviorSubject(2); 183 | const r = combined(() => $(o) + _(o2)); 184 | sub = r.subscribe(observer); 185 | 186 | expect(observer.next).toBeCalledWith(3); 187 | expect(observer.complete).not.toHaveBeenCalled(); 188 | 189 | // The only tracked dep completes, so result completes 190 | o.complete(); 191 | expect(observer.complete).toHaveBeenCalled(); 192 | }); 193 | 194 | it('completes immediately when only using untracked values', () => { 195 | const o = new BehaviorSubject(1); 196 | const o2 = new BehaviorSubject(2); 197 | const r = combined(() => _(o) + _(o2)); 198 | sub = r.subscribe(observer); 199 | 200 | expect(observer.next).toBeCalledWith(3); 201 | expect(observer.complete).toHaveBeenCalled(); 202 | }); 203 | 204 | it('doesn\'t rerun expression on completion of dep', () => { 205 | const o = new BehaviorSubject(1); 206 | let runCount = 0; 207 | const r = combined(() => $(o) + ++runCount); 208 | sub = r.subscribe(observer); 209 | 210 | expect(observer.next).toBeCalledWith(2); 211 | expect(runCount).toEqual(1); 212 | 213 | o.complete(); 214 | expect(observer.next).toBeCalledWith(2); 215 | expect(runCount).toEqual(1); 216 | }); 217 | 218 | it('completes correctly when deps complete synchronously', () => { 219 | const o = of(1); 220 | const o2 = of(2); 221 | const r = combined(() => $(o) + $(o2)); 222 | sub = r.subscribe(observer); 223 | 224 | expect(observer.next).toBeCalledWith(3); 225 | expect(observer.complete).toHaveBeenCalled(); 226 | }); 227 | }); 228 | 229 | describe('error', () => { 230 | it('will raise error if expression throws', () => { 231 | const r = combined(() => { throw 42; }); 232 | sub = r.subscribe(observer); 233 | 234 | expect(observer.next).not.toBeCalled(); 235 | expect(observer.error).toBeCalledWith(42); 236 | }); 237 | 238 | it('errors out when one of the deps errors out', () => { 239 | const o = new BehaviorSubject(1); 240 | const o2 = new BehaviorSubject(2); 241 | const r = combined(() => $(o) + $(o2)); 242 | sub = r.subscribe(observer); 243 | 244 | expect(observer.next).toBeCalledWith(3); 245 | expect(observer.error).not.toHaveBeenCalled(); 246 | 247 | o2.error('Some failure'); 248 | expect(observer.error).toHaveBeenCalledWith('Some failure'); 249 | }); 250 | 251 | it('errors out even when error value is undefined', () => { 252 | const o = new BehaviorSubject(1); 253 | const r = combined(() => $(o)); 254 | sub = r.subscribe(observer); 255 | 256 | expect(observer.next).toBeCalledWith(1); 257 | expect(observer.error).not.toHaveBeenCalled(); 258 | 259 | o.error(void 0); 260 | expect(observer.error).toHaveBeenCalledWith(void 0); 261 | }); 262 | 263 | it('also considers untracked observable errors', () => { 264 | const o = new BehaviorSubject(1); 265 | const o2 = new BehaviorSubject(2); 266 | const r = combined(() => $(o) + _(o2)); 267 | sub = r.subscribe(observer); 268 | 269 | expect(observer.next).toBeCalledWith(3); 270 | expect(observer.error).not.toHaveBeenCalled(); 271 | 272 | // Untracked observer errors out 273 | o2.error('Byebye'); 274 | expect(observer.error).toHaveBeenCalledWith('Byebye'); 275 | }); 276 | 277 | it('completes correctly when deps error out synchronously', () => { 278 | const o = of(1); 279 | const o2 = throwError('Byebye'); 280 | const r = combined(() => $(o) + $(o2)); 281 | sub = r.subscribe(observer); 282 | 283 | expect(observer.next).not.toBeCalled(); 284 | expect(observer.error).toHaveBeenCalledWith('Byebye'); 285 | }); 286 | }); 287 | 288 | describe('multiple subscribers', () => { 289 | it('should subscribe twice', () => { 290 | let count = 0; 291 | const o = defer(() => of(++count)); 292 | const r = combined(() => $(o)); 293 | 294 | r.subscribe(); 295 | r.subscribe(); 296 | expect(count).toBe(2); 297 | }); 298 | 299 | it('can be subscribed multiple times', () => { 300 | const o = new BehaviorSubject(1); 301 | const o2 = new BehaviorSubject(2); 302 | const observer2 = makeObserver(); 303 | const r = combined(() => $(o) + _(o2)); 304 | 305 | sub = new Subscription(); 306 | sub.add(r.subscribe(observer)); 307 | expect(observer.next).toBeCalledWith(3); 308 | expect(observer2.next).not.toHaveBeenCalled(); 309 | 310 | o.next(3); 311 | expect(observer.next).toBeCalledWith(5); 312 | expect(observer.next).toBeCalledTimes(2); 313 | expect(observer2.next).not.toHaveBeenCalled(); 314 | 315 | sub.add(r.subscribe(observer2)); 316 | expect(observer.next).toBeCalledWith(5); 317 | expect(observer.next).toBeCalledTimes(2); 318 | expect(observer.complete).not.toHaveBeenCalled(); 319 | expect(observer2.next).toBeCalledWith(5); 320 | expect(observer2.next).toBeCalledTimes(1); 321 | expect(observer2.complete).not.toHaveBeenCalled(); 322 | 323 | o.complete(); 324 | expect(observer.next).toBeCalledWith(5); 325 | expect(observer.next).toBeCalledTimes(2); 326 | expect(observer.complete).toHaveBeenCalled(); 327 | expect(observer2.next).toBeCalledWith(5); 328 | expect(observer2.next).toBeCalledTimes(1); 329 | expect(observer2.complete).toHaveBeenCalled(); 330 | }); 331 | 332 | it('subscribes upstream obserables multiple times', () => { 333 | let counter = 0; 334 | const o = defer(() => new BehaviorSubject(++counter)); 335 | const observer2 = makeObserver(); 336 | const r = combined(() => $(o)); 337 | 338 | sub = new Subscription(); 339 | sub.add(r.subscribe(observer)); 340 | expect(observer.next).toBeCalledWith(1); 341 | 342 | sub.add(r.subscribe(observer2)); 343 | expect(observer2.next).toBeCalledWith(2); 344 | }); 345 | 346 | it('subscriptions complete independently', () => { 347 | let counter = 0; 348 | const os = [new BehaviorSubject(1), new BehaviorSubject(2)]; 349 | const o = defer(() => os[counter++]); 350 | const observer2 = makeObserver(); 351 | const r = combined(() => $(o)); 352 | 353 | sub = new Subscription(); 354 | sub.add(r.subscribe(observer)); 355 | sub.add(r.subscribe(observer2)); 356 | expect(observer.complete).not.toBeCalled(); 357 | expect(observer2.complete).not.toBeCalled(); 358 | 359 | os[0].complete(); 360 | expect(observer.complete).toBeCalled(); 361 | expect(observer2.complete).not.toBeCalled(); 362 | 363 | os[1].complete(); 364 | expect(observer.complete).toBeCalled(); 365 | expect(observer2.complete).toBeCalled(); 366 | }); 367 | 368 | it('subscriptions error out independently', () => { 369 | let counter = 0; 370 | const os = [new BehaviorSubject(1), new BehaviorSubject(2)]; 371 | const o = defer(() => os[counter++]); 372 | const observer2 = makeObserver(); 373 | const r = combined(() => $(o)); 374 | 375 | sub = new Subscription(); 376 | sub.add(r.subscribe(observer)); 377 | sub.add(r.subscribe(observer2)); 378 | expect(observer.error).not.toBeCalled(); 379 | expect(observer2.error).not.toBeCalled(); 380 | 381 | os[0].error('First error'); 382 | expect(observer.error).toBeCalledWith('First error'); 383 | expect(observer2.error).not.toBeCalled(); 384 | 385 | os[1].error('Second error'); 386 | expect(observer.error).toBeCalledWith('First error'); 387 | expect(observer2.error).toBeCalledWith('Second error'); 388 | }); 389 | }); 390 | 391 | describe('branching', () => { 392 | describe('tracking', () => { 393 | it('untracks a dep when not tracked any longer due to branching', () => { 394 | const o = new BehaviorSubject(1); 395 | const o2 = new BehaviorSubject(2); 396 | let counter = 0; 397 | const r = combined(() => { 398 | ++counter; 399 | _(o2); // Make o2 strong so it stays subscribed 400 | // When o is odd, o2 is tracked 401 | // When o is even, o2 is not tracked (but still observed/subscribed) 402 | return ($(o) % 2) ? $(o2) : -1; 403 | }); 404 | sub = r.subscribe(observer); 405 | 406 | expect(observer.next).toBeCalledWith(2); 407 | expect(counter).toEqual(1); 408 | 409 | o2.next(3); // o2 is tracked, so new value expected 410 | expect(observer.next).toBeCalledWith(3); 411 | expect(counter).toEqual(2); 412 | 413 | o.next(2); // o2 now becomes untracked cause o is even 414 | expect(observer.next).toBeCalledWith(-1); 415 | expect(counter).toEqual(3); 416 | 417 | o2.next(4); // o2 is not tracked, so no effect. 418 | expect(observer.next).toBeCalledWith(-1); 419 | expect(counter).toEqual(3); 420 | 421 | o.next(1); // o2 now becomes tracked again 422 | expect(observer.next).toBeCalledWith(4); 423 | expect(counter).toEqual(4); 424 | 425 | o2.next(10); // o2 is tracked again, so new value expected 426 | expect(observer.next).toBeCalledWith(10); 427 | expect(counter).toEqual(5); 428 | }); 429 | 430 | it('untracks a dep when it becomes unreachable due to late subscription', () => { 431 | let counter = 0; 432 | // o is the discriminator. It determines whether o3 is observed 433 | const o = new BehaviorSubject(0); 434 | // o2 is the indicator. It indicates whether it is tracked or not 435 | const o2 = new BehaviorSubject(1); 436 | // o3 is the late emitter. It doesn't emit immediately 437 | const o3 = new Subject(); 438 | const r = combined(() => { 439 | ++counter; 440 | _(o2); 441 | const n = $(o) % 2 ? $(o3) : -1; 442 | return n + $(o2); 443 | }); 444 | sub = r.subscribe(observer); 445 | 446 | // o3 not subscribed yet. No problem. 447 | expect(observer.next).toBeCalledWith(0); // -1 + 1 448 | expect(counter).toEqual(1); 449 | 450 | // o2 is tracked 451 | o2.next(2); 452 | expect(observer.next).toBeCalledWith(1); // -1 + 2 453 | expect(counter).toEqual(2); 454 | 455 | // Will start to observe late emitter o3 now. 456 | // It doesn't have a value yet so the expression will be aborted. 457 | // o2 will be untracked now because its value change doesn't change 458 | // the outcome of the expression. 459 | o.next(1); 460 | expect(observer.next).toBeCalledWith(1); // No change 461 | expect(counter).toEqual(3); 462 | 463 | // o2 is not tracked so won't run the expression 464 | o2.next(3); 465 | expect(observer.next).toBeCalledWith(1); // No change 466 | expect(counter).toEqual(3); // Same as before 467 | 468 | // o3 now has a value, so o2 will be tracked and it's new value (3) 469 | // will be used. 470 | o3.next(1); 471 | expect(observer.next).toBeCalledWith(4); // 1 (o3) + 3 (o2) 472 | expect(counter).toEqual(4); 473 | 474 | // o2 is tracked again 475 | o2.next(4); 476 | expect(observer.next).toBeCalledWith(5); // 1 (o3) + 4 (o2) 477 | expect(counter).toEqual(5); 478 | }); 479 | }); 480 | 481 | describe('strength', () => { 482 | let isO2Subscribed: boolean; 483 | let counter: number; 484 | let o: BehaviorSubject; 485 | let o2: Observable; 486 | let o2_next: (n: number) => void; 487 | let o3: Subject; 488 | 489 | beforeEach(() => { 490 | isO2Subscribed = false; 491 | counter = 0; 492 | o = new BehaviorSubject(1); 493 | o2_next = _ => {}; 494 | o2 = new Observable(obs => { 495 | isO2Subscribed = true; 496 | obs.next(1); 497 | o2_next = obs.next.bind(obs); 498 | return () => isO2Subscribed = false; 499 | }); 500 | o3 = new Subject(); 501 | }); 502 | 503 | it('unsubscribes a dep when it is not relevant any longer due to branching', () => { 504 | const r = combined(() => { 505 | ++counter; 506 | // When o is odd, o2 is tracked 507 | // When o is even, o2 is not tracked and should be unsubscribed 508 | return $(o) % 2 ? $(o2) : -1; 509 | }); 510 | sub = r.subscribe(observer); 511 | 512 | expect(observer.next).toBeCalledWith(1); 513 | expect(counter).toEqual(1); 514 | expect(isO2Subscribed).toBeTruthy(); 515 | 516 | // Becomes unused, so will be unsubscribed. 517 | o.next(2); 518 | expect(observer.next).toBeCalledWith(-1); 519 | expect(counter).toEqual(2); 520 | expect(isO2Subscribed).toBeFalsy(); 521 | 522 | // Becomes used again, so will be subscribed. 523 | o.next(1); 524 | expect(observer.next).toBeCalledWith(1); 525 | expect(counter).toEqual(3); 526 | expect(isO2Subscribed).toBeTruthy(); 527 | }); 528 | 529 | it('doesn\'t unsubscribes a dep when it becomes unreachable due to late subscription', () => { 530 | // o is the discriminator. It determines whether o3 is observed 531 | // o2 is the detector. It detects whether it is observed or not 532 | // o3 is the late emitter. It doesn't emit immediately 533 | o.next(0); 534 | const r = combined(() => { 535 | ++counter; 536 | const n = $(o) % 2 ? $(o3) : -1; 537 | return n + $(o2); 538 | }); 539 | sub = r.subscribe(observer); 540 | 541 | // o3 not subscribed yet. No problem. 542 | expect(observer.next).toBeCalledWith(0); // -1 + 1 543 | expect(counter).toEqual(1); 544 | expect(isO2Subscribed).toBeTruthy(); 545 | 546 | // Will start to observe late emitter o3 now. 547 | // Currently the spec says that by default, it should *not* 548 | // unsubscribe an observable when it becomes (temporary) 549 | // unreachable due to late subscription. 550 | o.next(1); 551 | expect(observer.next).toBeCalledWith(0); // No change 552 | expect(counter).toEqual(2); 553 | expect(isO2Subscribed).toBeTruthy(); // Still subscribed 554 | 555 | // But since a value change of o2, wouldn't currently have any 556 | // effect, it temporarily isn't tracked. 557 | o2_next(2); 558 | expect(observer.next).toBeCalledWith(0); // No change 559 | expect(counter).toEqual(2); // also no change 560 | expect(isO2Subscribed).toBeTruthy(); // Still subscribed 561 | 562 | // o3 now has a value, so o2 will be tracked again. It's newly 563 | // acquired value (2) will be used. 564 | o3.next(1); 565 | expect(observer.next).toBeCalledWith(3); // 1 (o3) + 2 (o2) 566 | expect(counter).toEqual(3); 567 | expect(isO2Subscribed).toBeTruthy(); 568 | 569 | // Will have effect now 570 | o2_next(3); 571 | expect(observer.next).toBeCalledWith(4); // 1 (o3) + 3 (o2) 572 | expect(counter).toEqual(4); // changed again 573 | expect(isO2Subscribed).toBeTruthy(); 574 | }); 575 | 576 | it('unsubscribes a weak dep when it becomes unreachable due to late subscription', () => { 577 | // o is the discriminator. It determines whether o3 is observed 578 | // o2 is the detector. It detects whether it is observed or not 579 | // o3 is the late emitter. It doesn't emit immediately 580 | o.next(0); 581 | const r = combined(() => { 582 | ++counter; 583 | const n = $(o) % 2 ? $(o3) : -1; 584 | return n + $.weak(o2); // weak tracking 585 | }); 586 | sub = r.subscribe(observer); 587 | 588 | // o3 not subscribed yet. No problem. 589 | expect(observer.next).toBeCalledWith(0); // -1 + 1 590 | expect(counter).toEqual(1); 591 | expect(isO2Subscribed).toBeTruthy(); 592 | 593 | // Will start to observe late emitter o3 now. 594 | // It doesn't have a value yet so the expression will be aborted. 595 | // Note that o2 will be unsubscribed now because it is weak. Note 596 | // that this is OK, because a new value in o2 would not be able to 597 | // change the outcome of the expression, so it becomes irrelevant. 598 | o.next(1); 599 | expect(observer.next).toBeCalledWith(0); // No change 600 | expect(counter).toEqual(2); 601 | expect(isO2Subscribed).toBeFalsy(); // Is now unsubscribed 602 | 603 | // Will abort again cause o3 still doesn't have a value 604 | o.next(3); 605 | expect(observer.next).toBeCalledWith(0); // No change 606 | expect(counter).toEqual(3); 607 | expect(isO2Subscribed).toBeFalsy(); // Still unsubscribed 608 | 609 | // o3 now has a value, so o2 will be subscribed again 610 | o3.next(1); 611 | expect(observer.next).toBeCalledWith(2); // 1 (o3) + 1 (o2) 612 | expect(counter).toEqual(4); 613 | expect(isO2Subscribed).toBeTruthy(); // Subscribed again 614 | }); 615 | 616 | it('will ajust strength when dep used multiple times in different context', () => { 617 | const r = combined(() => { 618 | ++counter; 619 | const n = $(o) % 2 ? $(o3) : $(o2); // always strongly bound in false case 620 | return n 621 | + $.weak(o2) // first weakly bound 622 | + $(o2); // later strongly bound 623 | }); 624 | sub = r.subscribe(observer); 625 | 626 | expect(observer.next).not.toBeCalled(); 627 | expect(isO2Subscribed).toBeFalsy(); 628 | 629 | o3.next(4); 630 | expect(observer.next).toBeCalledWith(6); // 4 (o3) + 1 (o2) + 1 (o2) 631 | expect(isO2Subscribed).toBeTruthy(); 632 | 633 | // unsubscribes o3 so it needs to emit again later 634 | o.next(0); 635 | 636 | // Subscribes o3 again but it doesn't emit. So for o2 to be still subscribed 637 | // it needs to have 'normal' strength. 638 | o.next(1); 639 | expect(isO2Subscribed).toBeTruthy(); 640 | }); 641 | 642 | it('will bring back strength when normal dep not used anymore', () => { 643 | o.next(0); 644 | const r = combined(() => { 645 | ++counter; 646 | switch($(o)) 647 | { 648 | case 0: return $.weak(o2) + $(o2); 649 | case 1: return $.weak(o2); 650 | case 2: return $(o3) + $(o2); 651 | } 652 | }); 653 | sub = r.subscribe(observer); 654 | 655 | expect(isO2Subscribed).toBeTruthy(); 656 | 657 | // Remains subscribed due to strength 'normal' 658 | o.next(2); 659 | expect(isO2Subscribed).toBeTruthy(); 660 | 661 | // It should now reduce its strength to 'weak', because 662 | // the 'normal' bound o2 is not used in a succeeded run. 663 | o.next(1); 664 | expect(isO2Subscribed).toBeTruthy(); 665 | 666 | // Hence should now be unsubscribed. 667 | o.next(2); 668 | expect(isO2Subscribed).toBeFalsy(); 669 | }); 670 | 671 | it('will stay observed when strongly observed', () => { 672 | o.next(0); 673 | const r = combined(() => { 674 | ++counter; 675 | switch($(o)) 676 | { 677 | case 0: return $.weak(o2) + $.strong(o2); 678 | case 1: return $.weak(o2); 679 | case 2: return $(o3) + $(o2); 680 | case 3: return 100; 681 | case 4: return $.normal(o2); 682 | } 683 | }); 684 | sub = r.subscribe(observer); 685 | 686 | expect(isO2Subscribed).toBeTruthy(); 687 | 688 | // Remains subscribed due to strength 'strong' 689 | o.next(2); 690 | expect(isO2Subscribed).toBeTruthy(); 691 | 692 | // It should *not* reduce its strength to 'weak', even when 693 | // the 'strong' bound o2 is not used in a succeeded run. 694 | o.next(1); 695 | expect(isO2Subscribed).toBeTruthy(); 696 | 697 | // Hence it should remain subscribed on midflight interrupt ... 698 | o.next(2); 699 | expect(isO2Subscribed).toBeTruthy(); 700 | 701 | // ... and on a succeeded run where it is not used. 702 | o.next(3); 703 | expect(isO2Subscribed).toBeTruthy(); 704 | 705 | // Also when it acquires normal strength... 706 | o.next(4); 707 | expect(isO2Subscribed).toBeTruthy(); 708 | 709 | // ... it remains strongly subscribed. 710 | o.next(3); 711 | expect(isO2Subscribed).toBeTruthy(); 712 | 713 | // Except of cource when we are done with it. 714 | sub.unsubscribe(); 715 | expect(isO2Subscribed).toBeFalsy(); 716 | }); 717 | }); 718 | 719 | describe('NEVER', () => { 720 | it('will skip emission with NEVER', () => { 721 | const o = new Subject(); 722 | let i = 0; 723 | const r = combined(() => $(o) ? i++ : $(NEVER)); 724 | sub = r.subscribe(observer); 725 | expect(observer.next).not.toHaveBeenCalled(); 726 | 727 | // ok path 728 | o.next(true); 729 | expect(observer.next).toHaveBeenCalledWith(0); 730 | 731 | // NEVER path 732 | observer.next.mockClear(); 733 | o.next(false); 734 | expect(observer.next).not.toHaveBeenCalled(); 735 | 736 | // ok again 737 | observer.next.mockClear(); 738 | o.next(true); 739 | expect(observer.next).toHaveBeenCalledWith(1); 740 | }); 741 | }); 742 | 743 | describe('EMPTY', () => { 744 | it('will complete with sync EMPTY', () => { 745 | const o = new Subject(); 746 | const fn = jest.fn(() => 'hello'); 747 | const r = combined(() => $(o) + $(EMPTY) + fn()); 748 | sub = r.subscribe(observer); 749 | expect(observer.next).not.toHaveBeenCalled(); 750 | o.next('pew'); 751 | // will not proceed to the end 752 | expect(observer.next).not.toHaveBeenCalled(); 753 | // will interrupt before fn() 754 | expect(fn).not.toHaveBeenCalled(); 755 | // will complete 756 | expect(observer.complete).toHaveBeenCalled(); 757 | }); 758 | 759 | it('will complete with a-sync EMPTY', () => { 760 | const o = new Subject(); 761 | const asyncEMPTY = new Subject(); 762 | const fn = jest.fn(() => 'hello'); 763 | const r = combined(() => $(o) + $(asyncEMPTY) + fn()); 764 | sub = r.subscribe(observer); 765 | 766 | // proceed 767 | o.next('pew'); 768 | expect(observer.next).not.toBeCalled(); 769 | expect(observer.complete).not.toBeCalled(); 770 | 771 | // completes before it emits 772 | asyncEMPTY.complete(); 773 | // will not proceed to the end 774 | expect(observer.next).not.toBeCalled(); 775 | // will interrupt before fn() 776 | expect(fn).not.toHaveBeenCalled(); 777 | // will complete 778 | expect(observer.complete).toBeCalled(); 779 | }); 780 | }); 781 | }); 782 | 783 | describe('untracked with late emission', () => { 784 | it('always tracks a dep until it emits', () => { 785 | const o = new Subject(); 786 | const r = combined(() => _(o)); 787 | sub = r.subscribe(observer); 788 | 789 | // Waiting for a value of o... 790 | expect(observer.next).not.toBeCalled(); 791 | expect(observer.complete).not.toBeCalled(); 792 | 793 | // o receiving a value. Untracked so complete immediately 794 | o.next(3); 795 | expect(observer.next).toBeCalledWith(3); 796 | expect(observer.complete).toBeCalled(); 797 | }); 798 | 799 | it('will eventually start listening for tracked dep', () => { 800 | const o = new Subject(); 801 | const o2 = new BehaviorSubject(2); 802 | const r = combined(() => _(o) + $(o2)); 803 | sub = r.subscribe(observer); 804 | 805 | // Waiting for a value of o... o2 not observed yet. 806 | expect(observer.next).not.toBeCalled(); 807 | 808 | // o receiving a value. Will listen to tracked o2 now. 809 | o.next(3); 810 | expect(observer.next).toBeCalledWith(5); // 3 (o) + 2 (o2) 811 | 812 | o2.next(1); 813 | expect(observer.next).toBeCalledWith(4); // 3 (o) + 1 (o2) 814 | }); 815 | 816 | it('will only change on first value of untracked dep', () => { 817 | const o = new Subject(); 818 | const o2 = new BehaviorSubject(2); 819 | const r = combined(() => $(o2) + _(o)); 820 | sub = r.subscribe(observer); 821 | 822 | // Waiting for a value of o... 823 | expect(observer.next).not.toBeCalled(); 824 | 825 | // o receiving first value. 826 | o.next(3); 827 | expect(observer.next).toBeCalledWith(5); // 3 (o) + 2 (o2) 828 | 829 | // o receiving second value. But untracked so expression not called. 830 | o.next(4); 831 | expect(observer.next).toBeCalledWith(5); // Not changed 832 | 833 | // o2 receives new value. Will use changed value of o now too. 834 | o2.next(6); 835 | expect(observer.next).toBeCalledWith(10); // 4 (o) + 6 (o2) 836 | }); 837 | 838 | it('will complete anyway when untracked value completes before it emits', () => { 839 | const o = new Subject(); 840 | const r = combined(() => _(o)); 841 | sub = r.subscribe(observer); 842 | 843 | expect(observer.next).not.toBeCalled(); 844 | expect(observer.complete).not.toBeCalled(); 845 | 846 | o.complete(); 847 | expect(observer.next).not.toBeCalled(); 848 | expect(observer.complete).toBeCalled(); 849 | }); 850 | 851 | }); 852 | 853 | 854 | // TODO: cover logic branching w/ late subscription 855 | }); 856 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "outDir": "./dist/", 5 | "noImplicitAny": false, 6 | "noUnusedLocals": false, 7 | "strictNullChecks": true, 8 | "moduleResolution": "node", 9 | "module": "commonjs", 10 | "target": "es2015", 11 | "sourceMap": true, 12 | "downlevelIteration": false, 13 | "lib": [] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const config = require('./configs/webpack.base'); 2 | 3 | module.exports = config; 4 | --------------------------------------------------------------------------------