38

I am looking for a way to execute code when after I add changes to a $scope variable, in this case $scope.results. I need to do this in order to call some legacy code that requires the items to be in the DOM before it can execute.

My real code is triggering an AJAX call, and updating a scope variable in order to update the ui. So I currently my code is executing immediately after I push to the scope, but the legacy code is failing because the dom elements are not available yet.

I could add an ugly delay with setTimeout(), but that doesn't guarantee that the DOM is truly ready.

My question is, is there any ways I can bind to a "rendered" like event?

var myApp = angular.module('myApp', []);

myApp.controller("myController", ['$scope', function($scope){
    var resultsToLoad = [{id: 1, name: "one"},{id: 2, name: "two"},{id: 3, name: "three"}];
    $scope.results = [];

    $scope.loadResults = function(){
        for(var i=0; i < resultsToLoad.length; i++){
            $scope.results.push(resultsToLoad[i]);
        }
    }

    function doneAddingToDom(){
        // do something awesome like trigger a service call to log
    }
}]);
angular.bootstrap(document, ['myApp']);

Link to simulated code: http://jsfiddle.net/acolchado/BhApF/5/

Thanks in Advance!

Flip
  • 6,233
  • 7
  • 46
  • 75
acolchado
  • 531
  • 1
  • 4
  • 10
  • IF you want to use setTimeout() why don't you try the setInterval() method? In angular way, is there any way you can use the watch functionality in your code? – Foyzul Karim Jun 21 '13 at 00:12
  • I want to avoid using setTimeout. I found this fiddle that claims to do what I want, but it's not working. The key seems to be $evalAsync, so I have to find a way to make it work. I hope i am on the right path. – acolchado Jun 21 '13 at 01:26
  • I'm facing a similar problem, and my thoughts are running along the lines of using a promise to 'do something awesome', setting a $watch on the scope variable that will (eventually) be updated and resolving the promise in the $watch. I haven't tried this yet: just getting into Angular. I'd be interested to see if you can make it work! – Max Jun 21 '13 at 06:22

6 Answers6

63

The $evalAsync queue is used to schedule work which needs to occur outside of current stack frame, but before the browser's view render. -- http://docs.angularjs.org/guide/concepts#runtime

Okay, so what's a "stack frame"? A Github comment reveals more:

if you enqueue from a controller then it will be before, but if you enqueue from directive then it will be after. -- https://github.com/angular/angular.js/issues/734#issuecomment-3675158

Above, Misko is discussing when code that is queued for execution by $evalAsync is run, in relation to when the DOM is updated by Angular. I suggest reading the two Github comments before as well, to get the full context.

So if code is queued using $evalAsync from a directive, it should run after the DOM has been manipulated by Angular, but before the browser renders. If you need to run something after the browser renders, or after a controller updates a model, use $timeout(..., 0);

See also https://stackoverflow.com/a/13619324/215945, which also has an example fiddle that uses $evalAsync().

Community
  • 1
  • 1
Mark Rajcok
  • 362,217
  • 114
  • 495
  • 492
  • Thank you much for the explanation. It makes a lot more sense now why there is the need to use $timeout(..., 0), with or without $evalAsync(). After adding the forced wait, it worked perfectly fine. Now the browser is finishing it's render before the call happens. – acolchado Jun 21 '13 at 15:55
  • What do you mean by after the DOM is updated vs when the browser renders? Isn't the browser always rendering everything that's happening? In other words, if I'm running a function that can only be ran after a string has been asynchronously interpolated from a parent's scope model in a directive template, should I use evalAsync or $timeout? I've been using $timeout, and it's been working, but is this the correct way of doing things? – CMCDragonkai Aug 21 '13 at 16:36
  • 3
    @CMCDragonkai, since Javascript is single-threaded, when Angular is running, the browser is not updating what you see (i.e., it is not rendering). See the second picture on the [Concepts page](http://docs.angularjs.org/guide/concepts). The "DOM Render" happens after the Angular digest loop. I always try to use $evalAsync first, and if that doesn't work, I try $timeout. – Mark Rajcok Aug 21 '13 at 17:33
5

I forked your fiddle. http://jsfiddle.net/xGCmp/7/

I added a directive called emit-when. It takes two parameters. The event to be emitted and the condition that has to be met for the event to be emitted. This works because when the link function is executed in the directive, we know that the element has been rendered in the DOM. My solution is to emit an event when the last item in the ng-repeat has been rendered.

If we had an all Angular solution, I would not recommend doing this. It is kind of hacky. But, it might be an okey solution for handling the type of legacy code that you mention.

var myApp = angular.module('myApp', []);

myApp.controller("myController", ['$scope', function($scope){
    var resultsToLoad = [
        {id: 1, name: "one"},
        {id: 2, name: "two"},
        {id: 3, name: "three"}
    ];

    function doneAddingToDom() {
        console.log(document.getElementById('renderedList').children.length);
    }

    $scope.results = [];

    $scope.loadResults = function(){
        $scope.results = resultsToLoad;
        // If run doneAddingToDom here, we will find 0 list elements in the DOM. Check console.
        doneAddingToDom();
    }

    // If we run on doneAddingToDom here, we will find 3 list elements in the DOM.
    $scope.$on('allRendered', doneAddingToDom);
}]);

myApp.directive("emitWhen", function(){
    return {
        restrict: 'A',
        link: function(scope, element, attrs) {
            var params = scope.$eval(attrs.emitWhen),
                event = params.event,
                condition = params.condition;
            if(condition){
                scope.$emit(event);
            }
        }
    }
});

angular.bootstrap(document, ['myApp']);
Benny Johansson
  • 761
  • 6
  • 18
  • This is a very nice solution as well. Really appreciate it! One quick follow up question. If it was a lazy loaded list where I appended to the end of the list every time I clicked the button, would the event be fired every time? – acolchado Jun 23 '13 at 02:26
  • If what you are asking is described by this fiddle, then yes. =) http://jsfiddle.net/h98pH/1/ – Benny Johansson Jun 23 '13 at 06:53
0

Using timeout is not the correct way to do this. Use a directive to add/manipulate the DOM. If you do use timeout make sure to use $timeout which is hooked into Angular (for example returns a promise).

Johan
  • 1
0

If you're like me, you'll notice that in many instances $timeout with a wait of 0 runs well before the DOM is truly stable and completely static. When I want the DOM to be stable, I want it to be stable gosh dang it. And so the solution I've come across is to set a watcher on the element (or as in the example below the entire document), for the "DOMSubtreeModified" event. Once I've waited 500 milliseconds and there have been no DOM changes, I broadcast an event like "domRendered".

IE:

   //todo: Inject $rootScope and $window, 


   //Every call to $window.setTimeout will use this function
   var broadcast = function () {};

   if (document.addEventListener) {

       document.addEventListener("DOMSubtreeModified", function (e) {
           //If less than 500 milliseconds have passed, the previous broadcast will be cleared. 
           clearTimeout(broadcast)
           broadcast = $window.setTimeout(function () {
               //This will only fire after 500 ms have passed with no changes
               $rootScope.$broadcast('domRendered')
           }, 500)

       });

   //IE stupidity
   } else {
       document.attachEvent("DOMSubtreeModified", function (e) {

           clearTimeout(broadcast)
           broadcast = $window.setTimeout(function () {
               $rootScope.$broadcast('domRendered')
           }, 500)

       });
   }

This event can be hooked into, like all broadcasts, like so:

$rootScope.$on("domRendered", function(){
   //do something
})
Scottmas
  • 1,082
  • 10
  • 22
0

I had a custom directive and I needed the resulting height() property of the element inside my directive which meant I needed to read it after angular had run the entire $digest and the browser had flowed out the layout.

In the link function of my directive;

This didn't work reliably, not nearly late enough;

scope.$watch(function() {}); 

This was still not quite late enough;

scope.$evalAsync(function() {});

The following seemed to work (even with 0ms on Chrome) where curiously even ẁindow.setTimeout() with scope.$apply() did not;

$timeout(function() {}, 0);

Flicker was a concern though, so in the end I resorted to using requestAnimationFrame() with fallback to $timeout inside my directive (with appropriate vendor prefixes as appropriate). Simplified, this essentially looks like;

scope.$watch("someBoundPropertyIexpectWillAlterLayout", function(n,o) {
    $window.requestAnimationFrame(function() {
        scope.$apply(function() {
            scope.height = element.height(); // OK, this seems to be accurate for the layout
        });
    });
});

Then of course I can just use a;

scope.$watch("height", function() {
    // Adjust view model based on new layout metrics
});
cirrus
  • 5,624
  • 8
  • 44
  • 62
0

interval works for me,for example:

interval = $interval(function() {
    if ($("#target").children().length === 0) {
        return;
    }
    doSomething();
    $interval.cancel(interval);
}, 0);
Rory McCrossan
  • 331,213
  • 40
  • 305
  • 339
guoguo
  • 1