├── .npmignore ├── .gitignore ├── src ├── Getter.ts ├── index.ts ├── mobxShim.ts ├── autorunThrottled.ts ├── asyncComputed.ts ├── throttledComputed.ts ├── promisedComputed.ts └── deprecatedComputedAsync.ts ├── .vscode ├── settings.json └── launch.json ├── interactive-tests ├── index.html ├── webpack.config.js └── index.tsx ├── test ├── delay.ts ├── asyncComputedRenderTests.tsx ├── util.ts ├── throttledComputedTests.ts ├── asyncComputedTests.ts ├── promisedComputedTests.ts └── deprecatedComputedAsyncTests.ts ├── .travis.yml ├── fix-coverage.js ├── CHANGES.md ├── tsconfig.json ├── LICENSE ├── package.json └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | .git 2 | .DS_Store 3 | coverage 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | built 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /src/Getter.ts: -------------------------------------------------------------------------------- 1 | export interface Getter { 2 | get(): T; 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /interactive-tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /test/delay.ts: -------------------------------------------------------------------------------- 1 | export function delay(ms: number): Promise { 2 | return new Promise(resolve => setTimeout(resolve, ms)); 3 | } 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | install: 3 | - npm install 4 | script: npm run all 5 | after_success: 6 | - cat ./coverage/lcov.info|./node_modules/coveralls/bin/coveralls.js 7 | node_js: 8 | - 10 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./autorunThrottled"; 2 | export * from "./throttledComputed"; 3 | export { promisedComputed, PromisedComputedValue, isPromiseLike } from "./promisedComputed"; 4 | export * from "./asyncComputed"; 5 | export * from "./Getter"; 6 | 7 | export * from "./deprecatedComputedAsync"; 8 | -------------------------------------------------------------------------------- /fix-coverage.js: -------------------------------------------------------------------------------- 1 | var fs = require("fs"), 2 | prefix = "var __decorate =", 3 | paths = [ 4 | "built/src/promisedComputed.js", 5 | "built/src/deprecatedComputedAsync.js", 6 | ]; 7 | 8 | paths.forEach(path => { 9 | 10 | // tell istanbul to ignore TS-generated decorator code 11 | var src = fs.readFileSync(path, "utf8"); 12 | src = src.replace(prefix, "/* istanbul ignore next */\n" + prefix); 13 | fs.writeFileSync(path, src); 14 | }); 15 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | ## 1.1.0 2 | 3 | * Enhanced error handling: `rethrow` option causes errors to be rethrown when `value` is accessed in `fail` state. 4 | * `fetch` function can optionally return a plain value. 5 | 6 | ## 1.0.0 7 | 8 | * Added error handling (issue #2). 9 | * Most recent current value persists when not being observed. This should be less surprising behaviour and is more consistent with behaviour of the new error-related properties. 10 | 11 | ## 0.2.0 12 | 13 | * Initial public publish. 14 | -------------------------------------------------------------------------------- /interactive-tests/webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | 3 | module.exports = { 4 | entry: './index.tsx', 5 | output: { 6 | filename: 'bundle.js', 7 | path: __dirname + '/built' 8 | }, 9 | devtool: 'source-map', 10 | resolve: { 11 | extensions: ['.webpack.js', '.web.js', '.ts', '.tsx', '.js'] 12 | }, 13 | module: { 14 | loaders: [ 15 | { test: /\.tsx?$/, loader: 'ts-loader' } 16 | ] 17 | }, 18 | plugins: [], 19 | externals: {} 20 | } 21 | -------------------------------------------------------------------------------- /src/mobxShim.ts: -------------------------------------------------------------------------------- 1 | const mobx = require("mobx"); 2 | 3 | import { IAtom } from "mobx"; 4 | 5 | export const useStrict: (s: boolean) => void = mobx.configure ? 6 | (s => mobx.configure({ enforceActions: s ? "always" : "never" })) : mobx.useStrict 7 | 8 | export type CreateAtom = (name: string, onBecomeObservedHandler?: () => void, onBecomeUnobservedHandler?: () => void) => IAtom; 9 | 10 | export const createAtom: CreateAtom = mobx.createAtom || 11 | ((name, on, off) => new mobx.Atom(name, on, off)); 12 | 13 | export interface GlobalState { 14 | trackingDerivation: boolean; 15 | } 16 | 17 | export const getGlobalState = mobx._getGlobalState || mobx.extras.getGlobalState; 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "noImplicitAny": true, 6 | "strictNullChecks": true, 7 | "sourceMap": true, 8 | "declaration": true, 9 | "alwaysStrict": true, 10 | "esModuleInterop": true, 11 | "experimentalDecorators": true, 12 | "jsx": "react", 13 | "noFallthroughCasesInSwitch": true, 14 | "noImplicitThis": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noImplicitReturns": true, 18 | "outDir": "built", 19 | "lib": [ 20 | "es5", 21 | "es2015.promise", 22 | "dom" 23 | ] 24 | }, 25 | "exclude": [ 26 | "node_modules", 27 | "built" 28 | ] 29 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible Node.js debug attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "program": "${workspaceRoot}/built/test/asyncComputedTests.js", 12 | "cwd": "${workspaceRoot}", 13 | "outFiles": [], 14 | "sourceMaps": true 15 | }, 16 | { 17 | "type": "node", 18 | "request": "attach", 19 | "name": "Attach to Process", 20 | "port": 5858, 21 | "outFiles": [], 22 | "sourceMaps": true 23 | } 24 | ] 25 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Daniel Earwicker 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 | -------------------------------------------------------------------------------- /test/asyncComputedRenderTests.tsx: -------------------------------------------------------------------------------- 1 | require('jsdom-global')(); 2 | 3 | import test from "blue-tape"; 4 | import { testStrictness } from "./util"; 5 | import { delay } from "./delay"; 6 | import { asyncComputed } from "../src/index" 7 | import * as React from "react"; 8 | import * as ReactDOM from "react-dom"; 9 | import { observer } from "mobx-react"; 10 | 11 | @observer 12 | class C extends React.Component 13 | { 14 | ac = asyncComputed(0, 10, async () => { 15 | await delay(100); 16 | return 1; 17 | }) 18 | 19 | render() { 20 | return {this.ac.get()} 21 | } 22 | } 23 | 24 | testStrictness("asyncComputed - can be used in an observer render method", async (assert: test.Test) => { 25 | 26 | const root = document.body.appendChild(document.createElement('div')); 27 | 28 | ReactDOM.render(, root); 29 | 30 | const rendered = root.querySelector("span")!; 31 | 32 | assert.equal(rendered.innerHTML, "0", "Initially renders 0"); 33 | 34 | while (rendered.innerHTML === "0") { 35 | await delay(5); 36 | } 37 | 38 | assert.equal(rendered.innerHTML, "1", "Transitions to 1"); 39 | }); 40 | 41 | -------------------------------------------------------------------------------- /src/autorunThrottled.ts: -------------------------------------------------------------------------------- 1 | import { Reaction } from "mobx" 2 | 3 | /** 4 | * Closely based on autorunAsync but with difference that the first execution 5 | * happens synchronously. This allows `delayedComputed` to have a simpler 6 | * type signature: the value is never `undefined`. 7 | * 8 | * @param func The function to execute in reaction 9 | * @param delay The minimum delay between executions 10 | * @param name (optional) For MobX debug purposes 11 | */ 12 | export function autorunThrottled(func: () => void, delay: number, name?: string): () => void { 13 | if (!name) { 14 | name = "autorunThrottled"; 15 | } 16 | let isScheduled = false, isStarted = false; 17 | const r = new Reaction(name, () => { 18 | if (!isStarted) { 19 | isStarted = true; 20 | r.track(func); 21 | } else if (!isScheduled) { 22 | isScheduled = true; 23 | setTimeout(() => { 24 | isScheduled = false; 25 | if (!r.isDisposed) { 26 | r.track(func); 27 | } 28 | }, delay || 1); 29 | } 30 | }); 31 | r.runReaction(); 32 | return r.getDisposer(); 33 | } 34 | -------------------------------------------------------------------------------- /src/asyncComputed.ts: -------------------------------------------------------------------------------- 1 | import { promisedComputed, PromisedComputedValue } from "./promisedComputed"; 2 | import { throttledComputed } from "./throttledComputed" 3 | 4 | /** 5 | * Composition of promisedComputed and throttledComputed, so performs 6 | * conversion of a promised value into a plain value and also waits for 7 | * the specified minimum delay before launching a new promise in response 8 | * to changes. 9 | * 10 | * @param init Value to assume until the promise first resolves 11 | * @param delay Minimum time to wait between creating new promises 12 | * @param compute Evaluates to a promised or plain value 13 | * @param name (optional) For MobX debug purposes 14 | */ 15 | export function asyncComputed( 16 | init: T, 17 | delay: number, 18 | compute: () => T | PromiseLike, 19 | name?: string 20 | ): PromisedComputedValue { 21 | const throttled = throttledComputed(compute, delay, name); 22 | const promised = promisedComputed(init, throttled.get); 23 | 24 | return { 25 | get() { 26 | return promised.get(); 27 | }, 28 | get busy() { 29 | return promised.busy; 30 | }, 31 | getNonReactive() { 32 | return promised.getNonReactive(); 33 | }, 34 | refresh() { 35 | throttled.refresh(); 36 | } 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /test/util.ts: -------------------------------------------------------------------------------- 1 | import test from "blue-tape"; 2 | import { useStrict } from "../src/mobxShim"; 3 | import { observable, runInAction } from "mobx" 4 | 5 | import { delay } from "./delay"; 6 | 7 | export function testCombinations( 8 | description: string, 9 | script: (delayed: boolean, assert: test.Test) => Promise 10 | ) { 11 | for (const delayed of ([true, false])) { 12 | testStrictness(`${description}, delayed=${delayed}`, 13 | assert => script(delayed, assert) 14 | ); 15 | } 16 | } 17 | 18 | export function testStrictness( 19 | description: string, 20 | script: (assert: test.Test) => Promise 21 | ) { 22 | for (const strict of [true, false]) { 23 | test(`${description}, strict=${strict}`, 24 | assert => { 25 | useStrict(strict); 26 | return script(assert); 27 | } 28 | ); 29 | } 30 | } 31 | 32 | export async function waitForLength(ar: any[], length: number) { 33 | while (ar.length !== length) { 34 | await delay(5); 35 | } 36 | } 37 | 38 | export class Obs { 39 | 40 | @observable v: T; 41 | 42 | constructor(init: T) { 43 | runInAction(() => this.v = init); 44 | } 45 | 46 | get() { 47 | return this.v; 48 | } 49 | 50 | set(val: T) { 51 | this.v = val; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "computed-async-mobx", 3 | "version": "6.1.0", 4 | "description": "Define a computed by returning a Promise", 5 | "main": "built/src/index.js", 6 | "typings": "built/src/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "test": "tape built/test/**/*Tests.js", 10 | "coverage": "node fix-coverage.js && istanbul cover tape built/test/**/*Tests.js", 11 | "all": "npm run build && npm run test && npm run coverage", 12 | "prepublish": "npm run build && npm run test" 13 | }, 14 | "keywords": [ 15 | "mobx", 16 | "async", 17 | "Promise", 18 | "computed" 19 | ], 20 | "author": "Daniel Earwicker ", 21 | "license": "MIT", 22 | "peerDependencies": { 23 | "mobx": "^3.0.0 || ^4.0.0 || ^4.0.0-beta.2 || ^5.0.0", 24 | "mobx-utils": "^3.0.0 || ^4.0.0 || ^4.0.0-beta.2 || ^5.0.0" 25 | }, 26 | "devDependencies": { 27 | "@types/blue-tape": "^0.1.31", 28 | "@types/jsdom": "^11.0.2", 29 | "@types/react": "^16.9.1", 30 | "@types/react-dom": "^16.8.5", 31 | "blue-tape": "^1.0.0", 32 | "coveralls": "^2.11.15", 33 | "faucet": "0.0.1", 34 | "istanbul": "^0.4.5", 35 | "jsdom": "^11.3.0", 36 | "jsdom-global": "^3.0.2", 37 | "mobx": "^5.13.0", 38 | "mobx-react": "^6.1.3", 39 | "mobx-utils": "^5.4.1", 40 | "ts-loader": "^2.3.3", 41 | "typedoc": "^0.5.1", 42 | "typescript": "^3.0.0", 43 | "webpack": "^3.5.5", 44 | "react": "^16.9.0", 45 | "react-dom": "^16.9.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/throttledComputed.ts: -------------------------------------------------------------------------------- 1 | import { createAtom } from "./mobxShim"; 2 | import { autorunThrottled } from "./autorunThrottled"; 3 | 4 | /** 5 | * Like computed, except that after creation, subsequent re-evaluations 6 | * are throttled to occur at the specified minimum interval. 7 | * 8 | * @param compute The function to evaluate in reaction 9 | * @param delay The minimum delay between evaluations 10 | * @param name (optional) For MobX debug purposes 11 | */ 12 | export function throttledComputed(compute: () => T, delay: number, name?: string) { 13 | "use strict"; 14 | 15 | let monitor: undefined | (() => void); 16 | let latestValue: T | undefined; 17 | let latestError: any; 18 | 19 | function wake() { 20 | sleep(); 21 | monitor = autorunThrottled(observe, delay, name); 22 | } 23 | 24 | function observe(): void { 25 | try { 26 | const newValue = compute(); 27 | if (latestError || newValue !== latestValue) { 28 | latestValue = newValue; 29 | latestError = undefined; 30 | atom.reportChanged(); 31 | } 32 | } catch (x) { 33 | latestError = x; 34 | atom.reportChanged(); 35 | } 36 | } 37 | 38 | function sleep() { 39 | const dispose = monitor; 40 | monitor = undefined; 41 | 42 | if (dispose) { 43 | dispose(); 44 | } 45 | } 46 | 47 | const atom = createAtom(name || "DelayedComputedAtom", wake, sleep); 48 | 49 | return { 50 | get() { 51 | atom.reportObserved(); 52 | 53 | if (latestError) { 54 | throw latestError; 55 | } 56 | 57 | return latestValue!; 58 | }, 59 | refresh() { 60 | wake(); 61 | } 62 | } 63 | } 64 | 65 | -------------------------------------------------------------------------------- /interactive-tests/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import { computedAsync } from '../src/index'; 4 | import { delay } from '../test/delay'; 5 | import { observable } from 'mobx'; 6 | import { observer } from 'mobx-react'; 7 | 8 | @observer 9 | class SlowCalculator extends React.Component { 10 | 11 | @observable 12 | x = "0"; 13 | 14 | @observable 15 | y = "0"; 16 | 17 | answer = computedAsync({ 18 | init: 0, 19 | delay: 1000, 20 | fetch: async () => { 21 | console.log(`starting fetch with ${this.x} + ${this.y}`); 22 | const r = parseFloat(this.x) + parseFloat(this.y); 23 | await delay(1000); 24 | console.log(`fetch returning ${r}`); 25 | return r; 26 | } 27 | }); 28 | 29 | render() { 30 | return ( 31 |
32 | this.x = e.target.value }/> + 33 | this.y = e.target.value }/> = 34 | {this.answer.value} 35 | {this.answer.busy ?
busy...
: undefined} 36 |
37 | ); 38 | } 39 | } 40 | 41 | 42 | async function timeConsumingOperation() { 43 | for (let i = 0; i < 5; i++) { 44 | await delay(500); 45 | console.log(`Waiting (${i})...`); 46 | } 47 | } 48 | 49 | @observer 50 | class InitiallyBusy extends React.Component { 51 | observableValue = computedAsync({ 52 | init: 'Initial dummy value', 53 | fetch: async () => { 54 | await timeConsumingOperation(); 55 | return 'Computed value'; 56 | }, 57 | }); 58 | 59 | render() { 60 | const { value, busy } = this.observableValue; 61 | console.log('render()', { value, busy }); 62 | return (
    63 |
  • value: {value}
  • 64 |
  • busy: {JSON.stringify(busy)}
  • 65 |
); 66 | } 67 | } 68 | 69 | function App(_: {}) { 70 | return ( 71 |
72 |
73 |
74 |
75 | ); 76 | } 77 | 78 | ReactDOM.render(, document.body.appendChild(document.createElement('div'))); -------------------------------------------------------------------------------- /test/throttledComputedTests.ts: -------------------------------------------------------------------------------- 1 | import test from "blue-tape"; 2 | import { testStrictness, waitForLength, Obs } from "./util"; 3 | import { delay } from "./delay"; 4 | import { observable, runInAction, autorun } from "mobx" 5 | import { throttledComputed } from "../src/index" 6 | 7 | function getInsideReaction(getter: () => T) { 8 | 9 | let result: T = undefined!; 10 | 11 | const stop = autorun(() => { 12 | result = getter(); 13 | }); 14 | 15 | stop(); 16 | 17 | return result; 18 | } 19 | 20 | testStrictness("throttledComputed - synchronous at first", async (assert: test.Test) => { 21 | 22 | const o = observable({ x: 1, y: 2 }); 23 | 24 | const r = throttledComputed(() => o.x + o.y, 50); 25 | 26 | assert.equal(getInsideReaction(() => r.get()), 3, "Initial computation is synchronous"); 27 | 28 | runInAction(() => o.x = 5); 29 | 30 | assert.equal(getInsideReaction(() => r.get()), 7, "Subsequent computations outside reactive contexts are also synchronous"); 31 | 32 | runInAction(() => o.x = 6); 33 | 34 | assert.equal(getInsideReaction(() => r.get()), 8, "Ditto"); 35 | 36 | const results: number[] = []; 37 | 38 | const stop = autorun(() => results.push(r.get())); 39 | 40 | assert.deepEqual(results, [8]); 41 | 42 | runInAction(() => o.x = 3); 43 | 44 | assert.deepEqual(results, [8], "Reactive contexts don't see immediate changes"); 45 | 46 | await waitForLength(results, 2); 47 | 48 | assert.deepEqual(results, [8, 5], "But do see delayed changes"); 49 | 50 | runInAction(() => o.x = 10); 51 | runInAction(() => o.x = 20); 52 | 53 | await waitForLength(results, 3); 54 | 55 | assert.deepEqual(results, [8, 5, 22], "Changes are batched by throttling"); 56 | 57 | stop(); 58 | }); 59 | 60 | testStrictness("throttledComputed - propagates exceptions", async (assert: test.Test) => { 61 | 62 | const o = new Obs(false); 63 | 64 | const r = throttledComputed(() => { 65 | if (o.get()) { 66 | throw new Error("Badness"); 67 | } 68 | return 1; 69 | }, 50); 70 | 71 | assert.equal(getInsideReaction(() => r.get()), 1, "Initial computation is synchronous"); 72 | 73 | const results: (number | string)[] = []; 74 | 75 | const stop = autorun(() => { 76 | try { 77 | results.push(r.get()); 78 | } catch(x) { 79 | results.push(x.message); 80 | } 81 | }); 82 | 83 | assert.deepEqual(results, [1]); 84 | 85 | runInAction(() => o.set(true)); 86 | 87 | assert.deepEqual(results, [1], "Reactive contexts don't see immediate changes"); 88 | 89 | await waitForLength(results, 2); 90 | 91 | assert.deepEqual(results, [1, "Badness"], "But do see delayed changes"); 92 | 93 | runInAction(() => o.set(false)); 94 | runInAction(() => o.set(true)); 95 | runInAction(() => o.set(false)); 96 | 97 | await waitForLength(results, 3); 98 | 99 | assert.deepEqual(results, [1, "Badness", 1], "Changes are batched by throttling"); 100 | 101 | runInAction(() => o.set(true)); 102 | await delay(1); 103 | runInAction(() => o.set(false)); 104 | runInAction(() => o.set(true)); 105 | 106 | await waitForLength(results, 4); 107 | 108 | assert.deepEqual(results, [1, "Badness", 1, "Badness"], "Changes are batched again"); 109 | 110 | stop(); 111 | }); 112 | 113 | testStrictness("throttledComputed - can be refreshed", async (assert: test.Test) => { 114 | 115 | let counter = 0; 116 | 117 | const r = throttledComputed(() => ++counter, 10); 118 | 119 | const trace: (number)[] = []; 120 | const stop = autorun(() => trace.push(r.get())); 121 | 122 | assert.deepEqual(trace, [1], "Initial value appears synchronously"); 123 | 124 | r.refresh(); 125 | 126 | assert.deepEqual(trace, [1, 2], "Second value appears synchronously"); 127 | 128 | stop(); 129 | }); -------------------------------------------------------------------------------- /src/promisedComputed.ts: -------------------------------------------------------------------------------- 1 | import { computed, action, observable, runInAction, autorun } from "mobx" 2 | import { getGlobalState } from "./mobxShim"; 3 | import { fromPromise, IPromiseBasedObservable, isPromiseBasedObservable } from "mobx-utils"; 4 | import { Getter } from "./Getter"; 5 | 6 | export function isPromiseLike(result: PromiseLike|T): result is PromiseLike { 7 | return result && typeof (result as any).then === "function"; 8 | } 9 | 10 | /** 11 | * PromisedComputedValue 12 | */ 13 | export interface PromisedComputedValue extends Getter { 14 | /** True if the promise is currently resolving */ 15 | readonly busy: boolean; 16 | 17 | refresh(): void; 18 | 19 | getNonReactive(): T; 20 | } 21 | 22 | type PromiseResult = { ok: true; value: T } | { ok: false; error: any }; 23 | 24 | function value(value: T): PromiseResult { 25 | return { ok: true, value }; 26 | } 27 | 28 | function error(error: any): PromiseResult { 29 | return { ok: false, error }; 30 | } 31 | 32 | class PromisedComputed implements PromisedComputedValue { 33 | 34 | private cached: PromiseResult; 35 | 36 | @observable 37 | private refreshCallCount: number; 38 | 39 | @computed 40 | private get currentState(): IPromiseBasedObservable> | PromiseResult { 41 | 42 | try { 43 | this.refreshCallCount; 44 | const promiseOrValue = this.fetch(); 45 | 46 | return isPromiseLike(promiseOrValue) 47 | ? fromPromise(promiseOrValue.then(value, e => error(e))) 48 | : value(promiseOrValue); 49 | 50 | } catch (x) { 51 | return error(x); 52 | } 53 | } 54 | 55 | constructor(init: T, 56 | private readonly fetch: () => PromiseLike | T, 57 | private disableReactionChecking?: boolean) { 58 | 59 | runInAction(() => this.refreshCallCount = 0); 60 | this.cached = value(init); 61 | } 62 | 63 | @computed 64 | get busy() { 65 | const s = this.currentState; 66 | return !!(isPromiseBasedObservable(s) && s.state === "pending"); 67 | } 68 | 69 | @action 70 | refresh() { 71 | this.refreshCallCount++; 72 | } 73 | 74 | get() { 75 | if (!this.disableReactionChecking && 76 | !getGlobalState().trackingDerivation) { 77 | throw new Error("promisedComputed must be used inside reactions"); 78 | } 79 | 80 | return this.value; 81 | } 82 | 83 | /** 84 | * This exists purely to support scenarios such as unit tests that 85 | * want to verify the most recent value outside of a reactive context 86 | */ 87 | getNonReactive() { 88 | let result: T = undefined!; 89 | autorun(() => result = this.get())(); 90 | return result; 91 | } 92 | 93 | @computed 94 | private get value(): T { 95 | const s = this.currentState; 96 | 97 | const r = !isPromiseBasedObservable(s) ? s : 98 | s.state === "fulfilled" ? s.value : 99 | this.cached; 100 | 101 | this.cached = r; 102 | 103 | if (r.ok) { 104 | return r.value; 105 | } 106 | 107 | throw r.error; 108 | } 109 | } 110 | 111 | /** 112 | * Similar to the standard computed, except that it converts promises into 113 | * plain values, unwrapping them when they resolve and updating to the new 114 | * value. The supplied function may return a plain value in which case the 115 | * update is entirely synchronous like standard computed. 116 | * 117 | * As with the standard computed, exceptions (and rejected promises) are 118 | * propagated as re-thrown exceptions. To avoid this, perform your own 119 | * error handling in your supplied function. 120 | * 121 | * @param init Value to assume until the promise first resolves 122 | * @param compute Evaluates to a promised or plain value 123 | */ 124 | export function promisedComputed(init: T, compute: () => PromiseLike | T): PromisedComputedValue { 125 | return new PromisedComputed(init, compute); 126 | } 127 | 128 | export function promisedComputedInternal(init: T, compute: () => PromiseLike | T): PromisedComputedValue { 129 | return new PromisedComputed(init, compute, true); 130 | } 131 | -------------------------------------------------------------------------------- /src/deprecatedComputedAsync.ts: -------------------------------------------------------------------------------- 1 | import { computed } from "mobx" 2 | import { promisedComputedInternal, PromisedComputedValue } from "./promisedComputed"; 3 | import { throttledComputed } from "./throttledComputed" 4 | import { Getter } from "./Getter"; 5 | 6 | /** 7 | * DEPRECATED 8 | * 9 | * The type returned by the `computedAsync` function. Represents the current `value`. Accessing 10 | * the value inside a reaction will automatically listen to it, just like an `observable` or 11 | * `computed`. The `busy` property is `true` when the asynchronous function is currently running. 12 | */ 13 | export interface ComputedAsyncValue { 14 | /** The current value (observable) */ 15 | readonly value: T; 16 | /** True if an async evaluation is in progress */ 17 | readonly busy: boolean; 18 | /** True if Promise was rejected */ 19 | readonly failed: boolean; 20 | /** The error from the rejected promise, or undefined */ 21 | readonly error: any; 22 | } 23 | 24 | export interface ComputedAsyncOptions { 25 | readonly init: T; 26 | readonly fetch: () => PromiseLike | T; 27 | readonly delay?: number; 28 | readonly revert?: boolean; 29 | readonly name?: string; 30 | readonly error?: (error: any) => T; 31 | readonly rethrow?: boolean; 32 | } 33 | 34 | class ComputedAsync implements ComputedAsyncValue { 35 | 36 | private computation: Getter | T>; 37 | private promised: PromisedComputedValue 38 | 39 | private lastValue: T | undefined; 40 | 41 | constructor(private options: ComputedAsyncOptions) { 42 | 43 | if (options.delay) { 44 | this.computation = throttledComputed(options.fetch, options.delay); 45 | } else { 46 | this.computation = computed(options.fetch); 47 | } 48 | 49 | this.promised = promisedComputedInternal(options.init, () => this.computation.get()); 50 | } 51 | 52 | get busy() { 53 | return this.promised.busy; 54 | } 55 | 56 | @computed 57 | get failed() { 58 | try { 59 | this.promised.get(); 60 | return false; 61 | } catch (x) { 62 | return true; 63 | } 64 | } 65 | 66 | @computed 67 | get error() { 68 | try { 69 | this.promised.get(); 70 | return undefined; 71 | } catch (x) { 72 | return x; 73 | } 74 | } 75 | 76 | private initializedValue() { 77 | this.lastValue = this.promised.get(); 78 | return this.lastValue; // this.lastValue === undefined ? this.options.init : this.lastValue; 79 | } 80 | 81 | @computed 82 | get value(): T { 83 | if (this.promised.busy && this.options.revert) { 84 | return this.options.init; 85 | } 86 | 87 | if (this.options.rethrow) { 88 | return this.initializedValue(); 89 | } 90 | 91 | try { 92 | return this.initializedValue(); 93 | } catch (x) { 94 | if (this.options.error) { 95 | try { 96 | return this.options.error(x); 97 | } catch (x) { 98 | console.error(x); 99 | } 100 | } 101 | 102 | return this.lastValue === undefined ? this.options.init : this.lastValue; 103 | } 104 | } 105 | } 106 | 107 | /** 108 | * DEPRECATED - prefer `asyncComputed`, see https://github.com/danielearwicker/computed-async-mobx 109 | */ 110 | export function computedAsync( 111 | init: T, 112 | fetch: () => PromiseLike | T, 113 | delay?: number): ComputedAsyncValue; 114 | 115 | /** 116 | * DEPRECATED - prefer `asyncComputed`, see https://github.com/danielearwicker/computed-async-mobx 117 | */ 118 | export function computedAsync( 119 | options: ComputedAsyncOptions 120 | ): ComputedAsyncValue; 121 | 122 | export function computedAsync( 123 | init: T | ComputedAsyncOptions, 124 | fetch?: () => PromiseLike | T, 125 | delay?: number 126 | ) { 127 | if (arguments.length === 1) { 128 | return new ComputedAsync(init as ComputedAsyncOptions); 129 | } 130 | 131 | return new ComputedAsync({ 132 | init: init as T, 133 | fetch: fetch!, 134 | delay 135 | }); 136 | } 137 | -------------------------------------------------------------------------------- /test/asyncComputedTests.ts: -------------------------------------------------------------------------------- 1 | import test from "blue-tape"; 2 | import { testStrictness, waitForLength, Obs } from "./util"; 3 | import { delay } from "./delay"; 4 | import { observable, runInAction, autorun } from "mobx" 5 | import { asyncComputed } from "../src/index" 6 | 7 | testStrictness("asyncComputed - can't be used outside of reactive contexts", async (assert: test.Test) => { 8 | 9 | const o = observable({ x: 1, y: 2 }); 10 | 11 | const r = asyncComputed(undefined, 10, async () => { 12 | const result = o.x + o.y; 13 | await delay(100); 14 | return result; 15 | }); 16 | 17 | assert.throws(() => r.get(), /inside reactions/); 18 | }); 19 | 20 | testStrictness("asyncComputed - transitions to new values", async (assert: test.Test) => { 21 | 22 | const o = observable({ x: 1, y: 2 }); 23 | 24 | const r = asyncComputed(99, 10, async () => { 25 | const result = o.x + o.y; 26 | await delay(10); 27 | return result; 28 | }); 29 | 30 | const trace: (number | undefined)[] = []; 31 | const stop = autorun(() => trace.push(r.get())); 32 | 33 | assert.deepEqual(trace, [99], "Init value until promise resolves"); 34 | 35 | await waitForLength(trace, 2); 36 | 37 | assert.deepEqual(trace, [99, 3], "First real value appears"); 38 | 39 | runInAction(() => o.x = 5); 40 | 41 | assert.deepEqual(trace, [99, 3], "No second value until promise resolves [2]"); 42 | 43 | await waitForLength(trace, 3); 44 | 45 | assert.deepEqual(trace, [99, 3, 7], "Second value appears"); 46 | 47 | stop(); 48 | }); 49 | 50 | testStrictness("asyncComputed - can be refreshed", async (assert: test.Test) => { 51 | 52 | let counter = 0; 53 | 54 | const r = asyncComputed(0, 10, async () => { 55 | await delay(10); 56 | return ++counter; 57 | }); 58 | 59 | const trace: (number)[] = []; 60 | const stop = autorun(() => trace.push(r.get())); 61 | 62 | assert.deepEqual(trace, [0], "No new value until promise resolves"); 63 | 64 | await waitForLength(trace, 2); 65 | 66 | assert.deepEqual(trace, [0, 1], "First proper value appears"); 67 | 68 | r.refresh(); 69 | 70 | assert.deepEqual(trace, [0, 1], "No value until promise resolves [2]"); 71 | 72 | await waitForLength(trace, 3); 73 | 74 | assert.deepEqual(trace, [0, 1, 2], "Second value appears"); 75 | 76 | stop(); 77 | }); 78 | 79 | testStrictness("asyncComputed - busy property works by itself", async (assert: test.Test) => { 80 | 81 | const o = observable({ x: 1, y: 2 }); 82 | 83 | const r = asyncComputed(undefined, 10, async () => { 84 | const result = o.x + o.y; 85 | await delay(10); 86 | return result; 87 | }); 88 | 89 | const trace: boolean[] = []; 90 | const stop = autorun(() => trace.push(r.busy)); 91 | 92 | assert.deepEqual(trace, [true], "Is initially busy"); 93 | 94 | await waitForLength(trace, 2); 95 | 96 | assert.deepEqual(trace, [true, false], "Busy transitions to false"); 97 | 98 | runInAction(() => o.x = 5); 99 | 100 | assert.deepEqual(trace, [true, false], "Doesn't synchronously transition to true, due to throttling"); 101 | 102 | await waitForLength(trace, 3); 103 | 104 | assert.deepEqual(trace, [true, false, true], "Eventually transitions to true"); 105 | 106 | await waitForLength(trace, 4); 107 | 108 | assert.deepEqual(trace, [true, false, true, false], "Second transition to false"); 109 | 110 | stop(); 111 | }); 112 | 113 | testStrictness("asyncComputed - busy property interleaves with value changes", async (assert: test.Test) => { 114 | 115 | const o = observable({ x: 1, y: 2 }); 116 | 117 | const r = asyncComputed(99, 10, async () => { 118 | const result = o.x + o.y; 119 | await delay(10); 120 | return result; 121 | }); 122 | 123 | const trace: ({ 124 | value: number, 125 | busy: boolean 126 | })[] = []; 127 | 128 | const stop = autorun(() => trace.push({ value: r.get(), busy: r.busy })); 129 | 130 | assert.deepEqual(trace, [ 131 | {value: 99, busy: true} 132 | ], "No value until promise resolves"); 133 | 134 | await waitForLength(trace, 2); 135 | 136 | assert.deepEqual(trace, [ 137 | {value: 99, busy: true}, 138 | {value: 3, busy: false} 139 | ], "Initial value appears"); 140 | 141 | runInAction(() => o.x = 5); 142 | 143 | assert.deepEqual(trace, [ 144 | {value: 99, busy: true}, 145 | {value: 3, busy: false} 146 | ], "No synchronous change in busy"); 147 | 148 | await waitForLength(trace, 3); 149 | 150 | assert.deepEqual(trace, [ 151 | {value: 99, busy: true}, 152 | {value: 3, busy: false}, 153 | {value: 3, busy: true} 154 | ], "Eventually turns busy"); 155 | 156 | await waitForLength(trace, 4); 157 | 158 | assert.deepEqual(trace, [ 159 | {value: 99, busy: true}, 160 | {value: 3, busy: false}, 161 | {value: 3, busy: true}, 162 | {value: 7, busy: false} 163 | ], "Second value appears"); 164 | 165 | stop(); 166 | }); 167 | 168 | testStrictness("asyncComputed - propagates exceptions", async (assert: test.Test) => { 169 | 170 | const o = new Obs(false); 171 | 172 | const r = asyncComputed("Init", 10, async () => { 173 | const shouldThrow = o.get(); 174 | 175 | await delay(10); 176 | 177 | if (shouldThrow) { 178 | throw new Error("Badness"); 179 | } 180 | return "Goodness"; 181 | }); 182 | 183 | const trace: (number | string | undefined)[] = []; 184 | 185 | const stop = autorun(() => { 186 | try { 187 | trace.push(r.get()); 188 | } catch(x) { 189 | trace.push(x.message); 190 | } 191 | }); 192 | 193 | assert.deepEqual(trace, ["Init"]); 194 | 195 | await waitForLength(trace, 2); 196 | 197 | assert.deepEqual(trace, ["Init", "Goodness"]); 198 | 199 | runInAction(() => o.set(true)); 200 | 201 | assert.deepEqual(trace, ["Init", "Goodness"], "Reactive contexts don't seem immediate changes"); 202 | 203 | await waitForLength(trace, 3); 204 | 205 | assert.deepEqual(trace, ["Init", "Goodness", "Badness"], "But do see delayed changes"); 206 | 207 | runInAction(() => o.set(false)); 208 | runInAction(() => o.set(true)); 209 | runInAction(() => o.set(false)); 210 | 211 | await waitForLength(trace, 4); 212 | 213 | assert.deepEqual(trace, ["Init", "Goodness", "Badness", "Badness"], "Change to busy makes us see another exception"); 214 | 215 | await waitForLength(trace, 5); 216 | 217 | assert.deepEqual(trace, ["Init", "Goodness", "Badness", "Badness", "Goodness"], "Changes are batched by throttling"); 218 | 219 | runInAction(() => o.set(true)); 220 | await delay(1); 221 | runInAction(() => o.set(false)); 222 | runInAction(() => o.set(true)); 223 | 224 | await waitForLength(trace, 6); 225 | 226 | assert.deepEqual(trace, ["Init", "Goodness", "Badness", "Badness", "Goodness", "Badness"], "Changes are batched again"); 227 | 228 | stop(); 229 | }); 230 | 231 | -------------------------------------------------------------------------------- /test/promisedComputedTests.ts: -------------------------------------------------------------------------------- 1 | import test from "blue-tape"; 2 | import { testStrictness, waitForLength, Obs } from "./util"; 3 | import { delay } from "./delay"; 4 | import { observable, runInAction, autorun } from "mobx" 5 | import { promisedComputed } from "../src/index" 6 | 7 | testStrictness("promisedComputed - can't be used outside of reactive contexts", async (assert: test.Test) => { 8 | 9 | const o = observable({ x: 1, y: 2 }); 10 | 11 | const r = promisedComputed(undefined, async () => { 12 | const result = o.x + o.y; 13 | await delay(100); 14 | return result; 15 | }); 16 | 17 | assert.throws(() => r.get(), /inside reactions/); 18 | }); 19 | 20 | testStrictness("promisedComputed - can use getNonReactive outside of reactive contexts", async (assert: test.Test) => { 21 | 22 | const o = observable({ x: 1, y: 2 }); 23 | 24 | const r = promisedComputed(undefined, async () => { 25 | const result = o.x + o.y; 26 | await delay(100); 27 | return result; 28 | }); 29 | 30 | assert.equal(r.getNonReactive(), undefined); 31 | 32 | const stop = autorun(() => r.get()); 33 | 34 | while (r.getNonReactive() === undefined) { 35 | await delay(5); 36 | } 37 | 38 | stop(); 39 | 40 | assert.equal(r.getNonReactive(), 3); 41 | }); 42 | 43 | testStrictness("promisedComputed - transitions to new values", async (assert: test.Test) => { 44 | 45 | const o = observable({ x: 1, y: 2 }); 46 | 47 | const r = promisedComputed(101, async () => { 48 | const result = o.x + o.y; 49 | await delay(10); 50 | return result; 51 | }); 52 | 53 | const trace: (number)[] = []; 54 | const stop = autorun(() => trace.push(r.get())); 55 | 56 | assert.deepEqual(trace, [101], "No new value until promise resolves"); 57 | 58 | await waitForLength(trace, 2); 59 | 60 | assert.deepEqual(trace, [101, 3], "First proper value appears"); 61 | 62 | runInAction(() => o.x = 5); 63 | 64 | assert.deepEqual(trace, [101, 3], "No value until promise resolves [2]"); 65 | 66 | await waitForLength(trace, 3); 67 | 68 | assert.deepEqual(trace, [101, 3, 7], "Second value appears"); 69 | 70 | stop(); 71 | }); 72 | 73 | testStrictness("promisedComputed - busy property works by itself", async (assert: test.Test) => { 74 | 75 | const o = observable({ x: 1, y: 2 }); 76 | 77 | const r = promisedComputed(undefined, async () => { 78 | const result = o.x + o.y; 79 | await delay(10); 80 | return result; 81 | }); 82 | 83 | const trace: boolean[] = []; 84 | const stop = autorun(() => trace.push(r.busy)); 85 | 86 | assert.deepEqual(trace, [true], "Is initially busy"); 87 | 88 | await waitForLength(trace, 2); 89 | 90 | assert.deepEqual(trace, [true, false], "Busy transitions to false"); 91 | 92 | runInAction(() => o.x = 5); 93 | 94 | assert.deepEqual(trace, [true, false, true], "Synchronously transitions to true"); 95 | 96 | await waitForLength(trace, 4); 97 | 98 | assert.deepEqual(trace, [true, false, true, false], "Second transition to false"); 99 | 100 | stop(); 101 | }); 102 | 103 | testStrictness("promisedComputed - busy property interleaves with value changes", async (assert: test.Test) => { 104 | 105 | const o = observable({ x: 1, y: 2 }); 106 | 107 | const r = promisedComputed(undefined, async () => { 108 | const result = o.x + o.y; 109 | await delay(10); 110 | return result; 111 | }); 112 | 113 | const trace: ({ 114 | value: number | undefined, 115 | busy: boolean 116 | })[] = []; 117 | 118 | const stop = autorun(() => trace.push({ value: r.get(), busy: r.busy })); 119 | 120 | assert.deepEqual(trace, [ 121 | {value: undefined, busy: true} 122 | ], "No value until promise resolves"); 123 | 124 | await waitForLength(trace, 2); 125 | 126 | assert.deepEqual(trace, [ 127 | {value: undefined, busy: true}, 128 | {value: 3, busy: false} 129 | ], "Initial value appears"); 130 | 131 | runInAction(() => o.x = 5); 132 | 133 | assert.deepEqual(trace, [ 134 | {value: undefined, busy: true}, 135 | {value: 3, busy: false}, 136 | {value: 3, busy: true} 137 | ], "No value until promise resolves [2]"); 138 | 139 | await waitForLength(trace, 4); 140 | 141 | assert.deepEqual(trace, [ 142 | {value: undefined, busy: true}, 143 | {value: 3, busy: false}, 144 | {value: 3, busy: true}, 145 | {value: 7, busy: false} 146 | ], "Second value appears"); 147 | 148 | stop(); 149 | }); 150 | 151 | testStrictness("promisedComputed - propagates exceptions", async (assert: test.Test) => { 152 | 153 | const o = new Obs(false); 154 | 155 | const r = promisedComputed(101, async () => { 156 | const shouldThrow = o.get(); 157 | 158 | await delay(10); 159 | 160 | if (shouldThrow) { 161 | throw new Error("Badness"); 162 | } 163 | return 1; 164 | }); 165 | 166 | const trace: (number | string)[] = []; 167 | 168 | const stop = autorun(() => { 169 | try { 170 | trace.push(r.get()); 171 | } catch(x) { 172 | trace.push(x.message); 173 | } 174 | }); 175 | 176 | assert.deepEqual(trace, [101]); 177 | 178 | await waitForLength(trace, 2); 179 | 180 | assert.deepEqual(trace, [101, 1]); 181 | 182 | runInAction(() => o.set(true)); 183 | 184 | assert.deepEqual(trace, [101, 1], "Reactive contexts don't seem immediate changes"); 185 | 186 | await waitForLength(trace, 3); 187 | 188 | assert.deepEqual(trace, [101, 1, "Badness"], "But do see delayed changes"); 189 | 190 | runInAction(() => o.set(false)); 191 | 192 | await waitForLength(trace, 4); 193 | 194 | assert.deepEqual(trace, [101, 1, "Badness", "Badness"], "Transition to busy triggers new exception"); 195 | 196 | await waitForLength(trace, 5); 197 | 198 | assert.deepEqual(trace, [101, 1, "Badness", "Badness", 1], "And reverts back to non-throwing"); 199 | 200 | stop(); 201 | }); 202 | 203 | testStrictness("promisedComputed - is fully synchronous if value is not a promise", async (assert: test.Test) => { 204 | 205 | const o = new Obs("sync"); 206 | 207 | const r = promisedComputed("never", () => { 208 | const v = o.get(); 209 | if (v === "throw") { 210 | throw new Error(v); 211 | } 212 | return v === "async" ? delay(10).then(() => v) : v; 213 | }); 214 | 215 | const trace: string[] = []; 216 | 217 | const stop = autorun(() => { 218 | try { 219 | trace.push(r.get()); 220 | } catch (x) { 221 | trace.push("error: " + x.message); 222 | } 223 | }); 224 | 225 | assert.deepEqual(trace, ["sync"], "Synchronously has value"); 226 | 227 | runInAction(() => o.set("sync2")); 228 | 229 | assert.deepEqual(trace, ["sync", "sync2"], "Synchronously transitions"); 230 | 231 | runInAction(() => o.set("async")); 232 | 233 | assert.deepEqual(trace, ["sync", "sync2"], "Does not immediately transition to promised value"); 234 | 235 | await waitForLength(trace, 3); 236 | 237 | assert.deepEqual(trace, ["sync", "sync2", "async"], "Eventually transitions"); 238 | 239 | runInAction(() => o.set("throw")); 240 | 241 | assert.deepEqual(trace, ["sync", "sync2", "async", "error: throw"], "Synchronously transitions to throwing"); 242 | 243 | runInAction(() => o.set("sync3")); 244 | 245 | assert.deepEqual(trace, ["sync", "sync2", "async", "error: throw", "sync3"], "Synchronously transitions to normal"); 246 | 247 | stop(); 248 | }); 249 | 250 | testStrictness("promisedComputed - can be refreshed", async (assert: test.Test) => { 251 | 252 | let counter = 0; 253 | 254 | const r = promisedComputed(0, async () => { 255 | await delay(10); 256 | return ++counter; 257 | }); 258 | 259 | const trace: (number)[] = []; 260 | const stop = autorun(() => trace.push(r.get())); 261 | 262 | assert.deepEqual(trace, [0], "No new value until promise resolves"); 263 | 264 | await waitForLength(trace, 2); 265 | 266 | assert.deepEqual(trace, [0, 1], "First proper value appears"); 267 | 268 | r.refresh(); 269 | 270 | assert.deepEqual(trace, [0, 1], "No value until promise resolves [2]"); 271 | 272 | await waitForLength(trace, 3); 273 | 274 | assert.deepEqual(trace, [0, 1, 2], "Second value appears"); 275 | 276 | stop(); 277 | }); 278 | -------------------------------------------------------------------------------- /test/deprecatedComputedAsyncTests.ts: -------------------------------------------------------------------------------- 1 | import test from "blue-tape"; 2 | import { testCombinations, testStrictness } from "./util"; 3 | import { delay } from "./delay"; 4 | import { observable, autorun, runInAction, computed } from "mobx" 5 | import { computedAsync } from "../src/index" 6 | 7 | test("deprecated:ComputedAsync - busy is initially true", async (assert: test.Test) => { 8 | 9 | const o = observable({ x: 0, y: 0 }); 10 | 11 | const r = computedAsync(500, async () => { 12 | const vx = o.x, vy = o.y; 13 | await delay(100); 14 | return vx + vy; 15 | }); 16 | 17 | assert.equal(r.busy, true); 18 | }); 19 | 20 | testCombinations("deprecated:ComputedAsync - non-reverting", async (delayed: boolean, assert: test.Test) => { 21 | 22 | const o = observable({ x: 0, y: 0 }); 23 | 24 | const r = computedAsync(500, async () => { 25 | const vx = o.x, vy = o.y; 26 | await delay(100); 27 | return vx + vy; 28 | }, delayed ? 1 : 0); 29 | 30 | let expect = (v: number) => assert.equal(v, 500); 31 | 32 | function expected(expecting: number) { 33 | return new Promise(resolve => { 34 | expect = got => { 35 | assert.equal(got, expecting, "expected: " + expecting); 36 | resolve(); 37 | }; 38 | }); 39 | } 40 | 41 | let stopRunner = autorun(() => expect(r.value)); 42 | 43 | await delay(10); 44 | 45 | runInAction(() => o.x = 2); 46 | 47 | await expected(2); 48 | 49 | runInAction(() => o.y = 3); 50 | await delay(10); 51 | 52 | await expected(5); 53 | 54 | runInAction(() => o.x = 4); 55 | 56 | await expected(7); 57 | 58 | stopRunner(); 59 | 60 | runInAction(() => o.y = 4); 61 | 62 | // Not being observed, so value doesn't change to 4 + 4 yet 63 | assert.equal(r.value, 7, "0010"); 64 | 65 | expect = v => { 66 | assert.fail(`unexpected[1]: ${v}`); 67 | }; 68 | 69 | runInAction(() => o.x = 5); 70 | await delay(200); 71 | 72 | // Initially it will have the stale value when we start observing 73 | expect = v => assert.equal(v, 7, "0012"); 74 | 75 | stopRunner = autorun(() => expect(r.value)); 76 | 77 | // But will soon converge on the correct value 78 | await expected(9); 79 | 80 | runInAction(() => o.x = 1); 81 | 82 | await expected(5); 83 | 84 | stopRunner(); 85 | 86 | expect = v => assert.fail(`unexpected[2]: ${v}`); 87 | 88 | runInAction(() => o.x = 2); 89 | 90 | await delay(200); 91 | 92 | stopRunner(); 93 | }); 94 | 95 | testStrictness("deprecated:ComputedAsync - synchronous", async (assert: test.Test) => { 96 | 97 | const o = observable({ x: 0, y: 0 }); 98 | 99 | const r = computedAsync(500, async () => { 100 | const vx = o.x, vy = o.y; 101 | await delay(100); 102 | return vx + vy; 103 | }); 104 | 105 | let expect = (v: number) => assert.equal(v, 500); 106 | 107 | function expected(expecting: number) { 108 | return new Promise(resolve => { 109 | expect = got => { 110 | assert.equal(got, expecting, "expected " + expecting); 111 | resolve(); 112 | }; 113 | }); 114 | } 115 | 116 | let stopRunner = autorun(() => expect(r.value)); 117 | 118 | await delay(10); 119 | 120 | runInAction(() => o.x = 2); 121 | 122 | await expected(2); 123 | 124 | runInAction(() => o.y = 3); 125 | 126 | await delay(10); 127 | 128 | await expected(5); 129 | 130 | runInAction(() => o.x = 4); 131 | 132 | await expected(7); 133 | 134 | stopRunner(); 135 | 136 | runInAction(() => o.y = 4); 137 | 138 | assert.equal(r.value, 7, "0009"); 139 | 140 | expect = v => { 141 | assert.fail(`unexpected[1]: ${v}`); 142 | }; 143 | 144 | runInAction(() => o.x = 5); 145 | await delay(1000); 146 | 147 | expect = v => assert.equal(v, 7, "0011"); 148 | 149 | stopRunner = autorun(() => expect(r.value)); 150 | 151 | await expected(9); 152 | 153 | runInAction(() => o.x = 1); 154 | 155 | await expected(5); 156 | 157 | stopRunner(); 158 | 159 | expect = v => assert.fail(`unexpected[2]: ${v}`); 160 | 161 | runInAction(() => o.x = 2); 162 | 163 | await delay(200); 164 | 165 | stopRunner(); 166 | }); 167 | 168 | testStrictness("deprecated:ComputedAsync - full synchronous", async (assert: test.Test) => { 169 | 170 | const o = observable({ x: 0, y: 0 }); 171 | 172 | const r = computedAsync(500, () => { 173 | return o.x + o.y; 174 | }); 175 | 176 | assert.equal(r.value, 0, "0001"); 177 | 178 | runInAction(() => o.x = 2); 179 | 180 | assert.equal(r.value, 2, "0002"); 181 | 182 | runInAction(() => o.y = 3); 183 | 184 | assert.equal(r.value, 5, "0003"); 185 | 186 | return Promise.resolve(); 187 | }); 188 | 189 | testCombinations("deprecated:ComputedAsync - reverting", async (delayed: boolean, assert: test.Test) => { 190 | 191 | const o = observable({ x: 0, y: 0 }); 192 | 193 | const r = computedAsync({ 194 | init: 500, 195 | fetch: async () => { 196 | const vx = o.x, vy = o.y; 197 | await delay(100); 198 | return vx + vy; 199 | }, 200 | revert: true, 201 | delay: delayed ? 1 : 0 202 | }); 203 | 204 | const transitions: number[] = []; 205 | 206 | async function expect(...expected: number[]) { 207 | let timeout = 0; 208 | while (transitions.length < expected.length) { 209 | timeout++; 210 | assert.doesNotEqual(timeout, 20, `waiting for ${JSON.stringify(expected)}, seeing ${JSON.stringify(transitions)}`); 211 | await delay(100); 212 | } 213 | 214 | assert.deepEqual(transitions, expected); 215 | transitions.length = 0; 216 | } 217 | 218 | let stopRunner = autorun(() => transitions.push(r.value)); 219 | 220 | await expect(500); 221 | 222 | await delay(10); 223 | 224 | runInAction(() => o.x = 2); 225 | 226 | // don't expect a transition to 500, as it already was 500 227 | await expect(2); 228 | 229 | runInAction(() => o.y = 3); 230 | 231 | await expect(500, 5); 232 | 233 | runInAction(() => o.x = 4); 234 | 235 | await expect(500, 7); 236 | 237 | stopRunner(); 238 | 239 | runInAction(() => o.y = 4); 240 | 241 | assert.equal(r.value, 500, "0001"); 242 | await delay(200); 243 | assert.equal(r.value, 500, "0001"); 244 | await expect(); 245 | 246 | runInAction(() => o.x = 5); 247 | await delay(200); 248 | await expect(); 249 | 250 | stopRunner = autorun(() => transitions.push(r.value)); 251 | 252 | runInAction(() => o.x = 1); 253 | 254 | await delay(200); 255 | 256 | await expect(500, 5); 257 | 258 | stopRunner(); 259 | }); 260 | 261 | testCombinations("deprecated:ComputedAsync - error handling - default", async (delayed: boolean, assert: test.Test) => { 262 | 263 | const o = observable({ b: true }); 264 | 265 | const r = computedAsync(123, 266 | () => o.b 267 | ? Promise.reject("err") 268 | : Promise.resolve(456), 269 | delayed ? 1 : 0); 270 | 271 | assert.equal(r.value, 123); 272 | 273 | const changes: { error: any, value: number }[] = []; 274 | const stopMonitoring = autorun(() => { 275 | changes.push({ error: r.error, value: r.value }); 276 | }); 277 | 278 | assert.deepEqual(changes, [ 279 | { error: undefined, value: 123 } 280 | ]); 281 | 282 | await delay(10); 283 | 284 | assert.deepEqual(changes, [ 285 | { error: undefined, value: 123 }, 286 | { error: "err", value: 123 } 287 | ]); 288 | 289 | runInAction(() => o.b = false); 290 | 291 | await delay(10); 292 | 293 | assert.deepEqual(changes, [ 294 | { error: undefined, value: 123 }, 295 | { error: "err", value: 123 }, 296 | { error: undefined, value: 456 } 297 | ]); 298 | 299 | runInAction(() => o.b = true); 300 | 301 | await delay(100); 302 | 303 | assert.deepEqual(changes, [ 304 | { error: undefined, value: 123 }, 305 | { error: "err", value: 123 }, 306 | { error: undefined, value: 456 }, 307 | { error: "err", value: 456 } 308 | ]); 309 | 310 | stopMonitoring(); 311 | }); 312 | 313 | testCombinations("deprecated:ComputedAsync - error handling - replace", async (delayed: boolean, assert: test.Test) => { 314 | 315 | const o = observable({ b: true }); 316 | 317 | const r = computedAsync({ 318 | init: "123", 319 | fetch: () => o.b 320 | ? Promise.reject("bad") 321 | : Promise.resolve("456"), 322 | error: e => "error: " + e, 323 | delay: delayed ? 1 : 0 324 | }); 325 | 326 | assert.equal(r.value, "123", "0000"); 327 | 328 | const valueChanges: string[] = []; 329 | const stopCountValueChanges = autorun(() => { 330 | valueChanges.push(r.value); 331 | }); 332 | 333 | const errorChanges: string[] = []; 334 | const stopCountErrorChanges = autorun(() => { 335 | errorChanges.push(r.error); 336 | }); 337 | 338 | assert.deepEqual(valueChanges, ["123"], "0002"); 339 | assert.deepEqual(errorChanges, [undefined], "0003"); 340 | assert.equal(r.value, "123", "0004"); 341 | 342 | await delay(100); 343 | 344 | assert.deepEqual(valueChanges, ["123", "error: bad"], "0005"); 345 | assert.deepEqual(errorChanges, [undefined, "bad"], "0006"); 346 | assert.equal(r.value, "error: bad", "0007"); 347 | assert.equal(r.error, "bad", "0008"); 348 | 349 | runInAction(() => o.b = false); 350 | 351 | await delay(100); 352 | 353 | assert.deepEqual(valueChanges, ["123", "error: bad", "456"], "0009"); 354 | assert.deepEqual(errorChanges, [undefined, "bad", undefined], "0010"); 355 | assert.equal(r.value, "456", "0011"); 356 | assert.equal(r.error, undefined, "0012"); 357 | 358 | runInAction(() => o.b = true); 359 | 360 | await delay(100); 361 | 362 | assert.deepEqual(valueChanges, ["123", "error: bad", "456", "error: bad"], "0013"); 363 | assert.deepEqual(errorChanges, [undefined, "bad", undefined, "bad"], "0014"); 364 | assert.equal(r.value, "error: bad", "0015"); 365 | assert.equal(r.error, "bad", "0016"); 366 | 367 | stopCountErrorChanges(); 368 | stopCountValueChanges(); 369 | }); 370 | 371 | testCombinations("deprecated:ComputedAsync - inComputed", async (delayed: boolean, assert: test.Test) => { 372 | const o = observable({ x: 0, y: 0 }); 373 | const r = computedAsync({ 374 | init: 0, 375 | fetch: async () => { 376 | //await delay(100); 377 | return o.x + o.y; 378 | }, 379 | delay: delayed ? 10 : 0 380 | }); 381 | 382 | class Test { 383 | @computed get val() { 384 | return r.value; 385 | } 386 | } 387 | 388 | const t = new Test(); 389 | 390 | // Observe the nested computed value 391 | const stop = autorun(() => t.val); 392 | 393 | assert.equal(t.val, 0); 394 | 395 | stop(); 396 | 397 | await delay(100); 398 | }); 399 | 400 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # computed-async-mobx 2 | _Define a computed by returning a Promise_ 3 | 4 | [![Build Status](https://travis-ci.org/danielearwicker/computed-async-mobx.svg?branch=master)](https://travis-ci.org/danielearwicker/computed-async-mobx) 5 | [![Coverage Status](https://coveralls.io/repos/danielearwicker/computed-async-mobx/badge.svg?branch=master&service=github)](https://coveralls.io/github/danielearwicker/computed-async-mobx?branch=master) 6 | 7 | *"People starting with MobX tend to use reactions [*autorun*] too often. The golden rule is: if you want to create a value based on the current state, use computed."* - [MobX - Concepts & Principles](http://mobxjs.github.io/mobx/intro/concepts.html) 8 | 9 | # About MobX 6 10 | 11 | An attempt was made, then abandoned, to quick-fix this library to work with MobX 6, but it didn't work out (to put it mildly). Any suggestions for how to fix it are welcome! For now it only works with MobX versions 3.0 to 5.x. 12 | 13 | It's possible the API may need to be cut down to something a bit simpler with fewer guarantees but still achieving the basic goal. 14 | 15 | # What is this for? 16 | 17 | A `computed` in MobX is defined by a function, which consumes other observable values and is automatically re-evaluated, like a spreadsheet cell containing a calculation. 18 | 19 | ```ts 20 | @computed get creditScore() { 21 | return this.scoresByUser[this.userName]; 22 | } 23 | ``` 24 | However, it has to be a synchronous function body. What if you want to do something asynchronous? e.g. get something from the server. That's where this little extension comes in: 25 | 26 | ```ts 27 | creditScore = promisedComputed(0, async () => { 28 | const response = await fetch(`users/${this.userName}/score`); 29 | const data = await response.json(); 30 | return data.score; 31 | }); 32 | ``` 33 | 34 | [Further explanation, rationale, etc.](../../wiki) 35 | 36 | # New in Version 3.0.0... 37 | 38 | There is a completely new API, much more modular and made of simple, testable pieces. The 39 | old API is deprecated, though is still available for now. First here's how the current 40 | features work. Stay tuned for a migration guide below. 41 | 42 | ---- 43 | 44 | ## asyncComputed 45 | 46 | This is the most capable function. It is actually just a composition of two simpler functions, 47 | `promisedComputed` and `throttledComputed`, described below. 48 | 49 | ### Parameters 50 | 51 | - `init` - the value to assume until the first genuine value is returned 52 | - `delay` - the minimum time in milliseconds to wait between creating new promises 53 | - `compute` - the function to evaluate to get a promise (or plain value) 54 | 55 | ### Returns 56 | 57 | A Mobx-style getter, i.e. an object with a `get` function that returns the current value. It 58 | is an observable, so it can be used from other MobX contexts. It *cannot* be used outside 59 | MobX reactive contexts (it throws an exception if you attempt it). 60 | 61 | The returned object also has a `busy` property that is true while a promise is still pending. 62 | It also has a `refresh` method that can be called to force a new promise to be requested 63 | immediately (bypassing the delay time). 64 | 65 | **New in 4.2.0:** there is also a method `getNonReactive()` which can be used outside reactive 66 | contexts. It is a convenience for writing unit tests. Note that it will return the most recent 67 | value that was computed while the `asyncComputed` was being observed. 68 | 69 | ### Example 70 | 71 | ```ts 72 | fullName = asyncComputed("(Please wait...)", 500, async () => { 73 | const response = await fetch(`users/${this.userName}/info`); 74 | const data = await response.json(); 75 | return data.fullName; 76 | }); 77 | ``` 78 | 79 | The value of `fullName.get()` is observable. It will initially return 80 | `"(Please wait...)"` and will later transition to the user's full name. 81 | If the `this.userName` property is an observable and is modified, the 82 | `promisedComputed` will update also, but after waiting at least 500 83 | milliseconds. 84 | 85 | ---- 86 | 87 | ## promisedComputed 88 | 89 | Like `asyncComputed` but without the `delay` support. This has the slight advantage 90 | of being fully synchronous if the `compute` function returns a plain value. 91 | 92 | ### Parameters 93 | 94 | - `init` - the value to assume until the first genuine value is returned 95 | - `compute` - the function to evaluate to get a promise (or plain value) 96 | 97 | ### Returns 98 | 99 | Exactly as `asyncComputed`. 100 | 101 | ### Example 102 | 103 | ```ts 104 | fullName = promisedComputed("(Please wait...)", async () => { 105 | const response = await fetch(`users/${this.userName}/info`); 106 | const data = await response.json(); 107 | return data.fullName; 108 | }); 109 | ``` 110 | 111 | The value of `fullName.get()` is observable. It will initially return 112 | `"(Please wait...)"` and will later transition to the user's full name. 113 | If the `this.userName` property is an observable and is modified, the 114 | `promisedComputed` will update also, as soon as possible. 115 | 116 | ---- 117 | 118 | ## throttledComputed 119 | 120 | Like the standard `computed` but with support for delaying for a specified number of 121 | milliseconds before re-evaluation. It is like a computed version of the standard 122 | `autorunAsync`; the advantage is that you don't have to manually dispose it. 123 | 124 | (Note that `throttledComputed` has no special functionality for handling promises.) 125 | 126 | ### Parameters 127 | 128 | - `compute` - the function to evaluate to get a plain value 129 | - `delay` - the minimum time in milliseconds to wait before re-evaluating 130 | 131 | ### Returns 132 | 133 | A Mobx-style getter, i.e. an object with a `get` function that returns the current value. It 134 | is an observable, so it can be used from other MobX contexts. It can also be used outside 135 | MobX reactive contexts but (like standard `computed`) it reverts to simply re-evaluating 136 | every time you request the value. 137 | 138 | It also has a `refresh` method that *immediately* (synchronously) re-evaluates the function. 139 | 140 | ### Example 141 | 142 | ```ts 143 | fullName = throttledComputed(500, () => { 144 | const data = slowSearchInMemory(this.userName); 145 | return data.fullName; 146 | }); 147 | ``` 148 | 149 | The value of `fullName.get()` is observable. It will initially return the result of the 150 | search, which happens synchronously the first time. If the `this.userName` property is an 151 | observable and is modified, the `throttledComputed` will update also, but after waiting at 152 | least 500 milliseconds. 153 | 154 | ---- 155 | 156 | ## autorunThrottled 157 | 158 | Much like the standard `autorunAsync`, except that the initial run of the function happens 159 | synchronously. 160 | 161 | (This is used by `throttledComputed` to allow it to be synchronously initialized.) 162 | 163 | ### Parameters 164 | 165 | - `func` - The function to execute in reaction 166 | - `delay` - The minimum delay between executions 167 | - `name` - (optional) For MobX debug purposes 168 | 169 | ### Returns 170 | 171 | - a disposal function. 172 | 173 | A Mobx-style getter, i.e. an object with a `get` function that returns the current value. It 174 | is an observable, so it can be used from other MobX contexts. It can also be used outside 175 | MobX reactive contexts but (like standard `computed`) it reverts to simply re-evaluating 176 | every time you request the value. 177 | 178 | ---- 179 | 180 | # Installation 181 | 182 | npm install computed-async-mobx 183 | 184 | # TypeScript 185 | 186 | Of course TypeScript is optional; like a lot of libraries these days, this is a JavaScript 187 | library that happens to be written in TypeScript. It also has built-in type definitions: no 188 | need to `npm install @types/...` anything. 189 | 190 | # Acknowledgements 191 | 192 | I first saw this idea on the [Knockout.js wiki](https://github.com/knockout/knockout/wiki/Asynchronous-Dependent-Observables) in 2011. [As discussed here](https://smellegantcode.wordpress.com/2015/02/21/knockout-clear-fully-automatic-cleanup-in-knockoutjs-3-3/) it was tricky to make it well-behaved re: memory leaks for a few years. 193 | 194 | MobX uses the same (i.e. correct) approach as `ko.pureComputed` from the ground up, and the [Atom](http://mobxjs.github.io/mobx/refguide/extending.html#atoms) class makes it easy to detect when your data transitions 195 | between being observed and not. More recently I realised `fromPromise` in [mobx-utils](https://github.com/mobxjs/mobx-utils) 196 | could be used to implement `promisedComputed` pretty directly. If you don't need throttling (`delay` parameter) then all 197 | you need is a super-thin layer over existing libraries, which is what `promisedComputed` is. 198 | 199 | Also a :rose: for [Basarat](https://github.com/basarat) for pointing out the need to support strict mode! 200 | 201 | Thanks to [Daniel Nakov](https://github.com/dnakov) for fixes to support for MobX 4.x. 202 | 203 | # Usage 204 | 205 | Unlike the normal `computed` feature, `promisedComputed` can't work as a decorator on a property getter. This is because it changes the type of the return value from `PromiseLike` to `T`. 206 | 207 | Instead, as in the example above, declare an ordinary property. If you're using TypeScript (or an ES6 transpiler with equivalent support for classes) then you can declare and initialise the property in a class in one statement: 208 | 209 | ```ts 210 | class Person { 211 | 212 | @observable userName: string; 213 | 214 | creditScore = promisedComputed(0, async () => { 215 | const response = await fetch(`users/${this.userName}/score`); 216 | const data = await response.json(); 217 | return data.score; // score between 0 and 1000 218 | }); 219 | 220 | @computed 221 | get percentage() { 222 | return Math.round(this.creditScore.get() / 10); 223 | } 224 | } 225 | ``` 226 | 227 | Note how we can consume the value via the `.get()` function inside another (ordinary) computed and it too will re-evaluate when the score updates. 228 | 229 | # { enforceActions: "always" } 230 | 231 | This library is transparent with respect to [MobX's strict mode](https://github.com/mobxjs/mobx/blob/gh-pages/docs/refguide/api.md#enforceactions), and since 4.2.0 this is true even of the very strict `"always"` mode that doesn't even let you initialize fields of a class outside a reactive context. 232 | 233 | # Gotchas 234 | 235 | Take care when using `async`/`await`. MobX dependency tracking can only detect you reading data in the first "chunk" of a function containing `await`s. It's okay to read data in the expression passed to `await` (as in the above example) because that is evaluated before being passed to the first `await`. But after execution "returns" from the first `await` the context is different and MobX doesn't track further reads. 236 | 237 | For example, here we fetch two pieces of data to combine them together: 238 | 239 | ```ts 240 | answer = asyncComputed(0, 1000, async () => { 241 | const part1 = await fetch(this.part1Uri), 242 | part2 = await fetch(this.part2Uri); 243 | 244 | // combine part1 and part2 into a result somehow... 245 | return result; 246 | }); 247 | ``` 248 | 249 | The properties `part1Uri` and `part2Uri` are ordinary mobx `observable`s (or `computed`s). You'd expect that when either of those values changes, this `asyncComputed` will re-execute. But in fact it can only detect when `part1Uri` changes. When an `async` function is called, only the first part (up to the first `await`) executes immediately, and so that's the only part that MobX will be able to track. The remaining parts execute later on, when MobX has stopped listening. 250 | 251 | (Note: the expression on the right of `await` has to be executed before the `await` pauses the function, so the access to `this.part1Uri` is properly detected by MobX). 252 | 253 | We can work around this like so: 254 | 255 | ```ts 256 | answer = asyncComputed(0, 1000, async () => { 257 | const uri1 = this.part1Uri, 258 | uri2 = this.part2Uri; 259 | 260 | const part1 = await fetch(uri1), 261 | part2 = await fetch(uri2); 262 | 263 | // combine part1 and part2 into a result somehow... 264 | return result; 265 | }); 266 | ``` 267 | 268 | When in doubt, move all your gathering of observable values to the start of the `async` function. 269 | 270 | # Migration 271 | 272 | The API of previous versions is still available. It was a single `computedAsync` function that had all the 273 | capabilities, like a Swiss-Army Knife, making it difficult to test, maintain and use. It also had some 274 | built-in functionality that could just as easily be provided by user code, which is pointless and only 275 | creates obscurity. 276 | 277 | - Instead of calling `computedAsync` with a zero `delay`, use `promisedComputed`, which takes no `delay` 278 | parameter. 279 | - Instead of calling `computedAsync` with a non-zero `delay`, use `asyncComputed`. 280 | - Instead of using the `value` property, call the `get()` function (this is for closer consistency with 281 | standard MobX `computed`.) 282 | - Instead of using `revert`, use the `busy` property to decide when to substitute a different value. 283 | - The `rethrow` property made `computedAsync` propagate exceptions. There is no need to request this 284 | behaviour with `promisedComputed` and `asyncComputed` as they always propagate exceptions. 285 | - The `error` property computed a substitute value in case of an error. Instead, just do this substitution 286 | in your `compute` function. 287 | 288 | # Version History 289 | 290 | See [CHANGES.md](CHANGES.md). 291 | 292 | # License 293 | 294 | MIT, see [LICENSE](LICENSE) 295 | --------------------------------------------------------------------------------