From a3143f35e3d41f4c05f29843f59e96bdc57938be Mon Sep 17 00:00:00 2001 From: rodyhaddad Date: Tue, 25 Feb 2014 12:27:20 -0500 Subject: [PATCH 1/4] feat(Scope): add `$watchSet` method for observing a set of expressions It any one expression changes then the listener function fires Port of angular/angular.dart@a3c31ce1dddb4423faa316cb144568f3fc28b1a9 --- src/ng/rootScope.js | 62 +++++++++++++++++++++++++++++++++++ test/ng/rootScopeSpec.js | 70 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/src/ng/rootScope.js b/src/ng/rootScope.js index eb1dade2d927..efbacc79a5ac 100644 --- a/src/ng/rootScope.js +++ b/src/ng/rootScope.js @@ -354,6 +354,68 @@ function $RootScopeProvider(){ }; }, + /** + * @ngdoc method + * @name $rootScope.Scope#$watchSet + * @function + * + * @description + * A variant of {@link ng.$rootScope.Scope#$watch $watch()} where it watches an array of `watchExpressions`. + * If any one expression in the collection changes the `listener` is executed. + * + * - The items in the `watchCollection` array are observed via standard $watch operation and are examined on every + * call to $digest() to see if any items changes. + * - The `listener` is called whenever any expression in the `watchExpressions` array changes. + * + * @param {Array.} watchExpressions Array of expressions that will be individually + * watched using {@link ng.$rootScope.Scope#$watch $watch()} + * + * @param {function(newValues, oldValues, scope)} listener Callback called whenever the return value of any + * expression in `watchExpressions` changes + * The `newValues` array contains the current values of the `watchExpressions`, with the indexes matching + * those of `watchExpression` + * 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. + */ + $watchSet: function (watchExpressions, listener) { + if (watchExpressions.length === 0) return noop; + + var oldValues = new Array(watchExpressions.length), + newValues = new Array(watchExpressions.length); + + if (watchExpressions.length === 1) { + // Special case size of one + return this.$watch(watchExpressions[0], function (value, oldValue, scope) { + newValues[0] = value; + oldValues[0] = oldValue; + listener.call(this, newValues, oldValues, scope); + }); + } + var deregisterFns = [], + changeCount = 0; + + forEach(watchExpressions, function (expr, i) { + deregisterFns.push(this.$watch(expr, function (value, oldValue) { + newValues[i] = value; + oldValues[i] = oldValue; + changeCount++; + })); + }, this); + + deregisterFns.push(this.$watch(function () {return changeCount;}, function (c, o, scope) { + listener.call(this, newValues, oldValues, scope); + })); + + return function () { + forEach(deregisterFns, function (fn) { + fn(); + }); + }; + }, + /** * @ngdoc method diff --git a/test/ng/rootScopeSpec.js b/test/ng/rootScopeSpec.js index f9cf9412c605..3eef8aed92f8 100644 --- a/test/ng/rootScopeSpec.js +++ b/test/ng/rootScopeSpec.js @@ -733,6 +733,76 @@ describe('Scope', function() { }); }); + describe('$watchSet', function() { + var scope; + beforeEach(inject(function($rootScope) { + scope = $rootScope.$new(); + })); + + it('should skip empty sets', function() { + expect(scope.$watchSet([], null)()).toBeUndefined(); + }); + + it('should treat set of 1 as direct watch', function() { + var lastValues = ['foo']; + var log = ''; + var clean = scope.$watchSet(['a'], function(values, oldValues, s) { + log += values.join(',') + ';'; + expect(s).toBe(scope); + expect(oldValues).toEqual(lastValues); + lastValues = values.slice(); + }); + + scope.a = 'foo'; + scope.$digest(); + expect(log).toEqual('foo;'); + + scope.$digest(); + expect(log).toEqual('foo;'); + + scope.a = 'bar'; + scope.$digest(); + expect(log).toEqual('foo;bar;'); + + clean(); + scope.a = 'xxx'; + scope.$digest(); + expect(log).toEqual('foo;bar;'); + }); + + it('should detect a change to any one in a set', function() { + var lastValues = ['foo', 'bar']; + var log = ''; + var clean = scope.$watchSet(['a', 'b'], function(values, oldValues, s) { + log += values.join(',') + ';'; + expect(s).toBe(scope); + expect(oldValues).toEqual(lastValues); + lastValues = values.slice(); + }); + + scope.a = 'foo'; + scope.b = 'bar'; + scope.$digest(); + expect(log).toEqual('foo,bar;'); + + scope.$digest(); + expect(log).toEqual('foo,bar;'); + + scope.a = 'a'; + scope.$digest(); + expect(log).toEqual('foo,bar;a,bar;'); + + scope.a = 'A'; + scope.b = 'B'; + scope.$digest(); + expect(log).toEqual('foo,bar;a,bar;A,B;'); + + clean(); + scope.a = 'xxx'; + scope.$digest(); + expect(log).toEqual('foo,bar;a,bar;A,B;'); + }); + }); describe('$destroy', function() { var first = null, middle = null, last = null, log = null; From ef144e08eda6a05a68ac18d4ffef21c1e539b767 Mon Sep 17 00:00:00 2001 From: rodyhaddad Date: Tue, 8 Apr 2014 01:14:37 -0400 Subject: [PATCH 2/4] $watchSet changes. TODO squash --- src/ng/rootScope.js | 19 +++----- test/ng/rootScopeSpec.js | 96 ++++++++++++++++------------------------ 2 files changed, 44 insertions(+), 71 deletions(-) diff --git a/src/ng/rootScope.js b/src/ng/rootScope.js index efbacc79a5ac..85ab5f348666 100644 --- a/src/ng/rootScope.js +++ b/src/ng/rootScope.js @@ -383,30 +383,23 @@ function $RootScopeProvider(){ $watchSet: function (watchExpressions, listener) { if (watchExpressions.length === 0) return noop; - var oldValues = new Array(watchExpressions.length), + var self = this, + oldValues = new Array(watchExpressions.length), newValues = new Array(watchExpressions.length); - if (watchExpressions.length === 1) { - // Special case size of one - return this.$watch(watchExpressions[0], function (value, oldValue, scope) { - newValues[0] = value; - oldValues[0] = oldValue; - listener.call(this, newValues, oldValues, scope); - }); - } var deregisterFns = [], changeCount = 0; forEach(watchExpressions, function (expr, i) { - deregisterFns.push(this.$watch(expr, function (value, oldValue) { + deregisterFns.push(self.$watch(expr, function (value, oldValue) { newValues[i] = value; oldValues[i] = oldValue; changeCount++; })); - }, this); + }); - deregisterFns.push(this.$watch(function () {return changeCount;}, function (c, o, scope) { - listener.call(this, newValues, oldValues, scope); + deregisterFns.push(this.$watch(function () {return changeCount;}, function () { + listener.call(this, newValues, oldValues, self); })); return function () { diff --git a/test/ng/rootScopeSpec.js b/test/ng/rootScopeSpec.js index 3eef8aed92f8..cd0d903f36bf 100644 --- a/test/ng/rootScopeSpec.js +++ b/test/ng/rootScopeSpec.js @@ -734,74 +734,54 @@ describe('Scope', function() { }); describe('$watchSet', function() { - var scope; - beforeEach(inject(function($rootScope) { - scope = $rootScope.$new(); - })); - - it('should skip empty sets', function() { - expect(scope.$watchSet([], null)()).toBeUndefined(); - }); - it('should treat set of 1 as direct watch', function() { - var lastValues = ['foo']; - var log = ''; - var clean = scope.$watchSet(['a'], function(values, oldValues, s) { - log += values.join(',') + ';'; - expect(s).toBe(scope); - expect(oldValues).toEqual(lastValues); - lastValues = values.slice(); + it('should detect a change to any expression in the array', inject(function($rootScope, log) { + $rootScope.$watchSet(['a', 'b'], function(values, oldValues, scope) { + expect(scope).toBe($rootScope); + log(values + '-' + oldValues); }); - scope.a = 'foo'; - scope.$digest(); - expect(log).toEqual('foo;'); - - scope.$digest(); - expect(log).toEqual('foo;'); - - scope.a = 'bar'; - scope.$digest(); - expect(log).toEqual('foo;bar;'); + $rootScope.a = 'foo'; + $rootScope.b = 'bar'; + $rootScope.$digest(); + expect(log).toEqual('foo,bar-foo,bar'); - clean(); - scope.a = 'xxx'; - scope.$digest(); - expect(log).toEqual('foo;bar;'); - }); + log.reset(); + $rootScope.$digest(); + expect(log).toEqual(''); - it('should detect a change to any one in a set', function() { - var lastValues = ['foo', 'bar']; - var log = ''; - var clean = scope.$watchSet(['a', 'b'], function(values, oldValues, s) { - log += values.join(',') + ';'; - expect(s).toBe(scope); - expect(oldValues).toEqual(lastValues); - lastValues = values.slice(); - }); + log.reset(); + $rootScope.a = 'a'; + $rootScope.$digest(); + expect(log).toEqual('a,bar-foo,bar'); - scope.a = 'foo'; - scope.b = 'bar'; - scope.$digest(); - expect(log).toEqual('foo,bar;'); + log.reset(); + $rootScope.a = 'A'; + $rootScope.b = 'B'; + $rootScope.$digest(); + expect(log).toEqual('A,B-a,bar'); + })); - scope.$digest(); - expect(log).toEqual('foo,bar;'); + it('should return a function that allows listeners to be deregistered', inject(function($rootScope) { + var listener = jasmine.createSpy('watchSet listener'), + listenerRemove; - scope.a = 'a'; - scope.$digest(); - expect(log).toEqual('foo,bar;a,bar;'); + listenerRemove = $rootScope.$watchSet(['a'], listener); + $rootScope.$digest(); //init + expect(listener).toHaveBeenCalled(); + expect(listenerRemove).toBeDefined(); - scope.a = 'A'; - scope.b = 'B'; - scope.$digest(); - expect(log).toEqual('foo,bar;a,bar;A,B;'); + listener.reset(); + $rootScope.a = 'bar'; + $rootScope.$digest(); //trigger + expect(listener).toHaveBeenCalledOnce(); - clean(); - scope.a = 'xxx'; - scope.$digest(); - expect(log).toEqual('foo,bar;a,bar;A,B;'); - }); + listener.reset(); + $rootScope.a = 'baz'; + listenerRemove(); + $rootScope.$digest(); //trigger + expect(listener).not.toHaveBeenCalled(); + })); }); describe('$destroy', function() { From cd2ab143e2b67e32ba90a69e5a069cd08b035e65 Mon Sep 17 00:00:00 2001 From: rodyhaddad Date: Thu, 6 Mar 2014 15:57:24 -0500 Subject: [PATCH 3/4] refactor($interpolate): split .parts into .expressions and .separators BREAKING CHANGE: the function returned by $interpolate no longer has a `.parts` array set on it. It has been replaced by two arrays: * `.expressions`, an array of the expressions in the interpolated text. The expressions are parsed with $parse, with an extra layer converting them to strings when computed * `.separators`, an array of strings representing the separations between interpolations in the text. This array is **always** 1 item longer than the `.expressions` array for easy merging with it --- src/ng/interpolate.js | 91 ++++++++++++++++++++++-------------- src/ngScenario/Scenario.js | 4 +- test/ng/interpolateSpec.js | 96 ++++++++++++++++++++++---------------- 3 files changed, 112 insertions(+), 79 deletions(-) diff --git a/src/ng/interpolate.js b/src/ng/interpolate.js index 6b61e56f2a70..731f32590b10 100644 --- a/src/ng/interpolate.js +++ b/src/ng/interpolate.js @@ -125,32 +125,33 @@ function $InterpolateProvider() { var startIndex, endIndex, index = 0, - parts = [], - length = text.length, + separators = [], + expressions = [], hasInterpolation = false, + hasText = false, fn, exp, concat = []; - while(index < length) { + while(index < text.length) { if ( ((startIndex = text.indexOf(startSymbol, index)) != -1) && ((endIndex = text.indexOf(endSymbol, startIndex + startSymbolLength)) != -1) ) { - (index != startIndex) && parts.push(text.substring(index, startIndex)); - parts.push(fn = $parse(exp = text.substring(startIndex + startSymbolLength, endIndex))); + if (index !== startIndex) hasText = true; + separators.push(text.substring(index, startIndex)); + expressions.push(fn = interpolateParse(exp = text.substring(startIndex + startSymbolLength, endIndex))); fn.exp = exp; index = endIndex + endSymbolLength; hasInterpolation = true; } else { - // we did not find anything, so we have to add the remainder to the parts array - (index != length) && parts.push(text.substring(index)); - index = length; + // we did not find an interpolation, so we have to add the remainder to the separators array + if (index !== text.length) hasText = true; + separators.push(text.substring(index)); + break; } } - if (!(length = parts.length)) { - // we added, nothing, must have been an empty string. - parts.push(''); - length = 1; + if (separators.length === expressions.length) { + separators.push(''); } // Concatenating expressions makes it hard to reason about whether some combination of @@ -159,7 +160,7 @@ function $InterpolateProvider() { // that's used is assigned or constructed by some JS code somewhere that is more testable or // make it obvious that you bound the value to some user controlled value. This helps reduce // the load when auditing for XSS issues. - if (trustedContext && parts.length > 1) { + if (trustedContext && hasInterpolation && (hasText || expressions.length > 1)) { throw $interpolateMinErr('noconcat', "Error while interpolating: {0}\nStrict Contextual Escaping disallows " + "interpolations that concatenate multiple expressions when a trusted value is " + @@ -167,36 +168,54 @@ function $InterpolateProvider() { } if (!mustHaveExpression || hasInterpolation) { - concat.length = length; + concat.length = separators.length + expressions.length; + var computeFn = function (values, context) { + for(var i = 0, ii = expressions.length; i Date: Thu, 6 Mar 2014 16:00:27 -0500 Subject: [PATCH 4/4] perf($compile): watch interpolated expressions individually --- src/ng/compile.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ng/compile.js b/src/ng/compile.js index 0265beb47250..03b7237bdb21 100644 --- a/src/ng/compile.js +++ b/src/ng/compile.js @@ -1803,9 +1803,9 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { bindings = parent.data('$binding') || []; bindings.push(interpolateFn); safeAddClass(parent.data('$binding', bindings), 'ng-binding'); - scope.$watch(interpolateFn, function interpolateFnWatchAction(value) { + scope.$watchSet(interpolateFn.expressions, interpolateFn.$$invoke(function(value) { node[0].nodeValue = value; - }); + })); }) }); } @@ -1866,7 +1866,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { attr[name] = interpolateFn(scope); ($$observers[name] || ($$observers[name] = [])).$$inter = true; (attr.$$observers && attr.$$observers[name].$$scope || scope). - $watch(interpolateFn, function interpolateFnWatchAction(newValue, oldValue) { + $watchSet(interpolateFn.expressions, interpolateFn.$$invoke(function (newValue, oldValue) { //special case for class attribute addition + removal //so that class changes can tap into the animation //hooks provided by the $animate service. Be sure to @@ -1878,7 +1878,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) { } else { attr.$set(name, newValue); } - }); + })); } }; }