fix(common): ensure diffing in ngStyle/ngClass correctly emits value changes (#34307)

Prior to this change, in Ivy mode ngStyle/ngClass would accidentally emit value changes for static
(string-based) values even if the value itself had not changed. This patch ensures that
the style/class diffing code is more strict and when it signals ngClass/ngStyle that there has been
a value change.

Fixes #34336, #34444

PR Close #34307
This commit is contained in:
Matias Niemelä 2019-12-08 20:19:28 -08:00
parent 27b9eb5e38
commit abd4628587
4 changed files with 222 additions and 57 deletions

View File

@ -60,8 +60,20 @@
export class StylingDiffer<T> { export class StylingDiffer<T> {
public readonly value: T|null = null; public readonly value: T|null = null;
private _lastSetValue: {[key: string]: any}|string|string[]|null = null; /**
* The last set value that was applied via `setValue()`
*/
private _lastSetValue: {[key: string]: any}|string|string[]|undefined|null = undefined;
/**
* The type of value that the `_lastSetValue` variable is
*/
private _lastSetValueType: StylingDifferValueTypes = StylingDifferValueTypes.Null; private _lastSetValueType: StylingDifferValueTypes = StylingDifferValueTypes.Null;
/**
* Whether or not the last value change occurred because the variable itself changed reference
* (identity)
*/
private _lastSetValueIdentityChange = false; private _lastSetValueIdentityChange = false;
constructor(private _name: string, private _options: StylingDifferOptions) {} constructor(private _name: string, private _options: StylingDifferOptions) {}
@ -75,21 +87,29 @@ export class StylingDiffer<T> {
* @param value the new styling value provided from the ngClass/ngStyle binding * @param value the new styling value provided from the ngClass/ngStyle binding
*/ */
setValue(value: {[key: string]: any}|string[]|string|null) { setValue(value: {[key: string]: any}|string[]|string|null) {
if (Array.isArray(value)) { if (value !== this._lastSetValue) {
this._lastSetValueType = StylingDifferValueTypes.Array; let type: StylingDifferValueTypes;
} else if (value instanceof Set) { if (!value) { // matches empty strings, null, false and undefined
this._lastSetValueType = StylingDifferValueTypes.Set; type = StylingDifferValueTypes.Null;
} else if (value && typeof value === 'string') { value = null;
if (!(this._options & StylingDifferOptions.AllowStringValue)) { } else if (Array.isArray(value)) {
throw new Error(this._name + ' string values are not allowed'); type = StylingDifferValueTypes.Array;
} else if (value instanceof Set) {
type = StylingDifferValueTypes.Set;
} else if (typeof value === 'string') {
if (!(this._options & StylingDifferOptions.AllowStringValue)) {
throw new Error(this._name + ' string values are not allowed');
}
type = StylingDifferValueTypes.String;
} else {
type = StylingDifferValueTypes.StringMap;
} }
this._lastSetValueType = StylingDifferValueTypes.String;
} else {
this._lastSetValueType = value ? StylingDifferValueTypes.Map : StylingDifferValueTypes.Null;
}
this._lastSetValueIdentityChange = true; this._lastSetValueType = type;
this._lastSetValue = value || null; this._lastSetValueIdentityChange = true;
this._lastSetValue = value;
this._processValueChange(true);
}
} }
/** /**
@ -104,8 +124,28 @@ export class StylingDiffer<T> {
*/ */
hasValueChanged(): boolean { hasValueChanged(): boolean {
let valueHasChanged = this._lastSetValueIdentityChange; let valueHasChanged = this._lastSetValueIdentityChange;
if (!valueHasChanged && !(this._lastSetValueType & StylingDifferValueTypes.Collection)) if (!valueHasChanged && (this._lastSetValueType & StylingDifferValueTypes.Collection)) {
return false; valueHasChanged = this._processValueChange(false);
} else {
// this is set to false in the event that the value is a collection.
// This way (if the identity hasn't changed), then the algorithm can
// diff the collection value to see if the contents have mutated
// (otherwise the value change was processed during the time when
// the variable changed).
this._lastSetValueIdentityChange = false;
}
return valueHasChanged;
}
/**
* Examines the last set value to see if there was a change in data.
*
* @param hasIdentityChange whether or not the last set value changed in identity or not
*
* @returns true when the value has changed (either by identity or by shape if its a collection).
*/
private _processValueChange(hasIdentityChange: boolean) {
let valueHasChanged = hasIdentityChange;
let finalValue: {[key: string]: any}|string|null = null; let finalValue: {[key: string]: any}|string|null = null;
const trimValues = (this._options & StylingDifferOptions.TrimProperties) ? true : false; const trimValues = (this._options & StylingDifferOptions.TrimProperties) ? true : false;
@ -117,25 +157,27 @@ export class StylingDiffer<T> {
case StylingDifferValueTypes.String: case StylingDifferValueTypes.String:
const tokens = (this._lastSetValue as string).split(/\s+/g); const tokens = (this._lastSetValue as string).split(/\s+/g);
if (this._options & StylingDifferOptions.ForceAsMap) { if (this._options & StylingDifferOptions.ForceAsMap) {
finalValue = {}; finalValue = {} as{[key: string]: any};
tokens.forEach((token, i) => (finalValue as{[key: string]: any})[token] = true); for (let i = 0; i < tokens.length; i++) {
finalValue[tokens[i]] = true;
}
} else { } else {
finalValue = tokens.reduce((str, token, i) => str + (i ? ' ' : '') + token); finalValue = '';
for (let i = 0; i < tokens.length; i++) {
finalValue += (i !== 0 ? ' ' : '') + tokens[i];
}
} }
break; break;
// case 2: [input]="{key:value}" // case 2: [input]="{key:value}"
case StylingDifferValueTypes.Map: case StylingDifferValueTypes.StringMap:
const map: {[key: string]: any} = this._lastSetValue as{[key: string]: any}; const map: {[key: string]: any} = this._lastSetValue as{[key: string]: any};
const keys = Object.keys(map); const keys = Object.keys(map);
if (!valueHasChanged) { if (!valueHasChanged) {
if (this.value) { // we know that the classExp value exists and that it is
// we know that the classExp value exists and that it is // a map (otherwise an identity change would have occurred)
// a map (otherwise an identity change would have occurred) valueHasChanged =
valueHasChanged = mapHasChanged(keys, this.value as{[key: string]: any}, map); this.value ? mapHasChanged(keys, this.value as{[key: string]: any}, map) : true;
} else {
valueHasChanged = true;
}
} }
if (valueHasChanged) { if (valueHasChanged) {
@ -174,27 +216,27 @@ export class StylingDiffer<T> {
} }
/** /**
* Various options that are consumed by the [StylingDiffer] class. * Various options that are consumed by the [StylingDiffer] class
*/ */
export const enum StylingDifferOptions { export const enum StylingDifferOptions {
None = 0b00000, None = 0b00000, //
TrimProperties = 0b00001, TrimProperties = 0b00001, //
AllowSubKeys = 0b00010, AllowSubKeys = 0b00010, //
AllowStringValue = 0b00100, AllowStringValue = 0b00100, //
AllowUnits = 0b01000, AllowUnits = 0b01000, //
ForceAsMap = 0b10000, ForceAsMap = 0b10000, //
} }
/** /**
* The different types of inputs that the [StylingDiffer] can deal with * The different types of inputs that the [StylingDiffer] can deal with
*/ */
const enum StylingDifferValueTypes { const enum StylingDifferValueTypes {
Null = 0b0000, Null = 0b0000, //
String = 0b0001, String = 0b0001, //
Map = 0b0010, StringMap = 0b0010, //
Array = 0b0100, Array = 0b0100, //
Set = 0b1000, Set = 0b1000, //
Collection = 0b1110, Collection = 0b1110, //
} }

View File

@ -352,6 +352,21 @@ import {ComponentFixture, TestBed, async} from '@angular/core/testing';
detectChangesAndExpectClassName('init baz'); detectChangesAndExpectClassName('init baz');
})); }));
}); });
describe('non-regression', () => {
// https://github.com/angular/angular/issues/34336
it('should not write to native node when a bound expression doesnt change', () => {
fixture = createTestComponent(`<div [ngClass]="{'color-red': true}"></div>`);
detectChangesAndExpectClassName('color-red');
// Overwrite CSS classes to make sure that ngClass is not doing any DOM manipulation (as
// there was no change to the expression bound to [ngClass]).
fixture.debugElement.children[0].nativeElement.className = '';
detectChangesAndExpectClassName('');
});
});
}); });
} }

View File

@ -158,27 +158,22 @@ import {ComponentFixture, TestBed, async} from '@angular/core/testing';
expectNativeEl(fixture).toHaveCssStyle({'font-size': '12px'}); expectNativeEl(fixture).toHaveCssStyle({'font-size': '12px'});
})); }));
it('should skip keys that are set to undefined values', async(() => { it('should not write to native node when a bound expression doesnt change', () => {
const template = `<div [ngStyle]="expr"></div>`;
fixture = createTestComponent(template); const template = `<div [ngStyle]="{'color': 'red'}"></div>`;
getComponent().expr = { fixture = createTestComponent(template);
'border-top-color': undefined,
'border-top-style': undefined,
'border-color': 'red',
'border-style': 'solid',
'border-width': '1rem',
};
fixture.detectChanges(); fixture.detectChanges();
expectNativeEl(fixture).toHaveCssStyle({'color': 'red'});
expectNativeEl(fixture).toHaveCssStyle({ // Overwrite native styles to make sure that ngClass is not doing any DOM manipulation (as
'border-color': 'red', // there was no change to the expression bound to [ngStyle]).
'border-style': 'solid', fixture.debugElement.children[0].nativeElement.style.color = 'blue';
'border-width': '1rem', fixture.detectChanges();
}); expectNativeEl(fixture).toHaveCssStyle({'color': 'blue'});
}));
});
}); });
} }

View File

@ -0,0 +1,113 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {StylingDiffer, StylingDifferOptions} from '@angular/common/src/directives/styling_differ';
describe('StylingDiffer', () => {
it('should create a key/value object of values from a string', () => {
const d = new StylingDiffer(
'ngClass', StylingDifferOptions.ForceAsMap | StylingDifferOptions.AllowStringValue);
expect(d.value).toEqual(null);
d.setValue('one two');
expect(d.value).toEqual({one: true, two: true});
d.setValue('three');
expect(d.value).toEqual({three: true});
});
it('should not emit that a value has changed if a new non-collection value was not set', () => {
const d = new StylingDiffer(
'ngClass', StylingDifferOptions.ForceAsMap | StylingDifferOptions.AllowStringValue);
expect(d.value).toEqual(null);
d.setValue('one two');
expect(d.hasValueChanged()).toBeTruthy();
expect(d.value).toEqual({one: true, two: true});
expect(d.hasValueChanged()).toBeFalsy();
expect(d.value).toEqual({one: true, two: true});
d.setValue('three');
expect(d.hasValueChanged()).toBeTruthy();
expect(d.value).toEqual({three: true});
expect(d.hasValueChanged()).toBeFalsy();
expect(d.value).toEqual({three: true});
d.setValue(null);
expect(d.hasValueChanged()).toBeTruthy();
expect(d.value).toEqual(null);
expect(d.hasValueChanged()).toBeFalsy();
expect(d.value).toEqual(null);
});
it('should watch the contents of a StringMap value and emit new values if they change', () => {
const d = new StylingDiffer('ngClass', StylingDifferOptions.ForceAsMap);
const myMap: {[key: string]: any} = {};
myMap['abc'] = true;
d.setValue(myMap);
expect(d.hasValueChanged()).toBeTruthy();
expect(d.value).toEqual({abc: true});
expect(d.hasValueChanged()).toBeFalsy();
myMap['def'] = true;
expect(d.hasValueChanged()).toBeTruthy();
expect(d.value).toEqual({abc: true, def: true});
expect(d.hasValueChanged()).toBeFalsy();
delete myMap['abc'];
delete myMap['def'];
expect(d.hasValueChanged()).toBeTruthy();
expect(d.value).toEqual({});
expect(d.hasValueChanged()).toBeFalsy();
});
it('should watch the contents of an Array value and emit new values if they change', () => {
const d = new StylingDiffer('ngClass', StylingDifferOptions.ForceAsMap);
const myArray: string[] = [];
myArray.push('abc');
d.setValue(myArray);
expect(d.hasValueChanged()).toBeTruthy();
expect(d.value).toEqual({abc: true});
expect(d.hasValueChanged()).toBeFalsy();
myArray.push('def');
expect(d.hasValueChanged()).toBeTruthy();
expect(d.value).toEqual({abc: true, def: true});
expect(d.hasValueChanged()).toBeFalsy();
myArray.length = 0;
expect(d.hasValueChanged()).toBeTruthy();
expect(d.value).toEqual({});
expect(d.hasValueChanged()).toBeFalsy();
});
it('should watch the contents of a Set value and emit new values if they change', () => {
const d = new StylingDiffer('ngClass', StylingDifferOptions.ForceAsMap);
const mySet = new Set<string>();
mySet.add('abc');
d.setValue(mySet);
expect(d.hasValueChanged()).toBeTruthy();
expect(d.value).toEqual({abc: true});
expect(d.hasValueChanged()).toBeFalsy();
mySet.add('def');
expect(d.hasValueChanged()).toBeTruthy();
expect(d.value).toEqual({abc: true, def: true});
expect(d.hasValueChanged()).toBeFalsy();
mySet.clear();
expect(d.hasValueChanged()).toBeTruthy();
expect(d.value).toEqual({});
expect(d.hasValueChanged()).toBeFalsy();
});
});