From bcf72cacb5afcbeb6d2deb856b7f6e2f19285a24 Mon Sep 17 00:00:00 2001 From: rodyhaddad Date: Mon, 2 Jun 2014 10:17:36 -0700 Subject: [PATCH 1/2] revert: feat(*): lazy one-time binding support This reverts commit cee429f0aaebf32ef1c9aedd8447a48f163dd0a4. See #7700 for a more performant approach for bind-once. --- docs/content/guide/expression.ngdoc | 119 ---------------------------- src/ng/compile.js | 4 - src/ng/directive/ngBind.js | 6 +- src/ng/interpolate.js | 3 - src/ng/parse.js | 41 +--------- src/ng/rootScope.js | 35 +++----- src/ng/sce.js | 4 +- test/ng/compileSpec.js | 110 ------------------------- test/ng/directive/ngBindSpec.js | 65 --------------- test/ng/directive/ngRepeatSpec.js | 36 --------- test/ng/parseSpec.js | 75 ------------------ test/ng/rootScopeSpec.js | 49 ------------ test/ng/sceSpecs.js | 9 --- 13 files changed, 15 insertions(+), 541 deletions(-) diff --git a/docs/content/guide/expression.ngdoc b/docs/content/guide/expression.ngdoc index 1650a8286021..0d33647f3fe3 100644 --- a/docs/content/guide/expression.ngdoc +++ b/docs/content/guide/expression.ngdoc @@ -202,122 +202,3 @@ 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 ccf8aba56253..74d4c8b4b869 100644 --- a/src/ng/compile.js +++ b/src/ng/compile.js @@ -1517,7 +1517,6 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { parentSet(scope, parentValue = isolateScope[scopeName]); } } - parentValueWatch.$$unwatch = parentGet.$$unwatch; return lastValue = parentValue; }, null, parentGet.literal); break; @@ -1855,9 +1854,6 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { return 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); parent.data('$binding', bindings); if (!hasCompileParent) safeAddClass(parent, 'ng-binding'); diff --git a/src/ng/directive/ngBind.js b/src/ng/directive/ngBind.js index 794d84df8ecb..2e7c7ffa9e0f 100644 --- a/src/ng/directive/ngBind.js +++ b/src/ng/directive/ngBind.js @@ -181,11 +181,7 @@ var ngBindHtmlDirective = ['$sce', '$parse', function($sce, $parse) { element.addClass('ng-binding').data('$binding', attr.ngBindHtml); var parsed = $parse(attr.ngBindHtml); - function getStringValue() { - var value = parsed(scope); - getStringValue.$$unwatch = parsed.$$unwatch; - return (value || '').toString(); - } + function getStringValue() { return (parsed(scope) || '').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 830c548277c2..fac06e644af8 100644 --- a/src/ng/interpolate.js +++ b/src/ng/interpolate.js @@ -309,11 +309,9 @@ 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); @@ -321,7 +319,6 @@ 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 ba854dc1798d..da5a6e58ce17 100644 --- a/src/ng/parse.js +++ b/src/ng/parse.js @@ -992,21 +992,13 @@ function $ParseProvider() { $parseOptions.csp = $sniffer.csp; return function(exp) { - var parsedExpression, - oneTime; + var parsedExpression; switch (typeof exp) { case 'string': - exp = trim(exp); - - if (exp.charAt(0) === ':' && exp.charAt(1) === ':') { - oneTime = true; - exp = exp.substring(2); - } - if (cache.hasOwnProperty(exp)) { - return oneTime ? oneTimeWrapper(cache[exp]) : cache[exp]; + return cache[exp]; } var lexer = new Lexer($parseOptions); @@ -1019,7 +1011,7 @@ function $ParseProvider() { cache[exp] = parsedExpression; } - return oneTime || parsedExpression.constant ? oneTimeWrapper(parsedExpression) : parsedExpression; + return parsedExpression; case 'function': return exp; @@ -1027,32 +1019,5 @@ 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.constant && lastValue ? 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 === null || !lastValue.$$unwrapTrustedValue)) { - lastValue = copy(lastValue, null); - } - }); - } - } - return lastValue; - } - } - }; }]; } diff --git a/src/ng/rootScope.js b/src/ng/rootScope.js index 23990427a63b..cc9ea74a7a65 100644 --- a/src/ng/rootScope.js +++ b/src/ng/rootScope.js @@ -344,6 +344,14 @@ function $RootScopeProvider(){ watcher.fn = noop; } + 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 = []; } @@ -389,28 +397,17 @@ 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) { - var exprFn = $parse(expr); - deregisterFns.push(self.$watch(exprFn, function (value, oldValue) { + deregisterFns.push(self.$watch(expr, 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(watchGroupFn, function () { + deregisterFns.push(self.$watch(function () {return changeCount;}, function () { listener(newValues, oldValues, self); - if (unwatchCount === 0) { - watchGroupFn.$$unwatch = true; - } else { - watchGroupFn.$$unwatch = false; - } })); return function deregisterWatchGroup() { @@ -418,8 +415,6 @@ function $RootScopeProvider(){ fn(); }); }; - - function watchGroupFn() {return changeCount;} }, @@ -566,7 +561,6 @@ function $RootScopeProvider(){ } } } - $watchCollectionWatch.$$unwatch = objGetter.$$unwatch; return changeDetected; } @@ -662,7 +656,6 @@ function $RootScopeProvider(){ dirty, ttl = TTL, next, current, target = this, watchLog = [], - stableWatchesCandidates = [], logIdx, logMsg, asyncTask; beginPhase('$digest'); @@ -713,7 +706,6 @@ 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. @@ -760,13 +752,6 @@ 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 3b1e8ae72802..60de09c86611 100644 --- a/src/ng/sce.js +++ b/src/ng/sce.js @@ -788,9 +788,7 @@ function $SceProvider() { return parsed; } else { return function sceParseAsTrusted(self, locals) { - var result = sce.getTrusted(type, parsed(self, locals)); - sceParseAsTrusted.$$unwatch = parsed.$$unwatch; - return result; + return sce.getTrusted(type, parsed(self, locals)); }; } }; diff --git a/test/ng/compileSpec.js b/test/ng/compileSpec.js index b0de0f63a831..e7b0dffa310c 100755 --- a/test/ng/compileSpec.js +++ b/test/ng/compileSpec.js @@ -2201,39 +2201,6 @@ 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 one-time bind if the expression starts with a space and 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) { @@ -2846,83 +2813,6 @@ 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 0029da5fec16..7af4c13f2b63 100644 --- a/test/ng/directive/ngBindSpec.js +++ b/test/ng/directive/ngBindSpec.js @@ -45,43 +45,6 @@ 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'); - })); }); @@ -96,22 +59,6 @@ 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(); @@ -132,18 +79,6 @@ 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 90499ef12eb6..8b6ec970d03f 100644 --- a/test/ng/directive/ngRepeatSpec.js +++ b/test/ng/directive/ngRepeatSpec.js @@ -58,42 +58,6 @@ describe('ngRepeat', function() { expect(element.text()).toEqual('shyam;'); }); - it('should be possible to use one-time bindings on the collection', function() { - element = $compile( - '
    ' + - '
  • {{item.name}};
  • ' + - '
')(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( - '
    ' + - '
  • {{::item.name}};
  • ' + - '
')(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 ec8aed4faa41..bd02af8da512 100644 --- a/test/ng/parseSpec.js +++ b/test/ng/parseSpec.js @@ -780,7 +780,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); @@ -1079,80 +1078,6 @@ 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'}); - })); - - it('should not throw if the stable value is `null`', inject(function($parse, $rootScope) { - var fn = $parse('::foo'); - $rootScope.$watch(fn); - $rootScope.foo = null; - $rootScope.$digest(); - $rootScope.foo = 'foo'; - $rootScope.$digest(); - expect(fn()).toEqual(null); - })); - - }); - - 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 bc0ca80b4f2d..906137ea45ee 100644 --- a/test/ng/rootScopeSpec.js +++ b/test/ng/rootScopeSpec.js @@ -114,55 +114,6 @@ describe('Scope', function() { expect($rootScope.$$watchers.length).toEqual(0); })); - it('should not keep constant literals on the watch queue', inject(function($rootScope) { - $rootScope.$watch('[]', function() {}); - $rootScope.$watch('{}', function() {}); - expect($rootScope.$$watchers.length).toEqual(2); - $rootScope.$digest(); - - 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) { $exceptionHandlerProvider.mode('log'); diff --git a/test/ng/sceSpecs.js b/test/ng/sceSpecs.js index d3f00d9a010b..dfb260f24742 100644 --- a/test/ng/sceSpecs.js +++ b/test/ng/sceSpecs.js @@ -209,15 +209,6 @@ 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. From 86d55c1ded21a5be6091344493d70c6dc4194e43 Mon Sep 17 00:00:00 2001 From: rodyhaddad Date: Wed, 4 Jun 2014 13:49:23 -0700 Subject: [PATCH 2/2] perf(*): more performant interpolation and lazy one-time binding BEAKING CHANGE: Lazy-binding now happens on the scope watcher level. What this means is that given `parseFn = $parse('::foo')`, bind-once will only kick in when `parseFn` is being watched by a scope (i.e. `scope.$watch(parseFn)`) Bind-once will have no effect when directily invoking `parseFn` (i.e. `parseFn()`) --- docs/content/guide/expression.ngdoc | 119 ++++++++++++++++++ src/ng/compile.js | 6 +- src/ng/directive/ngBind.js | 8 +- src/ng/interpolate.js | 74 +++++------ src/ng/parse.js | 91 +++++++++++--- src/ng/rootScope.js | 65 ++++++---- src/ng/sce.js | 6 +- test/ng/compileSpec.js | 184 ++++++++++++++++++++++++++++ test/ng/directive/ngBindSpec.js | 65 ++++++++++ test/ng/directive/ngRepeatSpec.js | 36 ++++++ test/ng/parseSpec.js | 82 +++++++++++++ test/ng/rootScopeSpec.js | 78 ++++++++++++ test/ng/sceSpecs.js | 25 ++++ 13 files changed, 745 insertions(+), 94 deletions(-) diff --git a/docs/content/guide/expression.ngdoc b/docs/content/guide/expression.ngdoc index 0d33647f3fe3..1650a8286021 100644 --- a/docs/content/guide/expression.ngdoc +++ b/docs/content/guide/expression.ngdoc @@ -202,3 +202,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 74d4c8b4b869..eb7b84018217 100644 --- a/src/ng/compile.js +++ b/src/ng/compile.js @@ -1505,8 +1505,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { attrs[attrName], newIsolateScopeDirective.name); }; lastValue = isolateScope[scopeName] = parentGet(scope); - isolateScope.$watch(function parentValueWatch() { - var parentValue = parentGet(scope); + var unwatch = scope.$watch($parse(attrs[attrName], function parentValueWatch(parentValue) { if (!compare(parentValue, isolateScope[scopeName])) { // we are out of sync and need to copy if (!compare(parentValue, lastValue)) { @@ -1518,7 +1517,8 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } } return lastValue = parentValue; - }, null, parentGet.literal); + }), null, parentGet.literal); + isolateScope.$on('$destroy', unwatch); break; case '&': diff --git a/src/ng/directive/ngBind.js b/src/ng/directive/ngBind.js index 2e7c7ffa9e0f..88db9ef1ca61 100644 --- a/src/ng/directive/ngBind.js +++ b/src/ng/directive/ngBind.js @@ -181,9 +181,13 @@ 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(); } + var changeDetector = $parse(attr.ngBindHtml, function getStringValue(value) { + return (value || '').toString(); + }); - scope.$watch(getStringValue, function ngBindHtmlWatchAction(value) { + scope.$watch(changeDetector, function ngBindHtmlWatchAction() { + // we re-evaluate the expr because we want a TrustedValueHolderType + // for $sce, not a string element.html($sce.getTrustedHtml(parsed(scope)) || ''); }); }; diff --git a/src/ng/interpolate.js b/src/ng/interpolate.js index fac06e644af8..2f0a949f6d81 100644 --- a/src/ng/interpolate.js +++ b/src/ng/interpolate.js @@ -195,8 +195,7 @@ function $InterpolateProvider() { hasInterpolation = false, hasText = false, exp, - concat = [], - lastValuesCache = { values: {}, results: {}}; + concat = []; while(index < textLength) { if ( ((startIndex = text.indexOf(startSymbol, index)) != -1) && @@ -205,7 +204,7 @@ function $InterpolateProvider() { separators.push(text.substring(index, startIndex)); exp = text.substring(startIndex + startSymbolLength, endIndex); expressions.push(exp); - parseFns.push($parse(exp)); + parseFns.push($parse(exp, parseStringifyInterceptor)); index = endIndex + endSymbolLength; hasInterpolation = true; } else { @@ -246,6 +245,7 @@ function $InterpolateProvider() { var compute = function(values) { for(var i = 0, ii = expressions.length; i < ii; i++) { + if (allOrNothing && isUndefined(values[i])) return; concat[2*i] = separators[i]; concat[(2*i)+1] = values[i]; } @@ -254,13 +254,9 @@ function $InterpolateProvider() { }; var getValue = function (value) { - if (trustedContext) { - value = $sce.getTrusted(trustedContext, value); - } else { - value = $sce.valueOf(value); - } - - return value; + return trustedContext ? + $sce.getTrusted(trustedContext, value) : + $sce.valueOf(value); }; var stringify = function (value) { @@ -284,61 +280,49 @@ function $InterpolateProvider() { }; return extend(function interpolationFn(context) { - var scopeId = (context && context.$id) || 'notAScope'; - var lastValues = lastValuesCache.values[scopeId]; - var lastResult = lastValuesCache.results[scopeId]; var i = 0; var ii = expressions.length; var values = new Array(ii); - var val; - var inputsChanged = lastResult === undefined ? true: false; - - - // if we haven't seen this context before, initialize the cache and try to setup - // a cleanup routine that purges the cache when the scope goes away. - if (!lastValues) { - lastValues = []; - inputsChanged = true; - if (context && context.$on) { - context.$on('$destroy', function() { - lastValuesCache.values[scopeId] = null; - lastValuesCache.results[scopeId] = null; - }); - } - } - try { for (; i < ii; i++) { - val = getValue(parseFns[i](context)); - if (allOrNothing && isUndefined(val)) { - return; - } - val = stringify(val); - if (val !== lastValues[i]) { - inputsChanged = true; - } - values[i] = val; + values[i] = parseFns[i](context); } - if (inputsChanged) { - lastValuesCache.values[scopeId] = values; - lastValuesCache.results[scopeId] = lastResult = compute(values); - } + return compute(values); } catch(err) { var newErr = $interpolateMinErr('interr', "Can't interpolate: {0}\n{1}", text, err.toString()); $exceptionHandler(newErr); } - return lastResult; }, { // all of these properties are undocumented for now exp: text, //just for compatibility with regular watchers created via $watch separators: separators, - expressions: expressions + expressions: expressions, + $$watchDelegate: function (scope, listener, objectEquality, deregisterNotifier) { + var lastValue; + return scope.$watchGroup(parseFns, function interpolateFnWatcher(values, oldValues) { + var currValue = compute(values); + if (isFunction(listener)) { + listener.call(this, currValue, values !== oldValues ? lastValue : currValue, scope); + } + lastValue = currValue; + }, objectEquality, deregisterNotifier); + } }); } + + function parseStringifyInterceptor(value) { + try { + return stringify(getValue(value)); + } catch(err) { + var newErr = $interpolateMinErr('interr', "Can't interpolate: {0}\n{1}", text, + err.toString()); + $exceptionHandler(newErr); + } + } } diff --git a/src/ng/parse.js b/src/ng/parse.js index da5a6e58ce17..dbebc5b43ea9 100644 --- a/src/ng/parse.js +++ b/src/ng/parse.js @@ -991,33 +991,88 @@ function $ParseProvider() { this.$get = ['$filter', '$sniffer', function($filter, $sniffer) { $parseOptions.csp = $sniffer.csp; - return function(exp) { - var parsedExpression; + return function(exp, interceptorFn) { + var parsedExpression, oneTime, + cacheKey = (exp = trim(exp)); switch (typeof exp) { case 'string': - - if (cache.hasOwnProperty(exp)) { - return cache[exp]; + if (cache.hasOwnProperty(cacheKey)) { + parsedExpression = cache[cacheKey]; + } else { + if (exp.charAt(0) === ':' && exp.charAt(1) === ':') { + oneTime = true; + exp = exp.substring(2); + } + + var lexer = new Lexer($parseOptions); + var parser = new Parser(lexer, $filter, $parseOptions); + parsedExpression = parser.parse(exp); + + if (parsedExpression.constant) parsedExpression.$$watchDelegate = constantWatch; + else if (oneTime) parsedExpression.$$watchDelegate = oneTimeWatch; + + if (cacheKey !== 'hasOwnProperty') { + // Only cache the value if it's not going to mess up the cache object + // This is more performant that using Object.prototype.hasOwnProperty.call + cache[cacheKey] = parsedExpression; + } } + return addInterceptor(parsedExpression, interceptorFn); - var lexer = new Lexer($parseOptions); - var parser = new Parser(lexer, $filter, $parseOptions); - parsedExpression = parser.parse(exp); + case 'function': + return addInterceptor(exp, interceptorFn); - if (exp !== 'hasOwnProperty') { - // Only cache the value if it's not going to mess up the cache object - // This is more performant that using Object.prototype.hasOwnProperty.call - cache[exp] = parsedExpression; - } + default: + return addInterceptor(noop, interceptorFn); + } + }; - return parsedExpression; + function oneTimeWatch(scope, listener, objectEquality, deregisterNotifier, parsedExpression) { + var unwatch, lastValue; + return unwatch = scope.$watch(function oneTimeWatch(scope) { + return parsedExpression(scope); + }, function oneTimeListener(value, old, scope) { + lastValue = value; + if (isFunction(listener)) { + listener.apply(this, arguments); + } + if (isDefined(value)) { + scope.$$postDigest(function () { + if (isDefined(lastValue)) { + unwatch(); + } + }); + } + }, objectEquality, deregisterNotifier); + } - case 'function': - return exp; + function constantWatch(scope, listener, objectEquality, deregisterNotifier, parsedExpression) { + var unwatch; + return unwatch = scope.$watch(function constantWatch(scope) { + return parsedExpression(scope); + }, function constantListener(value, old, scope) { + if (isFunction(listener)) { + listener.apply(this, arguments); + } + unwatch(); + }, objectEquality, deregisterNotifier); + } - default: - return noop; + function addInterceptor(parsedExpression, interceptorFn) { + if (isFunction(interceptorFn)) { + var fn = function interceptedExpression(scope, locals) { + var value = parsedExpression(scope, locals); + var result = interceptorFn(value, scope, locals); + // we only return the interceptor's result if the + // initial value is defined (for bind-once) + return isDefined(value) ? result : value; + }; + fn.$$watchDelegate = parsedExpression.$$watchDelegate; + return fn; + } else { + return parsedExpression; } + } }]; } diff --git a/src/ng/rootScope.js b/src/ng/rootScope.js index cc9ea74a7a65..044a37cbbbfe 100644 --- a/src/ng/rootScope.js +++ b/src/ng/rootScope.js @@ -324,11 +324,17 @@ function $RootScopeProvider(){ * * @param {boolean=} objectEquality Compare for object equality using {@link angular.equals} instead of * comparing for reference equality. + * @param {function()=} deregisterNotifier Function to call when the deregistration function + * get called. * @returns {function()} Returns a deregistration function for this listener. */ - $watch: function(watchExp, listener, objectEquality) { + $watch: function(watchExp, listener, objectEquality, deregisterNotifier) { + var get = compileToFn(watchExp, 'watch'); + + if (get.$$watchDelegate) { + return get.$$watchDelegate(this, listener, objectEquality, deregisterNotifier, get); + } var scope = this, - get = compileToFn(watchExp, 'watch'), array = scope.$$watchers, watcher = { fn: listener, @@ -344,14 +350,6 @@ function $RootScopeProvider(){ watcher.fn = noop; } - 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 = []; } @@ -362,6 +360,9 @@ function $RootScopeProvider(){ return function deregisterWatch() { arrayRemove(array, watcher); lastDirtyWatch = null; + if (isFunction(deregisterNotifier)) { + deregisterNotifier(); + } }; }, @@ -388,7 +389,6 @@ function $RootScopeProvider(){ * and the `oldValues` array contains the previous values of the `watchExpressions`, with the indexes matching * those of `watchExpression` * The `scope` refers to the current scope. - * * @returns {function()} Returns a de-registration function for all listeners. */ $watchGroup: function(watchExpressions, listener) { @@ -397,23 +397,42 @@ function $RootScopeProvider(){ var deregisterFns = []; var changeCount = 0; var self = this; + var masterUnwatch; + + if (watchExpressions.length === 1) { + // Special case size of one + return this.$watch(watchExpressions[0], function watchGroupAction(value, oldValue, scope) { + newValues[0] = value; + oldValues[0] = oldValue; + listener.call(this, newValues, (value === oldValue) ? newValues : oldValues, scope); + }); + } forEach(watchExpressions, function (expr, i) { - deregisterFns.push(self.$watch(expr, function (value, oldValue) { + var unwatch = self.$watch(expr, function watchGroupSubAction(value, oldValue) { newValues[i] = value; oldValues[i] = oldValue; changeCount++; - })); + }, false, function watchGroupDeregNotifier() { + arrayRemove(deregisterFns, unwatch); + if (!deregisterFns.length) { + masterUnwatch(); + } + }); + + deregisterFns.push(unwatch); }, this); - deregisterFns.push(self.$watch(function () {return changeCount;}, function () { - listener(newValues, oldValues, self); - })); + masterUnwatch = self.$watch(function watchGroupChangeWatch() { + return changeCount; + }, function watchGroupChangeAction(value, oldValue) { + listener(newValues, (value === oldValue) ? newValues : oldValues, self); + }); return function deregisterWatchGroup() { - forEach(deregisterFns, function (fn) { - fn(); - }); + while (deregisterFns.length) { + deregisterFns[0](); + } }; }, @@ -485,14 +504,14 @@ function $RootScopeProvider(){ // only track veryOldValue if the listener is asking for it var trackVeryOldValue = (listener.length > 1); var changeDetected = 0; - var objGetter = $parse(obj); + var changeDetector = $parse(obj, $watchCollectionInterceptor); var internalArray = []; var internalObject = {}; var initRun = true; var oldLength = 0; - function $watchCollectionWatch() { - newValue = objGetter(self); + function $watchCollectionInterceptor(_value) { + newValue = _value; var newLength, key, bothNaN; if (!isObject(newValue)) { // if primitive @@ -593,7 +612,7 @@ function $RootScopeProvider(){ } } - return this.$watch($watchCollectionWatch, $watchCollectionAction); + return this.$watch(changeDetector, $watchCollectionAction); }, /** diff --git a/src/ng/sce.js b/src/ng/sce.js index 60de09c86611..4a002f941740 100644 --- a/src/ng/sce.js +++ b/src/ng/sce.js @@ -787,9 +787,9 @@ function $SceProvider() { if (parsed.literal && parsed.constant) { return parsed; } else { - return function sceParseAsTrusted(self, locals) { - return sce.getTrusted(type, parsed(self, locals)); - }; + return $parse(expr, function (value) { + return sce.getTrusted(type, value); + }); } }; diff --git a/test/ng/compileSpec.js b/test/ng/compileSpec.js index e7b0dffa310c..d22b52f075be 100755 --- a/test/ng/compileSpec.js +++ b/test/ng/compileSpec.js @@ -2201,6 +2201,39 @@ 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 one-time bind if the expression starts with a space and 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) { @@ -2813,6 +2846,157 @@ describe('$compile', function() { }); + describe('bind-once', function () { + + 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; + } + + it('should be possible to one-time bind a parameter on a component with a template', function() { + module(function() { + directive('otherTplDir', function() { + return { + scope: {param1: '=', param2: '='}, + template: '1:{{param1}};2:{{param2}};3:{{::param1}};4:{{::param2}}' + }; + }); + }); + + inject(function($rootScope) { + compile('
'); + expect(countWatches($rootScope)).toEqual(7); // 5 -> template watch group, 2 -> '=' + $rootScope.$digest(); + expect(element.html()).toBe('1:;2:;3:;4:'); + expect(countWatches($rootScope)).toEqual(7); + + $rootScope.foo = 'foo'; + $rootScope.$digest(); + expect(element.html()).toBe('1:foo;2:;3:foo;4:'); + expect(countWatches($rootScope)).toEqual(5); + + $rootScope.foo = 'baz'; + $rootScope.bar = 'bar'; + $rootScope.$digest(); + expect(element.html()).toBe('1:foo;2:bar;3:foo;4:bar'); + expect(countWatches($rootScope)).toEqual(4); + + $rootScope.bar = 'baz'; + $rootScope.$digest(); + expect(element.html()).toBe('1:foo;2:baz;3:foo;4:bar'); + }); + }); + + it('should be possible to one-time bind a parameter on a component with a template', function() { + module(function() { + directive('otherTplDir', function() { + return { + scope: {param1: '@', param2: '@'}, + template: '1:{{param1}};2:{{param2}};3:{{::param1}};4:{{::param2}}' + }; + }); + }); + + inject(function($rootScope) { + compile('
'); + expect(countWatches($rootScope)).toEqual(7); // 5 -> template watch group, 2 -> {{ }} + $rootScope.$digest(); + expect(element.html()).toBe('1:;2:;3:;4:'); + expect(countWatches($rootScope)).toEqual(5); // (- 2) -> bind-once in template + + $rootScope.foo = 'foo'; + $rootScope.$digest(); + expect(element.html()).toBe('1:foo;2:;3:;4:'); + expect(countWatches($rootScope)).toEqual(4); + + $rootScope.foo = 'baz'; + $rootScope.bar = 'bar'; + $rootScope.$digest(); + expect(element.html()).toBe('1:foo;2:bar;3:;4:'); + expect(countWatches($rootScope)).toEqual(4); + + $rootScope.bar = 'baz'; + $rootScope.$digest(); + expect(element.html()).toBe('1:foo;2:baz;3:;4:'); + }); + }); + + it('should be possible to one-time bind a parameter on a component with a template', function() { + module(function() { + directive('otherTplDir', function() { + return { + scope: {param1: '=', param2: '='}, + templateUrl: 'other.html' + }; + }); + }); + + inject(function($rootScope, $templateCache) { + $templateCache.put('other.html', '1:{{param1}};2:{{param2}};3:{{::param1}};4:{{::param2}}'); + compile('
'); + $rootScope.$digest(); + expect(element.html()).toBe('1:;2:;3:;4:'); + expect(countWatches($rootScope)).toEqual(7); // 5 -> template watch group, 2 -> '=' + + $rootScope.foo = 'foo'; + $rootScope.$digest(); + expect(element.html()).toBe('1:foo;2:;3:foo;4:'); + expect(countWatches($rootScope)).toEqual(5); + + $rootScope.foo = 'baz'; + $rootScope.bar = 'bar'; + $rootScope.$digest(); + expect(element.html()).toBe('1:foo;2:bar;3:foo;4:bar'); + expect(countWatches($rootScope)).toEqual(4); + + $rootScope.bar = 'baz'; + $rootScope.$digest(); + expect(element.html()).toBe('1:foo;2:baz;3:foo;4:bar'); + }); + }); + + it('should be possible to one-time bind a parameter on a component with a template', function() { + module(function() { + directive('otherTplDir', function() { + return { + scope: {param1: '@', param2: '@'}, + templateUrl: 'other.html' + }; + }); + }); + + inject(function($rootScope, $templateCache) { + $templateCache.put('other.html', '1:{{param1}};2:{{param2}};3:{{::param1}};4:{{::param2}}'); + compile('
'); + $rootScope.$digest(); + expect(element.html()).toBe('1:;2:;3:;4:'); + expect(countWatches($rootScope)).toEqual(5); // (5 - 2) -> template watch group, 2 -> {{ }} + + $rootScope.foo = 'foo'; + $rootScope.$digest(); + expect(element.html()).toBe('1:foo;2:;3:;4:'); + expect(countWatches($rootScope)).toEqual(4); + + $rootScope.foo = 'baz'; + $rootScope.bar = 'bar'; + $rootScope.$digest(); + expect(element.html()).toBe('1:foo;2:bar;3:;4:'); + expect(countWatches($rootScope)).toEqual(4); + + $rootScope.bar = 'baz'; + $rootScope.$digest(); + expect(element.html()).toBe('1:foo;2:baz;3:;4:'); + }); + }); + + }); + + 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..3635d0516d9e 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(3); + $rootScope.$digest(); + expect(element.hasClass('ng-binding')).toEqual(true); + expect(element.text()).toEqual(' Misko!'); + expect($rootScope.$$watchers.length).toEqual(2); + $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 8b6ec970d03f..90499ef12eb6 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( + '
    ' + + '
  • {{item.name}};
  • ' + + '
')(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( + '
    ' + + '
  • {{::item.name}};
  • ' + + '
')(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 bd02af8da512..327aee71a14d 100644 --- a/test/ng/parseSpec.js +++ b/test/ng/parseSpec.js @@ -215,6 +215,8 @@ describe('parser', function() { window.document.securityPolicy = originalSecurityPolicy; }); + beforeEach(module(provideLog)); + beforeEach(inject(function ($rootScope) { scope = $rootScope; })); @@ -1078,6 +1080,86 @@ describe('parser', function() { })); }); + describe('one-time binding', function() { + it('should always use the cache', inject(function($parse) { + expect($parse('foo')).toBe($parse('foo')); + expect($parse('::foo')).toBe($parse('::foo')); + })); + + it('should not affect calling the parseFn directly', inject(function($parse, $rootScope) { + var fn = $parse('::foo'); + $rootScope.$watch(fn); + + $rootScope.foo = 'bar'; + expect($rootScope.$$watchers.length).toBe(1); + expect(fn($rootScope)).toEqual('bar'); + + $rootScope.$digest(); + expect($rootScope.$$watchers.length).toBe(0); + expect(fn($rootScope)).toEqual('bar'); + + $rootScope.foo = 'man'; + $rootScope.$digest(); + expect($rootScope.$$watchers.length).toBe(0); + expect(fn($rootScope)).toEqual('man'); + + $rootScope.foo = 'shell'; + $rootScope.$digest(); + expect($rootScope.$$watchers.length).toBe(0); + expect(fn($rootScope)).toEqual('shell'); + })); + + it('should stay stable once the value defined', inject(function($parse, $rootScope, log) { + var fn = $parse('::foo'); + $rootScope.$watch(fn, function(value, old) { if (value !== old) log(value); }); + + $rootScope.$digest(); + expect($rootScope.$$watchers.length).toBe(1); + + $rootScope.foo = 'bar'; + $rootScope.$digest(); + expect($rootScope.$$watchers.length).toBe(0); + expect(log).toEqual('bar'); + log.reset(); + + $rootScope.foo = 'man'; + $rootScope.$digest(); + expect($rootScope.$$watchers.length).toBe(0); + expect(log).toEqual(''); + })); + + it('should have a stable value if at the end of a $digest it has a defined value', inject(function($parse, $rootScope, log) { + var fn = $parse('::foo'); + $rootScope.$watch(fn, function(value, old) { if (value !== old) log(value); }); + $rootScope.$watch('foo', function() { if ($rootScope.foo === 'bar') {$rootScope.foo = undefined; } }); + + $rootScope.foo = 'bar'; + $rootScope.$digest(); + expect($rootScope.$$watchers.length).toBe(2); + expect(log).toEqual(''); + + $rootScope.foo = 'man'; + $rootScope.$digest(); + expect($rootScope.$$watchers.length).toBe(1); + expect(log).toEqual('; man'); + + $rootScope.foo = 'shell'; + $rootScope.$digest(); + expect($rootScope.$$watchers.length).toBe(1); + expect(log).toEqual('; man'); + })); + + it('should not throw if the stable value is `null`', inject(function($parse, $rootScope) { + var fn = $parse('::foo'); + $rootScope.$watch(fn); + $rootScope.foo = null; + $rootScope.$digest(); + $rootScope.foo = 'foo'; + $rootScope.$digest(); + expect(fn()).toEqual(null); + })); + }); + 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 906137ea45ee..1c26bed7b676 100644 --- a/test/ng/rootScopeSpec.js +++ b/test/ng/rootScopeSpec.js @@ -114,6 +114,55 @@ describe('Scope', function() { expect($rootScope.$$watchers.length).toEqual(0); })); + it('should not keep constant literals on the watch queue', inject(function($rootScope) { + $rootScope.$watch('[]', function() {}); + $rootScope.$watch('{}', function() {}); + expect($rootScope.$$watchers.length).toEqual(2); + $rootScope.$digest(); + + expect($rootScope.$$watchers.length).toEqual(0); + })); + + it('should clean up stable watches on the watch queue', inject(function($rootScope) { + $rootScope.$watch('::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 clean up stable watches from $watchCollection', inject(function($rootScope) { + $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) { + $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) { $exceptionHandlerProvider.mode('log'); @@ -477,6 +526,34 @@ describe('Scope', function() { expect(log).toEqual(['watch1', 'watchAction1', 'watch2', 'watchAction2', 'watch3', 'watchAction3', 'watch2', 'watch3']); })); + + describe('deregisterNotifier', function () { + it('should call the deregisterNotifier when the watch is deregistered', inject( + function($rootScope) { + var notifier = jasmine.createSpy('deregisterNotifier'); + var listenerRemove = $rootScope.$watch('noop', noop, false, notifier); + + expect(notifier).not.toHaveBeenCalled(); + + listenerRemove(); + expect(notifier).toHaveBeenCalledOnce(); + })); + + + it('should call the deregisterNotifier when a one-time expression is stable', inject( + function($rootScope) { + var notifier = jasmine.createSpy('deregisterNotifier'); + $rootScope.$watch('::foo', noop, false, notifier); + + expect(notifier).not.toHaveBeenCalledOnce(); + $rootScope.$digest(); + expect(notifier).not.toHaveBeenCalledOnce(); + + $rootScope.foo = 'foo'; + $rootScope.$digest(); + expect(notifier).toHaveBeenCalledOnce(); + })); + }); }); @@ -853,6 +930,7 @@ describe('Scope', function() { scope.$digest(); expect(log).toEqual(''); }); + }); describe('$destroy', function() { diff --git a/test/ng/sceSpecs.js b/test/ng/sceSpecs.js index dfb260f24742..9b57eb36eed4 100644 --- a/test/ng/sceSpecs.js +++ b/test/ng/sceSpecs.js @@ -209,6 +209,31 @@ describe('SCE', function() { expect($sce.parseAsJs('"string"')()).toBe("string"); })); + it('should be possible to do one-time binding', function () { + module(provideLog); + inject(function($sce, $rootScope, log) { + $rootScope.$watch($sce.parseAsHtml('::foo'), function(value) { + log(value+''); + }); + + $rootScope.$digest(); + expect(log).toEqual('undefined'); // initial listener call + log.reset(); + + $rootScope.foo = $sce.trustAs($sce.HTML, 'trustedValue'); + expect($rootScope.$$watchers.length).toBe(1); + $rootScope.$digest(); + + expect($rootScope.$$watchers.length).toBe(0); + expect(log).toEqual('trustedValue'); + log.reset(); + + $rootScope.foo = $sce.trustAs($sce.HTML, 'anotherTrustedValue'); + $rootScope.$digest(); + expect(log).toEqual(''); // watcher no longer active + }); + }); + 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.