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

Commit 6926223

Browse files
committedDec 5, 2018
perf(input): prevent multiple validations on initialization
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
1 parent 5f8372e commit 6926223

File tree

7 files changed

+594
-68
lines changed

7 files changed

+594
-68
lines changed
 

‎src/ng/directive/input.js

+69-29
Original file line numberDiff line numberDiff line change
@@ -1497,7 +1497,7 @@ function createDateParser(regexp, mapping) {
14971497
}
14981498

14991499
function createDateInputType(type, regexp, parseDate, format) {
1500-
return function dynamicDateInputType(scope, element, attr, ctrl, $sniffer, $browser, $filter) {
1500+
return function dynamicDateInputType(scope, element, attr, ctrl, $sniffer, $browser, $filter, $parse) {
15011501
badInputChecker(scope, element, attr, ctrl, type);
15021502
baseInputType(scope, element, attr, ctrl, $sniffer, $browser);
15031503

@@ -1540,24 +1540,34 @@ function createDateInputType(type, regexp, parseDate, format) {
15401540
});
15411541

15421542
if (isDefined(attr.min) || attr.ngMin) {
1543-
var minVal;
1543+
var minVal = attr.min || $parse(attr.ngMin)(scope);
1544+
var parsedMinVal = parseObservedDateValue(minVal);
1545+
15441546
ctrl.$validators.min = function(value) {
1545-
return !isValidDate(value) || isUndefined(minVal) || parseDate(value) >= minVal;
1547+
return !isValidDate(value) || isUndefined(parsedMinVal) || parseDate(value) >= parsedMinVal;
15461548
};
15471549
attr.$observe('min', function(val) {
1548-
minVal = parseObservedDateValue(val);
1549-
ctrl.$validate();
1550+
if (val !== minVal) {
1551+
parsedMinVal = parseObservedDateValue(val);
1552+
minVal = val;
1553+
ctrl.$validate();
1554+
}
15501555
});
15511556
}
15521557

15531558
if (isDefined(attr.max) || attr.ngMax) {
1554-
var maxVal;
1559+
var maxVal = attr.max || $parse(attr.ngMax)(scope);
1560+
var parsedMaxVal = parseObservedDateValue(maxVal);
1561+
15551562
ctrl.$validators.max = function(value) {
1556-
return !isValidDate(value) || isUndefined(maxVal) || parseDate(value) <= maxVal;
1563+
return !isValidDate(value) || isUndefined(parsedMaxVal) || parseDate(value) <= parsedMaxVal;
15571564
};
15581565
attr.$observe('max', function(val) {
1559-
maxVal = parseObservedDateValue(val);
1560-
ctrl.$validate();
1566+
if (val !== maxVal) {
1567+
parsedMaxVal = parseObservedDateValue(val);
1568+
maxVal = val;
1569+
ctrl.$validate();
1570+
}
15611571
});
15621572
}
15631573

@@ -1709,50 +1719,68 @@ function isValidForStep(viewValue, stepBase, step) {
17091719
return (value - stepBase) % step === 0;
17101720
}
17111721

1712-
function numberInputType(scope, element, attr, ctrl, $sniffer, $browser) {
1722+
function numberInputType(scope, element, attr, ctrl, $sniffer, $browser, $filter, $parse) {
17131723
badInputChecker(scope, element, attr, ctrl, 'number');
17141724
numberFormatterParser(ctrl);
17151725
baseInputType(scope, element, attr, ctrl, $sniffer, $browser);
17161726

1717-
var minVal;
1718-
var maxVal;
1727+
var parsedMinVal;
17191728

17201729
if (isDefined(attr.min) || attr.ngMin) {
1730+
var minVal = attr.min || $parse(attr.ngMin)(scope);
1731+
parsedMinVal = parseNumberAttrVal(minVal);
1732+
17211733
ctrl.$validators.min = function(modelValue, viewValue) {
1722-
return ctrl.$isEmpty(viewValue) || isUndefined(minVal) || viewValue >= minVal;
1734+
return ctrl.$isEmpty(viewValue) || isUndefined(parsedMinVal) || viewValue >= parsedMinVal;
17231735
};
17241736

17251737
attr.$observe('min', function(val) {
1726-
minVal = parseNumberAttrVal(val);
1727-
// TODO(matsko): implement validateLater to reduce number of validations
1728-
ctrl.$validate();
1738+
if (val !== minVal) {
1739+
parsedMinVal = parseNumberAttrVal(val);
1740+
minVal = val;
1741+
// TODO(matsko): implement validateLater to reduce number of validations
1742+
ctrl.$validate();
1743+
}
17291744
});
17301745
}
17311746

17321747
if (isDefined(attr.max) || attr.ngMax) {
1748+
var maxVal = attr.max || $parse(attr.ngMax)(scope);
1749+
var parsedMaxVal = parseNumberAttrVal(maxVal);
1750+
17331751
ctrl.$validators.max = function(modelValue, viewValue) {
1734-
return ctrl.$isEmpty(viewValue) || isUndefined(maxVal) || viewValue <= maxVal;
1752+
return ctrl.$isEmpty(viewValue) || isUndefined(parsedMaxVal) || viewValue <= parsedMaxVal;
17351753
};
17361754

17371755
attr.$observe('max', function(val) {
1738-
maxVal = parseNumberAttrVal(val);
1739-
// TODO(matsko): implement validateLater to reduce number of validations
1740-
ctrl.$validate();
1756+
if (val !== maxVal) {
1757+
parsedMaxVal = parseNumberAttrVal(val);
1758+
maxVal = val;
1759+
// TODO(matsko): implement validateLater to reduce number of validations
1760+
ctrl.$validate();
1761+
}
17411762
});
17421763
}
17431764

17441765
if (isDefined(attr.step) || attr.ngStep) {
1745-
var stepVal;
1766+
var stepVal = attr.step || $parse(attr.ngStep)(scope);
1767+
var parsedStepVal = parseNumberAttrVal(stepVal);
1768+
17461769
ctrl.$validators.step = function(modelValue, viewValue) {
1747-
return ctrl.$isEmpty(viewValue) || isUndefined(stepVal) ||
1748-
isValidForStep(viewValue, minVal || 0, stepVal);
1770+
return ctrl.$isEmpty(viewValue) || isUndefined(parsedStepVal) ||
1771+
isValidForStep(viewValue, parsedMinVal || 0, parsedStepVal);
17491772
};
17501773

17511774
attr.$observe('step', function(val) {
1752-
stepVal = parseNumberAttrVal(val);
17531775
// TODO(matsko): implement validateLater to reduce number of validations
1754-
ctrl.$validate();
1776+
if (val !== stepVal) {
1777+
parsedStepVal = parseNumberAttrVal(val);
1778+
stepVal = val;
1779+
ctrl.$validate();
1780+
}
1781+
17551782
});
1783+
17561784
}
17571785
}
17581786

@@ -1782,6 +1810,8 @@ function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) {
17821810
originalRender;
17831811

17841812
if (hasMinAttr) {
1813+
minVal = parseNumberAttrVal(attr.min);
1814+
17851815
ctrl.$validators.min = supportsRange ?
17861816
// Since all browsers set the input to a valid value, we don't need to check validity
17871817
function noopMinValidator() { return true; } :
@@ -1794,6 +1824,8 @@ function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) {
17941824
}
17951825

17961826
if (hasMaxAttr) {
1827+
maxVal = parseNumberAttrVal(attr.max);
1828+
17971829
ctrl.$validators.max = supportsRange ?
17981830
// Since all browsers set the input to a valid value, we don't need to check validity
17991831
function noopMaxValidator() { return true; } :
@@ -1806,6 +1838,8 @@ function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) {
18061838
}
18071839

18081840
if (hasStepAttr) {
1841+
stepVal = parseNumberAttrVal(attr.step);
1842+
18091843
ctrl.$validators.step = supportsRange ?
18101844
function nativeStepValidator() {
18111845
// Currently, only FF implements the spec on step change correctly (i.e. adjusting the
@@ -1827,7 +1861,13 @@ function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) {
18271861
// attribute value when the input is first rendered, so that the browser can adjust the
18281862
// input value based on the min/max value
18291863
element.attr(htmlAttrName, attr[htmlAttrName]);
1830-
attr.$observe(htmlAttrName, changeFn);
1864+
var oldVal = attr[htmlAttrName];
1865+
attr.$observe(htmlAttrName, function wrappedObserver(val) {
1866+
if (val !== oldVal) {
1867+
oldVal = val;
1868+
changeFn(val);
1869+
}
1870+
});
18311871
}
18321872

18331873
function minChange(val) {
@@ -1881,11 +1921,11 @@ function rangeInputType(scope, element, attr, ctrl, $sniffer, $browser) {
18811921
}
18821922

18831923
// Some browsers don't adjust the input value correctly, but set the stepMismatch error
1884-
if (supportsRange && ctrl.$viewValue !== element.val()) {
1885-
ctrl.$setViewValue(element.val());
1886-
} else {
1924+
if (!supportsRange) {
18871925
// TODO(matsko): implement validateLater to reduce number of validations
18881926
ctrl.$validate();
1927+
} else if (ctrl.$viewValue !== element.val()) {
1928+
ctrl.$setViewValue(element.val());
18891929
}
18901930
}
18911931
}

‎src/ng/directive/ngModel.js

+1
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,7 @@ NgModelController.prototype = {
562562
* `$modelValue`, i.e. either the last parsed value or the last value set from the scope.
563563
*/
564564
$validate: function() {
565+
565566
// ignore $validate before model is initialized
566567
if (isNumberNaN(this.$modelValue)) {
567568
return;

‎src/ng/directive/validators.js

+95-35
Original file line numberDiff line numberDiff line change
@@ -62,24 +62,29 @@
6262
* </file>
6363
* </example>
6464
*/
65-
var requiredDirective = function() {
65+
var requiredDirective = ['$parse', function($parse) {
6666
return {
6767
restrict: 'A',
6868
require: '?ngModel',
6969
link: function(scope, elm, attr, ctrl) {
7070
if (!ctrl) return;
71+
var oldVal = attr.required || $parse(attr.ngRequired)(scope);
72+
7173
attr.required = true; // force truthy in case we are on non input element
7274

7375
ctrl.$validators.required = function(modelValue, viewValue) {
7476
return !attr.required || !ctrl.$isEmpty(viewValue);
7577
};
7678

77-
attr.$observe('required', function() {
78-
ctrl.$validate();
79+
attr.$observe('required', function(val) {
80+
if (oldVal !== val) {
81+
oldVal = val;
82+
ctrl.$validate();
83+
}
7984
});
8085
}
8186
};
82-
};
87+
}];
8388

8489
/**
8590
* @ngdoc directive
@@ -162,36 +167,59 @@ var requiredDirective = function() {
162167
* </file>
163168
* </example>
164169
*/
165-
var patternDirective = function() {
170+
var patternDirective = ['$parse', function($parse) {
166171
return {
167172
restrict: 'A',
168173
require: '?ngModel',
169-
link: function(scope, elm, attr, ctrl) {
170-
if (!ctrl) return;
174+
compile: function(tElm, tAttr) {
175+
var patternExp;
176+
var parseFn;
177+
178+
if (tAttr.ngPattern) {
179+
patternExp = tAttr.ngPattern;
171180

172-
var regexp, patternExp = attr.ngPattern || attr.pattern;
173-
attr.$observe('pattern', function(regex) {
174-
if (isString(regex) && regex.length > 0) {
175-
regex = new RegExp('^' + regex + '$');
181+
// ngPattern might be a scope expression, or an inlined regex, which is not parsable.
182+
// We get value of the attribute here, so we can compare the old and the new value
183+
// in the observer to avoid unnecessary validations
184+
if (tAttr.ngPattern.charAt(0) === '/' && REGEX_STRING_REGEXP.test(tAttr.ngPattern)) {
185+
parseFn = function() { return tAttr.ngPattern; };
186+
} else {
187+
parseFn = $parse(tAttr.ngPattern);
176188
}
189+
}
190+
191+
return function(scope, elm, attr, ctrl) {
192+
if (!ctrl) return;
177193

178-
if (regex && !regex.test) {
179-
throw minErr('ngPattern')('noregexp',
180-
'Expected {0} to be a RegExp but was {1}. Element: {2}', patternExp,
181-
regex, startingTag(elm));
194+
var attrVal = attr.pattern;
195+
196+
if (attr.ngPattern) {
197+
attrVal = parseFn(scope);
198+
} else {
199+
patternExp = attr.pattern;
182200
}
183201

184-
regexp = regex || undefined;
185-
ctrl.$validate();
186-
});
202+
var regexp = parsePatternAttr(attrVal, patternExp, elm);
203+
204+
attr.$observe('pattern', function(newVal) {
205+
var oldRegexp = regexp;
187206

188-
ctrl.$validators.pattern = function(modelValue, viewValue) {
189-
// HTML5 pattern constraint validates the input value, so we validate the viewValue
190-
return ctrl.$isEmpty(viewValue) || isUndefined(regexp) || regexp.test(viewValue);
207+
regexp = parsePatternAttr(newVal, patternExp, elm);
208+
209+
if ((oldRegexp && oldRegexp.toString()) !== (regexp && regexp.toString())) {
210+
ctrl.$validate();
211+
}
212+
});
213+
214+
ctrl.$validators.pattern = function(modelValue, viewValue) {
215+
// HTML5 pattern constraint validates the input value, so we validate the viewValue
216+
return ctrl.$isEmpty(viewValue) || isUndefined(regexp) || regexp.test(viewValue);
217+
};
191218
};
192219
}
220+
193221
};
194-
};
222+
}];
195223

196224
/**
197225
* @ngdoc directive
@@ -264,25 +292,29 @@ var patternDirective = function() {
264292
* </file>
265293
* </example>
266294
*/
267-
var maxlengthDirective = function() {
295+
var maxlengthDirective = ['$parse', function($parse) {
268296
return {
269297
restrict: 'A',
270298
require: '?ngModel',
271299
link: function(scope, elm, attr, ctrl) {
272300
if (!ctrl) return;
273301

274-
var maxlength = -1;
302+
var maxlength = attr.maxlength || $parse(attr.ngMaxlength)(scope);
303+
var maxlengthParsed = parseLength(maxlength);
304+
275305
attr.$observe('maxlength', function(value) {
276-
var intVal = toInt(value);
277-
maxlength = isNumberNaN(intVal) ? -1 : intVal;
278-
ctrl.$validate();
306+
if (maxlength !== value) {
307+
maxlengthParsed = parseLength(value);
308+
maxlength = value;
309+
ctrl.$validate();
310+
}
279311
});
280312
ctrl.$validators.maxlength = function(modelValue, viewValue) {
281-
return (maxlength < 0) || ctrl.$isEmpty(viewValue) || (viewValue.length <= maxlength);
313+
return (maxlengthParsed < 0) || ctrl.$isEmpty(viewValue) || (viewValue.length <= maxlengthParsed);
282314
};
283315
}
284316
};
285-
};
317+
}];
286318

287319
/**
288320
* @ngdoc directive
@@ -353,21 +385,49 @@ var maxlengthDirective = function() {
353385
* </file>
354386
* </example>
355387
*/
356-
var minlengthDirective = function() {
388+
var minlengthDirective = ['$parse', function($parse) {
357389
return {
358390
restrict: 'A',
359391
require: '?ngModel',
360392
link: function(scope, elm, attr, ctrl) {
361393
if (!ctrl) return;
362394

363-
var minlength = 0;
395+
var minlength = attr.minlength || $parse(attr.ngMinlength)(scope);
396+
var minlengthParsed = parseLength(minlength) || -1;
397+
364398
attr.$observe('minlength', function(value) {
365-
minlength = toInt(value) || 0;
366-
ctrl.$validate();
399+
if (minlength !== value) {
400+
minlengthParsed = parseLength(value) || -1;
401+
minlength = value;
402+
ctrl.$validate();
403+
}
404+
367405
});
368406
ctrl.$validators.minlength = function(modelValue, viewValue) {
369-
return ctrl.$isEmpty(viewValue) || viewValue.length >= minlength;
407+
return ctrl.$isEmpty(viewValue) || viewValue.length >= minlengthParsed;
370408
};
371409
}
372410
};
373-
};
411+
}];
412+
413+
414+
function parsePatternAttr(regex, patternExp, elm) {
415+
if (!regex) return undefined;
416+
417+
if (isString(regex)) {
418+
regex = new RegExp('^' + regex + '$');
419+
}
420+
421+
if (!regex.test) {
422+
throw minErr('ngPattern')('noregexp',
423+
'Expected {0} to be a RegExp but was {1}. Element: {2}', patternExp,
424+
regex, startingTag(elm));
425+
}
426+
427+
return regex;
428+
}
429+
430+
function parseLength(val) {
431+
var intVal = toInt(val);
432+
return isNumberNaN(intVal) ? -1 : intVal;
433+
}

‎test/helpers/testabilityPatch.js

+21
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,28 @@ window.dump = function() {
312312

313313
function generateInputCompilerHelper(helper) {
314314
beforeEach(function() {
315+
helper.validationCounter = {};
316+
315317
module(function($compileProvider) {
318+
$compileProvider.directive('validationSpy', function() {
319+
return {
320+
priority: 1,
321+
require: 'ngModel',
322+
link: function(scope, element, attrs, ctrl) {
323+
var validationName = attrs.validationSpy;
324+
325+
var originalValidator = ctrl.$validators[validationName];
326+
helper.validationCounter[validationName] = 0;
327+
328+
ctrl.$validators[validationName] = function(modelValue, viewValue) {
329+
helper.validationCounter[validationName]++;
330+
331+
return originalValidator(modelValue, viewValue);
332+
};
333+
}
334+
};
335+
});
336+
316337
$compileProvider.directive('attrCapture', function() {
317338
return function(scope, element, $attrs) {
318339
helper.attrs = $attrs;

‎test/ng/directive/inputSpec.js

+303-2
Large diffs are not rendered by default.

‎test/ng/directive/ngModelSpec.js

-1
Original file line numberDiff line numberDiff line change
@@ -863,7 +863,6 @@ describe('ngModel', function() {
863863
});
864864
});
865865

866-
867866
describe('view -> model update', function() {
868867

869868
it('should always perform validations using the parsed model value', function() {

‎test/ng/directive/validatorsSpec.js

+105-1
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,29 @@ describe('validators', function() {
230230
expect(ctrl.$error.pattern).toBe(true);
231231
expect(ctrlNg.$error.pattern).toBe(true);
232232
}));
233+
234+
it('should only validate once after compilation when inside ngRepeat', function() {
235+
236+
$rootScope.pattern = /\d{4}/;
237+
238+
helper.compileInput(
239+
'<div ng-repeat="input in [0]">' +
240+
'<input type="text" ng-model="value" pattern="\\d{4}" validation-spy="pattern" />' +
241+
'</div>');
242+
243+
$rootScope.$digest();
244+
245+
expect(helper.validationCounter.pattern).toBe(1);
246+
247+
helper.compileInput(
248+
'<div ng-repeat="input in [0]">' +
249+
'<input type="text" ng-model="value" ng-pattern="pattern" validation-spy="pattern" />' +
250+
'</div>');
251+
252+
$rootScope.$digest();
253+
254+
expect(helper.validationCounter.pattern).toBe(1);
255+
});
233256
});
234257

235258

@@ -312,9 +335,31 @@ describe('validators', function() {
312335
expect(ctrl.$error.minlength).toBe(true);
313336
expect(ctrlNg.$error.minlength).toBe(true);
314337
}));
315-
});
316338

317339

340+
it('should only validate once after compilation when inside ngRepeat', function() {
341+
$rootScope.minlength = 5;
342+
343+
var element = helper.compileInput(
344+
'<div ng-repeat="input in [0]">' +
345+
'<input type="text" ng-model="value" minlength="{{minlength}}" validation-spy="minlength" />' +
346+
'</div>');
347+
348+
$rootScope.$digest();
349+
350+
expect(helper.validationCounter.minlength).toBe(1);
351+
352+
element = helper.compileInput(
353+
'<div ng-repeat="input in [0]">' +
354+
'<input type="text" ng-model="value" ng-minlength="minlength" validation-spy="minlength" />' +
355+
'</div>');
356+
357+
$rootScope.$digest();
358+
359+
expect(helper.validationCounter.minlength).toBe(1);
360+
});
361+
});
362+
318363
describe('maxlength', function() {
319364

320365
it('should invalidate values that are longer than the given maxlength', function() {
@@ -500,6 +545,29 @@ describe('validators', function() {
500545
expect(ctrl.$error.maxlength).toBe(true);
501546
expect(ctrlNg.$error.maxlength).toBe(true);
502547
}));
548+
549+
550+
it('should only validate once after compilation when inside ngRepeat', function() {
551+
$rootScope.maxlength = 5;
552+
553+
var element = helper.compileInput(
554+
'<div ng-repeat="input in [0]">' +
555+
'<input type="text" ng-model="value" maxlength="{{maxlength}}" validation-spy="maxlength" />' +
556+
'</div>');
557+
558+
$rootScope.$digest();
559+
560+
expect(helper.validationCounter.maxlength).toBe(1);
561+
562+
element = helper.compileInput(
563+
'<div ng-repeat="input in [0]">' +
564+
'<input type="text" ng-model="value" ng-maxlength="maxlength" validation-spy="maxlength" />' +
565+
'</div>');
566+
567+
$rootScope.$digest();
568+
569+
expect(helper.validationCounter.maxlength).toBe(1);
570+
});
503571
});
504572

505573

@@ -626,5 +694,41 @@ describe('validators', function() {
626694
expect(ctrl.$error.required).toBe(true);
627695
expect(ctrlNg.$error.required).toBe(true);
628696
}));
697+
698+
699+
it('should validate only once after compilation when inside ngRepeat', function() {
700+
helper.compileInput(
701+
'<div ng-repeat="input in [0]">' +
702+
'<input type="text" ng-model="value" required validation-spy="required" />' +
703+
'</div>');
704+
705+
$rootScope.$digest();
706+
707+
expect(helper.validationCounter.required).toBe(1);
708+
});
709+
710+
711+
it('should validate only once after compilation when inside ngRepeat and ngRequired is true', function() {
712+
$rootScope.isRequired = true;
713+
714+
helper.compileInput(
715+
'<div ng-repeat="input in [0]">' +
716+
'<input type="text" ng-model="value" ng-required="isRequired" validation-spy="required" />' +
717+
'</div>');
718+
719+
expect(helper.validationCounter.required).toBe(1);
720+
});
721+
722+
723+
it('should validate only once after compilation when inside ngRepeat and ngRequired is false', function() {
724+
$rootScope.isRequired = false;
725+
726+
helper.compileInput(
727+
'<div ng-repeat="input in [0]">' +
728+
'<input type="text" ng-model="value" ng-required="isRequired" validation-spy="required" />' +
729+
'</div>');
730+
731+
expect(helper.validationCounter.required).toBe(1);
732+
});
629733
});
630734
});

0 commit comments

Comments
 (0)
This repository has been archived.