Skip to content
This repository was archived by the owner on Apr 12, 2024. It is now read-only.

feat($compile): add more lifecycle hooks to directive controllers #14302

Merged
merged 1 commit into from
Mar 25, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions docs/content/error/$compile/infchng.ngdoc
Original file line number Diff line number Diff line change
@@ -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
<c1 prop="a" on-change="a = -a"></c1>
```

```js
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add the component definition object? Makes it easier to understand if you know the bindings are bindings: {'prop': '<', onChange: '&'}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

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`}.
24 changes: 24 additions & 0 deletions docs/content/guide/component.ngdoc
Original file line number Diff line number Diff line change
Expand Up @@ -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 &amp; 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love it (as well as $onDestroy); more reasons to stop injecting $scope into controllers.

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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it also how it happens now if you use regular postLink functions in module.directive-defined directives?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes

I copied and pasted that text from the post link function :-)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH, I feel that this last limitation makes this hook pretty usel...ehm...much less useful than the corresponding ng2 hook :(

I assume, with the current implementation, this hook is very similar to the post-link function, which means that:

(a) It doesn't offer new functionality for ng1 users.
(b) It doesn't help much with migrating but for very simple components with inline templates. Even caching the templates won't help, right ?

These are just assumptions atm (I haven't looked at the code yet), but if they are true, I'm a little skeptical about this hook.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my current project I avoid these problems by not using templateUrl but relying on Webpack being able to import HTML files using the ES6 import syntax. Unfortunately you can't do things like that without any preprocessing done so it can't be a general solution.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right @gkalpak, which is why I was suggesting that we call it $afterLink instead.
It does, though, have value for components, where there is no way right now to hook into the post-link function.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am becoming very keen on the webpack template: require('some.template.html') approach :-)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I only wish TypeScript had something similar :(
But in the meantime, I believe most developers follow the "best practice" of caching their templates with $templateCache in a build step. Besides a component doesn't know what it's children (or its grandchildren) do wrt template vs templateUrl.

I am afraid this will confuse people and create some headaches while migrating.

If we can't/won't make it work similarly to ng2, it might be btter to name is $postLink and just provide it as a way to access the post-link function while using the .component() helper, as @petebacondarwin suggested.

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
Expand Down
128 changes: 124 additions & 4 deletions src/ng/compile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 &amp; 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
Expand Down Expand Up @@ -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)
*/
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing @returns docs.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

this.onChangesTtl = function(value) {
if (arguments.length) {
TTL = value;
return this;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest returning this for consistency. (And then also document it 😃)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

return TTL;
};

this.$get = [
'$injector', '$interpolate', '$exceptionHandler', '$templateRequest', '$parse',
'$controller', '$rootScope', '$sce', '$animate', '$$sanitizeUri',
Expand All @@ -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);
Expand Down Expand Up @@ -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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be a ridiculous edge case, but is there any concern about removal of the hook during the lifetime of the controller? $onDestroy may not necessarily exist on the instance in the future even though it does right now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the moment I don't want to support dynamic changes to the component hook methods: neither lazy definition nor removal. If this is a really common use case we could consider implementing it later without introducing a breaking change.

});
}
});

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
});
Expand Down Expand Up @@ -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);

Expand All @@ -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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why $$postDigest versus $evalAsync? Didn't pull this down to test, but $evalAsync would feel like the more correct method to use here so that we continue to live within the same digest cycle and don't accidentally violate the max TTL due to handling record changes

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want all the changes in the current digest to settle before we trigger the onChanges hook.
For this to happen we need the current digest to have completed, which is why postDigest is a good place. Perhaps you can convince me otherwise?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need anymore FWIW. The addition of the other TTL mechanism mitigated most of my concern, though it'd be sweet if the existing TTL could have been passed along rather then implementing another one so that the max TTL can be defined in one place.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @doberman can you clarify what you mean here?
Are you saying that the use of EvalAsync is not needed any more?

I did consider piggybacking on the rootscope TTL but since it is not actually related to the digest cycle I thought we should keep them independent

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]();
Expand Down
Loading