-
Notifications
You must be signed in to change notification settings - Fork 27.4k
perf(input): prevent multiple validations on compilation #16760
Conversation
985dc51
to
4ca5cf2
Compare
src/ng/directive/input.js
Outdated
parsedMinVal = parseObservedDateValue(val); | ||
ctrl.$validate(); | ||
} | ||
minVal = val; |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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
? 🤔
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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.
4ca5cf2
to
f2aa98a
Compare
There was a problem hiding this 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.
test/ng/directive/ngModelSpec.js
Outdated
@@ -1246,6 +1245,7 @@ describe('ngModel', function() { | |||
dealoc(element); | |||
})); | |||
|
|||
|
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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 = {};
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
src/ng/rootScope.js
Outdated
@@ -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); |
There was a problem hiding this comment.
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
src/ng/directive/validators.js
Outdated
regex = new RegExp('^' + regex + '$'); | ||
} | ||
|
||
if (regex && !regex.test) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 ;)
src/ng/directive/validators.js
Outdated
regex, startingTag(elm)); | ||
} | ||
|
||
return regex || undefined; |
There was a problem hiding this comment.
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;
}
src/ng/directive/validators.js
Outdated
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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
typo: unnessecary -> unnecessary
There was a problem hiding this comment.
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.
src/ng/directive/validators.js
Outdated
// 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); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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
.
There was a problem hiding this 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?
src/ng/directive/ngRepeat.js
Outdated
@@ -1,4 +1,4 @@ | |||
'use strict'; | |||
'use strict'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤔
src/ng/directive/ngRepeat.js
Outdated
@@ -531,6 +531,8 @@ var ngRepeatDirective = ['$parse', '$animate', '$compile', function($parse, $ani | |||
|
|||
//watch props | |||
$scope.$watchCollection(rhs, function ngRepeatAction(collection) { | |||
// console.log('ngRepeat action') |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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?
src/ng/directive/input.js
Outdated
// TODO(matsko): implement validateLater to reduce number of validations | ||
ctrl.$validate(); | ||
if (stepVal !== val) { |
There was a problem hiding this comment.
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 😛
test/ng/directive/validatorsSpec.js
Outdated
|
||
expect(helper.validationCounter.required).toBe(1); | ||
|
||
helper.validationCounter = {}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this necessary?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, please 😁
src/ng/directive/validators.js
Outdated
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The tests agree :)
src/ng/directive/validators.js
Outdated
function parsePatternAttr(regex, patternExp, elm) { | ||
if (!regex) return undefined; | ||
|
||
if (isString(regex) && regex.length > 0) { |
There was a problem hiding this comment.
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.
src/ng/directive/validators.js
Outdated
regex = new RegExp('^' + regex + '$'); | ||
} | ||
|
||
if (!(regex instanceof RegExp)) { |
There was a problem hiding this comment.
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 😁).
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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 😁
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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
.)
src/ng/directive/validators.js
Outdated
try { | ||
parseFn = $parse(tAttr.ngPattern); | ||
} catch (e) { | ||
if (/^\[\$parse:lexerr\]/.test(e.message)) { |
There was a problem hiding this comment.
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:
angular.js/src/ng/directive/attrs.js
Lines 392 to 395 in 0687a4f
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])); |
src/ng/directive/validators.js
Outdated
regex = new RegExp('^' + regex + '$'); | ||
} | ||
|
||
if (!(regex instanceof RegExp)) { |
There was a problem hiding this comment.
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?
test/ng/directive/validatorsSpec.js
Outdated
|
||
expect(helper.validationCounter.required).toBe(1); | ||
|
||
helper.validationCounter = {}; |
There was a problem hiding this comment.
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
038da95
to
4d82020
Compare
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
This commit updates in-built validators with observers to prevent
multiple calls to $validate that could happen after compilation in
certain circumstances:
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
Other information: