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

perf(ngClass): refactor to optimize the case of static map of classes with large objects #14404

Closed
wants to merge 2 commits into from

Conversation

drpicox
Copy link
Contributor

@drpicox drpicox commented Apr 9, 2016

What kind of change does this PR introduce? (Bug fix, feature, docs update, ...): performance improvement

What is the current behavior? (You can also link to an open issue here): it makes copies of very large objects:

    it('should not copy verylargeobject', inject(function($rootScope, $compile) {
      var getProp = jasmine.createSpy('getProp');
      var verylargeobject = {};
      Object.defineProperty(verylargeobject, 'prop', {
        get: getProp,
        enumerable: true
      });
      element = $compile('<div ng-class="{foo: verylargeobject}"></div>')($rootScope);
      $rootScope.verylargeobject = verylargeobject;
      $rootScope.$digest();

      expect(getProp).not.toHaveBeenCalled();
    }));

What is the new behavior (if this is a feature change)?: the user perceives no change but that it is faster with very large objects

Does this PR introduce a breaking change?: no

Please check if the PR fulfills these requirements

Other information:
This PR responds to #14394 (comment)
It is alternative to #14394

@@ -409,6 +409,78 @@ describe('ngClass', function() {
expect(e2.hasClass('even')).toBeTruthy();
expect(e2.hasClass('odd')).toBeFalsy();
}));

it('should do value stabilization as expected when one-time binding', inject(function($rootScope, $compile) {
Copy link
Member

Choose a reason for hiding this comment

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

Please, wrap lines at 100 chars.

@drpicox
Copy link
Contributor Author

drpicox commented Apr 10, 2016

For one time binding is getting hard, I just cannot previously convert to a plain string because of:

  it('stabilizes when in a literal are not undefineds', inject(function($rootScope, $compile) {
    $rootScope.foo = true;
    element = $compile('<div ng-class="::{foo:foo,bar:bar}"></div>')($rootScope);
    $rootScope.$digest();
    expect(element).toHaveClass('foo');
    $rootScope.bar = true;
    $rootScope.$digest();
    expect(element).toHaveClass('bar');
    $rootScope.bar = false;
    $rootScope.$digest();
    expect(element).toHaveClass('bar');
  }));

In the current implementation the watcher watches a string which cannot contain undefineds. I will change the strategy. Fortunately it is only one level:

  // this test fails (nested undefineds)
  it('stabilises when nested?', inject(function($rootScope, $compile) {
    $rootScope.foo = true;
    element = $compile('<div ng-class="::[{foo:foo,bar:bar}]"></div>')($rootScope);
    $rootScope.$digest();
    expect(element).toHaveClass('foo');
    $rootScope.bar = true;
    $rootScope.$digest();
    expect(element).toHaveClass('bar');
    $rootScope.bar = false;
    $rootScope.$digest();
    expect(element).toHaveClass('bar');
  }));

from parse.js (see that isAllDefined is one level, not nested):

    function oneTimeLiteralWatchDelegate(scope, listener, objectEquality, parsedExpression) {
      var unwatch, lastValue;
      return unwatch = scope.$watch(function oneTimeWatch(scope) {
        return parsedExpression(scope);
      }, function oneTimeListener(value, old, scope) {
        lastValue = value;
        if (isFunction(listener)) {
          listener.call(this, value, old, scope);
        }
        if (isAllDefined(value)) {
          scope.$$postDigest(function() {
            if (isAllDefined(lastValue)) unwatch();
          });
        }
      }, objectEquality);

      function isAllDefined(value) {
        var allDefined = true;
        forEach(value, function(val) {
          if (!isDefined(val)) allDefined = false;
        });
        return allDefined;
      }
    }

@drpicox drpicox force-pushed the perf-ngClass-watchExpr branch 2 times, most recently from d01e0c9 to 6b4ba2b Compare July 21, 2016 12:50
@drpicox
Copy link
Contributor Author

drpicox commented Jul 21, 2016

@gkalpak ¿can you take a look to:

it seems that the following test fails randomly in Firefox:

[firefox 28 #2] ..F...............................................................................................................................................................
[firefox 28 #2] 
[firefox 28 #2] Failures:
[firefox 28 #2] 1) $anchorScroll with `yOffset` should automatically scroll when navigating to a URL with a hash
[firefox 28 #2]   Message:
[firefox 28 #2]     Expected #anchor-5's top to be 50, but it was 208
[firefox 28 #2]   Stack:
[firefox 28 #2]     Error: Failed expectation
[firefox 28 #2]         at /home/travis/build/angular/angular.js/test/e2e/tests/anchor-scroll.spec.js:113:32
[firefox 28 #2]         at /home/travis/build/angular/angular.js/test/e2e/tests/anchor-scroll.spec.js:160:7

@drpicox drpicox force-pushed the perf-ngClass-watchExpr branch 4 times, most recently from 64bae8d to 0a5b49f Compare July 21, 2016 14:52
@gkalpak
Copy link
Member

gkalpak commented Jul 21, 2016

Yeah, I've seen it too. I thought these were flakes. I mean, they are flakes - but they seem to be more common than other flakes. These tests are "flake-prone".

Maybe #14912 will make them go away.

@drpicox drpicox force-pushed the perf-ngClass-watchExpr branch from 0a5b49f to 4d4c88c Compare July 21, 2016 15:35
@drpicox
Copy link
Contributor Author

drpicox commented Jul 21, 2016

Indeed. Firefox 28 is quite old :D

@gkalpak
Copy link
Member

gkalpak commented Jul 21, 2016

It's green now.

@Narretz
Copy link
Contributor

Narretz commented Sep 8, 2016

Did this LGTY back in July @gkalpak ?

@Narretz
Copy link
Contributor

Narretz commented Sep 8, 2016

@drpicox can you please rebase this?

@gkalpak
Copy link
Member

gkalpak commented Sep 8, 2016

Did this LGTY back in July @gkalpak ?

Not if I didn't say so 😃
(I got caught up in other stuff and I never gave this a final review. I'll take a look asap.)

@drpicox
Copy link
Contributor Author

drpicox commented Sep 9, 2016

Ops!

I'll do it now.

@drpicox drpicox force-pushed the perf-ngClass-watchExpr branch from 4d4c88c to 5fe61c2 Compare September 9, 2016 10:22
@drpicox
Copy link
Contributor Author

drpicox commented Sep 9, 2016

Rebased. Waiting for travis tests.

@drpicox
Copy link
Contributor Author

drpicox commented Sep 9, 2016

Green.

@gkalpak gkalpak self-assigned this Oct 6, 2016
Copy link
Member

@gkalpak gkalpak left a comment

Choose a reason for hiding this comment

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

There a couple of issues with this implementation.

TBH, I feel like there should be a more straight-forward way to achieve the same. I'll poke around.

attr.$observe('class', function(value) {
ngClassWatchAction(scope.$eval(attr[name]));
compile: function(tElement, tAttr) {
var classFn = $parse(tAttr[name], function(value) {
Copy link
Member

Choose a reason for hiding this comment

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

Having this in compile is an (unnecessary) breaking chage: Previously other directives would be able to modify the value before ngClass' linking. (Unlikely that anyone is actually relying on it, but we don't gain anything by breaking it either.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I did it because of performance reasons: ng-class are often used inside ngRepeat in which compile is executed once but link many. It can be undone.

Copy link
Member

Choose a reason for hiding this comment

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

The overhead should be negligible (and not enough to justify the breaking change).

(Note that $parse has a cache, so it doesn't have to actually lex/parse the same expressiom twice. It only has to retrieve the parsed expression and apply the interceptor.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll put it again inside a link function.
I suggest that it can be considered a "major upgrade" to do such refactor of all parse expression code from link functions to compile functions, in order to improve performance when ng-repeat & others are involved.

/* eslint-enable */
});
}
scope.$watch(classFn, ngClassWatchAction, classArrayEquals);
Copy link
Member

Choose a reason for hiding this comment

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

The 3rd argument is expected to be boolean. Passing a function is interpreted as true.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

sure? It dit it some time ago, so I'm not sure, but I remember look for this code inside angular and the third argument become the comparator if was not true (some kind of undocumented function). I'll check.

Copy link
Member

Choose a reason for hiding this comment

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

I think there is a PR for adding that feature, but right now it is treated a boolean.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll check, I was wrong. It takes classArrayEquals, using true, all tests passes as expected, no extra copies performed (I have also check the $watch code).
So it is updated, Thanks.

} else if (!classArrayEquals(oldClasses, newClasses)) {
updateClasses(oldClasses, newClasses);
}
oldClasses = shallowCopy(newClasses);
Copy link
Member

Choose a reason for hiding this comment

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

By not storing the oldClasses when (scope.$index & 1) !== selector, you could end up with an outdated oldClasses value when the $index changes. There is a similar bug currently in the scope.$index watch above.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll try to write a test to make it fail and fix it.

Copy link
Contributor Author

@drpicox drpicox Oct 7, 2016

Choose a reason for hiding this comment

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

Trying to find a test to make it break, I found a case that breaks angular in older versions (at least since 1.3.8 -I didn't tried more-). Is the case of the first scope.$watch that you comment.
I have the plnkr here: https://plnkr.co/edit/W1ck8dS08TCJpirWiGVA?p=preview
I solve it here, but I do not know if I should create another PR for solving it outside this PR, what did you suggest?

I tried to find a failing test for the shallow copy inside the condition so I should move it. But I have found that moving the shallow copy outside the condition interferes with the $index $watch and make the new test fails.

})
);

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

Choose a reason for hiding this comment

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

should do value remove --> should remove

Copy link
Contributor Author

Choose a reason for hiding this comment

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

thanks

})
);

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

Choose a reason for hiding this comment

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

should do value remove --> should remove

Copy link
Contributor Author

Choose a reason for hiding this comment

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

thanks

$rootScope.classVar[0].orange = false;
$rootScope.$digest();

expect(element).not.toHaveClass('orange');
Copy link
Member

Choose a reason for hiding this comment

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

Check that the class was present before changing orange to false.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ok

@drpicox drpicox force-pushed the perf-ngClass-watchExpr branch 2 times, most recently from e0d7ea8 to 510021d Compare October 7, 2016 22:14
gkalpak added a commit to gkalpak/angular.js that referenced this pull request Oct 8, 2016
gkalpak added a commit to gkalpak/angular.js that referenced this pull request Oct 8, 2016
The cases that should benefit most are:

1. When using large objects as keys (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
@gkalpak gkalpak mentioned this pull request Oct 8, 2016
3 tasks
@gkalpak
Copy link
Member

gkalpak commented Oct 8, 2016

I have an alternative PR (#15228) which includes several refactorings, the fix mentioned above and some perf improvements (that should cover the ones in this PR and more).

I have "borrowed" the tests and part of the implementation 😁

WDYT?

gkalpak added a commit to gkalpak/angular.js that referenced this pull request Oct 8, 2016
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
@drpicox
Copy link
Contributor Author

drpicox commented Oct 8, 2016

You have done a really good job 👍

@drpicox drpicox force-pushed the perf-ngClass-watchExpr branch from 510021d to 01e395a Compare October 10, 2016 18:58
gkalpak added a commit to gkalpak/angular.js that referenced this pull request Oct 11, 2016
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
@gkalpak gkalpak closed this in b820970 Dec 9, 2016
gkalpak added a commit that referenced this pull request Dec 9, 2016
…d copies

Includes the following commits (see #15246 for details):

- **perf(ngClass): only access the element's `data` once**

- **refactor(ngClass): simplify conditions**

- **refactor(ngClass): move helper functions outside the closure**

- **refactor(ngClass): exit `arrayDifference()` early if an input is empty**

- **perf(ngClass): avoid deep-watching (if possible) and unnecessary copies**

  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 #14404. Kudos to @drpicox for the initial idea and a big part
  of the implementation.

Closes #14404

Closes #15246
ellimist pushed a commit to ellimist/angular.js that referenced this pull request Mar 15, 2017
…d copies

Includes the following commits (see angular#15246 for details):

- **perf(ngClass): only access the element's `data` once**

- **refactor(ngClass): simplify conditions**

- **refactor(ngClass): move helper functions outside the closure**

- **refactor(ngClass): exit `arrayDifference()` early if an input is empty**

- **perf(ngClass): avoid deep-watching (if possible) and unnecessary copies**

  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

Closes angular#15246
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.

4 participants