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": {
"inline": 2062,
"main": 467103,
"polyfills": 55349,
"polyfills": 54292,
"embedded": 71711,
"prettify": 14888
}

View File

@ -30,7 +30,7 @@ describe('CodeExampleComponent', () => {
});
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', () => {

View File

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

View File

@ -9,8 +9,6 @@ import {TestBed, fakeAsync, tick} from '@angular/core/testing';
import { ElementsLoader } from './elements-loader';
import { ELEMENT_MODULE_PATHS_TOKEN, WithCustomElementComponent } from './element-registry';
const actualCustomElements = window.customElements;
class FakeComponentFactory extends ComponentFactory<any> {
selector: string;
componentType: Type<any>;
@ -29,21 +27,26 @@ class FakeComponentFactory extends ComponentFactory<any> {
}
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 injectedModuleRef: NgModuleRef<any>;
let fakeCustomElements;
let actualCustomElementsDefine;
let fakeCustomElementsDefine;
// ElementsLoader uses the window's customElements API. Provide a fake for this test.
beforeEach(() => {
fakeCustomElements = jasmine.createSpyObj('customElements', ['define', 'whenDefined']);
window.customElements = fakeCustomElements;
actualCustomElementsDefine = window.customElements.define;
fakeCustomElementsDefine = jasmine.createSpy('define');
window.customElements.define = fakeCustomElementsDefine;
});
afterEach(() => {
window.customElements = actualCustomElements;
window.customElements.define = actualCustomElementsDefine;
});
beforeEach(() => {
@ -52,7 +55,8 @@ fdescribe('ElementsLoader', () => {
ElementsLoader,
{ provide: NgModuleFactoryLoader, useClass: FakeModuleFactoryLoader },
{ 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);
tick();
const defineArgs = fakeCustomElements.define.calls.argsFor(0);
const defineArgs = fakeCustomElementsDefine.calls.argsFor(0);
expect(defineArgs[0]).toBe('element-a-selector');
// Verify the right component was loaded/created
@ -80,6 +84,30 @@ fdescribe('ElementsLoader', () => {
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(() => {
const hostEl = document.createElement('div');
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 { CustomElementsModule } from 'app/custom-elements/custom-elements.module';
import { TocService } from 'app/shared/toc.service';
import { ElementsLoader } from 'app/custom-elements/elements-loader';
import {
MockTitle, MockTocService, ObservableWithSubscriptionSpies,
TestDocViewerComponent, TestModule, TestParentComponent
MockTitle, MockTocService, ObservableWithSubscriptionSpies,
TestDocViewerComponent, TestModule, TestParentComponent, MockElementsLoader
} from 'testing/doc-viewer-utils';
import { MockLogger } from 'testing/logger.service';
import { DocViewerComponent, NO_ANIMATIONS } from './doc-viewer.component';
describe('DocViewerComponent', () => {
let parentFixture: ComponentFixture<TestParentComponent>;
let parentComponent: TestParentComponent;
@ -24,7 +24,7 @@ describe('DocViewerComponent', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TestModule, CustomElementsModule],
imports: [CustomElementsModule, TestModule],
});
parentFixture = TestBed.createComponent(TestParentComponent);
@ -294,12 +294,16 @@ describe('DocViewerComponent', () => {
describe('#render()', () => {
let prepareTitleAndTocSpy: jasmine.Spy;
let swapViewsSpy: jasmine.Spy;
let loadElementsSpy: jasmine.Spy;
const doRender = (contents: string | null, id = 'foo') =>
new Promise<void>((resolve, reject) =>
docViewer.render({contents, id}).subscribe(resolve, reject));
beforeEach(() => {
const elementsLoader = TestBed.get(ElementsLoader) as MockElementsLoader;
loadElementsSpy =
elementsLoader.loadContainingCustomElements.and.returnValue(of([]));
prepareTitleAndTocSpy = spyOn(docViewer, 'prepareTitleAndToc');
swapViewsSpy = spyOn(docViewer, 'swapViews').and.returnValue(of(undefined));
});
@ -333,7 +337,7 @@ describe('DocViewerComponent', () => {
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) => {
expect(targetEl.innerHTML).toBe('Some content');
expect(docId).toBe('foo');
@ -342,6 +346,7 @@ describe('DocViewerComponent', () => {
await doRender('Some content', 'foo');
expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1);
expect(prepareTitleAndTocSpy).toHaveBeenCalledBefore(loadElementsSpy);
});
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)', () => {
it('should still swap the views if the document is empty', async () => {
await doRender('');
@ -444,6 +482,25 @@ describe('DocViewerComponent', () => {
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 () => {
const error = Error('Typical `swapViews()` error');
@ -486,13 +543,24 @@ describe('DocViewerComponent', () => {
});
describe('(events)', () => {
it('should emit `docReady`', async () => {
it('should emit `docReady` after loading elements', async () => {
const onDocReadySpy = jasmine.createSpy('onDocReady');
docViewer.docReady.subscribe(onDocReadySpy);
await doRender('Some content');
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 () => {

View File

@ -67,12 +67,12 @@ export class DocViewerComponent implements OnDestroy {
@Output() docRendered = new EventEmitter<void>();
constructor(
elementRef: ElementRef,
private logger: Logger,
private titleService: Title,
private metaService: Meta,
private tocService: TocService,
private elementsLoader: ElementsLoader) {
elementRef: ElementRef,
private logger: Logger,
private titleService: Title,
private metaService: Meta,
private tocService: TocService,
private elementsLoader: ElementsLoader) {
this.hostElement = elementRef.nativeElement;
// Security: the initialDocViewerContent comes from the prerendered DOM and is considered to be secure
this.hostElement.innerHTML = initialDocViewerContent;

View File

@ -110,6 +110,15 @@
}
</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>
<body>

View File

@ -33,9 +33,6 @@ import './environments/environment';
/** Add support for window.customElements */
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`. **/
// 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 { TocService } from 'app/shared/toc.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');
}
export class MockElementsLoader {
loadContainingCustomElements =
jasmine.createSpy('MockElementsLoader#loadContainingCustomElements');
}
@NgModule({
declarations: [
DocViewerComponent,
@ -64,6 +70,7 @@ export class MockTocService {
{ provide: Title, useClass: MockTitle },
{ provide: Meta, useClass: MockMeta },
{ provide: TocService, useClass: MockTocService },
{ provide: ElementsLoader, useClass: MockElementsLoader },
],
})
export class TestModule { }

View File

@ -205,6 +205,13 @@ function extractProperties(
function extractName(type: Function): string|null {
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) {
// see https://www.w3.org/TR/html5/index.html
// 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
*/
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 {merge} from 'rxjs/observable/merge';
import {map} from 'rxjs/operator/map';
import {NgElementStrategy, NgElementStrategyEvent, NgElementStrategyFactory} from './element-strategy';
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. */
const DESTROY_DELAY = 10;
/**
* Factory that creates new ComponentFactoryNgElementStrategy instances with the strategy factory's
* injector. A new strategy instance is created with the provided component factory which will
* create its components on connect.
* Factory that creates new ComponentNgElementStrategy instance. Gets the component factory with the
* constructor's injector's factory resolver and passes that factory to each strategy.
*
* @experimental
*/
export class ComponentFactoryNgElementStrategyFactory implements NgElementStrategyFactory {
constructor(private componentFactory: ComponentFactory<any>, private injector: Injector) {}
export class ComponentNgElementStrategyFactory implements NgElementStrategyFactory {
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
*/
export class ComponentFactoryNgElementStrategy implements NgElementStrategy {
export class ComponentNgElementStrategy implements NgElementStrategy {
/** Merged stream of the component's output events. */
events: Observable<NgElementStrategyEvent>;
/** 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. */
private inputChanges: SimpleChanges|null = null;
@ -96,6 +102,7 @@ export class ComponentFactoryNgElementStrategy implements NgElementStrategy {
this.scheduledDestroyFn = scheduler.schedule(() => {
if (this.componentRef) {
this.componentRef !.destroy();
this.componentRef = null;
}
}, DESTROY_DELAY);
}

View File

@ -5,7 +5,7 @@
* 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 {ComponentFactory} from '@angular/core';
import {ComponentFactory, Injector} from '@angular/core';
import {Observable} from 'rxjs/Observable';
/**
@ -38,4 +38,7 @@ export interface NgElementStrategy {
*
* @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
*/
import {ComponentFactoryResolver, Injector, Type} from '@angular/core';
import {Injector, Type} from '@angular/core';
import {Subscription} from 'rxjs/Subscription';
import {ComponentFactoryNgElementStrategyFactory} from './component-factory-strategy';
import {ComponentNgElementStrategyFactory} from './component-factory-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.
@ -21,7 +21,7 @@ import {camelToDashCase, createCustomEvent} from './utils';
export interface NgElementConstructor<P> {
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
* that produces a strategy for each instantiated element. Additionally, provides a function
* that takes the component factory and provides a map of which attributes should be observed on
* the element and which property they are associated with.
* Initialization configuration for the NgElementConstructor which contains the injector to be used
* for retrieving the component's factory as well as the default context for the component. May
* provide a custom strategy factory to be used instead of the default. May provide a custom mapping
* of attribute names to component inputs.
*
* @experimental
*/
export interface NgElementConfig {
injector: Injector;
strategyFactory?: NgElementStrategyFactory;
propertyInputs?: 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
* 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>(
component: Type<any>, config: NgElementConfig): NgElementConstructor<P> {
const componentFactoryResolver =
config.injector.get(ComponentFactoryResolver) as ComponentFactoryResolver;
const componentFactory = componentFactoryResolver.resolveComponentFactory(component);
const inputs = componentFactory.inputs;
const inputs = getComponentInputs(component, config.injector);
const defaultStrategyFactory = config.strategyFactory ||
new ComponentFactoryNgElementStrategyFactory(componentFactory, config.injector);
const strategyFactory =
config.strategyFactory || new ComponentNgElementStrategyFactory(component, config.injector);
const attributeToPropertyInputs =
config.attributeToPropertyInputs || getDefaultAttributeToPropertyInputs(inputs);
@ -104,13 +90,9 @@ export function createNgElementConstructor<P>(
class NgElementImpl extends NgElement {
static readonly observedAttributes = Object.keys(attributeToPropertyInputs);
constructor(strategyFactoryOverride?: NgElementStrategyFactory) {
constructor(injector?: Injector) {
super();
// 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();
this.ngElementStrategy = strategyFactory.create(injector || config.injector);
}
attributeChangedCallback(
@ -120,14 +102,6 @@ export function createNgElementConstructor<P>(
}
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);
// 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
// contain property inputs, use all inputs by default.
const propertyInputs = config.propertyInputs || inputs.map(({propName}) => propName);
propertyInputs.forEach(property => {
inputs.map(({propName}) => propName).forEach(property => {
Object.defineProperty(NgElementImpl.prototype, property, {
get: function() { return this.ngElementStrategy.getInputValue(property); },
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
* found in the LICENSE file at https://angular.io/license
*/
import {Type} from '@angular/core';
import {ComponentFactoryResolver, Injector, Type} from '@angular/core';
const elProto = Element.prototype as any;
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 {
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 {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';
describe('ComponentFactoryNgElementStrategy', () => {
let factory: FakeComponentFactory;
let strategy: ComponentFactoryNgElementStrategy;
let strategy: ComponentNgElementStrategy;
let injector: any;
let componentRef: any;
@ -25,22 +25,26 @@ describe('ComponentFactoryNgElementStrategy', () => {
factory = new FakeComponentFactory();
componentRef = factory.componentRef;
injector = jasmine.createSpyObj('injector', ['get']);
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', () => {
const strategyFactory = new ComponentFactoryNgElementStrategyFactory(factory, injector);
expect(strategyFactory.create()).toBeTruthy();
const factoryResolver = jasmine.createSpyObj('factoryResolver', ['resolveComponentFactory']);
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', () => {
beforeEach(() => {
// Set up an initial value to make sure it is passed to the component
strategy.setInputValue('fooFoo', 'fooFoo-1');
injector.get.and.returnValue(applicationRef);
strategy.connect(document.createElement('div'));
});
@ -95,7 +99,7 @@ describe('ComponentFactoryNgElementStrategy', () => {
it('should not detect changes', fakeAsync(() => {
strategy.setInputValue('fooFoo', 'fooFoo-1');
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', () => {
const element = new NgElementCtor();
const element = new NgElementCtor(injector);
element.setAttribute('foo-foo', 'value-foo-foo');
element.setAttribute('barbar', 'value-barbar');
element.connectedCallback();
@ -62,7 +62,7 @@ if (typeof customElements !== 'undefined') {
});
it('should listen to output events after connected', () => {
const element = new NgElementCtor();
const element = new NgElementCtor(injector);
element.connectedCallback();
let eventValue: any = null;
@ -73,7 +73,7 @@ if (typeof customElements !== 'undefined') {
});
it('should not listen to output events after disconnected', () => {
const element = new NgElementCtor();
const element = new NgElementCtor(injector);
element.connectedCallback();
element.disconnectedCallback();
expect(strategy.disconnectCalled).toBe(true);
@ -86,7 +86,7 @@ if (typeof customElements !== 'undefined') {
});
it('should properly set getters/setters on the element', () => {
const element = new NgElementCtor();
const element = new NgElementCtor(injector);
element.fooFoo = 'foo-foo-value';
element.barBar = 'barBar-value';
@ -104,29 +104,26 @@ if (typeof customElements !== 'undefined') {
NgElementCtorWithChangedAttr = createNgElementConstructor(TestComponent, {
injector,
strategyFactory,
propertyInputs: ['prop1', 'prop2'],
attributeToPropertyInputs: {'attr-1': 'prop1', 'attr-2': 'prop2'}
attributeToPropertyInputs: {'attr-1': 'fooFoo', 'attr-2': 'barbar'}
});
customElements.define('test-element-with-changed-attributes', NgElementCtorWithChangedAttr);
});
beforeEach(() => { element = new NgElementCtorWithChangedAttr(); });
beforeEach(() => { element = new NgElementCtorWithChangedAttr(injector); });
it('should affect which attributes are watched', () => {
expect(NgElementCtorWithChangedAttr.observedAttributes).toEqual(['attr-1', 'attr-2']);
});
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-2', 'value-2');
element.setAttribute('attr-3', 'value-3'); // Made-up attribute
element.connectedCallback();
expect(strategy.getInputValue('prop1')).toBe('value-1');
expect(strategy.getInputValue('prop2')).toBe('value-2');
expect(strategy.getInputValue('prop3')).not.toBe('value-3');
expect(strategy.getInputValue('fooFoo')).toBe('value-1');
expect(strategy.getInputValue('barbar')).toBe('value-2');
});
});
});

View File

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

View File

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

View File

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