@ima/react-page-renderer
Version:
IMA.js React page renderer.
281 lines (280 loc) • 11 kB
JavaScript
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