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

Commit 2fcfd75

Browse files
Narretzpetebacondarwin
authored andcommitted
fix(ngOptions): override select option registration
When ngOptions is present on a select, the option directive should not be able to register options on the selectCtrl since this may cause errors during the ngOptions lifecycle. This can happen in the following cases: - there is a blank option below the select element, an ngModel directive, an ngOptions directive and some other directive on the select element, which compiles the children of the select (i.e. the option elements) before ngOptions is has finished linking. - there is a blank option below the select element, an ngModel directive, an ngOptions directive and another directive, which uses templateUrl and replace:true. What happens is: - the option directive is compiled and adds an element `$destroy` listener that will call `ngModel.$render` when the option element is removed. - when `ngOptions` processes the option, it removes the element, and triggers the `$destroy` listener on the option. - the registered `$destroy` listener calls `$render` on `ngModel`. - $render calls `selectCtrl.writeValue()`, which accesses the `options` object in the `ngOptions` directive. - Since `ngOptions` has not yet completed linking the `options` has not yet been defined and we get an error. This fix moves the registration code for the `option` directive into the `SelectController.registerOption()` method, which is then overridden by the `ngOptions` directive as a `noop`. Fixes #11685 Closes #12972 Closes #12968 Closes #13012
1 parent 2d40507 commit 2fcfd75

File tree

3 files changed

+147
-60
lines changed

3 files changed

+147
-60
lines changed

src/ng/directive/ngOptions.js

+14-6
Original file line numberDiff line numberDiff line change
@@ -392,11 +392,7 @@ var ngOptionsDirective = ['$compile', '$parse', function($compile, $parse) {
392392
var optionTemplate = document.createElement('option'),
393393
optGroupTemplate = document.createElement('optgroup');
394394

395-
return {
396-
restrict: 'A',
397-
terminal: true,
398-
require: ['select', 'ngModel'],
399-
link: function(scope, selectElement, attr, ctrls) {
395+
function ngOptionsPostLink(scope, selectElement, attr, ctrls) {
400396

401397
var selectCtrl = ctrls[0];
402398
var ngModelCtrl = ctrls[1];
@@ -448,7 +444,6 @@ var ngOptionsDirective = ['$compile', '$parse', function($compile, $parse) {
448444
unknownOption.remove();
449445
};
450446

451-
452447
// Update the controller methods for multiple selectable options
453448
if (!multiple) {
454449

@@ -726,7 +721,20 @@ var ngOptionsDirective = ['$compile', '$parse', function($compile, $parse) {
726721
}
727722

728723
}
724+
}
729725

726+
return {
727+
restrict: 'A',
728+
terminal: true,
729+
require: ['select', 'ngModel'],
730+
link: {
731+
pre: function ngOptionsPreLink(scope, selectElement, attr, ctrls) {
732+
// Deactivate the SelectController.register method to prevent
733+
// option directives from accidentally registering themselves
734+
// (and unwanted $destroy handlers etc.)
735+
ctrls[0].registerOption = noop;
736+
},
737+
post: ngOptionsPostLink
730738
}
731739
};
732740
}];

src/ng/directive/select.js

+56-53
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
var noopNgModelController = { $setViewValue: noop, $render: noop };
44

5+
function chromeHack(optionElement) {
6+
// Workaround for https://code.google.com/p/chromium/issues/detail?id=381459
7+
// Adding an <option selected="selected"> element to a <select required="required"> should
8+
// automatically select the new element
9+
if (optionElement[0].hasAttribute('selected')) {
10+
optionElement[0].selected = true;
11+
}
12+
}
13+
514
/**
615
* @ngdoc type
716
* @name select.SelectController
@@ -77,6 +86,8 @@ var SelectController =
7786
}
7887
var count = optionsMap.get(value) || 0;
7988
optionsMap.put(value, count + 1);
89+
self.ngModelCtrl.$render();
90+
chromeHack(element);
8091
};
8192

8293
// Tell the select control that an option, with the given value, has been removed
@@ -98,6 +109,39 @@ var SelectController =
98109
self.hasOption = function(value) {
99110
return !!optionsMap.get(value);
100111
};
112+
113+
114+
self.registerOption = function(optionScope, optionElement, optionAttrs, interpolateValueFn, interpolateTextFn) {
115+
116+
if (interpolateValueFn) {
117+
// The value attribute is interpolated
118+
var oldVal;
119+
optionAttrs.$observe('value', function valueAttributeObserveAction(newVal) {
120+
if (isDefined(oldVal)) {
121+
self.removeOption(oldVal);
122+
}
123+
oldVal = newVal;
124+
self.addOption(newVal, optionElement);
125+
});
126+
} else if (interpolateTextFn) {
127+
// The text content is interpolated
128+
optionScope.$watch(interpolateTextFn, function interpolateWatchAction(newVal, oldVal) {
129+
optionAttrs.$set('value', newVal);
130+
if (oldVal !== newVal) {
131+
self.removeOption(oldVal);
132+
}
133+
self.addOption(newVal, optionElement);
134+
});
135+
} else {
136+
// The value attribute is static
137+
self.addOption(optionAttrs.value, optionElement);
138+
}
139+
140+
optionElement.on('$destroy', function() {
141+
self.removeOption(optionAttrs.value);
142+
self.ngModelCtrl.$render();
143+
});
144+
};
101145
}];
102146

103147
/**
@@ -308,7 +352,13 @@ var selectDirective = function() {
308352
restrict: 'E',
309353
require: ['select', '?ngModel'],
310354
controller: SelectController,
311-
link: function(scope, element, attr, ctrls) {
355+
priority: 1,
356+
link: {
357+
pre: selectPreLink
358+
}
359+
};
360+
361+
function selectPreLink(scope, element, attr, ctrls) {
312362

313363
// if ngModel is not defined, we don't need to do anything
314364
var ngModelCtrl = ctrls[1];
@@ -378,37 +428,26 @@ var selectDirective = function() {
378428

379429
}
380430
}
381-
};
382431
};
383432

384433

385434
// The option directive is purely designed to communicate the existence (or lack of)
386435
// of dynamically created (and destroyed) option elements to their containing select
387436
// directive via its controller.
388437
var optionDirective = ['$interpolate', function($interpolate) {
389-
390-
function chromeHack(optionElement) {
391-
// Workaround for https://code.google.com/p/chromium/issues/detail?id=381459
392-
// Adding an <option selected="selected"> element to a <select required="required"> should
393-
// automatically select the new element
394-
if (optionElement[0].hasAttribute('selected')) {
395-
optionElement[0].selected = true;
396-
}
397-
}
398-
399438
return {
400439
restrict: 'E',
401440
priority: 100,
402441
compile: function(element, attr) {
403442

404443
if (isDefined(attr.value)) {
405444
// If the value attribute is defined, check if it contains an interpolation
406-
var valueInterpolated = $interpolate(attr.value, true);
445+
var interpolateValueFn = $interpolate(attr.value, true);
407446
} else {
408447
// If the value attribute is not defined then we fall back to the
409448
// text content of the option element, which may be interpolated
410-
var interpolateFn = $interpolate(element.text(), true);
411-
if (!interpolateFn) {
449+
var interpolateTextFn = $interpolate(element.text(), true);
450+
if (!interpolateTextFn) {
412451
attr.$set('value', element.text());
413452
}
414453
}
@@ -422,44 +461,8 @@ var optionDirective = ['$interpolate', function($interpolate) {
422461
selectCtrl = parent.data(selectCtrlName) ||
423462
parent.parent().data(selectCtrlName); // in case we are in optgroup
424463

425-
function addOption(optionValue) {
426-
selectCtrl.addOption(optionValue, element);
427-
selectCtrl.ngModelCtrl.$render();
428-
chromeHack(element);
429-
}
430-
431-
// Only update trigger option updates if this is an option within a `select`
432-
// that also has `ngModel` attached
433-
if (selectCtrl && selectCtrl.ngModelCtrl) {
434-
435-
if (valueInterpolated) {
436-
// The value attribute is interpolated
437-
var oldVal;
438-
attr.$observe('value', function valueAttributeObserveAction(newVal) {
439-
if (isDefined(oldVal)) {
440-
selectCtrl.removeOption(oldVal);
441-
}
442-
oldVal = newVal;
443-
addOption(newVal);
444-
});
445-
} else if (interpolateFn) {
446-
// The text content is interpolated
447-
scope.$watch(interpolateFn, function interpolateWatchAction(newVal, oldVal) {
448-
attr.$set('value', newVal);
449-
if (oldVal !== newVal) {
450-
selectCtrl.removeOption(oldVal);
451-
}
452-
addOption(newVal);
453-
});
454-
} else {
455-
// The value attribute is static
456-
addOption(attr.value);
457-
}
458-
459-
element.on('$destroy', function() {
460-
selectCtrl.removeOption(attr.value);
461-
selectCtrl.ngModelCtrl.$render();
462-
});
464+
if (selectCtrl) {
465+
selectCtrl.registerOption(scope, element, attr, interpolateValueFn, interpolateTextFn);
463466
}
464467
};
465468
}

test/ng/directive/ngOptionsSpec.js

+77-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
describe('ngOptions', function() {
44

5-
var scope, formElement, element, $compile;
5+
var scope, formElement, element, $compile, linkLog;
66

77
function compile(html) {
88
formElement = jqLite('<form name="form">' + html + '</form>');
@@ -104,6 +104,53 @@ describe('ngOptions', function() {
104104
});
105105
});
106106

107+
beforeEach(module(function($compileProvider, $provide) {
108+
linkLog = [];
109+
110+
$compileProvider
111+
.directive('customSelect', function() {
112+
return {
113+
restrict: "E",
114+
replace: true,
115+
scope: {
116+
ngModel: '=',
117+
options: '='
118+
},
119+
templateUrl: 'select_template.html',
120+
link: function(scope, $element, attributes) {
121+
scope.selectable_options = scope.options;
122+
}
123+
};
124+
})
125+
126+
.directive('oCompileContents', function() {
127+
return {
128+
link: function(scope, element) {
129+
linkLog.push('linkCompileContents');
130+
$compile(element.contents())(scope);
131+
}
132+
};
133+
});
134+
135+
$provide.decorator('ngOptionsDirective', function($delegate) {
136+
137+
var origPreLink = $delegate[0].link.pre;
138+
var origPostLink = $delegate[0].link.post;
139+
140+
$delegate[0].compile = function() {
141+
return {
142+
pre: origPreLink,
143+
post: function() {
144+
linkLog.push('linkNgOptions');
145+
origPostLink.apply(this, arguments);
146+
}
147+
};
148+
};
149+
150+
return $delegate;
151+
});
152+
}));
153+
107154
beforeEach(inject(function($rootScope, _$compile_) {
108155
scope = $rootScope.$new(); //create a child scope because the root scope can't be $destroy-ed
109156
$compile = _$compile_;
@@ -2119,6 +2166,35 @@ describe('ngOptions', function() {
21192166
option = element.find('option').eq(0);
21202167
expect(option.text()).toBe('A');
21212168
});
2169+
2170+
2171+
it('should not throw when a directive compiles the blank option before ngOptions is linked', function() {
2172+
expect(function() {
2173+
createSelect({
2174+
'o-compile-contents': '',
2175+
'name': 'select',
2176+
'ng-model': 'value',
2177+
'ng-options': 'item for item in items'
2178+
}, true);
2179+
}).not.toThrow();
2180+
2181+
expect(linkLog).toEqual(['linkCompileContents', 'linkNgOptions']);
2182+
});
2183+
2184+
2185+
it('should not throw with a directive that replaces', inject(function($templateCache, $httpBackend) {
2186+
$templateCache.put('select_template.html', '<select ng-options="option as option for option in selectable_options"> <option value="">This is a test</option> </select>');
2187+
2188+
scope.options = ['a', 'b', 'c', 'd'];
2189+
2190+
expect(function() {
2191+
element = $compile('<custom-select ng-model="value" options="options"></custom-select>')(scope);
2192+
scope.$digest();
2193+
}).not.toThrow();
2194+
2195+
dealoc(element);
2196+
}));
2197+
21222198
});
21232199

21242200

0 commit comments

Comments
 (0)