Previously, when an input property was set on an `NgElement` before instantiating the underlying component, the `SimpleChange` object passed to `ngOnChanges()` would have `firstChange` set to false, even if this was the first change (as far as the component instance was concerned). This commit fixes this by ensuring `SimpleChange#firstChange` is set to true on first change, regardless if the property was set before or after instantiating the component. This alignthe behavior of Angular custom elements with that of the corresponding components when used directly (not as custom elements). Jira issue: [FW-2007](https://angular-team.atlassian.net/browse/FW-2007) Fixes #36130 PR Close #36140
305 lines
12 KiB
TypeScript
305 lines
12 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 {ComponentFactory, ComponentRef, Injector, NgModuleRef, 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;
|
|
|
|
beforeEach(() => {
|
|
factory = new FakeComponentFactory();
|
|
componentRef = factory.componentRef;
|
|
|
|
applicationRef = jasmine.createSpyObj('applicationRef', ['attachView']);
|
|
injector = jasmine.createSpyObj('injector', ['get']);
|
|
injector.get.and.returnValue(applicationRef);
|
|
|
|
strategy = new ComponentNgElementStrategy(factory, injector);
|
|
});
|
|
|
|
it('should create a new strategy from the factory', () => {
|
|
const factoryResolver = jasmine.createSpyObj('factoryResolver', ['resolveComponentFactory']);
|
|
factoryResolver.resolveComponentFactory.and.returnValue(factory);
|
|
injector.get.and.returnValue(factoryResolver);
|
|
|
|
const strategyFactory = new ComponentNgElementStrategyFactory(FakeComponent, injector);
|
|
expect(strategyFactory.create(injector)).toBeTruthy();
|
|
});
|
|
|
|
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),
|
|
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');
|
|
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),
|
|
});
|
|
}));
|
|
});
|
|
|
|
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);
|
|
}));
|
|
});
|
|
});
|
|
|
|
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`);
|
|
}
|
|
});
|
|
}
|