How do I cleanup $rootScope.$on event subscriptions from inside a service?
I have a Factory that is being initialized in different controllers in my AngularJS application, where a $rootScope.$on event subscription is being declared. My problem is, when the controller is destroyed, the event is not cleaned up. I have done reading and found that with controllers and directives you can setup a $watch on $destroy for cleanup, but what about for services? How do you clean up services?
Here's a basic Plunker of my problem: http://plnkr.co/edit/dY3BVW. Click the 'create child' button a bunch of time to initialize a child controller that will initialize a factory with the $rootScope.$on. With the browser console open when you the click 'broadcast', you will see a bunch of event subscriptions being fired, event after the child controllers were destroyed.
Snippet from Plunker:
// setup rootScope on in Factory and create multiple instances
// to show that event subscriptions are not cleaned up
// TODO: how do you cleanup in factory?
app.factory('HelloWorldFactory', function($rootScope) {
// setup event subscription on initialization
function HelloWorldFactory() {
console.log('init helloWorldFactory');
var rootScopeOn = $rootScope.$on('test', function() {
console.log('test sub', Math.random());
});
}
return HelloWorldFactory;
});
// child ctrl will init a new factory instance
app.controller('ChildCtrl', function($scope, HelloWorldFactory) {
$scope.name = 'Child';
var helloWolrdFactory = new HelloWorldFactory();
});
You can kill the sentinel before creating the new event. The rootScopeOn variable will stay untouched after creating new controllers, so you can kill it every time you initialize it again.
app.factory('HelloWorldFactory', function($rootScope) {
var rootScopeOn;
// setup event subscription on initialization
function HelloWorldFactory() {
console.log('init helloWorldFactory');
if (typeof rootScopeOn === 'function') {
rootScopeOn();
}
rootScopeOn = $rootScope.$on('test', function() {
console.log('test sub', Math.random());
});
}
return HelloWorldFactory;
});
// child ctrl will init a new factory instance
app.controller('ChildCtrl', function($scope, HelloWorldFactory) {
$scope.name = 'Child';
var helloWolrdFactory = new HelloWorldFactory($scope);
});
Related
I need to call angular Scope function inside some controller from runblock. can i able to call the function or else need to use rootScope or $scope.apply.
First method:
myAPP.run(function ($rootScope, $state,$scope) {
$scope.menupage();
});
var SampleController = function ($scope,$localStorage) {
$scope.menupage =function()
{
// some logic
};
});
SampleController.$inject = ['$scope','$localStorage'];
Else need to use like this
myAPP.run(function ($rootScope, $state,$scope) {
$scope.$apply(function() {
$scope.menupage();
});
});
var SampleController = function ($scope,$localStorage) {
$scope.menupage =function()
{
// some logic
};
});
SampleController.$inject = ['$scope','$localStorage'];
myAPP.run(function ($rootScope, $state,$scope) {
You can't have $scope object as a third parameter here, because both $scope and $controller objects are created during compiling and linking phase, which follows run phase during which run blocks are executed. So you can't have them in your run blocks. Only the $rootScope is available in run blocks since it's a service and only services are available in run blocks.
I have two files 1) app.js 2) worker.js
I try to update the $scope.time but it is not showing in the view. It is my first time with webworkers.
app.js
angular.module('App', [])
.controller('MainCtrl', ['$scope', '$window', function($scope, $window) {
$scope.time = 100;
var worker = new Worker('worker.js');
worker.onmessage = function(e) {
$scope.time = e.data.time;
};
worker.postMessage($scope.time);
}]);
worker.js
self.onmessage = function(e) {
var time = e.data;
var timer = setInterval(toDo,1000);
function toDo(){
time = time-1;
postMessage({
time:time
});
}
}
When worker.onmessage is triggered it is going to be outside the Angular digest cycle. So even though you have updated the model, Angular does not know that it needs to update the views. In order for you to notify Angular that a new digest cycle has to happen you need to call $scope.$apply()
worker.onmessage = function(e) {
$scope.$apply(function(){
//do model changes here
$scope.time = e.data.time;
});
};
Instead of passing an anonymous function to $scope.$apply you could just do the changes and then call $scope.$apply() with no arguments. But I believe it is preferred that you use the anonymous function with $apply as it does under the hood work like wrapping it in try...catch.
$scope.time = e.data.time;
$scope.$apply();
Hitting the ceiling of my Angular knowledge and I have been going around in circles on this.
Basically I have video player and chapter list directives, each with a controller. The controllers use the same model service which looks like this:
.service('VideoPlayerModel', function(){
var model = this;
model.chapters = {
chapterPos: 0,
targetChapter:null,
data: []
},
model.getVideoData = function() {
return model.videoData;
};
model.setVideoData = function(vData){
...
...
...
};
});
In the video player controller as the time of the player updates it finds the needed chapter data and updates the model.chapters data like this:
updateChapter: function(currentTime){
var chapters = VideoPlayerModel.chapters;
var chaptersCtrl = videoPlayerCtrl.chapters;
if (chapters.nextChapter.start <= currentTime) {
chapters.chapterPos = chapters.chapterPos + 1;
chaptersCtrl.setChapter(); //This finds and sets the Target Chapter
}
},
After setChapter runs I call console.log(VideoPlayerModel.chapters) and I can see the data model has updated with a result like this:
Object {chapterPos: 1, targetChapter: Object, data: Array[6], nextChapter: Object}
However the watch in the ChapterListCtrl doesn't fire and any of the onscreen items displaying the ChapterPos still show just the initial val of 0.
The controller looks like this:
.controller("ChapterListCtrl", ['$scope', 'VideoPlayerModel', function($scope, VideoPlayerModel) {
$scope.chapters = VideoPlayerModel.chapters;
$scope.$watch(function() { return VideoPlayerModel.chapters; }, function(newValue, oldValue){
$scope.chapters = newValue;
console.log("A Change"); // Only runs at initialisation.
});
}])
I have tried different ways and ended up with this, not sure if I am in the complete wrong direction now. Can anyone please help?
You don't need to use $watch, $broadcast or $on. This is best solved by regular JavaScript thinking.
Your problem is $scope.chapters = newValue; That is where you break the binding that your controllers use by introducing a new object unrelated to your service.
What you should to instead is to think about your service model.chapters = {..} and say hey! This is THE ONE object that I will use. And if I want to change the data in this object anywhere, I will switch the data inside the object and NOT assign a new object to the reference I use.
To do this I use the following methods:
transferDataList = function (from, to) {
/*
http://stackoverflow.com/questions/1232040/empty-an-array-in-javascript
*/
to.length = 0;
for (var i = 0; i < from.length; i++) { to.push(from[i]); }
};
transferDataMap = function (from, to) {
/*
http://stackoverflow.com/questions/684575/how-to-quickly-clear-a-javascript-object
*/
var member;
for (member in to) { delete to[member]; }
for (member in from) { to[member] = from[member]; }
};
And when I want to change the data in my object I DON'T do:
$scope.chapters = newValue;
Instead I do:
transferDataMap(newValue, $scope.chapters);
Or:
transferDataList(newValue, $scope.chapters);
This way you will keep your binding and your Angular interfaces will always be updated.
You can use $broadcast() and $on() function to achieve your requirement.
$broadcast() will flush an event to all it's child controller. So, you can $broadcast() an event with your new value to all controllers when you set a new value to your shared model.
Add a broadcast method in your shared service.
model.setVideoData = function(vData){
UpdateYourModel();
// Inform that your model is updated
$rootScope.$broadcast('modelUpdated');
}
And now you can add a listener for the event modelUpdated in all your controllers.
$scope.$on('modelUpdated', function () {
$scope.controllerModel = VideoPlayerModel.getVideoData(); // Update your controller model
}
And also, inject $rootScope to your service,
.service("VideoPlayerModel", ["$rootScope", function($rootScope){
// define your service here.
}] );
That's all !!!
I hope this will help you.
Try changing your watcher to:
$scope.$watch('chapters', function(newValue, oldValue){
$scope.chapters = newValue;
console.log("A Change"); // Only runs at initialisation.
});
Alternatively if that doesn't achieve what you want, you can enable a deep watch by passing the third argument:
$scope.$watch('chapters', function(newValue, oldValue){
$scope.chapters = newValue;
console.log("A Change"); // Only runs at initialisation.
}, true);
Your watcher doesn't fire because it always returns the same chapters which Angular considers as not-changed because it checks by reference. Your watcher can also be refactored as:
$scope.$watch(function() { return VideoPlayerModel.chapters.length; }, function(newValue, oldValue){
$scope.chapters = newValue;
console.log("A Change"); // Only runs at initialisation.
});
So I have such decorator in app config:
angular.module('app').config(['$provide', function ($provide) {
$provide.decorator('$rootScope', ['$delegate', function ($delegate) {
$delegate.constructor.prototype.$onRootScope = function (name, listener) {
var unsubscribe = $delegate.$on(name, listener);
this.$on('$destroy', unsubscribe);
};
$delegate.constructor.prototype.$watchRootScope = function (name, listener) {
var unsubscribe = $delegate.$watch(name, listener);
this.$on('$destroy', unsubscribe);
};
$delegate.constructor.prototype.$watchAfterLoad = function (watchExpression, listener, objectEquality) {
var initialLoad = true;
this.$watch(watchExpression, function () {
if (initialLoad) {
// note: this obviously runs outside of angular, so sometimes the timeout could run after initial load
setTimeout(function () { initialLoad = false; }, 25);
} else {
listener.apply(this, arguments);
}
}, objectEquality);
};
return $delegate;
}]);
}]);
As you can see this decorator lets me use $scope.$onRootScope instead of $rootScope.$on and takes care of automatic listeners removal on scope destroy event...
When I unit test my code which logic contains $scope.$onRootScope I'm getting such error: TypeError: undefined is not a constructor (evaluating 'scope.$onRootScope') in
Before each test I'm loading all required models and do inject which looks like this ~:
beforeEach(function () {
inject(function (_$rootScope_) {
$rootScope = _$rootScope_;
});
});
How should I overcome this problem?
Is there a way to mock / mimic $scope.$onRootScope behaviour?
I'm quite new to unit testing & Jasmine so sorry for not very nicely formatted question.
EDIT #1:
As I'm mocking my $scope object (var $scope = {...}) before passing it as argument to service method which I'm testing I can avoid error by simply defining $scope method:
$scope = {
...
$onRootScope: function() {}
}
Still awaiting for some better ideas :-)
I believe you need to build your $scope based off of the decorated $rootScope, as opposed to creating a new dummy object.
Like so:
var $root, $scope;
beforeEach(function () {
module('app');
inject(function ($rootScope) {
$root = $rootScope;
$scope = $root.$new();
});
});
it('should have the expected property', function () {
expect($scope.constructor.prototype).to.have.property('$watchRootScope');
});
I'll chuck in a link to the spec suite of a mini-lib I put together some time ago, doing roughly the same thing you are now.
I'm dealing with an app that manages users login. Like in many apps, i want to change the header when the user logs in.
I've a main file (index.html) which uses ng-include to include the header.html
I found two solutions (i'm new to angular, so both may be wrong):
1) use a $rootScope.broadcast()
So when the user logs in I broadcast (the auth.js, it's inside a factory) a message that is intercepted by the controller in the header.
the auth.js
$rootScope.$broadcast('logged',user);
the controller.js
$scope.$on('logged', function(evnt, message){
$scope.user = message;
});
the header.html
<div class="header" ng-controller="GcUserCtrl as gcUserCtrl">
...
<li><a ng-show="user" href="#">User: {{user.name}}</a></li>
2) set a $rootScope variable
As far as I understood $rootScope is the root of all the scope (the naming is quite smart) and all the $scope have access to it.
the auth.js
$rootScope.user=user;
the heaeder.html (no controller is needed here)
<div class="header">
...
<li><a ng-show="user" href="#">User: {{user.name}}</a></li>
Now, what's the correct way to handle it?
the first seems a bit more expensive since the broadcast may have to do many checks.
the second .. well, I'm not a fan of global variables..
EDIT
3) use service
after the comment of alex I add this options, even if I'm not able to make it working. (here the plunkr)
it does not work without events
index.html
...
<ng-include src="'header.html'"></ng-include>
...
header.html
as for the number 1)
controller.js
.controller('GcUserCtrl', ['$scope','my.auth','$log', function ($scope, auth, $log) {
$scope.user = auth.currentUser();
}]);
my.auth.js
.factory('my.auth', ['$rootScope', '$log', function ($rootScope, $log, localStorageService) {
var currentUser = undefined;
return {
login: function (user) {
currentUser = user;
...
},
...
currentUser: function () {
return currentUser;
}
};
}]);
The problem here is that the controller is called only the first time and nothing happens after the login.
As I stated earlier you will want to use a Service which will store the user's information. Attach user information to this service where ever you are authenticating the user. If you have questions about the best way to authenticate that would be a seperate question but you may want to look into using a Login Factory that does the actual authentication (and any authorization). You can then inject the login Service into that factory. I have created a Plunker here as a reference.
var app = angular.module('myApp', []);
app.service('SessionService', function () {
this.attachUser = function(userId, fName){
this.userId = userId;
this.fName = fName;
}
});
app.controller('MainCtrl', function($scope, SessionService){
// You will want to invoke attachUser some other way (perhaps on authentication), this is for test purposes only
SessionService.attachUser(1234, 'John');
$scope.userName = SessionService.fName;
});
The code above is an example of your Service. This will act as a Session handler and store important information about the user. The controller MainCtrl can then invoke properties in the SessionService using dependency injection. The part I mentioned at the beginning of this post, SessionService.attachUser(userId, fName) would most likely live in a login factory.
The reason this is the best choice is because it decouples your application. It puts the session (which is really what you are storing in global variables) in a place that is designated to store that data. It makes it maintainable. You do not need to find every occurrence of $rootScope, for instance.
EDIT:
New plunker uses rootScope broadcast/on to capture changes
Events are the preferred way to communicate that action needs to be taken by something else. That an action occurred that something else might be interested in action against. It also reduces scope pollution as you mentioned.
The comment about using a service in this case is only partially accurate. All of the login logic could, and should, be put into a single service specific to logging and logging out. That service would then broadcast the event when a login occurs.
module.service('LoginHelper', function($rootScope) {
this.loginUser = function(username, password) {
// on success
$rootScope.broadcast('loggedIn', logginUserData)
}
this.logout = function() {
// on success
$rootScope.broadcast('loggedOut')
}
})
The logged in data should be stored and accessible by the service.
Alternatively, $emit could be used on $rootScope. You would then only be able to watch for the 'loggedIn' event on the $rootScope by there would be marginally less overhead.
Avoid watches
An event would be the appropriate way to go for this kind of requirement, like how alex has pointed out. A plunk demonstrating an example: http://plnkr.co/edit/v6OXjOXZzF9McMvtn6hG?p=preview
But for this particular scenario, I don't think the "angular way" is the "way". Given the nature of how $broadcast and/or $emit works (i.e. the default way events work in angular) I would avoid them...(Read the docs to understand why). In short, these mechanisms are meant to trigger listeners (attached to some scope) up/down the scope heirarchy. You don't really need all that. (Ref code for $emit)
I'd normally rely on other event propagation mechanisms (considering this pattern of requirement).
app.controller('MainCtrl', function($scope, SessionService, $document){
// You will want to invoke attachUser some other way (perhaps on authentication), this is for test purposes only
$scope.isLoggedIn = false;
$document.bind('$loggedin', function(){
$scope.isLoggedIn = true;
$scope.user = SessionService.fName;
});
$scope.logout = function() {
SessionService.attachUser(null, null);
$scope.isLoggedIn = false;
};
});
app.controller('LoginCtrl', function($scope, SessionService, $document){
$scope.doLogin = function() {
console.log('doLogin');
SessionService.attachUser(1234, $scope.username);
var doc = $document[0];
var evt = new Event('$loggedin');
doc.dispatchEvent(evt);
};
});
Plunk
Of course, when you are done with that view, always cleanup. Handle the $destroy event on that controller's scope and unbind the event handler...
$scope.$on('$destroy', function() {
$document.unbind('$loggedin');
};
Refer MDN for more on how to trigger events using DOM.
Update: [24 Sep]
Here is a small directive setup which demonstrates the point:
app.directive('ngNest', function($parse, $compile, $timeout){
var end = false;
var level = 0;
var fnPostLink = function(scope, element, attrs) {
//console.log('start:', ++fnPostLink.count);
var lvl = attrs.level;
if(!lvl) {
throw 'Level not specified';
}
var create = document.createElement.bind(document);
var level = parseInt(lvl);
var count = 0;
var div = create('div');
div.setAttribute('ng-controller', 'DummyCtrl');
var cls = function() {
return 'margin ' + (count % 2 ? 'even' : 'odd');
//return 'margin even';
};
div.setAttribute('class', cls());
var node = div;
while(count++ < level - 1) {
var child = create('div');
child.setAttribute('ng-controller', 'DummyCtrl');
child.setAttribute('class', cls());
node.appendChild(child);
node = child;
}
node.setAttribute('ng-controller', 'FinalCtrl');
node.innerHTML = 'foo';
var $new = $compile(div)(scope);
var el = element;
el.append($new);
};
fnPostLink.count = 0;
var fnPreLink = function(scope, element, attrs) {
//console.log('prelink');
};
var api = {
link: {
post: fnPostLink,
pre: fnPreLink
},
template: '<div></div>',
scope: {},
restrict: 'E',
replace: true
};
return api;
});
It simply nests divs attaching a controllers to it. I am attaching these two controllers:
app.controller('DummyCtrl', function($scope){
});
app.controller('FinalCtrl', function($scope, $document){
$scope.$on('$myEvt', function(){
console.log('$myEvt', $scope.$id, new Date().getTime());
});
$document.bind('$myEvt', function(){
console.log('$myEvt', $scope.$id, new Date().getTime());
});
});
FinalCtrl is added to the tail; DummyCtrl is added to the rest.
In the html template I do something like:
<ng-nest level="10"></ng-nest>
There is also in the html file a nested markup which is manually put there...
Entire code may be found here: https://gist.github.com/deostroll/a9a2de04d3913f021f13
Here are the results I've obtained running from my browser:
Live reload enabled.
$broadcast 1443074421928
$myEvt 14 1443074421929
$myEvt 19 1443074421930
DOM 1443074426405
$myEvt 14 1443074426405
$myEvt 19 1443074426405
You can notice the difference in the ticks when I've done $broadcast. I have done a $broadcast on $rootScope; hence angular walks down the scope tree depth-first and triggers those listeners attached the respective scopes, and, in that order...The stuff in $emit & $broadcast source code also validates this fact.