feat(elements): implement `NgElements`
This commit is contained in:
parent
0899f4f8fc
commit
60c0b178af
|
@ -11,6 +11,7 @@ const sourcemaps = require('rollup-plugin-sourcemaps');
|
||||||
|
|
||||||
const globals = {
|
const globals = {
|
||||||
'@angular/core': 'ng.core',
|
'@angular/core': 'ng.core',
|
||||||
|
'@angular/platform-browser': 'ng.platformBrowser',
|
||||||
'rxjs/Subscription': 'Rx',
|
'rxjs/Subscription': 'Rx',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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() {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in New Issue