import { TraceSource } from "@implab/core-amd/log/TraceSource"; import { isPromise } from "@implab/core-amd/safe"; 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 hooks?: (() => void)[]; } let _context: Context = { scope: Scope.dummy }; let _renderCount = 0; let _renderId = 1; let _renderedHooks: (() => void)[] = []; const guard = (cb: () => unknown) => { try { const result = cb(); if (isPromise(result)) { const warn = (ret: unknown) => trace.error("The callback {0} competed asynchronously. result = {1}", cb, ret); result.then(warn, warn); } } catch (e) { trace.error(e); } }; /** * * @param scope * @returns */ export const beginRender = (scope = getScope()) => { const prev = _context; _renderCount++; const renderId = _renderId++; trace.debug("beginRender [{0}], pending = {1}", renderId, _renderCount); if (_renderCount === 1) onRendering(); _context = { scope, hooks: [] }; return endRender(prev, _context, renderId); }; /** * Method for a deferred rendering. Returns a promise with `beginRender()` function. * Call to `scheduleRender` will save the current context, and will increment pending * operations counter. * * @example * * const begin = await scheduleRender(); * const end = begin(); * try { * // do some DOM manipulations * } finally { * end(); * } * * @param scope * @returns */ export const scheduleRender = async (scope = getScope()) => { _renderCount++; const renderId = _renderId ++; trace.debug("scheduleRender [{0}], pending = {1}", renderId, _renderCount); if (_renderCount === 1) onRendering(); await Promise.resolve(); return () => { trace.debug("beginRender [{0}], pending = {1}", renderId, _renderCount); const prev = _context; _context = { scope, hooks: [] }; return endRender(prev, _context, renderId); }; }; /** * Completes render operation */ const endRender = (prev: Context, current: Context, renderId: number) => () => { if (_context !== current) trace.error("endRender mismatched beginRender call"); const { hooks } = _context; if (hooks) hooks.forEach(guard); _renderCount--; _context = prev; trace.debug("endRender [{0}], pending = {1}", renderId, _renderCount); if (_renderCount === 0) onRendered(); }; // called when the first beginRender is called for this iteration const onRendering = () => { setTimeout(() => { if (_renderCount !== 0) trace.error("Rendering tasks aren't finished, currently running = {0}", _renderCount); }); }; // called when all render operations are complete const onRendered = () => { _renderedHooks.forEach(guard); _renderedHooks = []; }; export const whenRendered = () => new Promise((resolve) => { if (_renderCount) _renderedHooks.push(resolve); else resolve(); }); export const renderHook = (hook: () => void) => { const { hooks } = _context; if (hooks) hooks.push(hook); else guard(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 */ export const getScope = () => _context.scope; /** 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) => { const complete = beginRender(scope); try { return getItemDom(rendition); } finally { complete(); } }; 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)}`); } };