diff --git a/.vscode/settings.json b/.vscode/settings.json --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,6 +9,7 @@ "cSpell.words": [ "dijit", "djbase", - "djclass" + "djclass", + "Unsubscribable" ] } \ No newline at end of file diff --git a/djx/src/main/ts/observable.ts b/djx/src/main/ts/observable.ts --- a/djx/src/main/ts/observable.ts +++ b/djx/src/main/ts/observable.ts @@ -54,10 +54,10 @@ export interface Unsubscribable { unsubscribe(): void; } -export const isUnsubsribable = (v: unknown): v is Unsubscribable => +export const isUnsubscribable = (v: unknown): v is Unsubscribable => v !== null && v !== undefined && typeof (v as Unsubscribable).unsubscribe === "function"; -export const isSubsribable = (v: unknown): v is Subscribable => +export const isSubscribable = (v: unknown): v is Subscribable => v !== null && v !== undefined && typeof (v as Subscribable).subscribe === "function"; export interface Subscribable { diff --git a/djx/src/main/ts/store.ts b/djx/src/main/ts/store.ts --- a/djx/src/main/ts/store.ts +++ b/djx/src/main/ts/store.ts @@ -25,18 +25,18 @@ interface DjObservableResults { }; } -interface Queryable { - query(...args: A): PromiseOrValue; +interface Queryable { + query(query?: Q, options?: O): PromiseOrValue; } -export const isObservableResults = (v: object): v is DjObservableResults => +export const isDjObservableResults = (v: object): v is DjObservableResults => v && (typeof (v as { observe?: unknown; }).observe === "function"); -export const query = (store: Queryable, includeUpdates = true) => - (...args: A) => { +export const query = (store: Queryable, includeUpdates = true) => + (query?: Q, options?: O & { observe: boolean }) => { return observe>(({ next, complete, error, isClosed }) => { try { - const results = store.query(...args); + const results = store.query(query, options); if (isPromise(results)) { results.then(items => items.forEach((item, newIndex) => next({ item, newIndex, prevIndex: -1 }))) .then(undefined, error); @@ -44,7 +44,7 @@ export const query = next({ item, newIndex, prevIndex: -1 })); } - if (!isClosed() && isObservableResults(results)) { + if (!isClosed() && (options?.observe !== false) && isDjObservableResults(results)) { const h = results.observe((item, prevIndex, newIndex) => next({ item, prevIndex, newIndex }), includeUpdates); return () => h.remove(); } else { diff --git a/djx/src/main/ts/tsx.ts b/djx/src/main/ts/tsx.ts --- a/djx/src/main/ts/tsx.ts +++ b/djx/src/main/ts/tsx.ts @@ -102,7 +102,7 @@ export function watch( } } -export const watchFor = (source: T[] | Subscribable>, render: (item: T, index: number) => unknown, opts: AnimationAttrs = {}) => { +export const watchFor = (source: T[] | Subscribable> | null | undefined, render: (item: T, index: number) => unknown, opts: AnimationAttrs = {}) => { return new WatchForRendition({ ...opts, subject: source, diff --git a/djx/src/main/ts/tsx/Scope.ts b/djx/src/main/ts/tsx/Scope.ts --- a/djx/src/main/ts/tsx/Scope.ts +++ b/djx/src/main/ts/tsx/Scope.ts @@ -1,6 +1,6 @@ import { IDestroyable, IRemovable } from "@implab/core-amd/interfaces"; import { isDestroyable, isRemovable } from "@implab/core-amd/safe"; -import { isUnsubsribable, Unsubscribable } from "../observable"; +import { isUnsubscribable, Unsubscribable } from "../observable"; export interface IScope { own(target: (() => void) | IDestroyable | IRemovable | Unsubscribable): void; @@ -18,7 +18,7 @@ export class Scope implements IDestroyab this._cleanup.push(() => target.destroy()); } else if (isRemovable(target)) { this._cleanup.push(() => target.remove()); - } else if (isUnsubsribable(target)) { + } else if (isUnsubscribable(target)) { this._cleanup.push(() => target.unsubscribe()); } } 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 } from "./render"; +import { getScope, render, scheduleRender } from "./render"; import { RenditionBase } from "./RenditionBase"; import { Scope } from "./Scope"; import { Cancellation } from "@implab/core-amd/Cancellation"; @@ -9,8 +9,8 @@ import { collectNodes, destroy as safeDe import { IDestroyable } from "@implab/core-amd/interfaces"; import { play } from "../play"; import * as fx from "dojo/fx"; -import { isSubsribable, Subscribable } from "../observable"; -import { isObservableResults, OrderedUpdate } from "../store"; +import { isSubscribable, Subscribable } from "../observable"; +import { isDjObservableResults, OrderedUpdate } from "../store"; const trace = TraceSource.get(mid); @@ -35,7 +35,7 @@ export interface AnimationAttrs { } export interface WatchForRenditionAttrs extends AnimationAttrs { - subject: T[] | Subscribable>; + subject: T[] | Subscribable> | undefined | null; component: (arg: T, index: number) => unknown; } @@ -76,11 +76,10 @@ export class WatchForRendition extend constructor({ subject, component, animate, animateIn, animateOut }: WatchForRenditionAttrs) { super(); argumentNotNull(component, "component"); - argumentNotNull(subject, "component"); this._component = component; - this._subject = subject; + this._subject = subject ?? []; this._node = document.createComment("[WatchFor]"); this._animate = !!animate; @@ -98,7 +97,7 @@ export class WatchForRendition extend const result = this._subject; if (result) { - if (isSubsribable>(result)) { + if (isSubscribable>(result)) { let animate = false; const subscription = result.subscribe({ next: ({ item, prevIndex, newIndex }) => this._onItemUpdated({ item, prevIndex, newIndex, animate }) @@ -106,7 +105,7 @@ export class WatchForRendition extend scope.own(subscription); animate = this._animate; } else { - if (isObservableResults(result)) + if (isDjObservableResults(result)) scope.own(result.observe((item, prevIndex, newIndex) => this._onItemUpdated({ item, prevIndex, newIndex, animate: false }), true)); for (let i = 0, n = result.length; i < n; i++) @@ -129,13 +128,18 @@ export class WatchForRendition extend private async _render() { // fork - await Promise.resolve(); - // don't render destroyed rendition - if (this._ct.isRequested()) - return; + const beginRender = await scheduleRender(); + const endRender = beginRender(); + try { + // don't render destroyed rendition + if (this._ct.isRequested()) + return; - this._renderTasks.forEach(this._onRenderItem); - this._renderTasks.length = 0; + this._renderTasks.forEach(this._onRenderItem); + this._renderTasks.length = 0; + } finally { + endRender(); + } } private readonly _onRenderItem = ({ item, newIndex, prevIndex, animate: _animate }: RenderTask) => { @@ -198,7 +202,7 @@ export class WatchForRendition extend protected _getDomNode() { if (!this._node) - throw new Error("The instance of the widget isn't created"); + throw new Error("The instance of the rendition isn't created"); return this._node; } } 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,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 } from "./render"; +import { getItemDom, getScope, scheduleRender } from "./render"; import { RenditionBase } from "./RenditionBase"; import { Scope } from "./Scope"; import { Subscribable } from "../observable"; @@ -56,35 +56,36 @@ export class WatchRendition extends R }; private async _render() { - // fork - await Promise.resolve(); - // don't render destroyed rendition - if (this._ct.isRequested()) - return; + const beginRender = await scheduleRender(this._scope); + const endRender = beginRender(); + try { + // 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 = render( - this._renderJob ? this._component(this._renderJob.value) : undefined, - this._scope - ); + // 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 (isMounted(this._node)) + pending.forEach(n => startupWidgets(n)); - if (pending.length) - this._scope.own(() => pending.forEach(destroy)); + if (pending.length) + this._scope.own(() => pending.forEach(destroy)); - this._renderJob = undefined; + this._renderJob = undefined; + } finally { + endRender(); + } } protected _getDomNode() { 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 @@ -7,15 +7,20 @@ import { isNode, isRendition, isWidget } const trace = TraceSource.get(mid); interface Context { - scope: IScope; + readonly scope: IScope; - hooks?: (() => void)[]; + 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(); @@ -28,26 +33,104 @@ const guard = (cb: () => unknown) => { } }; -export const beginRender = (scope: IScope = getScope()) => { +/** + * + * @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); + 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()) => { + const prev = _context; + _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); + _context = { + scope, + hooks: [] + }; + return endRender(prev, _context, renderId); + }; }; /** * Completes render operation */ -const endRender = (prev: Context) => () => { +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) diff --git a/playground/src/main/ts/main.ts b/playground/src/main/ts/main.ts --- a/playground/src/main/ts/main.ts +++ b/playground/src/main/ts/main.ts @@ -2,6 +2,15 @@ import MainWidget from "./view/MainWidge import "@implab/djx/css!dojo/resources/dojo.css"; import "@implab/djx/css!dijit/themes/dijit.css"; import "@implab/djx/css!dijit/themes/tundra/tundra.css"; +import { TraceSource } from "@implab/core-amd/log/TraceSource"; +import { ConsoleLogger } from "@implab/core-amd/log/writers/ConsoleLogger"; + +const logger = new ConsoleLogger(); + +TraceSource.on(source => { + source.level = 400; + logger.writeEvents(source.events); +}); const w = new MainWidget(); w.placeAt(document.body); 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,6 +41,19 @@ export class MainContext implements IDes ); } + async load() { + await Promise.resolve(); + for (let i = 0; i < 2; i++) { + const id = Uuid(); + this._appointments.add({ + id, + startAt: new Date(), + duration: 30, + title: `Hello ${i+1}` + }); + } + } + private readonly _queryAppointmentsRx = query(this._appointments); private readonly _queryMembersRx = query(this._members); 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 @@ -8,6 +8,7 @@ import { MainContext } from "./MainConte import { LocalDate } from "@js-joda/core"; import { error } from "../logging"; import { TraceSource } from "@implab/core-amd/log/TraceSource"; +import { whenRendered } from "@implab/djx/tsx/render"; const trace = TraceSource.get(mid); @@ -52,10 +53,20 @@ export default class MainModel implement } addAppointment(title: string, startAt: Date, duration: number) { - this._context.createAppointment(title,startAt, duration, []).catch(error(trace)); + this._context.createAppointment(title,startAt, duration, []) + .then(() => { + trace.debug("addAppointment done"); + return whenRendered(); + }) + .then(() => { + trace.debug("Render dome"); + }) + .catch(error(trace)); } + load() { + this._context.load().catch(error(trace)); } destroy() { diff --git a/playground/src/tsconfig.json b/playground/src/tsconfig.json --- a/playground/src/tsconfig.json +++ b/playground/src/tsconfig.json @@ -13,6 +13,6 @@ ], "skipLibCheck": true, "target": "ES5", - "lib": ["ES2015"] + "lib": ["ES2015", "DOM"] } } \ No newline at end of file