feat(elements): injector create (#22413)

PR Close #22413
This commit is contained in:
Andrew Seguin 2018-03-06 14:02:25 -08:00 committed by Miško Hevery
parent 46efd4b938
commit 87f60bccfd
21 changed files with 275 additions and 143 deletions

View File

@ -4,7 +4,7 @@
"uncompressed": { "uncompressed": {
"inline": 2062, "inline": 2062,
"main": 467103, "main": 467103,
"polyfills": 55349, "polyfills": 54292,
"embedded": 71711, "embedded": 71711,
"prettify": 14888 "prettify": 14888
} }

View File

@ -30,7 +30,7 @@ describe('CodeExampleComponent', () => {
}); });
it('should be able to capture the code snippet provided in content', () => { it('should be able to capture the code snippet provided in content', () => {
expect(codeExampleComponent.code.trim()).toBe(`const foo = "bar";`); expect(codeExampleComponent.aioCode.code.trim()).toBe(`const foo = "bar";`);
}); });
it('should change aio-code classes based on title presence', () => { it('should change aio-code classes based on title presence', () => {

View File

@ -34,8 +34,6 @@ import { CodeComponent } from './code.component';
export class CodeExampleComponent implements AfterViewInit { export class CodeExampleComponent implements AfterViewInit {
classes: {}; classes: {};
code: string;
@Input() language: string; @Input() language: string;
@Input() linenums: string; @Input() linenums: string;

View File

@ -9,8 +9,6 @@ import {TestBed, fakeAsync, tick} from '@angular/core/testing';
import { ElementsLoader } from './elements-loader'; import { ElementsLoader } from './elements-loader';
import { ELEMENT_MODULE_PATHS_TOKEN, WithCustomElementComponent } from './element-registry'; import { ELEMENT_MODULE_PATHS_TOKEN, WithCustomElementComponent } from './element-registry';
const actualCustomElements = window.customElements;
class FakeComponentFactory extends ComponentFactory<any> { class FakeComponentFactory extends ComponentFactory<any> {
selector: string; selector: string;
componentType: Type<any>; componentType: Type<any>;
@ -29,21 +27,26 @@ class FakeComponentFactory extends ComponentFactory<any> {
} }
const FAKE_COMPONENT_FACTORIES = new Map([ const FAKE_COMPONENT_FACTORIES = new Map([
['element-a-module-path', new FakeComponentFactory('element-a-input')] ['element-a-module-path', new FakeComponentFactory('element-a-input')],
['element-b-module-path', new FakeComponentFactory('element-b-input')],
]); ]);
fdescribe('ElementsLoader', () => { describe('ElementsLoader', () => {
let elementsLoader: ElementsLoader; let elementsLoader: ElementsLoader;
let injectedModuleRef: NgModuleRef<any>; let injectedModuleRef: NgModuleRef<any>;
let fakeCustomElements; let actualCustomElementsDefine;
let fakeCustomElementsDefine;
// ElementsLoader uses the window's customElements API. Provide a fake for this test. // ElementsLoader uses the window's customElements API. Provide a fake for this test.
beforeEach(() => { beforeEach(() => {
fakeCustomElements = jasmine.createSpyObj('customElements', ['define', 'whenDefined']); actualCustomElementsDefine = window.customElements.define;
window.customElements = fakeCustomElements;
fakeCustomElementsDefine = jasmine.createSpy('define');
window.customElements.define = fakeCustomElementsDefine;
}); });
afterEach(() => { afterEach(() => {
window.customElements = actualCustomElements; window.customElements.define = actualCustomElementsDefine;
}); });
beforeEach(() => { beforeEach(() => {
@ -52,7 +55,8 @@ fdescribe('ElementsLoader', () => {
ElementsLoader, ElementsLoader,
{ provide: NgModuleFactoryLoader, useClass: FakeModuleFactoryLoader }, { provide: NgModuleFactoryLoader, useClass: FakeModuleFactoryLoader },
{ provide: ELEMENT_MODULE_PATHS_TOKEN, useValue: new Map([ { provide: ELEMENT_MODULE_PATHS_TOKEN, useValue: new Map([
['element-a-selector', 'element-a-module-path'] ['element-a-selector', 'element-a-module-path'],
['element-b-selector', 'element-b-module-path']
])}, ])},
] ]
}); });
@ -71,7 +75,7 @@ fdescribe('ElementsLoader', () => {
elementsLoader.loadContainingCustomElements(hostEl); elementsLoader.loadContainingCustomElements(hostEl);
tick(); tick();
const defineArgs = fakeCustomElements.define.calls.argsFor(0); const defineArgs = fakeCustomElementsDefine.calls.argsFor(0);
expect(defineArgs[0]).toBe('element-a-selector'); expect(defineArgs[0]).toBe('element-a-selector');
// Verify the right component was loaded/created // Verify the right component was loaded/created
@ -80,6 +84,30 @@ fdescribe('ElementsLoader', () => {
expect(elementsLoader.elementsToLoad.has('element-a-selector')).toBeFalsy(); expect(elementsLoader.elementsToLoad.has('element-a-selector')).toBeFalsy();
})); }));
it('should be able to register multiple elements', fakeAsync(() => {
// Verify that the elements loader considered `element-a-selector` to be unregistered.
expect(elementsLoader.elementsToLoad.has('element-a-selector')).toBeTruthy();
const hostEl = document.createElement('div');
hostEl.innerHTML = `
<element-a-selector></element-a-selector>
<element-b-selector></element-b-selector>
`;
elementsLoader.loadContainingCustomElements(hostEl);
tick();
const defineElementA = fakeCustomElementsDefine.calls.argsFor(0);
expect(defineElementA[0]).toBe('element-a-selector');
expect(defineElementA[1].observedAttributes[0]).toBe('element-a-input');
expect(elementsLoader.elementsToLoad.has('element-a-selector')).toBeFalsy();
const defineElementB = fakeCustomElementsDefine.calls.argsFor(1);
expect(defineElementB[0]).toBe('element-b-selector');
expect(defineElementB[1].observedAttributes[0]).toBe('element-b-input');
expect(elementsLoader.elementsToLoad.has('element-b-selector')).toBeFalsy();
}));
it('should only register an element one time', fakeAsync(() => { it('should only register an element one time', fakeAsync(() => {
const hostEl = document.createElement('div'); const hostEl = document.createElement('div');
hostEl.innerHTML = `<element-a-selector></element-a-selector>`; hostEl.innerHTML = `<element-a-selector></element-a-selector>`;

View File

@ -8,14 +8,14 @@ import { FILE_NOT_FOUND_ID, FETCHING_ERROR_ID } from 'app/documents/document.ser
import { Logger } from 'app/shared/logger.service'; import { Logger } from 'app/shared/logger.service';
import { CustomElementsModule } from 'app/custom-elements/custom-elements.module'; import { CustomElementsModule } from 'app/custom-elements/custom-elements.module';
import { TocService } from 'app/shared/toc.service'; import { TocService } from 'app/shared/toc.service';
import { ElementsLoader } from 'app/custom-elements/elements-loader';
import { import {
MockTitle, MockTocService, ObservableWithSubscriptionSpies, MockTitle, MockTocService, ObservableWithSubscriptionSpies,
TestDocViewerComponent, TestModule, TestParentComponent TestDocViewerComponent, TestModule, TestParentComponent, MockElementsLoader
} from 'testing/doc-viewer-utils'; } from 'testing/doc-viewer-utils';
import { MockLogger } from 'testing/logger.service'; import { MockLogger } from 'testing/logger.service';
import { DocViewerComponent, NO_ANIMATIONS } from './doc-viewer.component'; import { DocViewerComponent, NO_ANIMATIONS } from './doc-viewer.component';
describe('DocViewerComponent', () => { describe('DocViewerComponent', () => {
let parentFixture: ComponentFixture<TestParentComponent>; let parentFixture: ComponentFixture<TestParentComponent>;
let parentComponent: TestParentComponent; let parentComponent: TestParentComponent;
@ -24,7 +24,7 @@ describe('DocViewerComponent', () => {
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [TestModule, CustomElementsModule], imports: [CustomElementsModule, TestModule],
}); });
parentFixture = TestBed.createComponent(TestParentComponent); parentFixture = TestBed.createComponent(TestParentComponent);
@ -294,12 +294,16 @@ describe('DocViewerComponent', () => {
describe('#render()', () => { describe('#render()', () => {
let prepareTitleAndTocSpy: jasmine.Spy; let prepareTitleAndTocSpy: jasmine.Spy;
let swapViewsSpy: jasmine.Spy; let swapViewsSpy: jasmine.Spy;
let loadElementsSpy: jasmine.Spy;
const doRender = (contents: string | null, id = 'foo') => const doRender = (contents: string | null, id = 'foo') =>
new Promise<void>((resolve, reject) => new Promise<void>((resolve, reject) =>
docViewer.render({contents, id}).subscribe(resolve, reject)); docViewer.render({contents, id}).subscribe(resolve, reject));
beforeEach(() => { beforeEach(() => {
const elementsLoader = TestBed.get(ElementsLoader) as MockElementsLoader;
loadElementsSpy =
elementsLoader.loadContainingCustomElements.and.returnValue(of([]));
prepareTitleAndTocSpy = spyOn(docViewer, 'prepareTitleAndToc'); prepareTitleAndTocSpy = spyOn(docViewer, 'prepareTitleAndToc');
swapViewsSpy = spyOn(docViewer, 'swapViews').and.returnValue(of(undefined)); swapViewsSpy = spyOn(docViewer, 'swapViews').and.returnValue(of(undefined));
}); });
@ -333,7 +337,7 @@ describe('DocViewerComponent', () => {
expect(docViewerEl.textContent).toBe(''); expect(docViewerEl.textContent).toBe('');
}); });
it('should prepare the title and ToC', async () => { it('should prepare the title and ToC (before embedding components)', async () => {
prepareTitleAndTocSpy.and.callFake((targetEl: HTMLElement, docId: string) => { prepareTitleAndTocSpy.and.callFake((targetEl: HTMLElement, docId: string) => {
expect(targetEl.innerHTML).toBe('Some content'); expect(targetEl.innerHTML).toBe('Some content');
expect(docId).toBe('foo'); expect(docId).toBe('foo');
@ -342,6 +346,7 @@ describe('DocViewerComponent', () => {
await doRender('Some content', 'foo'); await doRender('Some content', 'foo');
expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1); expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1);
expect(prepareTitleAndTocSpy).toHaveBeenCalledBefore(loadElementsSpy);
}); });
it('should set the title and ToC (after the content has been set)', async () => { it('should set the title and ToC (after the content has been set)', async () => {
@ -384,6 +389,39 @@ describe('DocViewerComponent', () => {
}); });
}); });
describe('(embedding components)', () => {
it('should embed components', async () => {
await doRender('Some content');
expect(loadElementsSpy).toHaveBeenCalledTimes(1);
expect(loadElementsSpy).toHaveBeenCalledWith(docViewer.nextViewContainer);
});
it('should attempt to embed components even if the document is empty', async () => {
await doRender('');
await doRender(null);
expect(loadElementsSpy).toHaveBeenCalledTimes(2);
expect(loadElementsSpy.calls.argsFor(0)).toEqual([docViewer.nextViewContainer]);
expect(loadElementsSpy.calls.argsFor(1)).toEqual([docViewer.nextViewContainer]);
});
it('should unsubscribe from the previous "embed" observable when unsubscribed from', () => {
const obs = new ObservableWithSubscriptionSpies();
loadElementsSpy.and.returnValue(obs);
const renderObservable = docViewer.render({contents: 'Some content', id: 'foo'});
const subscription = renderObservable.subscribe();
expect(obs.subscribeSpy).toHaveBeenCalledTimes(1);
expect(obs.unsubscribeSpies[0]).not.toHaveBeenCalled();
subscription.unsubscribe();
expect(obs.subscribeSpy).toHaveBeenCalledTimes(1);
expect(obs.unsubscribeSpies[0]).toHaveBeenCalledTimes(1);
});
});
describe('(swapping views)', () => { describe('(swapping views)', () => {
it('should still swap the views if the document is empty', async () => { it('should still swap the views if the document is empty', async () => {
await doRender(''); await doRender('');
@ -444,6 +482,25 @@ describe('DocViewerComponent', () => {
expect(TestBed.get(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' }); expect(TestBed.get(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' });
}); });
it('when `EmbedComponentsService.embedInto()` fails', async () => {
const error = Error('Typical `embedInto()` error');
loadElementsSpy.and.callFake(() => {
expect(docViewer.nextViewContainer.innerHTML).not.toBe('');
throw error;
});
await doRender('Some content', 'bar');
expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1);
expect(loadElementsSpy).toHaveBeenCalledTimes(1);
expect(swapViewsSpy).not.toHaveBeenCalled();
expect(docViewer.nextViewContainer.innerHTML).toBe('');
expect(logger.output.error).toEqual([
[`[DocViewer] Error preparing document 'bar': ${error.stack}`],
]);
expect(TestBed.get(Meta).addTag).toHaveBeenCalledWith({ name: 'googlebot', content: 'noindex' });
expect(TestBed.get(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' });
});
it('when `swapViews()` fails', async () => { it('when `swapViews()` fails', async () => {
const error = Error('Typical `swapViews()` error'); const error = Error('Typical `swapViews()` error');
@ -486,13 +543,24 @@ describe('DocViewerComponent', () => {
}); });
describe('(events)', () => { describe('(events)', () => {
it('should emit `docReady`', async () => { it('should emit `docReady` after loading elements', async () => {
const onDocReadySpy = jasmine.createSpy('onDocReady'); const onDocReadySpy = jasmine.createSpy('onDocReady');
docViewer.docReady.subscribe(onDocReadySpy); docViewer.docReady.subscribe(onDocReadySpy);
await doRender('Some content'); await doRender('Some content');
expect(onDocReadySpy).toHaveBeenCalledTimes(1); expect(onDocReadySpy).toHaveBeenCalledTimes(1);
expect(loadElementsSpy).toHaveBeenCalledBefore(onDocReadySpy);
});
it('should emit `docReady` before swapping views', async () => {
const onDocReadySpy = jasmine.createSpy('onDocReady');
docViewer.docReady.subscribe(onDocReadySpy);
await doRender('Some content');
expect(onDocReadySpy).toHaveBeenCalledTimes(1);
expect(onDocReadySpy).toHaveBeenCalledBefore(swapViewsSpy);
}); });
it('should emit `docRendered` after swapping views', async () => { it('should emit `docRendered` after swapping views', async () => {

View File

@ -110,6 +110,15 @@
} }
</script> </script>
<script>
// Custom elements should always rely on the polyfill to avoid having to include a shim that
// handles downleveled ES2015 classes. Especially since that shim would break on IE11 which
// can't even parse such code.
if (window.customElements) {
window.customElements['forcePolyfill'] = true;
}
</script>
</head> </head>
<body> <body>

View File

@ -33,9 +33,6 @@ import './environments/environment';
/** Add support for window.customElements */ /** Add support for window.customElements */
import '@webcomponents/custom-elements/custom-elements.min'; import '@webcomponents/custom-elements/custom-elements.min';
/** Required for custom elements for apps building to es5. */
import '@webcomponents/custom-elements/src/native-shim';
/** ALL Firefox browsers require the following to support `@angular/animation`. **/ /** ALL Firefox browsers require the following to support `@angular/animation`. **/
// import 'web-animations-js'; // Run `npm install --save web-animations-js`. // import 'web-animations-js'; // Run `npm install --save web-animations-js`.

View File

@ -8,6 +8,7 @@ import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
import { Logger } from 'app/shared/logger.service'; import { Logger } from 'app/shared/logger.service';
import { TocService } from 'app/shared/toc.service'; import { TocService } from 'app/shared/toc.service';
import { MockLogger } from 'testing/logger.service'; import { MockLogger } from 'testing/logger.service';
import { ElementsLoader } from 'app/custom-elements/elements-loader';
//////////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////////
@ -54,6 +55,11 @@ export class MockTocService {
reset = jasmine.createSpy('TocService#reset'); reset = jasmine.createSpy('TocService#reset');
} }
export class MockElementsLoader {
loadContainingCustomElements =
jasmine.createSpy('MockElementsLoader#loadContainingCustomElements');
}
@NgModule({ @NgModule({
declarations: [ declarations: [
DocViewerComponent, DocViewerComponent,
@ -64,6 +70,7 @@ export class MockTocService {
{ provide: Title, useClass: MockTitle }, { provide: Title, useClass: MockTitle },
{ provide: Meta, useClass: MockMeta }, { provide: Meta, useClass: MockMeta },
{ provide: TocService, useClass: MockTocService }, { provide: TocService, useClass: MockTocService },
{ provide: ElementsLoader, useClass: MockElementsLoader },
], ],
}) })
export class TestModule { } export class TestModule { }

View File

@ -205,6 +205,13 @@ function extractProperties(
function extractName(type: Function): string|null { function extractName(type: Function): string|null {
let name = type['name']; let name = type['name'];
// The polyfill @webcomponents/custom-element/src/native-shim.js overrides the
// window.HTMLElement and does not have the name property. Check if this is the
// case and if so, set the name manually.
if (name === '' && type === HTMLElement) {
name = 'HTMLElement';
}
switch (name) { switch (name) {
// see https://www.w3.org/TR/html5/index.html // see https://www.w3.org/TR/html5/index.html
// TODO(vicb): generate this map from all the element types // TODO(vicb): generate this map from all the element types

View File

@ -6,29 +6,35 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {ApplicationRef, ComponentFactory, ComponentRef, EventEmitter, Injector, OnChanges, SimpleChange, SimpleChanges} from '@angular/core'; import {ApplicationRef, ComponentFactory, ComponentFactoryResolver, ComponentRef, EventEmitter, Injector, OnChanges, SimpleChange, SimpleChanges, Type} from '@angular/core';
import {Observable} from 'rxjs/Observable'; import {Observable} from 'rxjs/Observable';
import {merge} from 'rxjs/observable/merge'; import {merge} from 'rxjs/observable/merge';
import {map} from 'rxjs/operator/map'; import {map} from 'rxjs/operator/map';
import {NgElementStrategy, NgElementStrategyEvent, NgElementStrategyFactory} from './element-strategy'; import {NgElementStrategy, NgElementStrategyEvent, NgElementStrategyFactory} from './element-strategy';
import {extractProjectableNodes} from './extract-projectable-nodes'; import {extractProjectableNodes} from './extract-projectable-nodes';
import {camelToDashCase, isFunction, scheduler, strictEquals} from './utils'; import {isFunction, scheduler, strictEquals} from './utils';
/** Time in milliseconds to wait before destroying the component ref when disconnected. */ /** Time in milliseconds to wait before destroying the component ref when disconnected. */
const DESTROY_DELAY = 10; const DESTROY_DELAY = 10;
/** /**
* Factory that creates new ComponentFactoryNgElementStrategy instances with the strategy factory's * Factory that creates new ComponentNgElementStrategy instance. Gets the component factory with the
* injector. A new strategy instance is created with the provided component factory which will * constructor's injector's factory resolver and passes that factory to each strategy.
* create its components on connect.
* *
* @experimental * @experimental
*/ */
export class ComponentFactoryNgElementStrategyFactory implements NgElementStrategyFactory { export class ComponentNgElementStrategyFactory implements NgElementStrategyFactory {
constructor(private componentFactory: ComponentFactory<any>, private injector: Injector) {} componentFactory: ComponentFactory<any>;
create() { return new ComponentFactoryNgElementStrategy(this.componentFactory, this.injector); } constructor(private component: Type<any>, private injector: Injector) {
this.componentFactory =
injector.get(ComponentFactoryResolver).resolveComponentFactory(component);
}
create(injector: Injector) {
return new ComponentNgElementStrategy(this.componentFactory, injector);
}
} }
/** /**
@ -37,12 +43,12 @@ export class ComponentFactoryNgElementStrategyFactory implements NgElementStrate
* *
* @experimental * @experimental
*/ */
export class ComponentFactoryNgElementStrategy implements NgElementStrategy { export class ComponentNgElementStrategy implements NgElementStrategy {
/** Merged stream of the component's output events. */ /** Merged stream of the component's output events. */
events: Observable<NgElementStrategyEvent>; events: Observable<NgElementStrategyEvent>;
/** Reference to the component that was created on connect. */ /** Reference to the component that was created on connect. */
private componentRef: ComponentRef<any>; private componentRef: ComponentRef<any>|null;
/** Changes that have been made to the component ref since the last time onChanges was called. */ /** Changes that have been made to the component ref since the last time onChanges was called. */
private inputChanges: SimpleChanges|null = null; private inputChanges: SimpleChanges|null = null;
@ -96,6 +102,7 @@ export class ComponentFactoryNgElementStrategy implements NgElementStrategy {
this.scheduledDestroyFn = scheduler.schedule(() => { this.scheduledDestroyFn = scheduler.schedule(() => {
if (this.componentRef) { if (this.componentRef) {
this.componentRef !.destroy(); this.componentRef !.destroy();
this.componentRef = null;
} }
}, DESTROY_DELAY); }, DESTROY_DELAY);
} }

View File

@ -5,7 +5,7 @@
* Use of this source code is governed by an MIT-style license that can be * 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 * found in the LICENSE file at https://angular.io/license
*/ */
import {ComponentFactory} from '@angular/core'; import {ComponentFactory, Injector} from '@angular/core';
import {Observable} from 'rxjs/Observable'; import {Observable} from 'rxjs/Observable';
/** /**
@ -38,4 +38,7 @@ export interface NgElementStrategy {
* *
* @experimental * @experimental
*/ */
export interface NgElementStrategyFactory { create(): NgElementStrategy; } export interface NgElementStrategyFactory {
/** Creates a new instance to be used for an NgElement. */
create(injector: Injector): NgElementStrategy;
}

View File

@ -6,12 +6,12 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {ComponentFactoryResolver, Injector, Type} from '@angular/core'; import {Injector, Type} from '@angular/core';
import {Subscription} from 'rxjs/Subscription'; import {Subscription} from 'rxjs/Subscription';
import {ComponentFactoryNgElementStrategyFactory} from './component-factory-strategy'; import {ComponentNgElementStrategyFactory} from './component-factory-strategy';
import {NgElementStrategy, NgElementStrategyFactory} from './element-strategy'; import {NgElementStrategy, NgElementStrategyFactory} from './element-strategy';
import {camelToDashCase, createCustomEvent} from './utils'; import {createCustomEvent, getComponentInputs, getDefaultAttributeToPropertyInputs} from './utils';
/** /**
* Class constructor based on an Angular Component to be used for custom element registration. * Class constructor based on an Angular Component to be used for custom element registration.
@ -21,7 +21,7 @@ import {camelToDashCase, createCustomEvent} from './utils';
export interface NgElementConstructor<P> { export interface NgElementConstructor<P> {
readonly observedAttributes: string[]; readonly observedAttributes: string[];
new (): NgElement&WithProperties<P>; new (injector: Injector): NgElement&WithProperties<P>;
} }
/** /**
@ -50,30 +50,19 @@ export type WithProperties<P> = {
}; };
/** /**
* Initialization configuration for the NgElementConstructor. Provides the strategy factory * Initialization configuration for the NgElementConstructor which contains the injector to be used
* that produces a strategy for each instantiated element. Additionally, provides a function * for retrieving the component's factory as well as the default context for the component. May
* that takes the component factory and provides a map of which attributes should be observed on * provide a custom strategy factory to be used instead of the default. May provide a custom mapping
* the element and which property they are associated with. * of attribute names to component inputs.
* *
* @experimental * @experimental
*/ */
export interface NgElementConfig { export interface NgElementConfig {
injector: Injector; injector: Injector;
strategyFactory?: NgElementStrategyFactory; strategyFactory?: NgElementStrategyFactory;
propertyInputs?: string[];
attributeToPropertyInputs?: {[key: string]: string}; attributeToPropertyInputs?: {[key: string]: string};
} }
/** Gets a map of default set of attributes to observe and the properties they affect. */
function getDefaultAttributeToPropertyInputs(inputs: {propName: string, templateName: string}[]) {
const attributeToPropertyInputs: {[key: string]: string} = {};
inputs.forEach(({propName, templateName}) => {
attributeToPropertyInputs[camelToDashCase(templateName)] = propName;
});
return attributeToPropertyInputs;
}
/** /**
* @whatItDoes Creates a custom element class based on an Angular Component. Takes a configuration * @whatItDoes Creates a custom element class based on an Angular Component. Takes a configuration
* that provides initialization information to the created class. E.g. the configuration's injector * that provides initialization information to the created class. E.g. the configuration's injector
@ -90,13 +79,10 @@ function getDefaultAttributeToPropertyInputs(inputs: {propName: string, template
*/ */
export function createNgElementConstructor<P>( export function createNgElementConstructor<P>(
component: Type<any>, config: NgElementConfig): NgElementConstructor<P> { component: Type<any>, config: NgElementConfig): NgElementConstructor<P> {
const componentFactoryResolver = const inputs = getComponentInputs(component, config.injector);
config.injector.get(ComponentFactoryResolver) as ComponentFactoryResolver;
const componentFactory = componentFactoryResolver.resolveComponentFactory(component);
const inputs = componentFactory.inputs;
const defaultStrategyFactory = config.strategyFactory || const strategyFactory =
new ComponentFactoryNgElementStrategyFactory(componentFactory, config.injector); config.strategyFactory || new ComponentNgElementStrategyFactory(component, config.injector);
const attributeToPropertyInputs = const attributeToPropertyInputs =
config.attributeToPropertyInputs || getDefaultAttributeToPropertyInputs(inputs); config.attributeToPropertyInputs || getDefaultAttributeToPropertyInputs(inputs);
@ -104,13 +90,9 @@ export function createNgElementConstructor<P>(
class NgElementImpl extends NgElement { class NgElementImpl extends NgElement {
static readonly observedAttributes = Object.keys(attributeToPropertyInputs); static readonly observedAttributes = Object.keys(attributeToPropertyInputs);
constructor(strategyFactoryOverride?: NgElementStrategyFactory) { constructor(injector?: Injector) {
super(); super();
this.ngElementStrategy = strategyFactory.create(injector || config.injector);
// Use the constructor's strategy factory override if it is present, otherwise default to
// the config's factory.
const strategyFactory = strategyFactoryOverride || defaultStrategyFactory;
this.ngElementStrategy = strategyFactory.create();
} }
attributeChangedCallback( attributeChangedCallback(
@ -120,14 +102,6 @@ export function createNgElementConstructor<P>(
} }
connectedCallback(): void { connectedCallback(): void {
// Take element attribute inputs and set them as inputs on the strategy
NgElementImpl.observedAttributes.forEach(attrName => {
const propName = attributeToPropertyInputs[attrName] !;
if (this.hasAttribute(attrName)) {
this.ngElementStrategy.setInputValue(propName, this.getAttribute(attrName) !);
}
});
this.ngElementStrategy.connect(this); this.ngElementStrategy.connect(this);
// Listen for events from the strategy and dispatch them as custom events // Listen for events from the strategy and dispatch them as custom events
@ -149,8 +123,7 @@ export function createNgElementConstructor<P>(
// Add getters and setters to the prototype for each property input. If the config does not // Add getters and setters to the prototype for each property input. If the config does not
// contain property inputs, use all inputs by default. // contain property inputs, use all inputs by default.
const propertyInputs = config.propertyInputs || inputs.map(({propName}) => propName); inputs.map(({propName}) => propName).forEach(property => {
propertyInputs.forEach(property => {
Object.defineProperty(NgElementImpl.prototype, property, { Object.defineProperty(NgElementImpl.prototype, property, {
get: function() { return this.ngElementStrategy.getInputValue(property); }, get: function() { return this.ngElementStrategy.getInputValue(property); },
set: function(newValue: any) { this.ngElementStrategy.setInputValue(property, newValue); }, set: function(newValue: any) { this.ngElementStrategy.setInputValue(property, newValue); },

View File

@ -5,8 +5,7 @@
* Use of this source code is governed by an MIT-style license that can be * 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 * found in the LICENSE file at https://angular.io/license
*/ */
import {ComponentFactoryResolver, Injector, Type} from '@angular/core';
import {Type} from '@angular/core';
const elProto = Element.prototype as any; const elProto = Element.prototype as any;
const matches = elProto.matches || elProto.matchesSelector || elProto.mozMatchesSelector || const matches = elProto.matches || elProto.matchesSelector || elProto.mozMatchesSelector ||
@ -101,3 +100,25 @@ export function matchesSelector(element: Element, selector: string): boolean {
export function strictEquals(value1: any, value2: any): boolean { export function strictEquals(value1: any, value2: any): boolean {
return value1 === value2 || (value1 !== value1 && value2 !== value2); return value1 === value2 || (value1 !== value1 && value2 !== value2);
} }
/** Gets a map of default set of attributes to observe and the properties they affect. */
export function getDefaultAttributeToPropertyInputs(
inputs: {propName: string, templateName: string}[]) {
const attributeToPropertyInputs: {[key: string]: string} = {};
inputs.forEach(({propName, templateName}) => {
attributeToPropertyInputs[camelToDashCase(templateName)] = propName;
});
return attributeToPropertyInputs;
}
/**
* Gets a component's set of inputs. Uses the injector to get the component factory where the inputs
* are defined.
*/
export function getComponentInputs(
component: Type<any>, injector: Injector): {propName: string, templateName: string}[] {
const componentFactoryResolver: ComponentFactoryResolver = injector.get(ComponentFactoryResolver);
const componentFactory = componentFactoryResolver.resolveComponentFactory(component);
return componentFactory.inputs;
}

View File

@ -10,12 +10,12 @@ import {ComponentFactory, ComponentRef, Injector, NgModuleRef, SimpleChange, Sim
import {fakeAsync, tick} from '@angular/core/testing'; import {fakeAsync, tick} from '@angular/core/testing';
import {Subject} from 'rxjs/Subject'; import {Subject} from 'rxjs/Subject';
import {ComponentFactoryNgElementStrategy, ComponentFactoryNgElementStrategyFactory} from '../src/component-factory-strategy'; import {ComponentNgElementStrategy, ComponentNgElementStrategyFactory} from '../src/component-factory-strategy';
import {NgElementStrategyEvent} from '../src/element-strategy'; import {NgElementStrategyEvent} from '../src/element-strategy';
describe('ComponentFactoryNgElementStrategy', () => { describe('ComponentFactoryNgElementStrategy', () => {
let factory: FakeComponentFactory; let factory: FakeComponentFactory;
let strategy: ComponentFactoryNgElementStrategy; let strategy: ComponentNgElementStrategy;
let injector: any; let injector: any;
let componentRef: any; let componentRef: any;
@ -25,22 +25,26 @@ describe('ComponentFactoryNgElementStrategy', () => {
factory = new FakeComponentFactory(); factory = new FakeComponentFactory();
componentRef = factory.componentRef; componentRef = factory.componentRef;
injector = jasmine.createSpyObj('injector', ['get']);
applicationRef = jasmine.createSpyObj('applicationRef', ['attachView']); applicationRef = jasmine.createSpyObj('applicationRef', ['attachView']);
injector.get.and.returnValue(applicationRef);
strategy = new ComponentFactoryNgElementStrategy(factory, injector); strategy = new ComponentNgElementStrategy(factory, injector);
}); });
it('should create a new strategy from the factory', () => { it('should create a new strategy from the factory', () => {
const strategyFactory = new ComponentFactoryNgElementStrategyFactory(factory, injector); const factoryResolver = jasmine.createSpyObj('factoryResolver', ['resolveComponentFactory']);
expect(strategyFactory.create()).toBeTruthy(); factoryResolver.resolveComponentFactory.and.returnValue(factory);
injector = jasmine.createSpyObj('injector', ['get']);
injector.get.and.returnValue(factoryResolver);
const strategyFactory = new ComponentNgElementStrategyFactory(FakeComponent, injector);
expect(strategyFactory.create(injector)).toBeTruthy();
}); });
describe('after connected', () => { describe('after connected', () => {
beforeEach(() => { beforeEach(() => {
// Set up an initial value to make sure it is passed to the component // Set up an initial value to make sure it is passed to the component
strategy.setInputValue('fooFoo', 'fooFoo-1'); strategy.setInputValue('fooFoo', 'fooFoo-1');
injector.get.and.returnValue(applicationRef);
strategy.connect(document.createElement('div')); strategy.connect(document.createElement('div'));
}); });
@ -95,7 +99,7 @@ describe('ComponentFactoryNgElementStrategy', () => {
it('should not detect changes', fakeAsync(() => { it('should not detect changes', fakeAsync(() => {
strategy.setInputValue('fooFoo', 'fooFoo-1'); strategy.setInputValue('fooFoo', 'fooFoo-1');
tick(16); // scheduler waits 16ms if RAF is unavailable tick(16); // scheduler waits 16ms if RAF is unavailable
expect(componentRef.changeDetectorRef.detectChanges).toHaveBeenCalledTimes(0); expect(componentRef.changeDetectorRef.detectChanges).not.toHaveBeenCalled();
})); }));
}); });

View File

@ -51,7 +51,7 @@ if (typeof customElements !== 'undefined') {
}); });
it('should send input values from attributes when connected', () => { it('should send input values from attributes when connected', () => {
const element = new NgElementCtor(); const element = new NgElementCtor(injector);
element.setAttribute('foo-foo', 'value-foo-foo'); element.setAttribute('foo-foo', 'value-foo-foo');
element.setAttribute('barbar', 'value-barbar'); element.setAttribute('barbar', 'value-barbar');
element.connectedCallback(); element.connectedCallback();
@ -62,7 +62,7 @@ if (typeof customElements !== 'undefined') {
}); });
it('should listen to output events after connected', () => { it('should listen to output events after connected', () => {
const element = new NgElementCtor(); const element = new NgElementCtor(injector);
element.connectedCallback(); element.connectedCallback();
let eventValue: any = null; let eventValue: any = null;
@ -73,7 +73,7 @@ if (typeof customElements !== 'undefined') {
}); });
it('should not listen to output events after disconnected', () => { it('should not listen to output events after disconnected', () => {
const element = new NgElementCtor(); const element = new NgElementCtor(injector);
element.connectedCallback(); element.connectedCallback();
element.disconnectedCallback(); element.disconnectedCallback();
expect(strategy.disconnectCalled).toBe(true); expect(strategy.disconnectCalled).toBe(true);
@ -86,7 +86,7 @@ if (typeof customElements !== 'undefined') {
}); });
it('should properly set getters/setters on the element', () => { it('should properly set getters/setters on the element', () => {
const element = new NgElementCtor(); const element = new NgElementCtor(injector);
element.fooFoo = 'foo-foo-value'; element.fooFoo = 'foo-foo-value';
element.barBar = 'barBar-value'; element.barBar = 'barBar-value';
@ -104,29 +104,26 @@ if (typeof customElements !== 'undefined') {
NgElementCtorWithChangedAttr = createNgElementConstructor(TestComponent, { NgElementCtorWithChangedAttr = createNgElementConstructor(TestComponent, {
injector, injector,
strategyFactory, strategyFactory,
propertyInputs: ['prop1', 'prop2'], attributeToPropertyInputs: {'attr-1': 'fooFoo', 'attr-2': 'barbar'}
attributeToPropertyInputs: {'attr-1': 'prop1', 'attr-2': 'prop2'}
}); });
customElements.define('test-element-with-changed-attributes', NgElementCtorWithChangedAttr); customElements.define('test-element-with-changed-attributes', NgElementCtorWithChangedAttr);
}); });
beforeEach(() => { element = new NgElementCtorWithChangedAttr(); }); beforeEach(() => { element = new NgElementCtorWithChangedAttr(injector); });
it('should affect which attributes are watched', () => { it('should affect which attributes are watched', () => {
expect(NgElementCtorWithChangedAttr.observedAttributes).toEqual(['attr-1', 'attr-2']); expect(NgElementCtorWithChangedAttr.observedAttributes).toEqual(['attr-1', 'attr-2']);
}); });
it('should send attribute values as inputs when connected', () => { it('should send attribute values as inputs when connected', () => {
const element = new NgElementCtorWithChangedAttr(); const element = new NgElementCtorWithChangedAttr(injector);
element.setAttribute('attr-1', 'value-1'); element.setAttribute('attr-1', 'value-1');
element.setAttribute('attr-2', 'value-2'); element.setAttribute('attr-2', 'value-2');
element.setAttribute('attr-3', 'value-3'); // Made-up attribute
element.connectedCallback(); element.connectedCallback();
expect(strategy.getInputValue('prop1')).toBe('value-1'); expect(strategy.getInputValue('fooFoo')).toBe('value-1');
expect(strategy.getInputValue('prop2')).toBe('value-2'); expect(strategy.getInputValue('barbar')).toBe('value-2');
expect(strategy.getInputValue('prop3')).not.toBe('value-3');
}); });
}); });
}); });

View File

@ -56,7 +56,7 @@ if [[ ${TRAVIS} &&
travisFoldStart "yarn-install.aio" travisFoldStart "yarn-install.aio"
( (
# HACK (don't submit with this): Build Angular # HACK (don't submit with this): Build Angular
./build.sh ./build.sh --packages=core,elements --examples=false
cd ${PROJECT_ROOT}/aio cd ${PROJECT_ROOT}/aio
yarn install --frozen-lockfile --non-interactive yarn install --frozen-lockfile --non-interactive

View File

@ -76,7 +76,7 @@ Promise
.resolve() .resolve()
// Load browser-specific polyfills for custom elements. // Load browser-specific polyfills for custom elements.
// .then(function() { return loadCustomElementsPolyfills(); }) .then(function() { return loadCustomElementsPolyfills(); })
// Load necessary testing packages. // Load necessary testing packages.
.then(function() { .then(function() {
@ -151,29 +151,35 @@ function loadCustomElementsPolyfills() {
Object.setPrototypeOf = setPrototypeOf; Object.setPrototypeOf = setPrototypeOf;
} }
// The custom elements polyfill will patch `(HTML)Element` properties, including `innerHTML`: // The custom elements polyfill will patch properties and methods on `(HTML)Element` and `Node`
// https://github.com/webcomponents/custom-elements/blob/32f043c3a5e5fc3e035342c0ef10c6786fa416d7/src/Patch/Element.js#L28-L78 // (among others), including `(HTML)Element#innerHTML` and `Node#removeChild()`:
// The patched `innerHTML` setter will try to traverse the DOM (via `nextSibling`), which leads to // https://github.com/webcomponents/custom-elements/blob/4f7072c0dbda4beb505d16967acfffd33337b325/src/Patch/Element.js#L28-L73
// infinite loops when testing `HtmlSanitizer` with cloberred elements on browsers that do not // https://github.com/webcomponents/custom-elements/blob/4f7072c0dbda4beb505d16967acfffd33337b325/src/Patch/Node.js#L105-L120
// support the `<template>` element: // The patched `innerHTML` setter and `removeChild()` method will try to traverse the DOM (via
// `nextSibling` and `parentNode` respectively), which leads to infinite loops when testing
// `HtmlSanitizer` with cloberred elements on browsers that do not support the `<template>`
// element:
// https://github.com/angular/angular/blob/213baa37b0b71e72d00ad7b606ebfc2ade06b934/packages/platform-browser/src/security/html_sanitizer.ts#L29-L38 // https://github.com/angular/angular/blob/213baa37b0b71e72d00ad7b606ebfc2ade06b934/packages/platform-browser/src/security/html_sanitizer.ts#L29-L38
// To avoid that, we "unpatch" `(HTML)Element#innerHTML` and apply the patch only for the relevant // To avoid that, we "unpatch" these properties/methods and apply the patch only for the relevant
// `@angular/elements` tests. // `@angular/elements` tests.
var patchTarget; var patchConfig = {'innerHTML': ['Element', 'HTMLElement'], 'removeChild': ['Node']};
var originalDescriptor; var patchTargets = {};
var originalDescriptors = {};
if (!window.customElements) { if (!window.customElements) {
['Element', 'HTMLElement'] Object.keys(patchConfig).forEach(function(prop) {
patchConfig[prop]
.map(function(name) { return window[name].prototype; }) .map(function(name) { return window[name].prototype; })
.some(function(candidatePatchTarget) { .some(function(candidatePatchTarget) {
var candidateOriginalDescriptor = var candidateOriginalDescriptor =
Object.getOwnPropertyDescriptor(candidatePatchTarget, 'innerHTML'); Object.getOwnPropertyDescriptor(candidatePatchTarget, prop);
if (candidateOriginalDescriptor) { if (candidateOriginalDescriptor) {
patchTarget = candidatePatchTarget; patchTargets[prop] = candidatePatchTarget;
originalDescriptor = candidateOriginalDescriptor; originalDescriptors[prop] = candidateOriginalDescriptor;
return true; return true;
} }
}); });
});
} }
var polyfillPath = !window.customElements ? var polyfillPath = !window.customElements ?
@ -188,24 +194,32 @@ function loadCustomElementsPolyfills() {
// but custom element polyfills will replace `HTMLElement` with an anonymous function. // but custom element polyfills will replace `HTMLElement` with an anonymous function.
Object.defineProperty(HTMLElement, 'name', {value: 'HTMLElement'}); Object.defineProperty(HTMLElement, 'name', {value: 'HTMLElement'});
// Create helper functions on `window` for patching/restoring `(HTML)Element#innerHTML`. // Create helper functions on `window` for patching/restoring properties/methods.
if (!patchTarget) { Object.keys(patchConfig).forEach(function(prop) {
window.$$patchInnerHtmlProp = window.$$restoreInnerHtmlProp = function() {}; var patchMethod = '$$patch_' + prop;
var restoreMethod = '$$restore_' + prop;
if (!patchTargets[prop]) {
// No patching detected. Create no-op functions.
window[patchMethod] = window[restoreMethod] = function() {};
} else { } else {
var patchedDescriptor = Object.getOwnPropertyDescriptor(patchTarget, 'innerHTML'); var patchTarget = patchTargets[prop];
var originalDescriptor = originalDescriptors[prop];
var patchedDescriptor = Object.getOwnPropertyDescriptor(patchTarget, prop);
window.$$patchInnerHtmlProp = function() { window[patchMethod] = function() {
Object.defineProperty(patchTarget, 'innerHTML', patchedDescriptor); Object.defineProperty(patchTarget, prop, patchedDescriptor);
}; };
window.$$restoreInnerHtmlProp = function() { window[restoreMethod] = function() {
Object.defineProperty(patchTarget, 'innerHTML', originalDescriptor); Object.defineProperty(patchTarget, prop, originalDescriptor);
}; };
// Restore `innerHTML`. The patch will be manually applied only during the // Restore `prop`. The patch will be manually applied only during the
// `@angular/elements` tests that need it. // `@angular/elements` tests that need it.
window.$$restoreInnerHtmlProp(); window[restoreMethod]();
} }
}); });
});
return loadedPromise; return loadedPromise;
} }

View File

@ -16,14 +16,13 @@ export interface NgElementConfig {
[key: string]: string; [key: string]: string;
}; };
injector: Injector; injector: Injector;
propertyInputs?: string[];
strategyFactory?: NgElementStrategyFactory; strategyFactory?: NgElementStrategyFactory;
} }
/** @experimental */ /** @experimental */
export interface NgElementConstructor<P> { export interface NgElementConstructor<P> {
readonly observedAttributes: string[]; readonly observedAttributes: string[];
new (): NgElement & WithProperties<P>; new (injector: Injector): NgElement & WithProperties<P>;
} }
/** @experimental */ /** @experimental */
@ -43,7 +42,7 @@ export interface NgElementStrategyEvent {
/** @experimental */ /** @experimental */
export interface NgElementStrategyFactory { export interface NgElementStrategyFactory {
create(): NgElementStrategy; create(injector: Injector): NgElementStrategy;
} }
/** @experimental */ /** @experimental */