angular-cn/packages/core/test/render3/component_spec.ts

712 lines
22 KiB
TypeScript
Raw Normal View History

/**
* @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 {InjectionToken, ViewEncapsulation, ΔdefineInjectable, ΔdefineInjector} from '../../src/core';
import {createInjector} from '../../src/di/r3_injector';
import {AttributeMarker, ComponentFactory, LifecycleHooksFeature, getRenderedText, markDirty, ΔProvidersFeature, ΔdefineComponent, ΔdirectiveInject, Δtemplate} from '../../src/render3/index';
import {tick, Δbind, Δcontainer, ΔcontainerRefreshEnd, ΔcontainerRefreshStart, Δelement, ΔelementEnd, ΔelementProperty, ΔelementStart, ΔembeddedViewEnd, ΔembeddedViewStart, ΔnextContext, Δtext, ΔtextBinding} from '../../src/render3/instructions/all';
import {ComponentDef, RenderFlags} from '../../src/render3/interfaces/definition';
import {NgIf} from './common_with_def';
import {getRendererFactory2} from './imported_renderer2';
import {ComponentFixture, MockRendererFactory, containerEl, createComponent, renderComponent, renderToHtml, requestAnimationFrame, toHtml} from './render_util';
describe('component', () => {
class CounterComponent {
count = 0;
increment() { this.count++; }
static ngComponentDef = ΔdefineComponent({
type: CounterComponent,
encapsulation: ViewEncapsulation.None,
selectors: [['counter']],
consts: 1,
vars: 1,
template: function(rf: RenderFlags, ctx: CounterComponent) {
if (rf & RenderFlags.Create) {
Δtext(0);
}
if (rf & RenderFlags.Update) {
ΔtextBinding(0, Δbind(ctx.count));
}
},
factory: () => new CounterComponent,
inputs: {count: 'count'},
});
}
describe('renderComponent', () => {
it('should render on initial call', () => {
renderComponent(CounterComponent);
expect(toHtml(containerEl)).toEqual('0');
});
it('should re-render on input change or method invocation', () => {
const component = renderComponent(CounterComponent);
expect(toHtml(containerEl)).toEqual('0');
component.count = 123;
markDirty(component);
expect(toHtml(containerEl)).toEqual('0');
requestAnimationFrame.flush();
expect(toHtml(containerEl)).toEqual('123');
component.increment();
markDirty(component);
expect(toHtml(containerEl)).toEqual('123');
requestAnimationFrame.flush();
expect(toHtml(containerEl)).toEqual('124');
});
class MyService {
constructor(public value: string) {}
static ngInjectableDef =
ΔdefineInjectable({providedIn: 'root', factory: () => new MyService('no-injector')});
}
class MyComponent {
constructor(public myService: MyService) {}
static ngComponentDef = ΔdefineComponent({
type: MyComponent,
encapsulation: ViewEncapsulation.None,
selectors: [['my-component']],
factory: () => new MyComponent(ΔdirectiveInject(MyService)),
consts: 1,
vars: 1,
template: function(fs: RenderFlags, ctx: MyComponent) {
if (fs & RenderFlags.Create) {
Δtext(0);
}
if (fs & RenderFlags.Update) {
ΔtextBinding(0, Δbind(ctx.myService.value));
}
}
});
}
class MyModule {
static ngInjectorDef = ΔdefineInjector({
factory: () => new MyModule(),
providers: [{provide: MyService, useValue: new MyService('injector')}]
});
}
it('should support bootstrapping without injector', () => {
const fixture = new ComponentFixture(MyComponent);
expect(fixture.html).toEqual('no-injector');
});
it('should support bootstrapping with injector', () => {
const fixture = new ComponentFixture(MyComponent, {injector: createInjector(MyModule)});
expect(fixture.html).toEqual('injector');
});
});
it('should instantiate components at high indices', () => {
// {{ name }}
class Comp {
// @Input
name = '';
static ngComponentDef = ΔdefineComponent({
type: Comp,
selectors: [['comp']],
factory: () => new Comp(),
consts: 1,
vars: 1,
template: (rf: RenderFlags, ctx: Comp) => {
if (rf & RenderFlags.Create) {
Δtext(0);
}
if (rf & RenderFlags.Update) {
ΔtextBinding(0, Δbind(ctx.name));
}
},
inputs: {name: 'name'}
});
}
// Artificially inflating the slot IDs of this app component to mimic an app
// with a very large view
const App = createComponent('app', (rf: RenderFlags, ctx: any) => {
if (rf & RenderFlags.Create) {
Δelement(4097, 'comp');
}
if (rf & RenderFlags.Update) {
ΔelementProperty(4097, 'name', Δbind(ctx.name));
}
}, 4098, 1, [Comp]);
const fixture = new ComponentFixture(App);
expect(fixture.html).toEqual('<comp></comp>');
fixture.component.name = 'some name';
fixture.update();
expect(fixture.html).toEqual('<comp>some name</comp>');
});
});
it('should not invoke renderer destroy method for embedded views', () => {
let comp: Comp;
function MyComponent_div_Template_2(rf: any, ctx: any) {
if (rf & RenderFlags.Create) {
ΔelementStart(0, 'div');
Δtext(1, 'Child view');
ΔelementEnd();
}
}
class Comp {
visible = true;
static ngComponentDef = ΔdefineComponent({
type: Comp,
selectors: [['comp']],
consts: 3,
vars: 1,
factory: () => {
comp = new Comp();
return comp;
},
directives: [NgIf],
/**
* <div>Root view</div>
* <div *ngIf="visible">Child view</div>
*/
template: function(rf: RenderFlags, ctx: Comp) {
if (rf & RenderFlags.Create) {
ΔelementStart(0, 'div');
Δtext(1, 'Root view');
ΔelementEnd();
Δtemplate(2, MyComponent_div_Template_2, 2, 0, 'div', [AttributeMarker.Template, 'ngIf']);
}
if (rf & RenderFlags.Update) {
ΔelementProperty(2, 'ngIf', Δbind(ctx.visible));
}
}
});
}
const rendererFactory = new MockRendererFactory(['destroy']);
const fixture = new ComponentFixture(Comp, {rendererFactory});
comp !.visible = false;
fixture.update();
comp !.visible = true;
fixture.update();
const renderer = rendererFactory.lastRenderer !;
const destroySpy = renderer.spies['destroy'];
// we should never see `destroy` method being called
// in case child views are created/removed
expect(destroySpy.calls.count()).toBe(0);
});
describe('component with a container', () => {
function showItems(rf: RenderFlags, ctx: {items: string[]}) {
if (rf & RenderFlags.Create) {
Δcontainer(0);
}
if (rf & RenderFlags.Update) {
ΔcontainerRefreshStart(0);
{
for (const item of ctx.items) {
const rf0 = ΔembeddedViewStart(0, 1, 1);
{
if (rf0 & RenderFlags.Create) {
Δtext(0);
}
if (rf0 & RenderFlags.Update) {
ΔtextBinding(0, Δbind(item));
}
}
ΔembeddedViewEnd();
}
}
ΔcontainerRefreshEnd();
}
}
class WrapperComponent {
// TODO(issue/24571): remove '!'.
items !: string[];
static ngComponentDef = ΔdefineComponent({
type: WrapperComponent,
encapsulation: ViewEncapsulation.None,
selectors: [['wrapper']],
consts: 1,
vars: 0,
template: function ChildComponentTemplate(rf: RenderFlags, ctx: {items: string[]}) {
if (rf & RenderFlags.Create) {
Δcontainer(0);
}
if (rf & RenderFlags.Update) {
ΔcontainerRefreshStart(0);
{
const rf0 = ΔembeddedViewStart(0, 1, 0);
{ showItems(rf0, {items: ctx.items}); }
ΔembeddedViewEnd();
}
ΔcontainerRefreshEnd();
}
},
factory: () => new WrapperComponent,
inputs: {items: 'items'}
});
}
function template(rf: RenderFlags, ctx: {items: string[]}) {
if (rf & RenderFlags.Create) {
Δelement(0, 'wrapper');
}
if (rf & RenderFlags.Update) {
ΔelementProperty(0, 'items', Δbind(ctx.items));
}
}
const defs = [WrapperComponent];
it('should re-render on input change', () => {
const ctx: {items: string[]} = {items: ['a']};
expect(renderToHtml(template, ctx, 1, 1, defs)).toEqual('<wrapper>a</wrapper>');
ctx.items = [...ctx.items, 'b'];
expect(renderToHtml(template, ctx, 1, 1, defs)).toEqual('<wrapper>ab</wrapper>');
});
});
// TODO: add tests with Native once tests are run in real browser (domino doesn't support shadow
// root)
describe('encapsulation', () => {
class WrapperComponent {
static ngComponentDef = ΔdefineComponent({
type: WrapperComponent,
encapsulation: ViewEncapsulation.None,
selectors: [['wrapper']],
consts: 1,
vars: 0,
template: function(rf: RenderFlags, ctx: WrapperComponent) {
if (rf & RenderFlags.Create) {
Δelement(0, 'encapsulated');
}
},
factory: () => new WrapperComponent,
directives: () => [EncapsulatedComponent]
});
}
class EncapsulatedComponent {
static ngComponentDef = ΔdefineComponent({
type: EncapsulatedComponent,
selectors: [['encapsulated']],
consts: 2,
vars: 0,
template: function(rf: RenderFlags, ctx: EncapsulatedComponent) {
if (rf & RenderFlags.Create) {
Δtext(0, 'foo');
Δelement(1, 'leaf');
}
},
factory: () => new EncapsulatedComponent,
encapsulation: ViewEncapsulation.Emulated,
styles: [],
data: {},
directives: () => [LeafComponent]
});
}
class LeafComponent {
static ngComponentDef = ΔdefineComponent({
type: LeafComponent,
encapsulation: ViewEncapsulation.None,
selectors: [['leaf']],
consts: 2,
vars: 0,
template: function(rf: RenderFlags, ctx: LeafComponent) {
if (rf & RenderFlags.Create) {
ΔelementStart(0, 'span');
{ Δtext(1, 'bar'); }
ΔelementEnd();
}
},
factory: () => new LeafComponent,
});
}
it('should encapsulate children, but not host nor grand children', () => {
renderComponent(WrapperComponent, {rendererFactory: getRendererFactory2(document)});
expect(containerEl.outerHTML)
.toMatch(
/<div host=""><encapsulated _nghost-[a-z]+-c(\d+)="">foo<leaf _ngcontent-[a-z]+-c\1=""><span>bar<\/span><\/leaf><\/encapsulated><\/div>/);
});
it('should encapsulate host', () => {
renderComponent(EncapsulatedComponent, {rendererFactory: getRendererFactory2(document)});
expect(containerEl.outerHTML)
.toMatch(
/<div host="" _nghost-[a-z]+-c(\d+)="">foo<leaf _ngcontent-[a-z]+-c\1=""><span>bar<\/span><\/leaf><\/div>/);
});
it('should encapsulate host and children with different attributes', () => {
class WrapperComponentWith {
static ngComponentDef = ΔdefineComponent({
type: WrapperComponentWith,
selectors: [['wrapper']],
consts: 1,
vars: 0,
template: function(rf: RenderFlags, ctx: WrapperComponentWith) {
if (rf & RenderFlags.Create) {
Δelement(0, 'leaf');
}
},
factory: () => new WrapperComponentWith,
encapsulation: ViewEncapsulation.Emulated,
styles: [],
data: {},
directives: () => [LeafComponentwith]
});
}
class LeafComponentwith {
static ngComponentDef = ΔdefineComponent({
type: LeafComponentwith,
selectors: [['leaf']],
consts: 2,
vars: 0,
template: function(rf: RenderFlags, ctx: LeafComponentwith) {
if (rf & RenderFlags.Create) {
ΔelementStart(0, 'span');
{ Δtext(1, 'bar'); }
ΔelementEnd();
}
},
factory: () => new LeafComponentwith,
encapsulation: ViewEncapsulation.Emulated,
styles: [],
data: {},
});
}
renderComponent(WrapperComponentWith, {rendererFactory: getRendererFactory2(document)});
expect(containerEl.outerHTML)
.toMatch(
/<div host="" _nghost-[a-z]+-c(\d+)=""><leaf _ngcontent-[a-z]+-c\1="" _nghost-[a-z]+-c(\d+)=""><span _ngcontent-[a-z]+-c\2="">bar<\/span><\/leaf><\/div>/);
});
});
describe('recursive components', () => {
let events: string[];
let count: number;
beforeEach(() => {
events = [];
count = 0;
});
class TreeNode {
constructor(
public value: number, public depth: number, public left: TreeNode|null,
public right: TreeNode|null) {}
}
/**
* {{ data.value }}
*
* % if (data.left != null) {
* <tree-comp [data]="data.left"></tree-comp>
* % }
* % if (data.right != null) {
* <tree-comp [data]="data.right"></tree-comp>
* % }
*/
class TreeComponent {
data: TreeNode = _buildTree(0);
ngDoCheck() { events.push('check' + this.data.value); }
ngOnDestroy() { events.push('destroy' + this.data.value); }
static ngComponentDef = ΔdefineComponent({
type: TreeComponent,
encapsulation: ViewEncapsulation.None,
selectors: [['tree-comp']],
factory: () => new TreeComponent(),
consts: 3,
vars: 1,
template: (rf: RenderFlags, ctx: TreeComponent) => {
if (rf & RenderFlags.Create) {
Δtext(0);
Δcontainer(1);
Δcontainer(2);
}
if (rf & RenderFlags.Update) {
ΔtextBinding(0, Δbind(ctx.data.value));
ΔcontainerRefreshStart(1);
{
if (ctx.data.left != null) {
let rf0 = ΔembeddedViewStart(0, 1, 1);
if (rf0 & RenderFlags.Create) {
Δelement(0, 'tree-comp');
}
if (rf0 & RenderFlags.Update) {
ΔelementProperty(0, 'data', Δbind(ctx.data.left));
}
ΔembeddedViewEnd();
}
}
ΔcontainerRefreshEnd();
ΔcontainerRefreshStart(2);
{
if (ctx.data.right != null) {
let rf0 = ΔembeddedViewStart(0, 1, 1);
if (rf0 & RenderFlags.Create) {
Δelement(0, 'tree-comp');
}
if (rf0 & RenderFlags.Update) {
ΔelementProperty(0, 'data', Δbind(ctx.data.right));
}
ΔembeddedViewEnd();
}
}
ΔcontainerRefreshEnd();
}
},
inputs: {data: 'data'}
});
}
(TreeComponent.ngComponentDef as ComponentDef<TreeComponent>).directiveDefs =
() => [TreeComponent.ngComponentDef];
/**
* {{ data.value }}
* <ng-if-tree [data]="data.left" *ngIf="data.left"></ng-if-tree>
* <ng-if-tree [data]="data.right" *ngIf="data.right"></ng-if-tree>
*/
class NgIfTree {
data: TreeNode = _buildTree(0);
ngDoCheck() { events.push('check' + this.data.value); }
ngOnDestroy() { events.push('destroy' + this.data.value); }
static ngComponentDef = ΔdefineComponent({
type: NgIfTree,
encapsulation: ViewEncapsulation.None,
selectors: [['ng-if-tree']],
factory: () => new NgIfTree(),
consts: 3,
vars: 3,
template: (rf: RenderFlags, ctx: NgIfTree) => {
if (rf & RenderFlags.Create) {
Δtext(0);
Δtemplate(
fix(ivy): match attribute selectors for content projection with inline-templates (#29041) The content projection mechanism is static, in that it only looks at the static template nodes before directives are matched and change detection is run. When you have a selector-based content projection the selection is based on nodes that are available in the template. For example: ``` <ng-content selector="[some-attr]"></ng-content> ``` would match ``` <div some-attr="..."></div> ``` If you have an inline-template in your projected nodes. For example: ``` <div *ngIf="..." some-attr="..."></div> ``` This gets pre-parsed and converted to a canonical form. For example: ``` <ng-template [ngIf]="..."> <div some-attr=".."></div> </ng-template> ``` Note that only structural attributes (e.g. `*ngIf`) stay with the `<ng-template>` node. The other attributes move to the contained element inside the template. When this happens in ivy, the ng-template content is removed from the component template function and is compiled into its own template function. But this means that the information about the attributes that were on the content are lost and the projection selection mechanism is unable to match the original `<div *ngIf="..." some-attr>`. This commit adds support for this in ivy. Attributes are separated into three groups (Bindings, Templates and "other"). For inline-templates the Bindings and "other" types are hoisted back from the contained node to the `template()` instruction, so that they can be used in content projection matching. PR Close #29041
2019-03-07 03:31:31 -05:00
1, IfTemplate, 1, 1, 'ng-if-tree',
[AttributeMarker.Bindings, 'data', AttributeMarker.Template, 'ngIf']);
Δtemplate(
fix(ivy): match attribute selectors for content projection with inline-templates (#29041) The content projection mechanism is static, in that it only looks at the static template nodes before directives are matched and change detection is run. When you have a selector-based content projection the selection is based on nodes that are available in the template. For example: ``` <ng-content selector="[some-attr]"></ng-content> ``` would match ``` <div some-attr="..."></div> ``` If you have an inline-template in your projected nodes. For example: ``` <div *ngIf="..." some-attr="..."></div> ``` This gets pre-parsed and converted to a canonical form. For example: ``` <ng-template [ngIf]="..."> <div some-attr=".."></div> </ng-template> ``` Note that only structural attributes (e.g. `*ngIf`) stay with the `<ng-template>` node. The other attributes move to the contained element inside the template. When this happens in ivy, the ng-template content is removed from the component template function and is compiled into its own template function. But this means that the information about the attributes that were on the content are lost and the projection selection mechanism is unable to match the original `<div *ngIf="..." some-attr>`. This commit adds support for this in ivy. Attributes are separated into three groups (Bindings, Templates and "other"). For inline-templates the Bindings and "other" types are hoisted back from the contained node to the `template()` instruction, so that they can be used in content projection matching. PR Close #29041
2019-03-07 03:31:31 -05:00
2, IfTemplate2, 1, 1, 'ng-if-tree',
[AttributeMarker.Bindings, 'data', AttributeMarker.Template, 'ngIf']);
}
if (rf & RenderFlags.Update) {
ΔtextBinding(0, Δbind(ctx.data.value));
ΔelementProperty(1, 'ngIf', Δbind(ctx.data.left));
ΔelementProperty(2, 'ngIf', Δbind(ctx.data.right));
}
},
inputs: {data: 'data'},
});
}
function IfTemplate(rf: RenderFlags, left: any) {
if (rf & RenderFlags.Create) {
ΔelementStart(0, 'ng-if-tree');
ΔelementEnd();
}
if (rf & RenderFlags.Update) {
const parent = ΔnextContext();
ΔelementProperty(0, 'data', Δbind(parent.data.left));
}
}
function IfTemplate2(rf: RenderFlags, right: any) {
if (rf & RenderFlags.Create) {
ΔelementStart(0, 'ng-if-tree');
ΔelementEnd();
}
if (rf & RenderFlags.Update) {
const parent = ΔnextContext();
ΔelementProperty(0, 'data', Δbind(parent.data.right));
}
}
(NgIfTree.ngComponentDef as ComponentDef<NgIfTree>).directiveDefs =
() => [NgIfTree.ngComponentDef, NgIf.ngDirectiveDef];
function _buildTree(currDepth: number): TreeNode {
const children = currDepth < 2 ? _buildTree(currDepth + 1) : null;
const children2 = currDepth < 2 ? _buildTree(currDepth + 1) : null;
return new TreeNode(count++, currDepth, children, children2);
}
it('should check each component just once', () => {
const comp = renderComponent(TreeComponent, {hostFeatures: [LifecycleHooksFeature]});
expect(getRenderedText(comp)).toEqual('6201534');
expect(events).toEqual(['check6', 'check2', 'check0', 'check1', 'check5', 'check3', 'check4']);
events = [];
tick(comp);
expect(events).toEqual(['check6', 'check2', 'check0', 'check1', 'check5', 'check3', 'check4']);
});
// This tests that the view tree is set up properly for recursive components
it('should call onDestroys properly', () => {
/**
* % if (!skipContent) {
* <tree-comp></tree-comp>
* % }
*/
const App = createComponent('app', function(rf: RenderFlags, ctx: any) {
if (rf & RenderFlags.Create) {
Δcontainer(0);
}
if (rf & RenderFlags.Update) {
ΔcontainerRefreshStart(0);
if (!ctx.skipContent) {
const rf0 = ΔembeddedViewStart(0, 1, 0);
if (rf0 & RenderFlags.Create) {
ΔelementStart(0, 'tree-comp');
ΔelementEnd();
}
ΔembeddedViewEnd();
}
ΔcontainerRefreshEnd();
}
}, 1, 0, [TreeComponent]);
const fixture = new ComponentFixture(App);
expect(getRenderedText(fixture.component)).toEqual('6201534');
events = [];
fixture.component.skipContent = true;
fixture.update();
expect(events).toEqual(
['destroy0', 'destroy1', 'destroy2', 'destroy3', 'destroy4', 'destroy5', 'destroy6']);
});
it('should call onDestroys properly with ngIf', () => {
/**
* % if (!skipContent) {
* <ng-if-tree></ng-if-tree>
* % }
*/
const App = createComponent('app', function(rf: RenderFlags, ctx: any) {
if (rf & RenderFlags.Create) {
Δcontainer(0);
}
if (rf & RenderFlags.Update) {
ΔcontainerRefreshStart(0);
if (!ctx.skipContent) {
const rf0 = ΔembeddedViewStart(0, 1, 0);
if (rf0 & RenderFlags.Create) {
ΔelementStart(0, 'ng-if-tree');
ΔelementEnd();
}
ΔembeddedViewEnd();
}
ΔcontainerRefreshEnd();
}
}, 1, 0, [NgIfTree]);
const fixture = new ComponentFixture(App);
expect(getRenderedText(fixture.component)).toEqual('6201534');
expect(events).toEqual(['check6', 'check2', 'check0', 'check1', 'check5', 'check3', 'check4']);
events = [];
fixture.component.skipContent = true;
fixture.update();
expect(events).toEqual(
['destroy0', 'destroy1', 'destroy2', 'destroy3', 'destroy4', 'destroy5', 'destroy6']);
});
it('should map inputs minified & unminified names', async() => {
class TestInputsComponent {
// TODO(issue/24571): remove '!'.
minifiedName !: string;
static ngComponentDef = ΔdefineComponent({
type: TestInputsComponent,
encapsulation: ViewEncapsulation.None,
selectors: [['test-inputs']],
inputs: {minifiedName: 'unminifiedName'},
consts: 0,
vars: 0,
factory: () => new TestInputsComponent(),
template: function(rf: RenderFlags, ctx: TestInputsComponent): void {
// Template not needed for this test
}
});
}
const testInputsComponentFactory = new ComponentFactory(TestInputsComponent.ngComponentDef);
expect([
{propName: 'minifiedName', templateName: 'unminifiedName'}
]).toEqual(testInputsComponentFactory.inputs);
});
});
describe('view destruction', () => {
it('should invoke onDestroy when directly destroying a root view', () => {
let wasOnDestroyCalled = false;
class ComponentWithOnDestroy {
static ngComponentDef = ΔdefineComponent({
selectors: [['comp-with-destroy']],
type: ComponentWithOnDestroy,
consts: 0,
vars: 0,
factory: () => new ComponentWithOnDestroy(),
template: (rf: any, ctx: any) => {},
});
ngOnDestroy() { wasOnDestroyCalled = true; }
}
// This test asserts that the view tree is set up correctly based on the knowledge that this
// tree is used during view destruction. If the child view is not correctly attached as a
// child of the root view, then the onDestroy hook on the child view will never be called
// when the view tree is torn down following the destruction of that root view.
const ComponentWithChildOnDestroy = createComponent('test-app', (rf: RenderFlags, ctx: any) => {
if (rf & RenderFlags.Create) {
Δelement(0, 'comp-with-destroy');
}
}, 1, 0, [ComponentWithOnDestroy], [], null, [], []);
const fixture = new ComponentFixture(ComponentWithChildOnDestroy);
fixture.update();
fixture.destroy();
expect(wasOnDestroyCalled)
.toBe(
true,
'Expected component onDestroy method to be called when its parent view is destroyed');
});
});