diff --git a/src/ng/directive/ngClass.js b/src/ng/directive/ngClass.js index a01a8940b..a26c1c771 100644 --- a/src/ng/directive/ngClass.js +++ b/src/ng/directive/ngClass.js @@ -8,47 +8,71 @@ function classDirective(name, selector) { name = 'ngClass' + name; + var indexWatchExpression; - return [function() { + return ['$parse', function($parse) { return { restrict: 'AC', link: function(scope, element, attr) { - var oldVal; + var expression = attr[name].trim(); + var isOneTime = (expression.charAt(0) === ':') && (expression.charAt(1) === ':'); - if (name !== 'ngClass') { - scope.$watch('$index', function($index, old$index) { - /* eslint-disable no-bitwise */ - var mod = $index & 1; - if (mod !== (old$index & 1)) { - var classes = arrayClasses(oldVal); - if (mod === selector) { - addClasses(classes); - } else { - removeClasses(classes); - } - } - /* eslint-enable */ - }); - } + var watchInterceptor = isOneTime ? toFlatValue : toClassString; + var watchExpression = $parse(expression, watchInterceptor); + var watchAction = isOneTime ? ngClassOneTimeWatchAction : ngClassWatchAction; - scope.$watch(attr[name], ngClassWatchAction, true); + var classCounts = element.data('$classCounts'); + var oldModulo = true; + var oldClassString; - function addClasses(classes) { - var newClasses = digestClassCounts(classes, 1); - attr.$addClass(newClasses); - } - - function removeClasses(classes) { - var newClasses = digestClassCounts(classes, -1); - attr.$removeClass(newClasses); - } - - function digestClassCounts(classes, count) { + if (!classCounts) { // Use createMap() to prevent class assumptions involving property // names in Object.prototype - var classCounts = element.data('$classCounts') || createMap(); + classCounts = createMap(); + element.data('$classCounts', classCounts); + } + + if (name !== 'ngClass') { + if (!indexWatchExpression) { + indexWatchExpression = $parse('$index', function moduloTwo($index) { + // eslint-disable-next-line no-bitwise + return $index & 1; + }); + } + + scope.$watch(indexWatchExpression, ngClassIndexWatchAction); + } + + scope.$watch(watchExpression, watchAction, isOneTime); + + function addClasses(classString) { + classString = digestClassCounts(split(classString), 1); + attr.$addClass(classString); + } + + function removeClasses(classString) { + classString = digestClassCounts(split(classString), -1); + attr.$removeClass(classString); + } + + function updateClasses(oldClassString, newClassString) { + var oldClassArray = split(oldClassString); + var newClassArray = split(newClassString); + + var toRemoveArray = arrayDifference(oldClassArray, newClassArray); + var toAddArray = arrayDifference(newClassArray, oldClassArray); + + var toRemoveString = digestClassCounts(toRemoveArray, -1); + var toAddString = digestClassCounts(toAddArray, 1); + + attr.$addClass(toAddString); + attr.$removeClass(toRemoveString); + } + + function digestClassCounts(classArray, count) { var classesToUpdate = []; - forEach(classes, function(className) { + + forEach(classArray, function(className) { if (count > 0 || classCounts[className]) { classCounts[className] = (classCounts[className] || 0) + count; if (classCounts[className] === +(count > 0)) { @@ -56,74 +80,106 @@ function classDirective(name, selector) { } } }); - element.data('$classCounts', classCounts); + return classesToUpdate.join(' '); } - function updateClasses(oldClasses, newClasses) { - var toAdd = arrayDifference(newClasses, oldClasses); - var toRemove = arrayDifference(oldClasses, newClasses); - toAdd = digestClassCounts(toAdd, 1); - toRemove = digestClassCounts(toRemove, -1); + function ngClassIndexWatchAction(newModulo) { + // This watch-action should run before the `ngClass[OneTime]WatchAction()`, thus it + // adds/removes `oldClassString`. If the `ngClass` expression has changed as well, the + // `ngClass[OneTime]WatchAction()` will update the classes. + if (newModulo === selector) { + addClasses(oldClassString); + } else { + removeClasses(oldClassString); + } - attr.$addClass(toAdd); - attr.$removeClass(toRemove); + oldModulo = newModulo; } - function ngClassWatchAction(newVal) { - // eslint-disable-next-line no-bitwise - if (selector === true || (scope.$index & 1) === selector) { - var newClasses = arrayClasses(newVal || []); - if (!oldVal) { - addClasses(newClasses); - } else if (!equals(newVal,oldVal)) { - var oldClasses = arrayClasses(oldVal); - updateClasses(oldClasses, newClasses); - } + function ngClassOneTimeWatchAction(newClassValue) { + var newClassString = toClassString(newClassValue); + + if (newClassString !== oldClassString) { + ngClassWatchAction(newClassString); } - if (isArray(newVal)) { - oldVal = newVal.map(function(v) { return shallowCopy(v); }); - } else { - oldVal = shallowCopy(newVal); + } + + function ngClassWatchAction(newClassString) { + if (oldModulo === selector) { + updateClasses(oldClassString, newClassString); } + + oldClassString = newClassString; } } }; - - function arrayDifference(tokens1, tokens2) { - var values = []; - - outer: - for (var i = 0; i < tokens1.length; i++) { - var token = tokens1[i]; - for (var j = 0; j < tokens2.length; j++) { - if (token === tokens2[j]) continue outer; - } - values.push(token); - } - return values; - } - - function arrayClasses(classVal) { - var classes = []; - if (isArray(classVal)) { - forEach(classVal, function(v) { - classes = classes.concat(arrayClasses(v)); - }); - return classes; - } else if (isString(classVal)) { - return classVal.split(' '); - } else if (isObject(classVal)) { - forEach(classVal, function(v, k) { - if (v) { - classes = classes.concat(k.split(' ')); - } - }); - return classes; - } - return classVal; - } }]; + + // Helpers + function arrayDifference(tokens1, tokens2) { + if (!tokens1 || !tokens1.length) return []; + if (!tokens2 || !tokens2.length) return tokens1; + + var values = []; + + outer: + for (var i = 0; i < tokens1.length; i++) { + var token = tokens1[i]; + for (var j = 0; j < tokens2.length; j++) { + if (token === tokens2[j]) continue outer; + } + values.push(token); + } + + return values; + } + + function split(classString) { + return classString && classString.split(' '); + } + + function toClassString(classValue) { + var classString = classValue; + + if (isArray(classValue)) { + classString = classValue.map(toClassString).join(' '); + } else if (isObject(classValue)) { + classString = Object.keys(classValue). + filter(function(key) { return classValue[key]; }). + join(' '); + } + + return classString; + } + + function toFlatValue(classValue) { + var flatValue = classValue; + + if (isArray(classValue)) { + flatValue = classValue.map(toFlatValue); + } else if (isObject(classValue)) { + var hasUndefined = false; + + flatValue = Object.keys(classValue).filter(function(key) { + var value = classValue[key]; + + if (!hasUndefined && isUndefined(value)) { + hasUndefined = true; + } + + return value; + }); + + if (hasUndefined) { + // Prevent the `oneTimeLiteralWatchInterceptor` from unregistering + // the watcher, by including at least one `undefined` value. + flatValue.push(undefined); + } + } + + return flatValue; + } } /** diff --git a/test/ng/directive/ngClassSpec.js b/test/ng/directive/ngClassSpec.js index 29f27a5e6..ae671d050 100644 --- a/test/ng/directive/ngClassSpec.js +++ b/test/ng/directive/ngClassSpec.js @@ -568,46 +568,71 @@ describe('ngClass', function() { ); describe('large objects', function() { + var getProp; + var veryLargeObj; - var verylargeobject, getProp; beforeEach(function() { getProp = jasmine.createSpy('getProp'); - verylargeobject = {}; - Object.defineProperty(verylargeobject, 'prop', { + veryLargeObj = {}; + + Object.defineProperty(veryLargeObj, 'prop', { get: getProp, enumerable: true }); }); - it('should not be copied if static', inject(function($rootScope, $compile) { - element = $compile('
')($rootScope); - $rootScope.verylargeobject = verylargeobject; - $rootScope.$digest(); - - expect(getProp).not.toHaveBeenCalled(); - })); - - it('should not be copied if dynamic', inject(function($rootScope, $compile) { - $rootScope.fooClass = {foo: verylargeobject}; + it('should not be copied when using an expression', inject(function($compile, $rootScope) { element = $compile('')($rootScope); + $rootScope.fooClass = {foo: veryLargeObj}; $rootScope.$digest(); + expect(element).toHaveClass('foo'); expect(getProp).not.toHaveBeenCalled(); })); - it('should not be copied if inside an array', inject(function($rootScope, $compile) { - element = $compile('')($rootScope); - $rootScope.verylargeobject = verylargeobject; + it('should not be copied when using a literal', inject(function($compile, $rootScope) { + element = $compile('')($rootScope); + $rootScope.veryLargeObj = veryLargeObj; $rootScope.$digest(); + expect(element).toHaveClass('foo'); expect(getProp).not.toHaveBeenCalled(); })); - it('should not be copied when one-time binding', inject(function($rootScope, $compile) { - element = $compile('')($rootScope); - $rootScope.verylargeobject = verylargeobject; + it('should not be copied when inside an array', inject(function($compile, $rootScope) { + element = $compile('')($rootScope); + $rootScope.veryLargeObj = veryLargeObj; $rootScope.$digest(); + expect(element).toHaveClass('foo'); + expect(getProp).not.toHaveBeenCalled(); + })); + + it('should not be copied when using one-time binding', inject(function($compile, $rootScope) { + element = $compile('')($rootScope); + $rootScope.veryLargeObj = veryLargeObj; + $rootScope.$digest(); + + expect(element).toHaveClass('foo'); + expect(element).not.toHaveClass('bar'); + expect(getProp).not.toHaveBeenCalled(); + + $rootScope.$apply('veryLargeObj.bar = "bar"'); + + expect(element).toHaveClass('foo'); + expect(element).not.toHaveClass('bar'); + expect(getProp).not.toHaveBeenCalled(); + + $rootScope.$apply('bar = "bar"'); + + expect(element).toHaveClass('foo'); + expect(element).toHaveClass('bar'); + expect(getProp).not.toHaveBeenCalled(); + + $rootScope.$apply('veryLargeObj.bar = "qux"'); + + expect(element).toHaveClass('foo'); + expect(element).toHaveClass('bar'); expect(getProp).not.toHaveBeenCalled(); })); });