Skip to content
This repository was archived by the owner on Apr 12, 2024. It is now read-only.

ngClass[Odd/Even] overhaul #15228

Closed
wants to merge 15 commits into from
Closed

Conversation

gkalpak
Copy link
Member

@gkalpak gkalpak commented Oct 8, 2016

What kind of change does this PR introduce? (Bug fix, feature, docs update, ...)
Fix, Test, Refactor, Perf

What is the current behavior? (You can also link to an open issue here)
Broken, inefficient.

What is the new behavior (if this is a feature change)?
Fixed, efficient.

Does this PR introduce a breaking change?
No (to the best of my knowledge).

Please check if the PR fulfills these requirements

Other information:
Supercedes (and partly based on) #14404. Mad props to @drpicox for the initial idea and implementation.

This PR has been broken up in smaller, focused commits for easier reviewing. Better to review commit by commit (and probably in order). Most commits are (more or less) trivial - except for the last one.

Refactoring commits:

  • 8822006: refactor(ngClass): remove unnecessary dependency on $animate
  • 0409589: refactor(ngClass): remove redundant $observer
  • 2871a11: refactor(ngClass): simplify conditions
  • a7e69f7: refactor(ngClass): move helper functions outside the closure
  • 37cec7a: refactor(ngClass): exit arrayDifference() early if an input is empty

Test commits:

  • 2705085: test(ngClass): add some tests about one-time bindings and objects inside arrays

Fix commits:

  • 0ba4be6: fix(ngClassOdd/Even): keep track of classes even if odd/even do not match index

Perf commits:

  • 89268af: perf(ngClass): only access the element's data once
  • 5708424: perf(ngClass): avoid deep-watching (if possible) and unnecessary copies

Closes #14404

The code was added in b41fe9f in order to support having both `ngClass` and
interpolation in `class` work together. `ngClass` has changed considerably since
b41fe9f and for simple cases to work the `$observe`r is no longer necessary (as
indicated by the expanded test still passing).

That said, it is a [documented known issue][1] that `ngClass` should not be used
together with interpolation in `class` and more complicated cases do not work
anyway.

[1]: https://docs.angularjs.org/api/ng/directive/ngClass#known-issues
}

attr.$addClass(toAdd);
attr.$removeClass(toRemove);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I never knew about these helpers!!

@@ -1,6 +1,6 @@
'use strict';

describe('ngClass', function() {
fdescribe('ngClass', function() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naught naughty

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I already have a fixup commit at the end 😛

expect(e2).not.toHaveClass('four');
expect(e2).toHaveClass('five');

$rootScope.$apply('two = "too"');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

although this is very clever "two" and "too" are very similar and actually make the test harder to follow.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cleverness credits go to the original test 😃
I can change it.

})
);

it('should do value remove the watcher when static map one-time binding',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the test description here seems like a hybrid of the two tests above :-)

inject(function($rootScope, $compile) {
$rootScope.classVar = [{orange: true}];
element = $compile('<div ng-class="classVar"></div>')($rootScope);
$rootScope.$digest();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth adding an expectation that it should have the class "orange" here?

@@ -462,6 +462,47 @@ fdescribe('ngClass', function() {
expect(e2.hasClass('odd')).toBeFalsy();
}));


it('should keep track of old classes even if odd/even does not match `$index` atm',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This description makes hard to understand what is actually being tested.

Are you saying that ngClass is not removing old classes when both the $index and the expression value changes?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be because I was trying to solve it differently at first. Here is what was happening before this change:

  1. Assume ng-class-odd="foo", $index = 0, foo = 'foo'. The element has className === 'foo'.
  2. If the $index and foo change simultaneously, say $index = 1, foo = 'bar', then:
    1. The attr[name] watch kicks in first and it checks if scope.$index & 1 === selector (i.e. it uses the current value of $index to determine if it needs to update the classes). It decides it doesn't need to do anything, so at this point the element still has className === 'foo'.
    2. The $index watch kicks in. Since it determines that $index & 1 !== old$index & 1, it decides to remove the classes. But it tries to scope.$eval(attr[name]) which now evaluates to 'bar'. So the element will still have className === 'foo'.

Basically, the problem is that both watch-actions check against the current value of each other to determine what to do, which fails when both change in the same digest.
I.e. the attr[name] watch uses scope.$index (while it should use the previous index) and the $index watch uses scope.$eval(attr[name]) (while it should use the previous value - the "old classes").

The commit fxes it by:

  1. Moving the $index watch before the attr[name] watch.
  2. Using oldVal from the $index watch to add/remove.

This way, when the $index & 1 changes, the $index watch will correctly add/remove the classes and then the attr[name] watch will update the classes (i.e. add the new ones) if necessary.

I hope this makes sense 😃

How about:

It should add/remove the correct classes when the expression and $index change simultaneously

Copy link
Contributor

@petebacondarwin petebacondarwin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When using large objects as keys (e.g.: {loaded: $ctrl.data}).

I think you mean "When using large objects as values...", right?

@@ -1,6 +1,6 @@
'use strict';

fdescribe('ngClass', function() {
describe('ngClass', function() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ahah!

@petebacondarwin
Copy link
Contributor

A few minor comments but generally looks good to me.

The cases that should benefit most are:

1. When using large objects as values (e.g.: `{loaded: $ctrl.data}`).
2. When using objects/arrays and there are frequent changes.
3. When there are many `$index` changes (e.g. addition/deletion/reordering in large `ngRepeat`s).

The differences in operations per digest include:

1. `Regular expression (when not changed)`
   **Before:** `equals()`
   **After:**  `toClassString()`

2. `Regular expression (when changed)`
   **Before:** `copy()` + 2 x `arrayClasses()` + `shallowCopy()`
   **After:**  2 x `split()`

3. `One-time expression (when not changed)`
   **Before:** `equals()`
   **After:**  `toFlatValue()` + `equals()`*

4. `One-time expression (when changed)`
   **Before:** `copy()` + 2 x `arrayClasses()` + `shallowCopy()`
   **After:**  `copy()`* + `toClassString()`* + 2 x `split()`

5. `$index modulo changed`
   **Before:** `arrayClasses()`
   **After:**  -

(*): on flatter structure

In large based on angular#14404. Kudos to @drpicox for the initial idea and a big part
of the implementation.

Closes angular#14404
…do not match index

Change commit message to:
fix(ngClassOdd/Even): add/remove the correct classes when expression/`$index` change simultaneously
@gkalpak
Copy link
Member Author

gkalpak commented Oct 8, 2016

Thx for the review @petebacondarwin, @jbedard. I have added some fixup/squash commits addressing the comments (except for the parse interceptor for $index & 1).

PTAL

@petebacondarwin
Copy link
Contributor

LGTM

function arrayDifference(tokens1, tokens2) {
var values = [];
// Helpers
function arrayDifference(tokens1, tokens2) {
Copy link

@w-sz w-sz Oct 9, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this will be better ?

function arrayDifference(tokens1, tokens2) {
      var tokens1Length = tokens1.length, tokens2Length = tokens2.length;

      if (!tokens1 || !tokens1Length) return [];
      if (!tokens2 || !tokens2Length) return tokens1;

      var values = [];

      outer:
        for (var i = 0; i < tokens1Length; i++) {
          var token = tokens1[i];
          for (var j = 0; j < tokens2Length; j++) {
            if (token === tokens2[j]) continue outer;
          }
          values.push(token);
        }

      return values;
    }

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about that too. The "problem" is that tokens1/tokens2 can be undefined. So, "caching" their length gets more complicated (to the point it shouldn't make any difference).

I also thought about just caching it for the for loop (e.g. for (var i = 0, ii = tokens1.length; i < ii; i++)), but I think the VM will detect that there is nothing mutating .length inside the loop and make that optimisation itself (this is just an assumption - maybe I am wrong).

@gkalpak
Copy link
Member Author

gkalpak commented Oct 11, 2016

Closing in favor of #15246 (which is just a clean-up version of this).
All comments and discussions here apply to #15246 as well.

Thx for the reviews everyone!

@gkalpak gkalpak closed this Oct 11, 2016
@gkalpak gkalpak deleted the ngClass-overhaul branch December 9, 2016 10:12
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants