[MERGE][REF] *: update owl to version 2 with fine-grained reactivity

This PR updates owl to version 2 with fine-grained reactivity, and adapts the code in Odoo accordingly.

closes odoo/odoo#84314

Related: odoo/enterprise#24218
Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
Co-authored-by: Géry Debongnie [ged@odoo.com](mailto:ged@odoo.com)
This commit is contained in:
Odoo's Mergebot
2022-02-25 13:35:45 +01:00
committed by GitHub
co-authored by Géry Debongnie [ged@odoo.com](mailto:ged@odoo.com)
35 changed files with 405 additions and 422 deletions
@@ -16,7 +16,7 @@ import { BaseAutomationErrorDialog } from "../src/js/base_automation_error_dialo
import { patchWithCleanup,getFixture, mount, nextTick } from "@web/../tests/helpers/utils";
import { DialogContainer } from "@web/core/dialog/dialog_container";
const { onMounted } = owl;
const { onMounted, toRaw } = owl;
const serviceRegistry = registry.category("services");
@@ -72,7 +72,11 @@ QUnit.module("base_automation", {}, function () {
patchWithCleanup(BaseAutomationErrorDialog.prototype, {
setup() {
assert.equal(this.props.data.context, errorContext, "Received the correct error context");
assert.equal(
toRaw(this.props.data.context),
errorContext,
"Received the correct error context"
);
this._super();
},
});
@@ -1,49 +0,0 @@
/** @odoo-module **/
const { onPatched, useComponent } = owl;
/**
* Shallow compares props `a` and `b`.
*
* @param {Object} a
* @param {Object} b
* @returns {boolean}
*/
function isEqual(a, b) {
const keys = Object.keys(a);
if (Object.keys(b).length !== keys.length) {
return false;
}
for (const key of keys) {
if (a[key] !== b[key]) {
return false;
}
}
return true;
}
/**
* This hook overrides the `shouldUpdate` method to ensure the component is only
* updated if its props actually changed. This is especially useful to use on
* components for which an extra render costs proportionally a lot more than
* comparing props.
*/
export function useShouldUpdateBasedOnProps() {
const component = useComponent();
let forceRender = false;
component.shouldUpdate = nextProps => {
if (forceRender) {
return true;
}
const allNewProps = Object.assign({}, nextProps);
const defaultProps = component.constructor.defaultProps;
for (const key in defaultProps) {
if (allNewProps[key] === undefined) {
allNewProps[key] = defaultProps[key];
}
}
forceRender = !isEqual(component.props, allNewProps);
return forceRender;
};
onPatched(() => forceRender = false);
}
@@ -1,7 +1,6 @@
/** @odoo-module */
import { useModels } from "@mail/component_hooks/use_models/use_models";
import { useShouldUpdateBasedOnProps } from "@mail/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props";
const { useRef } = owl;
@@ -29,7 +28,6 @@ export function registerMessagingComponent(ComponentClass) {
setup() {
this.root = useRef('root');
useModels();
useShouldUpdateBasedOnProps();
super.setup();
}
get className() {
@@ -155,6 +155,13 @@ FormRenderer.include({
* @override
*/
_updateView() {
/**
* The chatter is detached before it's removed on the super._updateView function,
* this is done to avoid losing the event handlers.
*/
if (this._hasChatter()) {
this._chatterContainerTarget.remove();
}
this._super(...arguments);
if (this._hasChatter()) {
this.$chatterContainerHook.replaceWith(this._chatterContainerTarget);
@@ -49,7 +49,7 @@ export class ChromeAdapter extends Component {
const updateUI = debounce(() => {
if (this.env.isMobile !== currentIsMobile) {
currentIsMobile = this.env.isMobile;
this.render();
this.render(true);
}
}, 15);
useExternalListener(window, "resize", updateUI);
+5 -3
View File
@@ -10,7 +10,7 @@ odoo.define('point_of_sale.Chrome', function(require) {
const PopupControllerMixin = require('point_of_sale.PopupControllerMixin');
const Registries = require('point_of_sale.Registries');
const IndependentToOrderScreen = require('point_of_sale.IndependentToOrderScreen');
const { identifyError } = require('point_of_sale.utils');
const { identifyError, batched } = require('point_of_sale.utils');
const { odooExceptionTitleMap } = require("@web/core/errors/error_dialogs");
const { ConnectionLostError, ConnectionAbortedError, RPCError } = require('@web/core/network/rpc_service');
const { useBus } = require("@web/core/utils/hooks");
@@ -26,6 +26,7 @@ odoo.define('point_of_sale.Chrome', function(require) {
useRef,
useState,
useSubEnv,
reactive,
} = owl;
/**
@@ -74,7 +75,8 @@ odoo.define('point_of_sale.Chrome', function(require) {
this.previous_touch_y_coordinate = -1;
useSubEnv({ pos: useState(this.env.pos) });
const pos = reactive(this.env.pos, batched(() => this.render(true)))
useSubEnv({ pos });
onMounted(() => {
// remove default webclient handlers that induce click delay
@@ -200,7 +202,7 @@ odoo.define('point_of_sale.Chrome', function(require) {
this.env.pos.loadPartnersBackground();
}
if (this.env.pos.config.limited_products_loading && this.env.pos.config.product_load_background) {
this.env.pos.loadProductsBackground().then(() => this.render());
this.env.pos.loadProductsBackground().then(() => this.render(true));
}
}
@@ -78,7 +78,7 @@ odoo.define('point_of_sale.ClientDetailsEdit', function(require) {
const resizedImage = await this._resizeImage(loadedImage, 800, 600);
this.changes.image_1920 = resizedImage.toDataURL();
// Rerender to reflect the changes in the screen
this.render();
this.render(true);
}
}
}
@@ -56,7 +56,7 @@ odoo.define('point_of_sale.ClientListScreen', function(require) {
back() {
if(this.state.detailIsShown) {
this.state.detailIsShown = false;
this.render();
this.render(true);
} else {
this.props.resolve({ confirmed: false, payload: false });
this.trigger('close-temp-screen');
@@ -110,7 +110,7 @@ odoo.define('point_of_sale.ClientListScreen', function(require) {
this.state.selectedClient = clients[0];
this.clickNext();
} else {
this.render();
this.render(true);
}
}
clickClient(partner) {
@@ -119,14 +119,14 @@ odoo.define('point_of_sale.ClientListScreen', function(require) {
} else {
this.state.selectedClient = partner;
}
this.render();
this.render(true);
}
editClient() {
this.state.editModeProps = {
partner: this.state.selectedClient,
};
this.state.detailIsShown = true;
this.render();
this.render(true);
}
clickNext() {
this.state.selectedClient = this.nextButton.command === 'set' ? this.state.selectedClient : null;
@@ -142,7 +142,7 @@ odoo.define('point_of_sale.ClientListScreen', function(require) {
partner: this.state.selectedClient,
};
}
this.render();
this.render(true);
}
async saveChanges(event) {
try {
@@ -154,7 +154,7 @@ odoo.define('point_of_sale.ClientListScreen', function(require) {
await this.env.pos.load_new_partners();
this.state.selectedClient = this.env.pos.db.get_partner_by_id(partnerId);
this.state.detailIsShown = false;
this.render();
this.render(true);
} catch (error) {
if (isConnectionError(error)) {
await this.showPopup('OfflineErrorPopup', {
@@ -175,7 +175,7 @@ odoo.define('point_of_sale.ClientListScreen', function(require) {
body: this.env._t('No customer found'),
});
}
this.render();
this.render(true);
}
async getNewClient() {
var domain = [];
@@ -96,7 +96,7 @@ odoo.define('point_of_sale.PaymentScreen', function (require) {
toggleIsToInvoice() {
// click_invoice
this.currentOrder.set_to_invoice(!this.currentOrder.is_to_invoice());
this.render();
this.render(true);
}
openCashbox() {
this.env.proxy.printer.open_cashbox();
@@ -120,7 +120,7 @@ odoo.define('point_of_sale.PaymentScreen', function (require) {
toggleIsToShip() {
// click_ship
this.currentOrder.set_to_ship(!this.currentOrder.is_to_ship());
this.render();
this.render(true);
}
deletePaymentLine(event) {
var self = this;
@@ -135,13 +135,13 @@ odoo.define('point_of_sale.PaymentScreen', function (require) {
line.payment_method.payment_terminal.send_payment_cancel(this.currentOrder, cid).then(function() {
self.currentOrder.remove_paymentline(line);
NumberBuffer.reset();
self.render();
self.render(true);
})
}
else if (line.get_payment_status() !== 'waitingCancel') {
this.currentOrder.remove_paymentline(line);
NumberBuffer.reset();
this.render();
this.render(true);
}
}
selectPaymentLine(event) {
@@ -149,7 +149,7 @@ odoo.define('point_of_sale.PaymentScreen', function (require) {
const line = this.paymentLines.find((line) => line.cid === cid);
this.currentOrder.select_paymentline(line);
NumberBuffer.reset();
this.render();
this.render(true);
}
async validateOrder(isForceValidate) {
if(this.env.pos.config.cash_rounding) {
@@ -82,7 +82,7 @@ odoo.define('point_of_sale.ProductsWidget', function(require) {
this.state.searchWord = '';
}
_updateProductList(event) {
this.render();
this.render(true);
this.trigger('switch-category', 0);
}
}
@@ -4,7 +4,7 @@ import { useBus, useService } from '@web/core/utils/hooks';
import { ActionContainer } from '@web/webclient/actions/action_container';
import { MainComponentsContainer } from "@web/core/main_components_container";
import { useOwnDebugContext } from "@web/core/debug/debug_context";
import { ErrorHandler, NotUpdatable } from "@web/core/utils/components";
import { ErrorHandler } from "@web/core/utils/components";
import { session } from '@web/session';
const { Component, useEffect } = owl;
@@ -59,5 +59,5 @@ export class ProjectSharingWebClient extends Component {
}
}
ProjectSharingWebClient.components = { ActionContainer, ErrorHandler, NotUpdatable, MainComponentsContainer };
ProjectSharingWebClient.components = { ActionContainer, ErrorHandler, MainComponentsContainer };
ProjectSharingWebClient.template = 'project.ProjectSharingWebClient';
@@ -2,9 +2,7 @@
<templates xml:space="preserve">
<t t-name="project.ProjectSharingWebClient" owl="1">
<NotUpdatable>
<ActionContainer />
</NotUpdatable>
<ActionContainer />
<MainComponentsContainer/>
</t>
+284 -248
View File
@@ -1510,7 +1510,9 @@
}
function callSlot(ctx, parent, key, name, dynamic, extra, defaultContent) {
key = key + "__slot_" + name;
const slots = (ctx.props && ctx.props.slots) || {};
const nonReactiveProps = ctx.props && ctx.props[TARGET];
const slots = nonReactiveProps ? nonReactiveProps.slots || {} : {};
// const slots = (ctx.props && ctx.props.slots) || {};
const { __render, __ctx, __scope } = slots[name] || {};
const slotScope = Object.create(__ctx || {});
if (__scope) {
@@ -1717,6 +1719,210 @@
return stopped;
};
// Allows to get the target of a Reactive (used for making a new Reactive from the underlying object)
const TARGET = Symbol("Target");
// Escape hatch to prevent reactivity system to turn something into a reactive
const SKIP = Symbol("Skip");
// Special key to subscribe to, to be notified of key creation/deletion
const KEYCHANGES = Symbol("Key changes");
const objectToString = Object.prototype.toString;
/**
* Checks whether a given value can be made into a reactive object.
*
* @param value the value to check
* @returns whether the value can be made reactive
*/
function canBeMadeReactive(value) {
if (typeof value !== "object") {
return false;
}
// extract "RawType" from strings like "[object RawType]" => this lets us
// ignore many native objects such as Promise (whose toString is [object Promise])
// or Date ([object Date]).
const rawType = objectToString.call(value).slice(8, -1);
return rawType === "Object" || rawType === "Array";
}
/**
* Mark an object or array so that it is ignored by the reactivity system
*
* @param value the value to mark
* @returns the object itself
*/
function markRaw(value) {
value[SKIP] = true;
return value;
}
/**
* Given a reactive objet, return the raw (non reactive) underlying object
*
* @param value a reactive value
* @returns the underlying value
*/
function toRaw(value) {
return value[TARGET] || value;
}
const targetToKeysToCallbacks = new WeakMap();
/**
* Observes a given key on a target with an callback. The callback will be
* called when the given key changes on the target.
*
* @param target the target whose key should be observed
* @param key the key to observe (or Symbol(KEYCHANGES) for key creation
* or deletion)
* @param callback the function to call when the key changes
*/
function observeTargetKey(target, key, callback) {
if (!targetToKeysToCallbacks.get(target)) {
targetToKeysToCallbacks.set(target, new Map());
}
const keyToCallbacks = targetToKeysToCallbacks.get(target);
if (!keyToCallbacks.get(key)) {
keyToCallbacks.set(key, new Set());
}
keyToCallbacks.get(key).add(callback);
if (!callbacksToTargets.has(callback)) {
callbacksToTargets.set(callback, new Set());
}
callbacksToTargets.get(callback).add(target);
}
/**
* Notify Reactives that are observing a given target that a key has changed on
* the target.
*
* @param target target whose Reactives should be notified that the target was
* changed.
* @param key the key that changed (or Symbol `KEYCHANGES` if a key was created
* or deleted)
*/
function notifyReactives(target, key) {
const keyToCallbacks = targetToKeysToCallbacks.get(target);
if (!keyToCallbacks) {
return;
}
const callbacks = keyToCallbacks.get(key);
if (!callbacks) {
return;
}
// Loop on copy because clearReactivesForCallback will modify the set in place
for (const callback of [...callbacks]) {
clearReactivesForCallback(callback);
callback();
}
}
const callbacksToTargets = new WeakMap();
/**
* Clears all subscriptions of the Reactives associated with a given callback.
*
* @param callback the callback for which the reactives need to be cleared
*/
function clearReactivesForCallback(callback) {
const targetsToClear = callbacksToTargets.get(callback);
if (!targetsToClear) {
return;
}
for (const target of targetsToClear) {
const observedKeys = targetToKeysToCallbacks.get(target);
if (!observedKeys) {
continue;
}
for (const callbacks of observedKeys.values()) {
callbacks.delete(callback);
}
}
targetsToClear.clear();
}
const reactiveCache = new WeakMap();
/**
* Creates a reactive proxy for an object. Reading data on the reactive object
* subscribes to changes to the data. Writing data on the object will cause the
* notify callback to be called if there are suscriptions to that data. Nested
* objects and arrays are automatically made reactive as well.
*
* Whenever you are notified of a change, all subscriptions are cleared, and if
* you would like to be notified of any further changes, you should go read
* the underlying data again. We assume that if you don't go read it again after
* being notified, it means that you are no longer interested in that data.
*
* Subscriptions:
* + Reading a property on an object will subscribe you to changes in the value
* of that property.
* + Accessing an object keys (eg with Object.keys or with `for..in`) will
* subscribe you to the creation/deletion of keys. Checking the presence of a
* key on the object with 'in' has the same effect.
* - getOwnPropertyDescriptor does not currently subscribe you to the property.
* This is a choice that was made because changing a key's value will trigger
* this trap and we do not want to subscribe by writes. This also means that
* Object.hasOwnProperty doesn't subscribe as it goes through this trap.
*
* @param target the object for which to create a reactive proxy
* @param callback the function to call when an observed property of the
* reactive has changed
* @returns a proxy that tracks changes to it
*/
function reactive(target, callback = () => { }) {
if (!canBeMadeReactive(target)) {
throw new Error(`Cannot make the given value reactive`);
}
if (SKIP in target) {
return target;
}
const originalTarget = target[TARGET];
if (originalTarget) {
return reactive(originalTarget, callback);
}
if (!reactiveCache.has(target)) {
reactiveCache.set(target, new Map());
}
const reactivesForTarget = reactiveCache.get(target);
if (!reactivesForTarget.has(callback)) {
const proxy = new Proxy(target, {
get(target, key, proxy) {
if (key === TARGET) {
return target;
}
observeTargetKey(target, key, callback);
const value = Reflect.get(target, key, proxy);
if (!canBeMadeReactive(value)) {
return value;
}
return reactive(value, callback);
},
set(target, key, value, proxy) {
const isNewKey = !Object.hasOwnProperty.call(target, key);
const originalValue = Reflect.get(target, key, proxy);
const ret = Reflect.set(target, key, value, proxy);
if (isNewKey) {
notifyReactives(target, KEYCHANGES);
}
// While Array length may trigger the set trap, it's not actually set by this
// method but is updated behind the scenes, and the trap is not called with the
// new value. We disable the "same-value-optimization" for it because of that.
if (originalValue !== value || (Array.isArray(target) && key === "length")) {
notifyReactives(target, key);
}
return ret;
},
deleteProperty(target, key) {
const ret = Reflect.deleteProperty(target, key);
notifyReactives(target, KEYCHANGES);
notifyReactives(target, key);
return ret;
},
ownKeys(target) {
observeTargetKey(target, KEYCHANGES, callback);
return Reflect.ownKeys(target);
},
has(target, key) {
// TODO: this observes all key changes instead of only the presence of the argument key
observeTargetKey(target, KEYCHANGES, callback);
return Reflect.has(target, key);
},
});
reactivesForTarget.set(callback, proxy);
}
return reactivesForTarget.get(callback);
}
// Maps fibers to thrown errors
const fibersInError = new WeakMap();
const nodeErrorHandlers = new WeakMap();
@@ -1787,9 +1993,8 @@
let current = node.fiber;
if (current) {
let root = current.root;
root.counter -= cancelFibers(current.children);
root.counter = root.counter + 1 - cancelFibers(current.children);
current.children = [];
root.counter++;
current.bdom = null;
if (fibersInError.has(current)) {
fibersInError.delete(current);
@@ -1826,9 +2031,11 @@
this.bdom = null;
this.children = [];
this.appliedToDom = false;
this.deep = false;
this.node = node;
this.parent = parent;
if (parent) {
this.deep = parent.deep;
const root = parent.root;
root.counter++;
this.root = root;
@@ -1871,7 +2078,7 @@
}
current = undefined;
// Step 2: patching the dom
node.patch();
node._patch();
this.locked = false;
// Step 4: calling all mounted lifecycle hooks
let mountedFibers = this.mounted;
@@ -1958,6 +2165,39 @@
function useComponent() {
return currentNode.component;
}
// -----------------------------------------------------------------------------
// Integration with reactivity system (useState)
// -----------------------------------------------------------------------------
const batchedRenderFunctions = new WeakMap();
/**
* Creates a reactive object that will be observed by the current component.
* Reading data from the returned object (eg during rendering) will cause the
* component to subscribe to that data and be rerendered when it changes.
*
* @param state the state to observe
* @returns a reactive object that will cause the component to re-render on
* relevant changes
* @see reactive
*/
function useState(state) {
const node = getCurrent();
let render = batchedRenderFunctions.get(node);
if (!render) {
render = batched(node.render.bind(node));
batchedRenderFunctions.set(node, render);
// manual implementation of onWillDestroy to break cyclic dependency
node.willDestroy.push(clearReactivesForCallback.bind(null, render));
}
return reactive(state, render);
}
function arePropsDifferent(props1, props2) {
for (let k in props1) {
if (props1[k] !== props2[k]) {
return true;
}
}
return Object.keys(props1).length !== Object.keys(props2).length;
}
function component(name, props, key, ctx, parent) {
let node = ctx.children[key];
let isDynamic = typeof name !== "string";
@@ -1975,7 +2215,10 @@
}
const parentFiber = ctx.fiber;
if (node) {
node.updateAndRender(props, parentFiber);
const currentProps = node.component.props[TARGET];
if (parentFiber.deep || arePropsDifferent(currentProps, props)) {
node.updateAndRender(props, parentFiber);
}
}
else {
// new component
@@ -1991,8 +2234,7 @@
}
node = new ComponentNode(C, props, ctx.app, ctx);
ctx.children[key] = node;
const fiber = makeChildFiber(node, parentFiber);
node.initiateRender(fiber);
node.initiateRender(new Fiber(node, parentFiber));
}
return node;
}
@@ -2017,6 +2259,7 @@
applyDefaultProps(props, C);
const env = (parent && parent.childEnv) || app.env;
this.childEnv = env;
props = useState(props);
this.component = new C(props, env, this);
this.renderFn = app.getTemplate(C.template).bind(this.component, this.component, this);
this.component.setup();
@@ -2044,20 +2287,29 @@
this._render(fiber);
}
}
async render() {
async render(deep = false) {
let current = this.fiber;
if (current && current.root.locked) {
await Promise.resolve();
// situation may have changed after the microtask tick
current = this.fiber;
}
if (current && !current.bdom && !fibersInError.has(current)) {
return;
if (current) {
if (!current.bdom && !fibersInError.has(current)) {
if (deep) {
// we want the render from this point on to be with deep=true
current.deep = deep;
}
return;
}
// if current rendering was with deep=true, we want this one to be the same
deep = deep || current.deep;
}
if (!this.bdom && !current) {
else if (!this.bdom) {
return;
}
const fiber = makeRootFiber(this);
fiber.deep = deep;
this.fiber = fiber;
this.app.scheduler.addFiber(fiber);
await Promise.resolve();
@@ -2116,6 +2368,9 @@
this.fiber = fiber;
const component = this.component;
applyDefaultProps(props, component.constructor);
currentNode = this;
props = useState(props);
currentNode = null;
const prom = Promise.all(this.willUpdateProps.map((f) => f.call(component, props)));
await prom;
if (fiber !== this.fiber) {
@@ -2175,6 +2430,14 @@
this.bdom.moveBefore(other ? other.bdom : null, afterNode);
}
patch() {
if (this.fiber && this.fiber.parent) {
// we only patch here renderings coming from above. renderings initiated
// by the component will be patched independently in the appropriate
// fiber.complete
this._patch();
}
}
_patch() {
const hasChildren = Object.keys(this.children).length > 0;
this.bdom.patch(this.fiber.bdom, hasChildren);
if (hasChildren) {
@@ -3580,17 +3843,13 @@
slotDef = `{${slotStr.join(", ")}}`;
}
if (slotDef && !(ast.dynamicProps || hasSlotsProp)) {
props.push(`slots: ${slotDef}`);
this.helpers.add("markRaw");
props.push(`slots: markRaw(${slotDef})`);
}
const propStr = `{${props.join(",")}}`;
let propString = propStr;
if (ast.dynamicProps) {
if (!props.length) {
propString = `Object.assign({}, ${compileExpr(ast.dynamicProps)})`;
}
else {
propString = `Object.assign({}, ${compileExpr(ast.dynamicProps)}, ${propStr})`;
}
propString = `Object.assign({}, ${compileExpr(ast.dynamicProps)}${props.length ? ", " + propStr : ""})`;
}
let propVar;
if ((slotDef && (ast.dynamicProps || hasSlotsProp)) || this.dev) {
@@ -3599,7 +3858,8 @@
propString = propVar;
}
if (slotDef && (ast.dynamicProps || hasSlotsProp)) {
this.addLine(`${propVar}.slots = Object.assign(${slotDef}, ${propVar}.slots)`);
this.helpers.add("markRaw");
this.addLine(`${propVar}.slots = markRaw(Object.assign(${slotDef}, ${propVar}.slots))`);
}
// cmap key
const key = this.generateComponentKey();
@@ -4534,8 +4794,8 @@
this.__owl__ = node;
}
setup() { }
render() {
this.__owl__.render();
render(deep = false) {
this.__owl__.render(deep);
}
}
Component.template = "";
@@ -4791,231 +5051,6 @@ See https://github.com/odoo/owl/blob/${hash}/doc/reference/app.md#configuration
return true;
}
// Allows to get the target of a Reactive (used for making a new Reactive from the underlying object)
const TARGET = Symbol("Target");
// Escape hatch to prevent reactivity system to turn something into a reactive
const SKIP = Symbol("Skip");
// Special key to subscribe to, to be notified of key creation/deletion
const KEYCHANGES = Symbol("Key changes");
const objectToString = Object.prototype.toString;
/**
* Checks whether a given value can be made into a reactive object.
*
* @param value the value to check
* @returns whether the value can be made reactive
*/
function canBeMadeReactive(value) {
if (typeof value !== "object") {
return false;
}
// extract "RawType" from strings like "[object RawType]" => this lets us
// ignore many native objects such as Promise (whose toString is [object Promise])
// or Date ([object Date]).
const rawType = objectToString.call(value).slice(8, -1);
return rawType === "Object" || rawType === "Array";
}
/**
* Mark an object or array so that it is ignored by the reactivity system
*
* @param value the value to mark
* @returns the object itself
*/
function markRaw(value) {
value[SKIP] = true;
return value;
}
/**
* Given a reactive objet, return the raw (non reactive) underlying object
*
* @param value a reactive value
* @returns the underlying value
*/
function toRaw(value) {
return value[TARGET] || value;
}
const targetToKeysToCallbacks = new WeakMap();
/**
* Observes a given key on a target with an callback. The callback will be
* called when the given key changes on the target.
*
* @param target the target whose key should be observed
* @param key the key to observe (or Symbol(KEYCHANGES) for key creation
* or deletion)
* @param callback the function to call when the key changes
*/
function observeTargetKey(target, key, callback) {
if (!targetToKeysToCallbacks.get(target)) {
targetToKeysToCallbacks.set(target, new Map());
}
const keyToCallbacks = targetToKeysToCallbacks.get(target);
if (!keyToCallbacks.get(key)) {
keyToCallbacks.set(key, new Set());
}
keyToCallbacks.get(key).add(callback);
if (!callbacksToTargets.has(callback)) {
callbacksToTargets.set(callback, new Set());
}
callbacksToTargets.get(callback).add(target);
}
/**
* Notify Reactives that are observing a given target that a key has changed on
* the target.
*
* @param target target whose Reactives should be notified that the target was
* changed.
* @param key the key that changed (or Symbol `KEYCHANGES` if a key was created
* or deleted)
*/
function notifyReactives(target, key) {
const keyToCallbacks = targetToKeysToCallbacks.get(target);
if (!keyToCallbacks) {
return;
}
const callbacks = keyToCallbacks.get(key);
if (!callbacks) {
return;
}
// Loop on copy because clearReactivesForCallback will modify the set in place
for (const callback of [...callbacks]) {
clearReactivesForCallback(callback);
callback();
}
}
const callbacksToTargets = new WeakMap();
/**
* Clears all subscriptions of the Reactives associated with a given callback.
*
* @param callback the callback for which the reactives need to be cleared
*/
function clearReactivesForCallback(callback) {
const targetsToClear = callbacksToTargets.get(callback);
if (!targetsToClear) {
return;
}
for (const target of targetsToClear) {
const observedKeys = targetToKeysToCallbacks.get(target);
if (!observedKeys) {
continue;
}
for (const callbacks of observedKeys.values()) {
callbacks.delete(callback);
}
}
targetsToClear.clear();
}
const reactiveCache = new WeakMap();
/**
* Creates a reactive proxy for an object. Reading data on the reactive object
* subscribes to changes to the data. Writing data on the object will cause the
* notify callback to be called if there are suscriptions to that data. Nested
* objects and arrays are automatically made reactive as well.
*
* Whenever you are notified of a change, all subscriptions are cleared, and if
* you would like to be notified of any further changes, you should go read
* the underlying data again. We assume that if you don't go read it again after
* being notified, it means that you are no longer interested in that data.
*
* Subscriptions:
* + Reading a property on an object will subscribe you to changes in the value
* of that property.
* + Accessing an object keys (eg with Object.keys or with `for..in`) will
* subscribe you to the creation/deletion of keys. Checking the presence of a
* key on the object with 'in' has the same effect.
* - getOwnPropertyDescriptor does not currently subscribe you to the property.
* This is a choice that was made because changing a key's value will trigger
* this trap and we do not want to subscribe by writes. This also means that
* Object.hasOwnProperty doesn't subscribe as it goes through this trap.
*
* @param target the object for which to create a reactive proxy
* @param callback the function to call when an observed property of the
* reactive has changed
* @returns a proxy that tracks changes to it
*/
function reactive(target, callback = () => { }) {
if (!canBeMadeReactive(target)) {
throw new Error(`Cannot make the given value reactive`);
}
if (SKIP in target) {
return target;
}
const originalTarget = target[TARGET];
if (originalTarget) {
return reactive(originalTarget, callback);
}
if (!reactiveCache.has(target)) {
reactiveCache.set(target, new Map());
}
const reactivesForTarget = reactiveCache.get(target);
if (!reactivesForTarget.has(callback)) {
const proxy = new Proxy(target, {
get(target, key, proxy) {
if (key === TARGET) {
return target;
}
observeTargetKey(target, key, callback);
const value = Reflect.get(target, key, proxy);
if (!canBeMadeReactive(value)) {
return value;
}
return reactive(value, callback);
},
set(target, key, value, proxy) {
const isNewKey = !Object.hasOwnProperty.call(target, key);
const originalValue = Reflect.get(target, key, proxy);
const ret = Reflect.set(target, key, value, proxy);
if (isNewKey) {
notifyReactives(target, KEYCHANGES);
}
// While Array length may trigger the set trap, it's not actually set by this
// method but is updated behind the scenes, and the trap is not called with the
// new value. We disable the "same-value-optimization" for it because of that.
if (originalValue !== value || (Array.isArray(target) && key === "length")) {
notifyReactives(target, key);
}
return ret;
},
deleteProperty(target, key) {
const ret = Reflect.deleteProperty(target, key);
notifyReactives(target, KEYCHANGES);
notifyReactives(target, key);
return ret;
},
ownKeys(target) {
observeTargetKey(target, KEYCHANGES, callback);
return Reflect.ownKeys(target);
},
has(target, key) {
// TODO: this observes all key changes instead of only the presence of the argument key
observeTargetKey(target, KEYCHANGES, callback);
return Reflect.has(target, key);
},
});
reactivesForTarget.set(callback, proxy);
}
return reactivesForTarget.get(callback);
}
const batchedRenderFunctions = new WeakMap();
/**
* Creates a reactive object that will be observed by the current component.
* Reading data from the returned object (eg during rendering) will cause the
* component to subscribe to that data and be rerendered when it changes.
*
* @param state the state to observe
* @returns a reactive object that will cause the component to re-render on
* relevant changes
* @see reactive
*/
function useState(state) {
const node = getCurrent();
if (!batchedRenderFunctions.has(node)) {
batchedRenderFunctions.set(node, batched(() => node.render()));
onWillDestroy(() => clearReactivesForCallback(render));
}
const render = batchedRenderFunctions.get(node);
const reactiveState = reactive(state, render);
return reactiveState;
}
// -----------------------------------------------------------------------------
// useRef
// -----------------------------------------------------------------------------
@@ -5121,6 +5156,7 @@ See https://github.com/odoo/owl/blob/${hash}/doc/reference/app.md#configuration
config.shouldNormalizeDom = false;
config.mainEventHandler = mainEventHandler;
UTILS.Portal = Portal;
UTILS.markRaw = markRaw;
const blockDom = {
config,
// bdom entry points
@@ -5176,8 +5212,8 @@ See https://github.com/odoo/owl/blob/${hash}/doc/reference/app.md#configuration
__info__.version = '2.0.0-alpha.2';
__info__.date = '2022-02-23T07:56:30.264Z';
__info__.hash = '0dbd2bd';
__info__.date = '2022-02-25T08:12:58.656Z';
__info__.hash = '7845304';
__info__.url = 'https://github.com/odoo/owl';
@@ -1,6 +1,6 @@
/** @odoo-module **/
import { ErrorHandler, NotUpdatable } from "../utils/components";
import { ErrorHandler } from "../utils/components";
const { Component, xml } = owl;
@@ -22,18 +22,16 @@ export class DialogContainer extends Component {
});
}
}
DialogContainer.components = { ErrorHandler, NotUpdatable };
DialogContainer.components = { ErrorHandler };
//Legacy : The div wrapping the t-foreach, is placed to avoid owl to delete non-owl dialogs.
//This div can be removed after removing all legacy dialogs.
DialogContainer.template = xml`
<div class="o_dialog_container" t-att-class="{'modal-open': Object.keys(props.dialogs).length > 0}">
<div>
<t t-foreach="Object.values(props.dialogs)" t-as="dialog" t-key="dialog.id">
<NotUpdatable>
<ErrorHandler onError="(error) => this.handleError(error, dialog.id)">
<t t-component="dialog.class" t-props="dialog.props" isActive="dialog_last"/>
</ErrorHandler>
</NotUpdatable>
<ErrorHandler onError="(error) => this.handleError(error, dialog.id)">
<t t-component="dialog.class" t-props="dialog.props" isActive="dialog_last"/>
</ErrorHandler>
</t>
</div>
</div>
@@ -1,6 +1,6 @@
/** @odoo-module */
import { registry } from "./registry";
import { NotUpdatable, ErrorHandler } from "./utils/components";
import { ErrorHandler } from "./utils/components";
const { Component, xml } = owl;
@@ -27,12 +27,10 @@ export class MainComponentsContainer extends Component {
MainComponentsContainer.template = xml`
<div>
<t t-foreach="Components" t-as="C" t-key="C[0]">
<NotUpdatable>
<ErrorHandler onError="error => this.handleComponentError(error, C)">
<t t-component="C[1].Component" t-props="C[1].props"/>
</ErrorHandler>
</NotUpdatable>
<ErrorHandler onError="error => this.handleComponentError(error, C)">
<t t-component="C[1].Component" t-props="C[1].props"/>
</ErrorHandler>
</t>
</div>
`;
MainComponentsContainer.components = { NotUpdatable, ErrorHandler };
MainComponentsContainer.components = { ErrorHandler };
@@ -2,15 +2,6 @@
const { Component, onError, useComponent, xml } = owl;
export class NotUpdatable extends Component {
setup() {
const node = useComponent().__owl__;
node.patch = () => {};
node.updateAndRender = () => Promise.resolve();
}
}
NotUpdatable.template = xml`<t t-slot="default" />`;
export class ErrorHandler extends Component {
setup() {
onError((error) => {
@@ -11,7 +11,16 @@ odoo.define('web.ControlPanel', function (require) {
const SearchBar = require('web.SearchBar');
const { useModel } = require('web.Model');
const { Component, onMounted, onPatched, onWillDestroy, onWillUpdateProps, useRef, useSubEnv } = owl;
const {
Component,
onMounted,
onPatched,
onWillDestroy,
onWillUpdateProps,
toRaw,
useRef,
useSubEnv,
} = owl;
/**
* TODO: remove this whole mechanism as soon as `cp_content` is completely removed.
@@ -122,7 +131,7 @@ odoo.define('web.ControlPanel', function (require) {
searchViewButtons: useRef('searchViewButtons'),
};
this.fields = this._formatFields(this.props.fields);
this.fields = this._formatFields(toRaw(this.props.fields || {}));
this.sprintf = _.str.sprintf;
@@ -155,7 +164,7 @@ odoo.define('web.ControlPanel', function (require) {
subEnvView = nextProps.view;
}
if ("fields" in nextProps) {
this.fields = this._formatFields(nextProps.fields);
this.fields = this._formatFields(toRaw(nextProps.fields));
}
this.additionalContent = getAdditionalContent(nextProps);
});
@@ -13,6 +13,7 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js",
const FAVORITE_SHARED_GROUP = 2;
const DISABLE_FAVORITE = "search_disable_custom_filters";
const { toRaw } = owl;
/**
* Control panel model
*
@@ -158,7 +159,7 @@ odoo.define("web/static/src/js/control_panel/control_panel_model_extension.js",
this.actionContext = Object.assign({}, this.config.context);
this.searchMenuTypes = this.config.searchMenuTypes || [];
this.favoriteFilters = this.config.favoriteFilters || [];
this.fields = this.config.fields || {};
this.fields = toRaw(this.config.fields || {});
this.searchDefaults = {};
for (const key in this.actionContext) {
const match = /^search_default_(.*)$/.exec(key);
@@ -7,7 +7,7 @@ odoo.define('web.SearchBar', function (require) {
const { useModel } = require('web.Model');
const { fuzzyTest } = require('@web/core/utils/search');
const { Component, onMounted, onWillUnmount, useExternalListener, useState } = owl;
const { Component, onMounted, onWillUnmount, toRaw, useExternalListener, useState } = owl;
const CHAR_FIELDS = ['char', 'html', 'many2many', 'many2one', 'one2many', 'text'];
let sourceId = 0;
@@ -91,7 +91,7 @@ odoo.define('web.SearchBar', function (require) {
* @returns {Object}
*/
_createSource(filter) {
const field = this.props.fields[filter.fieldName];
const field = toRaw(this.props.fields)[filter.fieldName];
const type = field.type === "reference" ? "char" : field.type;
const source = {
active: true,
@@ -4,7 +4,7 @@ odoo.define('web.FieldWrapper', function (require) {
const { ComponentWrapper } = require('web.OwlCompatibility');
const field_utils = require('web.field_utils');
const { App } = owl;
const { App, reactive } = owl;
/**
* This file defines the FieldWrapper component, an extension of ComponentWrapper,
@@ -178,7 +178,7 @@ odoo.define('web.FieldWrapper', function (require) {
const fieldsInfo = this.props.record.fieldsInfo[viewType];
fieldsInfo[this.props.fieldName].modifiersValue = modifiers || {};
}
this.componentRef.comp.props = this.props;
this.componentRef.comp.props = reactive(this.props);
}
}
@@ -596,11 +596,15 @@ odoo.define('web.OwlCompatibility', function (require) {
if (options) {
this.mountOptions = options;
}
if (this.status === "unmounted") {
const remount = this.status === "unmounted";
if (remount) {
prepareForRemount(this.node);
}
this.status = "willMount";
const prom = this.app.mountNode(this.node, this.target, this.mountOptions);
if (remount) {
this.node.fiber.deep = true;
}
prepareForFinish(this.node);
await prom;
if (document.contains(this.target)) {
@@ -621,7 +625,7 @@ odoo.define('web.OwlCompatibility', function (require) {
render() {
if (this.renderProm) {
this.node.render();
this.node.render(true);
return this.renderProm;
}
this.renderProm = new Promise((resolve, reject) => {
@@ -632,7 +636,7 @@ odoo.define('web.OwlCompatibility', function (require) {
this.renderResolve = null;
this.renderReject = null;
});
this.node.render();
this.node.render(true);
return this.renderProm;
}
@@ -8,7 +8,7 @@ import { FilterMenu } from "../filter_menu/filter_menu";
import { GroupByMenu } from "../group_by_menu/group_by_menu";
import { SearchBar } from "../search_bar/search_bar";
const { Component } = owl;
const { Component, useState } = owl;
const MAPPING = {
filter: FilterMenu,
@@ -20,6 +20,9 @@ const MAPPING = {
export class ControlPanel extends Component {
setup() {
this.actionService = useService("action");
this.pagerProps = this.env.config.pagerProps
? useState(this.env.config.pagerProps)
: undefined;
}
/**
@@ -29,10 +29,7 @@
</t>
<div class="o_cp_pager" role="search">
<t t-set="pagerProps" t-value="env.config.getPagerProps()" />
<t t-if="pagerProps">
<Pager t-if="pagerProps.total > 0" t-props="pagerProps" />
</t>
<Pager t-if="pagerProps and pagerProps.total > 0" t-props="pagerProps"/>
</div>
<t t-if="env.config.viewSwitcherEntries.length">
+6 -2
View File
@@ -1,6 +1,6 @@
/** @odoo-module **/
const { useEnv, useChildSubEnv } = owl;
const { useEnv, useChildSubEnv, useState, onWillRender } = owl;
/**
* @typedef PagerUpdateParams
@@ -23,11 +23,15 @@ const { useEnv, useChildSubEnv } = owl;
*/
export function usePager(getProps) {
const env = useEnv();
const pagerState = useState(getProps());
useChildSubEnv({
config: {
...env.config,
getPagerProps: getProps,
pagerProps: pagerState,
},
});
onWillRender(() => {
Object.assign(pagerState, getProps());
});
}
+2 -2
View File
@@ -17,7 +17,7 @@ import {
} from "./utils/dates";
import { FACET_ICONS } from "./utils/misc";
const { EventBus } = owl;
const { EventBus, toRaw } = owl;
const { DateTime } = luxon;
/**
@@ -215,7 +215,7 @@ export class SearchModel extends EventBus {
const { comparison, context, domain, groupBy, orderBy } = config;
this.globalComparison = comparison;
this.globalContext = context || {};
this.globalContext = toRaw(context || {});
this.globalDomain = domain || [];
this.globalGroupBy = groupBy || [];
this.globalOrderBy = orderBy || [];
@@ -4,7 +4,7 @@ import { useBus, useService } from "@web/core/utils/hooks";
import { SearchModel } from "@web/search/search_model";
import { CallbackRecorder, useSetupAction } from "@web/webclient/actions/action_hook";
const { Component, onWillStart, onWillUpdateProps, useSubEnv, useChildSubEnv } = owl;
const { Component, onWillStart, onWillUpdateProps, toRaw, useSubEnv } = owl;
export const SEARCH_KEYS = ["comparison", "context", "domain", "groupBy", "orderBy"];
const OTHER_SEARCH_KEYS = ["irFilters", "searchViewArch", "searchViewFields", "searchViewId"];
@@ -36,7 +36,7 @@ export class WithSearch extends Component {
});
onWillStart(async () => {
const config = { ...this.props };
const config = { ...toRaw(this.props) };
if (config.globalState && config.globalState.searchModel) {
config.state = JSON.parse(config.globalState.searchModel);
delete config.globalState;
+2 -2
View File
@@ -9,7 +9,7 @@ import { WithSearch } from "@web/search/with_search/with_search";
import { useActionLinks } from "@web/views/helpers/view_hook";
import { extractLayoutComponents } from "@web/views/layout";
const { Component, onWillUpdateProps, onWillStart, useSubEnv } = owl;
const { Component, onWillUpdateProps, onWillStart, toRaw, useSubEnv } = owl;
const viewRegistry = registry.category("views");
/** @typedef {Object} Config
@@ -282,7 +282,7 @@ export class View extends Component {
// prepare the WithSearch component props
this.withSearchProps = {
...this.props,
...toRaw(this.props),
Component: ViewClass,
componentProps: viewProps,
};
@@ -5,7 +5,7 @@ import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { useService, useBus } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
import { debounce } from "@web/core/utils/timing";
import { ErrorHandler, NotUpdatable } from "@web/core/utils/components";
import { ErrorHandler } from "@web/core/utils/components";
const { Component, onWillDestroy, useExternalListener, useEffect, useRef } = owl;
const systrayRegistry = registry.category("systray");
@@ -185,4 +185,4 @@ export class NavBar extends Component {
}
}
NavBar.template = "web.NavBar";
NavBar.components = { Dropdown, DropdownItem, MenuDropdown, NotUpdatable, ErrorHandler };
NavBar.components = { Dropdown, DropdownItem, MenuDropdown, ErrorHandler };
@@ -30,11 +30,9 @@
<!-- Systray -->
<div class="o_menu_systray d-flex flex-shrink-0 ml-auto" role="menu">
<t t-foreach="systrayItems" t-as="item" t-key="item.key">
<NotUpdatable>
<ErrorHandler onError="error => this.handleItemError(error, item)">
<t t-component="item.Component" t-props="item.props"/>
</ErrorHandler>
</NotUpdatable>
<ErrorHandler onError="error => this.handleItemError(error, item)">
<t t-component="item.Component" t-props="item.props"/>
</ErrorHandler>
</t>
</div>
</nav>
@@ -5,7 +5,6 @@ import { DebugMenu } from "@web/core/debug/debug_menu";
import { localization } from "@web/core/l10n/localization";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { registry } from "@web/core/registry";
import { NotUpdatable } from "@web/core/utils/components";
import { useBus, useService } from "@web/core/utils/hooks";
import { ActionContainer } from "./actions/action_container";
import { NavBar } from "./navbar/navbar";
@@ -112,7 +111,6 @@ export class WebClient extends Component {
WebClient.components = {
ActionContainer,
NavBar,
NotUpdatable,
MainComponentsContainer,
};
WebClient.template = "web.WebClient";
@@ -2,12 +2,10 @@
<templates xml:space="preserve">
<t t-name="web.WebClient" owl="1">
<NotUpdatable t-if="!state.fullscreen">
<t t-if="!state.fullscreen">
<NavBar/>
</NotUpdatable>
<NotUpdatable>
<ActionContainer/>
</NotUpdatable>
</t>
<ActionContainer/>
<MainComponentsContainer/>
</t>
@@ -1,42 +1,14 @@
/** @odoo-module **/
import { NotUpdatable, ErrorHandler } from "@web/core/utils/components";
import { ErrorHandler } from "@web/core/utils/components";
import { makeTestEnv } from "../../helpers/mock_env";
import { getFixture, mount } from "../../helpers/utils";
const { Component, onMounted, onWillUpdateProps, xml } = owl;
const { Component, xml } = owl;
QUnit.module("utils", () => {
QUnit.module("components");
QUnit.test("NotUpdatable component", async function (assert) {
class Child extends Component {
setup() {
onMounted(() => {
assert.step("mounted");
});
onWillUpdateProps(() => {
assert.step("willupdateprops");
});
}
}
Child.template = xml`<div>hey</div>`;
class Parent extends Component {}
Parent.template = xml`
<div>
<Child/>
<NotUpdatable><Child/></NotUpdatable>
</div>`;
Parent.components = { Child, NotUpdatable };
const target = getFixture();
const parent = await mount(Parent, target, { env: makeTestEnv() });
assert.verifySteps(["mounted", "mounted"]);
await parent.render();
assert.verifySteps(["willupdateprops"]);
});
QUnit.test("ErrorHandler component", async function (assert) {
class Boom extends Component {}
Boom.template = xml`<div><t t-esc="this.will.throw"/></div>`;
+2 -1
View File
@@ -309,8 +309,9 @@
});
QUnit.debug = (name, cb) => {
document.body.classList.add("debug");
owl.whenReady(() => document.body.classList.add("debug"));
QUnit.config.debug = true;
QUnit.config.testTimeout = 60 * 60 * 1000;
QUnit.only(name, cb);
};
@@ -107,7 +107,7 @@ QUnit.module("Search", (hooks) => {
});
QUnit.test("pager", async (assert) => {
let pagerInfo = {
let pagerProps = {
offset: 0,
limit: 10,
total: 50,
@@ -119,15 +119,13 @@ QUnit.module("Search", (hooks) => {
resModel: "foo",
Component: ControlPanel,
config: {
getPagerProps() {
return pagerInfo;
},
pagerProps: pagerProps,
},
searchMenuTypes: [],
});
assert.containsOnce(controlPanel.el, ".o_pager");
pagerInfo.total = 0;
pagerProps.total = 0;
controlPanel.render();
await nextTick();
assert.containsNone(controlPanel.el, ".o_pager");
@@ -2,7 +2,7 @@
import { ControlPanel } from "@web/search/control_panel/control_panel";
import { usePager } from "@web/search/pager_hook";
import { click } from "../helpers/utils";
import { click, nextTick } from "../helpers/utils";
import { makeWithSearch, setupControlPanelServiceRegistry } from "./helpers";
const { Component, useState, xml } = owl;
@@ -110,5 +110,22 @@ QUnit.module("Search", (hooks) => {
offset: 10,
limit: 10,
});
comp.state.offset = 20;
await nextTick();
assert.containsOnce(comp, ".o_pager");
assert.strictEqual(
comp.el.querySelector(`.o_pager_counter .o_pager_value`).textContent.trim(),
"21-30"
);
assert.strictEqual(
comp.el.querySelector(`.o_pager_counter span.o_pager_limit`).textContent.trim(),
"50"
);
assert.deepEqual(comp.state, {
offset: 20,
limit: 10,
});
});
});