diff --git a/djx/src/main/ts/operators/subject.ts b/djx/src/main/ts/operators/subject.ts --- a/djx/src/main/ts/operators/subject.ts +++ b/djx/src/main/ts/operators/subject.ts @@ -8,8 +8,8 @@ const noop = () => { }; * * Use this wrapper to prevent spawning multiple producers. * - * The emitted values are not cached therefore the new subscriber will not receive - * the values emitted before it has been subscribed. + * The emitted values are not cached therefore new subscribers will not receive + * the values emitted before they had subscribed. * * @param source The source observable * @returns The new observable diff --git a/djx/src/main/ts/tsx/DjxWidgetBase.ts b/djx/src/main/ts/tsx/DjxWidgetBase.ts --- a/djx/src/main/ts/tsx/DjxWidgetBase.ts +++ b/djx/src/main/ts/tsx/DjxWidgetBase.ts @@ -5,7 +5,7 @@ import { isNode, isElementNode } from ". import registry = require("dijit/registry"); import on = require("dojo/on"); import { Scope } from "./Scope"; -import { render } from "./render"; +import { queueRenderTask, getPriority, render } from "./render"; import { isNull } from "@implab/core-amd/safe"; // type Handle = dojo.Handle; @@ -49,6 +49,8 @@ type _super = { export abstract class DjxWidgetBase extends djbase<_super, _AttachMixin>(_WidgetBase, _AttachMixin) { private readonly _scope = new Scope(); + private readonly _priority = getPriority() + 1; + buildRendering() { const node = render(this.render(), this._scope); if (!isElementNode(node)) @@ -71,6 +73,11 @@ export abstract class DjxWidgetBase void) { + return queueRenderTask(task, this._scope, this._priority); + } + abstract render(): JSX.Element; private _connectEventHandlers() { diff --git a/djx/src/main/ts/tsx/WatchForRendition.ts b/djx/src/main/ts/tsx/WatchForRendition.ts --- a/djx/src/main/ts/tsx/WatchForRendition.ts +++ b/djx/src/main/ts/tsx/WatchForRendition.ts @@ -1,7 +1,7 @@ import { id as mid } from "module"; import { TraceSource } from "@implab/core-amd/log/TraceSource"; import { argumentNotNull } from "@implab/core-amd/safe"; -import { getScope, render, scheduleRender } from "./render"; +import { queueRenderTask, getPriority, getScope, render } from "./render"; import { RenditionBase } from "./RenditionBase"; import { Scope } from "./Scope"; import { Cancellation } from "@implab/core-amd/Cancellation"; @@ -73,6 +73,8 @@ export class WatchForRendition extend private _ct = Cancellation.none; + private _priority = 0; + constructor({ subject, component, animate, animateIn, animateOut }: WatchForRenditionAttrs) { super(); argumentNotNull(component, "component"); @@ -88,6 +90,7 @@ export class WatchForRendition extend } protected _create() { + this._priority = getPriority() + 1; const scope = getScope(); scope.own(() => { this._itemRenditions.forEach(safeDestroy); @@ -119,28 +122,24 @@ export class WatchForRendition extend if (!this._renderTasks.length) { // schedule a new job this._renderTasks.push(item); - this._render().catch(e => trace.error(e)); + + // fork + // use dummy scope, because every item will have it's own scope + queueRenderTask(this._render, Scope.dummy, this._priority); } else { // update existing job this._renderTasks.push(item); } }; - private async _render() { - // fork - const beginRender = await scheduleRender(); - const endRender = beginRender(); - try { - // don't render destroyed rendition - if (this._ct.isRequested()) - return; + private readonly _render = () => { + // don't render destroyed rendition + if (this._ct.isRequested()) + return; - this._renderTasks.forEach(this._onRenderItem); - this._renderTasks.length = 0; - } finally { - endRender(); - } - } + this._renderTasks.forEach(this._onRenderItem); + this._renderTasks.length = 0; + }; private readonly _onRenderItem = ({ item, newIndex, prevIndex, animate: _animate }: RenderTask) => { const animate = _animate && prevIndex !== newIndex; diff --git a/djx/src/main/ts/tsx/WatchRendition.ts b/djx/src/main/ts/tsx/WatchRendition.ts --- a/djx/src/main/ts/tsx/WatchRendition.ts +++ b/djx/src/main/ts/tsx/WatchRendition.ts @@ -1,15 +1,11 @@ -import { id as mid } from "module"; -import { TraceSource } from "@implab/core-amd/log/TraceSource"; import { argumentNotNull } from "@implab/core-amd/safe"; -import { getItemDom, getScope, scheduleRender } from "./render"; +import { queueRenderTask, getItemDom, getPriority, getScope } from "./render"; import { RenditionBase } from "./RenditionBase"; import { Scope } from "./Scope"; import { Subscribable } from "../observable"; import { Cancellation } from "@implab/core-amd/Cancellation"; import { collectNodes, destroy, isDocumentFragmentNode, isMounted, placeAt, startupWidgets } from "./traits"; -const trace = TraceSource.get(mid); - export class WatchRendition extends RenditionBase { private readonly _component: (arg: T) => unknown; @@ -23,6 +19,8 @@ export class WatchRendition extends R private _ct = Cancellation.none; + private _priority = 0; + constructor(component: (arg: T) => unknown, subject: Subscribable) { super(); argumentNotNull(component, "component"); @@ -36,6 +34,8 @@ export class WatchRendition extends R protected _create() { const scope = getScope(); + this._priority = getPriority() + 1; + scope.own(() => { this._scope.destroy(); destroy(this._node); @@ -48,45 +48,40 @@ export class WatchRendition extends R if (!this._renderJob) { // schedule a new job this._renderJob = { value }; - this._render().catch(e => trace.error(e)); + queueRenderTask(this._render, this._scope, this._priority); } else { // update existing job this._renderJob = { value }; } }; - private async _render() { - const beginRender = await scheduleRender(this._scope); - const endRender = beginRender(); - try { - // don't render destroyed rendition - if (this._ct.isRequested()) - return; + private readonly _render = () => { + + // don't render destroyed rendition + if (this._ct.isRequested()) + return; - // remove all previous content - this._scope.clean(); + // remove all previous content + this._scope.clean(); - // render the new node - const node = getItemDom(this._renderJob ? this._component(this._renderJob.value) : undefined); + // render the new node + const node = getItemDom(this._renderJob ? this._component(this._renderJob.value) : undefined); - // get actual content - const pending = isDocumentFragmentNode(node) ? - collectNodes(node.childNodes) : - [node]; + // get actual content + const pending = isDocumentFragmentNode(node) ? + collectNodes(node.childNodes) : + [node]; - placeAt(node, this._node, "after"); + placeAt(node, this._node, "after"); - if (isMounted(this._node)) - pending.forEach(n => startupWidgets(n)); - - if (pending.length) - this._scope.own(() => pending.forEach(destroy)); + if (isMounted(this._node)) + pending.forEach(n => startupWidgets(n)); - this._renderJob = undefined; - } finally { - endRender(); - } - } + if (pending.length) + this._scope.own(() => pending.forEach(destroy)); + + this._renderJob = undefined; + }; protected _getDomNode() { if (!this._node) diff --git a/djx/src/main/ts/tsx/render.ts b/djx/src/main/ts/tsx/render.ts --- a/djx/src/main/ts/tsx/render.ts +++ b/djx/src/main/ts/tsx/render.ts @@ -1,5 +1,4 @@ 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"; @@ -9,108 +8,223 @@ const trace = TraceSource.get(mid); interface Context { readonly scope: IScope; + readonly priority: number; + readonly hooks?: (() => void)[]; } -let _context: Context = { - scope: Scope.dummy +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: () => unknown) => { +const guard = (cb: () => void) => { 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); - } + cb(); } catch (e) { trace.error(e); } }; /** + * Creates a new rendition context with the specified parameters and makes it + * an active context. * - * @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 + * @see getScope + * @see getPriority * - * const begin = await scheduleRender(); - * const end = begin(); - * try { - * // do some DOM manipulations - * } finally { - * end(); - * } - * - * @param scope - * @returns + * @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 */ -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(); +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"); - return () => { - trace.debug("beginRender [{0}], pending = {1}", renderId, _renderCount); - const prev = _context; + const { hooks } = _context; + if (hooks) + hooks.forEach(guard); - _context = { - scope, - hooks: [] - }; - return endRender(prev, _context, renderId); + _context = prev; }; }; /** - * Completes render operation + * 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 endRender = (prev: Context, current: Context, renderId: number) => () => { - if (_context !== current) - trace.error("endRender mismatched beginRender call"); +const startRender = () => { + _renderCount++; + if (_renderCount === 1) + onRendering(); - const { hooks } = _context; - if (hooks) - hooks.forEach(guard); + 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--; - _context = prev; - - trace.debug("endRender [{0}], pending = {1}", renderId, _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"); @@ -127,6 +241,7 @@ const onRendered = () => { _renderedHooks = []; }; +/** Returns promise when the rendering has been completed. */ export const whenRendered = () => new Promise((resolve) => { if (_renderCount) _renderedHooks.push(resolve); @@ -134,6 +249,12 @@ export const whenRendered = () => new Pr 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) @@ -142,6 +263,16 @@ export const renderHook = (hook: () => v 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) @@ -152,21 +283,24 @@ export const refHook = (value: T, ref scope.own(() => ref(undefined)); }; -/** Returns the current scope */ +/** 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) => { - const complete = beginRender(scope); - try { - return getItemDom(rendition); - } finally { - complete(); - } -}; +export const render = (rendition: unknown, scope = Scope.dummy) => + renderTask(() => getItemDom(rendition), scope, getPriority(), startRender()); const emptyFragment = document.createDocumentFragment(); diff --git a/playground/src/main/ts/model/MainContext.ts b/playground/src/main/ts/model/MainContext.ts --- a/playground/src/main/ts/model/MainContext.ts +++ b/playground/src/main/ts/model/MainContext.ts @@ -41,16 +41,34 @@ export class MainContext implements IDes ); } + async removeAppointment(appointmentId: string) { + await delay(10); + this._members.query({ appointmentId }) + .map(m => this._members.getIdentity(m)) + .forEach(id => this._members.remove(id)); + this._appointments.remove(appointmentId); + } + async load() { - await Promise.resolve(); - for (let i = 0; i < 2; i++) { + await delay(10); + for (let i = 0; i < 5; i++) { const id = Uuid(); this._appointments.add({ id, startAt: new Date(), duration: 30, - title: `Hello ${i+1}` + title: `Hello ${i + 1}` }); + + for (let ii = 0; ii < 3; ii++) + + this._members.add({ + appointmentId: id, + email: "some@no.mail", + name: `Peter ${ii}`, + position: "Manager", + role: "participant" + }, { id: Uuid() }); } } diff --git a/playground/src/main/ts/model/MainModel.ts b/playground/src/main/ts/model/MainModel.ts --- a/playground/src/main/ts/model/MainModel.ts +++ b/playground/src/main/ts/model/MainModel.ts @@ -64,6 +64,10 @@ export default class MainModel implement .catch(error(trace)); } + removeAppointment(appointmentId: string) { + this._context.removeAppointment(appointmentId).catch(error(trace)); + } + load() { this._context.load().catch(error(trace)); diff --git a/playground/src/main/ts/view/MainWidget.tsx b/playground/src/main/ts/view/MainWidget.tsx --- a/playground/src/main/ts/view/MainWidget.tsx +++ b/playground/src/main/ts/view/MainWidget.tsx @@ -1,3 +1,4 @@ +import { id as mid } from "module"; import { djbase, djclass } from "@implab/djx/declare"; import { DjxWidgetBase } from "@implab/djx/tsx/DjxWidgetBase"; import { attach, bind, createElement, prop, watch, watchFor } from "@implab/djx/tsx"; @@ -8,6 +9,9 @@ import { Appointment } from "../model/Ap import { LocalDate } from "@js-joda/core"; import Button = require("dijit/form/Button"); import NewAppointment from "./NewAppointment"; +import { TraceSource } from "@implab/core-amd/log/TraceSource"; + +const trace = TraceSource.get(mid); @djclass export default class MainWidget extends djbase(DjxWidgetBase) { @@ -46,6 +50,7 @@ export default class MainWidget extends
+
)} @@ -59,6 +64,7 @@ export default class MainWidget extends } load() { + trace.log("Loading data"); this.model.load(); } @@ -71,6 +77,10 @@ export default class MainWidget extends }); }; + private readonly _onRemoveAppointmentClick = (appointmentId: string) => { + this.model.removeAppointment(appointmentId); + }; + private readonly _onAddAppointmentClick = () => { this.model.addAppointment("Appointment", new Date, 30); };