diff --git a/owl.js b/owl.js index 4d99e2a6..04588d8c 100644 --- a/owl.js +++ b/owl.js @@ -92,14 +92,6 @@ this.weakMap = new WeakMap(); } notifyCB() { } - async notifyChange() { - this.dirty = true; - await Promise.resolve(); - if (this.dirty) { - this.dirty = false; - this.notifyCB(); - } - } observe(value, parent) { if (value === null || typeof value !== "object" || value instanceof Date) { // fun fact: typeof null === 'object' @@ -127,7 +119,7 @@ } self._updateRevNumber(target); target[key] = newVal; - self.notifyChange(); + self.notifyCB(); } return true; }, @@ -135,7 +127,7 @@ if (key in target) { delete target[key]; self._updateRevNumber(target); - self.notifyChange(); + self.notifyCB(); } return true; } @@ -186,7 +178,7 @@ //------------------------------------------------------------------------------ // Misc types, constants and helpers //------------------------------------------------------------------------------ - const RESERVED_WORDS = "true,false,NaN,null,undefined,debugger,console,window,in,instanceof,new,function,return,this,typeof,eval,void,Math,RegExp,Array,Object,Date".split(","); + const RESERVED_WORDS = "true,false,NaN,null,undefined,debugger,console,window,in,instanceof,new,function,return,this,eval,void,Math,RegExp,Array,Object,Date".split(","); const WORD_REPLACEMENT = { and: "&&", or: "||", @@ -205,7 +197,9 @@ "(": "LEFT_PAREN", ")": "RIGHT_PAREN" }; - const OPERATORS = ".,===,==,+,!==,!=,!,||,&&,>=,>,<=,<,?,-,*,/,%".split(","); + // note that the space after typeof is relevant. It makes sure that the formatted + // expression has a space after typeof + const OPERATORS = ".,===,==,+,!==,!=,!,||,&&,>=,>,<=,<,?,-,*,/,%,typeof ".split(","); let tokenizeString = function (expr) { let s = expr[0]; let start = s; @@ -282,9 +276,9 @@ const TOKENIZERS = [ tokenizeString, tokenizeNumber, + tokenizeOperator, tokenizeSymbol, - tokenizeStatic, - tokenizeOperator + tokenizeStatic ]; /** * Convert a javascript expression (as a string) into a list of tokens. For @@ -419,6 +413,30 @@ const id = this.rootContext.nextID++; return id; } + /** + * This method generates a "template key", which is basically a unique key + * which depends on the currently set keys, and on the iteration numbers (if + * we are in a loop). + * + * Such a key is necessary when we need to associate an id to some element + * generated by a template (for example, a component) + */ + generateTemplateKey() { + const id = this.generateID(); + let locationExpr = `\`__${this.generateID()}__`; + for (let i = 0; i < this.loopNumber - 1; i++) { + locationExpr += `\${i${i + 1}}__`; + } + if (this.lastNodeKey || this.currentKey) { + const k = this.lastNodeKey || this.currentKey; + this.addLine(`let k${id} = ${locationExpr}\` + ${k};`); + } + else { + locationExpr += this.loopNumber ? `\${i${this.loopNumber}}__\`` : "`"; + this.addLine(`let k${id} = ${locationExpr};`); + } + return `k${id}`; + } generateCode() { const shouldTrackScope = this.shouldTrackScope && this.scopeVars.length; if (shouldTrackScope) { @@ -1626,6 +1644,9 @@ // to node.setAttribute("t-component", node.tagName); } + else if (node.tagName !== 't' && node.hasAttribute('t-component')) { + throw new Error(`Directive 't-component' can only be used on nodes (used on a <${node.tagName}>)`); + } const attributes = node.attributes; const validDirectives = []; const finalizers = []; @@ -1939,10 +1960,8 @@ attrs[attr.name] = attr.textContent; } const children = []; - if (node.hasChildNodes) { - for (let c of node.childNodes) { - children.push(htmlToVNode(c)); - } + for (let c of node.childNodes) { + children.push(htmlToVNode(c)); } return h(node.tagName, { attrs }, children); } @@ -1974,7 +1993,7 @@ qweb._compileNode(ctx.caller, ctx); return; } - if (value.xml instanceof NodeList) { + if (value.xml instanceof NodeList && !value.id) { for (let node of Array.from(value.xml)) { qweb._compileNode(node, ctx); } @@ -2014,6 +2033,12 @@ ctx.addElse(); qweb._compileChildren(node, ctx); } + if (value.xml instanceof NodeList && value.id) { + ctx.addElse(); + for (let node of Array.from(value.xml)) { + qweb._compileNode(node, ctx); + } + } ctx.closeIf(); } QWeb.addDirective({ @@ -2044,24 +2069,22 @@ atNodeEncounter({ node, ctx }) { const variable = node.getAttribute("t-set"); let value = node.getAttribute("t-value"); + ctx.variables[variable] = ctx.variables[variable] || {}; + let qwebvar = ctx.variables[variable]; if (value) { const formattedValue = ctx.formatExpression(value); - if (ctx.variables.hasOwnProperty(variable)) { - ctx.addLine(`${ctx.variables[variable].id} = ${formattedValue}`); + if (ctx.variables.hasOwnProperty(variable) && qwebvar.id) { + ctx.addLine(`${qwebvar.id} = ${formattedValue}`); } else { const varName = `_${ctx.generateID()}`; ctx.addLine(`var ${varName} = ${formattedValue};`); - ctx.variables[variable] = { - id: varName, - expr: formattedValue - }; + qwebvar.id = varName; + qwebvar.expr = formattedValue; } } else { - ctx.variables[variable] = { - xml: node.childNodes - }; + qwebvar.xml = node.childNodes; } return true; } @@ -2465,7 +2488,15 @@ const type = node.getAttribute("type"); let handler; let event = fullName.includes(".lazy") ? "change" : "input"; - const expr = ctx.formatExpression(value); + // we keep here a reference to the "base expression" (if the expression + // is `t-model="some.expr.value", then the base expression is "some.expr"). + // This is necessary so we can capture it in the handler closure. + let expr = ctx.formatExpression(value); + const index = expr.lastIndexOf("."); + const baseExpr = expr.slice(0, index); + ctx.addLine(`let expr${nodeID} = ${baseExpr};`); + expr = `expr${nodeID}.${expr.slice(index + 1)}`; + const key = ctx.generateTemplateKey(); if (node.tagName === "select") { ctx.addLine(`p${nodeID}.props = {value: ${expr}};`); addNodeHook("create", `n.elm.value=${expr};`); @@ -2492,8 +2523,8 @@ } handler = `(ev) => {${expr} = ${valueCode}}`; } - ctx.addLine(`extra.handlers['${event}' + ${nodeID}] = extra.handlers['${event}' + ${nodeID}] || (${handler});`); - ctx.addLine(`p${nodeID}.on['${event}'] = extra.handlers['${event}' + ${nodeID}];`); + ctx.addLine(`extra.handlers[${key}] = extra.handlers[${key}] || (${handler});`); + ctx.addLine(`p${nodeID}.on['${event}'] = extra.handlers[${key}];`); } }); //------------------------------------------------------------------------------ @@ -2526,6 +2557,18 @@ } }); + /** + * We define here OwlEvent, a subclass of CustomEvent, with an additional + * attribute: + * - originalComponent: the component that triggered the event + */ + class OwlEvent extends CustomEvent { + constructor(component, eventType, options) { + super(eventType, options); + this.originalComponent = component; + } + } + //------------------------------------------------------------------------------ // t-component //------------------------------------------------------------------------------ @@ -2748,19 +2791,7 @@ .join(","); let defID = ctx.generateID(); let componentID = ctx.generateID(); - let locationExpr = `\`__${ctx.generateID()}__`; - for (let i = 0; i < ctx.loopNumber - 1; i++) { - locationExpr += `\${i${i + 1}}__`; - } - if (ctx.lastNodeKey || ctx.currentKey) { - const k = ctx.lastNodeKey || ctx.currentKey; - ctx.addLine(`let templateId${componentID} = ${locationExpr}\` + ${k};`); - } - else { - locationExpr += ctx.loopNumber ? `\${i${ctx.loopNumber}}__\`` : "`"; - ctx.addLine(`let templateId${componentID} = ${locationExpr};`); - } - const templateId = `templateId${componentID}`; + const templateKey = ctx.generateTemplateKey(); let ref = node.getAttribute("t-ref"); let refExpr = ""; let refKey = ""; @@ -2852,7 +2883,7 @@ const styleCode = styleExpr ? `vn.elm.style = ${styleExpr};` : ""; createHook = `vnode.data.hook = {create(_, vn){${styleCode}${eventsCode}}};`; } - ctx.addLine(`let w${componentID} = ${templateId} in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[${templateId}]] : false;`); + ctx.addLine(`let w${componentID} = ${templateKey} in parent.__owl__.cmap ? parent.__owl__.children[parent.__owl__.cmap[${templateKey}]] : false;`); let shouldProxy = !ctx.parentNode; if (shouldProxy) { let id = ctx.generateID(); @@ -2924,7 +2955,7 @@ // maybe only do this in dev mode... ctx.addLine(`if (!W${componentID}) {throw new Error('Cannot find the definition of component "' + componentKey${componentID} + '"')}`); ctx.addLine(`w${componentID} = new W${componentID}(parent, props${componentID});`); - ctx.addLine(`parent.__owl__.cmap[${templateId}] = w${componentID}.__owl__.id;`); + ctx.addLine(`parent.__owl__.cmap[${templateKey}] = w${componentID}.__owl__.id;`); if (hasSlots) { const clone = node.cloneNode(true); const slotNodes = clone.querySelectorAll("[t-set]"); @@ -2951,9 +2982,9 @@ } ctx.addLine(`let def${defID} = w${componentID}.__prepare(extra.fiber, ${scopeVars}, sibling);`); // hack: specify empty remove hook to prevent the node from being removed from the DOM - ctx.addLine(`let pvnode = h('dummy', {key: ${templateId}, hook: {insert(vn) { let nvn=w${componentID}.__mount(fiber, pvnode.elm);pvnode.elm=nvn.elm;${refExpr}${transitionsInsertCode}},remove() {},destroy(vn) {${finalizeComponentCode}}}});`); + ctx.addLine(`let pvnode = h('dummy', {key: ${templateKey}, hook: {insert(vn) { let nvn=w${componentID}.__mount(fiber, pvnode.elm);pvnode.elm=nvn.elm;${refExpr}${transitionsInsertCode}},remove() {},destroy(vn) {${finalizeComponentCode}}}});`); ctx.addLine(`const fiber = w${componentID}.__owl__.currentFiber;`); - ctx.addLine(`def${defID}.then(function () {if (fiber.isCancelled) {return;} const vnode = fiber.vnode; pvnode.sel = vnode.sel; ${createHook}});`); + ctx.addLine(`def${defID}.then(function () { if (fiber.isCompleted) { return; } const vnode = fiber.vnode; pvnode.sel = vnode.sel; ${createHook}});`); if (registerCode) { ctx.addLine(registerCode); } @@ -2965,6 +2996,7 @@ if (classObj) { ctx.addLine(`w${componentID}.__owl__.classObj=${classObj};`); } + ctx.addLine(`w${componentID}.__owl__.parentLastFiberId = extra.fiber.id;`); ctx.addLine(`sibling = w${componentID}.__owl__.currentFiber || sibling;`); return true; } @@ -2976,12 +3008,24 @@ this.isRunning = false; this.requestAnimationFrame = requestAnimationFrame; } - addFiber(fiber, callback) { - this.tasks.push({ fiber, callback }); - if (this.isRunning) { - return; - } - this.scheduleTasks(); + addFiber(fiber) { + return new Promise((resolve, reject) => { + if (fiber.error) { + return reject(fiber.error); + } + this.tasks.push({ + fiber, + callback: () => { + if (fiber.error) { + return reject(fiber.error); + } + resolve(); + } + }); + if (!this.isRunning) { + this.scheduleTasks(); + } + }); } /** * Process all current tasks. This only applies to the fibers that are ready. @@ -2991,11 +3035,20 @@ let tasks = this.tasks; this.tasks = []; tasks = tasks.filter(task => { - if (task.fiber.isCancelled) { + if (task.fiber.isCompleted) { + task.callback(); return false; } if (task.fiber.counter === 0) { - task.callback(task.fiber.error); + if (!task.fiber.error) { + try { + task.fiber.complete(); + } + catch (e) { + task.fiber.handleError(e); + } + } + task.callback(); return false; } return true; @@ -3031,10 +3084,12 @@ * states and in general determine the state of the rendering. */ class Fiber { - constructor(parent, component, scope, vars, force) { - // isCancelled means that the rendering corresponding to this fiber and its - // children is cancelled. No extra work should be done. - this.isCancelled = false; + constructor(parent, component, scope, vars, force, target) { + this.id = Fiber.nextId++; + // isCompleted means that the rendering corresponding to this fiber's work is + // done, either because the component has been mounted or patched, or because + // fiber has been cancelled. + this.isCompleted = false; // the fibers corresponding to component updates (updateProps) need to call // the willPatch and patched hooks from the corresponding component. However, // fibers corresponding to a new component do not need to do that. So, the @@ -3058,15 +3113,43 @@ this.scope = scope; this.vars = vars; this.component = component; + this.target = target; this.root = parent ? parent.root : this; this.parent = parent; let oldFiber = component.__owl__.currentFiber; - if (oldFiber && !oldFiber.isCancelled) { - this._remapFiber(oldFiber); + if (oldFiber && !oldFiber.isCompleted) { + if (oldFiber.root === oldFiber && !parent) { + // both oldFiber and this fiber are root fibers + this._reuseFiber(oldFiber); + return oldFiber; + } + else { + this._remapFiber(oldFiber); + } } this.root.counter++; component.__owl__.currentFiber = this; } + /** + * When the oldFiber is not completed yet, and both oldFiber and this fiber + * are root fibers, we want to reuse the oldFiber instead of creating a new + * one. Doing so will guarantee that the initiator(s) of those renderings will + * be notified (the promise will resolve) when the last rendering will be done. + * + * This function thus assumes that oldFiber is a root fiber. + */ + _reuseFiber(oldFiber) { + oldFiber.cancel(); // cancel children fibers + oldFiber.isCompleted = false; // keep the root fiber alive + oldFiber.isRendered = false; // the fiber has to be re-rendered + if (oldFiber.child) { + // remove relation to children + oldFiber.child.parent = null; + oldFiber.child = null; + } + oldFiber.counter = 1; // re-initialize counter + oldFiber.id = Fiber.nextId++; + } /** * In some cases, a rendering initiated at some component can detect that it * should be part of a larger rendering initiated somewhere up the component @@ -3076,7 +3159,7 @@ _remapFiber(oldFiber) { oldFiber.cancel(); if (oldFiber === oldFiber.root) { - oldFiber.root.counter++; + oldFiber.counter++; } if (oldFiber.parent && !this.parent) { // re-map links @@ -3124,7 +3207,26 @@ } } /** - * Apply the given patch queue from a fiber. + * Successfully complete the work of the fiber: call the mount or patch hooks + * and patch the DOM. This function is called once the fiber and its children + * are ready, and the scheduler decides to process it. + */ + complete() { + const component = this.component; + if (this.target) { + component.__patch(this.vnode); + this.target.appendChild(component.el); + if (document.body.contains(this.target)) { + component.__callMounted(); + } + } + else if (component.__owl__.isMounted && this === this.root) { + this.patchComponents(); + } + this.isCompleted = true; + } + /** + * Compute and apply the patch queue of the fiber. * 1) Call 'willPatch' on the component of each patch * 2) Call '__patch' on the component of each patch * 3) Call 'patched' on the component of each patch, in reverse order @@ -3134,41 +3236,32 @@ const doWork = function (f) { if (f.shouldPatch) { patchQueue.push(f); + return f.child; } - return f.child; }; this._walk(doWork); let component = this.component; const patchLen = patchQueue.length; - try { - for (let i = 0; i < patchLen; i++) { - component = patchQueue[i].component; - if (component.__owl__.willPatchCB) { - component.__owl__.willPatchCB(); - } - component.willPatch(); + for (let i = 0; i < patchLen; i++) { + component = patchQueue[i].component; + if (component.__owl__.willPatchCB) { + component.__owl__.willPatchCB(); } - } - catch (e) { - console.error(e); + component.willPatch(); } for (let i = 0; i < patchLen; i++) { const fiber = patchQueue[i]; component = fiber.component; component.__patch(fiber.vnode); + component.__owl__.currentFiber = null; } - try { - for (let i = patchLen - 1; i >= 0; i--) { - component = patchQueue[i].component; - component.patched(); - if (component.__owl__.patchedCB) { - component.__owl__.patchedCB(); - } + for (let i = patchLen - 1; i >= 0; i--) { + component = patchQueue[i].component; + component.patched(); + if (component.__owl__.patchedCB) { + component.__owl__.patchedCB(); } } - catch (e) { - console.error(e); - } } /** * Cancel a fiber and all its children. @@ -3178,7 +3271,7 @@ if (!f.isRendered) { f.root.counter--; } - f.isCancelled = true; + f.isCompleted = true; return f.child; }); } @@ -3191,20 +3284,18 @@ * being in a corrupted state. */ handleError(error) { - let canCatch = false; let component = this.component; - let qweb = component.env.qweb; + this.vnode = component.__owl__.vnode || h("div"); + const qweb = component.env.qweb; let root = component; + let canCatch = false; while (component && !(canCatch = !!component.catchError)) { root = component; component = component.__owl__.parent; } qweb.trigger("error", error); if (canCatch) { - setTimeout(() => { - console.error(error); - component.catchError(error); - }); + component.catchError(error); } else { // the 3 next lines aim to mark the root fiber as being in error, and @@ -3216,6 +3307,7 @@ } } } + Fiber.nextId = 1; //------------------------------------------------------------------------------ // Prop validation helper @@ -3308,12 +3400,12 @@ return true; } let result = isValidProp(prop, propDef.type); - if (propDef.type === Array) { + if (propDef.type === Array && propDef.element) { for (let i = 0, iLen = prop.length; i < iLen; i++) { result = result && isValidProp(prop[i], propDef.element); } } - if (propDef.type === Object) { + if (propDef.type === Object && propDef.shape) { const shape = propDef.shape; for (let key in shape) { result = result && isValidProp(prop[key], shape[key]); @@ -3398,6 +3490,7 @@ children: {}, cmap: {}, currentFiber: null, + parentLastFiberId: 0, boundHandlers: {}, mountedCB: null, willUnmountCB: null, @@ -3505,29 +3598,14 @@ message += `\nMaybe the DOM is not ready yet? (in that case, you can use owl.utils.whenReady)`; throw new Error(message); } - return new Promise((resolve, reject) => { - const fiber = new Fiber(null, this, undefined, undefined, false); - scheduler.addFiber(fiber, err => { - if (err) { - reject(err); - return; - } - if (!__owl__.isDestroyed) { - this.__patch(fiber.vnode); - target.appendChild(this.el); - if (document.body.contains(target)) { - this.__callMounted(); - } - } - resolve(); - }); - if (!__owl__.vnode) { - this.__prepareAndRender(fiber); - } - else { - this.__render(fiber); - } - }); + const fiber = new Fiber(null, this, undefined, undefined, false, target); + if (!__owl__.vnode) { + this.__prepareAndRender(fiber); + } + else { + this.__render(fiber); + } + return scheduler.addFiber(fiber); } /** * The unmount method is the opposite of the mount method. It is useful @@ -3550,24 +3628,35 @@ */ async render(force = false) { const __owl__ = this.__owl__; - if ((!__owl__.isMounted && !__owl__.currentFiber) || - (__owl__.currentFiber && !__owl__.currentFiber.isRendered)) { + if (!__owl__.isMounted && !__owl__.currentFiber) { + // if we get here, this means that the component was either never mounted, + // or was unmounted and some state change triggered a render. Either way, + // we do not want to actually render anything in this case. return; } - return new Promise((resolve, reject) => { - const fiber = new Fiber(null, this, undefined, undefined, force); - scheduler.addFiber(fiber.root, err => { - if (err) { - reject(err); - return; - } - if (__owl__.isMounted && fiber === fiber.root) { - fiber.patchComponents(); - } - resolve(); - }); - this.__render(fiber); + if (__owl__.currentFiber && !__owl__.currentFiber.isRendered) { + return scheduler.addFiber(__owl__.currentFiber.root); + } + // if we aren't mounted at this point, it implies that there is a + // currentFiber that is already rendered (isRendered is true), so we are + // about to be mounted + const isMounted = __owl__.isMounted; + const fiber = new Fiber(null, this, undefined, undefined, force, null); + Promise.resolve().then(() => { + if (__owl__.isMounted || !isMounted) { + // we are mounted (__owl__.isMounted), or if we are currently being + // mounted (!isMounted), so we call __render + this.__render(fiber); + } + else { + // we were mounted when render was called, but we aren't anymore, so we + // were actually about to be unmounted ; we can thus forget about this + // fiber + fiber.isCompleted = true; + __owl__.currentFiber = null; + } }); + return scheduler.addFiber(fiber); } /** * Destroy the component. This operation is quite complex: @@ -3604,7 +3693,7 @@ */ trigger(eventType, payload) { if (this.el) { - const ev = new CustomEvent(eventType, { + const ev = new OwlEvent(this, eventType, { bubbles: true, cancelable: true, detail: payload @@ -3648,7 +3737,7 @@ __owl__.isDestroyed = true; delete __owl__.vnode; if (__owl__.currentFiber) { - __owl__.currentFiber.isCancelled = true; + __owl__.currentFiber.isCompleted = true; } } __callMounted() { @@ -3661,14 +3750,10 @@ } } __owl__.isMounted = true; - try { - this.mounted(); - if (__owl__.mountedCB) { - __owl__.mountedCB(); - } - } - catch (e) { - console.error(e); // TODO : add a test + __owl__.currentFiber = null; + this.mounted(); + if (__owl__.mountedCB) { + __owl__.mountedCB(); } } __callWillUnmount() { @@ -3694,7 +3779,7 @@ const shouldUpdate = parentFiber.force || this.shouldUpdate(nextProps); if (shouldUpdate) { const __owl__ = this.__owl__; - const fiber = new Fiber(parentFiber, this, scope, vars, parentFiber.force); + const fiber = new Fiber(parentFiber, this, scope, vars, parentFiber.force, null); if (!parentFiber.child) { parentFiber.child = fiber; } @@ -3712,7 +3797,7 @@ this.willUpdateProps(nextProps), __owl__.willUpdatePropsCB && __owl__.willUpdatePropsCB(nextProps) ]); - if (fiber.isCancelled) { + if (fiber.isCompleted) { return; } this.props = nextProps; @@ -3727,7 +3812,6 @@ const __owl__ = this.__owl__; const target = __owl__.vnode || document.createElement(vnode.sel); __owl__.vnode = patch(target, vnode); - __owl__.currentFiber = null; } /** * The __prepare method is only called by the t-component directive, when a @@ -3735,7 +3819,7 @@ * parent template. */ __prepare(parentFiber, scope, vars, previousSibling) { - const fiber = new Fiber(parentFiber, this, scope, vars, parentFiber.force); + const fiber = new Fiber(parentFiber, this, scope, vars, parentFiber.force, null); fiber.shouldPatch = false; if (!parentFiber.child) { parentFiber.child = fiber; @@ -3770,13 +3854,12 @@ } catch (e) { fiber.handleError(e); - fiber.vnode = h("div"); // -> we render this div at the end return Promise.resolve(); } if (this.__owl__.isDestroyed) { return Promise.resolve(); } - if (!fiber.isCancelled) { + if (!fiber.isCompleted) { this.__render(fiber); } } @@ -3785,29 +3868,45 @@ if (__owl__.observer) { __owl__.observer.allowMutations = false; } - let vnode; + let error; try { - vnode = __owl__.renderFn(this, { + let vnode = __owl__.renderFn(this, { handlers: __owl__.boundHandlers, fiber: fiber }); + // we iterate over the children to detect those that no longer belong to the + // current rendering: those ones, if not mounted yet, can (and have to) be + // destroyed right now, because they are not in the DOM, and thus we won't + // be notified later on (when patching), that they are removed from the DOM + for (let childKey in __owl__.children) { + let child = __owl__.children[childKey]; + if (!child.__owl__.isMounted && child.__owl__.parentLastFiberId < fiber.id) { + child.destroy(); + } + } + if (!vnode) { + throw new Error(`Rendering '${this.constructor.name}' did not return anything`); + } + fiber.vnode = vnode; + // we apply here the class information described on the component by the + // template (so, something like ) to the actual + // root vnode + if (__owl__.classObj) { + const data = vnode.data; + data.class = Object.assign(data.class || {}, __owl__.classObj); + } } catch (e) { - vnode = __owl__.vnode || h("div"); - fiber.handleError(e); + error = e; } - fiber.vnode = vnode; if (__owl__.observer) { __owl__.observer.allowMutations = true; } - // we apply here the class information described on the component by the - // template (so, something like ) to the actual - // root vnode - if (__owl__.classObj) { - vnode.data.class = Object.assign(vnode.data.class || {}, __owl__.classObj); - } fiber.root.counter--; fiber.isRendered = true; + if (error) { + fiber.handleError(error); + } } /** * Only called by qweb t-component directive @@ -3859,6 +3958,142 @@ // expose scheduler s.t. it can be mocked for testing purposes Component.scheduler = scheduler; + /** + * The `Context` object provides a way to share data between an arbitrary number + * of component. Usually, data is passed from a parent to its children component, + * but when we have to deal with some mostly global information, this can be + * annoying, since each component will need to pass the information to each + * children, even though some or most of them will not use the information. + * + * With a `Context` object, each component can subscribe (with the `useContext` + * hook) to its state, and will be updated whenever the context state is updated. + */ + function partitionBy(arr, fn) { + let lastGroup = false; + let lastValue; + return arr.reduce((acc, cur) => { + let curVal = fn(cur); + if (lastGroup) { + if (curVal === lastValue) { + lastGroup.push(cur); + } + else { + lastGroup = false; + } + } + if (!lastGroup) { + lastGroup = [cur]; + acc.push(lastGroup); + } + lastValue = curVal; + return acc; + }, []); + } + class Context extends EventBus { + constructor(state = {}) { + super(); + this.rev = 1; + // mapping from component id to last observed context id + this.mapping = {}; + this.observer = new Observer(); + this.observer.notifyCB = () => { + // notify components in the next microtask tick to ensure that subscribers + // are notified only once for all changes that occur in the same micro tick + let rev = this.rev; + return Promise.resolve().then(() => { + if (rev === this.rev) { + this.__notifyComponents(); + } + }); + }; + this.state = this.observer.observe(state); + this.subscriptions.update = []; + } + /** + * Instead of using trigger to emit an update event, we actually implement + * our own function to do that. The reason is that we need to be smarter than + * a simple trigger function: we need to wait for parent components to be + * done before doing children components. More precisely, if an update + * as an effect of destroying a children, we do not want to call any code + * from the child, and certainly not render it. + * + * This method implements a simple grouping algorithm by depth. If we have + * connected components of depths [2, 4,4,4,4, 3,8,8], the Context will notify + * them in the following groups: [2], [4,4,4,4], [3], [8,8]. Each group will + * be updated sequentially, but each components in a given group will be done in + * parallel. + * + * This is a very simple algorithm, but it avoids checking if a given + * component is a child of another. + */ + async __notifyComponents() { + const rev = ++this.rev; + const subscriptions = this.subscriptions.update; + const groups = partitionBy(subscriptions, s => (s.owner ? s.owner.__owl__.depth : -1)); + for (let group of groups) { + const proms = group.map(sub => sub.callback.call(sub.owner, rev)); + // at this point, each component in the current group has registered a + // top level fiber in the scheduler. It could happen that rendering these + // components is done (if they have no children). This is why we manually + // flush the scheduler. This will force the scheduler to check + // immediately if they are done, which will cause their rendering + // promise to resolve earlier, which means that there is a chance of + // processing the next group in the same frame. + scheduler.flush(); + await Promise.all(proms); + } + } + } + /** + * The`useContext` hook is the normal way for a component to register themselve + * to context state changes. The `useContext` method returns the context state + */ + function useContext(ctx) { + const component = Component.current; + return useContextWithCB(ctx, component, component.render.bind(component)); + } + function useContextWithCB(ctx, component, method) { + const __owl__ = component.__owl__; + const id = __owl__.id; + const mapping = ctx.mapping; + if (id in mapping) { + return ctx.state; + } + if (!__owl__.observer) { + __owl__.observer = new Observer(); + __owl__.observer.notifyCB = component.render.bind(component); + } + const currentCB = __owl__.observer.notifyCB; + __owl__.observer.notifyCB = function () { + if (ctx.rev > mapping[id]) { + // in this case, the context has been updated since we were rendering + // last, and we do not need to render here with the observer. A + // rendering is coming anyway, with the correct props. + return; + } + currentCB(); + }; + mapping[id] = 0; + const renderFn = __owl__.renderFn; + __owl__.renderFn = function (comp, params) { + mapping[id] = ctx.rev; + return renderFn(comp, params); + }; + ctx.on("update", component, async (contextRev) => { + if (mapping[id] < contextRev) { + mapping[id] = contextRev; + await method(); + } + }); + const __destroy = component.__destroy; + component.__destroy = (parent) => { + ctx.off("update", component); + delete mapping[id]; + __destroy.call(component, parent); + }; + return ctx.state; + } + /** * Owl Hook System * @@ -3986,135 +4221,6 @@ useSubEnv: useSubEnv }); - /** - * The `Context` object provides a way to share data between an arbitrary number - * of component. Usually, data is passed from a parent to its children component, - * but when we have to deal with some mostly global information, this can be - * annoying, since each component will need to pass the information to each - * children, even though some or most of them will not use the information. - * - * With a `Context` object, each component can subscribe (with the `useContext` - * hook) to its state, and will be updated whenever the context state is updated. - */ - function partitionBy(arr, fn) { - let lastGroup = false; - let lastValue; - return arr.reduce((acc, cur) => { - let curVal = fn(cur); - if (lastGroup) { - if (curVal === lastValue) { - lastGroup.push(cur); - } - else { - lastGroup = false; - } - } - if (!lastGroup) { - lastGroup = [cur]; - acc.push(lastGroup); - } - lastValue = curVal; - return acc; - }, []); - } - class Context extends EventBus { - constructor(state = {}) { - super(); - this.rev = 1; - // mapping from component id to last observed context id - this.mapping = {}; - this.observer = new Observer(); - this.observer.notifyCB = this.__notifyComponents.bind(this); - this.state = this.observer.observe(state); - this.subscriptions.update = []; - } - /** - * Instead of using trigger to emit an update event, we actually implement - * our own function to do that. The reason is that we need to be smarter than - * a simple trigger function: we need to wait for parent components to be - * done before doing children components. More precisely, if an update - * as an effect of destroying a children, we do not want to call any code - * from the child, and certainly not render it. - * - * This method implements a simple grouping algorithm by depth. If we have - * connected components of depths [2, 4,4,4,4, 3,8,8], the Context will notify - * them in the following groups: [2], [4,4,4,4], [3], [8,8]. Each group will - * be updated sequentially, but each components in a given group will be done in - * parallel. - * - * This is a very simple algorithm, but it avoids checking if a given - * component is a child of another. - */ - async __notifyComponents() { - const rev = ++this.rev; - const subscriptions = this.subscriptions.update; - const groups = partitionBy(subscriptions, s => (s.owner ? s.owner.__owl__.depth : -1)); - for (let group of groups) { - const proms = Promise.all(group.map(sub => { - if (sub.owner ? sub.owner.__owl__.isMounted : true) { - return sub.callback.call(sub.owner, rev); - } - })); - // at this point, each component in the current group has registered a - // top level fiber in the scheduler. It could happen that rendering these - // components is done (if they have no children). This is why we manually - // flush the scheduler. This will force the scheduler to check - // immediately if they are done, which will cause their rendering - // promise to resolve earlier, which means that there is a chance of - // processing the next group in the same frame. - scheduler.flush(); - await proms; - } - } - } - /** - * The`useContext` hook is the normal way for a component to register themselve - * to context state changes. The `useContext` method returns the context state - */ - function useContext(ctx) { - const component = Component.current; - return useContextWithCB(ctx, component, component.render.bind(component)); - } - function useContextWithCB(ctx, component, method) { - const __owl__ = component.__owl__; - const id = __owl__.id; - const mapping = ctx.mapping; - if (id in mapping) { - return ctx.state; - } - if (!__owl__.observer) { - __owl__.observer = new Observer(); - __owl__.observer.notifyCB = component.render.bind(component); - } - const currentCB = __owl__.observer.notifyCB; - __owl__.observer.notifyCB = function () { - if (ctx.rev > mapping[id]) { - // in this case, the context has been updated since we were rendering - // last, and we do not need to render here with the observer. A - // rendering is coming anyway, with the correct props. - return; - } - currentCB(); - }; - mapping[id] = 0; - const renderFn = __owl__.renderFn; - __owl__.renderFn = function (comp, params) { - mapping[id] = ctx.rev; - return renderFn(comp, params); - }; - ctx.on("update", component, async (contextRev) => { - if (mapping[id] < contextRev) { - mapping[id] = contextRev; - await method(); - } - }); - onWillUnmount(() => { - ctx.off("update", component); - delete mapping[id]; - }); - return ctx.state; - } - class Store extends Context { constructor(config) { super(config.state); @@ -4173,9 +4279,9 @@ }); useContextWithCB(store, component, function () { let shouldRender = false; - updateFunctions.forEach(function (updateFn) { - shouldRender = updateFn() || shouldRender; - }); + for (let fn of updateFunctions) { + shouldRender = fn() || shouldRender; + } if (shouldRender) { return component.render(); } @@ -4544,9 +4650,9 @@ exports.useState = useState$1; exports.utils = utils; - exports.__info__.version = '1.0.0-alpha3'; - exports.__info__.date = '2019-11-13T14:44:19.699Z'; - exports.__info__.hash = 'd249f50'; + exports.__info__.version = '1.0.0-alpha4'; + exports.__info__.date = '2019-11-22T12:03:23.892Z'; + exports.__info__.hash = 'ff76747'; exports.__info__.url = 'https://github.com/odoo/owl'; }(this.owl = this.owl || {})); diff --git a/playground/app.js b/playground/app.js index f4d841fc..3345f67a 100644 --- a/playground/app.js +++ b/playground/app.js @@ -120,20 +120,34 @@ async function makeApp(js, css, xml) { .join("\n"); const JS = ` -async function loadTemplates() { - try { - return owl.utils.loadFile('app.xml'); - } catch(e) { - console.error(\`This app requires a static server. If you have python installed, try 'python app.py'\`); - } -} - -function start([TEMPLATES]) { - // Application code +/** + * This is the javascript code defined in the playground. + * In a larger application, this code should probably be moved in different + * sub files. + */ +function app() { ${processedJS} } -Promise.all([loadTemplates(), owl.utils.whenReady()]).then(start); +/** + * Initialization code + * This code load templates, and make sure everything is properly connected. + */ +async function start() { + let templates; + try { + templates = await owl.utils.loadFile('app.xml'); + } catch(e) { + console.error(\`This app requires a static server. If you have python installed, try 'python app.py'\`); + return; + } + const env = { qweb: new owl.QWeb({templates})}; + owl.Component.env = env; + await owl.utils.whenReady(); + app(); +} + +start(); `; zip.file("app.js", JS);