Skip to content

Commit

Permalink
feat(elements): injector create (angular#22413)
Browse files Browse the repository at this point in the history
PR Close angular#22413
  • Loading branch information
andrewseguin authored and mhevery committed Mar 16, 2018
1 parent 46efd4b commit 87f60bc
Show file tree
Hide file tree
Showing 21 changed files with 278 additions and 146 deletions.
2 changes: 1 addition & 1 deletion aio/scripts/_payload-limits.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"uncompressed": {
"inline": 2062,
"main": 467103,
"polyfills": 55349,
"polyfills": 54292,
"embedded": 71711,
"prettify": 14888
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
2 changes: 0 additions & 2 deletions aio/src/app/custom-elements/code/code-example.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,6 @@ import { CodeComponent } from './code.component';
export class CodeExampleComponent implements AfterViewInit {
classes: {};

code: string;

@Input() language: string;

@Input() linenums: string;
Expand Down
48 changes: 38 additions & 10 deletions aio/src/app/custom-elements/elements-loader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand All @@ -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(() => {
Expand All @@ -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']
])},
]
});
Expand All @@ -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
Expand All @@ -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>`;
Expand Down
Empty file.
Empty file.
80 changes: 74 additions & 6 deletions aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,7 +24,7 @@ describe('DocViewerComponent', () => {

beforeEach(() => {
TestBed.configureTestingModule({
imports: [TestModule, CustomElementsModule],
imports: [CustomElementsModule, TestModule],
});

parentFixture = TestBed.createComponent(TestParentComponent);
Expand Down Expand Up @@ -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));
});
Expand Down Expand Up @@ -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');
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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('');
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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 () => {
Expand Down
12 changes: 6 additions & 6 deletions aio/src/app/layout/doc-viewer/doc-viewer.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions aio/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand Down
3 changes: 0 additions & 3 deletions aio/src/polyfills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
7 changes: 7 additions & 0 deletions aio/src/testing/doc-viewer-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';


////////////////////////////////////////////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -54,6 +55,11 @@ export class MockTocService {
reset = jasmine.createSpy('TocService#reset');
}

export class MockElementsLoader {
loadContainingCustomElements =
jasmine.createSpy('MockElementsLoader#loadContainingCustomElements');
}

@NgModule({
declarations: [
DocViewerComponent,
Expand All @@ -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 { }
Expand Down
7 changes: 7 additions & 0 deletions packages/compiler/test/schema/schema_extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 87f60bc

Please sign in to comment.