diff --git a/docs/content/guide/expression.ngdoc b/docs/content/guide/expression.ngdoc
index f92a91b24a88..bce945718fe6 100644
--- a/docs/content/guide/expression.ngdoc
+++ b/docs/content/guide/expression.ngdoc
@@ -198,3 +198,122 @@ expose a `$event` object within the scope of that expression.
Note in the example above how we can pass in `$event` to `clickMe`, but how it does not show up
in `{{$event}}`. This is because `$event` is outside the scope of that binding.
+
+
+## One-time binding
+
+An expression that starts with `::` is considered a one-time expression. One-time expressions
+will stop recalculating once they are stable, which happens after the first digest if the expression
+result is a non-undefined value (see value stabilization algorithm below).
+
+
+
+
+
+
One time binding: {{::name}}
+
Normal binding: {{name}}
+
+
+
+ angular.module('oneTimeBidingExampleApp', []).
+ controller('EventController', ['$scope', function($scope) {
+ var counter = 0;
+ var names = ['Igor', 'Misko', 'Chirayu', 'Lucas'];
+ /*
+ * expose the event object to the scope
+ */
+ $scope.clickMe = function(clickEvent) {
+ $scope.name = names[counter % names.length];
+ counter++;
+ };
+ }]);
+
+
+ it('should freeze binding after its value has stabilized', function() {
+ var oneTimeBiding = element(by.id('one-time-binding-example'));
+ var normalBinding = element(by.id('normal-binding-example'));
+
+ expect(oneTimeBiding.getText()).toEqual('One time binding:');
+ expect(normalBinding.getText()).toEqual('Normal binding:');
+ element(by.buttonText('Click Me')).click();
+
+ expect(oneTimeBiding.getText()).toEqual('One time binding: Igor');
+ expect(normalBinding.getText()).toEqual('Normal binding: Igor');
+ element(by.buttonText('Click Me')).click();
+
+ expect(oneTimeBiding.getText()).toEqual('One time binding: Igor');
+ expect(normalBinding.getText()).toEqual('Normal binding: Misko');
+
+ element(by.buttonText('Click Me')).click();
+ element(by.buttonText('Click Me')).click();
+
+ expect(oneTimeBiding.getText()).toEqual('One time binding: Igor');
+ expect(normalBinding.getText()).toEqual('Normal binding: Lucas');
+ });
+
+
+
+
+### Why this feature
+
+The main purpose of one-time binding expression is to provide a way to create a binding
+that gets deregistered and frees up resources once the binding is stabilized.
+Reducing the number of expressions being watched makes the digest loop faster and allows more
+information to be displayed at the same time.
+
+
+### Value stabilization algorithm
+
+One-time binding expressions will retain the value of the expression at the end of the
+digest cycle as long as that value is not undefined. If the value of the expression is set
+within the digest loop and later, within the same digest loop, it is set to undefined,
+then the expression is not fulfilled and will remain watched.
+
+ 1. Given an expression that starts with `::` when a digest loop is entered and expression
+ is dirty-checked store the value as V
+ 2. If V is not undefined mark the result of the expression as stable and schedule a task
+ to deregister the watch for this expression when we exit the digest loop
+ 3. Process the digest loop as normal
+ 4. When digest loop is done and all the values have settled process the queue of watch
+ deregistration tasks. For each watch to be deregistered check if it still evaluates
+ to value that is not `undefined`. If that's the case, deregister the watch. Otherwise
+ keep dirty-checking the watch in the future digest loops by following the same
+ algorithm starting from step 1
+
+
+### How to benefit from one-time binding
+
+When interpolating text or attributes. If the expression, once set, will not change
+then it is a candidate for one-time expression.
+
+```html
+
text: {{::name}}
+```
+
+When using a directive with bidirectional binding and the parameters will not change
+
+```js
+someModule.directive('someDirective', function() {
+ return {
+ scope: {
+ name: '=',
+ color: '@'
+ },
+ template: '{{name}}: {{color}}'
+ };
+});
+```
+
+```html
+
+```
+
+
+When using a directive that takes an expression
+
+```html
+
+
{{item.name}};
+
+```
+
diff --git a/src/ng/compile.js b/src/ng/compile.js
index e5f77f148e5c..ce2002c80a73 100644
--- a/src/ng/compile.js
+++ b/src/ng/compile.js
@@ -1485,6 +1485,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
parentSet(scope, parentValue = isolateScope[scopeName]);
}
}
+ parentValueWatch.$$unwatch = parentGet.$$unwatch;
return lastValue = parentValue;
}, null, parentGet.literal);
break;
@@ -1813,6 +1814,9 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
compile: valueFn(function textInterpolateLinkFn(scope, node) {
var parent = node.parent(),
bindings = parent.data('$binding') || [];
+ // Need to interpolate again in case this is using one-time bindings in multiple clones
+ // of transcluded templates.
+ interpolateFn = $interpolate(text);
bindings.push(interpolateFn);
safeAddClass(parent.data('$binding', bindings), 'ng-binding');
scope.$watch(interpolateFn, function interpolateFnWatchAction(value) {
diff --git a/src/ng/directive/ngBind.js b/src/ng/directive/ngBind.js
index d2a9c2f1b21a..b37df64f2bba 100644
--- a/src/ng/directive/ngBind.js
+++ b/src/ng/directive/ngBind.js
@@ -174,7 +174,11 @@ var ngBindHtmlDirective = ['$sce', '$parse', function($sce, $parse) {
element.addClass('ng-binding').data('$binding', attr.ngBindHtml);
var parsed = $parse(attr.ngBindHtml);
- function getStringValue() { return (parsed(scope) || '').toString(); }
+ function getStringValue() {
+ var value = parsed(scope);
+ getStringValue.$$unwatch = parsed.$$unwatch;
+ return (value || '').toString();
+ }
scope.$watch(getStringValue, function ngBindHtmlWatchAction(value) {
element.html($sce.getTrustedHtml(parsed(scope)) || '');
diff --git a/src/ng/interpolate.js b/src/ng/interpolate.js
index 0f0f840d09a4..718a8deeb0b9 100644
--- a/src/ng/interpolate.js
+++ b/src/ng/interpolate.js
@@ -309,9 +309,11 @@ function $InterpolateProvider() {
try {
+ interpolationFn.$$unwatch = true;
for (; i < ii; i++) {
val = getValue(parseFns[i](context));
if (allOrNothing && isUndefined(val)) {
+ interpolationFn.$$unwatch = undefined;
return;
}
val = stringify(val);
@@ -319,6 +321,7 @@ function $InterpolateProvider() {
inputsChanged = true;
}
values[i] = val;
+ interpolationFn.$$unwatch = interpolationFn.$$unwatch && parseFns[i].$$unwatch;
}
if (inputsChanged) {
diff --git a/src/ng/parse.js b/src/ng/parse.js
index 0aa57a3351c6..bb59ea136e59 100644
--- a/src/ng/parse.js
+++ b/src/ng/parse.js
@@ -1018,13 +1018,19 @@ function $ParseProvider() {
$parseOptions.csp = $sniffer.csp;
return function(exp) {
- var parsedExpression;
+ var parsedExpression,
+ oneTime;
switch (typeof exp) {
case 'string':
+ if (exp.charAt(0) === ':' && exp.charAt(1) === ':') {
+ oneTime = true;
+ exp = exp.substring(2);
+ }
+
if (cache.hasOwnProperty(exp)) {
- return cache[exp];
+ return oneTime ? oneTimeWrapper(cache[exp]) : cache[exp];
}
var lexer = new Lexer($parseOptions);
@@ -1037,7 +1043,11 @@ function $ParseProvider() {
cache[exp] = parsedExpression;
}
- return parsedExpression;
+ if (parsedExpression.constant) {
+ parsedExpression.$$unwatch = true;
+ }
+
+ return oneTime ? oneTimeWrapper(parsedExpression) : parsedExpression;
case 'function':
return exp;
@@ -1045,6 +1055,31 @@ function $ParseProvider() {
default:
return noop;
}
+
+ function oneTimeWrapper(expression) {
+ var stable = false,
+ lastValue;
+ oneTimeParseFn.literal = expression.literal;
+ oneTimeParseFn.constant = expression.constant;
+ oneTimeParseFn.assign = expression.assign;
+ return oneTimeParseFn;
+
+ function oneTimeParseFn(self, locals) {
+ if (!stable) {
+ lastValue = expression(self, locals);
+ oneTimeParseFn.$$unwatch = isDefined(lastValue);
+ if (oneTimeParseFn.$$unwatch && self && self.$$postDigestQueue) {
+ self.$$postDigestQueue.push(function () {
+ // create a copy if the value is defined and it is not a $sce value
+ if ((stable = isDefined(lastValue)) && !lastValue.$$unwrapTrustedValue) {
+ lastValue = copy(lastValue);
+ }
+ });
+ }
+ }
+ return lastValue;
+ }
+ }
};
}];
}
diff --git a/src/ng/rootScope.js b/src/ng/rootScope.js
index 9922e1380819..030e0b05c248 100644
--- a/src/ng/rootScope.js
+++ b/src/ng/rootScope.js
@@ -338,14 +338,6 @@ function $RootScopeProvider(){
watcher.fn = function(newVal, oldVal, scope) {listenFn(scope);};
}
- if (typeof watchExp == 'string' && get.constant) {
- var originalFn = watcher.fn;
- watcher.fn = function(newVal, oldVal, scope) {
- originalFn.call(this, newVal, oldVal, scope);
- arrayRemove(array, watcher);
- };
- }
-
if (!array) {
array = scope.$$watchers = [];
}
@@ -391,17 +383,28 @@ function $RootScopeProvider(){
var deregisterFns = [];
var changeCount = 0;
var self = this;
+ var unwatchFlags = new Array(watchExpressions.length);
+ var unwatchCount = watchExpressions.length;
forEach(watchExpressions, function (expr, i) {
- deregisterFns.push(self.$watch(expr, function (value, oldValue) {
+ var exprFn = $parse(expr);
+ deregisterFns.push(self.$watch(exprFn, function (value, oldValue) {
newValues[i] = value;
oldValues[i] = oldValue;
changeCount++;
+ if (unwatchFlags[i] && !exprFn.$$unwatch) unwatchCount++;
+ if (!unwatchFlags[i] && exprFn.$$unwatch) unwatchCount--;
+ unwatchFlags[i] = exprFn.$$unwatch;
}));
}, this);
- deregisterFns.push(self.$watch(function () {return changeCount;}, function () {
+ deregisterFns.push(self.$watch(watchGroupFn, function () {
listener(newValues, oldValues, self);
+ if (unwatchCount === 0) {
+ watchGroupFn.$$unwatch = true;
+ } else {
+ watchGroupFn.$$unwatch = false;
+ }
}));
return function deregisterWatchGroup() {
@@ -409,6 +412,8 @@ function $RootScopeProvider(){
fn();
});
};
+
+ function watchGroupFn() {return changeCount;}
},
@@ -553,6 +558,7 @@ function $RootScopeProvider(){
}
}
}
+ $watchCollectionWatch.$$unwatch = objGetter.$$unwatch;
return changeDetected;
}
@@ -644,6 +650,7 @@ function $RootScopeProvider(){
dirty, ttl = TTL,
next, current, target = this,
watchLog = [],
+ stableWatchesCandidates = [],
logIdx, logMsg, asyncTask;
beginPhase('$digest');
@@ -694,6 +701,7 @@ function $RootScopeProvider(){
logMsg += '; newVal: ' + toJson(value) + '; oldVal: ' + toJson(last);
watchLog[logIdx].push(logMsg);
}
+ if (watch.get.$$unwatch) stableWatchesCandidates.push({watch: watch, array: watchers});
} else if (watch === lastDirtyWatch) {
// If the most recently dirty watcher is now clean, short circuit since the remaining watchers
// have already been tested.
@@ -740,6 +748,13 @@ function $RootScopeProvider(){
$exceptionHandler(e);
}
}
+
+ for (length = stableWatchesCandidates.length - 1; length >= 0; --length) {
+ var candidate = stableWatchesCandidates[length];
+ if (candidate.watch.get.$$unwatch) {
+ arrayRemove(candidate.array, candidate.watch);
+ }
+ }
},
diff --git a/src/ng/sce.js b/src/ng/sce.js
index b863a3599c7d..d9b70ccf96b3 100644
--- a/src/ng/sce.js
+++ b/src/ng/sce.js
@@ -787,7 +787,9 @@ function $SceProvider() {
return parsed;
} else {
return function sceParseAsTrusted(self, locals) {
- return sce.getTrusted(type, parsed(self, locals));
+ var result = sce.getTrusted(type, parsed(self, locals));
+ sceParseAsTrusted.$$unwatch = parsed.$$unwatch;
+ return result;
};
}
};
diff --git a/test/ng/compileSpec.js b/test/ng/compileSpec.js
index 3f0b1be260b3..b5cb48ce9799 100755
--- a/test/ng/compileSpec.js
+++ b/test/ng/compileSpec.js
@@ -2180,6 +2180,23 @@ describe('$compile', function() {
);
+ it('should one-time bind if the expression starts with two colons', inject(
+ function($rootScope, $compile) {
+ $rootScope.name = 'angular';
+ element = $compile('
text: {{::name}}
')($rootScope);
+ expect($rootScope.$$watchers.length).toBe(2);
+ $rootScope.$digest();
+ expect(element.text()).toEqual('text: angular');
+ expect(element.attr('name')).toEqual('attr: angular');
+ expect($rootScope.$$watchers.length).toBe(0);
+ $rootScope.name = 'not-angular';
+ $rootScope.$digest();
+ expect(element.text()).toEqual('text: angular');
+ expect(element.attr('name')).toEqual('attr: angular');
+ })
+ );
+
+
it('should process attribute interpolation in pre-linking phase at priority 100', function() {
module(function() {
directive('attrLog', function(log) {
@@ -2792,6 +2809,83 @@ describe('$compile', function() {
});
+ it('should be possible to one-time bind a parameter on a component with a template', function() {
+ module(function() {
+ directive('otherTplDir', function() {
+ return {
+ scope: {param: '@', anotherParam: '=' },
+ template: 'value: {{param}}, another value {{anotherParam}}'
+ };
+ });
+ });
+
+ function countWatches(scope) {
+ var result = 0;
+ while (scope !== null) {
+ result += (scope.$$watchers && scope.$$watchers.length) || 0;
+ result += countWatches(scope.$$childHead);
+ scope = scope.$$nextSibling;
+ }
+ return result;
+ }
+
+ inject(function($rootScope) {
+ compile('');
+ expect(countWatches($rootScope)).toEqual(3);
+ $rootScope.$digest();
+ expect(element.html()).toBe('value: , another value ');
+ expect(countWatches($rootScope)).toEqual(3);
+
+ $rootScope.foo = 'from-parent';
+ $rootScope.$digest();
+ expect(element.html()).toBe('value: from-parent, another value ');
+ expect(countWatches($rootScope)).toEqual(2);
+
+ $rootScope.foo = 'not-from-parent';
+ $rootScope.bar = 'some value';
+ $rootScope.$digest();
+ expect(element.html()).toBe('value: from-parent, another value some value');
+ expect(countWatches($rootScope)).toEqual(1);
+
+ $rootScope.bar = 'some new value';
+ $rootScope.$digest();
+ expect(element.html()).toBe('value: from-parent, another value some value');
+ });
+ });
+
+
+ it('should be possible to one-time bind a parameter on a component with a templateUrl', function() {
+ module(function() {
+ directive('otherTplDir', function() {
+ return {
+ scope: {param: '@', anotherParam: '=' },
+ templateUrl: 'other.html'
+ };
+ });
+ });
+
+ inject(function($rootScope, $templateCache) {
+ $templateCache.put('other.html', 'value: {{param}}, another value {{anotherParam}}');
+ compile('');
+ $rootScope.$digest();
+ expect(element.html()).toBe('value: , another value ');
+
+ $rootScope.foo = 'from-parent';
+ $rootScope.$digest();
+ expect(element.html()).toBe('value: from-parent, another value ');
+
+ $rootScope.foo = 'not-from-parent';
+ $rootScope.bar = 'some value';
+ $rootScope.$digest();
+ expect(element.html()).toBe('value: from-parent, another value some value');
+
+ $rootScope.bar = 'some new value';
+ $rootScope.$digest();
+ expect(element.html()).toBe('value: from-parent, another value some value');
+ });
+ });
+
+
describe('attribute', function() {
it('should copy simple attribute', inject(function() {
compile('
');
diff --git a/test/ng/directive/ngBindSpec.js b/test/ng/directive/ngBindSpec.js
index 7af4c13f2b63..0029da5fec16 100644
--- a/test/ng/directive/ngBindSpec.js
+++ b/test/ng/directive/ngBindSpec.js
@@ -45,6 +45,43 @@ describe('ngBind*', function() {
$rootScope.$digest();
expect(element.text()).toEqual('-0false');
}));
+
+ it('should one-time bind if the expression starts with two colons', inject(function($rootScope, $compile) {
+ element = $compile('')($rootScope);
+ $rootScope.a = 'lucas';
+ expect($rootScope.$$watchers.length).toEqual(1);
+ $rootScope.$digest();
+ expect(element.text()).toEqual('lucas');
+ expect($rootScope.$$watchers.length).toEqual(0);
+ $rootScope.a = undefined;
+ $rootScope.$digest();
+ expect(element.text()).toEqual('lucas');
+ }));
+
+ it('should be possible to bind to a new value within the same $digest', inject(function($rootScope, $compile) {
+ element = $compile('')($rootScope);
+ $rootScope.$watch('a', function(newVal) { if (newVal === 'foo') { $rootScope.a = 'bar'; } });
+ $rootScope.a = 'foo';
+ $rootScope.$digest();
+ expect(element.text()).toEqual('bar');
+ $rootScope.a = undefined;
+ $rootScope.$digest();
+ expect(element.text()).toEqual('bar');
+ }));
+
+ it('should remove the binding if the value is defined at the end of a $digest loop', inject(function($rootScope, $compile) {
+ element = $compile('')($rootScope);
+ $rootScope.$watch('a', function(newVal) { if (newVal === 'foo') { $rootScope.a = undefined; } });
+ $rootScope.a = 'foo';
+ $rootScope.$digest();
+ expect(element.text()).toEqual('');
+ $rootScope.a = 'bar';
+ $rootScope.$digest();
+ expect(element.text()).toEqual('bar');
+ $rootScope.a = 'man';
+ $rootScope.$digest();
+ expect(element.text()).toEqual('bar');
+ }));
});
@@ -59,6 +96,22 @@ describe('ngBind*', function() {
}));
+ it('should one-time bind the expressions that start with ::', inject(function($rootScope, $compile) {
+ element = $compile('')($rootScope);
+ $rootScope.name = 'Misko';
+ expect($rootScope.$$watchers.length).toEqual(1);
+ $rootScope.$digest();
+ expect(element.hasClass('ng-binding')).toEqual(true);
+ expect(element.text()).toEqual(' Misko!');
+ expect($rootScope.$$watchers.length).toEqual(1);
+ $rootScope.hello = 'Hello';
+ $rootScope.name = 'Lucas';
+ $rootScope.$digest();
+ expect(element.text()).toEqual('Hello Misko!');
+ expect($rootScope.$$watchers.length).toEqual(0);
+ }));
+
+
it('should render object as JSON ignore $$', inject(function($rootScope, $compile) {
element = $compile('
');
}));
+
+ it('should one-time bind if the expression starts with two colons', inject(function($rootScope, $compile) {
+ element = $compile('')($rootScope);
+ $rootScope.html = '