Skip to content

Commit e3fac87

Browse files
committed
fix(button-toggle): value cleared when replacing with list that still contains the selected value
In #14627 we added some logic that removes the selected button toggle from the selection when it's destroyed. In an attempt to work around a "changed after checked" error we wrapped the call in a `Promise.resolve`, however that introduced an issue where we might end up overwriting a subsequent value, if it happens immediately after a button is destroyed. These changes move some things around to ensure that we don't overwrite the user's value. Fixes #15297.
1 parent 3b0f7fc commit e3fac87

File tree

3 files changed

+48
-10
lines changed

3 files changed

+48
-10
lines changed

src/lib/button-toggle/button-toggle.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,31 @@ describe('MatButtonToggle with forms', () => {
206206

207207
expect(groupElement.querySelectorAll('.mat-ripple-element').length).toBe(0);
208208
});
209+
210+
it('should maintain the selected value when swapping out the list of toggles with one ' +
211+
'that still contains the value', fakeAsync(() => {
212+
expect(buttonToggleInstances[0].checked).toBe(false);
213+
expect(fixture.componentInstance.modelValue).toBeFalsy();
214+
expect(groupInstance.value).toBeFalsy();
215+
216+
groupInstance.value = 'red';
217+
fixture.detectChanges();
218+
219+
expect(buttonToggleInstances[0].checked).toBe(true);
220+
expect(groupInstance.value).toBe('red');
221+
222+
fixture.componentInstance.options = [...fixture.componentInstance.options];
223+
fixture.detectChanges();
224+
tick();
225+
fixture.detectChanges();
226+
227+
buttonToggleDebugElements = fixture.debugElement.queryAll(By.directive(MatButtonToggle));
228+
buttonToggleInstances = buttonToggleDebugElements.map(debugEl => debugEl.componentInstance);
229+
230+
expect(buttonToggleInstances[0].checked).toBe(true);
231+
expect(groupInstance.value).toBe('red');
232+
}));
233+
209234
});
210235
});
211236

src/lib/button-toggle/button-toggle.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -264,8 +264,12 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
264264
* @param toggle Toggle to be synced.
265265
* @param select Whether the toggle should be selected.
266266
* @param isUserInput Whether the change was a result of a user interaction.
267+
* @param deferEvents Whether to defer emitting the change events.
267268
*/
268-
_syncButtonToggle(toggle: MatButtonToggle, select: boolean, isUserInput = false) {
269+
_syncButtonToggle(toggle: MatButtonToggle,
270+
select: boolean,
271+
isUserInput = false,
272+
deferEvents = false) {
269273
// Deselect the currently-selected toggle, if we're in single-selection
270274
// mode and the button being toggled isn't selected at the moment.
271275
if (!this.multiple && this.selected && !toggle.checked) {
@@ -278,14 +282,11 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
278282
this._selectionModel.deselect(toggle);
279283
}
280284

281-
// Only emit the change event for user input.
282-
if (isUserInput) {
283-
this._emitChangeEvent();
285+
if (deferEvents) {
286+
Promise.resolve(() => this._updateModelValue(isUserInput));
287+
} else {
288+
this._updateModelValue(isUserInput);
284289
}
285-
286-
// Note: we emit this one no matter whether it was a user interaction, because
287-
// it is used by Angular to sync up the two-way data binding.
288-
this.valueChange.emit(this.value);
289290
}
290291

291292
/** Checks whether a button toggle is selected. */
@@ -344,6 +345,18 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
344345
this._selectionModel.select(correspondingOption);
345346
}
346347
}
348+
349+
/** Syncs up the group's value with the model and emits the change event. */
350+
private _updateModelValue(isUserInput: boolean) {
351+
// Only emit the change event for user input.
352+
if (isUserInput) {
353+
this._emitChangeEvent();
354+
}
355+
356+
// Note: we emit this one no matter whether it was a user interaction, because
357+
// it is used by Angular to sync up the two-way data binding.
358+
this.valueChange.emit(this.value);
359+
}
347360
}
348361

349362
// Boilerplate for applying mixins to the MatButtonToggle class.
@@ -497,7 +510,7 @@ export class MatButtonToggle extends _MatButtonToggleMixinBase implements OnInit
497510
// Remove the toggle from the selection once it's destroyed. Needs to happen
498511
// on the next tick in order to avoid "changed after checked" errors.
499512
if (group && group._isSelected(this)) {
500-
Promise.resolve().then(() => group._syncButtonToggle(this, false));
513+
group._syncButtonToggle(this, false, false, true);
501514
}
502515
}
503516

tools/public_api_guard/lib/button-toggle.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export declare class MatButtonToggleGroup implements ControlValueAccessor, OnIni
6161
_emitChangeEvent(): void;
6262
_isPrechecked(toggle: MatButtonToggle): boolean;
6363
_isSelected(toggle: MatButtonToggle): boolean;
64-
_syncButtonToggle(toggle: MatButtonToggle, select: boolean, isUserInput?: boolean): void;
64+
_syncButtonToggle(toggle: MatButtonToggle, select: boolean, isUserInput?: boolean, deferEvents?: boolean): void;
6565
ngAfterContentInit(): void;
6666
ngOnInit(): void;
6767
registerOnChange(fn: (value: any) => void): void;

0 commit comments

Comments
 (0)