diff --git a/packages/elements/src/component-factory-strategy.ts b/packages/elements/src/component-factory-strategy.ts index 3e5fcc5212..799cfba683 100644 --- a/packages/elements/src/component-factory-strategy.ts +++ b/packages/elements/src/component-factory-strategy.ts @@ -7,8 +7,8 @@ */ import {ApplicationRef, ComponentFactory, ComponentFactoryResolver, ComponentRef, EventEmitter, Injector, OnChanges, SimpleChange, SimpleChanges, Type} from '@angular/core'; -import {merge, Observable} from 'rxjs'; -import {map} from 'rxjs/operators'; +import {merge, Observable, ReplaySubject} from 'rxjs'; +import {map, switchMap} from 'rxjs/operators'; import {NgElementStrategy, NgElementStrategyEvent, NgElementStrategyFactory} from './element-strategy'; import {extractProjectableNodes} from './extract-projectable-nodes'; @@ -43,9 +43,11 @@ export class ComponentNgElementStrategyFactory implements NgElementStrategyFacto * @publicApi */ export class ComponentNgElementStrategy implements NgElementStrategy { + // Subject of `NgElementStrategyEvent` observables corresponding to the component's outputs. + private eventEmitters = new ReplaySubject[]>(1); + /** Merged stream of the component's output events. */ - // TODO(issue/24571): remove '!'. - events!: Observable; + readonly events = this.eventEmitters.pipe(switchMap(emitters => merge(...emitters))); /** Reference to the component that was created on connect. */ private componentRef: ComponentRef|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. */ protected initializeOutputs(componentRef: ComponentRef): void { - const eventEmitters = this.componentFactory.outputs.map(({propName, templateName}) => { - const emitter: EventEmitter = componentRef.instance[propName]; - return emitter.pipe(map(value => ({name: templateName, value}))); - }); + const eventEmitters: Observable[] = + this.componentFactory.outputs.map(({propName, templateName}) => { + const emitter: EventEmitter = 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. */ diff --git a/packages/elements/src/create-custom-element.ts b/packages/elements/src/create-custom-element.ts index d4c429cab6..5724018a9a 100644 --- a/packages/elements/src/create-custom-element.ts +++ b/packages/elements/src/create-custom-element.ts @@ -187,13 +187,30 @@ export function createCustomElement

( } connectedCallback(): void { + // For historical reasons, some strategies may not have initialized the `events` property + // until after `connect()` is run. Subscribe to `events` if it is available before running + // `connect()` (in order to capture events emitted suring inittialization), otherwise + // subscribe afterwards. + // + // TODO: Consider deprecating/removing the post-connect subscription in a future major version + // (e.g. v11). + + let subscribedToEvents = false; + + if (this.ngElementStrategy.events) { + // `events` are already available: Subscribe to it asap. + this.subscribeToEvents(); + subscribedToEvents = true; + } + this.ngElementStrategy.connect(this); - // Listen for events from the strategy and dispatch them as custom events - this.ngElementEventsSubscription = this.ngElementStrategy.events.subscribe(e => { - const customEvent = createCustomEvent(this.ownerDocument!, e.name, e.value); - this.dispatchEvent(customEvent); - }); + if (!subscribedToEvents) { + // `events` were not initialized before running `connect()`: Subscribe to them now. + // The events emitted during the component initialization have been missed, but at least + // future events will be captured. + this.subscribeToEvents(); + } } disconnectedCallback(): void { @@ -207,6 +224,14 @@ export function createCustomElement

( this.ngElementEventsSubscription = null; } } + + private subscribeToEvents(): void { + // Listen for events from the strategy and dispatch them as custom events. + this.ngElementEventsSubscription = this.ngElementStrategy.events.subscribe(e => { + const customEvent = createCustomEvent(this.ownerDocument!, e.name, e.value); + this.dispatchEvent(customEvent); + }); + } } // TypeScript 3.9+ defines getters/setters as configurable but non-enumerable properties (in diff --git a/packages/elements/test/component-factory-strategy_spec.ts b/packages/elements/test/component-factory-strategy_spec.ts index c05f6a08a5..53c9fbd0fc 100644 --- a/packages/elements/test/component-factory-strategy_spec.ts +++ b/packages/elements/test/component-factory-strategy_spec.ts @@ -41,6 +41,33 @@ describe('ComponentFactoryNgElementStrategy', () => { 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', () => { beforeEach(() => { // Set up an initial value to make sure it is passed to the component diff --git a/packages/elements/test/create-custom-element_spec.ts b/packages/elements/test/create-custom-element_spec.ts index 231b3f3a34..e4a3e0713d 100644 --- a/packages/elements/test/create-custom-element_spec.ts +++ b/packages/elements/test/create-custom-element_spec.ts @@ -40,12 +40,7 @@ if (browserDetection.supportsCustomElements) { strategyFactory = new TestStrategyFactory(); strategy = strategyFactory.testStrategy; - const {selector, ElementCtor} = createTestCustomElement(); - NgElementCtor = ElementCtor; - - // The `@webcomponents/custom-elements/src/native-shim.js` polyfill allows us to create - // new instances of the NgElement which extends HTMLElement, as long as we define it. - customElements.define(selector, NgElementCtor); + NgElementCtor = createAndRegisterTestCustomElement(strategyFactory); }) .then(done, done.fail); }); @@ -117,6 +112,47 @@ if (browserDetection.supportsCustomElements) { 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 not break if `NgElementStrategy#events` is not available before calling `NgElementStrategy#connect()`', + () => { + class TestStrategyWithLateEvents extends TestStrategy { + events: Subject = undefined!; + + connect(element: HTMLElement): void { + this.connectedElement = element; + this.events = new Subject(); + this.events.next({name: 'strategy-event', value: 'connect'}); + } + } + + const strategyWithLateEvents = new TestStrategyWithLateEvents(); + const capturedEvents: string[] = []; + + const NgElementCtorWithLateEventsStrategy = + createAndRegisterTestCustomElement({create: () => strategyWithLateEvents}); + + const element = new NgElementCtorWithLateEventsStrategy(injector); + element.addEventListener( + 'strategy-event', evt => capturedEvents.push((evt as CustomEvent).detail)); + element.connectedCallback(); + + // The "connect" event (emitted during initialization) was missed, but things didn't break. + expect(capturedEvents).toEqual([]); + + // Subsequent events are still captured. + strategyWithLateEvents.events.next({name: 'strategy-event', value: 'after-connect'}); + expect(capturedEvents).toEqual(['after-connect']); + }); + it('should properly set getters/setters on the element', () => { const element = new NgElementCtor(injector); element.fooFoo = 'foo-foo-value'; @@ -144,7 +180,7 @@ if (browserDetection.supportsCustomElements) { it('should capture properties set before upgrading the element', () => { // Create a regular element and set properties on it. - const {selector, ElementCtor} = createTestCustomElement(); + const {selector, ElementCtor} = createTestCustomElement(strategyFactory); const element = Object.assign(document.createElement(selector), { fooFoo: 'foo-prop-value', barBar: 'bar-prop-value', @@ -165,7 +201,7 @@ if (browserDetection.supportsCustomElements) { it('should capture properties set after upgrading the element but before inserting it into the DOM', () => { // Create a regular element and set properties on it. - const {selector, ElementCtor} = createTestCustomElement(); + const {selector, ElementCtor} = createTestCustomElement(strategyFactory); const element = Object.assign(document.createElement(selector), { fooFoo: 'foo-prop-value', barBar: 'bar-prop-value', @@ -193,7 +229,7 @@ if (browserDetection.supportsCustomElements) { it('should allow overwriting properties with attributes after upgrading the element but before inserting it into the DOM', () => { // Create a regular element and set properties on it. - const {selector, ElementCtor} = createTestCustomElement(); + const {selector, ElementCtor} = createTestCustomElement(strategyFactory); const element = Object.assign(document.createElement(selector), { fooFoo: 'foo-prop-value', barBar: 'bar-prop-value', @@ -219,7 +255,17 @@ if (browserDetection.supportsCustomElements) { }); // Helpers - function createTestCustomElement() { + function createAndRegisterTestCustomElement(strategyFactory: NgElementStrategyFactory) { + const {selector, ElementCtor} = createTestCustomElement(strategyFactory); + + // The `@webcomponents/custom-elements/src/native-shim.js` polyfill allows us to create + // new instances of the NgElement which extends HTMLElement, as long as we define it. + customElements.define(selector, ElementCtor); + + return ElementCtor; + } + + function createTestCustomElement(strategyFactory: NgElementStrategyFactory) { return { selector: `test-element-${++selectorUid}`, ElementCtor: createCustomElement(TestComponent, {injector, strategyFactory}), @@ -255,6 +301,7 @@ if (browserDetection.supportsCustomElements) { events = new Subject(); connect(element: HTMLElement): void { + this.events.next({name: 'strategy-event', value: 'connect'}); this.connectedElement = element; }