fix(ivy): ensure errors are thrown during checkNoChanges for style/class bindings (#33103)

Prior to this fix, all style/class bindings (e.g. `[style]` and
`[class.foo]`) would quietly update a binding value if and when the
current binding value changes during checkNoChanges.

With this patch, all styling instructions will properly check to see
if the value has changed during the second pass of detectChanges()
if checkNoChanges is active.

PR Close #33103
This commit is contained in:
Matias Niemelä 2019-10-11 17:31:26 +02:00
parent 9d54679e66
commit f45c43188f
8 changed files with 116 additions and 37 deletions

View File

@ -1,5 +1,5 @@
import { NO_ERRORS_SCHEMA, DebugElement } from '@angular/core'; import { NO_ERRORS_SCHEMA, DebugElement } from '@angular/core';
import { inject, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { inject, ComponentFixture, TestBed, fakeAsync, flushMicrotasks, tick } from '@angular/core/testing';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { APP_BASE_HREF } from '@angular/common'; import { APP_BASE_HREF } from '@angular/common';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
@ -529,7 +529,8 @@ describe('AppComponent', () => {
it('should call `scrollAfterRender` (via `onDocInserted`) when navigate to a new Doc', fakeAsync(() => { it('should call `scrollAfterRender` (via `onDocInserted`) when navigate to a new Doc', fakeAsync(() => {
locationService.go('guide/pipes'); locationService.go('guide/pipes');
tick(1); // triggers the HTTP response for the document tick(1); // triggers the HTTP response for the document
fixture.detectChanges(); // triggers the event that calls `onDocInserted` fixture.detectChanges(); // passes the new doc to the `DocViewer`
flushMicrotasks(); // triggers the `DocViewer` event that calls `onDocInserted`
expect(scrollAfterRenderSpy).toHaveBeenCalledWith(scrollDelay); expect(scrollAfterRenderSpy).toHaveBeenCalledWith(scrollDelay);

View File

@ -1,7 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Meta, Title } from '@angular/platform-browser'; import { Meta, Title } from '@angular/platform-browser';
import { Observable, of } from 'rxjs'; import { Observable, asapScheduler, of } from 'rxjs';
import { FILE_NOT_FOUND_ID, FETCHING_ERROR_ID } from 'app/documents/document.service'; import { FILE_NOT_FOUND_ID, FETCHING_ERROR_ID } from 'app/documents/document.service';
import { Logger } from 'app/shared/logger.service'; import { Logger } from 'app/shared/logger.service';
@ -21,6 +21,8 @@ describe('DocViewerComponent', () => {
let docViewerEl: HTMLElement; let docViewerEl: HTMLElement;
let docViewer: TestDocViewerComponent; let docViewer: TestDocViewerComponent;
const safeFlushAsapScheduler = () => asapScheduler.actions.length && asapScheduler.flush();
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [CustomElementsModule, TestModule], imports: [CustomElementsModule, TestModule],
@ -42,19 +44,20 @@ describe('DocViewerComponent', () => {
describe('#doc', () => { describe('#doc', () => {
let renderSpy: jasmine.Spy; let renderSpy: jasmine.Spy;
const setCurrentDoc = (contents: string|null, id = 'fizz/buzz') => { const setCurrentDoc = (newDoc: TestParentComponent['currentDoc']) => {
parentComponent.currentDoc = {contents, id}; parentComponent.currentDoc = newDoc && {id: 'fizz/buzz', ...newDoc};
parentFixture.detectChanges(); parentFixture.detectChanges(); // Run change detection to propagate the new doc to `DocViewer`.
safeFlushAsapScheduler(); // Flush `asapScheduler` to trigger `DocViewer#render()`.
}; };
beforeEach(() => renderSpy = spyOn(docViewer, 'render').and.callFake(() => of(undefined))); beforeEach(() => renderSpy = spyOn(docViewer, 'render').and.callFake(() => of(undefined)));
it('should render the new document', () => { it('should render the new document', () => {
setCurrentDoc('foo', 'bar'); setCurrentDoc({contents: 'foo', id: 'bar'});
expect(renderSpy).toHaveBeenCalledTimes(1); expect(renderSpy).toHaveBeenCalledTimes(1);
expect(renderSpy.calls.mostRecent().args).toEqual([{id: 'bar', contents: 'foo'}]); expect(renderSpy.calls.mostRecent().args).toEqual([{id: 'bar', contents: 'foo'}]);
setCurrentDoc(null, 'baz'); setCurrentDoc({contents: null, id: 'baz'});
expect(renderSpy).toHaveBeenCalledTimes(2); expect(renderSpy).toHaveBeenCalledTimes(2);
expect(renderSpy.calls.mostRecent().args).toEqual([{id: 'baz', contents: null}]); expect(renderSpy.calls.mostRecent().args).toEqual([{id: 'baz', contents: null}]);
}); });
@ -63,24 +66,20 @@ describe('DocViewerComponent', () => {
const obs = new ObservableWithSubscriptionSpies(); const obs = new ObservableWithSubscriptionSpies();
renderSpy.and.returnValue(obs); renderSpy.and.returnValue(obs);
setCurrentDoc('foo', 'bar'); setCurrentDoc({contents: 'foo', id: 'bar'});
expect(obs.subscribeSpy).toHaveBeenCalledTimes(1); expect(obs.subscribeSpy).toHaveBeenCalledTimes(1);
expect(obs.unsubscribeSpies[0]).not.toHaveBeenCalled(); expect(obs.unsubscribeSpies[0]).not.toHaveBeenCalled();
setCurrentDoc('baz', 'qux'); setCurrentDoc({contents: 'baz', id: 'qux'});
expect(obs.subscribeSpy).toHaveBeenCalledTimes(2); expect(obs.subscribeSpy).toHaveBeenCalledTimes(2);
expect(obs.unsubscribeSpies[0]).toHaveBeenCalledTimes(1); expect(obs.unsubscribeSpies[0]).toHaveBeenCalledTimes(1);
}); });
it('should ignore falsy document values', () => { it('should ignore falsy document values', () => {
parentComponent.currentDoc = null; setCurrentDoc(null);
parentFixture.detectChanges();
expect(renderSpy).not.toHaveBeenCalled(); expect(renderSpy).not.toHaveBeenCalled();
parentComponent.currentDoc = undefined; setCurrentDoc(undefined);
parentFixture.detectChanges();
expect(renderSpy).not.toHaveBeenCalled(); expect(renderSpy).not.toHaveBeenCalled();
}); });
}); });
@ -92,14 +91,17 @@ describe('DocViewerComponent', () => {
expect(renderSpy).not.toHaveBeenCalled(); expect(renderSpy).not.toHaveBeenCalled();
docViewer.doc = {contents: 'Some content', id: 'some-id'}; docViewer.doc = {contents: 'Some content', id: 'some-id'};
safeFlushAsapScheduler();
expect(renderSpy).toHaveBeenCalledTimes(1); expect(renderSpy).toHaveBeenCalledTimes(1);
docViewer.ngOnDestroy(); docViewer.ngOnDestroy();
docViewer.doc = {contents: 'Other content', id: 'other-id'}; docViewer.doc = {contents: 'Other content', id: 'other-id'};
safeFlushAsapScheduler();
expect(renderSpy).toHaveBeenCalledTimes(1); expect(renderSpy).toHaveBeenCalledTimes(1);
docViewer.doc = {contents: 'More content', id: 'more-id'}; docViewer.doc = {contents: 'More content', id: 'more-id'};
safeFlushAsapScheduler();
expect(renderSpy).toHaveBeenCalledTimes(1); expect(renderSpy).toHaveBeenCalledTimes(1);
}); });
}); });

View File

@ -1,8 +1,8 @@
import { Component, ElementRef, EventEmitter, Input, OnDestroy, Output } from '@angular/core'; import { Component, ElementRef, EventEmitter, Input, OnDestroy, Output } from '@angular/core';
import { Title, Meta } from '@angular/platform-browser'; import { Title, Meta } from '@angular/platform-browser';
import { Observable, of, timer } from 'rxjs'; import { asapScheduler, Observable, of, timer } from 'rxjs';
import { catchError, switchMap, takeUntil, tap } from 'rxjs/operators'; import { catchError, observeOn, switchMap, takeUntil, tap } from 'rxjs/operators';
import { DocumentContents, FILE_NOT_FOUND_ID, FETCHING_ERROR_ID } from 'app/documents/document.service'; import { DocumentContents, FILE_NOT_FOUND_ID, FETCHING_ERROR_ID } from 'app/documents/document.service';
import { Logger } from 'app/shared/logger.service'; import { Logger } from 'app/shared/logger.service';
@ -78,6 +78,7 @@ export class DocViewerComponent implements OnDestroy {
this.docContents$ this.docContents$
.pipe( .pipe(
observeOn(asapScheduler),
switchMap(newDoc => this.render(newDoc)), switchMap(newDoc => this.render(newDoc)),
takeUntil(this.onDestroy$), takeUntil(this.onDestroy$),
) )

View File

@ -7,19 +7,20 @@
*/ */
import {SafeValue} from '../../sanitization/bypass'; import {SafeValue} from '../../sanitization/bypass';
import {StyleSanitizeFn} from '../../sanitization/style_sanitizer'; import {StyleSanitizeFn} from '../../sanitization/style_sanitizer';
import {throwErrorIfNoChangesMode} from '../errors';
import {setInputsForProperty} from '../instructions/shared'; import {setInputsForProperty} from '../instructions/shared';
import {AttributeMarker, TAttributes, TNode, TNodeFlags, TNodeType} from '../interfaces/node'; import {AttributeMarker, TAttributes, TNode, TNodeFlags, TNodeType} from '../interfaces/node';
import {RElement} from '../interfaces/renderer'; import {RElement} from '../interfaces/renderer';
import {StylingMapArray, StylingMapArrayIndex, TStylingConfig, TStylingContext} from '../interfaces/styling'; import {StylingMapArray, StylingMapArrayIndex, TStylingConfig, TStylingContext} from '../interfaces/styling';
import {isDirectiveHost} from '../interfaces/type_checks'; import {isDirectiveHost} from '../interfaces/type_checks';
import {BINDING_INDEX, LView, RENDERER} from '../interfaces/view'; import {BINDING_INDEX, LView, RENDERER} from '../interfaces/view';
import {getActiveDirectiveId, getCurrentStyleSanitizer, getLView, getSelectedIndex, resetCurrentStyleSanitizer, setCurrentStyleSanitizer, setElementExitFn} from '../state'; import {getActiveDirectiveId, getCheckNoChangesMode, getCurrentStyleSanitizer, getLView, getSelectedIndex, resetCurrentStyleSanitizer, setCurrentStyleSanitizer, setElementExitFn} from '../state';
import {applyStylingMapDirectly, applyStylingValueDirectly, flushStyling, setClass, setStyle, updateClassViaContext, updateStyleViaContext} from '../styling/bindings'; import {applyStylingMapDirectly, applyStylingValueDirectly, flushStyling, setClass, setStyle, updateClassViaContext, updateStyleViaContext} from '../styling/bindings';
import {activateStylingMapFeature} from '../styling/map_based_bindings'; import {activateStylingMapFeature} from '../styling/map_based_bindings';
import {attachStylingDebugObject} from '../styling/styling_debug'; import {attachStylingDebugObject} from '../styling/styling_debug';
import {NO_CHANGE} from '../tokens'; import {NO_CHANGE} from '../tokens';
import {renderStringify} from '../util/misc_utils'; import {renderStringify} from '../util/misc_utils';
import {addItemToStylingMap, allocStylingMapArray, allocTStylingContext, allowDirectStyling, concatString, forceClassesAsString, forceStylesAsString, getInitialStylingValue, getStylingMapArray, hasClassInput, hasStyleInput, hasValueChanged, isContextLocked, isHostStylingActive, isStylingContext, normalizeIntoStylingMap, patchConfig, selectClassBasedInputName, setValue, stylingMapToString} from '../util/styling_utils'; import {addItemToStylingMap, allocStylingMapArray, allocTStylingContext, allowDirectStyling, concatString, forceClassesAsString, forceStylesAsString, getInitialStylingValue, getStylingMapArray, getValue, hasClassInput, hasStyleInput, hasValueChanged, isContextLocked, isHostStylingActive, isStylingContext, normalizeIntoStylingMap, patchConfig, selectClassBasedInputName, setValue, stylingMapToString} from '../util/styling_utils';
import {getNativeByTNode, getTNode} from '../util/view_utils'; import {getNativeByTNode, getTNode} from '../util/view_utils';
@ -171,6 +172,17 @@ function stylingProp(
patchConfig(context, TStylingConfig.HasPropBindings); patchConfig(context, TStylingConfig.HasPropBindings);
} }
// [style.prop] and [class.name] bindings do not use `bind()` and will
// therefore manage accessing and updating the new value in the lView directly.
// For this reason, the checkNoChanges situation must also be handled here
// as well.
if (ngDevMode && getCheckNoChangesMode()) {
const oldValue = getValue(lView, bindingIndex);
if (hasValueChanged(oldValue, value)) {
throwErrorIfNoChangesMode(false, oldValue, value);
}
}
// Direct Apply Case: bypass context resolution and apply the // Direct Apply Case: bypass context resolution and apply the
// style/class value directly to the element // style/class value directly to the element
if (allowDirectStyling(context, hostBindingsMode)) { if (allowDirectStyling(context, hostBindingsMode)) {
@ -246,7 +258,7 @@ export function ɵɵstyleMap(styles: {[styleName: string]: any} | NO_CHANGE | nu
styles = NO_CHANGE; styles = NO_CHANGE;
} }
const updated = _stylingMap(index, context, bindingIndex, styles, false); const updated = stylingMap(index, context, bindingIndex, styles, false);
if (ngDevMode) { if (ngDevMode) {
ngDevMode.styleMap++; ngDevMode.styleMap++;
if (updated) { if (updated) {
@ -303,7 +315,7 @@ export function classMapInternal(
classes = NO_CHANGE; classes = NO_CHANGE;
} }
const updated = _stylingMap(elementIndex, context, bindingIndex, classes, true); const updated = stylingMap(elementIndex, context, bindingIndex, classes, true);
if (ngDevMode) { if (ngDevMode) {
ngDevMode.classMap++; ngDevMode.classMap++;
if (updated) { if (updated) {
@ -318,7 +330,7 @@ export function classMapInternal(
* When this function is called it will activate support for `[style]` and * When this function is called it will activate support for `[style]` and
* `[class]` bindings in Angular. * `[class]` bindings in Angular.
*/ */
function _stylingMap( function stylingMap(
elementIndex: number, context: TStylingContext, bindingIndex: number, elementIndex: number, context: TStylingContext, bindingIndex: number,
value: {[key: string]: any} | string | null, isClassBased: boolean): boolean { value: {[key: string]: any} | string | null, isClassBased: boolean): boolean {
let updated = false; let updated = false;
@ -327,9 +339,18 @@ function _stylingMap(
const directiveIndex = getActiveDirectiveId(); const directiveIndex = getActiveDirectiveId();
const tNode = getTNode(elementIndex, lView); const tNode = getTNode(elementIndex, lView);
const native = getNativeByTNode(tNode, lView) as RElement; const native = getNativeByTNode(tNode, lView) as RElement;
const oldValue = lView[bindingIndex] as StylingMapArray | null; const oldValue = getValue(lView, bindingIndex);
const hostBindingsMode = isHostStyling(); const hostBindingsMode = isHostStyling();
const sanitizer = getCurrentStyleSanitizer(); const sanitizer = getCurrentStyleSanitizer();
const valueHasChanged = hasValueChanged(oldValue, value);
// [style] and [class] bindings do not use `bind()` and will therefore
// manage accessing and updating the new value in the lView directly.
// For this reason, the checkNoChanges situation must also be handled here
// as well.
if (ngDevMode && valueHasChanged && getCheckNoChangesMode()) {
throwErrorIfNoChangesMode(false, oldValue, value);
}
// we check for this in the instruction code so that the context can be notified // we check for this in the instruction code so that the context can be notified
// about prop or map bindings so that the direct apply check can decide earlier // about prop or map bindings so that the direct apply check can decide earlier
@ -338,7 +359,6 @@ function _stylingMap(
patchConfig(context, TStylingConfig.HasMapBindings); patchConfig(context, TStylingConfig.HasMapBindings);
} }
const valueHasChanged = hasValueChanged(oldValue, value);
const stylingMapArr = const stylingMapArr =
value === NO_CHANGE ? NO_CHANGE : normalizeIntoStylingMap(oldValue, value, !isClassBased); value === NO_CHANGE ? NO_CHANGE : normalizeIntoStylingMap(oldValue, value, !isClassBased);
@ -479,12 +499,12 @@ export function registerInitialStylingOnTNode(
if (typeof attr == 'number') { if (typeof attr == 'number') {
mode = attr; mode = attr;
} else if (mode == AttributeMarker.Classes) { } else if (mode == AttributeMarker.Classes) {
classes = classes || allocStylingMapArray(); classes = classes || allocStylingMapArray(null);
addItemToStylingMap(classes, attr, true); addItemToStylingMap(classes, attr, true);
hasAdditionalInitialStyling = true; hasAdditionalInitialStyling = true;
} else if (mode == AttributeMarker.Styles) { } else if (mode == AttributeMarker.Styles) {
const value = attrs[++i] as string | null; const value = attrs[++i] as string | null;
styles = styles || allocStylingMapArray(); styles = styles || allocStylingMapArray(null);
addItemToStylingMap(styles, attr, value); addItemToStylingMap(styles, attr, value);
hasAdditionalInitialStyling = true; hasAdditionalInitialStyling = true;
} }

View File

@ -522,8 +522,11 @@ export type LStylingData = LView | (string | number | boolean | null)[];
* of the key/value array that was used to populate the property/ * of the key/value array that was used to populate the property/
* value entries that take place in the remainder of the array. * value entries that take place in the remainder of the array.
*/ */
export interface StylingMapArray extends Array<{}|string|number|null> { export interface StylingMapArray extends Array<{}|string|number|null|undefined> {
[StylingMapArrayIndex.RawValuePosition]: {}|string|null; /**
* The last raw value used to generate the entries in the map.
*/
[StylingMapArrayIndex.RawValuePosition]: {}|string|number|null|undefined;
} }
/** /**

View File

@ -43,7 +43,7 @@ export const DEFAULT_GUARD_MASK_VALUE = 0b1;
*/ */
export function allocTStylingContext( export function allocTStylingContext(
initialStyling: StylingMapArray | null, hasDirectives: boolean): TStylingContext { initialStyling: StylingMapArray | null, hasDirectives: boolean): TStylingContext {
initialStyling = initialStyling || allocStylingMapArray(); initialStyling = initialStyling || allocStylingMapArray(null);
let config = TStylingConfig.Initial; let config = TStylingConfig.Initial;
if (hasDirectives) { if (hasDirectives) {
config |= TStylingConfig.HasDirectives; config |= TStylingConfig.HasDirectives;
@ -58,8 +58,8 @@ export function allocTStylingContext(
]; ];
} }
export function allocStylingMapArray(): StylingMapArray { export function allocStylingMapArray(value: {} | string | null): StylingMapArray {
return ['']; return [value];
} }
export function getConfig(context: TStylingContext) { export function getConfig(context: TStylingContext) {
@ -169,7 +169,7 @@ export function setValue(data: LStylingData, bindingIndex: number, value: any) {
} }
export function getValue<T = any>(data: LStylingData, bindingIndex: number): T|null { export function getValue<T = any>(data: LStylingData, bindingIndex: number): T|null {
return bindingIndex > 0 ? data[bindingIndex] as T : null; return bindingIndex !== 0 ? data[bindingIndex] as T : null;
} }
export function lockContext(context: TStylingContext, hostBindingsMode: boolean): void { export function lockContext(context: TStylingContext, hostBindingsMode: boolean): void {
@ -207,7 +207,7 @@ export function hasValueChanged(
/** /**
* Determines whether the provided styling value is truthy or falsy. * Determines whether the provided styling value is truthy or falsy.
*/ */
export function isStylingValueDefined<T extends string|number|{}|null>(value: T): export function isStylingValueDefined<T extends string|number|{}|null|undefined>(value: T):
value is NonNullable<T> { value is NonNullable<T> {
// the reason why null is compared against is because // the reason why null is compared against is because
// a CSS class value that is set to `false` must be // a CSS class value that is set to `false` must be
@ -396,8 +396,9 @@ export function normalizeIntoStylingMap(
bindingValue: null | StylingMapArray, bindingValue: null | StylingMapArray,
newValues: {[key: string]: any} | string | null | undefined, newValues: {[key: string]: any} | string | null | undefined,
normalizeProps?: boolean): StylingMapArray { normalizeProps?: boolean): StylingMapArray {
const stylingMapArr: StylingMapArray = Array.isArray(bindingValue) ? bindingValue : [null]; const stylingMapArr: StylingMapArray =
stylingMapArr[StylingMapArrayIndex.RawValuePosition] = newValues || null; Array.isArray(bindingValue) ? bindingValue : allocStylingMapArray(null);
stylingMapArr[StylingMapArrayIndex.RawValuePosition] = newValues;
// because the new values may not include all the properties // because the new values may not include all the properties
// that the old ones had, all values are set to `null` before // that the old ones had, all values are set to `null` before

View File

@ -2241,6 +2241,57 @@ describe('styling', () => {
fixture.detectChanges(); fixture.detectChanges();
expect(div.classList.contains('disabled')).toBe(false); expect(div.classList.contains('disabled')).toBe(false);
}); });
it('should throw an error if a prop-based style/class binding value is changed during checkNoChanges',
() => {
@Component({
template: `
<div [style.color]="color" [class.foo]="fooClass"></div>
`
})
class Cmp {
color = 'red';
fooClass = true;
ngAfterViewInit() {
this.color = 'blue';
this.fooClass = false;
}
}
TestBed.configureTestingModule({declarations: [Cmp]});
const fixture = TestBed.createComponent(Cmp);
expect(() => {
fixture.detectChanges();
}).toThrowError(/ExpressionChangedAfterItHasBeenCheckedError/);
});
onlyInIvy('only ivy allows for map-based style AND class bindings')
.it('should throw an error if a map-based style/class binding value is changed during checkNoChanges',
() => {
@Component({
template: `
<div [style]="style" [class]="klass"></div>
`
})
class Cmp {
style: any = {width: '100px'};
klass: any = {foo: true, bar: false};
ngAfterViewInit() {
this.style = {height: '200px'};
this.klass = {foo: false};
}
}
TestBed.configureTestingModule({declarations: [Cmp]});
const fixture = TestBed.createComponent(Cmp);
expect(() => {
fixture.detectChanges();
}).toThrowError(/ExpressionChangedAfterItHasBeenCheckedError/);
});
}); });
function assertStyleCounters(countForSet: number, countForRemove: number) { function assertStyleCounters(countForSet: number, countForRemove: number) {

View File

@ -81,5 +81,5 @@ describe('map-based bindings', () => {
function createAndAssertValues(newValue: any, entries: any[]) { function createAndAssertValues(newValue: any, entries: any[]) {
const result = createMap(null, newValue); const result = createMap(null, newValue);
expect(result).toEqual([newValue || null, ...entries]); expect(result).toEqual([newValue, ...entries]);
} }