Skip to content

Commit ee27815

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 4d4e603 commit ee27815

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
@@ -1976,13 +1976,40 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
19761976
*
19771977
* @description
19781978
* Runs each of the registered validators (first synchronous validators and then asynchronous validators).
1979+
* If the validity changes to invalid, the model will be set to undefined, unless ngModelOptions.allowInvalid
1980+
* is `true`. If the validity changes to valid, it will set the model to the last available valid
1981+
* modelValue.
19791982
*/
19801983
this.$validate = function() {
19811984
// ignore $validate before model is initialized
19821985
if (isNumber(ctrl.$modelValue) && isNaN(ctrl.$modelValue)) {
19831986
return;
19841987
}
1985-
this.$$parseAndValidate();
1988+
1989+
var viewValue = ctrl.$$lastCommittedViewValue;
1990+
var modelValue = ctrl.$$rawModelValue;
1991+
1992+
var prevValid = ctrl.$valid;
1993+
var prevModelValue = ctrl.$modelValue;
1994+
1995+
var allowInvalid = ctrl.$options && ctrl.$options.allowInvalid;
1996+
1997+
ctrl.$$runValidators(undefined, modelValue, viewValue, function(allValid) {
1998+
// If there was no change in validity, don't update the model
1999+
// This prevents changing an invalid modelValue to undefined
2000+
if (!allowInvalid && prevValid !== allValid) {
2001+
// Note: Don't check ctrl.$valid here, as we could have
2002+
// external validators (e.g. calculated on the server),
2003+
// that just call $setValidity and need the model value
2004+
// to calculate their validity.
2005+
ctrl.$modelValue = allValid ? modelValue : undefined;
2006+
2007+
if (ctrl.$modelValue !== prevModelValue) {
2008+
ctrl.$$writeModelToScope();
2009+
}
2010+
}
2011+
});
2012+
19862013
};
19872014

19882015
this.$$runValidators = function(parseValid, modelValue, viewValue, doneCallback) {
@@ -2130,6 +2157,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
21302157
}
21312158
var prevModelValue = ctrl.$modelValue;
21322159
var allowInvalid = ctrl.$options && ctrl.$options.allowInvalid;
2160+
ctrl.$$rawModelValue = modelValue;
21332161
if (allowInvalid) {
21342162
ctrl.$modelValue = modelValue;
21352163
writeToModelIfNeeded();
@@ -2254,7 +2282,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$
22542282
// if scope model value and ngModel value are out of sync
22552283
// TODO(perf): why not move this to the action fn?
22562284
if (modelValue !== ctrl.$modelValue) {
2257-
ctrl.$modelValue = modelValue;
2285+
ctrl.$modelValue = ctrl.$$rawModelValue = modelValue;
22582286

22592287
var formatters = ctrl.$formatters,
22602288
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) {
@@ -2311,6 +2524,14 @@ describe('input', function() {
23112524
expect(inputElm).toBeValid();
23122525
expect(scope.form.input.$error.minlength).not.toBe(true);
23132526
});
2527+
2528+
it('should validate when the model is initalized as a number', function() {
2529+
scope.value = 12345;
2530+
compileInput('<input type="text" name="input" ng-model="value" minlength="3" />');
2531+
expect(scope.value).toBe(12345);
2532+
expect(scope.form.input.$error.minlength).toBeUndefined();
2533+
});
2534+
23142535
});
23152536

23162537

@@ -2409,6 +2630,14 @@ describe('input', function() {
24092630
expect(scope.value).toBe('12345');
24102631
});
24112632

2633+
// This works both for string formatter and toString() in validator
2634+
it('should validate when the model is initalized as a number', function() {
2635+
scope.value = 12345;
2636+
compileInput('<input type="text" name="input" ng-model="value" maxlength="10" />');
2637+
expect(scope.value).toBe(12345);
2638+
expect(scope.form.input.$error.maxlength).toBeUndefined();
2639+
});
2640+
24122641
});
24132642

24142643

0 commit comments

Comments
 (0)