angular-cn/packages/elements/test/component-factory-strategy_...

390 lines
15 KiB
TypeScript
Raw Normal View History

/**
* @license
* Copyright Google LLC 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 {ApplicationRef, ComponentFactory, ComponentFactoryResolver, ComponentRef, Injector, NgModuleRef, NgZone, SimpleChange, SimpleChanges, Type} from '@angular/core';
import {fakeAsync, tick} from '@angular/core/testing';
import {Subject} from 'rxjs';
import {ComponentNgElementStrategy, ComponentNgElementStrategyFactory} from '../src/component-factory-strategy';
import {NgElementStrategyEvent} from '../src/element-strategy';
describe('ComponentFactoryNgElementStrategy', () => {
let factory: FakeComponentFactory;
let strategy: ComponentNgElementStrategy;
let injector: any;
let componentRef: any;
let applicationRef: any;
let ngZone: any;
let injectables: Map<unknown, unknown>;
beforeEach(() => {
factory = new FakeComponentFactory();
componentRef = factory.componentRef;
applicationRef = jasmine.createSpyObj('applicationRef', ['attachView']);
ngZone = jasmine.createSpyObj('ngZone', ['run']);
ngZone.run.and.callFake((fn: () => unknown) => fn());
injector = jasmine.createSpyObj('injector', ['get']);
injector.get.and.callFake((token: unknown) => {
if (!injectables.has(token)) {
throw new Error(`Failed to get injectable from mock injector: ${token}`);
}
return injectables.get(token);
});
injectables = new Map<unknown, unknown>([
[ApplicationRef, applicationRef],
[NgZone, ngZone],
]);
strategy = new ComponentNgElementStrategy(factory, injector);
ngZone.run.calls.reset();
});
it('should create a new strategy from the factory', () => {
const factoryResolver = jasmine.createSpyObj('factoryResolver', ['resolveComponentFactory']);
factoryResolver.resolveComponentFactory.and.returnValue(factory);
injectables.set(ComponentFactoryResolver, factoryResolver);
const strategyFactory = new ComponentNgElementStrategyFactory(FakeComponent, injector);
expect(strategyFactory.create(injector)).toBeTruthy();
});
fix(elements): fire custom element output events during component initialization (#37570) Previously, event listeners for component output events attached on an Angular custom element before inserting it into the DOM (i.e. before instantiating the underlying component) didn't fire for events emitted during initialization lifecycle hooks, such as `ngAfterContentInit`, `ngAfterViewInit`, `ngOnChanges` (initial call) and `ngOnInit`. The reason was that `NgElementImpl` [subscribed to events][1] _after_ calling [ngElementStrategy#connect()][2], which is where the [initial change detection][3] takes place (running the initialization lifecycle hooks). This commit fixes this by: 1. Ensuring `ComponentNgElementStrategy#events` is defined and available for subscribing to, even before instantiating the component. 2. Changing `NgElementImpl` to subscribe to `NgElementStrategy#events` (if available) before calling `NgElementStrategy#connect()` (which initializes the component instance) if available. 3. Falling back to the old behavior (subscribing to `events` after calling `connect()` for strategies that do not initialize `events` before their `connect()` is run). NOTE: By falling back to the old behavior when `NgElementStrategy#events` is not initialized before calling `NgElementStrategy#connect()`, we avoid breaking existing custom `NgElementStrategy` implementations (with @remackgeek's [ElementZoneStrategy][4] being a commonly used example). Jira issue: [FW-2010](https://angular-team.atlassian.net/browse/FW-2010) [1]: https://github.com/angular/angular/blob/c0143cb2abdd172de1b95fd1d2c4cfc738640e28/packages/elements/src/create-custom-element.ts#L167-L170 [2]: https://github.com/angular/angular/blob/c0143cb2abdd172de1b95fd1d2c4cfc738640e28/packages/elements/src/create-custom-element.ts#L164 [3]: https://github.com/angular/angular/blob/c0143cb2abdd172de1b95fd1d2c4cfc738640e28/packages/elements/src/component-factory-strategy.ts#L158 [4]: https://github.com/remackgeek/elements-zone-strategy/blob/f1b6699495a8c0909dbbfbdca12770ecca843e15/projects/elements-zone-strategy/src/lib/element-zone-strategy.ts Fixes #36141 PR Close #37570
2020-06-13 04:06:35 -04:00
describe('before connected', () => {
it('should allow subscribing to output events', () => {
const events: NgElementStrategyEvent[] = [];
strategy.events.subscribe(e => events.push(e));
// No events before connecting (since `componentRef` is not even on the strategy yet).
componentRef.instance.output1.next('output-1a');
componentRef.instance.output1.next('output-1b');
componentRef.instance.output2.next('output-2a');
expect(events).toEqual([]);
// No events upon connecting (since events are not cached/played back).
strategy.connect(document.createElement('div'));
expect(events).toEqual([]);
// Events emitted once connected.
componentRef.instance.output1.next('output-1c');
componentRef.instance.output1.next('output-1d');
componentRef.instance.output2.next('output-2b');
expect(events).toEqual([
{name: 'templateOutput1', value: 'output-1c'},
{name: 'templateOutput1', value: 'output-1d'},
{name: 'templateOutput2', value: 'output-2b'},
]);
});
});
describe('after connected', () => {
beforeEach(() => {
// Set up an initial value to make sure it is passed to the component
strategy.setInputValue('fooFoo', 'fooFoo-1');
strategy.setInputValue('falsyUndefined', undefined);
strategy.setInputValue('falsyNull', null);
strategy.setInputValue('falsyEmpty', '');
strategy.setInputValue('falsyFalse', false);
strategy.setInputValue('falsyZero', 0);
strategy.connect(document.createElement('div'));
});
it('should attach the component to the view', () => {
expect(applicationRef.attachView).toHaveBeenCalledWith(componentRef.hostView);
});
it('should detect changes', () => {
expect(componentRef.changeDetectorRef.detectChanges).toHaveBeenCalled();
});
it('should listen to output events', () => {
const events: NgElementStrategyEvent[] = [];
strategy.events.subscribe(e => events.push(e));
componentRef.instance.output1.next('output-1a');
componentRef.instance.output1.next('output-1b');
componentRef.instance.output2.next('output-2a');
expect(events).toEqual([
{name: 'templateOutput1', value: 'output-1a'},
{name: 'templateOutput1', value: 'output-1b'},
{name: 'templateOutput2', value: 'output-2a'},
]);
});
it('should initialize the component with initial values', () => {
expect(strategy.getInputValue('fooFoo')).toBe('fooFoo-1');
expect(componentRef.instance.fooFoo).toBe('fooFoo-1');
});
it('should initialize the component with falsy initial values', () => {
expect(strategy.getInputValue('falsyUndefined')).toEqual(undefined);
expect(componentRef.instance.falsyUndefined).toEqual(undefined);
expect(strategy.getInputValue('falsyNull')).toEqual(null);
expect(componentRef.instance.falsyNull).toEqual(null);
expect(strategy.getInputValue('falsyEmpty')).toEqual('');
expect(componentRef.instance.falsyEmpty).toEqual('');
expect(strategy.getInputValue('falsyFalse')).toEqual(false);
expect(componentRef.instance.falsyFalse).toEqual(false);
expect(strategy.getInputValue('falsyZero')).toEqual(0);
expect(componentRef.instance.falsyZero).toEqual(0);
});
it('should call ngOnChanges with the change', () => {
expectSimpleChanges(componentRef.instance.simpleChanges[0], {
fooFoo: new SimpleChange(undefined, 'fooFoo-1', true),
falsyUndefined: new SimpleChange(undefined, undefined, true),
falsyNull: new SimpleChange(undefined, null, true),
falsyEmpty: new SimpleChange(undefined, '', true),
falsyFalse: new SimpleChange(undefined, false, true),
falsyZero: new SimpleChange(undefined, 0, true),
});
});
it('should call ngOnChanges with proper firstChange value', fakeAsync(() => {
strategy.setInputValue('fooFoo', 'fooFoo-2');
strategy.setInputValue('barBar', 'barBar-1');
strategy.setInputValue('falsyUndefined', 'notanymore');
tick(16); // scheduler waits 16ms if RAF is unavailable
(strategy as any).detectChanges();
expectSimpleChanges(componentRef.instance.simpleChanges[1], {
fooFoo: new SimpleChange('fooFoo-1', 'fooFoo-2', false),
barBar: new SimpleChange(undefined, 'barBar-1', true),
falsyUndefined: new SimpleChange(undefined, 'notanymore', false),
});
}));
});
it('should not call ngOnChanges if not present on the component', () => {
factory.componentRef.instance = new FakeComponentWithoutNgOnChanges();
// Should simply succeed without problems (did not try to call ngOnChanges)
strategy.connect(document.createElement('div'));
});
describe('when inputs change and not connected', () => {
it('should cache the value', () => {
strategy.setInputValue('fooFoo', 'fooFoo-1');
expect(strategy.getInputValue('fooFoo')).toBe('fooFoo-1');
// Sanity check: componentRef isn't changed since its not even on the strategy
expect(componentRef.instance.fooFoo).toBe(undefined);
});
it('should not detect changes', fakeAsync(() => {
strategy.setInputValue('fooFoo', 'fooFoo-1');
tick(16); // scheduler waits 16ms if RAF is unavailable
expect(componentRef.changeDetectorRef.detectChanges).not.toHaveBeenCalled();
}));
});
describe('when inputs change and is connected', () => {
beforeEach(() => {
strategy.connect(document.createElement('div'));
});
it('should be set on the component instance', () => {
strategy.setInputValue('fooFoo', 'fooFoo-1');
expect(componentRef.instance.fooFoo).toBe('fooFoo-1');
expect(strategy.getInputValue('fooFoo')).toBe('fooFoo-1');
});
it('should detect changes', fakeAsync(() => {
// Connect detected changes automatically
expect(componentRef.changeDetectorRef.detectChanges).toHaveBeenCalledTimes(1);
strategy.setInputValue('fooFoo', 'fooFoo-1');
tick(16); // scheduler waits 16ms if RAF is unavailable
expect(componentRef.changeDetectorRef.detectChanges).toHaveBeenCalledTimes(2);
}));
it('should detect changes once for multiple input changes', fakeAsync(() => {
// Connect detected changes automatically
expect(componentRef.changeDetectorRef.detectChanges).toHaveBeenCalledTimes(1);
strategy.setInputValue('fooFoo', 'fooFoo-1');
strategy.setInputValue('barBar', 'barBar-1');
tick(16); // scheduler waits 16ms if RAF is unavailable
expect(componentRef.changeDetectorRef.detectChanges).toHaveBeenCalledTimes(2);
}));
it('should call ngOnChanges', fakeAsync(() => {
strategy.setInputValue('fooFoo', 'fooFoo-1');
tick(16); // scheduler waits 16ms if RAF is unavailable
expectSimpleChanges(
componentRef.instance.simpleChanges[0],
{fooFoo: new SimpleChange(undefined, 'fooFoo-1', true)});
}));
it('should call ngOnChanges once for multiple input changes', fakeAsync(() => {
strategy.setInputValue('fooFoo', 'fooFoo-1');
strategy.setInputValue('barBar', 'barBar-1');
tick(16); // scheduler waits 16ms if RAF is unavailable
expectSimpleChanges(componentRef.instance.simpleChanges[0], {
fooFoo: new SimpleChange(undefined, 'fooFoo-1', true),
barBar: new SimpleChange(undefined, 'barBar-1', true)
});
}));
it('should call ngOnChanges twice for changes in different rounds with previous values',
fakeAsync(() => {
strategy.setInputValue('fooFoo', 'fooFoo-1');
strategy.setInputValue('barBar', 'barBar-1');
tick(16); // scheduler waits 16ms if RAF is unavailable
expectSimpleChanges(componentRef.instance.simpleChanges[0], {
fooFoo: new SimpleChange(undefined, 'fooFoo-1', true),
barBar: new SimpleChange(undefined, 'barBar-1', true)
});
strategy.setInputValue('fooFoo', 'fooFoo-2');
strategy.setInputValue('barBar', 'barBar-2');
tick(16); // scheduler waits 16ms if RAF is unavailable
expectSimpleChanges(componentRef.instance.simpleChanges[1], {
fooFoo: new SimpleChange('fooFoo-1', 'fooFoo-2', false),
barBar: new SimpleChange('barBar-1', 'barBar-2', false)
});
}));
});
describe('disconnect', () => {
it('should be able to call if not connected', fakeAsync(() => {
strategy.disconnect();
// Sanity check: the strategy doesn't have an instance of the componentRef anyways
expect(componentRef.destroy).not.toHaveBeenCalled();
}));
it('should destroy the component after the destroy delay', fakeAsync(() => {
strategy.connect(document.createElement('div'));
strategy.disconnect();
expect(componentRef.destroy).not.toHaveBeenCalled();
tick(10);
expect(componentRef.destroy).toHaveBeenCalledTimes(1);
}));
it('should be able to call it multiple times but only destroy once', fakeAsync(() => {
strategy.connect(document.createElement('div'));
strategy.disconnect();
strategy.disconnect();
expect(componentRef.destroy).not.toHaveBeenCalled();
tick(10);
expect(componentRef.destroy).toHaveBeenCalledTimes(1);
strategy.disconnect();
expect(componentRef.destroy).toHaveBeenCalledTimes(1);
}));
});
describe('runInZone', () => {
const param = 'foofoo';
const fn = () => param;
it('should run the callback directly when invoked in element\'s zone', () => {
expect(strategy['runInZone'](fn)).toEqual('foofoo');
expect(ngZone.run).not.toHaveBeenCalled();
});
it('should run the callback inside the element\'s zone when invoked in a different zone',
() => {
expect(Zone.root.run(() => (strategy['runInZone'](fn)))).toEqual('foofoo');
expect(ngZone.run).toHaveBeenCalledWith(fn);
});
it('should run the callback directly when called without zone.js loaded', () => {
// simulate no zone.js loaded
(strategy as any)['elementZone'] = null;
expect(Zone.root.run(() => (strategy['runInZone'](fn)))).toEqual('foofoo');
expect(ngZone.run).not.toHaveBeenCalled();
});
});
});
export class FakeComponentWithoutNgOnChanges {
output1 = new Subject();
output2 = new Subject();
}
export class FakeComponent {
output1 = new Subject();
output2 = new Subject();
// Keep track of the simple changes passed to ngOnChanges
simpleChanges: SimpleChanges[] = [];
ngOnChanges(simpleChanges: SimpleChanges) {
this.simpleChanges.push(simpleChanges);
}
}
export class FakeComponentFactory extends ComponentFactory<any> {
componentRef: any = jasmine.createSpyObj(
'componentRef', ['instance', 'changeDetectorRef', 'hostView', 'destroy']);
constructor() {
super();
this.componentRef.instance = new FakeComponent();
this.componentRef.changeDetectorRef =
jasmine.createSpyObj('changeDetectorRef', ['detectChanges']);
}
get selector(): string {
return 'fake-component';
}
get componentType(): Type<any> {
return FakeComponent;
}
get ngContentSelectors(): string[] {
return ['content-1', 'content-2'];
}
get inputs(): {propName: string; templateName: string}[] {
return [
{propName: 'fooFoo', templateName: 'fooFoo'},
{propName: 'barBar', templateName: 'my-bar-bar'},
{propName: 'falsyUndefined', templateName: 'falsyUndefined'},
{propName: 'falsyNull', templateName: 'falsyNull'},
{propName: 'falsyEmpty', templateName: 'falsyEmpty'},
{propName: 'falsyFalse', templateName: 'falsyFalse'},
{propName: 'falsyZero', templateName: 'falsyZero'},
];
}
get outputs(): {propName: string; templateName: string}[] {
return [
{propName: 'output1', templateName: 'templateOutput1'},
{propName: 'output2', templateName: 'templateOutput2'},
];
}
create(
injector: Injector, projectableNodes?: any[][], rootSelectorOrNode?: string|any,
ngModule?: NgModuleRef<any>): ComponentRef<any> {
return this.componentRef;
}
}
function expectSimpleChanges(actual: SimpleChanges, expected: SimpleChanges) {
Object.keys(actual).forEach(key => {
expect(expected[key]).toBeTruthy(`Change included additional key ${key}`);
});
Object.keys(expected).forEach(key => {
expect(actual[key]).toBeTruthy(`Change should have included key ${key}`);
if (actual[key]) {
expect(actual[key].previousValue).toBe(expected[key].previousValue, `${key}.previousValue`);
expect(actual[key].currentValue).toBe(expected[key].currentValue, `${key}.currentValue`);
expect(actual[key].firstChange).toBe(expected[key].firstChange, `${key}.firstChange`);
}
});
}