Typescript async/await doesnt update AngularJS view
Asked Answered
F

10

30

I'm using Typescript 2.1(developer version) to transpile async/await to ES5.

I've noticed that after I change any property which is bound to view in my async function the view isn't updated with current value, so each time I have to call $scope.$apply() at the end of function.

Example async code:

async testAsync() {
     await this.$timeout(2000);
     this.text = "Changed";
     //$scope.$apply(); <-- would like to omit this
}

And new text value isn't shown in view after this.

Is there any workaround so I don't have to manually call $scope.$apply() every time?

Freya answered 9/10, 2016 at 13:3 Comment(3)
You can't 'omit this'. Because async/await uses native promises and not $q. $apply should be called, either there or in caller function. Btw, using $timeout is terrible idea here, it will result in extra digest, non-Angular promise-based solutions should be used, e.g. Bluebird.Bode
I haven't read it yet, but I am hoping this well help labs.magnet.me/nerds/2015/11/16/async-await-in-angularjs.htmlPavo
The best solution, in my opinion, would be a non-invasive way to use this feature with AngularJS. I guess we should focus more on how typescript transpiles this ES7 feature and find a way to trigger a digest cycle.Creamy
G
14

The answers here are correct in that AngularJS does not know about the method so you need to 'tell' Angular about any values that have been updated.

Personally I'd use $q for asynchronous behaviour instead of using await as its "The Angular way".

You can wrap non Angular methods with $q quite easily i.e. [Note this is how I wrap all Google Maps functions as they all follow this pattern of passing in a callback to be notified of completion]

function doAThing()
{
    var defer = $q.defer();
    // Note that this method takes a `parameter` and a callback function
    someMethod(parameter, (someValue) => {
        $q.resolve(someValue)
    });

    return defer.promise;
}

You can then use it like so

this.doAThing().then(someValue => {
    this.memberValue = someValue;
});

However if you do wish to continue with await there is a better way than using $apply, in this case, and that it to use $digest. Like so

async testAsync() {
   await this.$timeout(2000);
   this.text = "Changed";
   $scope.$digest(); <-- This is now much faster :)
}

$scope.$digest is better in this case because $scope.$apply will perform dirty checking (Angulars method for change detection) for all bound values on all scopes, this can be expensive performance wise - especially if you have many bindings. $scope.$digest will, however, only perform checking on bound values within the current $scope making it much more performant.

Glendon answered 15/7, 2017 at 23:27 Comment(3)
$scope.$digest() cannot be recommended by default. A dev should know very well what he/she's doing, because this will affect things a lot, and not always in obvious ways. This is advanced technique that isn't even covered by the manual. Generally I would recommend it only as optimization step in extensively tested app.Bode
I wouldn't consider this an advanced technique, and even if it were surely sharing knowledge is a good thing? My general rule of thumb is: if you only want to affect your current scope, use $digest, else use $apply. Applying this technique to my app yielded massive performance improvements.Glendon
Sharing knowledge is a good thing. This is advanced technique that can bring massive performance improvements and also massive troubles. AFAIK it never was documented enough. A dev who isn't aware of the consequences can mess things up. That's really bad advice to recommend $scope.$digest() as a rule of thumb it without knowing under which conditions testAsync works. Even if you know what you're doing very well, it may backfire because a lot of things in AngularJS land rely on global digests, e.g. 2-way binding.Bode
B
8

This can be conveniently done with angular-async-await extension:

class SomeController {
  constructor($async) {
    this.testAsync = $async(this.testAsync.bind(this));
  }

  async testAsync() { ... }
}

As it can be seen, all it does is wrapping promise-returning function with a wrapper that calls $rootScope.$apply() afterwards.

There is no reliable way to trigger digest automatically on async function, doing this would result in hacking both the framework and Promise implementation. There is no way to do this for native async function (TypeScript es2017 target), because it relies on internal promise implementation and not Promise global. More importantly, this way would be unacceptable because this is not a behaviour that is expected by default. A developer should have full control over it and assign this behaviour explicitly.

Given that testAsync is being called multiple times, and the only place where it is called is testsAsync, automatic digest in testAsync end would result in digest spam. While a proper way would be to trigger a digest once, after testsAsync.

In this case $async would be applied only to testsAsync and not to testAsync itself:

class SomeController {
  constructor($async) {
    this.testsAsync = $async(this.testsAsync.bind(this));
  }

  private async testAsync() { ... }

  async testsAsync() {
    await Promise.all([this.testAsync(1), this.testAsync(2), ...]);
    ...
  }
}
Bode answered 15/7, 2017 at 14:54 Comment(6)
While this would work, you'd have to use $async instead of the native syntax. We should focus more on the transpiled result rather than triggering a new digest cycle.Creamy
@CosminAbabei You have to use $async with native syntax, not instead. I'm not sure what you mean. A new digest cycle should be triggered any way to make this work as expected. And 'transpiled' result is not necessarily transpiled - native async functions are there already.Bode
$async alters the native syntax. Ideally, we would have a webpack loader which transpiles the async/await code to something AngularJS efficient.Creamy
@CosminAbabei This idea looks a bit out of date, because again, there are already native async/await, and modules, and Webpack may become optional sooner than it was expected. From my experience with Angular and JS, there's no clean way to do this transparently. Ok, this could be handled by custom Babel transform, but there's no way how the transform can reliably get $rootScope reference to call $apply on it. Any hack will be unreliable and result in memory leaks or other problems.Bode
@CosminAbabei FYI, I've added the explanation why exactly this would be a bad idea to have this behaviour by default on all async functions (even if this was possible). Hope this helpsBode
I've added a fiddle showcasing how this would work with native async functions and Promises.Creamy
R
5

I have examined the code of angular-async-await and It seems like they are using $rootScope.$apply() to digest the expression after the async promise is resolved.

This is not a good method. You can use AngularJS original $q and with a little trick, you can achieve the best performance.

First, create a function ( e.g., factory, method)

// inject $q ...
const resolver=(asyncFunc)=>{
    const deferred = $q.defer();
    asyncFunc()
      .then(deferred.resolve)
      .catch(deferred.reject);
    return deferred.promise;
}

Now, you can use it in your for instance services.

getUserInfo=()=>{

  return resolver(async()=>{

    const userInfo=await fetch(...);
    const userAddress= await fetch (...);

    return {userInfo,userAddress};
  });
};

This is as efficient as using AngularJS $q and with minimal code.

Ribaudo answered 10/10, 2018 at 21:6 Comment(0)
C
4

I've set up a fiddle showcasing the desired behavior. It can be seen here: Promises with AngularJS. Please note that it's using a bunch of Promises which resolve after 1000ms, an async function, and a Promise.race and it still only requires 4 digest cycles (open the console).

I'll reiterate what the desired behavior was:

  • to allow the usage of async functions just like in native JavaScript; this means no other 3rd party libraries, like $async
  • to automatically trigger the minimum number of digest cycles

How was this achieved?

In ES6 we've received an awesome featured called Proxy. This object is used to define custom behavior for fundamental operations (e.g. property lookup, assignment, enumeration, function invocation, etc).

This means that we can wrap the Promise into a Proxy which, when the promise gets resolved or rejected, triggers a digest cycle, only if needed. Since we need a way to trigger the digest cycle, this change is added at AngularJS run time.

function($rootScope) {
  function triggerDigestIfNeeded() {
    // $applyAsync acts as a debounced funciton which is exactly what we need in this case
    // in order to get the minimum number of digest cycles fired.
    $rootScope.$applyAsync();
  };

  // This principle can be used with other native JS "features" when we want to integrate 
  // then with AngularJS; for example, fetch.
  Promise = new Proxy(Promise, {
    // We are interested only in the constructor function
    construct(target, argumentsList) {
      return (() => {
        const promise = new target(...argumentsList);

        // The first thing a promise does when it gets resolved or rejected, 
        // is to trigger a digest cycle if needed
        promise.then((value) => {
          triggerDigestIfNeeded();

          return value;
        }, (reason) => {
          triggerDigestIfNeeded();

          return reason;
        });

        return promise;
      })();
    }
  });
}

Since async functions rely on Promises to work, the desired behavior was achieved with just a few lines of code. As an additional feature, one can use native Promises into AngularJS!

Later edit: It's not necessary to use Proxy as this behavior can be replicated with plain JS. Here it is:

Promise = ((Promise) => {
  const NewPromise = function(fn) {
    const promise = new Promise(fn);

    promise.then((value) => {
      triggerDigestIfNeeded();

      return value;
    }, (reason) => {
      triggerDigestIfNeeded();

      return reason;
    });

    return promise;
  };

  // Clone the prototype
  NewPromise.prototype = Promise.prototype;

  // Clone all writable instance properties
  for (const propertyName of Object.getOwnPropertyNames(Promise)) {
    const propertyDescription = Object.getOwnPropertyDescriptor(Promise, propertyName);

    if (propertyDescription.writable) {
      NewPromise[propertyName] = Promise[propertyName];
    }
  }

  return NewPromise;
})(Promise) as any;
Creamy answered 21/7, 2017 at 10:57 Comment(3)
There's no real need for Proxy (it's slow, for starters). The same thing can be achieved by subclassing Promise. It's a nice proof of concept, yet the problem is inevitable - there may be a lot of unnecessary digests, which are the bottleneck for any real-world Angular app. Any third-party library that relies on promises will hurt the performance, while it normally wouldn't (native promises are fast and non-blocking). Other problems include race conditions (there may be promises that were created before Promise was patched) and memory leaks, especially in tests.Bode
Wouldn't we need to throw reason; in order to propagate the rejection?Antrim
IMHO this is the solution to go. It's just a few lines of code and there is no need to adapt existing code.Magnusson
C
3

As @basarat said the native ES6 Promise doesn't know about the digest cycle.

What you could do is let Typescript use $q service promise instead of the native ES6 promise.

That way you won't need to invoke $scope.$apply()

angular.module('myApp')
    .run(['$window', '$q', ($window, $q) =>  {
        $window.Promise = $q;
    }]);
Clipper answered 14/7, 2017 at 14:6 Comment(3)
Replacing Promise with $q is hardly a good idea. This will break the code that depends on native promises since two implementations are really different.Bode
This doesn't work. Please try to set up a fiddle to test this.Creamy
The problem is more delicate and can not be solved like this. When the code gets transpiled, or even in its native form, it's executed outside of Angular's $digest cycle.Creamy
G
2

In case you're upgrading from AngularJS to Angular using ngUpgrade (see https://angular.io/guide/upgrade#upgrading-with-ngupgrade):

As Zone.js patches native Promises you can start rewriting all $q based AngularJS promises to native Promises, because Angular triggers a $digest automatically when the microtask queue is empty (e.g. when a Promise is resolved).

Even if you don't plan to upgrade to Angular, you can still do the same, by including Zone.js in your project and setting up a similar hook like ngUpgrade does.

Ghirlandaio answered 15/3, 2019 at 15:24 Comment(0)
F
0

Is there any workaround so I don't have to manually call $scope.$apply() every time?

This is because TypeScript uses the browser native Promise implementation and that is not what Angular 1.x knows about. To do its dirty checking all async functions that it does not control must trigger a digest cycle.

Flotow answered 9/10, 2016 at 22:7 Comment(1)
Would there be a way to extend the Promise prototype to check if it is an Angular Promise, and do something accordingly? It must be possible, I just don't know JS well enough to know how.Pavo
A
0

As @basarat said the native ES6 Promise doesn't know about the digest cycle. You should to promise

async testAsync() {
 await this.$timeout(2000).toPromise()
      .then(response => this.text = "Changed");
 }
Aluminous answered 15/7, 2017 at 0:55 Comment(0)
N
0

As it already has been described, angular does not know when the native Promise is finished. All async functions create a new Promise.

The possible solution can be this:

window.Promise = $q;

This way TypeScript/Babel will use angular promises instead. Is it safe? Honestly I'm not sure - still testing this solution.

Nich answered 24/11, 2017 at 10:0 Comment(1)
It is bad practice that will ruin any third-party library that depends on native Promise. Also, this affects transpiled async functions only and not native ones - the latter use internal promise implementation that is originally equal to global Promise but isn't affected by it.Bode
G
-2

I would write a converter function, in some generic factory (didnt tested this code, but should be work)

function toNgPromise(promise)
{
    var defer = $q.defer();
    promise.then((data) => {
        $q.resolve(data);
    }).catch(response)=> {
        $q.reject(response);
    });

    return defer.promise;
}

This is just to get you started, though I assume conversion in the end will not be as simple as this...

Goeger answered 19/7, 2017 at 7:31 Comment(2)
This is deferred antipattern. It's just $q.resolve(promise). This results in creating a promise object which will be never used and doesn't seem to be superior to $scope.$apply(), but it will work.Bode
Post only tested working solutions. Don't waste of time.Singlefoot

© 2022 - 2024 — McMap. All rights reserved.