Skip to content

Commit a833dfb

Browse files
crisbetojelbourn
authored andcommitted
fix(button-toggle): value cleared when replacing with list that still contains the selected value (#15508)
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 1218592 commit a833dfb

File tree

3 files changed

+51
-10
lines changed

3 files changed

+51
-10
lines changed

src/material/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/material/button-toggle/button-toggle.ts

Lines changed: 25 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,14 @@ 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+
// We need to defer in some cases in order to avoid "changed after checked errors", however
286+
// the side-effect is that we may end up updating the model value out of sequence in others
287+
// The `deferEvents` flag allows us to decide whether to do it on a case-by-case basis.
288+
if (deferEvents) {
289+
Promise.resolve(() => this._updateModelValue(isUserInput));
290+
} else {
291+
this._updateModelValue(isUserInput);
284292
}
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);
289293
}
290294

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

349365
// Boilerplate for applying mixins to the MatButtonToggle class.
@@ -498,7 +514,7 @@ export class MatButtonToggle extends _MatButtonToggleMixinBase implements OnInit
498514
// Remove the toggle from the selection once it's destroyed. Needs to happen
499515
// on the next tick in order to avoid "changed after checked" errors.
500516
if (group && group._isSelected(this)) {
501-
Promise.resolve().then(() => group._syncButtonToggle(this, false));
517+
group._syncButtonToggle(this, false, false, true);
502518
}
503519
}
504520

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

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

0 commit comments

Comments
 (0)