angular-docs-cn/packages/core/test/render3/instructions_spec.ts
Miško Hevery 5aabe93abe refactor(ivy): Switch styling to new reconcile algorithm (#34616)
NOTE: This change must be reverted with previous deletes so that it code remains in build-able state.

This change deletes old styling code and replaces it with a simplified styling algorithm.

The mental model for the new algorithm is:
- Create a linked list of styling bindings in the order of priority. All styling bindings ere executed in compiled order and than a linked list of bindings is created in priority order.
- Flush the style bindings at the end of `advance()` instruction. This implies that there are two flush events. One at the end of template `advance` instruction in the template. Second one at the end of `hostBindings` `advance` instruction when processing host bindings (if any).
- Each binding instructions effectively updates the string to represent the string at that location. Because most of the bindings are additive, this is a cheap strategy in most cases. In rare cases the strategy requires removing tokens from the styling up to this point. (We expect that to be rare case)S Because, the bindings are presorted in the order of priority, it is safe to resume the processing of the concatenated string from the last change binding.

PR Close #34616
2020-01-24 12:23:00 -08:00

495 lines
20 KiB
TypeScript

/**
* @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 {NgForOfContext} from '@angular/common';
import {getSortedClassName} from '@angular/core/testing/src/styling';
import {ɵɵdefineComponent} from '../../src/render3/definition';
import {RenderFlags, ɵɵattribute, ɵɵclassMap, ɵɵelement, ɵɵelementEnd, ɵɵelementStart, ɵɵproperty, ɵɵselect, ɵɵstyleMap, ɵɵstyleProp, ɵɵstyleSanitizer, ɵɵtemplate, ɵɵtext, ɵɵtextInterpolate1} from '../../src/render3/index';
import {AttributeMarker} from '../../src/render3/interfaces/node';
import {SafeValue, bypassSanitizationTrustHtml, bypassSanitizationTrustResourceUrl, bypassSanitizationTrustScript, bypassSanitizationTrustStyle, bypassSanitizationTrustUrl, getSanitizationBypassType, unwrapSafeValue} from '../../src/sanitization/bypass';
import {ɵɵdefaultStyleSanitizer, ɵɵsanitizeHtml, ɵɵsanitizeResourceUrl, ɵɵsanitizeScript, ɵɵsanitizeStyle, ɵɵsanitizeUrl} from '../../src/sanitization/sanitization';
import {Sanitizer} from '../../src/sanitization/sanitizer';
import {SecurityContext} from '../../src/sanitization/security';
import {NgForOf} from './common_with_def';
import {ComponentFixture, TemplateFixture} from './render_util';
describe('instructions', () => {
function createAnchor() { ɵɵelement(0, 'a'); }
function createDiv() { ɵɵelement(0, 'div', 0); }
function createScript() { ɵɵelement(0, 'script'); }
describe('ɵɵselect', () => {
it('should error in DevMode if index is out of range', () => {
// Only one constant added, meaning only index `0` is valid.
const t = new TemplateFixture(createDiv, () => {}, 1, 0);
expect(() => { t.update(() => { ɵɵselect(-1); }); }).toThrow();
expect(() => { t.update(() => { ɵɵselect(1); }); }).toThrow();
});
});
describe('bind', () => {
it('should update bindings when value changes with the correct perf counters', () => {
const t = new TemplateFixture(createAnchor, () => {}, 1, 1);
t.update(() => { ɵɵproperty('title', 'Hello'); });
expect(t.html).toEqual('<a title="Hello"></a>');
t.update(() => { ɵɵproperty('title', 'World'); });
expect(t.html).toEqual('<a title="World"></a>');
expect(ngDevMode).toHaveProperties({
firstCreatePass: 1,
tNode: 2, // 1 for hostElement + 1 for the template under test
tView: 2, // 1 for rootView + 1 for the template view
rendererCreateElement: 1,
rendererSetProperty: 2
});
});
it('should not update bindings when value does not change, with the correct perf counters',
() => {
const idempotentUpdate = () => { ɵɵproperty('title', 'Hello'); };
const t = new TemplateFixture(createAnchor, idempotentUpdate, 1, 1);
t.update();
expect(t.html).toEqual('<a title="Hello"></a>');
t.update();
expect(t.html).toEqual('<a title="Hello"></a>');
expect(ngDevMode).toHaveProperties({
firstCreatePass: 1,
tNode: 2, // 1 for hostElement + 1 for the template under test
tView: 2, // 1 for rootView + 1 for the template view
rendererCreateElement: 1,
rendererSetProperty: 1
});
});
});
describe('element', () => {
it('should create an element with the correct perf counters', () => {
const t = new TemplateFixture(() => {
ɵɵelement(0, 'div', 0);
}, () => {}, 1, 0, null, null, null, undefined, [['id', 'test', 'title', 'Hello']]);
const div = (t.hostElement as HTMLElement).querySelector('div') !;
expect(div.id).toEqual('test');
expect(div.title).toEqual('Hello');
expect(ngDevMode).toHaveProperties({
firstCreatePass: 1,
tNode: 2, // 1 for div, 1 for host element
tView: 2, // 1 for rootView + 1 for the template view
rendererCreateElement: 1,
});
});
});
describe('attribute', () => {
it('should use sanitizer function', () => {
const t = new TemplateFixture(createDiv, () => {}, 1, 1);
t.update(() => { ɵɵattribute('title', 'javascript:true', ɵɵsanitizeUrl); });
expect(t.html).toEqual('<div title="unsafe:javascript:true"></div>');
t.update(() => {
ɵɵattribute('title', bypassSanitizationTrustUrl('javascript:true'), ɵɵsanitizeUrl);
});
expect(t.html).toEqual('<div title="javascript:true"></div>');
expect(ngDevMode).toHaveProperties({
firstCreatePass: 1,
tNode: 2, // 1 for div, 1 for host element
tView: 2, // 1 for rootView + 1 for the template view
rendererCreateElement: 1,
rendererSetAttribute: 2
});
});
});
describe('property', () => {
/**
* TODO: We need to replace this with an acceptance test, but for right now,
* this is the only test that ensures chaining works, since code generation
* is not producing chained instructions yet.
*/
it('should chain', () => {
// <div [title]="title" [accesskey]="key"></div>
const t = new TemplateFixture(createDiv, () => {}, 1, 2);
t.update(() => { ɵɵproperty('title', 'one')('accessKey', 'A'); });
expect(t.html).toEqual('<div accesskey="A" title="one"></div>');
t.update(() => { ɵɵproperty('title', 'two')('accessKey', 'B'); });
expect(t.html).toEqual('<div accesskey="B" title="two"></div>');
expect(ngDevMode).toHaveProperties({
firstCreatePass: 1,
tNode: 2, // 1 for div, 1 for host element
tView: 2, // 1 for rootView + 1 for the template view
rendererCreateElement: 1,
rendererSetProperty: 4,
});
});
});
describe('styleProp', () => {
it('should automatically sanitize unless a bypass operation is applied', () => {
let backgroundImage: string|SafeValue = 'url("http://server")';
const t = new TemplateFixture(
() => { return createDiv(); },
() => {
ɵɵstyleSanitizer(ɵɵdefaultStyleSanitizer);
ɵɵstyleProp('background-image', backgroundImage);
},
2, 2);
// nothing is set because sanitizer suppresses it.
expect((t.hostElement.firstChild as HTMLElement).style.getPropertyValue('background-image'))
.toEqual('');
backgroundImage = bypassSanitizationTrustStyle('url("http://server2")');
t.update();
expect((t.hostElement.firstChild as HTMLElement).style.getPropertyValue('background-image'))
.toEqual('url("http://server2")');
});
});
describe('styleMap', () => {
const attrs = [[AttributeMarker.Styles, 'height', '10px']];
function createDivWithStyle() { ɵɵelement(0, 'div', 0); }
it('should add style', () => {
const fixture = new TemplateFixture(createDivWithStyle, () => {
ɵɵstyleMap({'background-color': 'red'});
}, 1, 2, null, null, null, undefined, attrs);
fixture.update();
expect(fixture.html).toEqual('<div style="background-color: red; height: 10px;"></div>');
});
it('should sanitize new styles that may contain `url` properties', () => {
const detectedValues: string[] = [];
const sanitizerInterceptor =
new MockSanitizerInterceptor(value => { detectedValues.push(value); });
const fixture = new TemplateFixture(
() => { return createDiv(); }, //
() => {
ɵɵstyleSanitizer(sanitizerInterceptor.getStyleSanitizer());
ɵɵstyleMap({
'background-image': 'background-image',
'background': 'background',
'border-image': 'border-image',
'list-style': 'list-style',
'list-style-image': 'list-style-image',
'filter': 'filter',
'width': 'width'
});
},
1, 2, null, null, sanitizerInterceptor);
const props = detectedValues.sort();
expect(props).toEqual([
'background', 'background-image', 'border-image', 'filter', 'list-style', 'list-style-image'
]);
});
});
describe('elementClass', () => {
function createDivWithStyling() { ɵɵelement(0, 'div'); }
it('should add class', () => {
const fixture = new TemplateFixture(
createDivWithStyling, () => { ɵɵclassMap('multiple classes'); }, 1, 2);
const div = fixture.containerElement.querySelector('div.multiple') !;
expect(getSortedClassName(div)).toEqual('classes multiple');
});
});
describe('performance counters', () => {
it('should create tViews only once for each nested level', () => {
function ToDoAppComponent_NgForOf_Template_0(
rf: RenderFlags, ctx0: NgForOfContext<any, any>) {
if (rf & RenderFlags.Create) {
ɵɵelementStart(0, 'ul');
ɵɵtemplate(1, ToDoAppComponent_NgForOf_NgForOf_Template_1, 2, 1, 'li', 0);
ɵɵelementEnd();
}
if (rf & RenderFlags.Update) {
const row_r2 = ctx0.$implicit;
ɵɵselect(1);
ɵɵproperty('ngForOf', row_r2);
}
}
function ToDoAppComponent_NgForOf_NgForOf_Template_1(
rf: RenderFlags, ctx1: NgForOfContext<any, any>) {
if (rf & RenderFlags.Create) {
ɵɵelementStart(0, 'li');
ɵɵtext(1);
ɵɵelementEnd();
}
if (rf & RenderFlags.Update) {
const col_r3 = ctx1.$implicit;
ɵɵselect(1);
ɵɵtextInterpolate1('', col_r3, '');
}
}
/**
* <ul *ngFor="let row of rows">
* <li *ngFor="let col of row.cols">{{col}}</li>
* </ul>
*/
class NestedLoops {
rows = [['a', 'b'], ['A', 'B'], ['a', 'b'], ['A', 'B']];
static ɵfac = function ToDoAppComponent_Factory() { return new NestedLoops(); };
static ɵcmp = ɵɵdefineComponent({
type: NestedLoops,
selectors: [['nested-loops']],
decls: 1,
vars: 1,
consts: [[AttributeMarker.Template, 'ngFor', 'ngForOf']],
template: function ToDoAppComponent_Template(rf: RenderFlags, ctx: NestedLoops) {
if (rf & RenderFlags.Create) {
ɵɵtemplate(0, ToDoAppComponent_NgForOf_Template_0, 2, 1, 'ul', 0);
}
if (rf & RenderFlags.Update) {
ɵɵproperty('ngForOf', ctx.rows);
}
},
directives: [NgForOf]
});
}
const fixture = new ComponentFixture(NestedLoops);
expect(ngDevMode).toHaveProperties({
// Expect: fixture view/Host view + component + ngForRow + ngForCol
tView: 4, // should be: 4,
});
});
});
describe('sanitization injection compatibility', () => {
it('should work for url sanitization', () => {
const s = new LocalMockSanitizer(value => `${value}-sanitized`);
const t = new TemplateFixture(createAnchor, undefined, 1, 1, null, null, s);
const inputValue = 'http://foo';
const outputValue = 'http://foo-sanitized';
t.update(() => { ɵɵattribute('href', inputValue, ɵɵsanitizeUrl); });
expect(t.html).toEqual(`<a href="${outputValue}"></a>`);
expect(s.lastSanitizedValue).toEqual(outputValue);
});
it('should bypass url sanitization if marked by the service', () => {
const s = new LocalMockSanitizer(value => '');
const t = new TemplateFixture(createAnchor, undefined, 1, 1, null, null, s);
const inputValue = s.bypassSecurityTrustUrl('http://foo');
const outputValue = 'http://foo';
t.update(() => { ɵɵattribute('href', inputValue, ɵɵsanitizeUrl); });
expect(t.html).toEqual(`<a href="${outputValue}"></a>`);
expect(s.lastSanitizedValue).toBeFalsy();
});
it('should bypass ivy-level url sanitization if a custom sanitizer is used', () => {
const s = new LocalMockSanitizer(value => '');
const t = new TemplateFixture(createAnchor, undefined, 1, 1, null, null, s);
const inputValue = bypassSanitizationTrustUrl('http://foo');
const outputValue = 'http://foo-ivy';
t.update(() => { ɵɵattribute('href', inputValue, ɵɵsanitizeUrl); });
expect(t.html).toEqual(`<a href="${outputValue}"></a>`);
expect(s.lastSanitizedValue).toBeFalsy();
});
it('should work for style sanitization', () => {
const s = new LocalMockSanitizer(value => `color:blue`);
const t = new TemplateFixture(createDiv, undefined, 1, 1, null, null, s);
const inputValue = 'color:red';
const outputValue = 'color:blue';
t.update(() => { ɵɵattribute('style', inputValue, ɵɵsanitizeStyle); });
expect(stripStyleWsCharacters(t.html)).toEqual(`<div style="${outputValue}"></div>`);
expect(s.lastSanitizedValue).toEqual(outputValue);
});
it('should bypass style sanitization if marked by the service', () => {
const s = new LocalMockSanitizer(value => '');
const t = new TemplateFixture(createDiv, undefined, 1, 1, null, null, s);
const inputValue = s.bypassSecurityTrustStyle('color:maroon');
const outputValue = 'color:maroon';
t.update(() => { ɵɵattribute('style', inputValue, ɵɵsanitizeStyle); });
expect(stripStyleWsCharacters(t.html)).toEqual(`<div style="${outputValue}"></div>`);
expect(s.lastSanitizedValue).toBeFalsy();
});
it('should bypass ivy-level style sanitization if a custom sanitizer is used', () => {
const s = new LocalMockSanitizer(value => '');
const t = new TemplateFixture(createDiv, undefined, 1, 1, null, null, s);
const inputValue = bypassSanitizationTrustStyle('font-family:foo');
const outputValue = 'font-family:foo-ivy';
t.update(() => { ɵɵattribute('style', inputValue, ɵɵsanitizeStyle); });
expect(stripStyleWsCharacters(t.html)).toEqual(`<div style="${outputValue}"></div>`);
expect(s.lastSanitizedValue).toBeFalsy();
});
it('should work for resourceUrl sanitization', () => {
const s = new LocalMockSanitizer(value => `${value}-sanitized`);
const t = new TemplateFixture(createScript, undefined, 1, 1, null, null, s);
const inputValue = 'http://resource';
const outputValue = 'http://resource-sanitized';
t.update(() => { ɵɵattribute('src', inputValue, ɵɵsanitizeResourceUrl); });
expect(t.html).toEqual(`<script src="${outputValue}"></script>`);
expect(s.lastSanitizedValue).toEqual(outputValue);
});
it('should bypass resourceUrl sanitization if marked by the service', () => {
const s = new LocalMockSanitizer(value => '');
const t = new TemplateFixture(createScript, undefined, 1, 1, null, null, s);
const inputValue = s.bypassSecurityTrustResourceUrl('file://all-my-secrets.pdf');
const outputValue = 'file://all-my-secrets.pdf';
t.update(() => { ɵɵattribute('src', inputValue, ɵɵsanitizeResourceUrl); });
expect(t.html).toEqual(`<script src="${outputValue}"></script>`);
expect(s.lastSanitizedValue).toBeFalsy();
});
it('should bypass ivy-level resourceUrl sanitization if a custom sanitizer is used', () => {
const s = new LocalMockSanitizer(value => '');
const t = new TemplateFixture(createScript, undefined, 1, 1, null, null, s);
const inputValue = bypassSanitizationTrustResourceUrl('file://all-my-secrets.pdf');
const outputValue = 'file://all-my-secrets.pdf-ivy';
t.update(() => { ɵɵattribute('src', inputValue, ɵɵsanitizeResourceUrl); });
expect(t.html).toEqual(`<script src="${outputValue}"></script>`);
expect(s.lastSanitizedValue).toBeFalsy();
});
it('should work for script sanitization', () => {
const s = new LocalMockSanitizer(value => `${value} //sanitized`);
const t = new TemplateFixture(createScript, undefined, 1, 1, null, null, s);
const inputValue = 'fn();';
const outputValue = 'fn(); //sanitized';
t.update(() => { ɵɵproperty('innerHTML', inputValue, ɵɵsanitizeScript); });
expect(t.html).toEqual(`<script>${outputValue}</script>`);
expect(s.lastSanitizedValue).toEqual(outputValue);
});
it('should bypass script sanitization if marked by the service', () => {
const s = new LocalMockSanitizer(value => '');
const t = new TemplateFixture(createScript, undefined, 1, 1, null, null, s);
const inputValue = s.bypassSecurityTrustScript('alert("bar")');
const outputValue = 'alert("bar")';
t.update(() => { ɵɵproperty('innerHTML', inputValue, ɵɵsanitizeScript); });
expect(t.html).toEqual(`<script>${outputValue}</script>`);
expect(s.lastSanitizedValue).toBeFalsy();
});
it('should bypass ivy-level script sanitization if a custom sanitizer is used', () => {
const s = new LocalMockSanitizer(value => '');
const t = new TemplateFixture(createScript, undefined, 1, 1, null, null, s);
const inputValue = bypassSanitizationTrustScript('alert("bar")');
const outputValue = 'alert("bar")-ivy';
t.update(() => { ɵɵproperty('innerHTML', inputValue, ɵɵsanitizeScript); });
expect(t.html).toEqual(`<script>${outputValue}</script>`);
expect(s.lastSanitizedValue).toBeFalsy();
});
it('should work for html sanitization', () => {
const s = new LocalMockSanitizer(value => `${value} <!--sanitized-->`);
const t = new TemplateFixture(createDiv, undefined, 1, 1, null, null, s);
const inputValue = '<header></header>';
const outputValue = '<header></header> <!--sanitized-->';
t.update(() => { ɵɵproperty('innerHTML', inputValue, ɵɵsanitizeHtml); });
expect(t.html).toEqual(`<div>${outputValue}</div>`);
expect(s.lastSanitizedValue).toEqual(outputValue);
});
it('should bypass html sanitization if marked by the service', () => {
const s = new LocalMockSanitizer(value => '');
const t = new TemplateFixture(createDiv, undefined, 1, 1, null, null, s);
const inputValue = s.bypassSecurityTrustHtml('<div onclick="alert(123)"></div>');
const outputValue = '<div onclick="alert(123)"></div>';
t.update(() => { ɵɵproperty('innerHTML', inputValue, ɵɵsanitizeHtml); });
expect(t.html).toEqual(`<div>${outputValue}</div>`);
expect(s.lastSanitizedValue).toBeFalsy();
});
it('should bypass ivy-level script sanitization if a custom sanitizer is used', () => {
const s = new LocalMockSanitizer(value => '');
const t = new TemplateFixture(createDiv, undefined, 1, 1, null, null, s);
const inputValue = bypassSanitizationTrustHtml('<div onclick="alert(123)"></div>');
const outputValue = '<div onclick="alert(123)"></div>-ivy';
t.update(() => { ɵɵproperty('innerHTML', inputValue, ɵɵsanitizeHtml); });
expect(t.html).toEqual(`<div>${outputValue}</div>`);
expect(s.lastSanitizedValue).toBeFalsy();
});
});
});
class LocalSanitizedValue {
constructor(public value: any) {}
toString() { return this.value; }
}
class LocalMockSanitizer implements Sanitizer {
// TODO(issue/24571): remove '!'.
public lastSanitizedValue !: string | null;
constructor(private _interceptor: (value: string|null|any) => string) {}
sanitize(context: SecurityContext, value: LocalSanitizedValue|string|null|any): string|null {
if (getSanitizationBypassType(value) != null) {
return unwrapSafeValue(value) + '-ivy';
}
if (value instanceof LocalSanitizedValue) {
return value.toString();
}
return this.lastSanitizedValue = this._interceptor(value);
}
bypassSecurityTrustHtml(value: string) { return new LocalSanitizedValue(value); }
bypassSecurityTrustStyle(value: string) { return new LocalSanitizedValue(value); }
bypassSecurityTrustScript(value: string) { return new LocalSanitizedValue(value); }
bypassSecurityTrustUrl(value: string) { return new LocalSanitizedValue(value); }
bypassSecurityTrustResourceUrl(value: string) { return new LocalSanitizedValue(value); }
}
class MockSanitizerInterceptor {
public lastValue: string|null = null;
constructor(private _interceptorFn?: ((value: any) => any)|null) {}
getStyleSanitizer() { return ɵɵdefaultStyleSanitizer; }
sanitize(context: SecurityContext, value: LocalSanitizedValue|string|null|any): string|null {
if (this._interceptorFn) {
this._interceptorFn(value);
}
return this.lastValue = value;
}
}
function stripStyleWsCharacters(value: string): string {
// color: blue; => color:blue
return value.replace(/;/g, '').replace(/:\s+/g, ':');
}