├── .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 |
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 |
--------------------------------------------------------------------------------