diff --git a/packages/elements/rollup.config.js b/packages/elements/rollup.config.js index 5d18f89d39..3c4b229ddf 100644 --- a/packages/elements/rollup.config.js +++ b/packages/elements/rollup.config.js @@ -11,6 +11,7 @@ const sourcemaps = require('rollup-plugin-sourcemaps'); const globals = { '@angular/core': 'ng.core', + '@angular/platform-browser': 'ng.platformBrowser', 'rxjs/Subscription': 'Rx', }; diff --git a/packages/elements/src/ng-elements.ts b/packages/elements/src/ng-elements.ts new file mode 100644 index 0000000000..ea82ae1120 --- /dev/null +++ b/packages/elements/src/ng-elements.ts @@ -0,0 +1,155 @@ +/** + * @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 {ComponentFactoryResolver, NgModuleRef, Type} from '@angular/core'; +import {DOCUMENT} from '@angular/platform-browser'; + +import {NgElement} from './ng-element'; +import {NgElementApplicationContext} from './ng-element-application-context'; +import {NgElementConstructor, NgElementConstructorInternal, createNgElementConstructor} from './ng-element-constructor'; +import {scheduler, throwError} from './utils'; + +/** + * TODO(gkalpak): Add docs. + * @experimental + */ +export class NgElements { + private doc = this.moduleRef.injector.get(DOCUMENT); + private definitions = new Map>(); + private upgradedElements = new Set>(); + private appContext = new NgElementApplicationContext(this.moduleRef.injector); + private changeDetectionScheduled = false; + + constructor(public readonly moduleRef: NgModuleRef, customElementComponents: Type[]) { + const resolver = moduleRef.componentFactoryResolver; + customElementComponents.forEach( + componentType => this.defineNgElement(this.appContext, resolver, componentType)); + } + + detachAll(root: Element = this.doc.documentElement): void { + const upgradedElements = Array.from(this.upgradedElements.values()); + const elementsToDetach: NgElement[] = []; + + this.traverseTree(root, (node: HTMLElement) => { + upgradedElements.some(ngElement => { + if (ngElement.getHost() === node) { + elementsToDetach.push(ngElement); + return true; + } + return false; + }); + }); + + // Detach in reverse traversal order. + this.appContext.runInNgZone( + () => elementsToDetach.reverse().forEach(ngElement => ngElement.detach())); + } + + detectChanges(): void { + this.changeDetectionScheduled = false; + this.appContext.runInNgZone( + () => this.upgradedElements.forEach(ngElement => ngElement.detectChanges())); + } + + forEach( + cb: + (def: NgElementConstructor, selector: string, + map: Map>) => void): void { + return this.definitions.forEach(cb); + } + + get(selector: string): NgElementConstructor|undefined { + return this.definitions.get(selector); + } + + markDirty(): void { + if (!this.changeDetectionScheduled) { + this.changeDetectionScheduled = true; + scheduler.scheduleBeforeRender(() => this.detectChanges()); + } + } + + register(customElements?: CustomElementRegistry): void { + if (!customElements && (typeof window !== 'undefined')) { + customElements = window.customElements; + } + + if (!customElements) { + throwError('Custom Elements are not supported in this environment.'); + } + + this.definitions.forEach(def => customElements !.define(def.is, def)); + } + + upgradeAll(root: Element = this.doc.documentElement): void { + const definitions = Array.from(this.definitions.values()); + + this.appContext.runInNgZone(() => { + this.traverseTree(root, (node: HTMLElement) => { + const nodeName = node.nodeName.toLowerCase(); + definitions.some(def => { + if (def.is === nodeName) { + // TODO(gkalpak): What happens if `node` contains more custom elements + // (as projectable content)? + def.upgrade(node, true); + return true; + } + return false; + }); + }); + }); + } + + private defineNgElement( + appContext: NgElementApplicationContext, resolver: ComponentFactoryResolver, + componentType: Type): void { + const componentFactory = resolver.resolveComponentFactory(componentType); + const def = createNgElementConstructor(appContext, componentFactory); + const selector = def.is; + + if (this.definitions.has(selector)) { + throwError( + `Defining an Angular custom element with selector '${selector}' is not allowed, ` + + 'because one is already defined.'); + } + + def.onConnected.subscribe((ngElement: NgElement) => this.upgradedElements.add(ngElement)); + def.onDisconnected.subscribe( + (ngElement: NgElement) => this.upgradedElements.delete(ngElement)); + + this.definitions.set(selector, def); + } + + // TODO(gkalpak): Add support for traversing through `shadowRoot` + // (as should happen according to the spec). + // TODO(gkalpak): Investigate security implications (e.g. as seen in + // https://github.com/angular/angular.js/pull/15699). + private traverseTree(root: Element, cb: (node: HTMLElement) => void): void { + let currentNode: Element|null = root; + + const getNextNonDescendant = (node: Element): Element | null => { + let currNode: Element|null = node; + let nextNode: Element|null = null; + + while (!nextNode && currNode && (currNode !== root)) { + nextNode = currNode.nextElementSibling; + currNode = currNode.parentElement; + } + + return nextNode; + }; + + while (currentNode) { + if (currentNode instanceof HTMLElement) { + cb(currentNode); + } + + currentNode = currentNode.firstElementChild || getNextNonDescendant(currentNode); + } + } +} diff --git a/packages/elements/test/ng-elements_spec.ts b/packages/elements/test/ng-elements_spec.ts new file mode 100644 index 0000000000..b5050907e1 --- /dev/null +++ b/packages/elements/test/ng-elements_spec.ts @@ -0,0 +1,552 @@ +/** + * @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, EventEmitter, Inject, Input, NgModule, NgModuleRef, Output, destroyPlatform} from '@angular/core'; +import {BrowserModule} from '@angular/platform-browser'; +import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; +import {NgElement} from '../src/ng-element'; +import {NgElements} from '../src/ng-elements'; +import {AsyncMockScheduler, installMockScheduler, patchEnv, restoreEnv, supportsCustomElements} from '../testing/index'; + +export function main() { + if (!supportsCustomElements()) { + return; + } + + describe('NgElements', () => { + const DESTROY_DELAY = 10; + let uid = 0; + let mockScheduler: AsyncMockScheduler; + let moduleRef: NgModuleRef; + let e: NgElements; + + beforeAll(() => patchEnv()); + beforeAll(done => { + mockScheduler = installMockScheduler(); + + destroyPlatform(); + platformBrowserDynamic() + .bootstrapModule(TestModule) + .then(ref => moduleRef = ref) + .then(done, done.fail); + }); + + afterAll(() => destroyPlatform()); + afterAll(() => restoreEnv()); + + beforeEach(() => { + mockScheduler.reset(); + + e = new NgElements(moduleRef, [TestComponentX, TestComponentY]); + + // 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()`. + // (Using dummy selectors to ensure that the browser does not automatically upgrade the + // inserted elements.) + e.forEach(ctor => customElements.define(`${ctor.is}-${++uid}`, ctor)); + }); + + describe('constructor()', () => { + it('should set the `moduleRef` property', + () => { expect(e.moduleRef.instance).toEqual(jasmine.any(TestModule)); }); + + it('should create an `NgElementConstructor` for each component', () => { + const XConstructor = e.get('test-component-for-nges-x') !; + expect(XConstructor).toEqual(jasmine.any(Function)); + expect(XConstructor.is).toBe('test-component-for-nges-x'); + expect(XConstructor.observedAttributes).toEqual(['x-foo']); + + const YConstructor = e.get('test-component-for-nges-y') !; + expect(YConstructor).toEqual(jasmine.any(Function)); + expect(YConstructor.is).toBe('test-component-for-nges-y'); + expect(YConstructor.observedAttributes).toEqual(['ybar']); + }); + + it('should throw if there are components with the same selector', () => { + const duplicateComponents = [TestComponentX, TestComponentX]; + const errorMessage = + 'Defining an Angular custom element with selector \'test-component-for-nges-x\' is not ' + + 'allowed, because one is already defined.'; + + expect(() => new NgElements(e.moduleRef, duplicateComponents)).toThrowError(errorMessage); + }); + }); + + describe('detachAll()', () => { + let root: Element; + let detachSpies: Map; + + beforeEach(() => { + root = document.createElement('div'); + root.innerHTML = ` +
+ , +
    +
  • + +
  • +
  • + +
  • +
  • + + + PROJECTED_CONTENT + + +
  • +
+ + + + +
+ `; + + e.upgradeAll(root); + + detachSpies = new Map(); + Array.prototype.forEach.call( + root.querySelectorAll('test-component-for-nges-x,test-component-for-nges-y'), + (node: NgElement) => detachSpies.set(node.id, spyOn(node.ngElement !, 'detach'))); + + expect(detachSpies.size).toBe(6); + }); + + it('should detach all upgraded elements in the specified sub-tree', () => { + e.detachAll(root); + detachSpies.forEach(spy => expect(spy).toHaveBeenCalledWith()); + }); + + it('should detach the root node itself (if appropriate)', () => { + const yNode = root.querySelector('#y1') !; + const xNode = root.querySelector('#x3') !; + + e.detachAll(yNode); + + detachSpies.forEach((spy, id) => { + const expectedCallCount = (id === 'y1' || id === 'x3') ? 1 : 0; + expect(spy.calls.count()).toBe(expectedCallCount); + }); + }); + + // For more info on "shadow-including tree order" see: + // https://dom.spec.whatwg.org/#concept-shadow-including-tree-order + it('should detach nodes in reverse "shadow-including tree order"', () => { + const ids: string[] = []; + + detachSpies.forEach((spy, id) => spy.and.callFake(() => ids.push(id))); + e.detachAll(root); + + expect(ids).toEqual(['y2', 'x4', 'x3', 'y1', 'x2', 'x1']); + }); + + it('should ignore already detached elements', () => { + const xNode = root.querySelector('#x1') !; + const ngElement = (xNode as NgElement).ngElement !; + + // Detach node. + ngElement.disconnectedCallback(); + mockScheduler.tick(DESTROY_DELAY); + + // Detach the whole sub-tree (including the already detached node). + e.detachAll(root); + + detachSpies.forEach((spy, id) => { + const expectedCallCount = (id === 'x1') ? 0 : 1; + expect(spy.calls.count()).toBe(expectedCallCount, id); + }); + }); + + it('should detach the whole document if no root node is specified', () => { + e.detachAll(); + detachSpies.forEach(spy => expect(spy).not.toHaveBeenCalled()); + + document.body.appendChild(root); + + e.detachAll(); + detachSpies.forEach(spy => expect(spy).toHaveBeenCalledTimes(1)); + }); + + it('should not run change detection after detaching each component', () => { + const appRef = moduleRef.injector.get(ApplicationRef); + const tickSpy = spyOn(appRef, 'tick'); + + e.detachAll(root); + + expect(tickSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('detectChanges()', () => { + let xElement: NgElement; + let yElement: NgElement; + let xDetectChangesSpy: jasmine.Spy; + let yDetectChangesSpy: jasmine.Spy; + + beforeEach(() => { + const XConstructor = e.get('test-component-for-nges-x') !; + const YConstructor = e.get('test-component-for-nges-y') !; + + xElement = new XConstructor(); + yElement = new YConstructor(); + + xDetectChangesSpy = spyOn(xElement, 'detectChanges'); + yDetectChangesSpy = spyOn(yElement, 'detectChanges'); + }); + + it('should not affect unconnected elements', () => { + e.detectChanges(); + + expect(xDetectChangesSpy).not.toHaveBeenCalled(); + expect(yDetectChangesSpy).not.toHaveBeenCalled(); + }); + + it('should call `detectChanges()` on all connected elements', () => { + xElement.connectedCallback(); + xDetectChangesSpy.calls.reset(); + e.detectChanges(); + + expect(xDetectChangesSpy).toHaveBeenCalledTimes(1); + expect(yDetectChangesSpy).not.toHaveBeenCalled(); + + yElement.connectedCallback(); + yDetectChangesSpy.calls.reset(); + e.detectChanges(); + + expect(xDetectChangesSpy).toHaveBeenCalledTimes(2); + expect(yDetectChangesSpy).toHaveBeenCalledTimes(1); + }); + + it('should not affect disconnected elements', () => { + xElement.connectedCallback(); + yElement.connectedCallback(); + xDetectChangesSpy.calls.reset(); + yDetectChangesSpy.calls.reset(); + e.detectChanges(); + + expect(xDetectChangesSpy).toHaveBeenCalledTimes(1); + expect(yDetectChangesSpy).toHaveBeenCalledTimes(1); + + xElement.disconnectedCallback(); + mockScheduler.tick(DESTROY_DELAY); + e.detectChanges(); + + expect(xDetectChangesSpy).toHaveBeenCalledTimes(1); + expect(yDetectChangesSpy).toHaveBeenCalledTimes(2); + + yElement.disconnectedCallback(); + mockScheduler.tick(DESTROY_DELAY); + e.detectChanges(); + + expect(xDetectChangesSpy).toHaveBeenCalledTimes(1); + expect(yDetectChangesSpy).toHaveBeenCalledTimes(2); + }); + + it('should allow scheduling more change detection', () => { + const detectChangesSpy = spyOn(e, 'detectChanges').and.callThrough(); + + e.markDirty(); + e.markDirty(); + mockScheduler.flushBeforeRender(); + + expect(detectChangesSpy).toHaveBeenCalledTimes(1); + + detectChangesSpy.calls.reset(); + e.markDirty(); + e.detectChanges(); + e.markDirty(); + mockScheduler.flushBeforeRender(); + + expect(detectChangesSpy).toHaveBeenCalledTimes(3); + }); + + it('should not run global change detection after checking each component', () => { + const appRef = moduleRef.injector.get(ApplicationRef); + const tickSpy = spyOn(appRef, 'tick'); + + xElement.connectedCallback(); + yElement.connectedCallback(); + tickSpy.calls.reset(); + + e.detectChanges(); + + expect(tickSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('forEach()', () => { + it('should allow looping through all `NgElementConstructor`s', () => { + const selectors = ['test-component-for-nges-x', 'test-component-for-nges-y']; + const callbackSpy = jasmine.createSpy('callback'); + e.forEach(callbackSpy); + + expect(callbackSpy).toHaveBeenCalledTimes(selectors.length); + selectors.forEach( + selector => expect(callbackSpy) + .toHaveBeenCalledWith(e.get(selector), selector, jasmine.any(Map))); + }); + }); + + describe('get()', () => { + it('should return the `ngElementConstructor` for the specified selector (if any)', () => { + expect(e.get('test-component-for-nges-x')).toEqual(jasmine.any(Function)); + expect(e.get('test-component-for-nges-y')).toEqual(jasmine.any(Function)); + expect(e.get('test-component-for-nges-z')).toBeUndefined(); + }); + }); + + describe('register()', () => { + let defineSpy: jasmine.Spy; + + beforeEach(() => defineSpy = spyOn(window.customElements, 'define')); + + it('should add each `NgElementConstructor` to the `CustomElementRegistry`', () => { + e.register(); + + expect(defineSpy).toHaveBeenCalledTimes(2); + e.forEach((ctor, selector) => expect(defineSpy).toHaveBeenCalledWith(selector, ctor)); + }); + + it('should support specifying a different `CustomElementRegistry`', () => { + const mockDefineSpy = jasmine.createSpy('mockDefine'); + + e.register({ define: mockDefineSpy } as any); + + expect(defineSpy).not.toHaveBeenCalled(); + expect(mockDefineSpy).toHaveBeenCalledTimes(2); + e.forEach((ctor, selector) => expect(mockDefineSpy).toHaveBeenCalledWith(selector, ctor)); + }); + + it('should throw if there is no `CustomElementRegistry`', () => { + const originalDescriptor = Object.getOwnPropertyDescriptor(window, 'customElements'); + const errorMessage = 'Custom Elements are not supported in this environment.'; + + try { + delete window.customElements; + + expect(() => e.register()).toThrowError(errorMessage); + expect(() => e.register(null as any)).toThrowError(errorMessage); + } finally { + Object.defineProperty(window, 'customElements', originalDescriptor); + } + }); + }); + + describe('markDirty()', () => { + let detectChangesSpy: jasmine.Spy; + + beforeEach(() => detectChangesSpy = spyOn(e, 'detectChanges')); + + it('should schedule change detection', () => { + e.markDirty(); + expect(detectChangesSpy).not.toHaveBeenCalled(); + + mockScheduler.flushBeforeRender(); + expect(detectChangesSpy).toHaveBeenCalledWith(); + }); + + it('should not schedule change detection if already scheduled', () => { + e.markDirty(); + e.markDirty(); + e.markDirty(); + mockScheduler.flushBeforeRender(); + + expect(detectChangesSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('upgradeAll()', () => { + const multiTrim = (input: string | null) => input && input.replace(/\s+/g, ''); + let root: Element; + + beforeEach(() => { + root = document.createElement('div'); + root.innerHTML = ` +
+ DIV( + , +
    + UL( +
  • + LI(SPAN) +
  • , +
  • + LI() +
  • , +
  • + LI( + + SPAN( + + PROJECTED_CONTENT + + ) + + ) +
  • + ) +
, + + SPAN( + , + + ) + + ) +
+ `; + }); + + it('should upgrade all matching elements in the specified sub-tree', () => { + e.upgradeAll(root); + expect(multiTrim(root.textContent)).toBe(multiTrim(` + DIV( + TestComponentX(xFoo)(), + UL( + LI(SPAN), + LI(TestComponentX(xFoo)()), + LI(SPAN(TestComponentY()(TestComponentX(xFoo)(PROJECTED_CONTENT)))) + ), + SPAN( + TestComponentX(newFoo)(), + TestComponentY(newBar)() + ) + ) + `)); + }); + + it('should upgrade the root node itself (if appropriate)', () => { + const yNode = root.querySelector('#y1') !; + e.upgradeAll(yNode); + expect(multiTrim(yNode.textContent)) + .toBe('TestComponentY()(TestComponentX(xFoo)(PROJECTED_CONTENT))'); + }); + + // For more info on "shadow-including tree order" see: + // https://dom.spec.whatwg.org/#concept-shadow-including-tree-order + it('should upgrade nodes in "shadow-including tree order"', () => { + const ids: string[] = []; + + e.forEach( + def => spyOn(def, 'upgrade').and.callFake((node: HTMLElement) => ids.push(node.id))); + e.upgradeAll(root); + + expect(ids).toEqual(['x1', 'x2', 'y1', 'x3', 'x4', 'y2']); + }); + + it('should ignore already upgraded elements (same component)', () => { + const xNode = root.querySelector('#x1') as HTMLElement; + const XConstructor = e.get('test-component-for-nges-x') !; + + // Upgrade node to matching `NgElement`. + expect(XConstructor.is).toBe(xNode.nodeName.toLowerCase()); + const oldNgElement = XConstructor.upgrade(xNode); + const oldComponent = oldNgElement.componentRef !.instance; + + // Upgrade the whole sub-tree (including the already upgraded node). + e.upgradeAll(root); + + const newNgElement = (xNode as NgElement).ngElement !; + const newComponent = newNgElement.componentRef !.instance; + expect(newNgElement).toBe(oldNgElement); + expect(newComponent).toBe(oldComponent); + expect(newComponent).toEqual(jasmine.any(TestComponentX)); + }); + + it('should ignore already upgraded elements (different component)', () => { + const xNode = root.querySelector('#x1') as HTMLElement; + const YConstructor = e.get('test-component-for-nges-y') !; + + // Upgrade node to matching `NgElement`. + expect(YConstructor.is).not.toBe(xNode.nodeName.toLowerCase()); + const oldNgElement = YConstructor.upgrade(xNode); + const oldComponent = oldNgElement.componentRef !.instance; + + // Upgrade the whole sub-tree (including the already upgraded node). + e.upgradeAll(root); + + const newNgElement = (xNode as NgElement).ngElement !; + const newComponent = newNgElement.componentRef !.instance; + expect(newNgElement).toBe(oldNgElement); + expect(newComponent).toBe(oldComponent); + expect(newComponent).toEqual(jasmine.any(TestComponentY)); + }); + + it('should upgrade the whole document if no root node is specified', () => { + const expectedUpgradedTextContent = multiTrim(` + DIV( + TestComponentX(xFoo)(), + UL( + LI(SPAN), + LI(TestComponentX(xFoo)()), + LI(SPAN(TestComponentY()(TestComponentX(xFoo)(PROJECTED_CONTENT)))) + ), + SPAN( + TestComponentX(newFoo)(), + TestComponentY(newBar)() + ) + ) + `); + + e.upgradeAll(); + expect(multiTrim(root.textContent)).not.toBe(expectedUpgradedTextContent); + + document.body.appendChild(root); + + e.upgradeAll(); + expect(multiTrim(root.textContent)).toBe(expectedUpgradedTextContent); + }); + + it('should not run global change detection after upgrading each component', () => { + const appRef = moduleRef.injector.get(ApplicationRef); + const tickSpy = spyOn(appRef, 'tick'); + + e.upgradeAll(root); + + expect(tickSpy).toHaveBeenCalledTimes(1); + }); + }); + + // Helpers + @Component({ + selector: 'test-component-for-nges-x', + template: 'TestComponentX({{ xFoo }})()', + }) + class TestComponentX { + @Input() xFoo: string = 'xFoo'; + @Output() xBaz = new EventEmitter(); + + constructor(@Inject('TEST_VALUE') public testValue: string) {} + } + + @Component({ + selector: 'test-component-for-nges-y', + template: 'TestComponentY({{ yBar }})()', + }) + class TestComponentY { + @Input('ybar') yBar: string; + @Output('yqux') yQux = new EventEmitter(); + + constructor(@Inject('TEST_VALUE') public testValue: string) {} + } + + @NgModule({ + imports: [BrowserModule], + providers: [ + {provide: 'TEST_VALUE', useValue: {value: 'TEST'}}, + ], + declarations: [TestComponentX, TestComponentY], + entryComponents: [TestComponentX, TestComponentY], + }) + class TestModule { + ngDoBootstrap() {} + } + }); +}