From 874c0fdcdd031aedae56a56a51eca8c2ca6e5a57 Mon Sep 17 00:00:00 2001 From: Peter Bacon Darwin Date: Thu, 24 Mar 2016 22:13:00 +0000 Subject: [PATCH] feat($compile): add more lifecycle hooks to directive controllers This change adds in the following new lifecycle hooks, which map in some way to those in Angular 2: * `$onChanges(changesObj)` - Called whenever one-way bindings are updated. The `changesObj` is a hash whose keys are the names of the bound properties that have changed, and the values are an object of the form `{ currentValue: ..., previousValue: ... }`. Use this hook to trigger updates within a component such as cloning the bound value to prevent accidental mutation of the outer value. * `$onDestroy` - Called on a controller when its containing scope is destroyed. Use this hook for releasing external resources, watches and event handlers. * `$postLink` - Called after this controller's element and its children been linked. Similar to the post-link function this hook can be used to set up DOM event handlers and do direct DOM manipulation. Note that child elements that contain `templateUrl` directives will not have been compiled and linked since they are waiting for their template to load asynchronously and their own compilation and linking has been suspended until that occurs. Closes #14127 Closes #14030 Closes #14020 Closes #13991 Closes #14302 --- docs/content/error/$compile/infchng.ngdoc | 30 ++ docs/content/guide/component.ngdoc | 24 ++ src/ng/compile.js | 128 ++++++- test/ng/compileSpec.js | 411 ++++++++++++++++++++-- 4 files changed, 563 insertions(+), 30 deletions(-) create mode 100644 docs/content/error/$compile/infchng.ngdoc diff --git a/docs/content/error/$compile/infchng.ngdoc b/docs/content/error/$compile/infchng.ngdoc new file mode 100644 index 000000000000..00d97061fe45 --- /dev/null +++ b/docs/content/error/$compile/infchng.ngdoc @@ -0,0 +1,30 @@ +@ngdoc error +@name $compile:infchng +@fullName Unstable `$onChanges` hooks +@description + +This error occurs when the application's model becomes unstable because some `$onChanges` hooks are causing updates which then trigger +further calls to `$onChanges` that can never complete. +Angular detects this situation and prevents an infinite loop from causing the browser to become unresponsive. + +For example, the situation can occur by setting up a `$onChanges()` hook which triggers an event on the component, which subsequently +triggers the component's bound inputs to be updated: + +```html + +``` + +```js +function Controller1() {} +Controller1.$onChanges = function() { + this.onChange(); +}; + +mod.component('c1', { + controller: Controller1, + bindings: {'prop': '<', onChange: '&'} +} +``` + +The maximum number of allowed iterations of the `$onChanges` hooks is controlled via TTL setting which can be configured via +{@link ng.$compileProvider#onChangesTtl `$compileProvider.onChangesTtl`}. diff --git a/docs/content/guide/component.ngdoc b/docs/content/guide/component.ngdoc index f2c5537b6055..55e22d137d6f 100644 --- a/docs/content/guide/component.ngdoc +++ b/docs/content/guide/component.ngdoc @@ -147,6 +147,30 @@ components should follow a few simple conventions: } ``` +- **Components have a well-defined lifecycle** +Each component can implement "lifecycle hooks". These are methods that will be called at certain points in the life +of the component. The following hook methods can be implemented: + + * `$onInit()` - Called on each controller after all the controllers on an element have been constructed and + had their bindings initialized (and before the pre & post linking functions for the directives on + this element). This is a good place to put initialization code for your controller. + * `$onChanges(changesObj)` - Called whenever one-way bindings are updated. The `changesObj` is a hash whose keys + are the names of the bound properties that have changed, and the values are an object of the form + `{ currentValue: ..., previousValue: ... }`. Use this hook to trigger updates within a component such as + cloning the bound value to prevent accidental mutation of the outer value. + * `$onDestroy()` - Called on a controller when its containing scope is destroyed. Use this hook for releasing + external resources, watches and event handlers. + * `$postLink()` - Called after this controller's element and its children have been linked. Similar to the post-link + function this hook can be used to set up DOM event handlers and do direct DOM manipulation. + Note that child elements that contain `templateUrl` directives will not have been compiled and linked since + they are waiting for their template to load asynchronously and their own compilation and linking has been + suspended until that occurs. + This hook can be considered analogous to the `ngAfterViewInit` and `ngAfterContentInit` hooks in Angular 2. + Since the compilation process is rather different in Angular 1 there is no direct mapping and care should + be taken when upgrading. + +By implementing these methods, you component can take part in its lifecycle. + - **An application is a tree of components:** Ideally, the whole application should be a tree of components that implement clearly defined inputs and outputs, and minimize two-way data binding. That way, it's easier to predict when data changes and what the state diff --git a/src/ng/compile.js b/src/ng/compile.js index 057c4c8545fe..12b0a893626e 100644 --- a/src/ng/compile.js +++ b/src/ng/compile.js @@ -293,9 +293,23 @@ * `true` if the specified slot contains content (i.e. one or more DOM nodes). * * The controller can provide the following methods that act as life-cycle hooks: - * * `$onInit` - Called on each controller after all the controllers on an element have been constructed and + * * `$onInit()` - Called on each controller after all the controllers on an element have been constructed and * had their bindings initialized (and before the pre & post linking functions for the directives on * this element). This is a good place to put initialization code for your controller. + * * `$onChanges(changesObj)` - Called whenever one-way (`<`) or interpolation (`@`) bindings are updated. The + * `changesObj` is a hash whose keys are the names of the bound properties that have changed, and the values are an + * object of the form `{ currentValue: ..., previousValue: ... }`. Use this hook to trigger updates within a component + * such as cloning the bound value to prevent accidental mutation of the outer value. + * * `$onDestroy()` - Called on a controller when its containing scope is destroyed. Use this hook for releasing + * external resources, watches and event handlers. Note that components have their `$onDestroy()` hooks called in + * the same order as the `$scope.$broadcast` events are triggered, which is top down. This means that parent + * components will have their `$onDestroy()` hook called before child components. + * * `$postLink()` - Called after this controller's element and its children have been linked. Similar to the post-link + * function this hook can be used to set up DOM event handlers and do direct DOM manipulation. + * Note that child elements that contain `templateUrl` directives will not have been compiled and linked since + * they are waiting for their template to load asynchronously and their own compilation and linking has been + * suspended until that occurs. + * * * #### `require` * Require another directive and inject its controller as the fourth argument to the linking function. The @@ -1207,6 +1221,36 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { return debugInfoEnabled; }; + + var TTL = 10; + /** + * @ngdoc method + * @name $compileProvider#onChangesTtl + * @description + * + * Sets the number of times `$onChanges` hooks can trigger new changes before giving up and + * assuming that the model is unstable. + * + * The current default is 10 iterations. + * + * In complex applications it's possible that dependencies between `$onChanges` hooks and bindings will result + * in several iterations of calls to these hooks. However if an application needs more than the default 10 + * iterations to stabilize then you should investigate what is causing the model to continuously change during + * the `$onChanges` hook execution. + * + * Increasing the TTL could have performance implications, so you should not change it without proper justification. + * + * @param {number} limit The number of `$onChanges` hook iterations. + * @returns {number|object} the current limit (or `this` if called as a setter for chaining) + */ + this.onChangesTtl = function(value) { + if (arguments.length) { + TTL = value; + return this; + } + return TTL; + }; + this.$get = [ '$injector', '$interpolate', '$exceptionHandler', '$templateRequest', '$parse', '$controller', '$rootScope', '$sce', '$animate', '$$sanitizeUri', @@ -1215,6 +1259,36 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { var SIMPLE_ATTR_NAME = /^\w/; var specialAttrHolder = document.createElement('div'); + + + + var onChangesTtl = TTL; + // The onChanges hooks should all be run together in a single digest + // When changes occur, the call to trigger their hooks will be added to this queue + var onChangesQueue; + + // This function is called in a $$postDigest to trigger all the onChanges hooks in a single digest + function flushOnChangesQueue() { + try { + if (!(--onChangesTtl)) { + // We have hit the TTL limit so reset everything + onChangesQueue = undefined; + throw $compileMinErr('infchng', '{0} $onChanges() iterations reached. Aborting!\n', TTL); + } + // We must run this hook in an apply since the $$postDigest runs outside apply + $rootScope.$apply(function() { + for (var i = 0, ii = onChangesQueue.length; i < ii; ++i) { + onChangesQueue[i](); + } + // Reset the queue to trigger a new schedule next time there is a change + onChangesQueue = undefined; + }); + } finally { + onChangesTtl++; + } + } + + function Attributes(element, attributesToCopy) { if (attributesToCopy) { var keys = Object.keys(attributesToCopy); @@ -2360,10 +2434,16 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } }); - // Trigger the `$onInit` method on all controllers that have one + // Handle the init and destroy lifecycle hooks on all controllers that have them forEach(elementControllers, function(controller) { - if (isFunction(controller.instance.$onInit)) { - controller.instance.$onInit(); + var controllerInstance = controller.instance; + if (isFunction(controllerInstance.$onInit)) { + controllerInstance.$onInit(); + } + if (isFunction(controllerInstance.$onDestroy)) { + controllerScope.$on('$destroy', function callOnDestroyHook() { + controllerInstance.$onDestroy(); + }); } }); @@ -2400,6 +2480,14 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { ); } + // Trigger $postLink lifecycle hooks + forEach(elementControllers, function(controller) { + var controllerInstance = controller.instance; + if (isFunction(controllerInstance.$postLink)) { + controllerInstance.$postLink(); + } + }); + // This is the function that is injected as `$transclude`. // Note: all arguments are optional! function controllersBoundTransclude(scope, cloneAttachFn, futureParentElement, slotName) { @@ -2995,6 +3083,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { // only occurs for isolate scopes and new scopes with controllerAs. function initializeDirectiveBindings(scope, attrs, destination, bindings, directive) { var removeWatchCollection = []; + var changes; forEach(bindings, function initializeBinding(definition, scopeName) { var attrName = definition.attrName, optional = definition.optional, @@ -3010,6 +3099,8 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } attrs.$observe(attrName, function(value) { if (isString(value)) { + var oldValue = destination[scopeName]; + recordChanges(scopeName, value, oldValue); destination[scopeName] = value; } }); @@ -3081,6 +3172,8 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { destination[scopeName] = parentGet(scope); removeWatch = scope.$watch(parentGet, function parentValueWatchAction(newParentValue) { + var oldValue = destination[scopeName]; + recordChanges(scopeName, newParentValue, oldValue); destination[scopeName] = newParentValue; }, parentGet.literal); @@ -3101,6 +3194,33 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } }); + function recordChanges(key, currentValue, previousValue) { + if (isFunction(destination.$onChanges) && currentValue !== previousValue) { + // If we have not already scheduled the top level onChangesQueue handler then do so now + if (!onChangesQueue) { + scope.$$postDigest(flushOnChangesQueue); + onChangesQueue = []; + } + // If we have not already queued a trigger of onChanges for this controller then do so now + if (!changes) { + changes = {}; + onChangesQueue.push(triggerOnChangesHook); + } + // If the has been a change on this property already then we need to reuse the previous value + if (changes[key]) { + previousValue = changes[key].previousValue; + } + // Store this change + changes[key] = {previousValue: previousValue, currentValue: currentValue}; + } + } + + function triggerOnChangesHook() { + destination.$onChanges(changes); + // Now clear the changes so that we schedule onChanges when more changes arrive + changes = undefined; + } + return removeWatchCollection.length && function removeWatches() { for (var i = 0, ii = removeWatchCollection.length; i < ii; ++i) { removeWatchCollection[i](); diff --git a/test/ng/compileSpec.js b/test/ng/compileSpec.js index c282067213be..a9fd0069e666 100755 --- a/test/ng/compileSpec.js +++ b/test/ng/compileSpec.js @@ -3515,6 +3515,391 @@ describe('$compile', function() { }); }); + describe('controller lifecycle hooks', function() { + + describe('$onInit', function() { + + it('should call `$onInit`, if provided, after all the controllers on the element have been initialized', function() { + + function check() { + /*jshint validthis:true */ + expect(this.element.controller('d1').id).toEqual(1); + expect(this.element.controller('d2').id).toEqual(2); + } + + function Controller1($element) { this.id = 1; this.element = $element; } + Controller1.prototype.$onInit = jasmine.createSpy('$onInit').and.callFake(check); + + function Controller2($element) { this.id = 2; this.element = $element; } + Controller2.prototype.$onInit = jasmine.createSpy('$onInit').and.callFake(check); + + angular.module('my', []) + .directive('d1', valueFn({ controller: Controller1 })) + .directive('d2', valueFn({ controller: Controller2 })); + + module('my'); + inject(function($compile, $rootScope) { + element = $compile('
')($rootScope); + expect(Controller1.prototype.$onInit).toHaveBeenCalledOnce(); + expect(Controller2.prototype.$onInit).toHaveBeenCalledOnce(); + }); + }); + }); + + + describe('$onDestroy', function() { + + it('should call `$onDestroy`, if provided, on the controller when its scope is destroyed', function() { + + function TestController() { this.count = 0; } + TestController.prototype.$onDestroy = function() { this.count++; }; + + angular.module('my', []) + .directive('d1', valueFn({ scope: true, controller: TestController })) + .directive('d2', valueFn({ scope: {}, controller: TestController })) + .directive('d3', valueFn({ controller: TestController })); + + module('my'); + inject(function($compile, $rootScope) { + + element = $compile('
')($rootScope); + + $rootScope.$apply('show = [true, true, true]'); + var d1Controller = element.find('d1').controller('d1'); + var d2Controller = element.find('d2').controller('d2'); + var d3Controller = element.find('d3').controller('d3'); + + expect([d1Controller.count, d2Controller.count, d3Controller.count]).toEqual([0,0,0]); + $rootScope.$apply('show = [false, true, true]'); + expect([d1Controller.count, d2Controller.count, d3Controller.count]).toEqual([1,0,0]); + $rootScope.$apply('show = [false, false, true]'); + expect([d1Controller.count, d2Controller.count, d3Controller.count]).toEqual([1,1,0]); + $rootScope.$apply('show = [false, false, false]'); + expect([d1Controller.count, d2Controller.count, d3Controller.count]).toEqual([1,1,1]); + }); + }); + + + it('should call `$onDestroy` top-down (the same as `scope.$broadcast`)', function() { + var log = []; + function ParentController() { log.push('parent created'); } + ParentController.prototype.$onDestroy = function() { log.push('parent destroyed'); }; + function ChildController() { log.push('child created'); } + ChildController.prototype.$onDestroy = function() { log.push('child destroyed'); }; + function GrandChildController() { log.push('grand child created'); } + GrandChildController.prototype.$onDestroy = function() { log.push('grand child destroyed'); }; + + angular.module('my', []) + .directive('parent', valueFn({ scope: true, controller: ParentController })) + .directive('child', valueFn({ scope: true, controller: ChildController })) + .directive('grandChild', valueFn({ scope: true, controller: GrandChildController })); + + module('my'); + inject(function($compile, $rootScope) { + + element = $compile('')($rootScope); + $rootScope.$apply('show = true'); + expect(log).toEqual(['parent created', 'child created', 'grand child created']); + log = []; + $rootScope.$apply('show = false'); + expect(log).toEqual(['parent destroyed', 'child destroyed', 'grand child destroyed']); + }); + }); + }); + + + describe('$postLink', function() { + + it('should call `$postLink`, if provided, after the element has completed linking (i.e. post-link)', function() { + + var log = []; + + function Controller1() { } + Controller1.prototype.$postLink = function() { log.push('d1 view init'); }; + + function Controller2() { } + Controller2.prototype.$postLink = function() { log.push('d2 view init'); }; + + angular.module('my', []) + .directive('d1', valueFn({ + controller: Controller1, + link: { pre: function(s, e) { log.push('d1 pre: ' + e.text()); }, post: function(s, e) { log.push('d1 post: ' + e.text()); } }, + template: '' + })) + .directive('d2', valueFn({ + controller: Controller2, + link: { pre: function(s, e) { log.push('d2 pre: ' + e.text()); }, post: function(s, e) { log.push('d2 post: ' + e.text()); } }, + template: 'loaded' + })); + + module('my'); + inject(function($compile, $rootScope) { + element = $compile('')($rootScope); + expect(log).toEqual([ + 'd1 pre: loaded', + 'd2 pre: loaded', + 'd2 post: loaded', + 'd2 view init', + 'd1 post: loaded', + 'd1 view init' + ]); + }); + }); + }); + + + describe('$onChanges', function() { + + it('should call `$onChanges`, if provided, when a one-way (`<`) or interpolation (`@`) bindings are updated', function() { + var log = []; + function TestController() { } + TestController.prototype.$onChanges = function(change) { log.push(change); }; + + angular.module('my', []) + .component('c1', { + controller: TestController, + bindings: { 'prop1': '<', 'prop2': '<', 'other': '=', 'attr': '@' } + }); + + module('my'); + inject(function($compile, $rootScope) { + // Setup a watch to indicate some complicated updated logic + $rootScope.$watch('val', function(val, oldVal) { $rootScope.val2 = val * 2; }); + // Setup the directive with two bindings + element = $compile('')($rootScope); + + // There should be no changes initially + expect(log).toEqual([]); + + // Update val to trigger the onChanges + $rootScope.$apply('val = 42'); + // Now we should have a single changes entry in the log + expect(log).toEqual([ + { + prop1: {previousValue: undefined, currentValue: 42}, + prop2: {previousValue: undefined, currentValue: 84} + } + ]); + + // Clear the log + log = []; + + // Update val to trigger the onChanges + $rootScope.$apply('val = 17'); + // Now we should have a single changes entry in the log + expect(log).toEqual([ + { + prop1: {previousValue: 42, currentValue: 17}, + prop2: {previousValue: 84, currentValue: 34} + } + ]); + + // Clear the log + log = []; + + // Update val3 to trigger the "other" two-way binding + $rootScope.$apply('val3 = 63'); + // onChanges should not have been called + expect(log).toEqual([]); + + // Update val4 to trigger the "attr" interpolation binding + $rootScope.$apply('val4 = 22'); + // onChanges should not have been called + expect(log).toEqual([ + { + attr: {previousValue: '', currentValue: '22'} + } + ]); + }); + }); + + + it('should pass the original value as `previousValue` even if there were multiple changes in a single digest', function() { + var log = []; + function TestController() { } + TestController.prototype.$onChanges = function(change) { log.push(change); }; + + angular.module('my', []) + .component('c1', { + controller: TestController, + bindings: { 'prop': '<' } + }); + + module('my'); + inject(function($compile, $rootScope) { + element = $compile('')($rootScope); + + // We add this watch after the compilation to ensure that it will run after the binding watchers + // therefore triggering the thing that this test is hoping to enfore + $rootScope.$watch('a', function(val) { $rootScope.b = val * 2; }); + + // There should be no changes initially + expect(log).toEqual([]); + + // Update val to trigger the onChanges + $rootScope.$apply('a = 42'); + // Now the change should have the real previous value (undefined), not the intermediate one (42) + expect(log).toEqual([{prop: {previousValue: undefined, currentValue: 126}}]); + + // Clear the log + log = []; + + // Update val to trigger the onChanges + $rootScope.$apply('a = 7'); + // Now the change should have the real previous value (126), not the intermediate one, (91) + expect(log).toEqual([{ prop: {previousValue: 126, currentValue: 21}}]); + }); + }); + + + it('should only trigger one extra digest however many controllers have changes', function() { + var log = []; + function TestController1() { } + TestController1.prototype.$onChanges = function(change) { log.push(['TestController1', change]); }; + function TestController2() { } + TestController2.prototype.$onChanges = function(change) { log.push(['TestController2', change]); }; + + angular.module('my', []) + .component('c1', { + controller: TestController1, + bindings: {'prop': '<'} + }) + .component('c2', { + controller: TestController2, + bindings: {'prop': '<'} + }); + + module('my'); + inject(function($compile, $rootScope) { + + // Create a watcher to count the number of digest cycles + var watchCount = 0; + $rootScope.$watch(function() { watchCount++; }); + + // Setup two sibling components with bindings that will change + element = $compile('
')($rootScope); + + // There should be no changes initially + expect(log).toEqual([]); + + // Update val to trigger the onChanges + $rootScope.$apply('val1 = 42; val2 = 17'); + + expect(log).toEqual([ + ['TestController1', {prop: {previousValue: undefined, currentValue: 42}}], + ['TestController2', {prop: {previousValue: undefined, currentValue: 17}}] + ]); + // A single apply should only trigger three turns of the digest loop + expect(watchCount).toEqual(3); + }); + }); + + + it('should cope with changes occuring inside `$onChanges()` hooks', function() { + var log = []; + function OuterController() { } + OuterController.prototype.$onChanges = function(change) { + log.push(['OuterController', change]); + // Make a change to the inner component + this.b = 72; + }; + + function InnerController() { } + InnerController.prototype.$onChanges = function(change) { log.push(['InnerController', change]); }; + + angular.module('my', []) + .component('outer', { + controller: OuterController, + bindings: {'prop1': '<'}, + template: '' + }) + .component('inner', { + controller: InnerController, + bindings: {'prop2': '<'} + }); + + module('my'); + inject(function($compile, $rootScope) { + + // Setup the directive with two bindings + element = $compile('')($rootScope); + + // There should be no changes initially + expect(log).toEqual([]); + + // Update val to trigger the onChanges + $rootScope.$apply('a = 42'); + + expect(log).toEqual([ + ['OuterController', {prop1: {previousValue: undefined, currentValue: 42}}], + ['InnerController', {prop2: {previousValue: undefined, currentValue: 72}}] + ]); + }); + }); + + + it('should throw an error if `$onChanges()` hooks are not stable', function() { + function TestController() {} + TestController.prototype.$onChanges = function(change) { + this.onChange(); + }; + + angular.module('my', []) + .component('c1', { + controller: TestController, + bindings: {'prop': '<', onChange: '&'} + }); + + module('my'); + inject(function($compile, $rootScope) { + + // Setup the directive with bindings that will keep updating the bound value forever + element = $compile('')($rootScope); + + // Update val to trigger the unstable onChanges, which will result in an error + expect(function() { + $rootScope.$apply('a = 42'); + }).toThrowMinErr('$compile', 'infchng'); + + dealoc(element); + element = $compile('')($rootScope); + $rootScope.$apply('b = 24'); + $rootScope.$apply('b = 48'); + }); + }); + + + it('should log an error if `$onChanges()` hooks are not stable', function() { + function TestController() {} + TestController.prototype.$onChanges = function(change) { + this.onChange(); + }; + + angular.module('my', []) + .component('c1', { + controller: TestController, + bindings: {'prop': '<', onChange: '&'} + }) + .config(function($exceptionHandlerProvider) { + // We need to test with the exceptionHandler not rethrowing... + $exceptionHandlerProvider.mode('log'); + }); + + module('my'); + inject(function($compile, $rootScope, $exceptionHandler) { + + // Setup the directive with bindings that will keep updating the bound value forever + element = $compile('')($rootScope); + + // Update val to trigger the unstable onChanges, which will result in an error + $rootScope.$apply('a = 42'); + expect($exceptionHandler.errors.length).toEqual(1); + expect($exceptionHandler.errors[0].toString()).toContain('[$compile:infchng] 10 $onChanges() iterations reached.'); + }); + }); + }); + }); + describe('isolated locals', function() { var componentScope, regularScope; @@ -5324,32 +5709,6 @@ describe('$compile', function() { }); }); - it('should call `controller.$onInit`, if provided after all the controllers have been constructed', function() { - - function check() { - /*jshint validthis:true */ - expect(this.element.controller('d1').id).toEqual(1); - expect(this.element.controller('d2').id).toEqual(2); - } - - function Controller1($element) { this.id = 1; this.element = $element; } - Controller1.prototype.$onInit = jasmine.createSpy('$onInit').and.callFake(check); - - function Controller2($element) { this.id = 2; this.element = $element; } - Controller2.prototype.$onInit = jasmine.createSpy('$onInit').and.callFake(check); - - angular.module('my', []) - .directive('d1', valueFn({ controller: Controller1 })) - .directive('d2', valueFn({ controller: Controller2 })); - - module('my'); - inject(function($compile, $rootScope) { - element = $compile('
')($rootScope); - expect(Controller1.prototype.$onInit).toHaveBeenCalledOnce(); - expect(Controller2.prototype.$onInit).toHaveBeenCalledOnce(); - }); - }); - describe('should not overwrite @-bound property each digest when not present', function() { it('when creating new scope', function() { module(function($compileProvider) {