UNPKG

@ima/react-page-renderer

Version:

IMA.js React page renderer.

281 lines (280 loc) 11 kB
import { autoYield, forceYield } from '@esmj/task'; import { RendererEvents, RendererTypes } from '@ima/core'; import { AbstractPageRenderer } from './AbstractPageRenderer'; /** * Client-side page renderer. The renderer attempts to reuse the markup sent by * server if possible. */ export class AbstractClientPageRenderer extends AbstractPageRenderer { _hydrated = false; _mounted = this._createMountedPromise(); _syncMounted = false; _renderedOnChange = true; _lastRendered = this._createRenderPromise(); /** * Flag signalling that the page is being rendered for the first time. */ _window; /** * The HTML element containing the current application view for the * current route. */ _viewContainer; /** * Initializes the client-side page renderer. * * @param factory Factory for receive $Utils to view. * @param helpers The IMA.js helper methods. * @param dispatcher Dispatcher fires events to app. * @param settings The application setting for the * current application environment. * @param window Helper for manipulating the global object * (`window`) regardless of the client/server-side * environment. */ constructor(factory, helpers, dispatcher, settings, window){ super(factory, helpers, dispatcher, settings); /** * Helper for manipulating the global object (`window`) * regardless of the client/server-side environment. */ this._window = window; } /** * @inheritDoc */ async mount(controller, pageView, pageResources, routeOptions) { const { values: defaultPageState, promises: loadedPromises } = this._separatePromisesAndValues(pageResources); await forceYield(); let batchPromise = Promise.resolve(); this._lastRendered = Promise.resolve(); if (this._viewContainer && this._viewContainer.children.length) { this._renderedOnChange = false; controller.setState(defaultPageState); this._renderedOnChange = true; await this._renderPageViewToDOM(controller, pageView, routeOptions); this._patchPromisesToState(controller, loadedPromises); if (this._settings?.$Page?.$Render?.batchResolve) { batchPromise = this._startBatchTransactions(controller, loadedPromises); } } return batchPromise.then(()=>{ if (this._settings?.$Page?.$Render?.resolveAfterRender) { return this._lastRendered; } }).then(()=>{ return this._helpers.allPromiseHash(loadedPromises).then(async (fetchedResources)=>{ const pageState = Object.assign({}, defaultPageState, fetchedResources); const isViewContainerEmpty = !this._viewContainer || !this._viewContainer.children.length; isViewContainerEmpty && controller.setState(pageState); controller.setMetaParams(pageState); isViewContainerEmpty && await this._renderPageViewToDOM(controller, pageView, routeOptions); return { pageState: controller.getState(), status: controller.getHttpStatus() }; }).catch((error)=>this._handleError(error)); }); } /** * @inheritDoc */ async update(controller, pageView, resourcesUpdate) { const { values: defaultPageState, promises: updatedPromises } = this._separatePromisesAndValues(resourcesUpdate); await autoYield(); controller.setState(defaultPageState); this._patchPromisesToState(controller, updatedPromises); let batchPromise = Promise.resolve(); this._lastRendered = Promise.resolve(); if (this._settings?.$Page?.$Render?.batchResolve) { batchPromise = this._startBatchTransactions(controller, updatedPromises); } return batchPromise.then(()=>{ if (this._settings?.$Page?.$Render?.resolveAfterRender) { return this._lastRendered; } }).then(()=>{ return this._helpers.allPromiseHash(updatedPromises).then(()=>{ controller.setMetaParams(controller.getState()); return { pageState: controller.getState(), status: controller.getHttpStatus() }; }).catch((error)=>this._handleError(error)); }); } unmount() { this._hydrated = false; this._syncMounted = false; this._mounted = this._createMountedPromise(); } async setState(pageState = {}) { if (!this._syncMounted) { await this._mounted; } if (!this._renderedOnChange) { return; } this._lastRendered = this._createRenderPromise(); this._renderViewAdapter(this._getUpdateCallback(pageState), { state: pageState }); } _getHydrateCallback() { return ()=>this._dispatcher.fire(RendererEvents.MOUNTED, { type: RendererTypes.HYDRATE }); } _getRenderCallback() { return ()=>this._dispatcher.fire(RendererEvents.MOUNTED, { type: RendererTypes.RENDER }); } _getUpdateCallback(pageState) { return ()=>{ this._dispatcher.fire(RendererEvents.UPDATED, { pageState }); }; } /** * Show error to console in $Debug mode and re-throw that error * for other error handler. * * @throws {Error} Re-throws the handled error. */ _handleError(error) { if ($Debug) { console.error('Render Error:', error); } throw error; } _createMountedPromise() { return new Promise((resolve)=>{ const handler = ()=>{ this._syncMounted = true; resolve(); this._dispatcher.unlisten(RendererEvents.MOUNTED, handler); }; this._dispatcher.listen(RendererEvents.MOUNTED, handler); }); } _createRenderPromise() { return new Promise((resolve)=>{ const handler = ()=>{ resolve(); this._dispatcher.unlisten(RendererEvents.UPDATED, handler); }; this._dispatcher.listen(RendererEvents.UPDATED, handler); }); } /** * Patch promise values to controller state. */ _patchPromisesToState(controller, patchedPromises) { for (const resourceName of Object.keys(patchedPromises)){ patchedPromises[resourceName].then((resource)=>{ controller.setState({ [resourceName]: resource }); }).catch((error)=>this._handleError(error)); } } _runUnmountCallback() { this._dispatcher.fire(RendererEvents.UNMOUNTED, { type: RendererTypes.UNMOUNT }); } /** * Batch patch promise values to controller state. */ _startBatchTransactions(controller, patchedPromises) { let hasResourcesLoaded = false; const options = { timeout: 100 }; const Window = this._window.getWindow(); let requestIdleCallback = (callback)=>setTimeout(callback, 0); if (Window && Window['requestIdleCallback']) { requestIdleCallback = (callback, options)=>Window.requestIdleCallback(callback, options); } const handler = (resolve)=>()=>{ if (this._settings?.$Page?.$Render?.batchResolveNoTransaction) { // TODO IMA@20 - make this default behaviour this._renderedOnChange = true; controller.setState(controller.getState()); } else { controller.commitStateTransaction(); } if (!hasResourcesLoaded) { if (this._settings?.$Page?.$Render?.batchResolveNoTransaction) { this._renderedOnChange = false; } else { controller.beginStateTransaction(); } setTimeout(()=>{ requestIdleCallback(handler(resolve), options); }, 75); } else { resolve(); } }; if (this._settings?.$Page?.$Render?.batchResolveNoTransaction) { this._renderedOnChange = false; } else { controller.beginStateTransaction(); } const batchPromise = new Promise((resolve)=>{ setTimeout(()=>{ requestIdleCallback(handler(resolve), options); }, 100); }); this._helpers.allPromiseHash(patchedPromises).then(()=>{ hasResourcesLoaded = true; }).catch(()=>{ hasResourcesLoaded = true; }); return batchPromise; } /** * Renders the current route to DOM. * * @param routeOptions The current route options. */ _renderPageViewToDOM(controller, pageView, routeOptions) { this._prepareViewAdapter(controller, pageView, routeOptions); const masterElementId = this._settings.$Page.$Render.masterElementId; const viewContainer = this._window.getElementById(masterElementId); if (viewContainer) { this._viewContainer = viewContainer; } if (!this._viewContainer) { const errorMessage = `ima.core.page.renderer.ClientPageRenderer:_renderPageViewToDOM: ` + `Element with ID "${masterElementId || 'unknown'}" was not found in the DOM. ` + `Maybe the DOM is not in the interactive mode yet.`; this._dispatcher.fire(RendererEvents.ERROR, { message: errorMessage }); return Promise.reject(new Error(errorMessage)); } if (!this._hydrated && this._viewContainer.children.length) { this._hydrateViewAdapter(); this._hydrated = true; return this._mounted; } else { this._renderViewAdapter(this._getRenderCallback()); return this._mounted; } } /** * Separate promises and values from provided data map. Values will be use * for default page state. Promises will be patched to state after their * resolve. * * @param dataMap A map of data. * @return Return separated promises and other values. */ _separatePromisesAndValues(dataMap) { const promises = {}; const values = {}; for (const field of Object.keys(dataMap)){ const value = dataMap[field]; if (value instanceof Promise) { promises[field] = value; } else { values[field] = value; } } return { promises, values }; } } //# sourceMappingURL=AbstractClientPageRenderer.js.map