diff --git a/src/ng/directive/ngClass.js b/src/ng/directive/ngClass.js index 090c2c387f8c..e289a4c10441 100644 --- a/src/ng/directive/ngClass.js +++ b/src/ng/directive/ngClass.js @@ -91,12 +91,6 @@ function classDirective(name, selector) { } function ngClassWatchAction(newClassString) { - // When using a one-time binding the newClassString will return - // the pre-interceptor value until the one-time is complete - if (!isString(newClassString)) { - newClassString = toClassString(newClassString); - } - if (oldModulo === selector) { updateClasses(oldClassString, newClassString); } diff --git a/src/ng/parse.js b/src/ng/parse.js index 731883334998..f1b3de867eb0 100644 --- a/src/ng/parse.js +++ b/src/ng/parse.js @@ -1798,15 +1798,9 @@ function $ParseProvider() { var lexer = new Lexer($parseOptions); var parser = new Parser(lexer, $filter, $parseOptions); parsedExpression = parser.parse(exp); - if (parsedExpression.constant) { - parsedExpression.$$watchDelegate = constantWatchDelegate; - } else if (oneTime) { - parsedExpression.oneTime = true; - parsedExpression.$$watchDelegate = oneTimeWatchDelegate; - } else if (parsedExpression.inputs) { - parsedExpression.$$watchDelegate = inputsWatchDelegate; - } - cache[cacheKey] = parsedExpression; + parsedExpression.oneTime = !!oneTime; + + cache[cacheKey] = addWatchDelegate(parsedExpression); } return addInterceptor(parsedExpression, interceptorFn); @@ -1890,28 +1884,37 @@ function $ParseProvider() { function oneTimeWatchDelegate(scope, listener, objectEquality, parsedExpression, prettyPrintExpression) { var isDone = parsedExpression.literal ? isAllDefined : isDefined; var unwatch, lastValue; - if (parsedExpression.inputs) { - unwatch = inputsWatchDelegate(scope, oneTimeListener, objectEquality, parsedExpression, prettyPrintExpression); - } else { - unwatch = scope.$watch(oneTimeWatch, oneTimeListener, objectEquality); - } + + var exp = parsedExpression.$$intercepted || parsedExpression; + var post = parsedExpression.$$interceptor || identity; + + var useInputs = parsedExpression.inputs && !exp.inputs; + + // Propogate the literal/inputs/constant attributes + // ... but not oneTime since we are handling it + oneTimeWatch.literal = parsedExpression.literal; + oneTimeWatch.constant = parsedExpression.constant; + oneTimeWatch.inputs = parsedExpression.inputs; + + // Allow other delegates to run on this wrapped expression + addWatchDelegate(oneTimeWatch); + + unwatch = scope.$watch(oneTimeWatch, listener, objectEquality, prettyPrintExpression); + return unwatch; - function oneTimeWatch(scope) { - return parsedExpression(scope); - } - function oneTimeListener(value, old, scope) { - lastValue = value; - if (isFunction(listener)) { - listener(value, old, scope); + function unwatchIfDone() { + if (isDone(lastValue)) { + unwatch(); } - if (isDone(value)) { - scope.$$postDigest(function() { - if (isDone(lastValue)) { - unwatch(); - } - }); + } + + function oneTimeWatch(scope, locals, assign, inputs) { + lastValue = useInputs && inputs ? inputs[0] : exp(scope, locals, assign, inputs); + if (isDone(lastValue)) { + scope.$$postDigest(unwatchIfDone); } + return post(lastValue, scope, locals); } } @@ -1931,40 +1934,58 @@ function $ParseProvider() { return unwatch; } + function addWatchDelegate(parsedExpression) { + if (parsedExpression.constant) { + parsedExpression.$$watchDelegate = constantWatchDelegate; + } else if (parsedExpression.oneTime) { + parsedExpression.$$watchDelegate = oneTimeWatchDelegate; + } else if (parsedExpression.inputs) { + parsedExpression.$$watchDelegate = inputsWatchDelegate; + } + + return parsedExpression; + } + + function chainInterceptors(first, second) { + function chainedInterceptor(value) { + return second(first(value)); + } + chainedInterceptor.$stateful = first.$stateful || second.$stateful; + + return chainedInterceptor; + } + function addInterceptor(parsedExpression, interceptorFn) { if (!interceptorFn) return parsedExpression; - var watchDelegate = parsedExpression.$$watchDelegate; - var useInputs = false; - var isDone = parsedExpression.literal ? isAllDefined : isDefined; - - function regularInterceptedExpression(scope, locals, assign, inputs) { - var value = useInputs && inputs ? inputs[0] : parsedExpression(scope, locals, assign, inputs); - return interceptorFn(value); + // Extract any existing interceptors out of the parsedExpression + // to ensure the original parsedExpression is always the $$intercepted + if (parsedExpression.$$interceptor) { + interceptorFn = chainInterceptors(parsedExpression.$$interceptor, interceptorFn); + parsedExpression = parsedExpression.$$intercepted; } - function oneTimeInterceptedExpression(scope, locals, assign, inputs) { + var useInputs = false; + + var fn = function interceptedExpression(scope, locals, assign, inputs) { var value = useInputs && inputs ? inputs[0] : parsedExpression(scope, locals, assign, inputs); - var result = interceptorFn(value); - // we only return the interceptor's result if the - // initial value is defined (for bind-once) - return isDone(value) ? result : value; - } + return interceptorFn(value); + }; - var fn = parsedExpression.oneTime ? oneTimeInterceptedExpression : regularInterceptedExpression; + // Maintain references to the interceptor/intercepted + fn.$$intercepted = parsedExpression; + fn.$$interceptor = interceptorFn; - // Propogate the literal/oneTime attributes + // Propogate the literal/oneTime/constant attributes fn.literal = parsedExpression.literal; fn.oneTime = parsedExpression.oneTime; + fn.constant = parsedExpression.constant; - // Propagate or create inputs / $$watchDelegates - useInputs = !parsedExpression.inputs; - if (watchDelegate && watchDelegate !== inputsWatchDelegate) { - fn.$$watchDelegate = watchDelegate; - fn.inputs = parsedExpression.inputs; - } else if (!interceptorFn.$stateful) { - // Treat interceptor like filters - assume non-stateful by default and use the inputsWatchDelegate - fn.$$watchDelegate = inputsWatchDelegate; + // Treat the interceptor like filters. + // If it is not $stateful then only watch its inputs. + // If the expression itself has no inputs then use the full expression as an input. + if (!interceptorFn.$stateful) { + useInputs = !parsedExpression.inputs; fn.inputs = (parsedExpression.inputs ? parsedExpression.inputs : [parsedExpression]).map(function(e) { // Remove the isPure flag of inputs when it is not absolute because they are now wrapped in a // potentially non-pure interceptor function. @@ -1975,7 +1996,7 @@ function $ParseProvider() { }); } - return fn; + return addWatchDelegate(fn); } }]; } diff --git a/test/ng/directive/ngClassSpec.js b/test/ng/directive/ngClassSpec.js index 1c08a1c4133c..0180d67a6aa4 100644 --- a/test/ng/directive/ngClassSpec.js +++ b/test/ng/directive/ngClassSpec.js @@ -532,6 +532,20 @@ describe('ngClass', function() { }) ); + it('should support a one-time mixed literal-array/object variable', inject(function($rootScope, $compile) { + element = $compile('
')($rootScope); + + $rootScope.classVar1 = {orange: true}; + $rootScope.$digest(); + expect(element).toHaveClass('orange'); + + $rootScope.classVar1.orange = false; + $rootScope.$digest(); + + expect(element).not.toHaveClass('orange'); + }) + ); + it('should do value stabilization as expected when one-time binding', inject(function($rootScope, $compile) { diff --git a/test/ng/interpolateSpec.js b/test/ng/interpolateSpec.js index f8ed846d93d2..2ed9b31b7f5f 100644 --- a/test/ng/interpolateSpec.js +++ b/test/ng/interpolateSpec.js @@ -149,6 +149,22 @@ describe('$interpolate', function() { expect($rootScope.$countWatchers()).toBe(0); })); + it('should respect one-time bindings for literals', inject(function($interpolate, $rootScope) { + var calls = []; + $rootScope.$watch($interpolate('{{ ::{x: x} }}'), function(val) { + calls.push(val); + }); + + $rootScope.$apply(); + expect(calls.pop()).toBe('{}'); + + $rootScope.$apply('x = 1'); + expect(calls.pop()).toBe('{"x":1}'); + + $rootScope.$apply('x = 2'); + expect(calls.pop()).toBeUndefined(); + })); + it('should stop watching strings with no expressions after first execution', inject(function($interpolate, $rootScope) { var spy = jasmine.createSpy(); diff --git a/test/ng/parseSpec.js b/test/ng/parseSpec.js index e8edd15349b1..025cf8ec50b0 100644 --- a/test/ng/parseSpec.js +++ b/test/ng/parseSpec.js @@ -3324,6 +3324,71 @@ describe('parser', function() { expect(called).toBe(true); })); + it('should always be invoked if flagged as $stateful when wrapping one-time', + inject(function($parse) { + + var interceptorCalls = 0; + function interceptor() { + interceptorCalls++; + return 123; + } + interceptor.$stateful = true; + + scope.$watch($parse('::a', interceptor)); + + interceptorCalls = 0; + scope.$digest(); + expect(interceptorCalls).not.toBe(0); + + interceptorCalls = 0; + scope.$digest(); + expect(interceptorCalls).not.toBe(0); + })); + + it('should always be invoked if flagged as $stateful when wrapping one-time with inputs', + inject(function($parse) { + + $filterProvider.register('identity', valueFn(identity)); + + var interceptorCalls = 0; + function interceptor() { + interceptorCalls++; + return 123; + } + interceptor.$stateful = true; + + scope.$watch($parse('::a | identity', interceptor)); + + interceptorCalls = 0; + scope.$digest(); + expect(interceptorCalls).not.toBe(0); + + interceptorCalls = 0; + scope.$digest(); + expect(interceptorCalls).not.toBe(0); + })); + + it('should always be invoked if flagged as $stateful when wrapping one-time literal', + inject(function($parse) { + + var interceptorCalls = 0; + function interceptor() { + interceptorCalls++; + return 123; + } + interceptor.$stateful = true; + + scope.$watch($parse('::[a]', interceptor)); + + interceptorCalls = 0; + scope.$digest(); + expect(interceptorCalls).not.toBe(0); + + interceptorCalls = 0; + scope.$digest(); + expect(interceptorCalls).not.toBe(0); + })); + it('should not be invoked unless the input changes', inject(function($parse) { var called = false; function interceptor(v) { @@ -3434,6 +3499,80 @@ describe('parser', function() { scope.$digest(); expect(scope.$$watchersCount).toBe(0); })); + + it('should watch the intercepted value of one-time bindings', inject(function($parse, log) { + scope.$watch($parse('::{x:x, y:y}', function(lit) { return lit.x; }), log); + + scope.$apply(); + expect(log.empty()).toEqual([undefined]); + + scope.$apply('x = 1'); + expect(log.empty()).toEqual([1]); + + scope.$apply('x = 2; y=1'); + expect(log.empty()).toEqual([2]); + + scope.$apply('x = 1; y=2'); + expect(log.empty()).toEqual([]); + })); + + it('should watch the intercepted value of one-time bindings in nested interceptors', inject(function($parse, log) { + scope.$watch($parse($parse('::{x:x, y:y}', function(lit) { return lit.x; }), identity), log); + + scope.$apply(); + expect(log.empty()).toEqual([undefined]); + + scope.$apply('x = 1'); + expect(log.empty()).toEqual([1]); + + scope.$apply('x = 2; y=1'); + expect(log.empty()).toEqual([2]); + + scope.$apply('x = 1; y=2'); + expect(log.empty()).toEqual([]); + })); + + it('should nest interceptors around eachother, not around the intercepted', inject(function($parse) { + function origin() { return 0; } + + var fn = origin; + function addOne(n) { return n + 1; } + + fn = $parse(fn, addOne); + expect(fn.$$intercepted).toBe(origin); + expect(fn()).toBe(1); + + fn = $parse(fn, addOne); + expect(fn.$$intercepted).toBe(origin); + expect(fn()).toBe(2); + + fn = $parse(fn, addOne); + expect(fn.$$intercepted).toBe(origin); + expect(fn()).toBe(3); + })); + + it('should not propogate $$watchDelegate to the interceptor wrapped expression', inject(function($parse) { + function getter(s) { + return s.x; + } + getter.$$watchDelegate = getter; + + function doubler(v) { + return 2 * v; + } + + var lastValue; + function watcher(val) { + lastValue = val; + } + scope.$watch($parse(getter, doubler), watcher); + + scope.$apply('x = 1'); + expect(lastValue).toBe(2 * 1); + + scope.$apply('x = 123'); + expect(lastValue).toBe(2 * 123); + })); }); describe('literals', function() {