Skip to content

Commit 874c0fd

Browse files
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 angular#14127 Closes angular#14030 Closes angular#14020 Closes angular#13991 Closes angular#14302
1 parent 7489d56 commit 874c0fd

File tree

4 files changed

+563
-30
lines changed

4 files changed

+563
-30
lines changed
+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
@ngdoc error
2+
@name $compile:infchng
3+
@fullName Unstable `$onChanges` hooks
4+
@description
5+
6+
This error occurs when the application's model becomes unstable because some `$onChanges` hooks are causing updates which then trigger
7+
further calls to `$onChanges` that can never complete.
8+
Angular detects this situation and prevents an infinite loop from causing the browser to become unresponsive.
9+
10+
For example, the situation can occur by setting up a `$onChanges()` hook which triggers an event on the component, which subsequently
11+
triggers the component's bound inputs to be updated:
12+
13+
```html
14+
<c1 prop="a" on-change="a = -a"></c1>
15+
```
16+
17+
```js
18+
function Controller1() {}
19+
Controller1.$onChanges = function() {
20+
this.onChange();
21+
};
22+
23+
mod.component('c1', {
24+
controller: Controller1,
25+
bindings: {'prop': '<', onChange: '&'}
26+
}
27+
```
28+
29+
The maximum number of allowed iterations of the `$onChanges` hooks is controlled via TTL setting which can be configured via
30+
{@link ng.$compileProvider#onChangesTtl `$compileProvider.onChangesTtl`}.

docs/content/guide/component.ngdoc

+24
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,30 @@ components should follow a few simple conventions:
147147
}
148148
```
149149

150+
- **Components have a well-defined lifecycle**
151+
Each component can implement "lifecycle hooks". These are methods that will be called at certain points in the life
152+
of the component. The following hook methods can be implemented:
153+
154+
* `$onInit()` - Called on each controller after all the controllers on an element have been constructed and
155+
had their bindings initialized (and before the pre &amp; post linking functions for the directives on
156+
this element). This is a good place to put initialization code for your controller.
157+
* `$onChanges(changesObj)` - Called whenever one-way bindings are updated. The `changesObj` is a hash whose keys
158+
are the names of the bound properties that have changed, and the values are an object of the form
159+
`{ currentValue: ..., previousValue: ... }`. Use this hook to trigger updates within a component such as
160+
cloning the bound value to prevent accidental mutation of the outer value.
161+
* `$onDestroy()` - Called on a controller when its containing scope is destroyed. Use this hook for releasing
162+
external resources, watches and event handlers.
163+
* `$postLink()` - Called after this controller's element and its children have been linked. Similar to the post-link
164+
function this hook can be used to set up DOM event handlers and do direct DOM manipulation.
165+
Note that child elements that contain `templateUrl` directives will not have been compiled and linked since
166+
they are waiting for their template to load asynchronously and their own compilation and linking has been
167+
suspended until that occurs.
168+
This hook can be considered analogous to the `ngAfterViewInit` and `ngAfterContentInit` hooks in Angular 2.
169+
Since the compilation process is rather different in Angular 1 there is no direct mapping and care should
170+
be taken when upgrading.
171+
172+
By implementing these methods, you component can take part in its lifecycle.
173+
150174
- **An application is a tree of components:**
151175
Ideally, the whole application should be a tree of components that implement clearly defined inputs
152176
and outputs, and minimize two-way data binding. That way, it's easier to predict when data changes and what the state

src/ng/compile.js

+124-4
Original file line numberDiff line numberDiff line change
@@ -293,9 +293,23 @@
293293
* `true` if the specified slot contains content (i.e. one or more DOM nodes).
294294
*
295295
* The controller can provide the following methods that act as life-cycle hooks:
296-
* * `$onInit` - Called on each controller after all the controllers on an element have been constructed and
296+
* * `$onInit()` - Called on each controller after all the controllers on an element have been constructed and
297297
* had their bindings initialized (and before the pre &amp; post linking functions for the directives on
298298
* this element). This is a good place to put initialization code for your controller.
299+
* * `$onChanges(changesObj)` - Called whenever one-way (`<`) or interpolation (`@`) bindings are updated. The
300+
* `changesObj` is a hash whose keys are the names of the bound properties that have changed, and the values are an
301+
* object of the form `{ currentValue: ..., previousValue: ... }`. Use this hook to trigger updates within a component
302+
* such as cloning the bound value to prevent accidental mutation of the outer value.
303+
* * `$onDestroy()` - Called on a controller when its containing scope is destroyed. Use this hook for releasing
304+
* external resources, watches and event handlers. Note that components have their `$onDestroy()` hooks called in
305+
* the same order as the `$scope.$broadcast` events are triggered, which is top down. This means that parent
306+
* components will have their `$onDestroy()` hook called before child components.
307+
* * `$postLink()` - Called after this controller's element and its children have been linked. Similar to the post-link
308+
* function this hook can be used to set up DOM event handlers and do direct DOM manipulation.
309+
* Note that child elements that contain `templateUrl` directives will not have been compiled and linked since
310+
* they are waiting for their template to load asynchronously and their own compilation and linking has been
311+
* suspended until that occurs.
312+
*
299313
*
300314
* #### `require`
301315
* Require another directive and inject its controller as the fourth argument to the linking function. The
@@ -1207,6 +1221,36 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
12071221
return debugInfoEnabled;
12081222
};
12091223

1224+
1225+
var TTL = 10;
1226+
/**
1227+
* @ngdoc method
1228+
* @name $compileProvider#onChangesTtl
1229+
* @description
1230+
*
1231+
* Sets the number of times `$onChanges` hooks can trigger new changes before giving up and
1232+
* assuming that the model is unstable.
1233+
*
1234+
* The current default is 10 iterations.
1235+
*
1236+
* In complex applications it's possible that dependencies between `$onChanges` hooks and bindings will result
1237+
* in several iterations of calls to these hooks. However if an application needs more than the default 10
1238+
* iterations to stabilize then you should investigate what is causing the model to continuously change during
1239+
* the `$onChanges` hook execution.
1240+
*
1241+
* Increasing the TTL could have performance implications, so you should not change it without proper justification.
1242+
*
1243+
* @param {number} limit The number of `$onChanges` hook iterations.
1244+
* @returns {number|object} the current limit (or `this` if called as a setter for chaining)
1245+
*/
1246+
this.onChangesTtl = function(value) {
1247+
if (arguments.length) {
1248+
TTL = value;
1249+
return this;
1250+
}
1251+
return TTL;
1252+
};
1253+
12101254
this.$get = [
12111255
'$injector', '$interpolate', '$exceptionHandler', '$templateRequest', '$parse',
12121256
'$controller', '$rootScope', '$sce', '$animate', '$$sanitizeUri',
@@ -1215,6 +1259,36 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
12151259

12161260
var SIMPLE_ATTR_NAME = /^\w/;
12171261
var specialAttrHolder = document.createElement('div');
1262+
1263+
1264+
1265+
var onChangesTtl = TTL;
1266+
// The onChanges hooks should all be run together in a single digest
1267+
// When changes occur, the call to trigger their hooks will be added to this queue
1268+
var onChangesQueue;
1269+
1270+
// This function is called in a $$postDigest to trigger all the onChanges hooks in a single digest
1271+
function flushOnChangesQueue() {
1272+
try {
1273+
if (!(--onChangesTtl)) {
1274+
// We have hit the TTL limit so reset everything
1275+
onChangesQueue = undefined;
1276+
throw $compileMinErr('infchng', '{0} $onChanges() iterations reached. Aborting!\n', TTL);
1277+
}
1278+
// We must run this hook in an apply since the $$postDigest runs outside apply
1279+
$rootScope.$apply(function() {
1280+
for (var i = 0, ii = onChangesQueue.length; i < ii; ++i) {
1281+
onChangesQueue[i]();
1282+
}
1283+
// Reset the queue to trigger a new schedule next time there is a change
1284+
onChangesQueue = undefined;
1285+
});
1286+
} finally {
1287+
onChangesTtl++;
1288+
}
1289+
}
1290+
1291+
12181292
function Attributes(element, attributesToCopy) {
12191293
if (attributesToCopy) {
12201294
var keys = Object.keys(attributesToCopy);
@@ -2360,10 +2434,16 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
23602434
}
23612435
});
23622436

2363-
// Trigger the `$onInit` method on all controllers that have one
2437+
// Handle the init and destroy lifecycle hooks on all controllers that have them
23642438
forEach(elementControllers, function(controller) {
2365-
if (isFunction(controller.instance.$onInit)) {
2366-
controller.instance.$onInit();
2439+
var controllerInstance = controller.instance;
2440+
if (isFunction(controllerInstance.$onInit)) {
2441+
controllerInstance.$onInit();
2442+
}
2443+
if (isFunction(controllerInstance.$onDestroy)) {
2444+
controllerScope.$on('$destroy', function callOnDestroyHook() {
2445+
controllerInstance.$onDestroy();
2446+
});
23672447
}
23682448
});
23692449

@@ -2400,6 +2480,14 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
24002480
);
24012481
}
24022482

2483+
// Trigger $postLink lifecycle hooks
2484+
forEach(elementControllers, function(controller) {
2485+
var controllerInstance = controller.instance;
2486+
if (isFunction(controllerInstance.$postLink)) {
2487+
controllerInstance.$postLink();
2488+
}
2489+
});
2490+
24032491
// This is the function that is injected as `$transclude`.
24042492
// Note: all arguments are optional!
24052493
function controllersBoundTransclude(scope, cloneAttachFn, futureParentElement, slotName) {
@@ -2995,6 +3083,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
29953083
// only occurs for isolate scopes and new scopes with controllerAs.
29963084
function initializeDirectiveBindings(scope, attrs, destination, bindings, directive) {
29973085
var removeWatchCollection = [];
3086+
var changes;
29983087
forEach(bindings, function initializeBinding(definition, scopeName) {
29993088
var attrName = definition.attrName,
30003089
optional = definition.optional,
@@ -3010,6 +3099,8 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
30103099
}
30113100
attrs.$observe(attrName, function(value) {
30123101
if (isString(value)) {
3102+
var oldValue = destination[scopeName];
3103+
recordChanges(scopeName, value, oldValue);
30133104
destination[scopeName] = value;
30143105
}
30153106
});
@@ -3081,6 +3172,8 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
30813172
destination[scopeName] = parentGet(scope);
30823173

30833174
removeWatch = scope.$watch(parentGet, function parentValueWatchAction(newParentValue) {
3175+
var oldValue = destination[scopeName];
3176+
recordChanges(scopeName, newParentValue, oldValue);
30843177
destination[scopeName] = newParentValue;
30853178
}, parentGet.literal);
30863179

@@ -3101,6 +3194,33 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
31013194
}
31023195
});
31033196

3197+
function recordChanges(key, currentValue, previousValue) {
3198+
if (isFunction(destination.$onChanges) && currentValue !== previousValue) {
3199+
// If we have not already scheduled the top level onChangesQueue handler then do so now
3200+
if (!onChangesQueue) {
3201+
scope.$$postDigest(flushOnChangesQueue);
3202+
onChangesQueue = [];
3203+
}
3204+
// If we have not already queued a trigger of onChanges for this controller then do so now
3205+
if (!changes) {
3206+
changes = {};
3207+
onChangesQueue.push(triggerOnChangesHook);
3208+
}
3209+
// If the has been a change on this property already then we need to reuse the previous value
3210+
if (changes[key]) {
3211+
previousValue = changes[key].previousValue;
3212+
}
3213+
// Store this change
3214+
changes[key] = {previousValue: previousValue, currentValue: currentValue};
3215+
}
3216+
}
3217+
3218+
function triggerOnChangesHook() {
3219+
destination.$onChanges(changes);
3220+
// Now clear the changes so that we schedule onChanges when more changes arrive
3221+
changes = undefined;
3222+
}
3223+
31043224
return removeWatchCollection.length && function removeWatches() {
31053225
for (var i = 0, ii = removeWatchCollection.length; i < ii; ++i) {
31063226
removeWatchCollection[i]();

0 commit comments

Comments
 (0)