diff --git a/src/ng/parse.js b/src/ng/parse.js index c9fa42b63e87..4455129d7ea7 100644 --- a/src/ng/parse.js +++ b/src/ng/parse.js @@ -622,15 +622,44 @@ function isStateless($filter, filterName) { return !fn.$stateful; } -function findConstantAndWatchExpressions(ast, $filter) { +// Detect nodes which could depend on non-shallow state of objects +function isPure(node, parentIsPure) { + switch (node.type) { + // Computed members might invoke a stateful toString() + case AST.MemberExpression: + if (node.computed) { + return false; + } + break; + + // Unary always convert to primative + case AST.UnaryExpression: + return true; + + // The binary + operator can invoke a stateful toString(). + case AST.BinaryExpression: + return node.operator !== '+'; + + // Functions / filters probably read state from within objects + case AST.CallExpression: + return false; + } + + return (undefined === parentIsPure) || parentIsPure; +} + +function findConstantAndWatchExpressions(ast, $filter, parentIsPure) { var allConstants; var argsToWatch; var isStatelessFilter; + + var astIsPure = ast.isPure = isPure(ast, parentIsPure); + switch (ast.type) { case AST.Program: allConstants = true; forEach(ast.body, function(expr) { - findConstantAndWatchExpressions(expr.expression, $filter); + findConstantAndWatchExpressions(expr.expression, $filter, astIsPure); allConstants = allConstants && expr.expression.constant; }); ast.constant = allConstants; @@ -640,26 +669,26 @@ function findConstantAndWatchExpressions(ast, $filter) { ast.toWatch = []; break; case AST.UnaryExpression: - findConstantAndWatchExpressions(ast.argument, $filter); + findConstantAndWatchExpressions(ast.argument, $filter, astIsPure); ast.constant = ast.argument.constant; ast.toWatch = ast.argument.toWatch; break; case AST.BinaryExpression: - findConstantAndWatchExpressions(ast.left, $filter); - findConstantAndWatchExpressions(ast.right, $filter); + findConstantAndWatchExpressions(ast.left, $filter, astIsPure); + findConstantAndWatchExpressions(ast.right, $filter, astIsPure); ast.constant = ast.left.constant && ast.right.constant; ast.toWatch = ast.left.toWatch.concat(ast.right.toWatch); break; case AST.LogicalExpression: - findConstantAndWatchExpressions(ast.left, $filter); - findConstantAndWatchExpressions(ast.right, $filter); + findConstantAndWatchExpressions(ast.left, $filter, astIsPure); + findConstantAndWatchExpressions(ast.right, $filter, astIsPure); ast.constant = ast.left.constant && ast.right.constant; ast.toWatch = ast.constant ? [] : [ast]; break; case AST.ConditionalExpression: - findConstantAndWatchExpressions(ast.test, $filter); - findConstantAndWatchExpressions(ast.alternate, $filter); - findConstantAndWatchExpressions(ast.consequent, $filter); + findConstantAndWatchExpressions(ast.test, $filter, astIsPure); + findConstantAndWatchExpressions(ast.alternate, $filter, astIsPure); + findConstantAndWatchExpressions(ast.consequent, $filter, astIsPure); ast.constant = ast.test.constant && ast.alternate.constant && ast.consequent.constant; ast.toWatch = ast.constant ? [] : [ast]; break; @@ -668,9 +697,9 @@ function findConstantAndWatchExpressions(ast, $filter) { ast.toWatch = [ast]; break; case AST.MemberExpression: - findConstantAndWatchExpressions(ast.object, $filter); + findConstantAndWatchExpressions(ast.object, $filter, astIsPure); if (ast.computed) { - findConstantAndWatchExpressions(ast.property, $filter); + findConstantAndWatchExpressions(ast.property, $filter, astIsPure); } ast.constant = ast.object.constant && (!ast.computed || ast.property.constant); ast.toWatch = [ast]; @@ -680,7 +709,7 @@ function findConstantAndWatchExpressions(ast, $filter) { allConstants = isStatelessFilter; argsToWatch = []; forEach(ast.arguments, function(expr) { - findConstantAndWatchExpressions(expr, $filter); + findConstantAndWatchExpressions(expr, $filter, astIsPure); allConstants = allConstants && expr.constant; if (!expr.constant) { argsToWatch.push.apply(argsToWatch, expr.toWatch); @@ -690,8 +719,8 @@ function findConstantAndWatchExpressions(ast, $filter) { ast.toWatch = isStatelessFilter ? argsToWatch : [ast]; break; case AST.AssignmentExpression: - findConstantAndWatchExpressions(ast.left, $filter); - findConstantAndWatchExpressions(ast.right, $filter); + findConstantAndWatchExpressions(ast.left, $filter, astIsPure); + findConstantAndWatchExpressions(ast.right, $filter, astIsPure); ast.constant = ast.left.constant && ast.right.constant; ast.toWatch = [ast]; break; @@ -699,7 +728,7 @@ function findConstantAndWatchExpressions(ast, $filter) { allConstants = true; argsToWatch = []; forEach(ast.elements, function(expr) { - findConstantAndWatchExpressions(expr, $filter); + findConstantAndWatchExpressions(expr, $filter, astIsPure); allConstants = allConstants && expr.constant; if (!expr.constant) { argsToWatch.push.apply(argsToWatch, expr.toWatch); @@ -712,13 +741,13 @@ function findConstantAndWatchExpressions(ast, $filter) { allConstants = true; argsToWatch = []; forEach(ast.properties, function(property) { - findConstantAndWatchExpressions(property.value, $filter); + findConstantAndWatchExpressions(property.value, $filter, astIsPure); allConstants = allConstants && property.value.constant && !property.computed; if (!property.value.constant) { argsToWatch.push.apply(argsToWatch, property.value.toWatch); } if (property.computed) { - findConstantAndWatchExpressions(property.key, $filter); + findConstantAndWatchExpressions(property.key, $filter, astIsPure); if (!property.key.constant) { argsToWatch.push.apply(argsToWatch, property.key.toWatch); } @@ -803,7 +832,7 @@ ASTCompiler.prototype = { var intoId = self.nextId(); self.recurse(watch, intoId); self.return_(intoId); - self.state.inputs.push(fnKey); + self.state.inputs.push({name: fnKey, isPure: watch.isPure}); watch.watchId = key; }); this.state.computing = 'fn'; @@ -839,13 +868,16 @@ ASTCompiler.prototype = { watchFns: function() { var result = []; - var fns = this.state.inputs; + var inputs = this.state.inputs; var self = this; - forEach(fns, function(name) { - result.push('var ' + name + '=' + self.generateFunction(name, 's')); + forEach(inputs, function(input) { + result.push('var ' + input.name + '=' + self.generateFunction(input.name, 's')); + if (input.isPure) { + result.push(input.name, '.isPure=true;'); + } }); - if (fns.length) { - result.push('fn.inputs=[' + fns.join(',') + '];'); + if (inputs.length) { + result.push('fn.inputs=[' + inputs.map(function(i) { return i.name; }).join(',') + '];'); } return result.join(''); }, @@ -1251,6 +1283,7 @@ ASTInterpreter.prototype = { inputs = []; forEach(toWatch, function(watch, key) { var input = self.recurse(watch); + input.isPure = watch.isPure; watch.input = input; inputs.push(input); watch.watchId = key; @@ -1817,7 +1850,7 @@ function $ParseProvider() { inputExpressions = inputExpressions[0]; return scope.$watch(function expressionInputWatch(scope) { var newInputValue = inputExpressions(scope); - if (!expressionInputDirtyCheck(newInputValue, oldInputValueOf, parsedExpression.literal)) { + if (!expressionInputDirtyCheck(newInputValue, oldInputValueOf, inputExpressions.isPure)) { lastResult = parsedExpression(scope, undefined, undefined, [newInputValue]); oldInputValueOf = newInputValue && getValueOf(newInputValue); } @@ -1837,7 +1870,7 @@ function $ParseProvider() { for (var i = 0, ii = inputExpressions.length; i < ii; i++) { var newInputValue = inputExpressions[i](scope); - if (changed || (changed = !expressionInputDirtyCheck(newInputValue, oldInputValueOfValues[i], parsedExpression.literal))) { + if (changed || (changed = !expressionInputDirtyCheck(newInputValue, oldInputValueOfValues[i], inputExpressions[i].isPure))) { oldInputValues[i] = newInputValue; oldInputValueOfValues[i] = newInputValue && getValueOf(newInputValue); } diff --git a/test/ng/directive/ngClassSpec.js b/test/ng/directive/ngClassSpec.js index ae671d050bd3..655f3b9cea1c 100644 --- a/test/ng/directive/ngClassSpec.js +++ b/test/ng/directive/ngClassSpec.js @@ -567,6 +567,26 @@ describe('ngClass', function() { }) ); + //https://github.com/angular/angular.js/issues/15960#issuecomment-299109412 + it('should always reevaluate filters with non-primitive inputs within literals', function() { + module(function($filterProvider) { + $filterProvider.register('foo', valueFn(function(o) { + return o.a || o.b; + })); + }); + + inject(function($rootScope, $compile) { + $rootScope.testObj = {}; + element = $compile('
')($rootScope); + + $rootScope.$apply(); + expect(element).not.toHaveClass('x'); + + $rootScope.$apply('testObj.a = true'); + expect(element).toHaveClass('x'); + }); + }); + describe('large objects', function() { var getProp; var veryLargeObj; diff --git a/test/ng/parseSpec.js b/test/ng/parseSpec.js index 13d7a10039b0..3c21649db71e 100644 --- a/test/ng/parseSpec.js +++ b/test/ng/parseSpec.js @@ -2817,323 +2817,677 @@ describe('parser', function() { expect(bCalled).toBe(true); }); - it('should not invoke filters unless the input/arguments change', function() { - var filterCalled = false; - $filterProvider.register('foo', valueFn(function(input) { - filterCalled = true; - return input; + describe('filters', function() { + + it('should not be invoked unless the input/arguments change', function() { + var filterCalled = false; + $filterProvider.register('foo', valueFn(function(input) { + filterCalled = true; + return input; + })); + + scope.$watch('a | foo:b:1'); + scope.a = 0; + scope.$digest(); + expect(filterCalled).toBe(true); + + filterCalled = false; + scope.$digest(); + expect(filterCalled).toBe(false); + + scope.a++; + scope.$digest(); + expect(filterCalled).toBe(true); + }); + + it('should always be invoked if they are marked as having $stateful', function() { + var filterCalled = false; + $filterProvider.register('foo', valueFn(extend(function(input) { + filterCalled = true; + return input; + }, {$stateful: true}))); + + scope.$watch('a | foo:b:1'); + scope.a = 0; + scope.$digest(); + expect(filterCalled).toBe(true); + + filterCalled = false; + scope.$digest(); + expect(filterCalled).toBe(true); + }); + + it('should be treated as constant when input are constant', inject(function($parse) { + var filterCalls = 0; + $filterProvider.register('foo', valueFn(function(input) { + filterCalls++; + return input; + })); + + var parsed = $parse('{x: 1} | foo:1'); + + expect(parsed.constant).toBe(true); + + var watcherCalls = 0; + scope.$watch(parsed, function(input) { + expect(input).toEqual({x:1}); + watcherCalls++; + }); + + scope.$digest(); + expect(filterCalls).toBe(1); + expect(watcherCalls).toBe(1); + + scope.$digest(); + expect(filterCalls).toBe(1); + expect(watcherCalls).toBe(1); })); - scope.$watch('a | foo:b:1'); - scope.a = 0; - scope.$digest(); - expect(filterCalled).toBe(true); + describe('with non-primitive input', function() { - filterCalled = false; - scope.$digest(); - expect(filterCalled).toBe(false); + describe('that does NOT support valueOf()', function() { - scope.a++; - scope.$digest(); - expect(filterCalled).toBe(true); - }); + it('should always be reevaluated', inject(function($parse) { + var filterCalls = 0; + $filterProvider.register('foo', valueFn(function(input) { + filterCalls++; + return input; + })); - it('should invoke filters if they are marked as having $stateful', function() { - var filterCalled = false; - $filterProvider.register('foo', valueFn(extend(function(input) { - filterCalled = true; - return input; - }, {$stateful: true}))); + var parsed = $parse('obj | foo'); + var obj = scope.obj = {}; - scope.$watch('a | foo:b:1'); - scope.a = 0; - scope.$digest(); - expect(filterCalled).toBe(true); + var watcherCalls = 0; + scope.$watch(parsed, function(input) { + expect(input).toBe(obj); + watcherCalls++; + }); - filterCalled = false; - scope.$digest(); - expect(filterCalled).toBe(true); - }); + scope.$digest(); + expect(filterCalls).toBe(2); + expect(watcherCalls).toBe(1); - it('should not invoke interceptorFns unless the input changes', inject(function($parse) { - var called = false; - function interceptor(v) { - called = true; - return v; - } - scope.$watch($parse('a', interceptor)); - scope.$watch($parse('a + b', interceptor)); - scope.a = scope.b = 0; - scope.$digest(); - expect(called).toBe(true); + scope.$digest(); + expect(filterCalls).toBe(3); + expect(watcherCalls).toBe(1); + })); - called = false; - scope.$digest(); - expect(called).toBe(false); + it('should always be reevaluated in literals', inject(function($parse) { + $filterProvider.register('foo', valueFn(function(input) { + return input.b > 0; + })); - scope.a++; - scope.$digest(); - expect(called).toBe(true); - })); + scope.$watch('[(a | foo)]', function() {}); - it('should not invoke interceptorFns unless the input.valueOf changes even if the instance changes', inject(function($parse) { - var called = false; - function interceptor(v) { - called = true; - return v; - } - scope.$watch($parse('a', interceptor)); - scope.a = new Date(); - scope.$digest(); - expect(called).toBe(true); + // Would be great if filter-output was checked for changes and this didn't throw... + expect(function() { scope.$apply('a = {b: 1}'); }).toThrowMinErr('$rootScope', 'infdig'); + })); - called = false; - scope.a = new Date(scope.a.valueOf()); - scope.$digest(); - expect(called).toBe(false); - })); + it('should always be reevaluated when passed literals', inject(function($parse) { + scope.$watch('[a] | filter', function() {}); - it('should invoke interceptorFns if input.valueOf changes even if the instance does not', inject(function($parse) { - var called = false; - function interceptor(v) { - called = true; - return v; - } - scope.$watch($parse('a', interceptor)); - scope.a = new Date(); - scope.$digest(); - expect(called).toBe(true); + scope.$apply('a = 1'); - called = false; - scope.a.setTime(scope.a.getTime() + 1); - scope.$digest(); - expect(called).toBe(true); - })); + // Would be great if filter-output was checked for changes and this didn't throw... + expect(function() { scope.$apply('a = {}'); }).toThrowMinErr('$rootScope', 'infdig'); + })); + }); - it('should invoke interceptors when the expression is `undefined`', inject(function($parse) { - var called = false; - function interceptor(v) { - called = true; - return v; - } - scope.$watch($parse(undefined, interceptor)); - scope.$digest(); - expect(called).toBe(true); - })); + describe('that does support valueOf()', function() { + + it('should not be reevaluated', + inject(function($parse) { + var filterCalls = 0; + $filterProvider.register('foo', valueFn(function(input) { + filterCalls++; + expect(input instanceof Date).toBe(true); + return input; + })); + + var parsed = $parse('date | foo:a'); + var date = scope.date = new Date(); + + var watcherCalls = 0; + scope.$watch(parsed, function(input) { + expect(input).toBe(date); + watcherCalls++; + }); + + scope.$digest(); + expect(filterCalls).toBe(1); + expect(watcherCalls).toBe(1); + + scope.$digest(); + expect(filterCalls).toBe(1); + expect(watcherCalls).toBe(1); + })); - it('should treat filters with constant input as constants', inject(function($parse) { - var filterCalls = 0; - $filterProvider.register('foo', valueFn(function(input) { - filterCalls++; - return input; - })); + it('should not be reevaluated in literals', inject(function($parse) { + var filterCalls = 0; + $filterProvider.register('foo', valueFn(function(input) { + filterCalls++; + return input; + })); + + scope.date = new Date(1234567890123); + + var watcherCalls = 0; + scope.$watch('[(date | foo)]', function(input) { + watcherCalls++; + }); + + scope.$digest(); + expect(filterCalls).toBe(1); + expect(watcherCalls).toBe(1); + + scope.$digest(); + expect(filterCalls).toBe(1); + expect(watcherCalls).toBe(1); + })); + + it('should be reevaluated when valueOf() changes', inject(function($parse) { + var filterCalls = 0; + $filterProvider.register('foo', valueFn(function(input) { + filterCalls++; + expect(input instanceof Date).toBe(true); + return input; + })); + + var parsed = $parse('date | foo:a'); + var date = scope.date = new Date(); + + var watcherCalls = 0; + scope.$watch(parsed, function(input) { + expect(input).toBe(date); + watcherCalls++; + }); + + scope.$digest(); + expect(filterCalls).toBe(1); + expect(watcherCalls).toBe(1); - var parsed = $parse('{x: 1} | foo:1'); + date.setYear(1901); - expect(parsed.constant).toBe(true); + scope.$digest(); + expect(filterCalls).toBe(2); + expect(watcherCalls).toBe(1); + })); + + it('should be reevaluated in literals when valueOf() changes', inject(function($parse) { + var filterCalls = 0; + $filterProvider.register('foo', valueFn(function(input) { + filterCalls++; + return input; + })); + + scope.date = new Date(1234567890123); + + var watcherCalls = 0; + scope.$watch('[(date | foo)]', function(input) { + watcherCalls++; + }); + + scope.$digest(); + expect(filterCalls).toBe(1); + expect(watcherCalls).toBe(1); + + scope.date.setTime(1234567890); + + scope.$digest(); + expect(filterCalls).toBe(2); + expect(watcherCalls).toBe(2); + })); + + it('should not be reevaluated when the instance changes but valueOf() does not', inject(function($parse) { + var filterCalls = 0; + $filterProvider.register('foo', valueFn(function(input) { + filterCalls++; + return input; + })); + + scope.date = new Date(1234567890123); + + var watcherCalls = 0; + scope.$watch($parse('[(date | foo)]'), function(input) { + watcherCalls++; + }); + + scope.$digest(); + expect(watcherCalls).toBe(1); + expect(filterCalls).toBe(1); + + scope.date = new Date(1234567890123); + scope.$digest(); + expect(watcherCalls).toBe(1); + expect(filterCalls).toBe(1); + })); + }); + + it('should not be reevaluated when input is simplified via unary operators', inject(function($parse) { + var filterCalls = 0; + $filterProvider.register('foo', valueFn(function(input) { + filterCalls++; + return input; + })); + + scope.obj = {}; + + var watcherCalls = 0; + scope.$watch('!obj | foo:!obj', function(input) { + watcherCalls++; + }); + + scope.$digest(); + expect(filterCalls).toBe(1); + expect(watcherCalls).toBe(1); + + scope.$digest(); + expect(filterCalls).toBe(1); + expect(watcherCalls).toBe(1); + })); + + it('should not be reevaluated when input is simplified via non-plus/concat binary operators', inject(function($parse) { + var filterCalls = 0; + $filterProvider.register('foo', valueFn(function(input) { + filterCalls++; + return input; + })); + + scope.obj = {}; - var watcherCalls = 0; - scope.$watch(parsed, function(input) { - expect(input).toEqual({x:1}); - watcherCalls++; + var watcherCalls = 0; + scope.$watch('1 - obj | foo:(1 * obj)', function(input) { + watcherCalls++; + }); + + scope.$digest(); + expect(filterCalls).toBe(1); + expect(watcherCalls).toBe(1); + + scope.$digest(); + expect(filterCalls).toBe(1); + expect(watcherCalls).toBe(1); + })); + + it('should be reevaluated when input is simplified via plus/concat', inject(function($parse) { + var filterCalls = 0; + $filterProvider.register('foo', valueFn(function(input) { + filterCalls++; + return input; + })); + + scope.obj = {}; + + var watcherCalls = 0; + scope.$watch('1 + obj | foo', function(input) { + watcherCalls++; + }); + + scope.$digest(); + expect(filterCalls).toBe(2); + expect(watcherCalls).toBe(1); + + scope.$digest(); + expect(filterCalls).toBe(3); + expect(watcherCalls).toBe(1); + })); + + it('should reevaluate computed member expressions', inject(function($parse) { + var toStringCalls = 0; + + scope.obj = {}; + scope.key = { + toString: function() { + toStringCalls++; + return 'foo'; + } + }; + + var watcherCalls = 0; + scope.$watch('obj[key]', function(input) { + watcherCalls++; + }); + + scope.$digest(); + expect(toStringCalls).toBe(2); + expect(watcherCalls).toBe(1); + + scope.$digest(); + expect(toStringCalls).toBe(3); + expect(watcherCalls).toBe(1); + })); + + it('should be reevaluated with input created with null prototype', inject(function($parse) { + var filterCalls = 0; + $filterProvider.register('foo', valueFn(function(input) { + filterCalls++; + return input; + })); + + var parsed = $parse('obj | foo'); + var obj = scope.obj = Object.create(null); + + var watcherCalls = 0; + scope.$watch(parsed, function(input) { + expect(input).toBe(obj); + watcherCalls++; + }); + + scope.$digest(); + expect(filterCalls).toBe(2); + expect(watcherCalls).toBe(1); + + scope.$digest(); + expect(filterCalls).toBe(3); + expect(watcherCalls).toBe(1); + })); }); - scope.$digest(); - expect(filterCalls).toBe(1); - expect(watcherCalls).toBe(1); + describe('with primitive input', function() { - scope.$digest(); - expect(filterCalls).toBe(1); - expect(watcherCalls).toBe(1); - })); + it('should not be reevaluated when passed literals', inject(function($parse) { + var filterCalls = 0; + $filterProvider.register('foo', valueFn(function(input) { + filterCalls++; + return input; + })); - it('should always reevaluate filters with non-primitive input that doesn\'t support valueOf()', - inject(function($parse) { - var filterCalls = 0; - $filterProvider.register('foo', valueFn(function(input) { - filterCalls++; - return input; - })); + var watcherCalls = 0; + scope.$watch('[a] | foo', function(input) { + watcherCalls++; + }); + + scope.$apply('a = 1'); + expect(filterCalls).toBe(1); + expect(watcherCalls).toBe(1); + + scope.$apply('a = 2'); + expect(filterCalls).toBe(2); + expect(watcherCalls).toBe(2); + })); + + it('should not be reevaluated in literals', inject(function($parse) { + var filterCalls = 0; + $filterProvider.register('foo', valueFn(function(input) { + filterCalls++; + return input; + })); + + scope.prim = 1234567890123; - var parsed = $parse('obj | foo'); - var obj = scope.obj = {}; + var watcherCalls = 0; + scope.$watch('[(prim | foo)]', function(input) { + watcherCalls++; + }); - var watcherCalls = 0; - scope.$watch(parsed, function(input) { - expect(input).toBe(obj); - watcherCalls++; + scope.$digest(); + expect(filterCalls).toBe(1); + expect(watcherCalls).toBe(1); + + scope.$digest(); + expect(filterCalls).toBe(1); + expect(watcherCalls).toBe(1); + })); }); + }); - scope.$digest(); - expect(filterCalls).toBe(2); - expect(watcherCalls).toBe(1); + describe('interceptorFns', function() { - scope.$digest(); - expect(filterCalls).toBe(3); - expect(watcherCalls).toBe(1); - })); + it('should always be invoked if they are flagged as having $stateful', + inject(function($parse) { + var called = false; + function interceptor() { + called = true; + } + interceptor.$stateful = true; - it('should always reevaluate filters with non-primitive input created with null prototype', - inject(function($parse) { - var filterCalls = 0; - $filterProvider.register('foo', valueFn(function(input) { - filterCalls++; - return input; + scope.$watch($parse('a', interceptor)); + scope.a = 0; + scope.$digest(); + expect(called).toBe(true); + + called = false; + scope.$digest(); + expect(called).toBe(true); + + scope.a++; + called = false; + scope.$digest(); + expect(called).toBe(true); })); - var parsed = $parse('obj | foo'); - var obj = scope.obj = Object.create(null); + it('should not be invoked unless the input changes', inject(function($parse) { + var called = false; + function interceptor(v) { + called = true; + return v; + } + scope.$watch($parse('a', interceptor)); + scope.$watch($parse('a + b', interceptor)); + scope.a = scope.b = 0; + scope.$digest(); + expect(called).toBe(true); + + called = false; + scope.$digest(); + expect(called).toBe(false); + + scope.a++; + scope.$digest(); + expect(called).toBe(true); + })); - var watcherCalls = 0; - scope.$watch(parsed, function(input) { - expect(input).toBe(obj); - watcherCalls++; - }); + it('should not be invoked unless the input.valueOf() changes even if the instance changes', inject(function($parse) { + var called = false; + function interceptor(v) { + called = true; + return v; + } + scope.$watch($parse('a', interceptor)); + scope.a = new Date(); + scope.$digest(); + expect(called).toBe(true); + + called = false; + scope.a = new Date(scope.a.valueOf()); + scope.$digest(); + expect(called).toBe(false); + })); - scope.$digest(); - expect(filterCalls).toBe(2); - expect(watcherCalls).toBe(1); + it('should be invoked if input.valueOf() changes even if the instance does not', inject(function($parse) { + var called = false; + function interceptor(v) { + called = true; + return v; + } + scope.$watch($parse('a', interceptor)); + scope.a = new Date(); + scope.$digest(); + expect(called).toBe(true); + + called = false; + scope.a.setTime(scope.a.getTime() + 1); + scope.$digest(); + expect(called).toBe(true); + })); - scope.$digest(); - expect(filterCalls).toBe(3); - expect(watcherCalls).toBe(1); - })); + it('should be invoked when the expression is `undefined`', inject(function($parse) { + var called = false; + function interceptor(v) { + called = true; + return v; + } + scope.$watch($parse(undefined, interceptor)); + scope.$digest(); + expect(called).toBe(true); + })); + }); - it('should not reevaluate filters with non-primitive input that does support valueOf()', - inject(function($parse) { - var filterCalls = 0; - $filterProvider.register('foo', valueFn(function(input) { - filterCalls++; - expect(input instanceof Date).toBe(true); - return input; + describe('literals', function() { + + it('should support watching', inject(function($parse) { + var lastVal = NaN; + var callCount = 0; + var listener = function(val) { callCount++; lastVal = val; }; + + scope.$watch('{val: val}', listener); + + scope.$apply('val = 1'); + expect(callCount).toBe(1); + expect(lastVal).toEqual({val: 1}); + + scope.$apply('val = []'); + expect(callCount).toBe(2); + expect(lastVal).toEqual({val: []}); + + scope.$apply('val = []'); + expect(callCount).toBe(3); + expect(lastVal).toEqual({val: []}); + + scope.$apply('val = {}'); + expect(callCount).toBe(4); + expect(lastVal).toEqual({val: {}}); })); - var parsed = $parse('date | foo:a'); - var date = scope.date = new Date(); + it('should only watch the direct inputs', inject(function($parse) { + var lastVal = NaN; + var callCount = 0; + var listener = function(val) { callCount++; lastVal = val; }; - var watcherCalls = 0; - scope.$watch(parsed, function(input) { - expect(input).toBe(date); - watcherCalls++; - }); + scope.$watch('{val: val}', listener); - scope.$digest(); - expect(filterCalls).toBe(1); - expect(watcherCalls).toBe(1); + scope.$apply('val = 1'); + expect(callCount).toBe(1); + expect(lastVal).toEqual({val: 1}); - scope.$digest(); - expect(filterCalls).toBe(1); - expect(watcherCalls).toBe(1); - })); + scope.$apply('val = [2]'); + expect(callCount).toBe(2); + expect(lastVal).toEqual({val: [2]}); - it('should reevaluate filters with non-primitive input that does support valueOf() when' + - 'valueOf() value changes', inject(function($parse) { - var filterCalls = 0; - $filterProvider.register('foo', valueFn(function(input) { - filterCalls++; - expect(input instanceof Date).toBe(true); - return input; + scope.$apply('val.push(3)'); + expect(callCount).toBe(2); + + scope.$apply('val.length = 0'); + expect(callCount).toBe(2); })); - var parsed = $parse('date | foo:a'); - var date = scope.date = new Date(); + it('should only watch the direct inputs when nested', inject(function($parse) { + var lastVal = NaN; + var callCount = 0; + var listener = function(val) { callCount++; lastVal = val; }; - var watcherCalls = 0; - scope.$watch(parsed, function(input) { - expect(input).toBe(date); - watcherCalls++; - }); + scope.$watch('[{val: [val]}]', listener); - scope.$digest(); - expect(filterCalls).toBe(1); - expect(watcherCalls).toBe(1); + scope.$apply('val = 1'); + expect(callCount).toBe(1); + expect(lastVal).toEqual([{val: [1]}]); - date.setYear(1901); + scope.$apply('val = [2]'); + expect(callCount).toBe(2); + expect(lastVal).toEqual([{val: [[2]]}]); - scope.$digest(); - expect(filterCalls).toBe(2); - expect(watcherCalls).toBe(1); - })); + scope.$apply('val.push(3)'); + expect(callCount).toBe(2); - it('should invoke interceptorFns if they are flagged as having $stateful', - inject(function($parse) { - var called = false; - function interceptor() { - called = true; - } - interceptor.$stateful = true; + scope.$apply('val.length = 0'); + expect(callCount).toBe(2); + })); - scope.$watch($parse('a', interceptor)); - scope.a = 0; - scope.$digest(); - expect(called).toBe(true); + describe('with non-primative input', function() { - called = false; - scope.$digest(); - expect(called).toBe(true); + describe('that does NOT support valueOf()', function() { + it('should not be reevaluated', inject(function($parse) { + var obj = scope.obj = {}; - scope.a++; - called = false; - scope.$digest(); - expect(called).toBe(true); - })); + var parsed = $parse('[obj]'); + var watcherCalls = 0; + scope.$watch(parsed, function(input) { + expect(input[0]).toBe(obj); + watcherCalls++; + }); - it('should not reevaluate literals with non-primitive input that does support valueOf()', - inject(function($parse) { + scope.$digest(); + expect(watcherCalls).toBe(1); + + scope.$digest(); + expect(watcherCalls).toBe(1); + })); + }); - var date = scope.date = new Date(); + describe('that does support valueOf()', function() { + it('should not be reevaluated', inject(function($parse) { + var date = scope.date = new Date(); - var parsed = $parse('[date]'); - var watcherCalls = 0; - scope.$watch(parsed, function(input) { - expect(input[0]).toBe(date); - watcherCalls++; - }); + var parsed = $parse('[date]'); + var watcherCalls = 0; + scope.$watch(parsed, function(input) { + expect(input[0]).toBe(date); + watcherCalls++; + }); - scope.$digest(); - expect(watcherCalls).toBe(1); + scope.$digest(); + expect(watcherCalls).toBe(1); - scope.$digest(); - expect(watcherCalls).toBe(1); - })); + scope.$digest(); + expect(watcherCalls).toBe(1); + })); - it('should not reevaluate literals with non-primitive input that does support valueOf()' + - ' when the instance changes but valueOf() does not', inject(function($parse) { + it('should be reevaluated even when valueOf() changes', inject(function($parse) { + var date = scope.date = new Date(); - scope.date = new Date(1234567890123); + var parsed = $parse('[date]'); + var watcherCalls = 0; + scope.$watch(parsed, function(input) { + expect(input[0]).toBe(date); + watcherCalls++; + }); - var parsed = $parse('[date]'); - var watcherCalls = 0; - scope.$watch(parsed, function(input) { - watcherCalls++; - }); + scope.$digest(); + expect(watcherCalls).toBe(1); - scope.$digest(); - expect(watcherCalls).toBe(1); + date.setYear(1901); - scope.date = new Date(1234567890123); - scope.$digest(); - expect(watcherCalls).toBe(1); - })); + scope.$digest(); + expect(watcherCalls).toBe(2); + })); - it('should reevaluate literals with non-primitive input that does support valueOf()' + - ' when the instance does not change but valueOf() does', inject(function($parse) { + it('should not be reevaluated when the instance changes but valueOf() does not', inject(function($parse) { + scope.date = new Date(1234567890123); - scope.date = new Date(1234567890123); + var parsed = $parse('[date]'); + var watcherCalls = 0; + scope.$watch(parsed, function(input) { + watcherCalls++; + }); - var parsed = $parse('[date]'); - var watcherCalls = 0; - scope.$watch(parsed, function(input) { - watcherCalls++; - }); + scope.$digest(); + expect(watcherCalls).toBe(1); - scope.$digest(); - expect(watcherCalls).toBe(1); + scope.date = new Date(1234567890123); + scope.$digest(); + expect(watcherCalls).toBe(1); + })); - scope.date.setTime(scope.date.getTime() + 1); - scope.$digest(); - expect(watcherCalls).toBe(2); - })); + it('should be reevaluated when the instance does not change but valueOf() does', inject(function($parse) { + + scope.date = new Date(1234567890123); + + var parsed = $parse('[date]'); + var watcherCalls = 0; + scope.$watch(parsed, function(input) { + watcherCalls++; + }); + + scope.$digest(); + expect(watcherCalls).toBe(1); + + scope.date.setTime(scope.date.getTime() + 1); + scope.$digest(); + expect(watcherCalls).toBe(2); + })); + }); + }); + }); it('should continue with the evaluation of the expression without invoking computed parts', inject(function($parse) { @@ -3229,74 +3583,6 @@ describe('parser', function() { expect(count).toBe(4); expect(values[3]).toEqual({'undefined': true}); }); - - it('should support watching literals', inject(function($parse) { - var lastVal = NaN; - var callCount = 0; - var listener = function(val) { callCount++; lastVal = val; }; - - scope.$watch('{val: val}', listener); - - scope.$apply('val = 1'); - expect(callCount).toBe(1); - expect(lastVal).toEqual({val: 1}); - - scope.$apply('val = []'); - expect(callCount).toBe(2); - expect(lastVal).toEqual({val: []}); - - scope.$apply('val = []'); - expect(callCount).toBe(3); - expect(lastVal).toEqual({val: []}); - - scope.$apply('val = {}'); - expect(callCount).toBe(4); - expect(lastVal).toEqual({val: {}}); - })); - - it('should only watch the direct inputs to literals', inject(function($parse) { - var lastVal = NaN; - var callCount = 0; - var listener = function(val) { callCount++; lastVal = val; }; - - scope.$watch('{val: val}', listener); - - scope.$apply('val = 1'); - expect(callCount).toBe(1); - expect(lastVal).toEqual({val: 1}); - - scope.$apply('val = [2]'); - expect(callCount).toBe(2); - expect(lastVal).toEqual({val: [2]}); - - scope.$apply('val.push(3)'); - expect(callCount).toBe(2); - - scope.$apply('val.length = 0'); - expect(callCount).toBe(2); - })); - - it('should only watch the direct inputs to nested literals', inject(function($parse) { - var lastVal = NaN; - var callCount = 0; - var listener = function(val) { callCount++; lastVal = val; }; - - scope.$watch('[{val: [val]}]', listener); - - scope.$apply('val = 1'); - expect(callCount).toBe(1); - expect(lastVal).toEqual([{val: [1]}]); - - scope.$apply('val = [2]'); - expect(callCount).toBe(2); - expect(lastVal).toEqual([{val: [[2]]}]); - - scope.$apply('val.push(3)'); - expect(callCount).toBe(2); - - scope.$apply('val.length = 0'); - expect(callCount).toBe(2); - })); }); describe('locals', function() {