Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at cf.Objective() 2011 (Minneapolis, MN) with: Angela Buraglia
Ben Nadel at cf.Objective() 2011 (Minneapolis, MN) with: Angela Buraglia@aburaglia )

Forcing $q .notify() To Execute With A No-Op In AngularJS

By Ben Nadel on

I work with a lot of client-side, cached data. And, before AngularJS added the .notify() event to the $q service in AngularJS 1.2, I had to jump through a lot of really hacky and unfortunate hoops to get deferred values to resolve twice (once with cached data, once with live data). The .notify() method makes this hella easier; but, due to an AngularJS performance optimization, you still need to get a little hacky to use .notify() for cached data.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

The beauty of the .notify() event is that our data-access layer can use it serve up the "immediately available, yet stale" data while still using the .resolve() event for nothing but live data. This gives the calling context - your controller - great insight and control over which dataset is cached and whether or not it [the controller] even wants to incorporate cached data.

But, we run into a little bit of a race condition. The data-access service, that owns the cached data, needs to call .notify() before it returns the promise to the calling context. This means that your controller binds to the notify event after .notify() has been called. From a philosophical standpoint, this should be fine - Promises (and just about everything that is event-driven) are intended to invoke bindings asynchronously in order to create uniformity of access.

From a practical standpoint, however, it's not quite that simple. While AngularJS follows this philosophy, it also adds a few optimizations to cut down on processing. In our case specifically, AngularJS won't schedule the callback-processing in a deferred object unless it sees that at least one callback is bound (otherwise it thinks the world isn't listening). As such, our controller will never be notified about the cached data.

To get around this, we can have our service layer bind a no-op (no operation) function to the notify event before it calls .notify(). This way, when it does call .notify(), AngularJS will see that at least one callback is registered and it will scheduled a flushing of the pending queue in the next tick (which is implemented via $rootScope.$evalAsync()). This allows our controller to get notified of cached data even if it binds to the notify event after .notify() has been invoked.

To see this in action, I've created a friendService that returns data through two different methods. Both of the methods attempt to return cached data via .notify() and then "live" data via .resolve(). The only difference between the two methods is that one binds a no-op to the notify event before calling .notify().

  • <!doctype html>
  • <html ng-app="Demo">
  • <head>
  • <meta charset="utf-8" />
  •  
  • <title>
  • Forcing $q .notify() To Execute With A No-Op In AngularJS
  • </title>
  •  
  • <link rel="stylesheet" type="text/css" href="./demo.css"></link>
  • </head>
  • <body ng-controller="AppController">
  •  
  • <h1>
  • Forcing $q .notify() To Execute With A No-Op In AngularJS
  • </h1>
  •  
  • <h2>
  • Friends
  • </h2>
  •  
  • <div ng-switch="isLoading">
  •  
  • <!-- Show while friends are being loaded. -->
  • <p ng-switch-when="true">
  • <em>Loading...</em>
  • </p>
  •  
  • <!-- Show once the friends have loaded and are available in the view-model. -->
  • <ul ng-switch-when="false">
  • <li ng-repeat="friend in friends track by friend.id">
  • {{ friend.name }}
  • </li>
  • </ul>
  •  
  • </div>
  •  
  • <p>
  • <a ng-click="load()">Load</a>
  • &nbsp;|&nbsp;
  • <a ng-click="loadWithNoop()">Load With No-Op</a>
  • </p>
  •  
  •  
  • <!-- Load scripts. -->
  • <script type="text/javascript" src="../../vendor/angularjs/angular-1.3.13.min.js"></script>
  • <script type="text/javascript">
  •  
  • // Create an application module for our demo.
  • var app = angular.module( "Demo", [] );
  •  
  •  
  • // -------------------------------------------------- //
  • // -------------------------------------------------- //
  •  
  •  
  • // I control the root of the application.
  • app.controller(
  • "AppController",
  • function( $scope, friendService ) {
  •  
  • $scope.isLoading = false;
  •  
  • $scope.friends = [];
  •  
  • // Load the friend data (defaults to "get" method vs. "getWithNoop").
  • loadRemoteData();
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I reload the list of friends using friendService.get().
  • $scope.load = function() {
  •  
  • loadRemoteData( "get" );
  •  
  • };
  •  
  •  
  • // I reload the list of friends using friendService.getWithNoop().
  • $scope.loadWithNoop = function() {
  •  
  • loadRemoteData( "getWithNoop" );
  •  
  • };
  •  
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  •  
  • // I load the friends from the friend repository. I am passing-in the
  • // method name to demonstrate that, from the Controller's point-of-view,
  • // nothing here is different other than the name of the method. The real
  • // substantive difference exists in the implementation of the friend-
  • // Service method and how it interacts with $q / Deferred.
  • function loadRemoteData( loadingMethod ) {
  •  
  • console.info( "Loading friends with [", loadingMethod, "]" );
  •  
  • // Indicate that we are in the loading phase.
  • $scope.isLoading = true;
  •  
  • // When we make the request, we expect the service to try to use
  • // cached-data, which it will make available via the "notify" event
  • // handler on the promise. As such, we're going to wire up the same
  • // event handler to both the "resolve" and the "notify" callbacks.
  • friendService[ loadingMethod || "get" ]
  • .call( friendService )
  • .then(
  • handleResolve, // Resolve.
  • null,
  • handleResolve // Notify.
  • )
  • ;
  •  
  • function handleResolve( friends ) {
  •  
  • // Indicate that the data is no longer being loaded.
  • $scope.isLoading = false;
  •  
  • $scope.friends = friends;
  •  
  • console.log( "Friends loaded successfully at", ( new Date() ).getTime() );
  •  
  • }
  •  
  • }
  •  
  • }
  • );
  •  
  •  
  • // -------------------------------------------------- //
  • // -------------------------------------------------- //
  •  
  •  
  • // I provide access to the friend repository.
  • app.factory(
  • "friendService",
  • function( $q, $timeout ) {
  •  
  • // Our friend "repository".
  • var friends = [
  • {
  • id: 1,
  • name: "Tricia"
  • },
  • {
  • id: 2,
  • name: "Heather"
  • },
  • {
  • id: 3,
  • name: "Kim"
  • }
  • ];
  •  
  • // Return the public API.
  • return({
  • get: get,
  • getWithNoop: getWithNoop
  • });
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I return the list of friends. If the friends are cached locally, the
  • // cached collection will be exposed via the promise' .notify() event.
  • function get() {
  •  
  • var deferred = $q.defer();
  •  
  • // Notify the calling context with the cached data.
  • deferred.notify( angular.copy( friends ) );
  •  
  • $timeout(
  • function networkLatency() {
  •  
  • deferred.resolve( angular.copy( friends ) );
  •  
  • },
  • 1000,
  • false // No need to trigger digest - $q will do that already.
  • );
  •  
  • return( deferred.promise );
  •  
  • }
  •  
  •  
  • // I return the list of friends. If the friends are cached locally, the
  • // cached collection will be exposed via the promise' .notify() event.
  • function getWithNoop() {
  •  
  • var deferred = $q.defer();
  •  
  • // -- BEGIN: Hack. ----------------------------------------------- //
  • // CAUTION: This is a work-around for an optimization in the way
  • // AngularJS implemented $q. When we go to invoke .notify(),
  • // AngularJS will ignore the event if there are no pending callbacks
  • // for the event. Since our calling context can't bind to .notify()
  • // until after we invoke .notify() here (and return the promise),
  • // AngularJS will ignore it. However, if we bind a No-Op (no
  • // operation) function to the .notify() event, AngularJS will
  • // schedule a flushing of the deferred queue in the "next tick,"
  • // which will give the calling context time to bind to .notify().
  • deferred.promise.then( null, null, angular.noop );
  • // -- END: Hack. ------------------------------------------------- //
  •  
  • // Notify the calling context with the cached data.
  • deferred.notify( angular.copy( friends ) );
  •  
  • $timeout(
  • function networkLatency() {
  •  
  • deferred.resolve( angular.copy( friends ) );
  •  
  • },
  • 1000,
  • false // No need to trigger digest - $q will do that already.
  • );
  •  
  • return( deferred.promise );
  •  
  • }
  •  
  • }
  • );
  •  
  • </script>
  •  
  • </body>
  • </html>

As you can see, the controller binds the same handler to the "resolve" and "notify" event of the promise. In this way, it can handle the cached data and the live data uniformly. The only difference is in which service-layer method it invokes - get() vs. getWithNoop(). And, if we invoke .get() a few times and then .getWithNoop() a few times, we can see the difference in the console:


 
 
 

 
 Forcing $q to invoke the notify callback queue by binding a no-op (no operation) function. 
 
 
 

As you can see, when we load the data with .get(), the controller's "notify" callback never gets invoked. However, when we use .getWithNoop(), both the notify and the resolve callbacks get invoked. This is because the service-layer is binding a no-op to the notify event which forces AngularJS to schedule the notify-queue processing.

Part of me wants to consider this a bug since - I believe - promise events are always supposed to happen asynchronously. But, at the same time, I understand that this is being done as a performance optimization. That said, I'm pretty sure that AngularJS can find a way to schedule the processing, regardless, and then defer the queue-check until just before callback invocation. Until then, however, the no-op hack works well.




Reader Comments

That's a really clever way to implement "synchronous" notify! I usually invoke notify in a $timeout to get around this.

get("/foo").then(handleResolve, null, handleResolve):

function get(url) {
var deferred = $q.defer();
if (cachedData) {
$timeout(function () { deferred.notify(cachedData); });
}
$http.get(url).then(function (data) {
handleData(data);
deferred.resolve(data);
});
return deferred.promise;
}

Reply to this Comment

@Steven,

I think a $timeout() would definitely work. Both of them will trigger a $digest to be executed after the notify, which is a bit of bummer. In my case, a $digest will be triggered because there is a .notify() handler; in your case, a $digest will be triggered by the $timeout(), itself, even if there is no .notify() handler configured.

That said, you could probably add a FALSE to the end of your $timeout():

$timeout( function(){ deferred.notify(...) }, 0, false );

... the "false" will tell $timeout() to NOT trigger a digest. Then, it's up to the $q component to figure out if a $digest needs to be triggered.

In fact, now that I think of it, I wonder if the .nofity() call could just be moved into something like $rootScope.$applyAsync(). In this particular context, $applyAsync() would allow it be asynchronous in relation to the calling context, but would still be called at the top of the digest that is [likely] about to start.

Good sir, you got me thinking! Very awesome :D

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.