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 = {
|
||||
'@angular/core': 'ng.core',
|
||||
'@angular/platform-browser': 'ng.platformBrowser',
|
||||
'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