-
Notifications
You must be signed in to change notification settings - Fork 27.4k
fix(ngOptions): fix model<->option interaction when using track by #10893
Conversation
@petebacondarwin I don't know the code of select / ngOptions well enough to give you thumbs up but I would feel much better if tests could verify the impact of changes in the object on what is displayed by select (if possible). I guess this PR will need another pair of eyes or we can hangout together so you can walk me through the code / design. |
@pkozlowski-opensource - I updated the test. Let me know if this is still missing something... |
2329e9d
to
63d5819
Compare
@@ -492,6 +495,7 @@ var ngOptionsDirective = ['$compile', '$parse', function($compile, $parse) { | |||
|
|||
// We will re-render the option elements if the option values or labels change | |||
scope.$watchCollection(ngOptions.getWatchables, updateOptions); | |||
scope.$watch(attr.ngModel, function() { ngModelCtrl.$render(); }, true); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The reason we now need this is that we can't assume that the selected model value is not a complex object, in which case the normal ngModel
checking would fail to notice the change here.
As far as I can tell, this looks good. Your explanation in #10869 (comment) was very helpful. I think it would make sense to add a condensed version of the explanation to the commit message or add your comments as code comments. |
Great! Thanks @Narretz - I will add that info to the commit and possibly as comments in code. |
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 angular#10869 Closes angular#10893
63d5819
to
6a03ca2
Compare
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 angular#10869 Closes angular#10893
Closes #10869