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

perf(input): prevent multiple validations on compilation #16760

Merged
merged 7 commits into from
Dec 5, 2018

Conversation

Narretz
Copy link
Contributor

@Narretz Narretz commented Nov 15, 2018

This commit updates in-built validators with observers to prevent
multiple calls to $validate that could happen after compilation in
certain circumstances:

  • when an input is wrapped in a transclude: element directive (e.g. ngRepeat),
    the order of execution between ngModel and the input / validation directives changes so that
    the initial observer call happens when ngModel has already been initalized,
    leading to another call to $validate, which calls all defined validators again.
    Without ngRepeat, ngModel hasn't been initialized yet, and $validate does not call the validators.

When using validators with scope expressions, the expression value is not available when
ngModel first runs the validators (e.g. ngMinlength="myMinlength"). Only in the first call to
the observer does the value become available, making a call to $validate a necessity.

This commit solves the first problem by storing the validation attribute value so we can compare
the current value and the observed value - which will be the same after compilation.

The second problem is solved by parsing the validation expression once in the link function,
so the value is available when ngModel first validates.

Closes #14691

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

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

What is the new behavior (if this is a feature change)?

Does this PR introduce a breaking change?

Please check if the PR fulfills these requirements

  • The commit message follows our guidelines
  • Fix/Feature: Docs have been added/updated
  • Fix/Feature: Tests have been added; existing tests pass

Other information:

Sorry, something went wrong.

parsedMinVal = parseObservedDateValue(val);
ctrl.$validate();
}
minVal = val;
Copy link
Collaborator

@jbedard jbedard Nov 15, 2018

Choose a reason for hiding this comment

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

These could go within the if.

Any reason to do it after the $validate call instead of along with the parsedMinValue assignment?

return {
restrict: 'A',
require: '?ngModel',
link: function(scope, elm, attr, ctrl) {
if (!ctrl) return;
var oldVal = attr.required || $parse(attr.ngRequired)(scope);
Copy link
Collaborator

Choose a reason for hiding this comment

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

What if attr.required is false? 🤔

Copy link
Contributor

Choose a reason for hiding this comment

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

I believe that attr.required === false can't occur, right? If the attribute exists at all then, being a boolean attribute, its value will be 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.

That's right, the value of the required attribute is ignored.

@Narretz Narretz force-pushed the fix-validate-multiple branch from 4ca5cf2 to f2aa98a Compare November 15, 2018 20:04
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.

Generally LGTM. A few minor suggestions.

@@ -1246,6 +1245,7 @@ describe('ngModel', function() {
dealoc(element);
}));


Copy link
Contributor

Choose a reason for hiding this comment

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

Unnecessary whitespace changes?

var validationName = attrs.validationSpy;

var originalValidator = ctrl.$validators[validationName];
helper.validationCounter[validationName] = 0;
Copy link
Contributor

Choose a reason for hiding this comment

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

Because of this line, do you need to reset the validationCounter in the middle of tests?

helper.validationCounter = {};

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 do it in the middle of tests when I test the normal and the ng- variation of a validator in the same test.

Copy link
Contributor

Choose a reason for hiding this comment

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

Right but in those tests you are recompiling so the directive will be linked again, causing the object to be reset. But I guess it does no harm to reset it in the test as well.

@@ -828,6 +830,7 @@ function $RootScopeProvider() {
lastDirtyWatch = watch;
watch.last = watch.eq ? copy(value, null) : value;
fn = watch.fn;
// console.log('call watch fn', watch.exp, fn);
Copy link
Contributor

Choose a reason for hiding this comment

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

I assume these will disappear before merging :-P

regex = new RegExp('^' + regex + '$');
}

if (regex && !regex.test) {
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 not possible to test for a regex using regex instanceof RegExp? Or do we support non-regex objects that happen to have a test() method?

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 can dig in the code to see if there's a reason for this (not my change ;)

regex, startingTag(elm));
}

return regex || undefined;
Copy link
Contributor

Choose a reason for hiding this comment

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

I think you could simplify this function by bailing out early if the regex is falsy:

function parsePatternAttr(regex, patternExp, elm) {
  if (!regex) return undefined;
  if (isString(regex)) {
    regex = new RegExp('^' + regex + '$');
  }
  if (!regex.test) {
    throw minErr('ngPattern')('noregexp',
      'Expected {0} to be a RegExp but was {1}. Element: {2}', patternExp,
      regex, startingTag(elm));
  }
  return regex;
}

regex, startingTag(elm));
// ngPattern might be a scope expressions, or an inlined regex, which is not parsable.
// We get value of the attribute here, so we can compare the old and the new value
// in the observer to avoid unnessecary validations
Copy link
Contributor

Choose a reason for hiding this comment

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

typo: unnessecary -> unnecessary

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This word is the bane of my existence.

// We get value of the attribute here, so we can compare the old and the new value
// in the observer to avoid unnessecary validations
try {
attrVal = $parse(attr.ngPattern)(scope);
Copy link
Contributor

Choose a reason for hiding this comment

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

Does the error get thrown in the call to $parse() or to the call on the result of parse $parse(...)(scope)?
If the former then we could move this block to a the compile function, so it is only executed once rather than per instance in an ngRepeat, say.

return {
restrict: 'A',
require: '?ngModel',
link: function(scope, elm, attr, ctrl) {
if (!ctrl) return;
var oldVal = attr.required || $parse(attr.ngRequired)(scope);
Copy link
Contributor

Choose a reason for hiding this comment

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

I believe that attr.required === false can't occur, right? If the attribute exists at all then, being a boolean attribute, its value will be true.

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.

I generally like the idea of not re-validating if the values haven't changed (as long as we can guarantee that no validations are skipped when they shouldn't).

I am not a big fan of manually parsing values though. Couldn't we avoid that and instead rely on the parsing done inside the observers?

@@ -1,4 +1,4 @@
'use strict';
'use strict';
Copy link
Member

Choose a reason for hiding this comment

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

🤔

@@ -531,6 +531,8 @@ var ngRepeatDirective = ['$parse', '$animate', '$compile', function($parse, $ani

//watch props
$scope.$watchCollection(rhs, function ngRepeatAction(collection) {
// console.log('ngRepeat action')
Copy link
Member

Choose a reason for hiding this comment

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

😱

};

attr.$observe('step', function(val) {
stepVal = parseNumberAttrVal(val);
// TODO(matsko): implement validateLater to reduce number of validations
Copy link
Member

Choose a reason for hiding this comment

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

Move to before ctrl.$validate() for consistency?

// TODO(matsko): implement validateLater to reduce number of validations
ctrl.$validate();
if (stepVal !== val) {
Copy link
Member

Choose a reason for hiding this comment

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

Come one! You are using val !== ... is all other places 😛


expect(helper.validationCounter.required).toBe(1);

helper.validationCounter = {};
Copy link
Member

Choose a reason for hiding this comment

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

Is this necessary?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not really. Should I remove it?

Copy link
Member

Choose a reason for hiding this comment

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

Yes, please 😁

return {
restrict: 'A',
require: '?ngModel',
link: function(scope, elm, attr, ctrl) {
if (!ctrl) return;

var minlength = 0;
var minlength = attr.minlength || $parse(attr.ngMinlength)(scope);
var minlengthParsed = toInt(minlength) || 0;
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 it is safe to fall back to -1 and thus be able to re-use parseLength() here as well.
If not, then parseLength() should be renamed to make it obvious it is not suitable for all kinds of lengths.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The tests agree :)

function parsePatternAttr(regex, patternExp, elm) {
if (!regex) return undefined;

if (isString(regex) && regex.length > 0) {
Copy link
Member

Choose a reason for hiding this comment

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

Since there is a !regex check above, the regex.length > 0 check is redundant.

regex = new RegExp('^' + regex + '$');
}

if (!(regex instanceof RegExp)) {
Copy link
Member

Choose a reason for hiding this comment

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

Checking for instanceof RegExp instead of regep.test looks like a breaking change (and an unnecessary one afaict 😁).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We only allow RegExp and regex literals, which both are identified correctly by instanceof. Or could there be another reason for having .test instead?

Copy link
Member

@gkalpak gkalpak Nov 21, 2018

Choose a reason for hiding this comment

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

I mean someone might be taking advantage of the fact that we were only testing for .test and provide a custom object like {test: () => /* Do some fancy stuff */}.
I know it is not a great idea, but I also know I've done it in the past 😁

What I am saying is that I don't see any benefit in breaking this (admittedly dubious) usecase at this point (a few months into LTS already).
Is there any benefit in changing that?

var parseFn;

if (tAttr.ngPattern) {
patternExp = tAttr.ngPattern;
Copy link
Member

Choose a reason for hiding this comment

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

Previously, patternExp would also be set to attr.pattern (if attr.ngPattern was undefined).
Why is this changed?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

if ngPattern isn't set, there can be no pattern expression, because pattern can only have a string value.

Copy link
Member

Choose a reason for hiding this comment

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

Theoretically, it can have any value 😁
(E.g. imagine a custom myPattern directive, that sets $attrs.pattern.)

@@ -395,11 +395,11 @@ var minlengthDirective = function($parse) {
if (!ctrl) return;

var minlength = attr.minlength || $parse(attr.ngMinlength)(scope);
var minlengthParsed = toInt(minlength) || 0;
var minlengthParsed = parseLength(minlength) || -1;
Copy link
Member

Choose a reason for hiding this comment

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

The || -1 part is redundant, since parseLength() will take care of that (if needed).

// TODO(matsko): implement validateLater to reduce number of validations
ctrl.$validate();
if (val !== stepVal) {
Copy link
Member

Choose a reason for hiding this comment

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

I suspect that we should be also checking for minVal here, because the step validation depends on that.

Copy link
Contributor Author

@Narretz Narretz Nov 23, 2018

Choose a reason for hiding this comment

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

You mean ensure that the minVal has changed? That doesn't work, because we need to run this also if the minVal is the same.
This is a good point though - the changes in this PR only prevent multiple calls to $validate immediately after compilation / linking. If you have a range or number with more than one modifier, i.e. step, max, min and change all values in a single digest, every observer willcall $validate.
I don't think there's an easy way to prevent this, because the observers are independent of each other. Maybe that's what Matias meant with validateLater - a way to collect the $validate calls of a single "validation group" (For the record, I thought he meant a function that collects all $validate calls on an element).
Long story short, I don't think we can do anything about this without making bigger changes. I can adjust the commit message to reflect that changing multiple observed values will still result in multiple validate calls. (I believe this happens much rarer though, so the PR is still worthwhile).

Copy link
Member

@gkalpak gkalpak Nov 26, 2018

Choose a reason for hiding this comment

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

No, I don't meant ensure that minVal has changed. What I meant is that that validator needs to run if either of minVal or stepVal has changed. I.e. even if stepVal is the same, but minVal has changed.

I am pretty sure that indeed Matias meant avoid multiple calls to $validate() 😁
We could easily do this by running $validate() asynchronously (e.g. adding it in the $postDigest queue and only if it is not already scheduled), but that would be a breaking change (which does not make sense at this point).

We could use a similar technique in the validators as this PR uses in observers:
Keep track of the involved values (e.g. module/viewValue, validators params, etc) and do not re-run validation unless the values have changed. But that would complicate things even further, open up new possibilities for memory leaks (if not handled carefully) and would also not help with the stateful async validators (which are usually the most expensive ones). So, again, not worth it atm.

I believe this happens much rarer though, so the PR is still worthwhile.

Actually, I believe it will happen all the time 😛
Basically, afaict, saves some (half) of the re-runs during initialization, but does nothing to prevent multiple runs as the user interacts with the input (which is where the majority of runs comes from throughout the lifetime of an input).

If we can keep it clean and BC-free (with reasonable confidence 😁), then it is definitely worth landing. But I wouldn't go to great extends, complicating the internal logic too much etc., just to save a few runs 😁

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No, I don't me ensure that minVal has changed. What I meant is that that validator needs to run if either of minVal or stepVal has changed. I.e. even if stepVal is the same, but minVal has changed.

Right, but if the minVal has changed, it will call $validate, which will also run the step validator. Since we are not fixing multiple runs after initialization , we don't have to deal with this,

Actually, I believe it will happen all the time

It will only call $validate multiple times if observed values change, which I think is much rarer than initialization, which happens always. ;)
If you change the input value, only one call to $validate from inside ngModel will be made.
The issue that this addresses (#14691) is also about load / initialization, not interaction primarily.
Initially I also thought about using postDigest, but as you said, it's too late for that now.

attr.$observe(htmlAttrName, changeFn);
var oldVal = attr[htmlAttrName];
attr.$observe(htmlAttrName, function wrappedObserver(val) {
if (val !== oldVal) {
Copy link
Member

Choose a reason for hiding this comment

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

Same as above: I believe minVal should be checked along with stepVal.

var parseFn;

if (tAttr.ngPattern) {
patternExp = tAttr.ngPattern;
Copy link
Member

Choose a reason for hiding this comment

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

Theoretically, it can have any value 😁
(E.g. imagine a custom myPattern directive, that sets $attrs.pattern.)

try {
parseFn = $parse(tAttr.ngPattern);
} catch (e) {
if (/^\[\$parse:lexerr\]/.test(e.message)) {
Copy link
Member

Choose a reason for hiding this comment

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

@Narretz, yes, I suggest we re-use that bit of logic:

if (ngAttr === 'ngPattern' && attr.ngPattern.charAt(0) === '/') {
var match = attr.ngPattern.match(REGEX_STRING_REGEXP);
if (match) {
attr.$set('ngPattern', new RegExp(match[1], match[2]));

regex = new RegExp('^' + regex + '$');
}

if (!(regex instanceof RegExp)) {
Copy link
Member

@gkalpak gkalpak Nov 21, 2018

Choose a reason for hiding this comment

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

I mean someone might be taking advantage of the fact that we were only testing for .test and provide a custom object like {test: () => /* Do some fancy stuff */}.
I know it is not a great idea, but I also know I've done it in the past 😁

What I am saying is that I don't see any benefit in breaking this (admittedly dubious) usecase at this point (a few months into LTS already).
Is there any benefit in changing that?


expect(helper.validationCounter.required).toBe(1);

helper.validationCounter = {};
Copy link
Member

Choose a reason for hiding this comment

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

Yes, please 😁

This commit updates in-built validators with observers to prevent
multiple calls to $validate that could happen after compilation in
certain circumstances:

- when an input is wrapped in a transclude: element directive (e.g. ngRepeat),
the order of execution between ngModel and the input / validation directives changes so that
the initial observer call happens when ngModel has already been initalized,
leading to another call to $validate, which calls *all* defined validators again.
Without ngRepeat, ngModel hasn't been initialized yet, and $validate does not call the validators.

When using validators with scope expressions, the expression value is not available when
ngModel first runs the validators (e.g. ngMinlength="myMinlength"). Only in the first call to
the observer does the value become available, making a call to $validate a necessity.

This commit solves the first problem by storing the validation attribute value so we can compare
the current value and the observed value - which will be the same after compilation.

The second problem is solved by parsing the validation expression once in the link function,
so the value is available when ngModel first validates.

Closes angular#14691

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
@Narretz Narretz force-pushed the fix-validate-multiple branch from 038da95 to 4d82020 Compare December 5, 2018 07:32
@Narretz Narretz merged commit 0637a21 into angular:master Dec 5, 2018
Narretz added a commit that referenced this pull request Dec 5, 2018
This commit updates in-built validators with observers to prevent
multiple calls to $validate that could happen on initial linking of the directives in
certain circumstances:

- when an input is wrapped in a transclude: element directive (e.g. ngRepeat),
the order of execution between ngModel and the input / validation directives changes so that
the initial observer call happens when ngModel has already been initalized,
leading to another call to $validate, which calls *all* defined validators again.
Without ngRepeat, ngModel hasn't been initialized yet, and $validate does not call the validators.

When using validators with scope expressions, the expression value is not available when
ngModel first runs the validators (e.g. ngMinlength="myMinlength"). Only in the first call to
the observer does the value become available, making a call to $validate a necessity.

This commit solves the first problem by storing the validation attribute value so we can compare
the current value and the observed value - which will be the same after compilation.

The second problem is solved by parsing the validation expression once in the link function,
so the value is available when ngModel first validates.

Closes #14691 
Closes #16760
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.

$asyncValidators run multiple times per element on load (given ng-repeat || ng-(min/max)length))
5 participants