import { TraceSource } from "@implab/core-amd/log/TraceSource"; import { id as mid } from "module"; import { IScope, Scope } from "./Scope"; import { isNode, isRendition, isWidget } from "./traits"; const trace = TraceSource.get(mid); interface Context { readonly scope: IScope; readonly priority: number; readonly hooks?: (() => void)[]; } type RenderTask = { /** * The priority for this task */ readonly priority: number, /** * The rendering action performed in this task */ readonly render: () => void; }; type Range = { /** minimum value in this range */ readonly min: number; /** maximum value in this range */ readonly max: number; }; // empty range const emptyPriorities: Range = { min: NaN, max: NaN }; // holds render tasks let _renderQueue: RenderTask[] = []; // holds the minimum and the maximum task priorities in the queue. Used to // optimize rendering process is all tasks are with the same priority. let _renderQueuePriorities: Range = emptyPriorities; // current context let _context: Context = { scope: Scope.dummy, priority: 0 }; // started render operations let _renderCount = 0; // next id for render operations let _renderId = 1; // hooks for render completion, executed when all render operations has // been completed let _renderedHooks: (() => void)[] = []; const guard = (cb: () => void) => { try { cb(); } catch (e) { trace.error(e); } }; /** * Creates a new rendition context with the specified parameters and makes it * an active context. * * @see getScope * @see getPriority * * @param scope The scope for the current context * @param priority The priority for the current context * @returns The function to restore the previous context and execute pending hooks */ const enterContext = (scope: IScope, priority: number) => { const prev = _context; const captured = _context = { scope, priority, hooks: [] }; return () => { if (_context !== captured) trace.error("endRender mismatched beginRender call"); const { hooks } = _context; if (hooks) hooks.forEach(guard); _context = prev; }; }; /** * Starts the new render operation. When the operation is started the counter * of running operations is increased. If this is the first render operation * then the `onRendering` event is fired. * * @returns The id of the started rendering operation. */ const startRender = () => { _renderCount++; if (_renderCount === 1) onRendering(); return _renderId++; }; /** * Completes the rendering operation. When the operation is completed the counter * of running operations is decreased. If there is no more running operations left * then the `onRendered` event is fired. * */ const completeRender = () => { _renderCount--; if (_renderCount === 0) onRendered(); }; /** * Invokes the specified within the rendition context. The rendition context * is created for the task invocation and restored after the task is competed. * * @param scope The cope for the rendition context * @param priority The priority for the rendition context * @returns The result returned by the task */ export const renderTask = (task: () => T, scope: IScope, priority: number, renderId: number) => { const restoreContext = enterContext(scope, priority); try { trace.debug("beginRender [{0}], priority = {1}", renderId, priority); return task(); } finally { trace.debug("endRender [{0}]", renderId); restoreContext(); completeRender(); } }; const processRenderQueue = () => { const q = _renderQueue; const { min, max } = _renderQueuePriorities; _renderQueue = []; _renderQueuePriorities = emptyPriorities; // all tasks scheduled due queue processing will be queued to the next // processRenderQueue iteration if (min !== max) { // if there are tasks with different priorities in the queue trace.debug("Processing render queue, {0} tasks, priorities=[{1}..{2}] ", q.length, min, max); q.sort(({ priority: a }, { priority: b }) => a - b).forEach(({ render }) => guard(render)); } else { // all tasks are have same priority trace.debug("Processing render queue, {0} tasks, priority = {1} ", q.length, min); q.forEach(({ render }) => guard(render)); } if (_renderQueue.length) trace.debug("Render queue is processed, {0} tasks rescheduled", _renderQueue.length); }; /** * Adds the specified task to the render queue. The task will be added with the * specified priority. * * Render queue contains a list of render tasks. Each task is executed within * its own rendering context with the specified scope. The priority determines * the order in which tasks will be executed where the tasks with lower priority * numbers are executed first. * * When the queue is empty and the task is added then the render queue will be * scheduled for execution. While the current queue is being executed all * enqueued tasks are added to the new queue and processed after the current * execution has been completed. * * @param task The action to execute. This action will be executed with * {@link renderTask} function in its own rendering context. * @param scope The scope used to create a rendering context for the task. * @param priority The priority */ export const queueRenderTask = (task: () => void, scope = Scope.dummy, priority = getPriority()) => { const renderId = startRender(); trace.debug("scheduleRender [{0}], priority = {1}", renderId, priority); const render = () => renderTask(task, scope, priority, renderId); if (!_renderQueue.length) { // this is the first task, schedule next render queue processing Promise.resolve().then(processRenderQueue, e => trace.error(e)); // initialize priorities _renderQueuePriorities = { min: priority, max: priority }; } else { // update priorities if needed const { min, max } = _renderQueuePriorities; if (priority < min) _renderQueuePriorities = { min: priority, max }; else if (priority > max) _renderQueuePriorities = { min, max: priority }; } _renderQueue.push({ priority, render }); }; /** * Starts the synchronous rendering process with the specified scope and priority. * * @param scope The scope for the current rendition * @param priority The priority for the current scope * @returns The function to complete the current rendering */ export const beginRender = (scope = Scope.dummy, priority = 0) => { const renderId = startRender(); const restoreContext = enterContext(scope, priority); trace.debug("beginRender [{0}], priority = {1}", renderId, priority); return () => { trace.debug("endRender [{0}]", renderId); restoreContext(); completeRender(); }; }; // called when the first beginRender is called for this iteration const onRendering = () => { trace.log("Rendering started"); setTimeout(() => { if (_renderCount !== 0) trace.error("Rendering tasks aren't finished, currently running = {0}", _renderCount); }); }; // called when all render operations are complete const onRendered = () => { trace.log("Rendering compete"); _renderedHooks.forEach(guard); _renderedHooks = []; }; /** Returns promise when the rendering has been completed. */ export const whenRendered = () => new Promise((resolve) => { if (_renderCount) _renderedHooks.push(resolve); else resolve(); }); /** * Registers hook which is called after the render operation is completed. The * hook will be called once only for the current operation. * * @param hook The hook which should be called when rendering is complete. */ export const renderHook = (hook: () => void) => { const { hooks } = _context; if (hooks) hooks.push(hook); else guard(hook); }; /** * Registers special hook which will be called with the specified state. The * hook is called once after the rendering is complete. When the rendition is * destroyed the hook is called with the undefined parameter. * * This function is used to register `ref` hooks form a tsx rendition. * * @param value The state which will be supplied as a parameter for the hook * @param ref reference hook */ export const refHook = (value: T, ref: JSX.Ref) => { const { hooks, scope } = _context; if (hooks) hooks.push(() => ref(value)); else guard(() => ref(value)); scope.own(() => ref(undefined)); }; /** Returns the current scope. Scope is used to track resources bound to the * current rendering. When the rendering is destroyed the scope is cleaned and * all bound resources are released. */ export const getScope = () => _context.scope; /** * Returns the current render task priority. This value is used by some renditions * to schedule asynchronous nested updates with lower priority then themselves. */ export const getPriority = () => _context.priority; /** Schedules the rendition to be rendered to the DOM Node * @param rendition The rendition to be rendered * @param scope The scope */ export const render = (rendition: unknown, scope = Scope.dummy) => renderTask(() => getItemDom(rendition), scope, getPriority(), startRender()); const emptyFragment = document.createDocumentFragment(); /** Renders DOM element for different types of the argument. */ export const getItemDom = (v: unknown) => { if (typeof v === "string" || typeof v === "number" || v instanceof RegExp || v instanceof Date) { // primitive types converted to the text nodes return document.createTextNode(v.toString()); } else if (isNode(v)) { // nodes are kept as is return v; } else if (isRendition(v)) { // renditions are instantiated return v.getDomNode(); } else if (isWidget(v)) { // widgets are converted to it's markup return v.domNode; } else if (typeof v === "boolean" || v === null || v === undefined) { // null | undefined | boolean are removed return emptyFragment; } else if (v instanceof Array) { // arrays will be translated to document fragments const fragment = document.createDocumentFragment(); v.map(item => getItemDom(item)) .forEach(node => fragment.appendChild(node)); return fragment; } else { // bug: explicit error otherwise throw new Error(`Invalid parameter: ${String(v)}`); } };