diff --git a/CHANGELOG.md b/CHANGELOG.md index da950a7..02c9b66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/.gitignore b/docs/.gitignore deleted file mode 100644 index 084d0fd..0000000 --- a/docs/.gitignore +++ /dev/null @@ -1,14 +0,0 @@ -# See http://help.github.com/ignore-files/ for more about ignoring files. - -# Ruby -Gemfile.lock - -# Compiled output -/_site -/assets/build - -# Node -/node_modules -/package-lock.json -npm-debug.log -yarn-error.log diff --git a/projects/ngx-dynamic-hooks/package.json b/projects/ngx-dynamic-hooks/package.json index c1f8b20..797a1cb 100644 --- a/projects/ngx-dynamic-hooks/package.json +++ b/projects/ngx-dynamic-hooks/package.json @@ -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 ", "license": "MIT", diff --git a/projects/ngx-dynamic-hooks/src/lib/interfacesPublic.ts b/projects/ngx-dynamic-hooks/src/lib/interfacesPublic.ts index ee79db8..e64ad02 100644 --- a/projects/ngx-dynamic-hooks/src/lib/interfacesPublic.ts +++ b/projects/ngx-dynamic-hooks/src/lib/interfacesPublic.ts @@ -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; diff --git a/projects/ngx-dynamic-hooks/src/lib/services/core/componentCreator.ts b/projects/ngx-dynamic-hooks/src/lib/services/core/componentCreator.ts index 5433ebb..1c48c7b 100644 --- a/projects/ngx-dynamic-hooks/src/lib/services/core/componentCreator.ts +++ b/projects/ngx-dynamic-hooks/src/lib/services/core/componentCreator.ts @@ -277,25 +277,31 @@ export class ComponentCreator { loadComponentClass(componentConfig: ComponentConfig): ReplaySubject any> { const componentClassLoaded: ReplaySubject 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; diff --git a/projects/ngx-dynamic-hooks/src/tests/integration/componentLoading.spec.ts b/projects/ngx-dynamic-hooks/src/tests/integration/componentLoading.spec.ts index f6901ac..700f618 100644 --- a/projects/ngx-dynamic-hooks/src/tests/integration/componentLoading.spec.ts +++ b/projects/ngx-dynamic-hooks/src/tests/integration/componentLoading.spec.ts @@ -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'; @@ -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', () => { @@ -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 = ` -

- A couple of components: - [multitag-string] - [whatever-string][/whatever-string] - [/multitag-string] - [singletag-string] -

- `; - - 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 = ` +

+ A couple of components: + [multitag-string] + [whatever-string][/whatever-string] + [/multitag-string] + [singletag-string] +

+ `; + + 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', () => {