diff --git a/owl.js b/owl.js index 8c03ea0d..3da6d7fe 100644 --- a/owl.js +++ b/owl.js @@ -212,7 +212,8 @@ } function makePropSetter(name) { return function setProp(value) { - this[name] = value || ""; + // support 0, fallback to empty string for other falsy values + this[name] = value === 0 ? 0 : value || ""; }; } function isProp(tag, key) { @@ -518,7 +519,7 @@ const characterDataSetData = getDescriptor$1(characterDataProto, "data").set; const nodeGetFirstChild = getDescriptor$1(nodeProto$2, "firstChild").get; const nodeGetNextSibling = getDescriptor$1(nodeProto$2, "nextSibling").get; - const NO_OP$1 = () => { }; + const NO_OP = () => { }; const cache$1 = {}; /** * Compiling blocks is a multi-step process: @@ -822,7 +823,7 @@ idx: info.idx, refIdx: info.refIdx, setData: makeRefSetter(index, ctx.refList), - updateData: NO_OP$1, + updateData: NO_OP, }); } } @@ -1623,6 +1624,11 @@ if (key === TARGET) { return target; } + // non-writable non-configurable properties cannot be made reactive + const desc = Object.getOwnPropertyDescriptor(target, key); + if (desc && !desc.writable && !desc.configurable) { + return Reflect.get(target, key, proxy); + } observeTargetKey(target, key, callback); return possiblyReactive(Reflect.get(target, key, proxy), callback); }, @@ -1695,6 +1701,23 @@ } }; } + /** + * Creates a forEach function that will delegate to forEach on the underlying + * collection while observing key changes, and keys as they're iterated over, + * and making the passed keys/values reactive. + * + * @param target @see reactive + * @param callback @see reactive + */ + function makeForEachObserver(target, callback) { + return function forEach(forEachCb, thisArg) { + observeTargetKey(target, KEYCHANGES, callback); + target.forEach(function (val, key, targetObj) { + observeTargetKey(target, key, callback); + forEachCb.call(thisArg, possiblyReactive(val, callback), possiblyReactive(key, callback), possiblyReactive(targetObj, callback)); + }, thisArg); + }; + } /** * Creates a function that will delegate to an underlying method, and check if * that method has modified the presence or value of a key, and notify the @@ -1753,6 +1776,7 @@ values: makeIteratorObserver("values", target, callback), entries: makeIteratorObserver("entries", target, callback), [Symbol.iterator]: makeIteratorObserver(Symbol.iterator, target, callback), + forEach: makeForEachObserver(target, callback), clear: makeClearNotifier(target), get size() { observeTargetKey(target, KEYCHANGES, callback); @@ -1768,6 +1792,7 @@ values: makeIteratorObserver("values", target, callback), entries: makeIteratorObserver("entries", target, callback), [Symbol.iterator]: makeIteratorObserver(Symbol.iterator, target, callback), + forEach: makeForEachObserver(target, callback), clear: makeClearNotifier(target), get size() { observeTargetKey(target, KEYCHANGES, callback); @@ -1821,11 +1846,13 @@ await Promise.resolve(); if (!called) { called = true; - callback(); // wait for all calls in this microtick to fall through before resetting "called" - // so that only the first call to the batched function calls the original callback - await Promise.resolve(); - called = false; + // so that only the first call to the batched function calls the original callback. + // Schedule this before calling the callback so that calls to the batched function + // within the callback will proceed only after resetting called to false, and have + // a chance to execute the callback again + Promise.resolve().then(() => (called = false)); + callback(); } }; } @@ -1888,7 +1915,7 @@ // Maps fibers to thrown errors const fibersInError = new WeakMap(); const nodeErrorHandlers = new WeakMap(); - function _handleError(node, error, isFirstRound = false) { + function _handleError(node, error) { if (!node) { return false; } @@ -1898,22 +1925,19 @@ } const errorHandlers = nodeErrorHandlers.get(node); if (errorHandlers) { - let stopped = false; + let handled = false; // execute in the opposite order for (let i = errorHandlers.length - 1; i >= 0; i--) { try { errorHandlers[i](error); - stopped = true; + handled = true; break; } catch (e) { error = e; } } - if (stopped) { - if (isFirstRound && fiber && fiber.node.fiber) { - fiber.root.counter--; - } + if (handled) { return true; } } @@ -1931,7 +1955,7 @@ current = current.parent; } while (current); fibersInError.set(fiber.root, error); - const handled = _handleError(node, error, true); + const handled = _handleError(node, error); if (!handled) { console.warn(`[Owl] Unhandled error. Destroying the root component`); try { @@ -1955,8 +1979,14 @@ let current = node.fiber; if (current) { let root = current.root; - root.counter = root.counter + 1 - cancelFibers(current.children); + // lock root fiber because canceling children fibers may destroy components, + // which means any arbitrary code can be run in onWillDestroy, which may + // trigger new renderings + root.locked = true; + root.setCounter(root.counter + 1 - cancelFibers(current.children)); + root.locked = false; current.children = []; + current.childrenMap = {}; current.bdom = null; if (fibersInError.has(current)) { fibersInError.delete(current); @@ -1974,13 +2004,21 @@ } return fiber; } + function throwOnRender() { + throw new Error("Attempted to render cancelled fiber"); + } /** * @returns number of not-yet rendered fibers cancelled */ function cancelFibers(fibers) { let result = 0; for (let fiber of fibers) { - fiber.node.fiber = null; + let node = fiber.node; + fiber.render = throwOnRender; + if (node.status === 0 /* NEW */) { + node.destroy(); + } + node.fiber = null; if (fiber.bdom) { // if fiber has been rendered, this means that the component props have // been updated. however, this fiber will not be patched to the dom, so @@ -1988,7 +2026,7 @@ // the same props, and skip the render completely. With the next line, // we kindly request the component code to force a render, so it works as // expected. - fiber.node.forceNextRender = true; + node.forceNextRender = true; } else { result++; @@ -2003,12 +2041,13 @@ this.children = []; this.appliedToDom = false; this.deep = false; + this.childrenMap = {}; this.node = node; this.parent = parent; if (parent) { this.deep = parent.deep; const root = parent.root; - root.counter++; + root.setCounter(root.counter + 1); this.root = root; parent.children.push(this); } @@ -2016,6 +2055,42 @@ this.root = this; } } + render() { + // if some parent has a fiber => register in followup + let prev = this.root.node; + let scheduler = prev.app.scheduler; + let current = prev.parent; + while (current) { + if (current.fiber) { + let root = current.fiber.root; + if (root.counter === 0 && prev.parentKey in current.fiber.childrenMap) { + current = root.node; + } + else { + scheduler.delayedRenders.push(this); + return; + } + } + prev = current; + current = current.parent; + } + // there are no current rendering from above => we can render + this._render(); + } + _render() { + const node = this.node; + const root = this.root; + if (root) { + try { + this.bdom = true; + this.bdom = node.renderFn(); + } + catch (e) { + handleError({ node, error: e }); + } + root.setCounter(root.counter - 1); + } + } } class RootFiber extends Fiber { constructor() { @@ -2077,6 +2152,12 @@ handleError({ fiber: current || this, error: e }); } } + setCounter(newValue) { + this.counter = newValue; + if (newValue === 0) { + this.node.app.scheduler.flush(); + } + } } class MountFiber extends RootFiber { constructor(node, target, options = {}) { @@ -2088,6 +2169,7 @@ let current = this; try { const node = this.node; + node.children = this.childrenMap; node.app.constructor.validateTarget(this.target); if (node.bdom) { // this is a complicated situation: if we mount a fiber with an existing @@ -2293,17 +2375,10 @@ const node = getCurrent(); let render = batchedRenderFunctions.get(node); if (!render) { - render = batched(node.render.bind(node)); + render = batched(node.render.bind(node, false)); batchedRenderFunctions.set(node, render); // manual implementation of onWillDestroy to break cyclic dependency node.willDestroy.push(clearReactivesForCallback.bind(null, render)); - if (node.app.dev) { - Object.defineProperty(node, "subscriptions", { - get() { - return getSubscriptions(render); - }, - }); - } } return reactive(state, render); } @@ -2318,14 +2393,8 @@ function component(name, props, key, ctx, parent) { let node = ctx.children[key]; let isDynamic = typeof name !== "string"; - if (node) { - if (node.status < 1 /* MOUNTED */) { - node.destroy(); - node = undefined; - } - else if (node.status === 2 /* DESTROYED */) { - node = undefined; - } + if (node && node.status === 2 /* DESTROYED */) { + node = undefined; } if (isDynamic && node && node.component.constructor !== name) { node = undefined; @@ -2356,14 +2425,15 @@ throw new Error(`Cannot find the definition of component "${name}"`); } } - node = new ComponentNode(C, props, ctx.app, ctx); + node = new ComponentNode(C, props, ctx.app, ctx, key); ctx.children[key] = node; node.initiateRender(new Fiber(node, parentFiber)); } + parentFiber.childrenMap[key] = node; return node; } class ComponentNode { - constructor(C, props, app, parent) { + constructor(C, props, app, parent, parentKey) { this.fiber = null; this.bdom = null; this.status = 0 /* NEW */; @@ -2379,7 +2449,8 @@ this.willDestroy = []; currentNode = this; this.app = app; - this.parent = parent || null; + this.parent = parent; + this.parentKey = parentKey; this.level = parent ? parent.level + 1 : 0; applyDefaultProps(props, C); const env = (parent && parent.childEnv) || app.env; @@ -2409,12 +2480,12 @@ return; } if (this.status === 0 /* NEW */ && this.fiber === fiber) { - this._render(fiber); + fiber.render(); } } - async render(deep = false) { + async render(deep) { let current = this.fiber; - if (current && current.root.locked) { + if (current && (current.root.locked || current.bdom === true)) { await Promise.resolve(); // situation may have changed after the microtask tick current = this.fiber; @@ -2453,16 +2524,7 @@ // embedded in a rendering coming from above, so the fiber will be rendered // in the next microtick anyway, so we should not render it again. if (this.fiber === fiber && (current || !fiber.parent)) { - this._render(fiber); - } - } - _render(fiber) { - try { - fiber.bdom = this.renderFn(); - fiber.root.counter--; - } - catch (e) { - handleError({ node: this, error: e }); + fiber.render(); } } destroy() { @@ -2482,8 +2544,15 @@ for (let child of Object.values(this.children)) { child._destroy(); } - for (let cb of this.willDestroy) { - cb.call(component); + if (this.willDestroy.length) { + try { + for (let cb of this.willDestroy) { + cb.call(component); + } + } + catch (e) { + handleError({ error: e, node: this }); + } } this.status = 2 /* DESTROYED */; } @@ -2502,7 +2571,7 @@ return; } component.props = props; - this._render(fiber); + fiber.render(); const parentRoot = parentFiber.root; if (this.willPatch.length) { parentRoot.willPatch.push(fiber); @@ -2549,6 +2618,7 @@ bdom.mount(parent, anchor); this.status = 1 /* MOUNTED */; this.fiber.appliedToDom = true; + this.children = this.fiber.childrenMap; this.fiber = null; } moveBefore(other, afterNode) { @@ -2564,10 +2634,8 @@ } _patch() { const hasChildren = Object.keys(this.children).length > 0; + this.children = this.fiber.childrenMap; this.bdom.patch(this.fiber.bdom, hasChildren); - if (hasChildren) { - this.cleanOutdatedChildren(); - } this.fiber.appliedToDom = true; this.fiber = null; } @@ -2577,18 +2645,15 @@ remove() { this.bdom.remove(); } - cleanOutdatedChildren() { - const children = this.children; - for (const key in children) { - const node = children[key]; - const status = node.status; - if (status !== 1 /* MOUNTED */) { - delete children[key]; - if (status !== 2 /* DESTROYED */) { - node.destroy(); - } - } - } + // --------------------------------------------------------------------------- + // Some debug helpers + // --------------------------------------------------------------------------- + get name() { + return this.component.constructor.name; + } + get subscriptions() { + const render = batchedRenderFunctions.get(this); + return render ? getSubscriptions(render) : []; } } @@ -2598,59 +2663,59 @@ class Scheduler { constructor() { this.tasks = new Set(); - this.isRunning = false; + this.frame = 0; + this.delayedRenders = []; this.requestAnimationFrame = Scheduler.requestAnimationFrame; } - start() { - this.isRunning = true; - this.scheduleTasks(); - } - stop() { - this.isRunning = false; - } addFiber(fiber) { this.tasks.add(fiber.root); - if (!this.isRunning) { - this.start(); - } } /** * Process all current tasks. This only applies to the fibers that are ready. * Other tasks are left unchanged. */ flush() { - this.tasks.forEach((fiber) => { - if (fiber.root !== fiber) { - this.tasks.delete(fiber); - return; - } - const hasError = fibersInError.has(fiber); - if (hasError && fiber.counter !== 0) { - this.tasks.delete(fiber); - return; - } - if (fiber.node.status === 2 /* DESTROYED */) { - this.tasks.delete(fiber); - return; - } - if (fiber.counter === 0) { - if (!hasError) { - fiber.complete(); + if (this.delayedRenders.length) { + let renders = this.delayedRenders; + this.delayedRenders = []; + for (let f of renders) { + if (f.root && f.node.status !== 2 /* DESTROYED */ && f.node.fiber === f) { + f.render(); } - this.tasks.delete(fiber); } - }); - if (this.tasks.size === 0) { - this.stop(); + } + if (this.frame === 0) { + this.frame = this.requestAnimationFrame(() => { + this.frame = 0; + this.tasks.forEach((fiber) => this.processFiber(fiber)); + for (let task of this.tasks) { + if (task.node.status === 2 /* DESTROYED */) { + this.tasks.delete(task); + } + } + }); } } - scheduleTasks() { - this.requestAnimationFrame(() => { - this.flush(); - if (this.isRunning) { - this.scheduleTasks(); + processFiber(fiber) { + if (fiber.root !== fiber) { + this.tasks.delete(fiber); + return; + } + const hasError = fibersInError.has(fiber); + if (hasError && fiber.counter !== 0) { + this.tasks.delete(fiber); + return; + } + if (fiber.node.status === 2 /* DESTROYED */) { + this.tasks.delete(fiber); + return; + } + if (fiber.counter === 0) { + if (!hasError) { + fiber.complete(); } - }); + this.tasks.delete(fiber); + } } } // capture the value of requestAnimationFrame as soon as possible, to avoid @@ -3939,8 +4004,11 @@ let slotStr = []; for (let slotName in ast.slots) { const slotAst = ast.slots[slotName]; - const name = this.compileInNewTarget("slot", slotAst.content, ctx, slotAst.on); - const params = [`__render: ${name}, __ctx: ${ctxStr}`]; + const params = []; + if (slotAst.content) { + const name = this.compileInNewTarget("slot", slotAst.content, ctx, slotAst.on); + params.push(`__render: ${name}, __ctx: ${ctxStr}`); + } const scope = ast.slots[slotName].scope; if (scope) { params.push(`__scope: "${scope}"`); @@ -4042,7 +4110,7 @@ if (dynamic) { let name = this.generateId("slot"); this.define(name, slotName); - blockString = `toggler(${name}, callSlot(ctx, node, key, ${name}), ${dynamic}, ${scope})`; + blockString = `toggler(${name}, callSlot(ctx, node, key, ${name}, ${dynamic}, ${scope}))`; } else { blockString = `callSlot(ctx, node, key, ${slotName}, ${dynamic}, ${scope})`; @@ -4583,28 +4651,26 @@ slotNode.removeAttribute("t-set-slot"); slotNode.remove(); const slotAst = parseNode(slotNode, ctx); - if (slotAst) { - let on = null; - let attrs = null; - let scope = null; - for (let attributeName of slotNode.getAttributeNames()) { - const value = slotNode.getAttribute(attributeName); - if (attributeName === "t-slot-scope") { - scope = value; - continue; - } - else if (attributeName.startsWith("t-on-")) { - on = on || {}; - on[attributeName.slice(5)] = value; - } - else { - attrs = attrs || {}; - attrs[attributeName] = value; - } + let on = null; + let attrs = null; + let scope = null; + for (let attributeName of slotNode.getAttributeNames()) { + const value = slotNode.getAttribute(attributeName); + if (attributeName === "t-slot-scope") { + scope = value; + continue; + } + else if (attributeName.startsWith("t-on-")) { + on = on || {}; + on[attributeName.slice(5)] = value; + } + else { + attrs = attrs || {}; + attrs[attributeName] = value; } - slots = slots || {}; - slots[name] = { content: slotAst, on, attrs, scope }; } + slots = slots || {}; + slots[name] = { content: slotAst, on, attrs, scope }; } // default slot const defaultContent = parseChildNodes(clone, ctx); @@ -4835,12 +4901,26 @@ return new Function("bdom, helpers", code); } + const TIMEOUT = Symbol("timeout"); function wrapError(fn, hookName) { const error = new Error(`The following error occurred in ${hookName}: `); + const timeoutError = new Error(`${hookName}'s promise hasn't resolved after 3 seconds`); + const node = getCurrent(); return (...args) => { try { const result = fn(...args); if (result instanceof Promise) { + if (hookName === "onWillStart" || hookName === "onWillUpdateProps") { + const fiber = node.fiber; + Promise.race([ + result, + new Promise((resolve) => setTimeout(() => resolve(TIMEOUT), 3000)), + ]).then((res) => { + if (res === TIMEOUT && node.fiber === fiber) { + console.warn(timeoutError); + } + }); + } return result.catch((cause) => { error.cause = cause; if (cause instanceof Error) { @@ -4936,7 +5016,7 @@ } setup() { } render(deep = false) { - this.__owl__.render(deep); + this.__owl__.render(deep === true); } } Component.template = ""; @@ -5233,13 +5313,19 @@ } this.helpers = makeHelpers(this.getTemplate.bind(this)); } - addTemplate(name, template, options = {}) { - if (name in this.rawTemplates && !options.allowDuplicate) { - throw new Error(`Template ${name} already defined`); + addTemplate(name, template) { + if (name in this.rawTemplates) { + const rawTemplate = this.rawTemplates[name]; + const currentAsString = typeof rawTemplate === "string" ? rawTemplate : rawTemplate.outerHTML; + const newAsString = typeof template === "string" ? template : template.outerHTML; + if (currentAsString === newAsString) { + return; + } + throw new Error(`Template ${name} already defined with different content`); } this.rawTemplates[name] = template; } - addTemplates(xml, options = {}) { + addTemplates(xml) { if (!xml) { // empty string return; @@ -5247,7 +5333,7 @@ xml = xml instanceof Document ? xml : parseXML(xml); for (const template of xml.querySelectorAll("[t-name]")) { const name = template.getAttribute("t-name"); - this.addTemplate(name, template, options); + this.addTemplate(name, template); } } getTemplate(name) { @@ -5318,7 +5404,7 @@ See https://github.com/odoo/owl/blob/${hash}/doc/reference/app.md#configuration return prom; } makeNode(Component, props) { - return new ComponentNode(Component, props, this); + return new ComponentNode(Component, props, this, null, null); } mountNode(node, target, options) { const promise = new Promise((resolve, reject) => { @@ -5350,6 +5436,7 @@ See https://github.com/odoo/owl/blob/${hash}/doc/reference/app.md#configuration } destroy() { if (this.root) { + this.scheduler.flush(); this.root.destroy(); } } @@ -5415,10 +5502,6 @@ See https://github.com/odoo/owl/blob/${hash}/doc/reference/app.md#configuration const node = getCurrent(); node.childEnv = extendEnv(node.childEnv, envExtension); } - // ----------------------------------------------------------------------------- - // useEffect - // ----------------------------------------------------------------------------- - const NO_OP = () => { }; /** * This hook will run a callback when a component is mounted and patched, and * will run a cleanup function before patching and before unmounting the @@ -5436,18 +5519,20 @@ See https://github.com/odoo/owl/blob/${hash}/doc/reference/app.md#configuration let dependencies; onMounted(() => { dependencies = computeDependencies(); - cleanup = effect(...dependencies) || NO_OP; + cleanup = effect(...dependencies); }); onPatched(() => { const newDeps = computeDependencies(); const shouldReapply = newDeps.some((val, i) => val !== dependencies[i]); if (shouldReapply) { dependencies = newDeps; - cleanup(); - cleanup = effect(...dependencies) || NO_OP; + if (cleanup) { + cleanup(); + } + cleanup = effect(...dependencies); } }); - onWillUnmount(() => cleanup()); + onWillUnmount(() => cleanup && cleanup()); } // ----------------------------------------------------------------------------- // useExternalListener @@ -5527,9 +5612,9 @@ See https://github.com/odoo/owl/blob/${hash}/doc/reference/app.md#configuration Object.defineProperty(exports, '__esModule', { value: true }); - __info__.version = '2.0.0-beta.3'; - __info__.date = '2022-03-08T11:47:57.414Z'; - __info__.hash = 'c356351'; + __info__.version = '2.0.0-beta-7'; + __info__.date = '2022-04-27T09:08:50.320Z'; + __info__.hash = '0cd66c8'; __info__.url = 'https://github.com/odoo/owl';