[REF] web: allow services to specify async methods

This commit improves the service API to allow the service creator
to mark some methods of the service as 'async'. This is a very
important feature when we consider the fact that a component calling
such a method may be destroyed before the asynchronous work is
complete. In that case, we do not want any code from the component
to be executed. Such async method, when called in a component (i.e.
when the service was obtained with the useService hook), will be
wrapped in such a way that the returned promises will not be resolved
if the component is destroyed.

Note that it only impact method calls for services obtained from the
useService hook, not calls for the actual service obtained from the env.

closes odoo-dev/odoo#886

Signed-off-by: Géry Debongnie (ged) <ged@openerp.com>
This commit is contained in:
Géry Debongnie
2021-06-18 21:31:29 +02:00
committed by Aaron Bohy
parent fe7a21dfac
commit 9850b8e4dc
9 changed files with 97 additions and 74 deletions
@@ -96,21 +96,13 @@ function jsonrpc(env, rpcId, url, params, settings = {}) {
// RPC service
// -----------------------------------------------------------------------------
export const rpcService = {
async: true,
start(env) {
let rpcId = 0;
return function rpc(route, params = {}, settings) {
return jsonrpc(env, rpcId++, route, params, settings);
};
},
specializeForComponent(component, rpc) {
return async (...args) => {
if (component.__owl__.status === 5 /* DESTROYED */) {
throw new Error("A destroyed component should never initiate a RPC");
}
const result = await rpc(...args);
return component.__owl__.status === 5 ? new Promise(() => {}) : result;
};
},
};
registry.category("services").add("rpc", rpcService);
+27 -22
View File
@@ -1,6 +1,5 @@
/** @odoo-module **/
import { useService } from "./service_hook";
import { registry } from "./registry";
/**
@@ -55,22 +54,6 @@ class ORM {
return this.call(model, "read", [ids, fields], { context: ctx });
}
unlink(model, ids, ctx) {
return this.call(model, "unlink", [ids], { context: ctx });
}
write(model, ids, data, ctx) {
return this.call(model, "write", [ids, data], { context: ctx });
}
search(model, domain, options = {}, ctx = {}) {
const kwargs = {
context: ctx,
};
assignOptions(kwargs, options, ["offset", "limit", "order"]);
return this.call(model, "search", [domain], kwargs);
}
readGroup(model, domain, fields, groupby, options = {}, ctx = {}) {
const kwargs = {
domain,
@@ -82,17 +65,33 @@ class ORM {
return this.call(model, "web_read_group", [], kwargs);
}
search(model, domain, options = {}, ctx = {}) {
const kwargs = {
context: ctx,
};
assignOptions(kwargs, options, ["offset", "limit", "order"]);
return this.call(model, "search", [domain], kwargs);
}
searchRead(model, domain, fields, options = {}, ctx = {}) {
const kwargs = { context: ctx, domain, fields };
assignOptions(kwargs, options, ["offset", "limit", "order"]);
return this.call(model, "search_read", [], kwargs);
}
unlink(model, ids, ctx) {
return this.call(model, "unlink", [ids], { context: ctx });
}
webSearchRead(model, domain, fields, options = {}, ctx = {}) {
const kwargs = { context: ctx, domain, fields };
assignOptions(kwargs, options, ["offset", "limit", "order"]);
return this.call(model, "web_search_read", [], kwargs);
}
write(model, ids, data, ctx) {
return this.call(model, "write", [ids, data], { context: ctx });
}
}
/**
@@ -109,14 +108,20 @@ class ORM {
*/
export const ormService = {
dependencies: ["rpc", "user"],
async: [
"call",
"create",
"read",
"readGroup",
"search",
"searchRead",
"unlink",
"web_search_read",
"write",
],
start(env, { rpc, user }) {
return new ORM(rpc, user);
},
specializeForComponent() {
const rpc = useService("rpc");
const user = useService("user");
return new ORM(rpc, user);
},
};
registry.category("services").add("orm", ormService);
@@ -0,0 +1,39 @@
/** @odoo-module **/
import { useService } from "../service_hook";
const { onWillUnmount, useComponent } = owl.hooks;
export function usePopover() {
const keys = new Set();
const service = useService("popover");
const component = useComponent();
onWillUnmount(function () {
for (const key of keys) {
service.remove(key);
}
keys.clear();
});
return Object.assign(Object.create(service), {
add(params) {
const newParams = Object.create(params);
newParams.onClose = function (key) {
if (!params.keepOnClose) {
// manager will delete the popover if keepOnClose is falsy
keys.delete(key);
}
if (params.onClose && component.__owl__.status !== 5 /* DESTROYED */) {
params.onClose(key);
}
};
const key = service.add(newParams);
keys.add(key);
return key;
},
remove(key) {
keys.delete(key);
service.remove(key);
},
});
}
@@ -7,7 +7,6 @@ import { Popover } from "./popover";
const { Component } = owl;
const { EventBus } = owl.core;
const { onWillUnmount } = owl.hooks;
const { xml } = owl.tags;
export class KeyAlreadyExistsError extends Error {}
@@ -131,36 +130,6 @@ export const popoverService = {
},
};
},
specializeForComponent(component, service) {
const keys = new Set();
onWillUnmount(function () {
for (const key of keys) {
service.remove(key);
}
keys.clear();
});
return Object.assign(Object.create(service), {
add(params) {
const newParams = Object.create(params);
newParams.onClose = function (key) {
if (!params.keepOnClose) {
// manager will delete the popover if keepOnClose is falsy
keys.delete(key);
}
if (params.onClose && component.__owl__.status !== 5 /* DESTROYED */) {
params.onClose(key);
}
};
const key = service.add(newParams);
keys.add(key);
return key;
},
remove(key) {
keys.delete(key);
service.remove(key);
},
});
},
};
registry.category("services").add("popover", popoverService);
+22 -3
View File
@@ -1,6 +1,6 @@
/** @odoo-module **/
import { SPECIAL_METHOD } from "../env";
import { SERVICES_METADATA } from "../env";
const { useComponent } = owl.hooks;
@@ -17,8 +17,27 @@ export function useService(serviceName) {
throw new Error(`Service ${serviceName} is not available`);
}
const service = services[serviceName];
if (service && SPECIAL_METHOD in service) {
return service[SPECIAL_METHOD](component, service);
if (serviceName in SERVICES_METADATA) {
if (service instanceof Function) {
return protectMethod(component, null, service);
} else {
const methods = SERVICES_METADATA[serviceName];
const result = Object.create(service);
for (let method of methods) {
result[method] = protectMethod(component, service, service[method]);
}
return result;
}
}
return service;
}
function protectMethod(component, caller, fn) {
return async (...args) => {
if (component.__owl__.status === 5 /* DESTROYED */) {
throw new Error("Component is destroyed");
}
const result = await fn.call(caller, ...args);
return component.__owl__.status === 5 ? new Promise(() => {}) : result;
};
}
+3 -3
View File
@@ -47,7 +47,7 @@ export function makeEnv(debug) {
const serviceRegistry = registry.category("services");
export const SPECIAL_METHOD = Symbol("special_method");
export const SERVICES_METADATA = {};
/**
* Start all services registered in the service registry, while making sure
@@ -96,8 +96,8 @@ async function _startServices(env, toStart, timeoutId) {
const entries = (service.dependencies || []).map((dep) => [dep, services[dep]]);
const dependencies = Object.fromEntries(entries);
const value = service.start(env, dependencies);
if (value && "specializeForComponent" in service) {
value[SPECIAL_METHOD] = service.specializeForComponent;
if ("async" in service) {
SERVICES_METADATA[name] = service.async;
}
if (value instanceof Promise) {
proms.push(
@@ -163,7 +163,7 @@ QUnit.test("rpc initiated from destroyed components throw exception", async (ass
try {
await component.rpc("/my/route");
} catch (e) {
assert.strictEqual(e.message, "A destroyed component should never initiate a RPC");
assert.strictEqual(e.message, "Component is destroyed");
}
});
@@ -255,7 +255,6 @@ QUnit.test("useModel is specialized for component", async (assert) => {
const target = getFixture();
const component = await mount(MyComponent, { env, target });
assert.strictEqual(component.rpc, component.orm.rpc);
assert.notStrictEqual(component.orm, env.services.orm);
});
@@ -8,7 +8,7 @@ import {
popoverService,
} from "@web/core/popover/popover_service";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/service_hook";
import { usePopover } from "@web/core/popover/popover_hook";
import { registerCleanup } from "../../helpers/cleanup";
import { clearRegistryWithCleanup, makeTestEnv } from "../../helpers/mock_env";
import { click, getFixture, nextTick } from "../../helpers/utils";
@@ -246,7 +246,7 @@ QUnit.test("remove popover when component is unmount", async function (assert) {
class MyComp extends Component {
setup() {
this.popoverService = useService("popover");
this.popoverService = usePopover();
}
showPopover(key) {
const params = {
@@ -304,7 +304,7 @@ QUnit.test("service does not try to remove destroyed popover", async function (a
class MyComp extends Component {
setup() {
this.popoverService = useService("popover");
this.popoverService = usePopover();
}
showPopover() {
const params = {
@@ -345,7 +345,7 @@ QUnit.test("popover kept on close is removed when component unmount", async func
class MyComp extends Component {
setup() {
this.popoverService = useService("popover");
this.popoverService = usePopover();
}
showPopover() {
const params = {