Skip to content

Commit 50f7e66

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
1 parent ee1fc1d commit 50f7e66

File tree

2 files changed

+259
-2
lines changed

2 files changed

+259
-2
lines changed

src/ng/directive/input.js

+30-2
Original file line numberDiff line numberDiff line change
@@ -1956,13 +1956,40 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
19561956
*
19571957
* @description
19581958
* Runs each of the registered validators (first synchronous validators and then asynchronous validators).
1959+
* If the validity changes to invalid, the model will be set to undefined, unless ngModelOptions.allowInvalid
1960+
* is `true`. If the validity changes to valid, it will set the model to the last available valid
1961+
* modelValue.
19591962
*/
19601963
this.$validate = function() {
19611964
// ignore $validate before model is initialized
19621965
if (isNumber(ctrl.$modelValue) && isNaN(ctrl.$modelValue)) {
19631966
return;
19641967
}
1965-
this.$$parseAndValidate();
1968+
1969+
var viewValue = ctrl.$$lastCommittedViewValue;
1970+
var modelValue = ctrl.$$rawModelValue;
1971+
1972+
var prevValid = ctrl.$valid;
1973+
var prevModelValue = ctrl.$modelValue;
1974+
1975+
var allowInvalid = ctrl.$options && ctrl.$options.allowInvalid;
1976+
1977+
ctrl.$$runValidators(undefined, modelValue, viewValue, function(allValid) {
1978+
// If there was no change in validity, don't update the model
1979+
// This prevents changing an invalid modelValue to undefined
1980+
if (!allowInvalid && prevValid !== allValid) {
1981+
// Note: Don't check ctrl.$valid here, as we could have
1982+
// external validators (e.g. calculated on the server),
1983+
// that just call $setValidity and need the model value
1984+
// to calculate their validity.
1985+
ctrl.$modelValue = allValid ? modelValue : undefined;
1986+
1987+
if (ctrl.$modelValue !== prevModelValue) {
1988+
ctrl.$$writeModelToScope();
1989+
}
1990+
}
1991+
});
1992+
19661993
};
19671994

19681995
this.$$runValidators = function(parseValid, modelValue, viewValue, doneCallback) {
@@ -2110,6 +2137,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
21102137
}
21112138
var prevModelValue = ctrl.$modelValue;
21122139
var allowInvalid = ctrl.$options && ctrl.$options.allowInvalid;
2140+
ctrl.$$rawModelValue = modelValue;
21132141
if (allowInvalid) {
21142142
ctrl.$modelValue = modelValue;
21152143
writeToModelIfNeeded();
@@ -2234,7 +2262,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
22342262
// if scope model value and ngModel value are out of sync
22352263
// TODO(perf): why not move this to the action fn?
22362264
if (modelValue !== ctrl.$modelValue) {
2237-
ctrl.$modelValue = modelValue;
2265+
ctrl.$modelValue = ctrl.$$rawModelValue = modelValue;
22382266

22392267
var formatters = ctrl.$formatters,
22402268
idx = formatters.length;

test/ng/directive/inputSpec.js

+229
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,209 @@ describe('NgModelController', function() {
934934
});
935935
});
936936

937+
describe('initialization', function() {
938+
var formElm, inputElm, ctrl, scope, $compile, $sniffer, $compileProvider, changeInputValueTo;
939+
940+
function compileInput(inputHtml) {
941+
inputElm = jqLite(inputHtml);
942+
formElm = jqLite('<form name="form"></form>');
943+
formElm.append(inputElm);
944+
$compile(formElm)(scope);
945+
ctrl = inputElm.controller('ngModel');
946+
scope.$digest();
947+
}
948+
949+
function addValidator(validity, shouldObserve) {
950+
if (!isDefined(shouldObserve)) {
951+
shouldObserve = true;
952+
}
953+
954+
$compileProvider.directive('obs', function() {
955+
return {
956+
require: 'ngModel',
957+
link: function(scope, element, attrs, ngModelCtrl) {
958+
959+
ngModelCtrl.$validators.obs = isFunction(validity) ? validity : function(value) {
960+
return validity;
961+
};
962+
963+
if (shouldObserve) {
964+
attrs.$observe('obs', function() {
965+
ngModelCtrl.$validate();
966+
});
967+
}
968+
}
969+
};
970+
971+
});
972+
}
973+
974+
function addFormatter(formatFunction) {
975+
$compileProvider.directive('format', function() {
976+
return {
977+
require: 'ngModel',
978+
link: function(scope, element, attrs, ctrl) {
979+
980+
ctrl.$formatters.push(formatFunction);
981+
}
982+
};
983+
984+
});
985+
}
986+
987+
function addParser(parseFunction) {
988+
$compileProvider.directive('parse', function() {
989+
return {
990+
require: 'ngModel',
991+
link: function(scope, element, attrs, ctrl) {
992+
993+
ctrl.$parsers.push(parseFunction);
994+
}
995+
};
996+
997+
});
998+
}
999+
1000+
beforeEach(module(function(_$compileProvider_) {
1001+
$compileProvider = _$compileProvider_;
1002+
}));
1003+
1004+
beforeEach(inject(function(_$compile_, _$rootScope_, _$sniffer_) {
1005+
$compile = _$compile_;
1006+
$sniffer = _$sniffer_;
1007+
scope = _$rootScope_;
1008+
1009+
changeInputValueTo = function(value) {
1010+
inputElm.val(value);
1011+
browserTrigger(inputElm, $sniffer.hasEvent('input') ? 'input' : 'change');
1012+
};
1013+
}));
1014+
1015+
afterEach(function() {
1016+
dealoc(formElm);
1017+
});
1018+
1019+
// https://github.com/angular/angular.js/issues/9959
1020+
it('should not change model of type number to string with validator using observer', function() {
1021+
addValidator(true);
1022+
scope.value = 12345;
1023+
scope.attr = 'mock';
1024+
scope.ngChangeSpy = jasmine.createSpy();
1025+
1026+
compileInput('<input type="text" name="input" ng-model="value"' +
1027+
'ng-change="ngChangeSpy()" obs="{{attr}}" />');
1028+
1029+
expect(scope.value).toBe(12345);
1030+
expect(scope.ngChangeSpy).not.toHaveBeenCalled();
1031+
});
1032+
1033+
//https://github.com/angular/angular.js/issues/9063
1034+
it('should not set a null model that is invalid to undefined', function() {
1035+
addValidator(false);
1036+
scope.value = null;
1037+
scope.required = true;
1038+
compileInput('<input type="text" name="textInput" ng-model="value"' +
1039+
'ng-required="required" obs="{{attr}}" />');
1040+
1041+
expect(inputElm).toBeInvalid();
1042+
expect(scope.value).toBe(null);
1043+
expect(scope.form.textInput.$error.obs).toBeTruthy();
1044+
});
1045+
1046+
//https://github.com/angular/angular.js/issues/9996
1047+
it('should not change an undefined model that uses ng-required and formatters and parsers', function() {
1048+
addParser(function (viewValue) {
1049+
return null;
1050+
});
1051+
addFormatter(function (modelValue) {
1052+
return '';
1053+
});
1054+
1055+
scope.ngChangeSpy = jasmine.createSpy();
1056+
compileInput('<input type="text" parse format name="textInput" ng-model="value"' +
1057+
'ng-required="undefinedProp" ng-change="ngChangeSpy()" />');
1058+
1059+
expect(inputElm).toBeValid();
1060+
expect(scope.value).toBeUndefined();
1061+
expect(scope.ngChangeSpy).not.toHaveBeenCalled();
1062+
});
1063+
1064+
// https://github.com/angular/angular.js/issues/10025
1065+
it('should not change a model that uses custom $formatters and $parsers', function() {
1066+
addValidator(true);
1067+
addFormatter(function (modelValue) {
1068+
return 'abc';
1069+
});
1070+
addParser(function (viewValue) {
1071+
return 'xyz';
1072+
});
1073+
scope.value = 'abc';
1074+
scope.attr = 'mock';
1075+
compileInput('<input type="text" parse format name="textInput" ng-model="value"' +
1076+
'obs="{{attr}}" />');
1077+
1078+
expect(inputElm).toBeValid();
1079+
expect(scope.value).toBe('abc');
1080+
});
1081+
1082+
describe('$validate', function() {
1083+
1084+
// Sanity test: since a parse error sets the modelValue to undefined, the
1085+
// $$rawModelValue will always be undefined, hence $validate does not have
1086+
// a 'good' value to update
1087+
it('should not update a model that has a parse error', function() {
1088+
scope.value = 'abc';
1089+
addParser(function() {
1090+
return undefined;
1091+
});
1092+
1093+
addValidator(true, false);
1094+
1095+
compileInput('<input type="text" name="textInput" obs parse ng-model="value"/>');
1096+
expect(inputElm).toBeValid();
1097+
expect(scope.value).toBe('abc');
1098+
1099+
changeInputValueTo('xyz');
1100+
expect(inputElm).toBeInvalid();
1101+
expect(scope.value).toBeUndefined();
1102+
expect(ctrl.$error.parse).toBe(true);
1103+
1104+
ctrl.$validate();
1105+
expect(inputElm).toBeInvalid();
1106+
expect(scope.value).toBeUndefined();
1107+
});
1108+
1109+
it('should restore the last valid modelValue when a validator becomes valid', function() {
1110+
scope.value = 'abc';
1111+
scope.count = 0;
1112+
1113+
addValidator(function() {
1114+
scope.count++;
1115+
dump('count', scope.count);
1116+
return scope.count === 1 ? true : scope.count === 2 ? false : true;
1117+
});
1118+
1119+
compileInput('<input type="text" name="textInput" obs ng-model="value"/>');
1120+
expect(inputElm).toBeValid();
1121+
expect(scope.value).toBe('abc');
1122+
expect(ctrl.$viewValue).toBe('abc');
1123+
1124+
ctrl.$validate();
1125+
scope.$digest();
1126+
expect(inputElm).toBeInvalid();
1127+
expect(scope.value).toBeUndefined();
1128+
expect(ctrl.$viewValue).toBe('abc');
1129+
1130+
ctrl.$validate();
1131+
scope.$digest();
1132+
expect(inputElm).toBeValid();
1133+
expect(scope.value).toBe('abc');
1134+
});
1135+
1136+
1137+
});
1138+
});
1139+
9371140
describe('ngModel', function() {
9381141
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;
9391142

@@ -1348,6 +1551,16 @@ describe('input', function() {
13481551
expect(scope.form.$$renameControl).not.toHaveBeenCalled();
13491552
});
13501553

1554+
it('should not invoke viewChangeListeners before input is touched', function() {
1555+
scope.value = 1;
1556+
var change = scope.change = jasmine.createSpy('change');
1557+
var element = $compile('<div><div ng-repeat="i in [1]">' +
1558+
'<input type="text" ng-model="value" maxlength="1" ng-change="change()" />' +
1559+
'</div></div>')(scope);
1560+
scope.$digest();
1561+
expect(change).not.toHaveBeenCalled();
1562+
dealoc(element);
1563+
});
13511564

13521565
describe('compositionevents', function() {
13531566
it('should not update the model between "compositionstart" and "compositionend" on non android', inject(function($sniffer) {
@@ -2264,6 +2477,14 @@ describe('input', function() {
22642477
expect(inputElm).toBeValid();
22652478
expect(scope.form.input.$error.minlength).not.toBe(true);
22662479
});
2480+
2481+
it('should validate when the model is initalized as a number', function() {
2482+
scope.value = 12345;
2483+
compileInput('<input type="text" name="input" ng-model="value" minlength="3" />');
2484+
expect(scope.value).toBe(12345);
2485+
expect(scope.form.input.$error.minlength).toBeUndefined();
2486+
});
2487+
22672488
});
22682489

22692490

@@ -2362,6 +2583,14 @@ describe('input', function() {
23622583
expect(scope.value).toBe('12345');
23632584
});
23642585

2586+
// This works both for string formatter and toString() in validator
2587+
it('should validate when the model is initalized as a number', function() {
2588+
scope.value = 12345;
2589+
compileInput('<input type="text" name="input" ng-model="value" maxlength="10" />');
2590+
expect(scope.value).toBe(12345);
2591+
expect(scope.form.input.$error.maxlength).toBeUndefined();
2592+
});
2593+
23652594
});
23662595

23672596

0 commit comments

Comments
 (0)