From 0899f4f8fc236f131c44e418a6b66f893b2cef22 Mon Sep 17 00:00:00 2001 From: George Kalpakas Date: Thu, 12 Oct 2017 12:37:22 +0300 Subject: [PATCH] feat(elements): implement `NgElementConstructor` --- packages/elements/public_api.ts | 1 + .../elements/src/ng-element-constructor.ts | 141 +++++++ .../test/ng-element-constructor_spec.ts | 349 ++++++++++++++++++ test-main.js | 1 + tools/public_api_guard/elements/elements.d.ts | 8 + 5 files changed, 500 insertions(+) create mode 100644 packages/elements/src/ng-element-constructor.ts create mode 100644 packages/elements/test/ng-element-constructor_spec.ts diff --git a/packages/elements/public_api.ts b/packages/elements/public_api.ts index e6e2d0fb13..6bba985ec1 100644 --- a/packages/elements/public_api.ts +++ b/packages/elements/public_api.ts @@ -12,6 +12,7 @@ * Entry point for all public APIs of the `elements` package. */ export {NgElement, NgElementWithProps} from './src/ng-element'; +export {NgElementConstructor} from './src/ng-element-constructor'; export {VERSION} from './src/version'; // This file only reexports content of the `src` folder. Keep it that way. diff --git a/packages/elements/src/ng-element-constructor.ts b/packages/elements/src/ng-element-constructor.ts new file mode 100644 index 0000000000..0ea67aae49 --- /dev/null +++ b/packages/elements/src/ng-element-constructor.ts @@ -0,0 +1,141 @@ +/** + * @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, EventEmitter} from '@angular/core'; + +import {NgElementImpl, NgElementWithProps} from './ng-element'; +import {NgElementApplicationContext} from './ng-element-application-context'; +import {camelToKebabCase, throwError} from './utils'; + +/** + * TODO(gkalpak): Add docs. + * @experimental + */ +export interface NgElementConstructor { + readonly is: string; + readonly observedAttributes: string[]; + + upgrade(host: HTMLElement): NgElementWithProps; + + new (): NgElementWithProps; +} + +export interface NgElementConstructorInternal extends NgElementConstructor { + readonly onConnected: EventEmitter>; + readonly onDisconnected: EventEmitter>; + upgrade(host: HTMLElement, ignoreUpgraded?: boolean): NgElementWithProps; +} + +type WithProperties

= { + [property in keyof P]: P[property] +}; + +// For more info on `PotentialCustomElementName` rules see: +// https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name +const PCEN_RE = createPcenRe(); +const PCEN_BLACKLIST = [ + 'annotation-xml', + 'color-profile', + 'font-face', + 'font-face-src', + 'font-face-uri', + 'font-face-format', + 'font-face-name', + 'missing-glyph', +]; + +export function createNgElementConstructor( + appContext: NgElementApplicationContext, + componentFactory: ComponentFactory): NgElementConstructorInternal { + const selector = componentFactory.selector; + + if (!isPotentialCustomElementName(selector)) { + throwError( + `Using '${selector}' as a custom element name is not allowed. ` + + 'See https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name for more info.'); + } + + const inputs = componentFactory.inputs.map(({propName, templateName}) => ({ + propName, + attrName: camelToKebabCase(templateName), + })); + const outputs = + componentFactory.outputs.map(({propName, templateName}) => ({ + propName, + // TODO(gkalpak): Verify this is what we want and document. + eventName: templateName, + })); + + // Note: According to the spec, this needs to be an ES2015 class + // (i.e. not transpiled to an ES5 constructor function). + // TODO(gkalpak): Document that if you are using ES5 sources you need to include a polyfill (e.g. + // https://github.com/webcomponents/custom-elements/blob/32f043c3a/src/native-shim.js). + class NgElementConstructorImpl extends NgElementImpl { + static readonly is = selector; + static readonly observedAttributes = inputs.map(input => input.attrName); + static readonly onConnected = new EventEmitter>(); + static readonly onDisconnected = new EventEmitter>(); + + static upgrade(host: HTMLElement, ignoreUpgraded = false): NgElementWithProps { + const ngElement = new NgElementConstructorImpl(); + + ngElement.setHost(host); + ngElement.connectedCallback(ignoreUpgraded); + + return ngElement as typeof ngElement & WithProperties

; + } + + constructor() { + super(appContext, componentFactory, inputs, outputs); + + const ngElement = this as this & WithProperties

; + this.onConnected.subscribe(() => NgElementConstructorImpl.onConnected.emit(ngElement)); + this.onDisconnected.subscribe(() => NgElementConstructorImpl.onDisconnected.emit(ngElement)); + } + } + + inputs.forEach(({propName}) => { + Object.defineProperty(NgElementConstructorImpl.prototype, propName, { + get: function(this: NgElementImpl) { return this.getInputValue(propName); }, + set: function(this: NgElementImpl, newValue: any) { + this.setInputValue(propName, newValue); + }, + configurable: true, + enumerable: true, + }); + }); + + return NgElementConstructorImpl as typeof NgElementConstructorImpl & { + new (): NgElementConstructorImpl&WithProperties

; + }; +} + +function createPcenRe() { + // According to [the + // spec](https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name), + // `pcenChar` is allowed to contain Unicode characters in the 10000-EFFFF range. But in order to + // match this characters with a RegExp, we need the implementation to support the `u` flag. + // On browsers that do not support it, valid PotentialCustomElementNames using characters in the + // 10000-EFFFF range will still cause an error (but these characters are not expected to be used + // in practice). + let pcenChar = '-.0-9_a-z\\u00B7\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u037D\\u037F-\\u1FFF' + + '\\u200C-\\u200D\\u203F-\\u2040\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF' + + '\\uF900-\\uFDCF\\uFDF0-\\uFFFD'; + let flags = ''; + + if (RegExp.prototype.hasOwnProperty('unicode')) { + pcenChar += '\\u{10000}-\\u{EFFFF}'; + flags += 'u'; + } + + return RegExp(`^[a-z][${pcenChar}]*-[${pcenChar}]*$`, flags); +} + +function isPotentialCustomElementName(name: string): boolean { + return PCEN_RE.test(name) && (PCEN_BLACKLIST.indexOf(name) === -1); +} diff --git a/packages/elements/test/ng-element-constructor_spec.ts b/packages/elements/test/ng-element-constructor_spec.ts new file mode 100644 index 0000000000..c9dee06793 --- /dev/null +++ b/packages/elements/test/ng-element-constructor_spec.ts @@ -0,0 +1,349 @@ +/** + * @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 {ApplicationRef, Component, ComponentFactory, EventEmitter, Inject, Input, NgModule, NgModuleRef, NgZone, Output, destroyPlatform} from '@angular/core'; +import {BrowserModule} from '@angular/platform-browser'; +import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; +import {Subscription} from 'rxjs/Subscription'; + +import {NgElementImpl, NgElementWithProps} from '../src/ng-element'; +import {NgElementApplicationContext} from '../src/ng-element-application-context'; +import {NgElementConstructorInternal, createNgElementConstructor} from '../src/ng-element-constructor'; +import {installMockScheduler, patchEnv, restoreEnv, supportsCustomElements} from '../testing/index'; + +type WithFooBar = { + fooFoo: string, + barBar: string +}; + +export function main() { + if (!supportsCustomElements()) { + return; + } + + describe('NgElementConstructor', () => { + let moduleRef: NgModuleRef; + let c: NgElementConstructorInternal; + + beforeAll(() => patchEnv()); + beforeAll(done => { + installMockScheduler(true); + + destroyPlatform(); + platformBrowserDynamic() + .bootstrapModule(TestModule) + .then(ref => { + moduleRef = ref; + + const appContext = new NgElementApplicationContext(ref.injector); + const factory = ref.componentFactoryResolver.resolveComponentFactory(TestComponent); + + c = createNgElementConstructor(appContext, factory); + + // The `@webcomponents/custom-elements/src/native-shim.js` polyfill, that we use to + // enable ES2015 classes transpiled to ES5 constructor functions to be used as Custom + // Elements in tests, only works if the elements have been registered with + // `customElements.define()`. + customElements.define(c.is, c); + }) + .then(done, done.fail); + }); + + afterAll(() => destroyPlatform()); + afterAll(() => restoreEnv()); + + describe('is', () => { + it('should be derived from the component\'s selector', + () => { expect(c.is).toBe('test-component-for-ngec'); }); + + it('should be a valid custom element name', () => { + const buildTestFn = (selector: string) => { + const mockAppContext = {} as NgElementApplicationContext; + const mockFactory = { selector } as ComponentFactory; + return () => createNgElementConstructor(mockAppContext, mockFactory); + }; + const buildError = (selector: string) => + `Using '${selector}' as a custom element name is not allowed. ` + + 'See https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name for more info.'; + + const validNames = [ + 'foo-bar', + 'baz-', + 'q-u-x', + 'this_is-fine.too', + 'this_is-fine.3', + 'this.is-φίνε.4', + 'tΉΪς.is-Φine.5', + ]; + const invalidNames = [ + 'foo', + 'BAR', + 'baz-Qux', + 'φine-not', + 'not:fine-at:all', + '.no-no', + '[nay-nay]', + 'close-but,not-quite', + ':not(my-element)', + // Blacklisted: + 'color-profile', + 'font-face-format', + 'missing-glyph', + ]; + + validNames.forEach(name => expect(buildTestFn(name)).not.toThrowError(buildError(name))); + invalidNames.forEach(name => expect(buildTestFn(name)).toThrowError(buildError(name))); + }); + }); + + describe('observedAttributes', () => { + it('should be derived from the component\'s inputs', () => { + expect(c.observedAttributes).toEqual(['foo-foo', 'barbar']); + }); + }); + + describe('constructor()', () => { + let e: NgElementWithProps; + + beforeEach(() => { + e = new c(); + e.connectedCallback(); + }); + + it('should create an `NgElement`', () => { + // When using the `Object.setPrototypeOf()` shim, we can't check for the `NgElementImpl` + // prototype. Check for `HTMLElement` instead. + const ParentClass = (Object as any).setPrototypeOf.$$shimmed ? HTMLElement : NgElementImpl; + + expect(e).toEqual(jasmine.any(ParentClass)); + expect(e.getHost()).toBe(e); + expect(e.ngElement).toBe(e); + }); + + it('should pass `ApplicationRef` to the element', () => { + const appRef = moduleRef.injector.get(ApplicationRef); + const component = e.componentRef !.instance; + + component.fooFoo = 'newFoo'; + component.barBar = 'newBar'; + expect(e.innerHTML).toBe('TestComponent|foo(foo)|bar()'); + + appRef.tick(); + expect(e.innerHTML).toBe('TestComponent|foo(newFoo)|bar(newBar)'); + }); + + it('should pass `NgModuleRef` injector to the element', () => { + const component = e.componentRef !.instance; + expect(component.testValue).toBe('TEST'); + }); + + it('should pass appropriate inputs to the element', () => { + const component = e.componentRef !.instance; + + expect(component.fooFoo).toBe('foo'); + expect(component.barBar).toBeUndefined(); + + e.attributeChangedCallback('foo-foo', null, 'newFoo'); + expect(component.fooFoo).toBe('newFoo'); + + e.attributeChangedCallback('barbar', null, 'newBar'); + expect(component.barBar).toBe('newBar'); + }); + + it('should pass appropriate outputs to the element', () => { + const bazListener = jasmine.createSpy('bazListener'); + const quxListener = jasmine.createSpy('quxListener'); + const component = e.componentRef !.instance; + + e.addEventListener('bazBaz', bazListener); + e.addEventListener('quxqux', quxListener); + component.bazBaz.emit(false); + component.quxQux.emit({qux: true}); + + expect(bazListener).toHaveBeenCalledWith(jasmine.objectContaining({ + type: 'bazBaz', + detail: false, + })); + expect(quxListener).toHaveBeenCalledWith(jasmine.objectContaining({ + type: 'quxqux', + detail: {qux: true}, + })); + }); + + it('should set up property getters/setters for the inputs', () => { + const getInputValueSpy = + spyOn(e as any as NgElementImpl, 'getInputValue').and.callThrough(); + const setInputValueSpy = + spyOn(e as any as NgElementImpl, 'setInputValue').and.callThrough(); + + (e as any).randomProp = 'ignored'; + expect(setInputValueSpy).not.toHaveBeenCalled(); + expect((e as any).randomProp).toBe('ignored'); + expect(getInputValueSpy).not.toHaveBeenCalled(); + + e.fooFoo = 'newFoo'; + expect(setInputValueSpy).toHaveBeenCalledWith('fooFoo', 'newFoo'); + expect(e.fooFoo).toBe('newFoo'); + expect(getInputValueSpy).toHaveBeenCalledWith('fooFoo'); + + e.barBar = 'newBar'; + expect(setInputValueSpy).toHaveBeenCalledWith('barBar', 'newBar'); + expect(e.barBar).toBe('newBar'); + expect(getInputValueSpy).toHaveBeenCalledWith('barBar'); + }); + }); + + describe('upgrade()', () => { + let host: HTMLElement; + let e: NgElementWithProps; + + beforeEach(() => { + host = document.createElement('div'); + e = c.upgrade(host); + }); + + it('should create an `NgElement`', () => { + // When using the `Object.setPrototypeOf()` shim, we can't check for the `NgElementImpl` + // prototype. Check for `HTMLElement` instead. + const ParentClass = (Object as any).setPrototypeOf.$$shimmed ? HTMLElement : NgElementImpl; + + expect(e).toEqual(jasmine.any(ParentClass)); + }); + + it('should immediatelly instantiate the underlying component', () => { + expect(e.ngElement).toBe(e); + expect(e.getHost().innerHTML).toBe('TestComponent|foo(foo)|bar()'); + }); + + it('should use the specified host', () => { + expect(e.getHost()).toBe(host); + expect((host as typeof e).ngElement).toBe(e); + }); + + it('should throw if the host is already upgraded (ignoreUpgraded: false)', () => { + const errorMessage = + 'Upgrading \'DIV\' element to component \'TestComponent\' is not allowed, ' + + 'because the element is already upgraded to component \'TestComponent\'.'; + + expect(() => c.upgrade(host)).toThrowError(errorMessage); + expect(() => c.upgrade(host, false)).toThrowError(errorMessage); + }); + + it('should do nothing if the host is already upgraded (ignoreUpgraded: true)', () => { + const compRef = e.componentRef !; + + expect(() => c.upgrade(host, true)).not.toThrow(); + expect((host as typeof e).ngElement).toBe(e); + expect((host as typeof e).ngElement !.componentRef).toBe(compRef); + }); + }); + + describe('onConnected', () => { + let onConnectedSpy: jasmine.Spy; + let subscription: Subscription; + + beforeEach(() => { + onConnectedSpy = jasmine.createSpy('onConnected'); + subscription = c.onConnected.subscribe(onConnectedSpy); + }); + + afterEach(() => subscription.unsubscribe()); + + it('should emit every time an `NgElement` is connected', () => { + const e1 = new c(); + expect(onConnectedSpy).not.toHaveBeenCalled(); + + e1.connectedCallback(); + expect(onConnectedSpy).toHaveBeenCalledTimes(1); + expect(onConnectedSpy).toHaveBeenCalledWith(e1); + + onConnectedSpy.calls.reset(); + const e2 = c.upgrade(document.createElement('div')); + expect(onConnectedSpy).toHaveBeenCalledTimes(1); + expect(onConnectedSpy).toHaveBeenCalledWith(e2); + + onConnectedSpy.calls.reset(); + (e1 as any as NgElementImpl).onConnected.emit('ignored' as any); + expect(onConnectedSpy).toHaveBeenCalledTimes(1); + expect(onConnectedSpy).toHaveBeenCalledWith(e1); + + onConnectedSpy.calls.reset(); + (e2 as any as NgElementImpl).onConnected.emit('ignored' as any); + expect(onConnectedSpy).toHaveBeenCalledTimes(1); + expect(onConnectedSpy).toHaveBeenCalledWith(e2); + }); + }); + + describe('onDisconnected', () => { + let onDisconnectedSpy: jasmine.Spy; + let subscription: Subscription; + + beforeEach(() => { + onDisconnectedSpy = jasmine.createSpy('onDisconnected'); + subscription = c.onDisconnected.subscribe(onDisconnectedSpy); + }); + + afterEach(() => subscription.unsubscribe()); + + it('should emit every time an `NgElement` is disconnected', () => { + const e1 = new c(); + e1.connectedCallback(); + expect(onDisconnectedSpy).not.toHaveBeenCalled(); + + e1.disconnectedCallback(); + expect(onDisconnectedSpy).toHaveBeenCalledTimes(1); + expect(onDisconnectedSpy).toHaveBeenCalledWith(e1); + + onDisconnectedSpy.calls.reset(); + const e2 = c.upgrade(document.createElement('div')); + expect(onDisconnectedSpy).not.toHaveBeenCalled(); + + e2.disconnectedCallback(); + expect(onDisconnectedSpy).toHaveBeenCalledTimes(1); + expect(onDisconnectedSpy).toHaveBeenCalledWith(e2); + + onDisconnectedSpy.calls.reset(); + (e1 as any as NgElementImpl).onDisconnected.emit('ignored' as any); + expect(onDisconnectedSpy).toHaveBeenCalledTimes(1); + expect(onDisconnectedSpy).toHaveBeenCalledWith(e1); + + onDisconnectedSpy.calls.reset(); + (e2 as any as NgElementImpl).onDisconnected.emit('ignored' as any); + expect(onDisconnectedSpy).toHaveBeenCalledTimes(1); + expect(onDisconnectedSpy).toHaveBeenCalledWith(e2); + }); + }); + + // Helpers + @Component({ + selector: 'test-component-for-ngec', + template: 'TestComponent|foo({{ fooFoo }})|bar({{ barBar }})', + }) + class TestComponent { + @Input() fooFoo: string = 'foo'; + @Input('barbar') barBar: string; + + @Output() bazBaz = new EventEmitter(); + @Output('quxqux') quxQux = new EventEmitter(); + + constructor(@Inject('TEST_VALUE') public testValue: string) {} + } + + @NgModule({ + imports: [BrowserModule], + providers: [ + {provide: 'TEST_VALUE', useValue: 'TEST'}, + ], + declarations: [TestComponent], + entryComponents: [TestComponent], + }) + class TestModule { + ngDoBootstrap() {} + } + }); +} diff --git a/test-main.js b/test-main.js index 6fd4dfd992..a4208e1bfd 100644 --- a/test-main.js +++ b/test-main.js @@ -147,6 +147,7 @@ function loadCustomElementsPolyfills() { return obj; }; + Object.defineProperty(setPrototypeOf, '$$shimmed', {value: true}); Object.setPrototypeOf = setPrototypeOf; } diff --git a/tools/public_api_guard/elements/elements.d.ts b/tools/public_api_guard/elements/elements.d.ts index 76a0106c99..3f44258609 100644 --- a/tools/public_api_guard/elements/elements.d.ts +++ b/tools/public_api_guard/elements/elements.d.ts @@ -11,6 +11,14 @@ export interface NgElement extends HTMLElement { markDirty(): void; } +/** @experimental */ +export interface NgElementConstructor { + readonly is: string; + readonly observedAttributes: string[]; + new (): NgElementWithProps; + upgrade(host: HTMLElement): NgElementWithProps; +} + /** @experimental */ export declare type NgElementWithProps = NgElement & { [property in keyof