Skip to content

Commit

Permalink
Refactor: Added simpler way to configure lazily-loading components by…
Browse files Browse the repository at this point in the history
… using a function that return the component class directly
  • Loading branch information
MTobisch committed Sep 4, 2024
1 parent dcb0df9 commit fad47ed
Show file tree
Hide file tree
Showing 6 changed files with 144 additions and 133 deletions.
8 changes: 6 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

## [3.0.1] - 2023-03-15
## [3.0.2] - 2024-09-04
### Maintenance
- Fix: Added manual toggle in SelectorHookParserConfig to revert to regex-based parsing for selector hooks to improved backwards-compatibility
- Refactor: Added simpler way to configure lazily-loading components by using a function that return the component class directly

## [3.0.1] - 2024-08-30
### Maintenance
- Fix: Added manual toggle in SelectorHookParserConfig to revert to regex-based parsing for selector hooks to improve backwards-compatibility

## [3.0.0] - 2024-08-21
### Added
Expand Down
14 changes: 0 additions & 14 deletions docs/.gitignore

This file was deleted.

2 changes: 1 addition & 1 deletion projects/ngx-dynamic-hooks/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ngx-dynamic-hooks",
"version": "3.0.1",
"version": "3.0.2",
"description": "Automatically insert live Angular components into a dynamic string of content (based on their selector or any pattern of your choice) and render the result in the DOM.",
"person": "Marvin Tobisch <mvin@live.de>",
"license": "MIT",
Expand Down
17 changes: 11 additions & 6 deletions projects/ngx-dynamic-hooks/src/lib/interfacesPublic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,22 +137,27 @@ export interface HookComponentData {
/**
* A config object describing the component that is supposed to be loaded for this Hook
*
* Can be either the component class itself or a LazyLoadComponentConfig, if the component
* should be lazy-loaded (Ivy-feature)
* Can be either:
* - The component class itself
* - A function that returns a promise with the component class
* - An explicit LazyLoadComponentConfig
*/
export type ComponentConfig = (new(...args: any[]) => any) | LazyLoadComponentConfig;
export type ComponentConfig = (new(...args: any[]) => any) | (() => Promise<(new(...args: any[]) => any)>) | LazyLoadComponentConfig;

/**
* A config object for a component that is supposed to be lazy-loaded (Ivy-feature)
* An explicit config object for a component that is supposed to be lazy-loaded.
*
* importPromise has to be a function that returns the import promise for the component file (not the import promise itself!)
* importName has to be the name of the component class to be imported
* - importPromise has to be a function that returns the import promise for the component file (not the import promise itself!)
* - importName has to be the name of the component class to be imported
*
* Example:
* {
* importPromise: () => import('./someComponent/someComponent.c'),
* importName: 'SomeComponent'
* }
*
* Note: This mostly exists for backwards-compatibility. Lazy-loading components is easier accomplished by using a function
* that returns a promise with the component class in the component field of HookComponentData
*/
export interface LazyLoadComponentConfig {
importPromise: () => Promise<any>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,25 +277,31 @@ export class ComponentCreator {
loadComponentClass(componentConfig: ComponentConfig): ReplaySubject<new(...args: any[]) => any> {
const componentClassLoaded: ReplaySubject<new(...args: any[]) => any> = new ReplaySubject(1);

// a) If is normal class
// a) If is component class
if (componentConfig.hasOwnProperty('prototype')) {
componentClassLoaded.next(componentConfig as (new(...args: any[]) => any));

// b) If is LazyLoadComponentConfig
// c) If is function that returns promise with component class
} else if (typeof componentConfig === 'function') {
(componentConfig as (() => Promise<(new(...args: any[]) => any)>))().then(compClass => {
componentClassLoaded.next(compClass);
})

// c) If is LazyLoadComponentConfig
} else if (componentConfig.hasOwnProperty('importPromise') && componentConfig.hasOwnProperty('importName')) {
// Catch typical importPromise error
if ((componentConfig as LazyLoadComponentConfig).importPromise instanceof Promise) {
throw Error(`When lazy-loading a component, the "importPromise"-field must contain a function returning the import-promise, but it contained the promise itself.`);
}

(componentConfig as LazyLoadComponentConfig).importPromise().then((m) => {
(componentConfig as LazyLoadComponentConfig).importPromise().then((m) => {
const importName = (componentConfig as LazyLoadComponentConfig).importName;
const compClass = Object.prototype.hasOwnProperty.call(m, importName) ? m[importName] : m['default'];
componentClassLoaded.next(compClass);
});

} else {
throw Error('The "component" property of a returned HookData object must either contain the component class or a LazyLoadComponentConfig');
throw Error('The "component" property of a returned HookData object must either contain the component class, a function that returns a promise with the component class or an explicit LazyLoadComponentConfig');
}

return componentClassLoaded;
Expand Down
222 changes: 116 additions & 106 deletions projects/ngx-dynamic-hooks/src/tests/integration/componentLoading.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ComponentFixtureAutoDetect, TestBed, fakeAsync, tick } from '@angular/c
import { first } from 'rxjs/operators';

// Testing api resources
import { DynamicHooksComponent, LoadedComponent, anchorAttrHookId, anchorAttrParseToken, anchorElementTag, provideDynamicHooks } from '../testing-api';
import { ComponentConfig, DynamicHooksComponent, LoadedComponent, anchorAttrHookId, anchorAttrParseToken, anchorElementTag, provideDynamicHooks } from '../testing-api';

// Custom testing resources
import { defaultBeforeEach, prepareTestingModule, testParsers } from './shared';
Expand Down Expand Up @@ -45,7 +45,7 @@ describe('Component loading', () => {
it('#should ensure the passed componentConfig is correct', () => {
// Load with nonsensical componentConfig
expect(() => comp['dynamicHooksService']['componentCreator'].loadComponentClass(true as any))
.toThrow(new Error('The "component" property of a returned HookData object must either contain the component class or a LazyLoadComponentConfig'));
.toThrow(new Error('The "component" property of a returned HookData object must either contain the component class, a function that returns a promise with the component class or an explicit LazyLoadComponentConfig'));
});

it('#should be able to load module components', () => {
Expand Down Expand Up @@ -531,122 +531,132 @@ describe('Component loading', () => {
});

it('#should lazy-load components', fakeAsync(() => {
const genericMultiTagParser = TestBed.inject(GenericMultiTagStringParser);
genericMultiTagParser.onGetBindings = (hookId, hookValue, context) => {
return {
inputs: {
numberProp: 4

const testLazyLoading = (lazyCompConfig: ComponentConfig) => {

const genericMultiTagParser = TestBed.inject(GenericMultiTagStringParser);
genericMultiTagParser.onGetBindings = (hookId, hookValue, context) => {
return {
inputs: {
numberProp: 4
}
}
}
}

// Whatever parsers lazy-loads a component for this test
const genericWhateverParser = TestBed.inject(GenericWhateverStringParser);
genericWhateverParser.component = {
// Simulate that loading this component takes 100ms
importPromise: () => new Promise(resolve => setTimeout(() => {
resolve({LazyTestComponent: LazyTestComponent})
}, 100)),
importName: 'LazyTestComponent'
};
genericWhateverParser.onGetBindings = (hookId, hookValue, context) => {
return {
inputs: {
name: 'sleepy'
const genericSingleTagParser = TestBed.inject(GenericSingleTagStringParser);
genericSingleTagParser.onGetBindings = (hookId, hookValue, context) => {
return {
inputs: {
numberProp: 87
}
}
}
}

const genericSingleTagParser = TestBed.inject(GenericSingleTagStringParser);
genericSingleTagParser.onGetBindings = (hookId, hookValue, context) => {
return {
inputs: {
numberProp: 87
// Whatever parsers lazy-loads a component for this test
const genericWhateverParser = TestBed.inject(GenericWhateverStringParser);
genericWhateverParser.component = lazyCompConfig;
genericWhateverParser.onGetBindings = (hookId, hookValue, context) => {
return {
inputs: {
name: 'sleepy'
}
}
}
}

const testText = `
<p>
A couple of components:
[multitag-string]
[whatever-string][/whatever-string]
[/multitag-string]
[singletag-string]
</p>
`;

comp.content = testText;
comp.context = context;
let loadedComponents: LoadedComponent[] = [];
comp.componentsLoaded.pipe(first()).subscribe((lc: any) => loadedComponents = lc);
comp.ngOnChanges({content: true, context: true} as any);

// Everything except the lazy-loaded component should be loaded
expect(fixture.nativeElement.querySelector('.multitag-component')).not.toBe(null);
expect(fixture.nativeElement.querySelector('.lazy-component')).toBe(null);
expect(fixture.nativeElement.querySelector('.singletag-component')).not.toBe(null);

expect(Object.values(comp.hookIndex).length).toBe(3);
expect(comp.hookIndex[1].componentRef!.instance.constructor.name).toBe('MultiTagTestComponent');
expect(comp.hookIndex[2].componentRef).toBeNull();
expect(comp.hookIndex[3].componentRef!.instance.constructor.name).toBe('SingleTagTestComponent');

// Make sure that onDynamicChanges has triggered on component init
spyOn(comp.hookIndex[1].componentRef!.instance, 'onDynamicChanges').and.callThrough();
expect(comp.hookIndex[1].componentRef!.instance.onDynamicChanges['calls'].count()).toBe(0);
expect(comp.hookIndex[1].componentRef!.instance.changesContext).toEqual(context);
expect(comp.hookIndex[1].componentRef!.instance.changesContentChildren).toBeUndefined();

// Make sure that onDynamicMount has not yet triggered
spyOn(comp.hookIndex[1].componentRef!.instance, 'onDynamicMount').and.callThrough();
expect(comp.hookIndex[1].componentRef!.instance.onDynamicMount['calls'].count()).toBe(0);
expect(comp.hookIndex[1].componentRef!.instance.mountContext).toBeUndefined();
expect(comp.hookIndex[1].componentRef!.instance.mountContentChildren).toBeUndefined();

// Also, componentsLoaded should not yet have triggered
expect(loadedComponents).toEqual([]);

// Wait for imports via fakeAsync()'s tick() that synchronously advances time for testing
// This didn't always work. Used to have to manually wait by using (done) => {} as the testing wrapper function isntead of faceAsync,
// then wait via setTimeout() and call done() when testing is finished. This had the disadvantage of actually having to wait for the timeout
tick(500);

// Lazy-loaded component should be loaded by now in anchor
expect(fixture.nativeElement.querySelector('.lazy-component')).not.toBe(null);
expect(fixture.nativeElement.querySelector('.lazy-component').parentElement.tagName).toBe(anchorElementTag.toUpperCase());
expect(comp.hookIndex[2].componentRef!.instance.constructor.name).toBe('LazyTestComponent');
expect(comp.hookIndex[2].componentRef!.instance.name).toBe('sleepy');

// Make sure that onDynamicChanges has triggered again (with contentChildren)
expect(comp.hookIndex[1].componentRef!.instance.onDynamicChanges['calls'].count()).toBe(1);
expect(comp.hookIndex[1].componentRef!.instance.changesContext).toEqual(context);
expect(comp.hookIndex[1].componentRef!.instance.changesContentChildren.length).toBe(1);
expect(comp.hookIndex[1].componentRef!.instance.changesContentChildren[0].componentRef.location.nativeElement.tagName).toBe(anchorElementTag.toUpperCase());

// Make sure that onDynamicMount has triggered
expect(comp.hookIndex[1].componentRef!.instance.onDynamicMount['calls'].count()).toBe(1);
expect(comp.hookIndex[1].componentRef!.instance.mountContext).toEqual(context);
expect(comp.hookIndex[1].componentRef!.instance.mountContentChildren.length).toBe(1);
expect(comp.hookIndex[1].componentRef!.instance.mountContentChildren[0].componentRef.location.nativeElement.tagName).toBe(anchorElementTag.toUpperCase());

// ComponentsLoaded should have emitted now and contain the lazy-loaded component
expect(loadedComponents.length).toBe(3);
const testText = `
<p>
A couple of components:
[multitag-string]
[whatever-string][/whatever-string]
[/multitag-string]
[singletag-string]
</p>
`;

comp.content = testText;
comp.context = context;
let loadedComponents: LoadedComponent[] = [];
comp.componentsLoaded.pipe(first()).subscribe((lc: any) => loadedComponents = lc);
comp.ngOnChanges({content: true, context: true} as any);

// Everything except the lazy-loaded component should be loaded
expect(fixture.nativeElement.querySelector('.multitag-component')).not.toBe(null);
expect(fixture.nativeElement.querySelector('.lazy-component')).toBe(null);
expect(fixture.nativeElement.querySelector('.singletag-component')).not.toBe(null);

expect(Object.values(comp.hookIndex).length).toBe(3);
expect(comp.hookIndex[1].componentRef!.instance.constructor.name).toBe('MultiTagTestComponent');
expect(comp.hookIndex[2].componentRef).toBeNull();
expect(comp.hookIndex[3].componentRef!.instance.constructor.name).toBe('SingleTagTestComponent');

// Make sure that onDynamicChanges has triggered on component init
spyOn(comp.hookIndex[1].componentRef!.instance, 'onDynamicChanges').and.callThrough();
expect(comp.hookIndex[1].componentRef!.instance.onDynamicChanges['calls'].count()).toBe(0);
expect(comp.hookIndex[1].componentRef!.instance.changesContext).toEqual(context);
expect(comp.hookIndex[1].componentRef!.instance.changesContentChildren).toBeUndefined();

// Make sure that onDynamicMount has not yet triggered
spyOn(comp.hookIndex[1].componentRef!.instance, 'onDynamicMount').and.callThrough();
expect(comp.hookIndex[1].componentRef!.instance.onDynamicMount['calls'].count()).toBe(0);
expect(comp.hookIndex[1].componentRef!.instance.mountContext).toBeUndefined();
expect(comp.hookIndex[1].componentRef!.instance.mountContentChildren).toBeUndefined();

// Also, componentsLoaded should not yet have triggered
expect(loadedComponents).toEqual([]);

// Wait for imports via fakeAsync()'s tick() that synchronously advances time for testing
// This didn't always work. Used to have to manually wait by using (done) => {} as the testing wrapper function isntead of faceAsync,
// then wait via setTimeout() and call done() when testing is finished. This had the disadvantage of actually having to wait for the timeout
tick(500);

// Lazy-loaded component should be loaded by now in anchor
expect(fixture.nativeElement.querySelector('.lazy-component')).not.toBe(null);
expect(fixture.nativeElement.querySelector('.lazy-component').parentElement.tagName).toBe(anchorElementTag.toUpperCase());
expect(comp.hookIndex[2].componentRef!.instance.constructor.name).toBe('LazyTestComponent');
expect(comp.hookIndex[2].componentRef!.instance.name).toBe('sleepy');

// Make sure that onDynamicChanges has triggered again (with contentChildren)
expect(comp.hookIndex[1].componentRef!.instance.onDynamicChanges['calls'].count()).toBe(1);
expect(comp.hookIndex[1].componentRef!.instance.changesContext).toEqual(context);
expect(comp.hookIndex[1].componentRef!.instance.changesContentChildren.length).toBe(1);
expect(comp.hookIndex[1].componentRef!.instance.changesContentChildren[0].componentRef.location.nativeElement.tagName).toBe(anchorElementTag.toUpperCase());

// Make sure that onDynamicMount has triggered
expect(comp.hookIndex[1].componentRef!.instance.onDynamicMount['calls'].count()).toBe(1);
expect(comp.hookIndex[1].componentRef!.instance.mountContext).toEqual(context);
expect(comp.hookIndex[1].componentRef!.instance.mountContentChildren.length).toBe(1);
expect(comp.hookIndex[1].componentRef!.instance.mountContentChildren[0].componentRef.location.nativeElement.tagName).toBe(anchorElementTag.toUpperCase());

// ComponentsLoaded should have emitted now and contain the lazy-loaded component
expect(loadedComponents.length).toBe(3);

expect(loadedComponents[0].hookId).toBe(1);
expect(loadedComponents[0].hookValue).toEqual({openingTag: `[multitag-string]`, closingTag: `[/multitag-string]`, element: null, elementSnapshot: null});
expect(loadedComponents[0].hookParser).toBeDefined();
expect(loadedComponents[0].componentRef.instance.numberProp).toBe(4);

expect(loadedComponents[1].hookId).toBe(2);
expect(loadedComponents[1].hookValue).toEqual({openingTag: `[whatever-string]`, closingTag: `[/whatever-string]`, element: null, elementSnapshot: null});
expect(loadedComponents[1].hookParser).toBeDefined();
expect(loadedComponents[1].componentRef.instance.name).toBe('sleepy');

expect(loadedComponents[2].hookId).toBe(3);
expect(loadedComponents[2].hookValue).toEqual({openingTag: `[singletag-string]`, closingTag: null, element: null, elementSnapshot: null});
expect(loadedComponents[2].hookParser).toBeDefined();
expect(loadedComponents[2].componentRef.instance.numberProp).toBe(87);
}

expect(loadedComponents[0].hookId).toBe(1);
expect(loadedComponents[0].hookValue).toEqual({openingTag: `[multitag-string]`, closingTag: `[/multitag-string]`, element: null, elementSnapshot: null});
expect(loadedComponents[0].hookParser).toBeDefined();
expect(loadedComponents[0].componentRef.instance.numberProp).toBe(4);
// Test with function that returns promise with component class directly
testLazyLoading(() =>
new Promise(resolve => setTimeout(() => resolve({LazyTestComponent: LazyTestComponent}), 100))
.then((m: any) => m.LazyTestComponent))

expect(loadedComponents[1].hookId).toBe(2);
expect(loadedComponents[1].hookValue).toEqual({openingTag: `[whatever-string]`, closingTag: `[/whatever-string]`, element: null, elementSnapshot: null});
expect(loadedComponents[1].hookParser).toBeDefined();
expect(loadedComponents[1].componentRef.instance.name).toBe('sleepy');
// Test with explicit LazyLoadComponentConfig
testLazyLoading({
importPromise: () => new Promise(resolve => setTimeout(() => resolve({LazyTestComponent: LazyTestComponent}), 100)),
importName: 'LazyTestComponent'
});

expect(loadedComponents[2].hookId).toBe(3);
expect(loadedComponents[2].hookValue).toEqual({openingTag: `[singletag-string]`, closingTag: null, element: null, elementSnapshot: null});
expect(loadedComponents[2].hookParser).toBeDefined();
expect(loadedComponents[2].componentRef.instance.numberProp).toBe(87);
}));

it('#should check that the "importPromise"-field of lazy-loaded parsers is not the promise itself', () => {
Expand Down

0 comments on commit fad47ed

Please sign in to comment.