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

Commit 6a03ca2

Browse files
fix(ngOptions): fix model<->option interaction when using track by
This problem is beset by the problem of `ngModel` expecting models to be atomic things (primitives/objects). > When it was first invented it was expected that ngModel would only be a primitive, e.g. a string or a number. Later when things like ngList and ngOptions were added or became more complex then various hacks were put in place to make it look like it worked well with those but it doesn't. ------------- Just to be clear what is happening, lets name the objects: ```js var option1 = { uid: 1, name: 'someName1' }; var option2 = { uid: 2, name: 'someName2' }; var option3 = { uid: 3, name: 'someName3' }; var initialItem = { uid: 1, name: 'someName1' }; model { options: [option1, option2, option3], selected: initialItem }; ``` Now when we begin we have: ```js expect(model.selected).toBe(initialItem); expect(model.selected.uid).toEqual(option1.uid); expect(model.selected).not.toBe(option1); ``` So although `ngOptions` has found a match between an option and the modelValue, these are not the same object. Now if we change the properties of the `model.selected` object, we are effectively changing the `initialItem` object. ```js model.selected.uid = 3; model.selected.name = 'someName3'; expect(model.selected).toBe(initialItem); expect(model.selected.uid).toEqual(option3.uid); expect(model.selected).not.toBe(option3); ``` At the moment `ngModel` only watches for changes to the object identity and so it doesn't trigger an update to the `ngOptions` directive. This commit fixes this in `ngOptions` by adding a **deep** watch on the `attr.ngModel` expression... ```js scope.$watch(attr.ngModel, updateOptions, true); ``` You can see that in this Plunker: http://plnkr.co/edit/0PE7qN5FXIA23y4RwyN0?p=preview ------- But this isn't the end of the story. Since `ngModel` and `ngOptions` did not make copies between the model and the view, we can't go around just changing the properties of the `model.selected` object. This is particularly important in the situation where the user has actually chosen an option, since the `model.selected` points directly to one of the option objects: ```js // User selects "someName2" option expect(model.selected).toBe(option2); expect(model.selected.uid).toEqual(option2.uid); expect(model.selected).not.toBe(initialOption); ``` If we now change the `model.selected` object's properties we are actually changing the `option2` object: ```js expect(model.selected).toBe(option2); model.selected.uid = 3; model.selected.name = 'someName3'; expect(model.selected).toBe(option2); expect(model.selected).not.toBe(option3); expect(option2.uid).toEqual(3); expect(option2.name).toEqual('someName3'); ``` which means that the options are now broken: ```js expect(model.options).toEqual([ { uid: 1, name: 'someName1' }, { uid: 3, name: 'someName3' }, { uid: 3, name: 'someName3' } ]); ``` This commit fixes this in `ngOptions` by making copies when reading the value if `track by` is being used. If we are not using `track by` then we really do care about the identity of the object and should not be copying... You can see this in the Plunker here: http://plnkr.co/edit/YEzEf4dxHTnoW5pbeJDp?p=preview Closes #10869 Closes #10893
1 parent 210c184 commit 6a03ca2

File tree

2 files changed

+96
-7
lines changed

2 files changed

+96
-7
lines changed

src/ng/directive/ngOptions.js

+11-2
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,11 @@ var ngOptionsDirective = ['$compile', '$parse', function($compile, $parse) {
335335
selectValueMap: selectValueMap,
336336
getOptionFromViewValue: function(value) {
337337
return selectValueMap[getTrackByValue(value, getLocals(value))];
338+
},
339+
getViewValueFromOption: function(option) {
340+
// If the viewValue could be an object that may be mutated by the application,
341+
// we need to make a copy and not return the reference to the value on the option.
342+
return trackBy ? angular.copy(option.viewValue) : option.viewValue;
338343
}
339344
};
340345
}
@@ -428,7 +433,7 @@ var ngOptionsDirective = ['$compile', '$parse', function($compile, $parse) {
428433
if (selectedOption && !selectedOption.disabled) {
429434
removeEmptyOption();
430435
removeUnknownOption();
431-
return selectedOption.viewValue;
436+
return options.getViewValueFromOption(selectedOption);
432437
}
433438
return null;
434439
};
@@ -462,7 +467,7 @@ var ngOptionsDirective = ['$compile', '$parse', function($compile, $parse) {
462467

463468
forEach(selectedValues, function(value) {
464469
var option = options.selectValueMap[value];
465-
if (!option.disabled) selections.push(option.viewValue);
470+
if (!option.disabled) selections.push(options.getViewValueFromOption(option));
466471
});
467472

468473
return selections;
@@ -493,6 +498,10 @@ var ngOptionsDirective = ['$compile', '$parse', function($compile, $parse) {
493498
// We will re-render the option elements if the option values or labels change
494499
scope.$watchCollection(ngOptions.getWatchables, updateOptions);
495500

501+
// We also need to watch to see if the internals of the model changes, since
502+
// ngModel only watches for object identity change
503+
scope.$watch(attr.ngModel, function() { ngModelCtrl.$render(); }, true);
504+
496505
// ------------------------------------------------------------------ //
497506

498507

test/ng/directive/ngOptionsSpec.js

+85-5
Original file line numberDiff line numberDiff line change
@@ -856,7 +856,87 @@ describe('ngOptions', function() {
856856
expect(options.eq(2)).toEqualTrackedOption(20, 'twenty');
857857
});
858858

859-
859+
860+
it('should update the selected option even if only the tracked property on the selected object changes (single)', function() {
861+
createSelect({
862+
'ng-model': 'selected',
863+
'ng-options': 'item.label for item in arr track by item.id'
864+
});
865+
866+
scope.$apply(function() {
867+
scope.selected = {id: 10, label: 'ten'};
868+
});
869+
870+
expect(element.val()).toEqual('10');
871+
872+
// Update the properties on the selected object, rather than replacing the whole object
873+
scope.$apply(function() {
874+
scope.selected.id = 20;
875+
scope.selected.label = 'new twenty';
876+
});
877+
878+
// The value of the select should change since the id property changed
879+
expect(element.val()).toEqual('20');
880+
881+
// But the label of the selected option does not change
882+
var option = element.find('option').eq(1);
883+
expect(option.prop('selected')).toEqual(true);
884+
expect(option.text()).toEqual('twenty'); // not 'new twenty'
885+
});
886+
887+
888+
it('should update the selected options even if only the tracked properties on the objects in the ' +
889+
'selected collection change (multi)', function() {
890+
createSelect({
891+
'ng-model': 'selected',
892+
'multiple': true,
893+
'ng-options': 'item.label for item in arr track by item.id'
894+
});
895+
896+
scope.$apply(function() {
897+
scope.selected = [{id: 10, label: 'ten'}];
898+
});
899+
900+
expect(element.val()).toEqual(['10']);
901+
902+
// Update the properties on the object in the selected array, rather than replacing the whole object
903+
scope.$apply(function() {
904+
scope.selected[0].id = 20;
905+
scope.selected[0].label = 'new twenty';
906+
});
907+
908+
// The value of the select should change since the id property changed
909+
expect(element.val()).toEqual(['20']);
910+
911+
// But the label of the selected option does not change
912+
var option = element.find('option').eq(1);
913+
expect(option.prop('selected')).toEqual(true);
914+
expect(option.text()).toEqual('twenty'); // not 'new twenty'
915+
});
916+
917+
918+
it('should prevent changes to the selected object from modifying the options objects (single)', function() {
919+
920+
createSelect({
921+
'ng-model': 'selected',
922+
'ng-options': 'item.label for item in arr track by item.id'
923+
});
924+
925+
element.val('10');
926+
browserTrigger(element, 'change');
927+
928+
expect(scope.selected).toEqual(scope.arr[0]);
929+
930+
scope.$apply(function() {
931+
scope.selected.id = 20;
932+
});
933+
934+
expect(scope.selected).not.toEqual(scope.arr[0]);
935+
expect(element.val()).toEqual('20');
936+
expect(scope.arr).toEqual([{id: 10, label: 'ten'}, {id:20, label: 'twenty'}]);
937+
});
938+
939+
860940
it('should preserve value even when reference has changed (single&array)', function() {
861941
createSelect({
862942
'ng-model': 'selected',
@@ -919,7 +999,7 @@ describe('ngOptions', function() {
919999
expect(element.val()).toBe('10');
9201000

9211001
setSelectValue(element, 1);
922-
expect(scope.selected).toBe(scope.obj['2']);
1002+
expect(scope.selected).toEqual(scope.obj['2']);
9231003
});
9241004

9251005

@@ -998,7 +1078,7 @@ describe('ngOptions', function() {
9981078

9991079
element.val('10');
10001080
browserTrigger(element, 'change');
1001-
expect(scope.selected).toBe(scope.arr[0].subItem);
1081+
expect(scope.selected).toEqual(scope.arr[0].subItem);
10021082

10031083
// Now reload the array
10041084
scope.$apply(function() {
@@ -1577,7 +1657,7 @@ describe('ngOptions', function() {
15771657
scope.values.pop();
15781658
});
15791659

1580-
expect(element.val()).toEqualUnknownValue();
1660+
expect(element.val()).toEqual('');
15811661
expect(scope.selected).toEqual(null);
15821662

15831663
// Check after model change
@@ -1591,7 +1671,7 @@ describe('ngOptions', function() {
15911671
scope.values.pop();
15921672
});
15931673

1594-
expect(element.val()).toEqualUnknownValue();
1674+
expect(element.val()).toEqual('');
15951675
expect(scope.selected).toEqual(null);
15961676
});
15971677

0 commit comments

Comments
 (0)