Skip to content

Commit e3764e3

Browse files
committed
fix(ngModel): don't run parsers when executing $validate
Previously, $validate would execute the parsers to obtain a modelValue for validation. This was necessary, because a validator that is called outside of model / view update (e.g. from an observer) otherwise might only an undefined modelValue, because a previous view update has found a validation $error and set the model to undefined (as is tradition in angular) This is problematic as validators that are run immediately after the ngModelController initializes would parse the modelValue and replace the model, even though there had been no user input. The solution is to go back to an older design: the ngModelController will now internally record the $$rawModelValue. This means a model or view update will store the set / parsed modelValue regardless of validity, that is, it will never set it to undefined because of validation errors. When $validate is called, the $$rawModelValue will passed to the validators. If the validity has changed, the usual behavior is kept: if it became invalid, set the model to undefined, if valid, restore the last available modelValue - the $$rawModelValue. Additionally, $validate will only update the model when the validity changed. This is to prevent setting initially invalid models other than undefined to undefined (see angular#9063) Fixes: angular#9063 Fixes: angular#9959 Fixes: angular#9996 Fixes: angular#10025 Closes: angular#9890 Closes: angular#9913 Closes: angular#9997 Closes: angular#10048
1 parent c9899c5 commit e3764e3

File tree

2 files changed

+173
-4
lines changed

2 files changed

+173
-4
lines changed

src/ng/directive/input.js

+42-3
Original file line numberDiff line numberDiff line change
@@ -1721,6 +1721,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
17211721
function($scope, $exceptionHandler, $attr, $element, $parse, $animate, $timeout, $rootScope, $q, $interpolate) {
17221722
this.$viewValue = Number.NaN;
17231723
this.$modelValue = Number.NaN;
1724+
this.$rawModelValue = undefined; // stores the parsed modelValue / model set from scope regardless of validity.
17241725
this.$validators = {};
17251726
this.$asyncValidators = {};
17261727
this.$parsers = [];
@@ -1975,14 +1976,51 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
19751976
* @name ngModel.NgModelController#$validate
19761977
*
19771978
* @description
1978-
* Runs each of the registered validators (first synchronous validators and then asynchronous validators).
1979+
* Runs each of the registered validators (first synchronous validators and then
1980+
* asynchronous validators).
1981+
* If the validity changes to invalid, the model will be set to `undefined`,
1982+
* unless {@link ngModelOptions `ngModelOptions.allowInvalid`} is `true`.
1983+
* If the validity changes to valid, it will set the model to the last available valid
1984+
* modelValue, i.e. either the last parsed value or the last value set from the scope.
19791985
*/
19801986
this.$validate = function() {
19811987
// ignore $validate before model is initialized
19821988
if (isNumber(ctrl.$modelValue) && isNaN(ctrl.$modelValue)) {
19831989
return;
19841990
}
1985-
this.$$parseAndValidate();
1991+
1992+
var viewValue = ctrl.$$lastCommittedViewValue;
1993+
// Note: we use the $$rawModelValue as $modelValue might have been
1994+
// set to undefined during a view -> model update that found validation
1995+
// errors. We can't parse the view here, since that could change
1996+
// the model although neither viewValue nor the model on the scope changed
1997+
var modelValue = ctrl.$$rawModelValue;
1998+
1999+
// Check if the there's a parse error, so we don't unset it accidentially
2000+
var parserName = ctrl.$$parserName || 'parse';
2001+
var parserValid = ctrl.$error[parserName] ? false : undefined;
2002+
2003+
var prevValid = ctrl.$valid;
2004+
var prevModelValue = ctrl.$modelValue;
2005+
2006+
var allowInvalid = ctrl.$options && ctrl.$options.allowInvalid;
2007+
2008+
ctrl.$$runValidators(parserValid, modelValue, viewValue, function(allValid) {
2009+
// If there was no change in validity, don't update the model
2010+
// This prevents changing an invalid modelValue to undefined
2011+
if (!allowInvalid && prevValid !== allValid) {
2012+
// Note: Don't check ctrl.$valid here, as we could have
2013+
// external validators (e.g. calculated on the server),
2014+
// that just call $setValidity and need the model value
2015+
// to calculate their validity.
2016+
ctrl.$modelValue = allValid ? modelValue : undefined;
2017+
2018+
if (ctrl.$modelValue !== prevModelValue) {
2019+
ctrl.$$writeModelToScope();
2020+
}
2021+
}
2022+
});
2023+
19862024
};
19872025

19882026
this.$$runValidators = function(parseValid, modelValue, viewValue, doneCallback) {
@@ -2130,6 +2168,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
21302168
}
21312169
var prevModelValue = ctrl.$modelValue;
21322170
var allowInvalid = ctrl.$options && ctrl.$options.allowInvalid;
2171+
ctrl.$$rawModelValue = modelValue;
21332172
if (allowInvalid) {
21342173
ctrl.$modelValue = modelValue;
21352174
writeToModelIfNeeded();
@@ -2254,7 +2293,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
22542293
// if scope model value and ngModel value are out of sync
22552294
// TODO(perf): why not move this to the action fn?
22562295
if (modelValue !== ctrl.$modelValue) {
2257-
ctrl.$modelValue = modelValue;
2296+
ctrl.$modelValue = ctrl.$$rawModelValue = modelValue;
22582297

22592298
var formatters = ctrl.$formatters,
22602299
idx = formatters.length;

test/ng/directive/inputSpec.js

+131-1
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,121 @@ describe('NgModelController', function() {
505505
expect(ctrl.$valid).toBe(true);
506506
});
507507

508+
it('should pass the last parsed modelValue to the validators', function() {
509+
ctrl.$parsers.push(function(modelValue) {
510+
return modelValue + 'def';
511+
});
512+
513+
ctrl.$setViewValue('abc');
514+
515+
ctrl.$validators.test = function(modelValue, viewValue) {
516+
return true;
517+
};
518+
519+
spyOn(ctrl.$validators, 'test');
520+
521+
ctrl.$validate();
522+
523+
expect(ctrl.$validators.test).toHaveBeenCalledWith('abcdef', 'abc');
524+
});
525+
526+
it('should set the model to undefined when it becomes invalid', function() {
527+
var valid = true;
528+
ctrl.$validators.test = function(modelValue, viewValue) {
529+
return valid;
530+
};
531+
532+
scope.$apply('value = "abc"');
533+
expect(scope.value).toBe('abc');
534+
535+
valid = false;
536+
ctrl.$validate();
537+
538+
expect(scope.value).toBeUndefined();
539+
});
540+
541+
it('should update the model when it becomes valid', function() {
542+
var valid = true;
543+
ctrl.$validators.test = function(modelValue, viewValue) {
544+
return valid;
545+
};
546+
547+
scope.$apply('value = "abc"');
548+
expect(scope.value).toBe('abc');
549+
550+
valid = false;
551+
ctrl.$validate();
552+
expect(scope.value).toBeUndefined();
553+
554+
valid = true;
555+
ctrl.$validate();
556+
expect(scope.value).toBe('abc');
557+
});
558+
559+
it('should not update the model when it is valid, but there is a parse error', function() {
560+
ctrl.$parsers.push(function(modelValue) {
561+
return undefined;
562+
});
563+
564+
ctrl.$setViewValue('abc');
565+
expect(ctrl.$error.parse).toBe(true);
566+
expect(scope.value).toBeUndefined();
567+
568+
ctrl.$validators.test = function(modelValue, viewValue) {
569+
return true;
570+
};
571+
572+
ctrl.$validate();
573+
expect(ctrl.$error).toEqual({parse: true});
574+
expect(scope.value).toBeUndefined();
575+
});
576+
577+
it('should not set an invalid model to undefined when validity is the same', function() {
578+
ctrl.$validators.test = function() {
579+
return false;
580+
};
581+
582+
scope.$apply('value = "invalid"');
583+
expect(ctrl.$valid).toBe(false);
584+
expect(scope.value).toBe('invalid');
585+
586+
ctrl.$validate();
587+
expect(ctrl.$valid).toBe(false);
588+
expect(scope.value).toBe('invalid');
589+
});
590+
591+
it('should not change a model that has a formatter', function() {
592+
ctrl.$validators.test = function() {
593+
return true;
594+
};
595+
596+
ctrl.$formatters.push(function(modelValue) {
597+
return 'xyz';
598+
});
599+
600+
scope.$apply('value = "abc"');
601+
expect(ctrl.$viewValue).toBe('xyz');
602+
603+
ctrl.$validate();
604+
expect(scope.value).toBe('abc');
605+
});
606+
607+
it('should not change a model that has a parser', function() {
608+
ctrl.$validators.test = function() {
609+
return true;
610+
};
611+
612+
ctrl.$parsers.push(function(modelValue) {
613+
return 'xyz';
614+
});
615+
616+
scope.$apply('value = "abc"');
617+
618+
ctrl.$validate();
619+
expect(scope.value).toBe('abc');
620+
});
621+
});
622+
508623
describe('view -> model update', function() {
509624
it('should always perform validations using the parsed model value', function() {
510625
var captures;
@@ -937,6 +1052,7 @@ describe('NgModelController', function() {
9371052
});
9381053
});
9391054

1055+
9401056
describe('ngModel', function() {
9411057
var EMAIL_REGEXP = /^[a-z0-9!#$%&'*+\/=?^_`{|}~.-]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i;
9421058

@@ -1351,7 +1467,6 @@ describe('input', function() {
13511467
expect(scope.form.$$renameControl).not.toHaveBeenCalled();
13521468
});
13531469

1354-
13551470
describe('compositionevents', function() {
13561471
it('should not update the model between "compositionstart" and "compositionend" on non android', inject(function($sniffer) {
13571472
$sniffer.android = false;
@@ -2314,6 +2429,14 @@ describe('input', function() {
23142429
expect(inputElm).toBeValid();
23152430
expect(scope.form.input.$error.minlength).not.toBe(true);
23162431
});
2432+
2433+
it('should validate when the model is initalized as a number', function() {
2434+
scope.value = 12345;
2435+
compileInput('<input type="text" name="input" ng-model="value" minlength="3" />');
2436+
expect(scope.value).toBe(12345);
2437+
expect(scope.form.input.$error.minlength).toBeUndefined();
2438+
});
2439+
23172440
});
23182441

23192442

@@ -2412,6 +2535,13 @@ describe('input', function() {
24122535
expect(scope.value).toBe('12345');
24132536
});
24142537

2538+
it('should validate when the model is initalized as a number', function() {
2539+
scope.value = 12345;
2540+
compileInput('<input type="text" name="input" ng-model="value" maxlength="10" />');
2541+
expect(scope.value).toBe(12345);
2542+
expect(scope.form.input.$error.maxlength).toBeUndefined();
2543+
});
2544+
24152545
});
24162546

24172547

0 commit comments

Comments
 (0)