├── .babelrc ├── .editorconfig ├── .eslintrc ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── scripts └── version.js ├── src ├── index.js ├── lazy.js ├── shouldUpdate.js ├── util.js └── watch.js ├── test └── index.js ├── types └── index.d.ts └── vitest.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "modules": false 5 | }] 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "standard", 3 | "plugins": [ 4 | "promise" 5 | ], 6 | "env": { 7 | "node": true, 8 | "es6": true 9 | }, 10 | "rules": { 11 | "quotes": 0, 12 | "no-unused-vars": 0, 13 | "comma-dangle": [ 14 | 2, 15 | "only-multiline" 16 | ], 17 | "indent": [ 18 | 2, 19 | 2, 20 | { 21 | "SwitchCase": 1, 22 | "VariableDeclarator": { 23 | "var": 2, 24 | "let": 2, 25 | "const": 3 26 | } 27 | } 28 | ], 29 | "one-var": 0, 30 | "promise/catch-or-return": 2 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | install: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | # Keep in sync 21 | node-version: 20.x 22 | - name: Cache node_modules 23 | uses: actions/cache@v3 24 | id: cache 25 | with: 26 | # Caching node_modules isn't recommended because it can break across 27 | # Node versions and won't work with npm ci (See https://github.com/actions/cache/blob/main/examples.md#node---npm ) 28 | # But we pin the node version, and we don't update it that often anyways. And 29 | # we don't use `npm ci` specifically to try to get faster CI flows. So caching 30 | # `node_modules` directly. 31 | path: 'node_modules' 32 | key: ${{ runner.os }}-node-20-${{ hashFiles('package*.json') }} 33 | - if: steps.cache.outputs.cache-hit != 'true' 34 | run: npm install 35 | 36 | build: 37 | needs: install 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v4 41 | - uses: actions/setup-node@v4 42 | with: 43 | node-version: 20.x 44 | - name: Load node_modules from cache 45 | uses: actions/cache@v3 46 | with: 47 | # Use node_modules from previous jobs 48 | path: 'node_modules' 49 | key: ${{ runner.os }}-node-20-${{ hashFiles('package*.json') }} 50 | - run: npm run buildOnly 51 | 52 | lint: 53 | needs: install 54 | runs-on: ubuntu-latest 55 | steps: 56 | - uses: actions/checkout@v4 57 | - uses: actions/setup-node@v4 58 | with: 59 | node-version: 20.x 60 | - name: Load node_modules from cache 61 | uses: actions/cache@v3 62 | with: 63 | # Use node_modules from previous jobs 64 | path: 'node_modules' 65 | key: ${{ runner.os }}-node-20-${{ hashFiles('package*.json') }} 66 | - run: npm run lint 67 | 68 | test: 69 | needs: install 70 | runs-on: ubuntu-latest 71 | steps: 72 | - uses: actions/checkout@v4 73 | - uses: actions/setup-node@v4 74 | with: 75 | node-version: 20.x 76 | - name: Load node_modules from cache 77 | uses: actions/cache@v3 78 | with: 79 | # Use node_modules from previous jobs 80 | path: 'node_modules' 81 | key: ${{ runner.os }}-node-20-${{ hashFiles('package*.json') }} 82 | - run: npm run test 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | logs 3 | *.log 4 | node_modules 5 | dist 6 | tmp 7 | coverage 8 | npm-debug.log 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - v14 4 | - v12 5 | - v10 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Changelog 4 | 5 | - [v4.0.0](#v400) 6 | - [v3.9.0](#v390) 7 | - [v3.8.1](#v381) 8 | - [v3.8.0](#v380) 9 | - [v3.7.0](#v370) 10 | - [v3.6.1](#v361) 11 | - [v3.6.0](#v360) 12 | - [v3.5.2](#v352) 13 | - [v3.5.1](#v351) 14 | - [v3.5.0](#v350) 15 | - [v3.4.0](#v340) 16 | - [v3.3.0](#v330) 17 | - [v3.2.1](#v321) 18 | - [v3.2.0](#v320) 19 | - [v3.1.3](#v313) 20 | - [v3.1.1](#v311) 21 | - [v3.1.0](#v310) 22 | - [v3.0.1](#v301) 23 | - [v3.0.0](#v300) 24 | - [v2.1.1](#v211) 25 | - [v2.1.0](#v210) 26 | - [v2.0.0](#v200) 27 | - [v1.4.0](#v140) 28 | - [v1.2.0](#v120) 29 | - [v1.1.0](#v110) 30 | - [v1.0.0](#v100) 31 | 32 | 33 | 34 | ### v4.0.0 35 | * Add support for Vue 3. 36 | * Drop support for Vue 2. 37 | 38 | ### v3.9.0 39 | * [#95](https://github.com/foxbenjaminfox/vue-async-computed/pull/95) Fix a bug where default values weren't properly used properly for lazy async computed properties. 40 | 41 | ### v3.8.1 42 | * Bugfix release in order to actually publish the typescript types along with the pacakge. 43 | 44 | ### v3.8.0 45 | * [#83](https://github.com/foxbenjaminfox/vue-async-computed/pull/83) Stop the update method from working after the component is destroyed. 46 | * Include the long-requested ([#25](https://github.com/foxbenjaminfox/vue-async-computed/issues/25)) typescript types in the `master` branch. 47 | * [#85](https://github.com/foxbenjaminfox/vue-async-computed/pull/85) Add support in the typescript types for the array of strings version of `watch`. 48 | 49 | ### v3.7.0 50 | * [#68](https://github.com/foxbenjaminfox/vue-async-computed/pull/68) Refactoring to make some of the code be more readable. 51 | * [#71](https://github.com/foxbenjaminfox/vue-async-computed/pull/71) Add `vm` and `info` arguments to the error handler callback (when `useRawError` is set.) 52 | 53 | ### v3.6.1 54 | * Fix for browsers that don't support `Symbol.iterator`. 55 | 56 | ### v3.6.0 57 | * Fix bug in handling the argument to the generated `data` function. 58 | * [#66](https://github.com/foxbenjaminfox/vue-async-computed/pull/66) Add option for `watch` to be an array of property paths instead of a function. 59 | 60 | ### v3.5.2 61 | * Point to a pre-transpiled version of the library as the `module` field in package.json. 62 | 63 | ### v3.5.1 64 | * [#54](https://github.com/foxbenjaminfox/vue-async-computed/pull/54): Fix the missing execution context during recomputations triggered through the `.update` method in `$asyncComputed`. 65 | * [#58](https://github.com/foxbenjaminfox/vue-async-computed/pull/58): Fix the reactivity of the `$asyncComputed` object. 66 | * [#59](https://github.com/foxbenjaminfox/vue-async-computed/pull/59): Distribute also as an ESM module. 67 | 68 | ### v3.5.0 69 | * [#45](https://github.com/foxbenjaminfox/vue-async-computed/pull/45): add a status property `$asyncComputed` to each Vue instance with information about the status 70 | of its async computed properties. 71 | 72 | ### v3.4.0 73 | * Add a `shouldUpdate` option, which can control when and if 74 | an async computed property updates. 75 | 76 | ### v3.3.0 77 | * New feature: lazily computed properties. 78 | 79 | ### v3.2.1 80 | * Fix bugs with dev dependencies and the new package-lock.json file. 81 | * Tests on Travis now also run on Node 8. 82 | 83 | ### v3.2.0 84 | * Introduce `watch` feature. 85 | 86 | ### v3.1.3 87 | * Fix a bug where extra properties on `Object.prototype` would be 88 | considered relevent to `vue-async-computed`. 89 | 90 | ### v3.1.1 91 | * Fix bug where `vue-async-computed` wouldn't find async computed 92 | properties that were further up the prototype chain. 93 | 94 | ### v3.1.0 95 | * Add option for setting a global default value 96 | * Improve test coverage 97 | * Async computed properties that return a non-promise value no longer cause 98 | an error to be thrown. Instead that value is automaticly promoted to a 99 | promise with `Promise.resolve`. 100 | 101 | ### v3.0.1 102 | * More test cases 103 | 104 | ### v3.0.0 105 | * Pass the raw error to the error handler when passed the `useRawError` option. 106 | * Allow default values to be given as functions. 107 | 108 | ### v2.1.1 109 | * Automatic installation when used in a script tag. 110 | 111 | ### v2.1.0 112 | * Allow object syntax for defining computed properties. 113 | * Enable custom default values. 114 | 115 | ### v2.0.0 116 | * Now compatible with Vue 2.0. 117 | 118 | ### v1.4.0 119 | * Add CommonJS support. 120 | 121 | ### v1.2.0 122 | * Use the same strategy to merge `asyncComputed` objects as regular `computed` objects. 123 | 124 | ### v1.1.0 125 | 126 | * Handle errors in async computed properties. 127 | 128 | ### v1.0.0 129 | 130 | * Initial public version of the library. 131 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-2020 Benjamin Fox 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.)) 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

vue-async-computed

2 | 3 |

4 | 5 | NPM Version 7 | 8 | 9 | 10 | Build Status 12 | 13 | 14 | 15 | Downloads 17 | 18 | 19 | 20 | License 22 | 23 |

24 | 25 | With this plugin, you can have computed properties in Vue that are computed asynchronously. 26 | 27 | Without using this plugin, you can't do this: 28 | 29 | ```js 30 | export default { 31 | data () { 32 | return { 33 | userId: 1 34 | } 35 | }, 36 | computed: { 37 | username () { 38 | return fetch(`/get-username-by-id/${this.userId}`) 39 | // This assumes that this endpoint will send us a response 40 | // that contains something like this: 41 | // { 42 | // "username": "username-goes-here" 43 | // } 44 | .then(response => response.json()) 45 | .then(user => user.username) 46 | } 47 | } 48 | } 49 | ``` 50 | 51 | Or rather, you could, but it wouldn't do what you'd want it to do. But using this plugin, it works just like you'd expect: 52 | 53 | ```js 54 | export default { 55 | data () { 56 | return { 57 | userId: 1 58 | } 59 | }, 60 | asyncComputed: { 61 | username () { 62 | return fetch(`/get-username-by-id/${this.userId}`) 63 | .then(r => r.json()) 64 | .then(user => user.username) 65 | } 66 | } 67 | } 68 | ``` 69 | 70 | This is especially useful with ES7 async functions: 71 | 72 | ```js 73 | export default { 74 | asyncComputed: { 75 | async someCalculation () { 76 | const x = await someAsycFunction() 77 | const y = await anotherAsyncFunction() 78 | return x + y 79 | } 80 | } 81 | } 82 | ``` 83 | 84 | ## Install 85 | 86 | ```sh 87 | npm install --save vue-async-computed 88 | ``` 89 | 90 | And then install `vue-async-computed` via `app.use()` to make it available for all your components: 91 | 92 | ```js 93 | import { createApp } from 'vue' 94 | import App from './App.vue' 95 | import AsyncComputed from 'vue-async-computed' 96 | 97 | const app = createApp(App) 98 | app.use(AsyncComputed) 99 | app.mount('#app') 100 | ``` 101 | 102 | Alternately, you can link it directly from a CDN: 103 | 104 | ```html 105 | 106 | 107 | 108 |
109 | + 110 | = {{sum == null ? 'Loading' : sum}} 111 |
112 | 113 | 132 | ``` 133 | 134 | ## Usage example 135 | 136 | ```js 137 | export default { 138 | data () { 139 | return { 140 | x: 2, 141 | y: 3 142 | } 143 | }, 144 | 145 | /* 146 | When you create a Vue instance (or component), 147 | you can pass an object named "asyncComputed" as well as 148 | or instead of the standard "computed" option. The functions 149 | you pass to "asyncComputed" should return promises, and the values 150 | those promises resolve to are then asynchronously bound to the 151 | Vue instance as they resolve. Just as with normal computed 152 | properties, if the data the property depends on changes 153 | then the property is re-run automatically. 154 | 155 | You can almost completely ignore the fact that behind the 156 | scenes they are asynchronous. The one thing to remember is 157 | that until a asynchronous property's promise resolves 158 | for the first time, the value of the computed property is null. 159 | */ 160 | asyncComputed: { 161 | /* 162 | Until one second has passed, vm.sum will be null. After that, 163 | vm.sum will be 5. If you change vm.x or vm.y, then one 164 | second later vm.sum will automatically update itself to be 165 | the sum of the values to which you set vm.x and vm.y the previous second. 166 | */ 167 | async sum () { 168 | const total = this.x + this.y 169 | await new Promise(resolve => setTimeout(resolve, 1000)) 170 | return total 171 | } 172 | } 173 | } 174 | ``` 175 | 176 | [Like with regular synchronous computed properties](https://vuejs.org/guide/essentials/computed.html#writable-computed), you can pass an object 177 | with a `get` method instead of a function, but unlike regular computed 178 | properties, async computed properties are always getter-only. If the 179 | object provided has a `set` method it will be ignored. 180 | 181 | Async computed properties can also have a custom default value, which will 182 | be used until the data is loaded for the first time: 183 | 184 | ```js 185 | export default { 186 | data () { 187 | return { 188 | postId: 1 189 | } 190 | }, 191 | asyncComputed: { 192 | blogPostContent: { 193 | // The `get` function is the same as the function you would 194 | // pass directly as the value to `blogPostContent` if you 195 | // didn't need to specify a default value. 196 | async get () { 197 | const post = await fetch(`/post/${this.postId}`) 198 | .then(response => response.json()) 199 | return post.postContent 200 | }, 201 | // The computed property `blogPostContent` will have 202 | // the value 'Loading...' until the first time the promise 203 | // returned from the `get` function resolves. 204 | default: 'Loading...' 205 | } 206 | } 207 | } 208 | 209 | /* 210 | Now you can display {{blogPostContent}} in your template, which 211 | will show a loading message until the blog post's content arrives 212 | from the server. 213 | */ 214 | ``` 215 | 216 | You can instead define the default value as a function, in order to depend on 217 | props or on data: 218 | 219 | ```js 220 | export default { 221 | data () { 222 | return { 223 | postId: 1 224 | } 225 | }, 226 | asyncComputed: { 227 | blogPostContent: { 228 | async get () { 229 | const post = await fetch(`/post/${this.postId}`) 230 | .then(response => response.json()) 231 | return post.postContent 232 | }, 233 | default () { 234 | return `Loading post ${this.postId}...` 235 | } 236 | } 237 | } 238 | } 239 | ``` 240 | 241 | You can also set a custom global default value in the options passed to `app.use`: 242 | 243 | ```javascript 244 | app.use(AsyncComputed, { 245 | default: 'Global default value' 246 | }) 247 | ``` 248 | 249 | ## Recalculation 250 | 251 | Just like normal computed properties, async computed properties keep track of their dependencies, and are only 252 | recalculated if those dependencies change. But often you'll have an async computed property you'll want to run again 253 | without any of its (local) dependencies changing, such as for instance the data may have changed in the database. 254 | 255 | You can set up a `watch` property, listing the additional dependencies to watch. 256 | Your async computed property will then be recalculated also if any of the watched 257 | dependencies change, in addition to the real dependencies the property itself has: 258 | 259 | ```js 260 | export default { 261 | data () { 262 | return { 263 | postId: 1, 264 | timesPostHasBeenUpdated: 0 265 | } 266 | }, 267 | asyncComputed: { 268 | // blogPostContent will update its contents if postId is changed 269 | // to point to a diffrent post, but will also refetch the post's 270 | // contents when you increment timesPostHasBeenUpdated. 271 | blogPostContent: { 272 | async get () { 273 | const post = await fetch(`/post/${this.postId}`) 274 | .then(response => response.json()) 275 | return post.postContent 276 | }, 277 | watch: ['timesPostHasBeenUpdated'] 278 | } 279 | } 280 | } 281 | ``` 282 | 283 | Just like with Vue's normal `watch`, you can use a dotted path in order to watch a nested property. For example, `watch: ['a.b.c', 'd.e']` would declare a dependency on `this.a.b.c` and on `this.d.e`. 284 | 285 | You can trigger re-computation of an async computed property manually, e.g. to re-try if an error occurred during evaluation. This should be avoided if you are able to achieve the same result using a watched property. 286 | 287 | ````js 288 | export default { 289 | asyncComputed: { 290 | blogPosts: { 291 | async get () { 292 | return fetch('/posts') 293 | .then(response => response.json()) 294 | } 295 | } 296 | }, 297 | methods: { 298 | refresh() { 299 | // Triggers an immediate update of blogPosts 300 | // Will work even if an update is in progress. 301 | this.$asyncComputed.blogPosts.update() 302 | } 303 | } 304 | } 305 | ```` 306 | 307 | ### Conditional Recalculation 308 | 309 | Using `watch` it is possible to force the computed property to run again unconditionally. 310 | If you need more control over when the computation should be rerun you can use `shouldUpdate`: 311 | 312 | ```js 313 | 314 | export default { 315 | data () { 316 | return { 317 | postId: 1, 318 | // Imagine pageType can be one of 'index', 'details' and 'edit'. 319 | pageType: 'index' 320 | } 321 | }, 322 | asyncComputed: { 323 | blogPostContent: { 324 | async get () { 325 | const post = await fetch(`/post/${this.postId}`) 326 | .then(response => response.json()) 327 | return post.postContent 328 | }, 329 | // Will update whenever the pageType or postId changes, 330 | // but only if the pageType is not 'index'. This way the 331 | // blogPostContent will be refetched only when loading the 332 | // 'details' and 'edit' pages. 333 | shouldUpdate () { 334 | return this.pageType !== 'index' 335 | } 336 | } 337 | } 338 | } 339 | ``` 340 | 341 | The main advantage over adding an `if` statement within the get function is that the old value is still accessible even if the computation is not re-run. 342 | 343 | ## Lazy properties 344 | 345 | Normally, computed properties are both run immediately, and re-run as necessary when their dependencies change. 346 | With async computed properties, you sometimes don't want that. With `lazy: true`, an async computed 347 | property will only be computed the first time it's accessed. 348 | 349 | For example: 350 | ```js 351 | export default { 352 | data () { 353 | return { 354 | id: 1 355 | } 356 | }, 357 | asyncComputed: { 358 | mightNotBeNeeded: { 359 | lazy: true, 360 | async get () { 361 | return fetch(`/might-not-be-needed/${this.id}`) 362 | .then(response => response.json()) 363 | .then(response => response.value) 364 | } 365 | // The value of `mightNotBeNeeded` will only be 366 | // calculated when it is first accessed. 367 | } 368 | } 369 | } 370 | ``` 371 | 372 | ## Computation status 373 | 374 | For each async computed property, an object is added to `$asyncComputed` that contains information about the current computation state of that object. This object contains the following properties: 375 | 376 | ```js 377 | { 378 | // Can be one of updating, success, error 379 | state: 'updating', 380 | // A boolean that is true while the property is updating. 381 | updating: true, 382 | // The property finished updating without errors (the promise was resolved) and the current value is available. 383 | success: false, 384 | // The promise was rejected. 385 | error: false, 386 | // The raw error/exception with which the promise was rejected. 387 | exception: null 388 | } 389 | ``` 390 | 391 | It is meant to be used in your rendering code to display update / error information: 392 | 393 | ````html 394 | 403 | 418 | ```` 419 | 420 | Note: If you want to display a special message the first time the posts load, you can use the fact that the default value is null: 421 | 422 | ```html 423 |
Loading posts
424 | ``` 425 | 426 | ## Global error handling 427 | 428 | By default, in case of a rejected promise in an async computed property, vue-async-computed will take care of logging the error for you. 429 | 430 | If you want to use a custom logging function, the plugin takes an `errorHandler` 431 | option, which should be the function you want called with the error information. 432 | By default, it will be called with only the error's stack trace as an argument, 433 | but if you register the `errorHandler` with `useRawError` set to `true` the 434 | function will receive the raw error, a reference to the `Vue` instance that 435 | threw the error and the error's stack trace. 436 | 437 | For example: 438 | 439 | ```js 440 | app.use(AsyncComputed, { 441 | errorHandler (stack) { 442 | console.log('Hey, an error!') 443 | console.log('---') 444 | console.log(stack) 445 | } 446 | }) 447 | 448 | // Or with `useRawError`: 449 | app.use(AsyncComputed, { 450 | useRawError: true, 451 | errorHandler (err, vm, stack) { 452 | console.log('An error occurred!') 453 | console.log('The error message was: ' + err.msg) 454 | console.log('And the stack trace was:') 455 | console.log(stack) 456 | } 457 | }) 458 | ``` 459 | 460 | You can pass `false` as the `errorHandler` in order to silently ignore rejected promises. 461 | 462 | ## License 463 | 464 | MIT © [Benjamin Fox](https://github.com/foxbenjaminfox) 465 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-async-computed", 3 | "version": "4.0.1", 4 | "description": "Async computed properties for Vue", 5 | "main": "dist/vue-async-computed.js", 6 | "module": "dist/vue-async-computed.esm.js", 7 | "types": "types/index.d.ts", 8 | "files": [ 9 | "bin/", 10 | "dist/", 11 | "types/" 12 | ], 13 | "scripts": { 14 | "clean": "rimraf dist", 15 | "lint": "eslint src test", 16 | "watch": "watch 'npm run build' src test", 17 | "test": "vitest run", 18 | "prebuild": "npm run lint -s", 19 | "build": "npm run buildOnly", 20 | "buildOnly": "npm run clean -s && mkdirp dist && npm run rollup -s && npm run babel -s", 21 | "rollup-esm": "rollup src/index.js --output.format esm --name AsyncComputed --output.file dist/vue-async-computed.esm.esnext.js", 22 | "rollup-umd": "rollup src/index.js --output.format umd --name AsyncComputed --output.file dist/vue-async-computed.esnext.js", 23 | "rollup": "npm run rollup-umd -s && npm run rollup-esm -s", 24 | "babel-umd": "babel --optional runtime dist/vue-async-computed.esnext.js --out-file dist/vue-async-computed.js", 25 | "babel-esm": "babel --optional runtime dist/vue-async-computed.esm.esnext.js --out-file dist/vue-async-computed.esm.js", 26 | "babel": "npm run babel-umd -s && npm run babel-esm -s", 27 | "postbuild": "npm run test -s", 28 | "prepublishOnly": "npm run build -s", 29 | "version": "node scripts/version.js", 30 | "patch": "npm version patch && npm publish", 31 | "minor": "npm version minor && npm publish", 32 | "major": "npm version major && npm publish", 33 | "postpublish": "git push origin master --follow-tags", 34 | "toc": "doctoc --github --title \"# Changelog\" CHANGELOG.md" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "git+https://github.com/foxbenjaminfox/vue-async-computed.git" 39 | }, 40 | "keywords": [ 41 | "vue", 42 | "data", 43 | "async", 44 | "computed", 45 | "computed data" 46 | ], 47 | "author": "Benjamin Fox ", 48 | "license": "MIT", 49 | "bugs": { 50 | "url": "https://github.com/foxbenjaminfox/vue-async-computed/issues" 51 | }, 52 | "homepage": "https://github.com/foxbenjaminfox/vue-async-computed#readme", 53 | "peerDependencies": { 54 | "vue": "~3" 55 | }, 56 | "devDependencies": { 57 | "babel-cli": "^6.26.0", 58 | "babel-core": "^6.26.3", 59 | "babel-preset-env": "^1.7.0", 60 | "doctoc": "^1.4.0", 61 | "eslint": "^8.54.0", 62 | "eslint-config-standard": "^17.1.0", 63 | "eslint-plugin-import": "^2.29.0", 64 | "eslint-plugin-node": "^11.1.0", 65 | "eslint-plugin-promise": "^6.1.1", 66 | "happy-dom": "^12.10.3", 67 | "mkdirp": "^3.0.1", 68 | "rimraf": "^5.0.5", 69 | "rollup": "^2.26.3", 70 | "vitest": "^0.34.6", 71 | "vue": "^3.3.7", 72 | "watch": "^1.0.2" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /scripts/version.js: -------------------------------------------------------------------------------- 1 | // Replace version in README.md 2 | const { version: NEW_VERSION } = require('../package.json'); 3 | const fs = require('fs'); 4 | const { execSync } = require('child_process'); 5 | 6 | const readme = fs.readFileSync('README.md', 'utf8'); 7 | const newReadme = readme.replace(/vue-async-computed@\d+\.\d+\.\d+/g, `vue-async-computed@${NEW_VERSION}`); 8 | fs.writeFileSync('README.md', newReadme, 'utf8'); 9 | 10 | execSync('git add README.md'); 11 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | initLazy, 3 | isComputedLazy, 4 | isLazyActive, 5 | makeLazyComputed, 6 | silentGetLazy, 7 | silentSetLazy, 8 | } from './lazy' 9 | import { 10 | getterOnly, 11 | hasOwnProperty, 12 | setAsyncState, 13 | } from './util' 14 | import { getWatchedGetter } from './watch' 15 | import { 16 | getGetterWithShouldUpdate, 17 | shouldNotUpdate, 18 | } from './shouldUpdate' 19 | 20 | const prefix = '_async_computed$' 21 | 22 | /** @type {import('vue').Plugin} */ 23 | const AsyncComputed = { 24 | install (app, pluginOptions) { 25 | // Use same logic as `computed` merging. 26 | // See: https://github.com/vuejs/core/blob/32bdc5d1900ceb8df1e8ee33ea65af7b4da61051/packages/runtime-core/src/componentOptions.ts#L1059 27 | const mergeStrategy = function (to, from) { 28 | return to ? Object.assign(Object.create(null), to, from) : from 29 | } 30 | app.config.optionMergeStrategies.asyncComputed = mergeStrategy 31 | 32 | app.mixin(getAsyncComputedMixin(pluginOptions)) 33 | } 34 | } 35 | 36 | function getAsyncComputedMixin (pluginOptions = {}) { 37 | /** @type {import('vue').ComponentOptionsMixin} */ 38 | return { 39 | data () { 40 | return { 41 | _asyncComputed: {}, 42 | _asyncComputedIsMounted: false, 43 | } 44 | }, 45 | computed: { 46 | $asyncComputed () { 47 | return this.$data._asyncComputed 48 | } 49 | }, 50 | beforeCreate () { 51 | const asyncComputed = this.$options.asyncComputed || {} 52 | 53 | if (!Object.keys(asyncComputed).length) return 54 | 55 | for (const key in asyncComputed) { 56 | const getter = getterFn(key, asyncComputed[key]) 57 | this.$options.computed[prefix + key] = getter 58 | } 59 | 60 | this.$options.data = initDataWithAsyncComputed(this.$options, pluginOptions) 61 | }, 62 | created () { 63 | for (const key in this.$options.asyncComputed || {}) { 64 | const item = this.$options.asyncComputed[key], 65 | value = generateDefault.call(this, item, pluginOptions) 66 | if (isComputedLazy(item)) { 67 | silentSetLazy(this, key, value) 68 | } else { 69 | this[key] = value 70 | } 71 | } 72 | 73 | for (const key in this.$options.asyncComputed || {}) { 74 | handleAsyncComputedPropetyChanges(this, key, pluginOptions) 75 | } 76 | }, 77 | 78 | mounted () { 79 | this._asyncComputedIsMounted = true 80 | }, 81 | beforeUnmount () { 82 | this._asyncComputedIsMounted = false 83 | }, 84 | } 85 | } 86 | const AsyncComputedMixin = getAsyncComputedMixin() 87 | 88 | function handleAsyncComputedPropetyChanges (vm, key, pluginOptions) { 89 | let promiseId = 0 90 | const watcher = newPromise => { 91 | const thisPromise = ++promiseId 92 | 93 | if (shouldNotUpdate(newPromise)) return 94 | 95 | if (!newPromise || !newPromise.then) { 96 | newPromise = Promise.resolve(newPromise) 97 | } 98 | setAsyncState(vm, key, 'updating') 99 | 100 | newPromise.then(value => { 101 | if (thisPromise !== promiseId) return 102 | setAsyncState(vm, key, 'success') 103 | vm[key] = value 104 | }).catch(err => { 105 | if (thisPromise !== promiseId) return 106 | 107 | setAsyncState(vm, key, 'error') 108 | vm.$data._asyncComputed[key].exception = err 109 | if (pluginOptions.errorHandler === false) return 110 | 111 | const handler = (pluginOptions.errorHandler === undefined) 112 | ? console.error.bind(console, 'Error evaluating async computed property:') 113 | : pluginOptions.errorHandler 114 | 115 | if (pluginOptions.useRawError) { 116 | handler(err, vm, err.stack) 117 | } else { 118 | handler(err.stack) 119 | } 120 | }) 121 | } 122 | vm.$data._asyncComputed[key] = { 123 | exception: null, 124 | update: () => { 125 | if (vm._asyncComputedIsMounted) { 126 | watcher(getterOnly(vm.$options.asyncComputed[key]).apply(vm)) 127 | } 128 | } 129 | } 130 | setAsyncState(vm, key, 'updating') 131 | vm.$watch(prefix + key, watcher, { immediate: true }) 132 | } 133 | 134 | function initDataWithAsyncComputed (options, pluginOptions) { 135 | const optionData = options.data 136 | const asyncComputed = options.asyncComputed || {} 137 | 138 | return function vueAsyncComputedInjectedDataFn (vm) { 139 | const data = ((typeof optionData === 'function') 140 | ? optionData.call(this, vm) 141 | : optionData) || {} 142 | for (const key in asyncComputed) { 143 | const item = this.$options.asyncComputed[key] 144 | 145 | const value = generateDefault.call(this, item, pluginOptions) 146 | if (isComputedLazy(item)) { 147 | initLazy(data, key, value) 148 | this.$options.computed[key] = makeLazyComputed(key) 149 | } else { 150 | data[key] = value 151 | } 152 | } 153 | return data 154 | } 155 | } 156 | 157 | function getterFn (key, fn) { 158 | if (typeof fn === 'function') return fn 159 | 160 | let getter = fn.get 161 | 162 | if (hasOwnProperty(fn, 'watch')) { 163 | getter = getWatchedGetter(fn) 164 | } 165 | 166 | if (hasOwnProperty(fn, 'shouldUpdate')) { 167 | getter = getGetterWithShouldUpdate(fn, getter) 168 | } 169 | 170 | if (isComputedLazy(fn)) { 171 | const nonLazy = getter 172 | getter = function lazyGetter () { 173 | if (isLazyActive(this, key)) { 174 | return nonLazy.call(this) 175 | } else { 176 | return silentGetLazy(this, key) 177 | } 178 | } 179 | } 180 | return getter 181 | } 182 | 183 | function generateDefault (fn, pluginOptions) { 184 | let defaultValue = null 185 | 186 | if ('default' in fn) { 187 | defaultValue = fn.default 188 | } else if ('default' in pluginOptions) { 189 | defaultValue = pluginOptions.default 190 | } 191 | 192 | if (typeof defaultValue === 'function') { 193 | return defaultValue.call(this) 194 | } else { 195 | return defaultValue 196 | } 197 | } 198 | 199 | export default AsyncComputed 200 | export { AsyncComputed as AsyncComputedPlugin, AsyncComputedMixin } 201 | 202 | /* istanbul ignore if */ 203 | if (typeof window !== 'undefined') { 204 | // Provide as global in dist mode 205 | window.AsyncComputed = AsyncComputed 206 | } 207 | -------------------------------------------------------------------------------- /src/lazy.js: -------------------------------------------------------------------------------- 1 | import { hasOwnProperty } from './util' 2 | 3 | export function isComputedLazy (item) { 4 | return hasOwnProperty(item, 'lazy') && item.lazy 5 | } 6 | 7 | export function isLazyActive (vm, key) { 8 | return vm[lazyActivePrefix + key] 9 | } 10 | 11 | const lazyActivePrefix = 'async_computed$lazy_active$', 12 | lazyDataPrefix = 'async_computed$lazy_data$' 13 | 14 | export function initLazy (data, key, value) { 15 | data[lazyActivePrefix + key] = false 16 | data[lazyDataPrefix + key] = value 17 | } 18 | 19 | export function makeLazyComputed (key) { 20 | return { 21 | get () { 22 | this[lazyActivePrefix + key] = true 23 | return this[lazyDataPrefix + key] 24 | }, 25 | set (value) { 26 | this[lazyDataPrefix + key] = value 27 | } 28 | } 29 | } 30 | 31 | export function silentSetLazy (vm, key, value) { 32 | vm[lazyDataPrefix + key] = value 33 | } 34 | export function silentGetLazy (vm, key) { 35 | return vm[lazyDataPrefix + key] 36 | } 37 | -------------------------------------------------------------------------------- /src/shouldUpdate.js: -------------------------------------------------------------------------------- 1 | const DidNotUpdate = typeof Symbol === 'function' ? Symbol('did-not-update') : {} 2 | 3 | export const getGetterWithShouldUpdate = (asyncProprety, currentGetter) => { 4 | return function getter () { 5 | return (asyncProprety.shouldUpdate.call(this)) 6 | ? currentGetter.call(this) 7 | : DidNotUpdate 8 | } 9 | } 10 | 11 | export const shouldNotUpdate = (value) => DidNotUpdate === value 12 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | export function setAsyncState (vm, stateObject, state) { 2 | vm.$data._asyncComputed[stateObject].state = state 3 | vm.$data._asyncComputed[stateObject].updating = state === 'updating' 4 | vm.$data._asyncComputed[stateObject].error = state === 'error' 5 | vm.$data._asyncComputed[stateObject].success = state === 'success' 6 | } 7 | 8 | export function getterOnly (fn) { 9 | if (typeof fn === 'function') return fn 10 | 11 | return fn.get 12 | } 13 | 14 | export function hasOwnProperty (object, property) { 15 | return Object.prototype.hasOwnProperty.call(object, property) 16 | } 17 | -------------------------------------------------------------------------------- /src/watch.js: -------------------------------------------------------------------------------- 1 | const getGetterWatchedByArray = computedAsyncProperty => 2 | function getter () { 3 | computedAsyncProperty.watch.forEach(key => { 4 | // Check if nested key is watched. 5 | const splittedByDot = key.split('.') 6 | if (splittedByDot.length === 1) { 7 | // If not, just access it. 8 | // eslint-disable-next-line no-unused-expressions 9 | this[key] 10 | } else { 11 | // Access the nested propety. 12 | try { 13 | let start = this 14 | splittedByDot.forEach(part => { 15 | start = start[part] 16 | }) 17 | } catch (error) { 18 | console.error('AsyncComputed: bad path: ', key) 19 | throw error 20 | } 21 | } 22 | }) 23 | return computedAsyncProperty.get.call(this) 24 | } 25 | 26 | const getGetterWatchedByFunction = computedAsyncProperty => 27 | function getter () { 28 | computedAsyncProperty.watch.call(this) 29 | return computedAsyncProperty.get.call(this) 30 | } 31 | 32 | export function getWatchedGetter (computedAsyncProperty) { 33 | if (typeof computedAsyncProperty.watch === 'function') { 34 | return getGetterWatchedByFunction(computedAsyncProperty) 35 | } else if (Array.isArray(computedAsyncProperty.watch)) { 36 | computedAsyncProperty.watch.forEach(key => { 37 | if (typeof key !== 'string') { 38 | throw new Error('AsyncComputed: watch elemnts must be strings') 39 | } 40 | }) 41 | return getGetterWatchedByArray(computedAsyncProperty) 42 | } else { 43 | throw Error('AsyncComputed: watch should be function or an array') 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, expect, test, vi } from 'vitest' 2 | import AsyncComputed from "../src" 3 | import { createApp, defineComponent } from 'vue' 4 | 5 | function newVue (component, pluginOpts = {}) { 6 | // Provide default template to silence warnings 7 | app = createApp(Object.assign({ template: '
' }, component)) 8 | app.use(AsyncComputed, pluginOpts) 9 | return app.mount(document.body) 10 | } 11 | 12 | let app = null 13 | beforeEach(() => { 14 | vi.useFakeTimers() 15 | }) 16 | afterEach(() => { 17 | vi.restoreAllMocks() 18 | app.unmount() 19 | }) 20 | 21 | test("Async computed values are computed", async () => { 22 | const vm = newVue({ 23 | asyncComputed: { 24 | a () { 25 | return new Promise(resolve => { 26 | setTimeout(() => resolve('done'), 10) 27 | }) 28 | }, 29 | b () { 30 | return new Promise(resolve => { 31 | setTimeout(() => resolve(1337), 20) 32 | }) 33 | } 34 | } 35 | }) 36 | 37 | expect(vm.a).toBeNull() 38 | expect(vm.b).toBeNull() 39 | await vi.runAllTimersAsync() 40 | expect(vm.a).toBe('done') 41 | expect(vm.b).toBe(1337) 42 | }) 43 | 44 | test("An async computed value which is an pre-resolved promise updates at the next tick", async () => { 45 | const vm = newVue({ 46 | asyncComputed: { 47 | a () { 48 | return Promise.resolve('done') 49 | } 50 | } 51 | }) 52 | 53 | expect(vm.a).toBeNull() 54 | await vm.$nextTick() 55 | expect(vm.a).toBe('done') 56 | }) 57 | 58 | test("Sync and async computed data work together", async () => { 59 | const vm = newVue({ 60 | asyncComputed: { 61 | a () { 62 | return new Promise(resolve => { 63 | setTimeout(() => resolve('done'), 10) 64 | }) 65 | } 66 | }, 67 | computed: { 68 | b () { 69 | return 0 70 | } 71 | } 72 | }) 73 | 74 | expect(vm.a).toBeNull() 75 | expect(vm.b).toBe(0) 76 | 77 | await vi.runAllTimersAsync() 78 | 79 | expect(vm.a).toBe('done') 80 | expect(vm.b).toBe(0) 81 | }) 82 | 83 | test("Async values are properly recalculated", async () => { 84 | const vm = newVue({ 85 | asyncComputed: { 86 | a () { 87 | const data = this.x 88 | return new Promise(resolve => { 89 | setTimeout(() => resolve(data), 10) 90 | }) 91 | }, 92 | b () { 93 | return new Promise(resolve => { 94 | setTimeout(() => resolve('done'), 40) 95 | }) 96 | } 97 | }, 98 | data: { 99 | x: 0 100 | } 101 | }) 102 | 103 | expect(vm.a).toBeNull() 104 | expect(vm.b).toBeNull() 105 | expect(vm.x).toBe(0) 106 | 107 | await vi.advanceTimersByTimeAsync(10) 108 | expect(vm.a).toBe(0) 109 | expect(vm.b).toBeNull() 110 | expect(vm.x).toBe(0) 111 | 112 | vm.x = 1 113 | expect(vm.a).toBe(0) 114 | await vi.advanceTimersByTimeAsync(10) 115 | expect(vm.a).toBe(1) 116 | 117 | await vi.advanceTimersByTimeAsync(20) 118 | expect(vm.b).toBe('done') 119 | }) 120 | 121 | test("Old async values are properly invalidated", async () => { 122 | const vm = newVue({ 123 | asyncComputed: { 124 | a () { 125 | return new Promise(resolve => { 126 | setTimeout(() => resolve(this.waitTime), this.waitTime) 127 | }) 128 | } 129 | }, 130 | data: { 131 | waitTime: 40 132 | } 133 | }) 134 | 135 | expect(vm.a).toBeNull() 136 | await vi.advanceTimersByTimeAsync(10) 137 | vm.waitTime = 10 138 | expect(vm.a).toBeNull() 139 | await vi.advanceTimersByTimeAsync(10) 140 | expect(vm.a).toBe(10) 141 | await vi.runAllTimersAsync() 142 | expect(vm.a).toBe(10) // Not 40, even though the promise was created with 40 143 | }) 144 | 145 | test("Having only sync computed data still works", async () => { 146 | const vm = newVue({ 147 | computed: { 148 | a () { 149 | return this.x 150 | } 151 | }, 152 | data: { 153 | x: 2 154 | } 155 | }) 156 | expect(vm.a).toBe(2) 157 | 158 | const watchListener = vi.fn() 159 | vm.$watch('a', watchListener) 160 | vm.x++ 161 | expect(vm.a).toBe(3) 162 | await vm.$nextTick() 163 | expect(watchListener).toHaveBeenCalledTimes(1) 164 | expect(watchListener).toHaveBeenCalledWith(3, 2, expect.anything()) 165 | }) 166 | 167 | test("Errors in computed properties are handled", async () => { 168 | const errorHandler = vi.fn() 169 | const vm = newVue({ 170 | asyncComputed: { 171 | a () { 172 | return Promise.reject(new Error('error')) 173 | } 174 | } 175 | }, { errorHandler }) 176 | expect(vm.a).toBeNull() 177 | await vm.$nextTick() // Triggers the asyncComputed body 178 | await vm.$nextTick() // Triggers the reject 179 | expect(vm.a).toBeNull() 180 | expect(errorHandler).toHaveBeenCalledTimes(1) 181 | expect(errorHandler.mock.lastCall[0].slice(0, 13)).toBe('Error: error\n') 182 | }) 183 | 184 | test("Errors in computed properties are handled, with useRawError", async () => { 185 | const errorHandler = vi.fn() 186 | 187 | const vm = newVue({ 188 | asyncComputed: { 189 | a () { 190 | // eslint-disable-next-line prefer-promise-reject-errors 191 | return Promise.reject('error') 192 | } 193 | } 194 | }, { errorHandler, useRawError: true }) 195 | expect(vm.a).toBeNull() 196 | await vm.$nextTick() // Triggers the asyncComputed body 197 | await vm.$nextTick() // Triggers the reject 198 | expect(vm.a).toBeNull() 199 | expect(errorHandler).toHaveBeenCalledTimes(1) 200 | expect(errorHandler.mock.lastCall[0]).toBe('error') 201 | }) 202 | 203 | test("Multiple asyncComputed objects are handled the same as normal computed property objects", async () => { 204 | const vm = newVue({ 205 | mixins: [{ 206 | asyncComputed: { 207 | a () { 208 | return Promise.resolve('mixin-a') 209 | }, 210 | b () { 211 | return Promise.resolve('mixin-b') 212 | } 213 | } 214 | }], 215 | asyncComputed: { 216 | a () { 217 | return Promise.resolve('vm-a') 218 | }, 219 | c () { 220 | return Promise.resolve('vm-c') 221 | } 222 | } 223 | }) 224 | await vm.$nextTick() 225 | expect(vm.a).toBe('vm-a') 226 | expect(vm.b).toBe('mixin-b') 227 | expect(vm.c).toBe('vm-c') 228 | }) 229 | 230 | test("Async computed values can have defaults", async () => { 231 | const xWatcher = vi.fn() 232 | const computedFromX = vi.fn(function () { return this.x }) 233 | 234 | const vm = newVue({ 235 | asyncComputed: { 236 | x: { 237 | default: false, 238 | get () { 239 | return Promise.resolve(true) 240 | } 241 | }, 242 | y () { 243 | return Promise.resolve(true) 244 | }, 245 | z: { 246 | get () { 247 | return Promise.resolve(true) 248 | } 249 | } 250 | }, 251 | watch: { 252 | x: { 253 | deep: true, 254 | immediate: true, 255 | handler: xWatcher, 256 | }, 257 | }, 258 | computed: { 259 | computedFromX, 260 | }, 261 | }) 262 | 263 | expect(vm.x).toBe(false) // x should default to false 264 | expect(vm.y).toBeNull() // y doesn't have a default 265 | expect(vm.z).toBeNull() // z doesn't have a default despite being defined with an object 266 | 267 | expect(xWatcher).toHaveBeenCalledTimes(1) 268 | expect(xWatcher).toHaveBeenCalledWith(false, undefined, expect.anything()) 269 | expect(computedFromX).toHaveBeenCalledTimes(0) 270 | const computed = vm.computedFromX // Force computed execution 271 | expect(computed).toBe(false) 272 | expect(xWatcher).toHaveBeenCalledTimes(1) 273 | expect(computedFromX).toHaveBeenCalledTimes(1) 274 | 275 | await vm.$nextTick() 276 | expect(vm.x).toBe(true) // x resolves to true 277 | expect(vm.y).toBe(true) // y resolves to true 278 | expect(vm.z).toBe(true) // z resolves to true 279 | }) 280 | 281 | test("Default values can be functions", async () => { 282 | const vm = newVue({ 283 | data: { 284 | x: 1 285 | }, 286 | asyncComputed: { 287 | y: { 288 | default () { return 2 }, 289 | get () { 290 | return Promise.resolve(3) 291 | } 292 | }, 293 | z: { 294 | default () { return this.x }, 295 | get () { 296 | return Promise.resolve(4) 297 | } 298 | } 299 | } 300 | }) 301 | expect(vm.y).toBe(2) 302 | expect(vm.z).toBe(1) 303 | await vm.$nextTick() 304 | expect(vm.y).toBe(3) 305 | expect(vm.z).toBe(4) 306 | }) 307 | 308 | test("Async computed values can be written to, and then will be properly overridden", async () => { 309 | const vm = newVue({ 310 | data: { 311 | x: 1 312 | }, 313 | asyncComputed: { 314 | y () { 315 | this.y = this.x + 1 316 | return new Promise(resolve => { 317 | setTimeout(() => resolve(this.x), 10) 318 | }) 319 | } 320 | } 321 | }) 322 | expect(vm.y).toBe(2) 323 | await vi.advanceTimersByTimeAsync(10) 324 | expect(vm.y).toBe(1) 325 | vm.x = 4 326 | expect(vm.y).toBe(1) 327 | await vm.$nextTick() 328 | expect(vm.y).toBe(5) 329 | await vi.advanceTimersByTimeAsync(10) 330 | expect(vm.y).toBe(4) 331 | }) 332 | 333 | test("Watchers rerun the computation when a value changes", async () => { 334 | let i = 0 335 | const vm = newVue({ 336 | data: { 337 | x: 0, 338 | y: 2, 339 | }, 340 | asyncComputed: { 341 | z: { 342 | get () { 343 | return Promise.resolve(i + this.y) 344 | }, 345 | watch () { 346 | // eslint-disable-next-line no-unused-expressions 347 | this.x 348 | } 349 | } 350 | } 351 | }) 352 | expect(vm.z).toBeNull() 353 | await vm.$nextTick() 354 | expect(vm.z).toBe(2) 355 | i++ 356 | vm.x-- 357 | await vm.$nextTick() 358 | // This tick, Vue registers the change 359 | // in the watcher, and reevaluates 360 | // the getter function 361 | // And since we 'await', the promise chain 362 | // is finished and z is 3 363 | expect(vm.z).toBe(3) 364 | }) 365 | 366 | test("shouldUpdate controls when to rerun the computation when a value changes", async () => { 367 | let i = 0 368 | let getCallCount = 0 369 | const vm = newVue({ 370 | data: { 371 | x: 0, 372 | y: 2, 373 | }, 374 | asyncComputed: { 375 | z: { 376 | get () { 377 | getCallCount++ 378 | return Promise.resolve(i + this.y) 379 | }, 380 | shouldUpdate () { 381 | return this.x % 2 === 0 382 | } 383 | } 384 | } 385 | }) 386 | expect(getCallCount).toBe(1) 387 | expect(vm.z).toBeNull() 388 | await vm.$nextTick() 389 | expect(getCallCount).toBe(1) 390 | expect(vm.z).toBe(2) 391 | i++ 392 | // update x so it will be 1 393 | // should update returns false now 394 | vm.x++ 395 | expect(getCallCount).toBe(1) 396 | await vm.$nextTick() 397 | // This tick, Vue registers the change 398 | // in the watcher, and reevaluates 399 | // the getter function 400 | expect(vm.z).toBe(2) 401 | await vm.$nextTick() 402 | // Now in this tick the promise has 403 | // resolved, and z is 2 since should update returned false. 404 | expect(vm.z).toBe(2) 405 | // update x so it will be 2 406 | // should update returns true now 407 | expect(getCallCount).toBe(1) 408 | vm.x++ 409 | expect(getCallCount).toBe(1) 410 | await vm.$nextTick() 411 | expect(getCallCount).toBe(2) 412 | // This tick, Vue registers the change 413 | // in the watcher, and reevaluates 414 | // the getter function 415 | // And since we 'await', the promise chain 416 | // is finished and z is 3 417 | expect(vm.z).toBe(3) 418 | }) 419 | 420 | test("Watchers trigger but shouldUpdate can still block their updates", async () => { 421 | let i = 0 422 | const vm = newVue({ 423 | data: { 424 | canUpdate: true, 425 | x: 0, 426 | y: 2, 427 | }, 428 | asyncComputed: { 429 | z: { 430 | get () { 431 | return Promise.resolve(i + this.y) 432 | }, 433 | watch () { 434 | // eslint-disable-next-line no-unused-expressions 435 | this.x 436 | }, 437 | shouldUpdate () { 438 | return this.canUpdate 439 | } 440 | } 441 | } 442 | }) 443 | expect(vm.z).toBeNull() 444 | await vm.$nextTick() 445 | expect(vm.z).toBe(2) 446 | i++ 447 | vm.x-- 448 | await vm.$nextTick() 449 | // This tick, Vue registers the change 450 | // in the watcher, and reevaluates 451 | // the getter function 452 | // And since we 'await', the promise chain 453 | // is finished 454 | expect(vm.z).toBe(3) 455 | // We stop all updates from now on 456 | vm.canUpdate = false 457 | i++ 458 | vm.x-- 459 | await vm.$nextTick() 460 | // This tick, Vue registers the change 461 | // in the watcher, and reevaluates 462 | // the getter function but no update 463 | expect(vm.z).toBe(3) 464 | await vm.$nextTick() 465 | // Now in this tick the promise has 466 | // resolved, and z is still 3. 467 | expect(vm.z).toBe(3) 468 | }) 469 | 470 | test("The default default value can be set in the plugin options", async () => { 471 | const vm = newVue({ 472 | asyncComputed: { 473 | x () { 474 | return Promise.resolve(0) 475 | } 476 | } 477 | }, { default: 53 }) 478 | expect(vm.x).toBe(53) 479 | await vm.$nextTick() 480 | expect(vm.x).toBe(0) 481 | }) 482 | 483 | test("The default default value can be set to undefined in the plugin options", async () => { 484 | const vm = newVue({ 485 | asyncComputed: { 486 | x () { 487 | return Promise.resolve(0) 488 | } 489 | } 490 | }, { default: undefined }) 491 | expect(vm.x).toBeUndefined() 492 | await vm.$nextTick() 493 | expect(vm.x).toBe(0) 494 | }) 495 | 496 | test("Handle an async computed value returning synchronously", async () => { 497 | const vm = newVue({ 498 | asyncComputed: { 499 | x () { 500 | return 1 501 | } 502 | } 503 | }) 504 | expect(vm.x).toBeNull() 505 | await vm.$nextTick() 506 | expect(vm.x).toBe(1) 507 | }) 508 | 509 | test("Work correctly with Vue.extend", async () => { 510 | const SubVue = defineComponent({ 511 | asyncComputed: { 512 | x () { 513 | return Promise.resolve(1) 514 | } 515 | } 516 | }) 517 | const vm = newVue({ extends: SubVue }) 518 | expect(vm.x).toBeNull() 519 | await vm.$nextTick() 520 | expect(vm.x).toBe(1) 521 | }) 522 | 523 | test("Async computed values can be calculated lazily", async () => { 524 | let called = false 525 | const vm = newVue({ 526 | asyncComputed: { 527 | a: { 528 | lazy: true, 529 | get () { 530 | called = true 531 | return Promise.resolve(10) 532 | } 533 | } 534 | } 535 | }) 536 | 537 | expect(called).toBe(false) 538 | await vm.$nextTick() 539 | expect(called).toBe(false) 540 | expect(vm.a).toBe(null) 541 | expect(vm.a).toBe(null) 542 | expect(called).toBe(false) 543 | await vm.$nextTick() 544 | expect(called).toBe(true) 545 | expect(vm.a).toBe(10) 546 | }) 547 | 548 | test("Async computed values aren't lazy with { lazy: false }", async () => { 549 | let called = false 550 | const vm = newVue({ 551 | asyncComputed: { 552 | a: { 553 | lazy: false, 554 | get () { 555 | called = true 556 | return Promise.resolve(10) 557 | } 558 | } 559 | } 560 | }) 561 | 562 | expect(called).toBe(true) 563 | expect(vm.a).toBeNull() 564 | await vm.$nextTick() 565 | expect(called).toBe(true) 566 | expect(vm.a).toBe(10) 567 | }) 568 | 569 | test("Async computed values can be calculated lazily with a default", async () => { 570 | let called = false 571 | const vm = newVue({ 572 | asyncComputed: { 573 | a: { 574 | lazy: true, 575 | default: 3, 576 | get () { 577 | called = true 578 | return Promise.resolve(4) 579 | } 580 | } 581 | } 582 | }) 583 | 584 | expect(called).toBe(false) 585 | await vm.$nextTick() 586 | expect(called).toBe(false) 587 | expect(vm.a).toBe(3) 588 | expect(vm.a).toBe(3) 589 | expect(called).toBe(false) 590 | await vm.$nextTick() 591 | expect(called).toBe(true) 592 | expect(vm.a).toBe(4) 593 | }) 594 | 595 | test("Underscore prefixes work (issue #33)", async () => { 596 | const vm = newVue({ 597 | computed: { 598 | sync_a () { 599 | return 1 600 | }, 601 | _sync_b () { 602 | return 2 603 | } 604 | }, 605 | asyncComputed: { 606 | _async_a () { 607 | return new Promise(resolve => { 608 | setTimeout(() => resolve(this.sync_a), 10) 609 | }) 610 | }, 611 | async_b () { 612 | return new Promise(resolve => { 613 | setTimeout(() => resolve(this._sync_b), 10) 614 | }) 615 | } 616 | } 617 | }) 618 | expect(vm._async_a).toBeNull() 619 | expect(vm.async_b).toBeNull() 620 | // _async_a is not reactive, because 621 | // it begins with an underscore 622 | await vi.advanceTimersByTimeAsync(10) 623 | expect(vm._async_a).toBe(1) 624 | expect(vm.async_b).toBe(2) 625 | }) 626 | 627 | test("shouldUpdate works with lazy", async () => { 628 | const vm = newVue({ 629 | data: { 630 | a: 0, 631 | x: true, 632 | y: false, 633 | }, 634 | asyncComputed: { 635 | b: { 636 | lazy: true, 637 | get () { 638 | return Promise.resolve(this.a) 639 | }, 640 | shouldUpdate () { 641 | return this.x 642 | } 643 | }, 644 | c: { 645 | lazy: true, 646 | get () { 647 | return Promise.resolve(this.a) 648 | }, 649 | shouldUpdate () { 650 | return this.y 651 | } 652 | } 653 | } 654 | }) 655 | 656 | await vm.$nextTick() 657 | expect(vm.b).toBe(null) 658 | expect(vm.c).toBe(null) 659 | await vm.$nextTick() 660 | expect(vm.b).toBe(0) 661 | expect(vm.c).toBe(null) 662 | vm.a++ 663 | await vm.$nextTick() 664 | expect(vm.b).toBe(1) 665 | expect(vm.c).toBe(null) 666 | vm.x = false 667 | vm.y = true 668 | vm.a++ 669 | await vm.$nextTick() 670 | expect(vm.b).toBe(1) 671 | expect(vm.c).toBe(2) 672 | }) 673 | 674 | test("$asyncComputed is empty if there are no async computed properties", () => { 675 | const vm = newVue({ 676 | }) 677 | expect(vm.$asyncComputed).toStrictEqual({}) 678 | }) 679 | 680 | test("$asyncComputed[name] is created for all async computed properties", async () => { 681 | const vm = newVue({ 682 | asyncComputed: { 683 | a () { 684 | return Promise.resolve(1) 685 | }, 686 | b () { 687 | return Promise.resolve(2) 688 | } 689 | } 690 | }) 691 | expect(Object.keys(vm.$asyncComputed)).toStrictEqual(['a', 'b']) 692 | expect(vm.$asyncComputed.a.state).toBe('updating') 693 | expect(vm.$asyncComputed.b.state).toBe('updating') 694 | expect(vm.$asyncComputed.a.updating).toBe(true) 695 | expect(vm.$asyncComputed.a.success).toBe(false) 696 | expect(vm.$asyncComputed.a.error).toBe(false) 697 | expect(vm.$asyncComputed.a.exception).toBe(null) 698 | 699 | await vm.$nextTick() 700 | expect(vm.a).toBe(1) 701 | expect(vm.b).toBe(2) 702 | expect(vm.$asyncComputed.a.state).toBe('success') 703 | expect(vm.$asyncComputed.b.state).toBe('success') 704 | expect(vm.$asyncComputed.a.updating).toBe(false) 705 | expect(vm.$asyncComputed.a.success).toBe(true) 706 | expect(vm.$asyncComputed.a.error).toBe(false) 707 | expect(vm.$asyncComputed.a.exception).toBe(null) 708 | }) 709 | 710 | test("$asyncComputed[name] handles errors and captures exceptions", async () => { 711 | const errorHandler = vi.fn() 712 | const vm = newVue({ 713 | asyncComputed: { 714 | a () { 715 | // eslint-disable-next-line prefer-promise-reject-errors 716 | return Promise.reject('error-message') 717 | } 718 | } 719 | }, { errorHandler }) 720 | expect(vm.$asyncComputed.a.state).toBe('updating') 721 | await vm.$nextTick() 722 | await vm.$nextTick() 723 | expect(errorHandler).toHaveBeenCalledTimes(1) 724 | expect(vm.a).toBe(null) 725 | expect(vm.$asyncComputed.a.state).toBe('error') 726 | expect(vm.$asyncComputed.a.updating).toBe(false) 727 | expect(vm.$asyncComputed.a.success).toBe(false) 728 | expect(vm.$asyncComputed.a.error).toBe(true) 729 | expect(vm.$asyncComputed.a.exception).toBe('error-message') 730 | }) 731 | 732 | test("$asyncComputed[name].update triggers re-evaluation", async () => { 733 | let valueToReturn = 1 734 | const vm = newVue({ 735 | asyncComputed: { 736 | a () { 737 | return new Promise(resolve => { 738 | resolve(valueToReturn) 739 | }) 740 | } 741 | } 742 | }) 743 | 744 | await vm.$nextTick() 745 | expect(vm.a).toBe(1) 746 | valueToReturn = 2 747 | expect(vm.$asyncComputed.a.state).toBe('success') 748 | vm.$asyncComputed.a.update() 749 | expect(vm.$asyncComputed.a.state).toBe('updating') 750 | await vm.$nextTick() 751 | expect(vm.a).toBe(2) 752 | valueToReturn = 3 753 | expect(vm.a).toBe(2) 754 | }) 755 | 756 | test("$asyncComputed[name].update has the correct execution context", async () => { 757 | let addedValue = 1 758 | const vm = newVue({ 759 | data () { 760 | return { 761 | valueToReturn: 1, 762 | } 763 | }, 764 | asyncComputed: { 765 | a () { 766 | return new Promise(resolve => { 767 | resolve(this.valueToReturn + addedValue) 768 | }) 769 | }, 770 | b: { 771 | get () { 772 | return new Promise(resolve => { 773 | resolve(this.valueToReturn + addedValue) 774 | }) 775 | }, 776 | }, 777 | }, 778 | }) 779 | 780 | await vm.$nextTick() 781 | // case 1: a is a function 782 | expect(vm.a).toBe(2) 783 | expect(vm.$asyncComputed.a.state).toBe('success') 784 | // case 2: b is an object with a getter function 785 | expect(vm.b).toBe(2) 786 | expect(vm.$asyncComputed.b.state).toBe('success') 787 | 788 | addedValue = 4 789 | 790 | vm.$asyncComputed.a.update() 791 | expect(vm.$asyncComputed.a.state).toBe('updating') 792 | 793 | vm.$asyncComputed.b.update() 794 | expect(vm.$asyncComputed.b.state).toBe('updating') 795 | 796 | await vm.$nextTick() 797 | expect(vm.a).toBe(5) 798 | expect(vm.b).toBe(5) 799 | }) 800 | 801 | test("Plain components with neither `data` nor `asyncComputed` still work (issue #50)", async () => { 802 | const vm = newVue({ 803 | computed: { 804 | a () { 805 | return 1 806 | } 807 | } 808 | }) 809 | expect(vm.a).toBe(1) 810 | }) 811 | 812 | test('Data of component still work as function and got vm', async () => { 813 | let _vmContext = null 814 | const vm = newVue({ 815 | data (vmContext) { 816 | _vmContext = vmContext 817 | }, 818 | asyncComputed: { 819 | async a () { 820 | return Promise.resolve(1) 821 | }, 822 | }, 823 | 824 | }) 825 | expect(vm).toBe(_vmContext) 826 | }) 827 | 828 | test("Watch as a function", async () => { 829 | let i = 0 830 | const vm = newVue({ 831 | data: { 832 | y: 2, 833 | obj: { 834 | t: 0 835 | } 836 | }, 837 | asyncComputed: { 838 | z: { 839 | get () { 840 | return Promise.resolve(i + this.y) 841 | }, 842 | watch () { 843 | // eslint-disable-next-line no-unused-expressions 844 | this.obj.t 845 | } 846 | } 847 | } 848 | }) 849 | expect(vm.z).toBeNull() 850 | await vm.$nextTick() 851 | expect(vm.z).toBe(2) 852 | i++ 853 | vm.obj.t-- 854 | await vm.$nextTick() 855 | // This tick, Vue registers the change 856 | // in the watcher, and reevaluates 857 | // the getter function 858 | // And since we 'await', the promise chain 859 | // is finished and z is 3 860 | expect(vm.z).toBe(3) 861 | }) 862 | 863 | test("Watchers as array with nested path rerun the computation when a value changes", async () => { 864 | let i = 0 865 | const vm = newVue({ 866 | data: { 867 | y: 2, 868 | obj: { 869 | t: 0 870 | } 871 | }, 872 | asyncComputed: { 873 | z: { 874 | get () { 875 | return Promise.resolve(i + this.y) 876 | }, 877 | watch: ['obj.t'] 878 | } 879 | } 880 | }) 881 | expect(vm.z).toBeNull() 882 | await vm.$nextTick() 883 | expect(vm.z).toBe(2) 884 | i++ 885 | vm.obj.t-- 886 | await vm.$nextTick() 887 | // This tick, Vue registers the change 888 | // in the watcher, and reevaluates 889 | // the getter function 890 | // And since we 'await', the promise chain 891 | // is finished and z is 3 892 | expect(vm.z).toBe(3) 893 | }) 894 | 895 | test("Watch as array with more then one value", async () => { 896 | let i = 0 897 | const vm = newVue({ 898 | data: { 899 | y: 2, 900 | obj: { 901 | t: 0 902 | }, 903 | r: 0 904 | }, 905 | asyncComputed: { 906 | z: { 907 | get () { 908 | return Promise.resolve(i + this.y) 909 | }, 910 | watch: ['obj.t', 'r'] 911 | } 912 | } 913 | }) 914 | expect(vm.z).toBeNull() 915 | await vm.$nextTick() 916 | expect(vm.z).toBe(2) 917 | i++ 918 | // checking for nested property 919 | vm.obj.t-- 920 | await vm.$nextTick() 921 | // This tick, Vue registers the change 922 | // in the watcher, and reevaluates 923 | // the getter function 924 | // And since we 'await', the promise chain 925 | // is finished and z is 3 926 | expect(vm.z).toBe(3) 927 | i++ 928 | // one level and multiple watchers 929 | vm.r-- 930 | await vm.$nextTick() 931 | expect(vm.z).toBe(4) 932 | }) 933 | 934 | test("$asyncComputed[name].state resolves to 'success' even if the computed value is 0 (issue #75)", async () => { 935 | const vm = newVue({ 936 | computed: { 937 | isUpdating () { 938 | return this.$asyncComputed.a.updating 939 | } 940 | }, 941 | asyncComputed: { 942 | a: { 943 | async get () { 944 | return 0 945 | }, 946 | default: null 947 | } 948 | } 949 | }) 950 | expect(vm.$asyncComputed.a.state).toBe('updating') 951 | expect(vm.$asyncComputed.a.updating).toBe(true) 952 | expect(vm.$asyncComputed.a.success).toBe(false) 953 | expect(vm.$asyncComputed.a.error).toBe(false) 954 | expect(vm.$asyncComputed.a.exception).toBe(null) 955 | expect(vm.isUpdating).toBe(true) 956 | 957 | await vm.$nextTick() 958 | expect(vm.a).toBe(0) 959 | expect(vm.$asyncComputed.a.state).toBe('success') 960 | expect(vm.$asyncComputed.a.updating).toBe(false) 961 | expect(vm.$asyncComputed.a.success).toBe(true) 962 | expect(vm.$asyncComputed.a.error).toBe(false) 963 | expect(vm.$asyncComputed.a.exception).toBe(null) 964 | expect(vm.isUpdating).toBe(false) 965 | }) 966 | 967 | test("$asyncComputed[name].update does nothing if called after the component is destroyed", async () => { 968 | let i = 0 969 | const vm = newVue({ 970 | asyncComputed: { 971 | a: { 972 | async get () { 973 | return ++i 974 | } 975 | } 976 | } 977 | }) 978 | 979 | expect(vm.a).toBeNull() 980 | await vm.$nextTick() 981 | expect(vm.a).toBe(1) 982 | app.unmount(vm) 983 | vm.$asyncComputed.a.update() 984 | await vm.$nextTick() 985 | expect(i).toBe(1) 986 | expect(vm.a).toBe(1) 987 | }) 988 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { PluginFunction } from 'vue'; 2 | 3 | export interface IAsyncComputedOptions { 4 | errorHandler?: (error: string | Error) => void; 5 | useRawError?: boolean; 6 | default?: any; 7 | } 8 | 9 | export default class AsyncComputed { 10 | constructor(options?: IAsyncComputedOptions); 11 | static install: PluginFunction; 12 | static version: string; 13 | } 14 | 15 | export type AsyncComputedGetter = () => Promise; 16 | 17 | export interface IAsyncComputedValueBase { 18 | default?: T | (() => T); 19 | watch?: string[] | (() => void); 20 | shouldUpdate?: () => boolean; 21 | lazy?: boolean; 22 | } 23 | 24 | export interface IAsyncComputedValue extends IAsyncComputedValueBase { 25 | get: AsyncComputedGetter; 26 | } 27 | 28 | export interface AsyncComputedObject { 29 | [K: string]: AsyncComputedGetter | IAsyncComputedValue; 30 | } 31 | 32 | export interface IASyncComputedState { 33 | state: 'updating' | 'success' | 'error'; 34 | updating: boolean; 35 | success: boolean; 36 | error: boolean; 37 | exception: Error | null; 38 | update: () => void; 39 | } 40 | 41 | declare module 'vue/types/options' { 42 | interface ComponentOptions { 43 | asyncComputed?: AsyncComputedObject; 44 | } 45 | } 46 | 47 | declare module 'vue/types/vue' { 48 | interface Vue { 49 | $asyncComputed: {[K: string]: IASyncComputedState}; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'happy-dom', // or 'jsdom', 'node' 6 | include: ['test/*.js'], 7 | }, 8 | }) 9 | --------------------------------------------------------------------------------