fix(aio): do not load custom elements again while already loading (#23944)

PR Close #23944
This commit is contained in:
George Kalpakas 2018-05-15 19:41:13 +03:00 committed by Matias Niemelä
parent a6f34be9f5
commit ae86cb3be0
3 changed files with 251 additions and 97 deletions

View File

@ -3,7 +3,7 @@
"master": {
"uncompressed": {
"runtime": 2712,
"main": 479417,
"main": 479729,
"polyfills": 38453,
"prettify": 14913
}

View File

@ -4,49 +4,19 @@ import {
NgModuleRef,
Type
} from '@angular/core';
import {TestBed, fakeAsync, tick} from '@angular/core/testing';
import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
import { ElementsLoader } from './elements-loader';
import { ELEMENT_MODULE_PATHS_TOKEN, WithCustomElementComponent } from './element-registry';
class FakeComponentFactory extends ComponentFactory<any> {
selector: string;
componentType: Type<any>;
ngContentSelectors: string[];
inputs = [{propName: this.identifyingInput, templateName: this.identifyingInput}];
outputs = [];
constructor(private identifyingInput: string) { super(); }
create(injector: Injector,
projectableNodes?: any[][],
rootSelectorOrNode?: string | any,
ngModule?: NgModuleRef<any>): ComponentRef<any> {
return (jasmine.createSpy('ComponentRef') as any) as ComponentRef<any>;
};
interface Deferred {
resolve(): void;
reject(err: any): void;
}
const FAKE_COMPONENT_FACTORIES = new Map([
['element-a-module-path', new FakeComponentFactory('element-a-input')],
['element-b-module-path', new FakeComponentFactory('element-b-input')],
]);
describe('ElementsLoader', () => {
let elementsLoader: ElementsLoader;
let actualCustomElementsDefine;
let fakeCustomElementsDefine;
// ElementsLoader uses the window's customElements API. Provide a fake for this test.
beforeEach(() => {
actualCustomElementsDefine = window.customElements.define;
fakeCustomElementsDefine = jasmine.createSpy('define');
window.customElements.define = fakeCustomElementsDefine;
});
afterEach(() => {
window.customElements.define = actualCustomElementsDefine;
});
beforeEach(() => {
const injector = TestBed.configureTestingModule({
@ -63,63 +33,196 @@ describe('ElementsLoader', () => {
elementsLoader = injector.get(ElementsLoader);
});
it('should be able to register an element', fakeAsync(() => {
// Verify that the elements loader considered `element-a-selector` to be unregistered.
expect(elementsLoader.elementsToLoad.has('element-a-selector')).toBeTruthy();
describe('loadContainingCustomElements()', () => {
let loadCustomElementSpy: jasmine.Spy;
const hostEl = document.createElement('div');
hostEl.innerHTML = `<element-a-selector></element-a-selector>`;
beforeEach(() => loadCustomElementSpy = spyOn(elementsLoader, 'loadCustomElement'));
elementsLoader.loadContainingCustomElements(hostEl);
tick();
it('should attempt to load and register all contained elements', fakeAsync(() => {
expect(loadCustomElementSpy).not.toHaveBeenCalled();
const defineArgs = fakeCustomElementsDefine.calls.argsFor(0);
expect(defineArgs[0]).toBe('element-a-selector');
const hostEl = document.createElement('div');
hostEl.innerHTML = `
<element-a-selector></element-a-selector>
<element-b-selector></element-b-selector>
`;
// Verify the right component was loaded/created
expect(defineArgs[1].observedAttributes[0]).toBe('element-a-input');
elementsLoader.loadContainingCustomElements(hostEl);
flushMicrotasks();
expect(elementsLoader.elementsToLoad.has('element-a-selector')).toBeFalsy();
}));
expect(loadCustomElementSpy).toHaveBeenCalledTimes(2);
expect(loadCustomElementSpy).toHaveBeenCalledWith('element-a-selector');
expect(loadCustomElementSpy).toHaveBeenCalledWith('element-b-selector');
}));
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();
it('should attempt to load and register only contained elements', fakeAsync(() => {
expect(loadCustomElementSpy).not.toHaveBeenCalled();
const hostEl = document.createElement('div');
hostEl.innerHTML = `
<element-a-selector></element-a-selector>
<element-b-selector></element-b-selector>
`;
const hostEl = document.createElement('div');
hostEl.innerHTML = `
<element-b-selector></element-b-selector>
`;
elementsLoader.loadContainingCustomElements(hostEl);
tick();
elementsLoader.loadContainingCustomElements(hostEl);
flushMicrotasks();
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();
expect(loadCustomElementSpy).toHaveBeenCalledTimes(1);
expect(loadCustomElementSpy).toHaveBeenCalledWith('element-b-selector');
}));
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 wait for all contained elements to load and register', fakeAsync(() => {
const deferreds = returnPromisesFromSpy(loadCustomElementSpy);
it('should only register an element one time', fakeAsync(() => {
const hostEl = document.createElement('div');
hostEl.innerHTML = `<element-a-selector></element-a-selector>`;
const hostEl = document.createElement('div');
hostEl.innerHTML = `
<element-a-selector></element-a-selector>
<element-b-selector></element-b-selector>
`;
elementsLoader.loadContainingCustomElements(hostEl);
tick(); // Tick for the module factory loader's async `load` function
const log: any[] = [];
elementsLoader.loadContainingCustomElements(hostEl).subscribe(
v => log.push(`emitted: ${v}`),
e => log.push(`errored: ${e}`),
() => log.push('completed'),
);
// Call again to to check how many times customElements.define was called.
elementsLoader.loadContainingCustomElements(hostEl);
tick(); // Tick for the module factory loader's async `load` function
flushMicrotasks();
expect(log).toEqual([]);
// Should have only been called once, since the second load would not query for element-a
expect(window.customElements.define).toHaveBeenCalledTimes(1);
}));
deferreds[0].resolve();
flushMicrotasks();
expect(log).toEqual([]);
deferreds[1].resolve();
flushMicrotasks();
expect(log).toEqual(['emitted: undefined', 'completed']);
}));
it('should fail if any of the contained elements fails to load and register', fakeAsync(() => {
const deferreds = returnPromisesFromSpy(loadCustomElementSpy);
const hostEl = document.createElement('div');
hostEl.innerHTML = `
<element-a-selector></element-a-selector>
<element-b-selector></element-b-selector>
`;
const log: any[] = [];
elementsLoader.loadContainingCustomElements(hostEl).subscribe(
v => log.push(`emitted: ${v}`),
e => log.push(`errored: ${e}`),
() => log.push('completed'),
);
flushMicrotasks();
expect(log).toEqual([]);
deferreds[0].resolve();
flushMicrotasks();
expect(log).toEqual([]);
deferreds[1].reject('foo');
flushMicrotasks();
expect(log).toEqual(['errored: foo']);
}));
});
describe('loadCustomElement()', () => {
let definedSpy: jasmine.Spy;
let whenDefinedSpy: jasmine.Spy;
let whenDefinedDeferreds: Deferred[];
beforeEach(() => {
// `loadCustomElement()` uses the `window.customElements` API. Provide mocks for these tests.
definedSpy = spyOn(window.customElements, 'define');
whenDefinedSpy = spyOn(window.customElements, 'whenDefined');
whenDefinedDeferreds = returnPromisesFromSpy(whenDefinedSpy);
});
it('should be able to load and register an element', fakeAsync(() => {
elementsLoader.loadCustomElement('element-a-selector');
flushMicrotasks();
expect(definedSpy).toHaveBeenCalledTimes(1);
expect(definedSpy).toHaveBeenCalledWith('element-a-selector', jasmine.any(Function));
// Verify the right component was loaded/registered.
const Ctor = definedSpy.calls.argsFor(0)[1];
expect(Ctor.observedAttributes).toEqual(['element-a-module-path']);
}));
it('should wait until the element is defined', fakeAsync(() => {
let state = 'pending';
elementsLoader.loadCustomElement('element-b-selector').then(() => state = 'resolved');
flushMicrotasks();
expect(state).toBe('pending');
expect(whenDefinedSpy).toHaveBeenCalledTimes(1);
expect(whenDefinedSpy).toHaveBeenCalledWith('element-b-selector');
whenDefinedDeferreds[0].resolve();
flushMicrotasks();
expect(state).toBe('resolved');
}));
it('should not load and register the same element more than once', fakeAsync(() => {
elementsLoader.loadCustomElement('element-a-selector');
flushMicrotasks();
expect(definedSpy).toHaveBeenCalledTimes(1);
definedSpy.calls.reset();
// While loading/registering is still in progress:
elementsLoader.loadCustomElement('element-a-selector');
flushMicrotasks();
expect(definedSpy).not.toHaveBeenCalled();
definedSpy.calls.reset();
whenDefinedDeferreds[0].resolve();
// Once loading/registering is already completed:
let state = 'pending';
elementsLoader.loadCustomElement('element-a-selector').then(() => state = 'resolved');
flushMicrotasks();
expect(state).toBe('resolved');
expect(definedSpy).not.toHaveBeenCalled();
}));
it('should fail if defining the the custom element fails', fakeAsync(() => {
let state = 'pending';
elementsLoader.loadCustomElement('element-b-selector').catch(e => state = `rejected: ${e}`);
flushMicrotasks();
expect(state).toBe('pending');
whenDefinedDeferreds[0].reject('foo');
flushMicrotasks();
expect(state).toBe('rejected: foo');
}));
it('should be able to load and register an element again if previous attempt failed',
fakeAsync(() => {
elementsLoader.loadCustomElement('element-a-selector');
flushMicrotasks();
expect(definedSpy).toHaveBeenCalledTimes(1);
definedSpy.calls.reset();
// While loading/registering is still in progress:
elementsLoader.loadCustomElement('element-a-selector').catch(() => undefined);
flushMicrotasks();
expect(definedSpy).not.toHaveBeenCalled();
whenDefinedDeferreds[0].reject('foo');
flushMicrotasks();
expect(definedSpy).not.toHaveBeenCalled();
// Once loading/registering has already failed:
elementsLoader.loadCustomElement('element-a-selector');
flushMicrotasks();
expect(definedSpy).toHaveBeenCalledTimes(1);
})
);
});
});
// TEST CLASSES/HELPERS
@ -128,11 +231,28 @@ class FakeCustomElementModule implements WithCustomElementComponent {
customElementComponent: Type<any>;
}
class FakeComponentFactory extends ComponentFactory<any> {
selector: string;
componentType: Type<any>;
ngContentSelectors: string[];
inputs = [{propName: this.identifyingInput, templateName: this.identifyingInput}];
outputs = [];
constructor(private identifyingInput: string) { super(); }
create(injector: Injector,
projectableNodes?: any[][],
rootSelectorOrNode?: string | any,
ngModule?: NgModuleRef<any>): ComponentRef<any> {
return jasmine.createSpy('ComponentRef') as any;
};
}
class FakeComponentFactoryResolver extends ComponentFactoryResolver {
constructor(private modulePath) { super(); }
resolveComponentFactory(component: Type<any>): ComponentFactory<any> {
return FAKE_COMPONENT_FACTORIES.get(this.modulePath)!;
return new FakeComponentFactory(this.modulePath);
}
}
@ -168,3 +288,9 @@ class FakeModuleFactoryLoader extends NgModuleFactoryLoader {
return Promise.resolve(fakeModuleFactory);
}
}
function returnPromisesFromSpy(spy: jasmine.Spy): Deferred[] {
const deferreds: Deferred[] = [];
spy.and.callFake(() => new Promise((resolve, reject) => deferreds.push({resolve, reject})));
return deferreds;
}

View File

@ -11,11 +11,13 @@ import { createCustomElement } from '@angular/elements';
@Injectable()
export class ElementsLoader {
/** Map of unregistered custom elements and their respective module paths to load. */
elementsToLoad: Map<string, string>;
private elementsToLoad: Map<string, string>;
/** Map of custom elements that are in the process of being loaded and registered. */
private elementsLoading = new Map<string, Promise<void>>();
constructor(private moduleFactoryLoader: NgModuleFactoryLoader,
private moduleRef: NgModuleRef<any>,
@Inject(ELEMENT_MODULE_PATHS_TOKEN) elementModulePaths) {
@Inject(ELEMENT_MODULE_PATHS_TOKEN) elementModulePaths: Map<string, string>) {
this.elementsToLoad = new Map(elementModulePaths);
}
@ -25,30 +27,56 @@ export class ElementsLoader {
* elements so that they will not be queried in subsequent calls.
*/
loadContainingCustomElements(element: HTMLElement): Observable<void> {
const selectors: any[] = Array.from(this.elementsToLoad.keys())
const unregisteredSelectors = Array.from(this.elementsToLoad.keys())
.filter(s => element.querySelector(s));
if (!selectors.length) { return of(undefined); }
if (!unregisteredSelectors.length) { return of(undefined); }
// Returns observable that completes when all discovered elements have been registered.
return fromPromise(Promise.all(selectors.map(s => this.register(s))).then(result => undefined));
const allRegistered = Promise.all(unregisteredSelectors.map(s => this.loadCustomElement(s)));
return fromPromise(allRegistered.then(() => undefined));
}
/** Registers the custom element defined on the WithCustomElement module factory. */
private register(selector: string) {
const modulePath = this.elementsToLoad.get(selector)!;
return this.moduleFactoryLoader.load(modulePath).then(elementModuleFactory => {
if (!this.elementsToLoad.has(selector)) { return; }
/** Loads and registers the custom element defined on the `WithCustomElement` module factory. */
loadCustomElement(selector: string): Promise<void> {
if (this.elementsLoading.has(selector)) {
// The custom element is in the process of being loaded and registered.
return this.elementsLoading.get(selector)!;
}
const elementModuleRef = elementModuleFactory.create(this.moduleRef.injector);
const CustomElementComponent = elementModuleRef.instance.customElementComponent;
const CustomElement =
createCustomElement(CustomElementComponent, {injector: elementModuleRef.injector});
if (this.elementsToLoad.has(selector)) {
// Load and register the custom element (for the first time).
const modulePath = this.elementsToLoad.get(selector)!;
const loadedAndRegistered = this.moduleFactoryLoader
.load(modulePath)
.then(elementModuleFactory => {
const elementModuleRef = elementModuleFactory.create(this.moduleRef.injector);
const injector = elementModuleRef.injector;
const CustomElementComponent = elementModuleRef.instance.customElementComponent;
const CustomElement = createCustomElement(CustomElementComponent, {injector});
customElements!.define(selector, CustomElement);
this.elementsToLoad.delete(selector);
customElements!.define(selector, CustomElement);
return customElements.whenDefined(selector);
})
.then(() => {
// The custom element has been successfully loaded and registered.
// Remove from `elementsLoading` and `elementsToLoad`.
this.elementsLoading.delete(selector);
this.elementsToLoad.delete(selector);
})
.catch(err => {
// The custom element has failed to load and register.
// Remove from `elementsLoading`.
// (Do not remove from `elementsToLoad` in case it was a temporary error.)
this.elementsLoading.delete(selector);
return Promise.reject(err);
});
return customElements.whenDefined(selector);
});
this.elementsLoading.set(selector, loadedAndRegistered);
return loadedAndRegistered;
}
// The custom element has already been loaded and registered.
return Promise.resolve();
}
}