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 + +``` + 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('
{{ {key:"value", $$key:"hide"}  }}
')($rootScope); $rootScope.$digest(); @@ -79,6 +132,18 @@ describe('ngBind*', function() { $rootScope.$digest(); expect(angular.lowercase(element.html())).toEqual('
hello
'); })); + + it('should one-time bind if the expression starts with two colons', inject(function($rootScope, $compile) { + element = $compile('
')($rootScope); + $rootScope.html = '
hello
'; + expect($rootScope.$$watchers.length).toEqual(1); + $rootScope.$digest(); + expect(element.text()).toEqual('hello'); + expect($rootScope.$$watchers.length).toEqual(0); + $rootScope.html = '
hello
'; + $rootScope.$digest(); + expect(element.text()).toEqual('hello'); + })); }); diff --git a/test/ng/directive/ngRepeatSpec.js b/test/ng/directive/ngRepeatSpec.js index e267e0e9b029..0cc847e66634 100644 --- a/test/ng/directive/ngRepeatSpec.js +++ b/test/ng/directive/ngRepeatSpec.js @@ -58,6 +58,42 @@ describe('ngRepeat', function() { expect(element.text()).toEqual('shyam;'); }); + it('should be possible to use one-time bindings on the collection', function() { + element = $compile( + '')(scope); + + scope.$digest(); + + scope.items = [{name: 'misko'}, {name:'shyam'}]; + scope.$digest(); + expect(element.find('li').length).toEqual(2); + expect(element.text()).toEqual('misko;shyam;'); + scope.items.push({name: 'adam'}); + scope.$digest(); + expect(element.find('li').length).toEqual(2); + expect(element.text()).toEqual('misko;shyam;'); + }); + + it('should be possible to use one-time bindings on the content', function() { + element = $compile( + '')(scope); + + scope.$digest(); + + scope.items = [{name: 'misko'}, {name:'shyam'}]; + scope.$digest(); + expect(element.find('li').length).toEqual(2); + expect(element.text()).toEqual('misko;shyam;'); + scope.items.push({name: 'adam'}); + scope.$digest(); + expect(element.find('li').length).toEqual(3); + expect(element.text()).toEqual('misko;shyam;adam;'); + }); + it('should iterate over an array-like object', function() { element = $compile( diff --git a/test/ng/parseSpec.js b/test/ng/parseSpec.js index 154d2bc23ca5..b1c8b5a1253d 100644 --- a/test/ng/parseSpec.js +++ b/test/ng/parseSpec.js @@ -760,9 +760,6 @@ describe('parser', function() { 'disallowed! Expression: wrap["d"]'); })); - it('should NOT allow access to the Window or DOM returned from a function', inject(function($window, $document) { - scope.getWin = valueFn($window); - scope.getDoc = valueFn($document); expect(function() { scope.$eval('getWin()', scope); @@ -953,6 +950,69 @@ describe('parser', function() { }); + describe('one-time binding', function() { + it('should only use the cache when it is not a one-time binding', inject(function($parse) { + expect($parse('foo')).toBe($parse('foo')); + expect($parse('::foo')).not.toBe($parse('::foo')); + })); + + it('should stay stable once the value defined', inject(function($parse, $rootScope) { + var fn = $parse('::foo'); + expect(fn.$$unwatch).not.toBe(true); + $rootScope.$watch(fn); + + $rootScope.$digest(); + expect(fn.$$unwatch).not.toBe(true); + + $rootScope.foo = 'bar'; + $rootScope.$digest(); + expect(fn.$$unwatch).toBe(true); + expect(fn($rootScope)).toBe('bar'); + expect(fn()).toBe('bar'); + + $rootScope.foo = 'man'; + $rootScope.$digest(); + expect(fn.$$unwatch).toBe(true); + expect(fn($rootScope)).toBe('bar'); + expect(fn()).toBe('bar'); + })); + + it('should have a stable value if at the end of a $digest it has a defined value', inject(function($parse, $rootScope) { + var fn = $parse('::foo'); + $rootScope.$watch(fn); + $rootScope.$watch('foo', function() { if ($rootScope.foo === 'bar') {$rootScope.foo = undefined; } }); + + $rootScope.foo = 'bar'; + $rootScope.$digest(); + expect(fn.$$unwatch).toBe(false); + + $rootScope.foo = 'man'; + $rootScope.$digest(); + expect(fn.$$unwatch).toBe(true); + expect(fn($rootScope)).toBe('man'); + expect(fn()).toBe('man'); + + $rootScope.foo = 'shell'; + $rootScope.$digest(); + expect(fn.$$unwatch).toBe(true); + expect(fn($rootScope)).toBe('man'); + expect(fn()).toBe('man'); + })); + + it('should keep a copy of the stable element', inject(function($parse, $rootScope) { + var fn = $parse('::foo'), + value = {bar: 'bar'}; + $rootScope.$watch(fn); + $rootScope.foo = value; + $rootScope.$digest(); + + value.baz = 'baz'; + expect(fn()).toEqual({bar: 'bar'}); + + })); + }); + + describe('locals', function() { it('should expose local variables', inject(function($parse) { expect($parse('a')({a: 0}, {a: 1})).toEqual(1); diff --git a/test/ng/rootScopeSpec.js b/test/ng/rootScopeSpec.js index c7717dc57469..1007e9760f28 100644 --- a/test/ng/rootScopeSpec.js +++ b/test/ng/rootScopeSpec.js @@ -114,6 +114,45 @@ describe('Scope', function() { expect($rootScope.$$watchers.length).toEqual(0); })); + it('should clean up stable watches on the watch queue', inject(function($rootScope, $parse) { + $rootScope.$watch($parse('::foo'), function() {}); + expect($rootScope.$$watchers.length).toEqual(1); + + $rootScope.$digest(); + expect($rootScope.$$watchers.length).toEqual(1); + + $rootScope.foo = 'foo'; + $rootScope.$digest(); + expect($rootScope.$$watchers.length).toEqual(0); + })); + + it('should claen up stable watches from $watchCollection', inject(function($rootScope, $parse) { + $rootScope.$watchCollection('::foo', function() {}); + expect($rootScope.$$watchers.length).toEqual(1); + + $rootScope.$digest(); + expect($rootScope.$$watchers.length).toEqual(1); + + $rootScope.foo = []; + $rootScope.$digest(); + expect($rootScope.$$watchers.length).toEqual(0); + })); + + it('should clean up stable watches from $watchGroup', inject(function($rootScope, $parse) { + $rootScope.$watchGroup(['::foo', '::bar'], function() {}); + expect($rootScope.$$watchers.length).toEqual(3); + + $rootScope.$digest(); + expect($rootScope.$$watchers.length).toEqual(3); + + $rootScope.foo = 'foo'; + $rootScope.$digest(); + expect($rootScope.$$watchers.length).toEqual(2); + + $rootScope.bar = 'bar'; + $rootScope.$digest(); + expect($rootScope.$$watchers.length).toEqual(0); + })); it('should delegate exceptions', function() { module(function($exceptionHandlerProvider) { diff --git a/test/ng/sceSpecs.js b/test/ng/sceSpecs.js index dfb260f24742..d3f00d9a010b 100644 --- a/test/ng/sceSpecs.js +++ b/test/ng/sceSpecs.js @@ -209,6 +209,15 @@ describe('SCE', function() { expect($sce.parseAsJs('"string"')()).toBe("string"); })); + it('should be possible to do one-time binding', inject(function($sce, $rootScope) { + var exprFn = $sce.parseAsHtml('::foo'); + expect(exprFn($rootScope, {'foo': $sce.trustAs($sce.HTML, 'trustedValue')})).toBe('trustedValue'); + expect(exprFn($rootScope, {'foo': $sce.trustAs($sce.HTML, 'anotherTrustedValue')})).toBe('anotherTrustedValue'); + $rootScope.$digest(); + expect(exprFn($rootScope, {'foo': $sce.trustAs($sce.HTML, 'somethingElse')})).toBe('anotherTrustedValue'); + expect(exprFn.$$unwatch).toBe(true); + })); + it('should NOT parse constant non-literals', inject(function($sce) { // Until there's a real world use case for this, we're disallowing // constant non-literals. See $SceParseProvider.