diff --git a/aio/scripts/_payload-limits.json b/aio/scripts/_payload-limits.json index 38ff6ba3fe..59a30a0135 100755 --- a/aio/scripts/_payload-limits.json +++ b/aio/scripts/_payload-limits.json @@ -4,7 +4,7 @@ "uncompressed": { "inline": 2062, "main": 467103, - "polyfills": 55349, + "polyfills": 54292, "embedded": 71711, "prettify": 14888 } diff --git a/aio/src/app/custom-elements/code/code-example.component.spec.ts b/aio/src/app/custom-elements/code/code-example.component.spec.ts index ec97440747..7045d9ba2e 100644 --- a/aio/src/app/custom-elements/code/code-example.component.spec.ts +++ b/aio/src/app/custom-elements/code/code-example.component.spec.ts @@ -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', () => { diff --git a/aio/src/app/custom-elements/code/code-example.component.ts b/aio/src/app/custom-elements/code/code-example.component.ts index 2e402dd07a..6459f7273e 100644 --- a/aio/src/app/custom-elements/code/code-example.component.ts +++ b/aio/src/app/custom-elements/code/code-example.component.ts @@ -34,8 +34,6 @@ import { CodeComponent } from './code.component'; export class CodeExampleComponent implements AfterViewInit { classes: {}; - code: string; - @Input() language: string; @Input() linenums: string; diff --git a/aio/src/app/custom-elements/elements-loader.spec.ts b/aio/src/app/custom-elements/elements-loader.spec.ts index 064478f4c8..3d9ac80fed 100644 --- a/aio/src/app/custom-elements/elements-loader.spec.ts +++ b/aio/src/app/custom-elements/elements-loader.spec.ts @@ -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 { selector: string; componentType: Type; @@ -29,21 +27,26 @@ class FakeComponentFactory extends ComponentFactory { } 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; - 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 = ` + + + `; + + 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 = ``; diff --git a/aio/src/app/embedded/code/code-example.component.spec.ts b/aio/src/app/embedded/code/code-example.component.spec.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/aio/src/app/embedded/code/code-example.component.ts b/aio/src/app/embedded/code/code-example.component.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts b/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts index 37daf7eda7..967ac8b926 100644 --- a/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts +++ b/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts @@ -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; 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((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 () => { diff --git a/aio/src/app/layout/doc-viewer/doc-viewer.component.ts b/aio/src/app/layout/doc-viewer/doc-viewer.component.ts index 5e103aded0..8ab529b96e 100644 --- a/aio/src/app/layout/doc-viewer/doc-viewer.component.ts +++ b/aio/src/app/layout/doc-viewer/doc-viewer.component.ts @@ -67,12 +67,12 @@ export class DocViewerComponent implements OnDestroy { @Output() docRendered = new EventEmitter(); 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; diff --git a/aio/src/index.html b/aio/src/index.html index 66959ba1a1..7a279807c2 100644 --- a/aio/src/index.html +++ b/aio/src/index.html @@ -110,6 +110,15 @@ } + + diff --git a/aio/src/polyfills.ts b/aio/src/polyfills.ts index 3cfa5675f0..a7743cf635 100644 --- a/aio/src/polyfills.ts +++ b/aio/src/polyfills.ts @@ -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`. diff --git a/aio/src/testing/doc-viewer-utils.ts b/aio/src/testing/doc-viewer-utils.ts index c153002ba8..ed2e3ed858 100644 --- a/aio/src/testing/doc-viewer-utils.ts +++ b/aio/src/testing/doc-viewer-utils.ts @@ -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 { } diff --git a/packages/compiler/test/schema/schema_extractor.ts b/packages/compiler/test/schema/schema_extractor.ts index b9f83d0ab7..533ca59a6a 100644 --- a/packages/compiler/test/schema/schema_extractor.ts +++ b/packages/compiler/test/schema/schema_extractor.ts @@ -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 diff --git a/packages/elements/src/component-factory-strategy.ts b/packages/elements/src/component-factory-strategy.ts index d66bf7ee71..5d52397b44 100644 --- a/packages/elements/src/component-factory-strategy.ts +++ b/packages/elements/src/component-factory-strategy.ts @@ -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, private injector: Injector) {} +export class ComponentNgElementStrategyFactory implements NgElementStrategyFactory { + componentFactory: ComponentFactory; - create() { return new ComponentFactoryNgElementStrategy(this.componentFactory, this.injector); } + constructor(private component: Type, 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; /** Reference to the component that was created on connect. */ - private componentRef: ComponentRef; + private componentRef: ComponentRef|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); } diff --git a/packages/elements/src/element-strategy.ts b/packages/elements/src/element-strategy.ts index a58ec07704..c915075159 100644 --- a/packages/elements/src/element-strategy.ts +++ b/packages/elements/src/element-strategy.ts @@ -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; +} diff --git a/packages/elements/src/ng-element-constructor.ts b/packages/elements/src/ng-element-constructor.ts index af5095cf2f..0d003d0429 100644 --- a/packages/elements/src/ng-element-constructor.ts +++ b/packages/elements/src/ng-element-constructor.ts @@ -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

{ readonly observedAttributes: string[]; - new (): NgElement&WithProperties

; + new (injector: Injector): NgElement&WithProperties

; } /** @@ -50,30 +50,19 @@ export type WithProperties

= { }; /** - * 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

( component: Type, config: NgElementConfig): NgElementConstructor

{ - 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

( 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

( } 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

( // 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); }, diff --git a/packages/elements/src/utils.ts b/packages/elements/src/utils.ts index 6e105483e1..01f8d8b2af 100644 --- a/packages/elements/src/utils.ts +++ b/packages/elements/src/utils.ts @@ -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, injector: Injector): {propName: string, templateName: string}[] { + const componentFactoryResolver: ComponentFactoryResolver = injector.get(ComponentFactoryResolver); + const componentFactory = componentFactoryResolver.resolveComponentFactory(component); + return componentFactory.inputs; +} diff --git a/packages/elements/test/component-factory-strategy_spec.ts b/packages/elements/test/component-factory-strategy_spec.ts index 35bab98e2d..50e12f920e 100644 --- a/packages/elements/test/component-factory-strategy_spec.ts +++ b/packages/elements/test/component-factory-strategy_spec.ts @@ -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(); })); }); diff --git a/packages/elements/test/ng-element-constructor_spec.ts b/packages/elements/test/ng-element-constructor_spec.ts index 837e9e4117..40cfed3751 100644 --- a/packages/elements/test/ng-element-constructor_spec.ts +++ b/packages/elements/test/ng-element-constructor_spec.ts @@ -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'); }); }); }); diff --git a/scripts/ci/install.sh b/scripts/ci/install.sh index d85d3d58b7..bc8028c643 100755 --- a/scripts/ci/install.sh +++ b/scripts/ci/install.sh @@ -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 diff --git a/test-main.js b/test-main.js index ff45a5e77b..ce3aea78f6 100644 --- a/test-main.js +++ b/test-main.js @@ -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 `