fix(elements): fire custom element output events during component initialization (#36161)

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 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. Ensuring `NgElementImpl` subscribes to `NgElementStrategy#events`
   before calling `NgElementStrategy#connect()` (which initializes the
   component instance).

Jira issue: [FW-2010](https://angular-team.atlassian.net/browse/FW-2010)

[1]: c0143cb2ab/packages/elements/src/create-custom-element.ts (L167-L170)
[2]: c0143cb2ab/packages/elements/src/create-custom-element.ts (L164)
[3]: c0143cb2ab/packages/elements/src/component-factory-strategy.ts (L158)

Fixes #36141

PR Close #36161
This commit is contained in:
George Kalpakas 2020-06-05 20:14:18 +03:00 committed by atscott
parent 569d1ef583
commit 2a330a60ed
4 changed files with 52 additions and 11 deletions

View File

@ -7,8 +7,8 @@
*/ */
import {ApplicationRef, ComponentFactory, ComponentFactoryResolver, ComponentRef, EventEmitter, Injector, OnChanges, SimpleChange, SimpleChanges, Type} from '@angular/core'; import {ApplicationRef, ComponentFactory, ComponentFactoryResolver, ComponentRef, EventEmitter, Injector, OnChanges, SimpleChange, SimpleChanges, Type} from '@angular/core';
import {merge, Observable} from 'rxjs'; import {merge, Observable, ReplaySubject} from 'rxjs';
import {map} from 'rxjs/operators'; import {map, switchMap} from 'rxjs/operators';
import {NgElementStrategy, NgElementStrategyEvent, NgElementStrategyFactory} from './element-strategy'; import {NgElementStrategy, NgElementStrategyEvent, NgElementStrategyFactory} from './element-strategy';
import {extractProjectableNodes} from './extract-projectable-nodes'; import {extractProjectableNodes} from './extract-projectable-nodes';
@ -43,9 +43,11 @@ export class ComponentNgElementStrategyFactory implements NgElementStrategyFacto
* @publicApi * @publicApi
*/ */
export class ComponentNgElementStrategy implements NgElementStrategy { export class ComponentNgElementStrategy implements NgElementStrategy {
// Subject of `NgElementStrategyEvent` observables corresponding to the component's outputs.
private eventEmitters = new ReplaySubject<Observable<NgElementStrategyEvent>[]>(1);
/** Merged stream of the component's output events. */ /** Merged stream of the component's output events. */
// TODO(issue/24571): remove '!'. readonly events = this.eventEmitters.pipe(switchMap(emitters => merge(...emitters)));
events!: Observable<NgElementStrategyEvent>;
/** Reference to the component that was created on connect. */ /** Reference to the component that was created on connect. */
private componentRef: ComponentRef<any>|null = null; private componentRef: ComponentRef<any>|null = null;
@ -187,12 +189,13 @@ export class ComponentNgElementStrategy implements NgElementStrategy {
/** Sets up listeners for the component's outputs so that the events stream emits the events. */ /** Sets up listeners for the component's outputs so that the events stream emits the events. */
protected initializeOutputs(componentRef: ComponentRef<any>): void { protected initializeOutputs(componentRef: ComponentRef<any>): void {
const eventEmitters = this.componentFactory.outputs.map(({propName, templateName}) => { const eventEmitters: Observable<NgElementStrategyEvent>[] =
const emitter: EventEmitter<any> = componentRef.instance[propName]; this.componentFactory.outputs.map(({propName, templateName}) => {
return emitter.pipe(map(value => ({name: templateName, value}))); const emitter: EventEmitter<any> = componentRef.instance[propName];
}); return emitter.pipe(map(value => ({name: templateName, value})));
});
this.events = merge(...eventEmitters); this.eventEmitters.next(eventEmitters);
} }
/** Calls ngOnChanges with all the inputs that have changed since the last call. */ /** Calls ngOnChanges with all the inputs that have changed since the last call. */

View File

@ -187,13 +187,13 @@ export function createCustomElement<P>(
} }
connectedCallback(): void { connectedCallback(): void {
this.ngElementStrategy.connect(this);
// Listen for events from the strategy and dispatch them as custom events // Listen for events from the strategy and dispatch them as custom events
this.ngElementEventsSubscription = this.ngElementStrategy.events.subscribe(e => { this.ngElementEventsSubscription = this.ngElementStrategy.events.subscribe(e => {
const customEvent = createCustomEvent(this.ownerDocument!, e.name, e.value); const customEvent = createCustomEvent(this.ownerDocument!, e.name, e.value);
this.dispatchEvent(customEvent); this.dispatchEvent(customEvent);
}); });
this.ngElementStrategy.connect(this);
} }
disconnectedCallback(): void { disconnectedCallback(): void {

View File

@ -41,6 +41,33 @@ describe('ComponentFactoryNgElementStrategy', () => {
expect(strategyFactory.create(injector)).toBeTruthy(); expect(strategyFactory.create(injector)).toBeTruthy();
}); });
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', () => { describe('after connected', () => {
beforeEach(() => { beforeEach(() => {
// Set up an initial value to make sure it is passed to the component // Set up an initial value to make sure it is passed to the component

View File

@ -117,6 +117,16 @@ if (browserDetection.supportsCustomElements) {
expect(eventValue).toEqual(null); expect(eventValue).toEqual(null);
}); });
it('should listen to output events during initialization', () => {
const events: string[] = [];
const element = new NgElementCtor(injector);
element.addEventListener('strategy-event', evt => events.push((evt as CustomEvent).detail));
element.connectedCallback();
expect(events).toEqual(['connect']);
});
it('should properly set getters/setters on the element', () => { it('should properly set getters/setters on the element', () => {
const element = new NgElementCtor(injector); const element = new NgElementCtor(injector);
element.fooFoo = 'foo-foo-value'; element.fooFoo = 'foo-foo-value';
@ -255,6 +265,7 @@ if (browserDetection.supportsCustomElements) {
events = new Subject<NgElementStrategyEvent>(); events = new Subject<NgElementStrategyEvent>();
connect(element: HTMLElement): void { connect(element: HTMLElement): void {
this.events.next({name: 'strategy-event', value: 'connect'});
this.connectedElement = element; this.connectedElement = element;
} }