diff --git a/CHANGELOG.md b/CHANGELOG.md index 824db62..e818a0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +## 2.2.0 + +### New features + +- Add asynchronous `beforeRender` hook ([#20](https://github.com/costrojs/costro/pull/20)) + ## 2.1.2 ### Fixes diff --git a/package-lock.json b/package-lock.json index 2be5363..14927f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "costro", - "version": "2.1.2", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "costro", - "version": "2.1.2", + "version": "2.2.0", "license": "MIT", "devDependencies": { "@babel/core": "^7.21.8", diff --git a/package.json b/package.json index 2b18703..85627cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "costro", - "version": "2.1.2", + "version": "2.2.0", "description": "Build web applications with Components, Store and Router in 3KB", "keywords": [ "costro", diff --git a/src/app.ts b/src/app.ts index b17094b..53ba082 100644 --- a/src/app.ts +++ b/src/app.ts @@ -256,19 +256,26 @@ export default class App { this.initComponentInCache() } - let componentView = this.getComponentView() - if (componentView) { - if (!this.currentRoute.interfaceType) { - this.currentRoute.interfaceType = this.getInterfaceTypeFromView(componentView) - this.routes.set(this.currentRoute.path, this.currentRoute) - } + this.getComponentView() + .then((componentView) => { + if (componentView && this.currentRoute) { + if (!this.currentRoute.interfaceType) { + this.currentRoute.interfaceType = + this.getInterfaceTypeFromView(componentView) + this.routes.set(this.currentRoute.path, this.currentRoute) + } - if (this.currentRoute.interfaceType === 'STRING') { - componentView = this.transformLinksInStringComponent(componentView) - } - this.target.appendChild(componentView) - this.currentRoute.isComponentClass && this.currentRoute.component.afterRender() - } + if (this.currentRoute.interfaceType === 'STRING') { + componentView = this.transformLinksInStringComponent(componentView) + } + this.target.appendChild(componentView) + this.currentRoute.isComponentClass && + this.currentRoute.component.afterRender() + } + }) + .catch((error) => { + console.warn('getComponentView::promise rejected', error) + }) } } @@ -305,15 +312,39 @@ export default class App { if (this.currentRoute) { if (this.currentRoute.isComponentClass) { this.updateComponentRouteData() - this.currentRoute.component.beforeRender() - return this.currentRoute.component.render() - } else { - return this.currentRoute.component.call( + const beforeRenderFn = this.currentRoute.component.beforeRender() + + if (beforeRenderFn instanceof Promise) { + return this.runRenderWhenReady(this.currentRoute, beforeRenderFn) + } + + return Promise.resolve(this.currentRoute.component.render()) + } + + return Promise.resolve( + this.currentRoute.component.call( this.currentRoute.component, this.currentRoute.props ) - } + ) } + + return Promise.reject(new Error('getComponentView::promise not resolved')) + } + + /** + * Run render function when asynchronous before render is resolved + * @param currentRoute Current route + * @param beforeRenderFn Before render promise + * @returns The render content + */ + runRenderWhenReady(currentRoute: RouteData, beforeRenderFn: Promise) { + return Promise.resolve(beforeRenderFn).then(() => { + // Check is route has changed before the promise resolution + if (this.currentRoute && this.currentRoute.path === currentRoute.path) { + return currentRoute.component.render() + } + }) } /** diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index 6b86beb..2056691 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -687,7 +687,7 @@ describe('App', () => { app = getInstance() }) - it('should call the createComponent function with a component and HTMLElement', () => { + it('should call the createComponent function with a component and HTMLElement', async () => { app.currentRoute = { component: { afterRender: jest.fn() @@ -698,12 +698,12 @@ describe('App', () => { } app.initComponentInCache = jest.fn() - app.getComponentView = jest.fn().mockReturnValue(
Component
) + app.getComponentView = jest.fn().mockResolvedValue(
Component
) app.getInterfaceTypeFromView = jest.fn().mockReturnValue('ELEMENT_NODE') app.transformLinksInStringComponent = jest.fn() app.target.appendChild = jest.fn() - app.createComponent() + await app.createComponent() expect(app.initComponentInCache).toHaveBeenCalled() expect(app.getComponentView).toHaveBeenCalled() @@ -714,7 +714,7 @@ describe('App', () => { expect(app.currentRoute.component.afterRender).toHaveBeenCalled() }) - it('should call the createComponent function with a component and String', () => { + it('should call the createComponent function with a component and String', async () => { app.currentRoute = { component: { afterRender: jest.fn() @@ -727,12 +727,12 @@ describe('App', () => { app.initComponentInCache = jest.fn() app.getComponentView = jest .fn() - .mockReturnValue('
Link
') + .mockResolvedValue('
Link
') app.getInterfaceTypeFromView = jest.fn().mockReturnValue('STRING') app.transformLinksInStringComponent = jest.fn().mockReturnValue(
) app.target.appendChild = jest.fn() - app.createComponent() + await app.createComponent() expect(app.initComponentInCache).toHaveBeenCalled() expect(app.getComponentView).toHaveBeenCalled() @@ -744,6 +744,62 @@ describe('App', () => { expect(app.target.appendChild).toHaveBeenCalledWith(
) expect(app.currentRoute.component.afterRender).toHaveBeenCalled() }) + + it('should call the createComponent function with a promise returning undefined (route has changed)', async () => { + app.currentRoute = { + component: { + afterRender: jest.fn() + }, + interfaceType: null, + isComponentClass: true, + isComponentClassReady: false + } + + app.initComponentInCache = jest.fn() + app.getComponentView = jest.fn().mockResolvedValue(undefined) + app.getInterfaceTypeFromView = jest.fn() + app.transformLinksInStringComponent = jest.fn() + app.target.appendChild = jest.fn() + console.warn = jest.fn() + + await app.createComponent() + + expect(app.initComponentInCache).toHaveBeenCalled() + expect(app.getComponentView).toHaveBeenCalled() + expect(app.getInterfaceTypeFromView).not.toHaveBeenCalled() + expect(app.transformLinksInStringComponent).not.toHaveBeenCalled() + expect(app.target.appendChild).not.toHaveBeenCalled() + expect(app.currentRoute.component.afterRender).not.toHaveBeenCalled() + }) + + it('should call the createComponent function with a promise rejected', async () => { + app.currentRoute = { + component: { + afterRender: jest.fn() + }, + interfaceType: null, + isComponentClass: true, + isComponentClassReady: false + } + + app.initComponentInCache = jest.fn() + app.getComponentView = jest.fn().mockRejectedValue(new Error('Promise rejection error')) + + app.getInterfaceTypeFromView = jest.fn() + app.transformLinksInStringComponent = jest.fn() + app.target.appendChild = jest.fn() + app.test = jest.fn() + console.warn = jest.fn() + + await app.createComponent() + + expect(app.initComponentInCache).toHaveBeenCalled() + expect(app.getComponentView).toHaveBeenCalled() + expect(app.getInterfaceTypeFromView).not.toHaveBeenCalled() + expect(app.transformLinksInStringComponent).not.toHaveBeenCalled() + expect(app.target.appendChild).not.toHaveBeenCalled() + expect(app.currentRoute.component.afterRender).not.toHaveBeenCalled() + }) }) describe('initComponentInCache', () => { @@ -808,44 +864,158 @@ describe('App', () => { app.updateComponentRouteData = jest.fn() }) - it('should call the getComponentView function with a component class', () => { + describe('Component', () => { + it('should call the getComponentView function with a component class with before render not a promise', async () => { + app.currentRoute = { + component: { + afterRender: jest.fn(), + beforeRender: jest.fn(), + render: jest.fn().mockReturnValue(
Component
) + }, + isComponentClass: true + } + app.runRenderWhenReady = jest.fn() + + const result = await app.getComponentView() + + expect(app.updateComponentRouteData).toHaveBeenCalled() + expect(app.currentRoute.component.beforeRender).toHaveBeenCalled() + expect(app.runRenderWhenReady).not.toHaveBeenCalled() + expect(app.currentRoute.component.render).toHaveBeenCalled() + expect(result).toStrictEqual(
Component
) + }) + + it('should call the getComponentView function with a component class with before render a promise', async () => { + app.currentRoute = { + component: { + afterRender: jest.fn(), + beforeRender: jest.fn().mockResolvedValue(), + render: jest.fn() + }, + isComponentClass: true + } + app.runRenderWhenReady = jest.fn().mockResolvedValue(
Component
) + + const result = await app.getComponentView() + + expect(app.updateComponentRouteData).toHaveBeenCalled() + expect(app.currentRoute.component.beforeRender).toHaveBeenCalled() + expect(app.runRenderWhenReady).toHaveBeenCalled() + expect(app.currentRoute.component.render).not.toHaveBeenCalled() + expect(result).toStrictEqual(
Component
) + }) + + it('should call the getComponentView function with a component class with before render a promise and route has changed', async () => { + const beforeRenderMock = jest.fn().mockResolvedValue() + + app.currentRoute = { + component: { + afterRender: jest.fn(), + beforeRender: beforeRenderMock, + render: jest.fn() + }, + isComponentClass: true + } + app.runRenderWhenReady = jest.fn().mockResolvedValue(
Component
) + + const result = await app.getComponentView() + + expect(app.updateComponentRouteData).toHaveBeenCalled() + expect(app.currentRoute.component.beforeRender).toHaveBeenCalled() + expect(app.runRenderWhenReady).toHaveBeenCalledWith( + app.currentRoute, + expect.any(Promise) + ) + expect(app.currentRoute.component.render).not.toHaveBeenCalled() + expect(result).toStrictEqual(
Component
) + }) + }) + + describe('Not a component', () => { + it('should call the getComponentView function with not a component class', async () => { + app.currentRoute = { + component: jest.fn().mockReturnValue(
Component
), + isComponentClass: false, + props: { + name: 'John Doe' + } + } + jest.spyOn(app.currentRoute.component, 'call') + + const result = await app.getComponentView() + + expect(app.updateComponentRouteData).not.toHaveBeenCalled() + expect(app.currentRoute.component.call).toHaveBeenCalledWith( + app.currentRoute.component, + { + name: 'John Doe' + } + ) + expect(result).toStrictEqual(
Component
) + }) + }) + + it('should call the getComponentView function with a promise rejection', async () => { + await expect(app.getComponentView()).rejects.toStrictEqual( + new Error('getComponentView::promise not resolved') + ) + }) + }) + + describe('runRenderWhenReady', () => { + beforeEach(() => { + jest.spyOn(App.prototype, 'createRoutesData').mockReturnValue(customRoutes) + jest.spyOn(App.prototype, 'addEvents').mockImplementation(() => { + /* Empty */ + }) + jest.spyOn(App.prototype, 'onRouteChange').mockImplementation(() => { + /* Empty */ + }) + + app = getInstance() + app.updateComponentRouteData = jest.fn() + }) + + it('should call the runRenderWhenReady function with render function called', async () => { app.currentRoute = { component: { - afterRender: jest.fn(), - beforeRender: jest.fn(), render: jest.fn().mockReturnValue(
Component
) }, - isComponentClass: true + isComponentClass: true, + path: '/home' } - const result = app.getComponentView() + const result = await app.runRenderWhenReady( + app.currentRoute, + jest.fn().mockResolvedValue() + ) - expect(app.updateComponentRouteData).toHaveBeenCalled() - expect(app.currentRoute.component.beforeRender).toHaveBeenCalled() expect(app.currentRoute.component.render).toHaveBeenCalled() expect(result).toStrictEqual(
Component
) }) - it('should call the getComponentView function with not a component class', () => { + it('should call the runRenderWhenReady function with route changed and render function not called', async () => { app.currentRoute = { - component: jest.fn().mockReturnValue(
Component
), - isComponentClass: false, - props: { - name: 'John Doe' - } + component: { + render: jest.fn().mockReturnValue(
Component
) + }, + path: '/page-2' } - jest.spyOn(app.currentRoute.component, 'call') - const result = app.getComponentView() + const previousRoute = { + component: { + render: jest.fn().mockReturnValue(
Component
) + }, + path: '/page-1' + } - expect(app.updateComponentRouteData).not.toHaveBeenCalled() - expect(app.currentRoute.component.call).toHaveBeenCalledWith( - app.currentRoute.component, - { - name: 'John Doe' - } + const result = await app.runRenderWhenReady( + previousRoute, + jest.fn().mockResolvedValue() ) - expect(result).toStrictEqual(
Component
) + + expect(app.currentRoute.component.render).not.toHaveBeenCalled() + expect(result).toStrictEqual(undefined) }) })