feat(elements): implement `NgElements`

This commit is contained in:
George Kalpakas 2017-10-12 12:38:09 +03:00 committed by Victor Berchet
parent 0899f4f8fc
commit 60c0b178af
3 changed files with 708 additions and 0 deletions

View File

@ -11,6 +11,7 @@ const sourcemaps = require('rollup-plugin-sourcemaps');
const globals = {
'@angular/core': 'ng.core',
'@angular/platform-browser': 'ng.platformBrowser',
'rxjs/Subscription': 'Rx',
};

View File

@ -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<T> {
private doc = this.moduleRef.injector.get<Document>(DOCUMENT);
private definitions = new Map<string, NgElementConstructorInternal<any, any>>();
private upgradedElements = new Set<NgElement<any>>();
private appContext = new NgElementApplicationContext(this.moduleRef.injector);
private changeDetectionScheduled = false;
constructor(public readonly moduleRef: NgModuleRef<T>, customElementComponents: Type<any>[]) {
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<any>[] = [];
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<any, any>, selector: string,
map: Map<string, NgElementConstructor<any, any>>) => void): void {
return this.definitions.forEach(cb);
}
get<C, P>(selector: string): NgElementConstructor<C, P>|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<any>): void {
const componentFactory = resolver.resolveComponentFactory(componentType);
const def = createNgElementConstructor<any, any>(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<T>) => this.upgradedElements.add(ngElement));
def.onDisconnected.subscribe(
(ngElement: NgElement<T>) => 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);
}
}
}

View File

@ -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<TestModule>;
let e: NgElements<TestModule>;
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<string, jasmine.Spy>;
beforeEach(() => {
root = document.createElement('div');
root.innerHTML = `
<div>
<test-component-for-nges-x id="x1"></test-component-for-nges-x>,
<ul>
<li>
<span></span>
</li>
<li>
<test-component-for-nges-x id="x2"></test-component-for-nges-x>
</li>
<li>
<span>
<test-component-for-nges-y id="y1">
<test-component-for-nges-x id="x3">PROJECTED_CONTENT</test-component-for-nges-x>
</test-component-for-nges-y>
</span>
</li>
</ul>
<span>
<test-component-for-nges-x id="x4" x-foo="newFoo"></test-component-for-nges-x>
<test-component-for-nges-y id="y2" ybar="newBar"></test-component-for-nges-y>
</span>
</div>
`;
e.upgradeAll(root);
detachSpies = new Map();
Array.prototype.forEach.call(
root.querySelectorAll('test-component-for-nges-x,test-component-for-nges-y'),
(node: NgElement<any>) => 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<any>).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>(ApplicationRef);
const tickSpy = spyOn(appRef, 'tick');
e.detachAll(root);
expect(tickSpy).toHaveBeenCalledTimes(1);
});
});
describe('detectChanges()', () => {
let xElement: NgElement<TestComponentX>;
let yElement: NgElement<TestComponentY>;
let xDetectChangesSpy: jasmine.Spy;
let yDetectChangesSpy: jasmine.Spy;
beforeEach(() => {
const XConstructor = e.get<TestComponentX, {}>('test-component-for-nges-x') !;
const YConstructor = e.get<TestComponentY, {}>('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>(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>
DIV(
<test-component-for-nges-x id="x1"></test-component-for-nges-x>,
<ul>
UL(
<li>
LI(<span>SPAN</span>)
</li>,
<li>
LI(<test-component-for-nges-x id="x2"></test-component-for-nges-x>)
</li>,
<li>
LI(
<span>
SPAN(
<test-component-for-nges-y id="y1">
<test-component-for-nges-x id="x3">PROJECTED_CONTENT</test-component-for-nges-x>
</test-component-for-nges-y>
)
</span>
)
</li>
)
</ul>,
<span>
SPAN(
<test-component-for-nges-x id="x4" x-foo="newFoo"></test-component-for-nges-x>,
<test-component-for-nges-y id="y2" ybar="newBar"></test-component-for-nges-y>
)
</span>
)
</div>
`;
});
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<TestComponentX, {}>('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<any>).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<TestComponentY, {}>('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<any>).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>(ApplicationRef);
const tickSpy = spyOn(appRef, 'tick');
e.upgradeAll(root);
expect(tickSpy).toHaveBeenCalledTimes(1);
});
});
// Helpers
@Component({
selector: 'test-component-for-nges-x',
template: 'TestComponentX({{ xFoo }})(<ng-content></ng-content>)',
})
class TestComponentX {
@Input() xFoo: string = 'xFoo';
@Output() xBaz = new EventEmitter<boolean>();
constructor(@Inject('TEST_VALUE') public testValue: string) {}
}
@Component({
selector: 'test-component-for-nges-y',
template: 'TestComponentY({{ yBar }})(<ng-content></ng-content>)',
})
class TestComponentY {
@Input('ybar') yBar: string;
@Output('yqux') yQux = new EventEmitter<object>();
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() {}
}
});
}