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) {