angular fires $watch callback listeners multiple times even for empty watchExpression #1305

Closed
godmar opened this Issue Aug 28, 2012 · 13 comments

Comments

Projects
None yet
8 participants
Contributor

godmar commented Aug 28, 2012

This is either a bug, or a request for enhancement (the documentation, see below, is unclear about the normative behavior).

Example http://plnkr.co/edit/PAOakV?p=preview

I need to be notified, once, when all model changes that are caused by either a user event, or a call to $apply, have completed. In other words, when the model has stabilized. The AngularJS user mailing list recommended to pass the listener as the first argument to $watch, and not passing a watchexpression.

The documentation at http://docs.angularjs.org/api/ng.$rootScope.Scope is unclear if this should work. It says:

"If you want to be notified whenever $digest is called, you can register an watchExpression function with no listener. (Since watchExpression, can execute multiple times per $digest cycle when a change is detected, be prepared for multiple calls to your listener.)"

If you have 'no listener', how can you have 'multiple calls to your listener'?

In any event, as the pluncker shows, I'm getting two calls per model change. (I'm guessing once after the model changed, and a second even though it hasn't changed subsequently.)

I would like a means to be notified when a model update cycle completes, and be notified only once. My use case is to persist the model to localStorage to create immediate persistence (e.g. no 'Save' button).

Owner

mhevery commented Aug 31, 2012

This is by design. read: http://stackoverflow.com/questions/9682092/databinding-in-angularjs

We keep dirty checking the model until there are no changes. This means that we do it twice most of the time. Once to read the change and trigger its handler and another time to make sure that there are no more changes.

In other words, when the model has stabilized.

you can sort of achieve this with $evalAsync(), but the issue is that your callback can again change the model, so we will check one more time after async just to make sure.

Better question is why do you need to know this? What is your use case?

@mhevery mhevery closed this Aug 31, 2012

Contributor

godmar commented Aug 31, 2012

The use case is what I call 'immediate persistence'.

Whenever the user performs any input - checking a box, entering a value, what have you, that causes one or even multiple changes to the model, I want to write the entire model persistently to localStorage. For efficiency, I'd like to do that only once, and not multiple times.

I think that's a compelling use case - it's similar to how MacOS works - you check a box, it's active and there's no 'Save' or 'Submit' button. Note that I'm actually writing a Chrome extension, so localStorage is all I need (no remote server involved).

Contributor

godmar commented Sep 1, 2012

I don't understand where I would be calling $evalAsync.

Calling it from the watch listener looks up the browser in an infinite loop: http://plnkr.co/edit/dp7Yov?p=preview

I'm interested in knowing the solution for @godmar's use case as I am currently facing a similar dilemma.

EDIT: I may have solved my own problem through the use of Angular's "copy()" method. I'm not sure if it is appropriate in this case but it certainly gets the job done for me.

I have the same use case than @godmar actually, it's just a natural thing that comes in mind when you start to think about optimisation in that case, but it could be extended to any heavy task that don't make changes to the scope.

For the moment I use a throttled function with setTimeout, it's doing the job, but a framework way would be nice.

It could be under another name than $watch to be clear that it will not be executed in a scope $apply and should not make changes to the scope)

daaain commented Sep 19, 2012

If you $watch a specific property you get the new and old values as arguments in the callback: http://plnkr.co/edit/IczFPU?p=preview

Contributor

godmar commented Sep 19, 2012

Understood, but no help to my use case.

In my application, I have dozens of properties. Changing some changes others. For instance, if you click a checkbox in a checktree (similar to http://static.geewax.org/checktree/index.html ), all descendants are checked. There's not a specific property I could tie to.

daaain commented Sep 19, 2012

Ah right, sorry, just realised that you actually mentioned this in the original post.

I'm actually working on a similar use case and was wishfully thinking that I could watch a model Object and $watch would be triggered when any of its properties change, but just found out that's not the case :(

tomsdev commented Sep 19, 2012

Same usecase for me too, perhaps we could have the choice to not use POJO to have more control over change events of an object or collections of objects (added event, removed event, item changed event, etc.).

daaain commented Sep 19, 2012

Good idea @tomsdev, something along the lines how $http simplifies REST interaction.

Especially because if you're using a Promise then 2-way binding becomes even less wieldy, at the moment the best solution I could find is to set up a $rootScope.$watch in my Service around the Promise resolve logic.

Also would be nice not having to work around the $$v wrapper inside the Promise manually.

I have the same use case as @godmar, @guillaume86, and @tomsdev. I didn't try to stop Angular's models stabilization, as @mhevery pointed out.

Instead, I implemented my own $save function that gets attached to every retrieved object via this, not prototype. Then, I attached to $save a timeout promise (or ID) unless it already existed. If the timeout promise (or ID) already had a non-null value, I ended that timeout with $timeout.cancel (or window.clearTimeout). Lastly, I assigned a call to $timeout (or window.timeout) to $save's timeout promise (or ID).

@MarkMYoung your approach sounds really interesting and I'd love to see an example. Would you mind posting a code snippet or fiddle?

Sure, I just didn't want to continue talking if no one was listening. I needed this because one model modification would cause multiple $watchers to fire--each asking to save the model. Since the last save request also included all the previous modifications, it was the only one that needed to complete.

// Extend each object with an Angular-esque '$save' function.
((objectOrObjects instanceof Array)?(objectOrObjects):([objectOrObjects]))
.forEach( function( each )
{
    if( !each ){return;}
    each.$delete = function dollarDelete()
    {
        var deferred = $.Deferred();
        var that = this;
        self.delete( that, deferred.resolve, function( error )
        {window.console.error( error );deferred.reject( error );});
        return( deferred.promise());
    };
    each.$save = function dollarSave()
    {
        var that = this;
        // Cancel any pending saves.
        if( that.$save.timeout_id )
        {window.clearTimeout( that.$save.timeout_id );that.$save.timeout_id = null;}
        // Create a deferred object for 'this' object shared by all calls to '$save'.
        else
        {that.$save.$deferred = $.Deferred();}

        // Batch the saves into one call.
        that.$save.timeout_id = window.setTimeout( function()
        {
            self.save( that, deferred.resolve, function( error )
            {window.console.error( error );deferred.reject( error );});
            that.$save.timeout_id = null;
        }, 500 );
        return( that.$save.$deferred.promise());
    };
});

This is extracted from an 150-line interface class for describing local and remote transaction to allow for synchronization, so let me know if any of this doesn't make sense.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment