[IMP] runtime: only destroy component in raf callback

Before this commit, most of the time, components are destroyed when the
virtual dom is patched and a component node is removed. However, since
Owl is asynchronous and a component may take some time to get ready
(with onWillStart), it can happen that a component is created, then
before it is ready, it is recreated.  In that case, the initial instance
has to be destroyed.

Before this commit, the destroy operation was done immediately, when we
cancel the current fibers.  However, this means that we cannot have a
guarantee between micro task ticks that a component has not been
destroyed in the meantime.

For example, in Odoo, it is common to use the rpc service, which will
throw an error if called by a destroyed component. But because of the
possible destruction of a component at any time, the following code is
unsafe:

async loadSomeData() {
  // guaranteed to be called when component is alive
  await Promise.resolve();
  // however here, component may have been destroyed
  this.rpc(...)
}

So, to prevent this issue, we can slightly delay the destroy operation.
It is not entirely trivial, since we need to find a way to neutralize
the component in the meantime. But it seems like performing all that
kind of operation at the "commit" phase (so, the request animation frame
callback) makes sense to me.

So, this commit modifies the code to add a new component status
(cancelled) and use it to cancel components that are waiting to be
destroyed. These components will then be destroyed as soon as the
requestanimation frame starts, before all other dom operations.
This commit is contained in:
Géry Debongnie
2023-06-27 09:27:52 +02:00
committed by Sam Degueldre
parent 3e9ba9ca8e
commit 7538aeae0e
9 changed files with 259 additions and 42 deletions
+1
View File
@@ -451,5 +451,6 @@ console.log(status(component));
// logs either: // logs either:
// - 'new', if the component is new and has not been mounted yet // - 'new', if the component is new and has not been mounted yet
// - 'mounted', if the component is currently mounted // - 'mounted', if the component is currently mounted
// - 'cancelled', if the component has not been mounted yet but will be destroyed soon
// - 'destroyed' if the component is currently destroyed // - 'destroyed' if the component is currently destroyed
``` ```
+18 -1
View File
@@ -145,6 +145,9 @@ export class ComponentNode<P extends Props = any, E = any> implements VNode<Comp
} }
async render(deep: boolean) { async render(deep: boolean) {
if (this.status >= STATUS.CANCELLED) {
return;
}
let current = this.fiber; let current = this.fiber;
if (current && (current.root!.locked || (current as any).bdom === true)) { if (current && (current.root!.locked || (current as any).bdom === true)) {
await Promise.resolve(); await Promise.resolve();
@@ -171,7 +174,7 @@ export class ComponentNode<P extends Props = any, E = any> implements VNode<Comp
this.app.scheduler.addFiber(fiber); this.app.scheduler.addFiber(fiber);
await Promise.resolve(); await Promise.resolve();
if (this.status === STATUS.DESTROYED) { if (this.status >= STATUS.CANCELLED) {
return; return;
} }
// We only want to actually render the component if the following two // We only want to actually render the component if the following two
@@ -190,6 +193,20 @@ export class ComponentNode<P extends Props = any, E = any> implements VNode<Comp
} }
} }
cancel() {
this._cancel();
delete this.parent!.children[this.parentKey!];
this.app.scheduler.scheduleDestroy(this);
}
_cancel() {
this.status = STATUS.CANCELLED;
const children = this.children;
for (let childKey in children) {
children[childKey]._cancel();
}
}
destroy() { destroy() {
let shouldRemove = this.status === STATUS.MOUNTED; let shouldRemove = this.status === STATUS.MOUNTED;
this._destroy(); this._destroy();
+3 -1
View File
@@ -51,8 +51,9 @@ export function handleError(params: ErrorParams) {
); );
} }
const node = "node" in params ? params.node : params.fiber.node; const node = "node" in params ? params.node : params.fiber.node;
const fiber = "fiber" in params ? params.fiber : node.fiber!; const fiber = "fiber" in params ? params.fiber : node.fiber;
if (fiber) {
// resets the fibers on components if possible. This is important so that // resets the fibers on components if possible. This is important so that
// new renderings can be properly included in the initial one, if any. // new renderings can be properly included in the initial one, if any.
let current: Fiber | null = fiber; let current: Fiber | null = fiber;
@@ -62,6 +63,7 @@ export function handleError(params: ErrorParams) {
} while (current); } while (current);
fibersInError.set(fiber.root!, error); fibersInError.set(fiber.root!, error);
}
const handled = _handleError(node, error); const handled = _handleError(node, error);
if (!handled) { if (!handled) {
+1 -2
View File
@@ -55,8 +55,7 @@ function cancelFibers(fibers: Fiber[]): number {
let node = fiber.node; let node = fiber.node;
fiber.render = throwOnRender; fiber.render = throwOnRender;
if (node.status === STATUS.NEW) { if (node.status === STATUS.NEW) {
node.destroy(); node.cancel();
delete node.parent!.children[node.parentKey!];
} }
node.fiber = null; node.fiber = null;
if (fiber.bdom) { if (fiber.bdom) {
+21 -4
View File
@@ -1,3 +1,4 @@
import type { ComponentNode } from "./component_node";
import { fibersInError } from "./error_handling"; import { fibersInError } from "./error_handling";
import { Fiber, RootFiber } from "./fibers"; import { Fiber, RootFiber } from "./fibers";
import { STATUS } from "./status"; import { STATUS } from "./status";
@@ -14,6 +15,7 @@ export class Scheduler {
requestAnimationFrame: Window["requestAnimationFrame"]; requestAnimationFrame: Window["requestAnimationFrame"];
frame: number = 0; frame: number = 0;
delayedRenders: Fiber[] = []; delayedRenders: Fiber[] = [];
cancelledNodes: Set<ComponentNode> = new Set();
constructor() { constructor() {
this.requestAnimationFrame = Scheduler.requestAnimationFrame; this.requestAnimationFrame = Scheduler.requestAnimationFrame;
@@ -23,6 +25,13 @@ export class Scheduler {
this.tasks.add(fiber.root!); this.tasks.add(fiber.root!);
} }
scheduleDestroy(node: ComponentNode) {
this.cancelledNodes.add(node);
if (this.frame === 0) {
this.frame = this.requestAnimationFrame(() => this.processTasks());
}
}
/** /**
* Process all current tasks. This only applies to the fibers that are ready. * Process all current tasks. This only applies to the fibers that are ready.
* Other tasks are left unchanged. * Other tasks are left unchanged.
@@ -39,16 +48,24 @@ export class Scheduler {
} }
if (this.frame === 0) { if (this.frame === 0) {
this.frame = this.requestAnimationFrame(() => { this.frame = this.requestAnimationFrame(() => this.processTasks());
}
}
processTasks() {
this.frame = 0; this.frame = 0;
this.tasks.forEach((fiber) => this.processFiber(fiber)); for (let node of this.cancelledNodes) {
node._destroy();
}
this.cancelledNodes.clear();
for (let task of this.tasks) {
this.processFiber(task);
}
for (let task of this.tasks) { for (let task of this.tasks) {
if (task.node.status === STATUS.DESTROYED) { if (task.node.status === STATUS.DESTROYED) {
this.tasks.delete(task); this.tasks.delete(task);
} }
} }
});
}
} }
processFiber(fiber: RootFiber) { processFiber(fiber: RootFiber) {
+6 -1
View File
@@ -7,15 +7,20 @@ import type { Component } from "./component";
export const enum STATUS { export const enum STATUS {
NEW, NEW,
MOUNTED, // is ready, and in DOM. It has a valid el MOUNTED, // is ready, and in DOM. It has a valid el
// component has been created, but has been replaced by a newer component before being mounted
// it is cancelled until the next animation frame where it will be destroyed
CANCELLED,
DESTROYED, DESTROYED,
} }
type STATUS_DESCR = "new" | "mounted" | "destroyed"; type STATUS_DESCR = "new" | "mounted" | "cancelled" | "destroyed";
export function status(component: Component): STATUS_DESCR { export function status(component: Component): STATUS_DESCR {
switch (component.__owl__.status) { switch (component.__owl__.status) {
case STATUS.NEW: case STATUS.NEW:
return "new"; return "new";
case STATUS.CANCELLED:
return "cancelled";
case STATUS.MOUNTED: case STATUS.MOUNTED:
return "mounted"; return "mounted";
case STATUS.DESTROYED: case STATUS.DESTROYED:
@@ -212,6 +212,73 @@ exports[`changing state before first render does not trigger a render 1`] = `
}" }"
`; `;
exports[`component destroyed just after render 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
const comp1 = app.createComponent(\`B\`, true, false, false, []);
return function template(ctx, node, key = \\"\\") {
return comp1({}, key + \`__1\`, node, this, null);
}
}"
`;
exports[`component destroyed just after render 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
const b2 = text(\`B\`);
const b3 = text(ctx['state'].value);
return multi([b2, b3]);
}
}"
`;
exports[`components are not destroyed between animation frame 1`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
const comp1 = app.createComponent(\`B\`, true, false, false, []);
return function template(ctx, node, key = \\"\\") {
let b2,b3;
b2 = text(\`A\`);
if (ctx['state'].flag) {
b3 = comp1({}, key + \`__1\`, node, this, null);
}
return multi([b2, b3]);
}
}"
`;
exports[`components are not destroyed between animation frame 2`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
const comp1 = app.createComponent(\`C\`, true, false, false, []);
return function template(ctx, node, key = \\"\\") {
const b2 = text(\`B\`);
const b3 = comp1({}, key + \`__1\`, node, this, null);
return multi([b2, b3]);
}
}"
`;
exports[`components are not destroyed between animation frame 3`] = `
"function anonymous(app, bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, comment } = bdom;
return function template(ctx, node, key = \\"\\") {
return text(\`C\`);
}
}"
`;
exports[`concurrent renderings scenario 1 1`] = ` exports[`concurrent renderings scenario 1 1`] = `
"function anonymous(app, bdom, helpers "function anonymous(app, bdom, helpers
) { ) {
+123 -18
View File
@@ -115,13 +115,7 @@ test("destroying/recreating a subwidget with different props (if start is not ov
await nextMicroTick(); await nextMicroTick();
expect(n).toBe(2); expect(n).toBe(2);
expect([ expect(["W:willRender", "Child:setup", "Child:willStart", "W:rendered"]).toBeLogged();
"Child:willDestroy",
"W:willRender",
"Child:setup",
"Child:willStart",
"W:rendered",
]).toBeLogged();
def.resolve(); def.resolve();
await nextTick(); await nextTick();
@@ -130,6 +124,7 @@ test("destroying/recreating a subwidget with different props (if start is not ov
expect([ expect([
"Child:willRender", "Child:willRender",
"Child:rendered", "Child:rendered",
"Child:willDestroy",
"W:willPatch", "W:willPatch",
"Child:mounted", "Child:mounted",
"W:patched", "W:patched",
@@ -178,13 +173,13 @@ test("destroying/recreating a subcomponent, other scenario", async () => {
"Child:setup", "Child:setup",
"Child:willStart", "Child:willStart",
"Parent:rendered", "Parent:rendered",
"Child:willDestroy",
"Parent:willRender", "Parent:willRender",
"Child:setup", "Child:setup",
"Child:willStart", "Child:willStart",
"Parent:rendered", "Parent:rendered",
"Child:willRender", "Child:willRender",
"Child:rendered", "Child:rendered",
"Child:willDestroy",
"Parent:willPatch", "Parent:willPatch",
"Child:mounted", "Child:mounted",
"Parent:patched", "Parent:patched",
@@ -251,13 +246,13 @@ test("creating two async components, scenario 1", async () => {
await nextTick(); await nextTick();
expect(fixture.innerHTML).toBe(""); expect(fixture.innerHTML).toBe("");
expect([ expect([
"ChildA:willDestroy",
"Parent:willRender", "Parent:willRender",
"ChildA:setup", "ChildA:setup",
"ChildA:willStart", "ChildA:willStart",
"ChildB:setup", "ChildB:setup",
"ChildB:willStart", "ChildB:willStart",
"Parent:rendered", "Parent:rendered",
"ChildA:willDestroy",
]).toBeLogged(); ]).toBeLogged();
defB.resolve(); defB.resolve();
@@ -703,13 +698,13 @@ test("rendering component again in next microtick", async () => {
"Child:setup", "Child:setup",
"Child:willStart", "Child:willStart",
"Parent:rendered", "Parent:rendered",
"Child:willDestroy",
"Parent:willRender", "Parent:willRender",
"Child:setup", "Child:setup",
"Child:willStart", "Child:willStart",
"Parent:rendered", "Parent:rendered",
"Child:willRender", "Child:willRender",
"Child:rendered", "Child:rendered",
"Child:willDestroy",
"Parent:willPatch", "Parent:willPatch",
"Child:mounted", "Child:mounted",
"Parent:patched", "Parent:patched",
@@ -1732,9 +1727,9 @@ test("concurrent renderings scenario 10", async () => {
expect(fixture.innerHTML).toBe("<div><p></p></div>"); expect(fixture.innerHTML).toBe("<div><p></p></div>");
expect([ expect([
"ComponentA:willRender", "ComponentA:willRender",
"ComponentC:willDestroy",
"ComponentB:willUpdateProps", "ComponentB:willUpdateProps",
"ComponentA:rendered", "ComponentA:rendered",
"ComponentC:willDestroy",
]).toBeLogged(); ]).toBeLogged();
defB.resolve(); defB.resolve();
@@ -2282,7 +2277,6 @@ test("concurrent renderings scenario 16", async () => {
"D:setup", "D:setup",
"D:willStart", "D:willStart",
"C:rendered", "C:rendered",
"D:willDestroy",
"B:willRender", "B:willRender",
"C:willUpdateProps", "C:willUpdateProps",
"B:rendered", "B:rendered",
@@ -2290,6 +2284,7 @@ test("concurrent renderings scenario 16", async () => {
"D:setup", "D:setup",
"D:willStart", "D:willStart",
"C:rendered", "C:rendered",
"D:willDestroy",
]).toBeLogged(); ]).toBeLogged();
// at this point, C rendering is still pending, and nothing should have been // at this point, C rendering is still pending, and nothing should have been
@@ -2997,11 +2992,11 @@ test("t-key on dom node having a component", async () => {
expect(fixture.innerHTML).toBe("<div>3</div>"); expect(fixture.innerHTML).toBe("<div>3</div>");
expect([ expect([
"Child (2):willDestroy",
"Child (3):setup", "Child (3):setup",
"Child (3):willStart", "Child (3):willStart",
"Child (3):willRender", "Child (3):willRender",
"Child (3):rendered", "Child (3):rendered",
"Child (2):willDestroy",
"Child (1):willUnmount", "Child (1):willUnmount",
"Child (1):willDestroy", "Child (1):willDestroy",
"Child (3):mounted", "Child (3):mounted",
@@ -3055,11 +3050,11 @@ test("t-key on dynamic async component (toggler is never patched)", async () =>
expect(fixture.innerHTML).toBe("<div>3</div>"); expect(fixture.innerHTML).toBe("<div>3</div>");
expect([ expect([
"Child (2):willDestroy",
"Child (3):setup", "Child (3):setup",
"Child (3):willStart", "Child (3):willStart",
"Child (3):willRender", "Child (3):willRender",
"Child (3):rendered", "Child (3):rendered",
"Child (2):willDestroy",
"Child (1):willUnmount", "Child (1):willUnmount",
"Child (1):willDestroy", "Child (1):willDestroy",
"Child (3):mounted", "Child (3):mounted",
@@ -3114,11 +3109,11 @@ test("t-foreach with dynamic async component", async () => {
expect(fixture.innerHTML).toBe("<div>3</div>"); expect(fixture.innerHTML).toBe("<div>3</div>");
expect([ expect([
"Child (2):willDestroy",
"Child (3):setup", "Child (3):setup",
"Child (3):willStart", "Child (3):willStart",
"Child (3):willRender", "Child (3):willRender",
"Child (3):rendered", "Child (3):rendered",
"Child (2):willDestroy",
"Child (1):willUnmount", "Child (1):willUnmount",
"Child (1):willDestroy", "Child (1):willDestroy",
"Child (3):mounted", "Child (3):mounted",
@@ -3801,7 +3796,7 @@ test("destroyed component causes other soon to be destroyed component to rerende
static template = xml`<t t-esc="state.val + props.value"/>`; static template = xml`<t t-esc="state.val + props.value"/>`;
state = useState({ val: 0 }); state = useState({ val: 0 });
setup() { setup() {
c = this; c = c || this;
useLogLifecycle(); useLogLifecycle();
} }
} }
@@ -3846,8 +3841,6 @@ test("destroyed component causes other soon to be destroyed component to rerende
parent.state.valueB = 2; parent.state.valueB = 2;
await nextTick(); await nextTick();
expect([ expect([
"B:willDestroy",
"C:willDestroy",
"A:willRender", "A:willRender",
"B:setup", "B:setup",
"B:willStart", "B:willStart",
@@ -3858,6 +3851,8 @@ test("destroyed component causes other soon to be destroyed component to rerende
"B:rendered", "B:rendered",
"C:willRender", "C:willRender",
"C:rendered", "C:rendered",
"B:willDestroy",
"C:willDestroy",
"A:willPatch", "A:willPatch",
"C:mounted", "C:mounted",
"B:mounted", "B:mounted",
@@ -4200,6 +4195,116 @@ test("delayed render is not cancelled by upcoming render", async () => {
]).toBeLogged(); ]).toBeLogged();
}); });
test("components are not destroyed between animation frame", async () => {
const def = makeDeferred();
class C extends Component {
static template = xml`C`;
setup() {
useLogLifecycle();
}
}
class B extends Component {
static template = xml`B<C/>`;
static components = { C };
setup() {
useLogLifecycle();
onWillStart(() => {
return def;
});
}
}
class A extends Component {
static template = xml`A<B t-if="state.flag"/>`;
static components = { B };
state = useState({ flag: false });
setup() {
useLogLifecycle();
}
}
const a = await mount(A, fixture);
expect(fixture.innerHTML).toBe("A");
expect(["A:setup", "A:willStart", "A:willRender", "A:rendered", "A:mounted"]).toBeLogged();
// turn the flag on, this will render A and stops at B because of def
a.state.flag = true;
await nextTick();
expect(["A:willRender", "B:setup", "B:willStart", "A:rendered"]).toBeLogged();
// force a render of A
// => owl will need to create a new B component
// => initial B component will be cancelled
a.render();
await nextMicroTick();
expect([
// note that B is not destroyed here. It is cancelled instead
"A:willRender",
"B:setup",
"B:willStart",
"A:rendered",
]).toBeLogged();
// resolve def, so B render is unblocked
def.resolve();
await nextTick();
expect([
"B:willRender",
"C:setup",
"C:willStart",
"B:rendered",
"C:willRender",
"C:rendered",
// animation frame callback starts here
"B:willDestroy", // B is destroyed here
"A:willPatch",
"C:mounted",
"B:mounted",
"A:patched",
]).toBeLogged();
});
test("component destroyed just after render", async () => {
let stateB: any;
class B extends Component {
static template = xml`B<t t-esc="state.value"/>`;
state = useState({ value: 1 });
setup() {
stateB = this.state;
useLogLifecycle();
}
}
class A extends Component {
static template = xml`<B/>`;
static components = { B };
setup() {
useLogLifecycle();
}
}
const a = await mount(A, fixture);
expect(fixture.innerHTML).toBe("B1");
expect([
"A:setup",
"A:willStart",
"A:willRender",
"B:setup",
"B:willStart",
"A:rendered",
"B:willRender",
"B:rendered",
"B:mounted",
"A:mounted",
]).toBeLogged();
stateB!.value++; // force a render of B
await nextMicroTick(); // wait for B render to actually start
a.__owl__.app.destroy();
expect(["A:willUnmount", "B:willUnmount", "B:willDestroy", "A:willDestroy"]).toBeLogged();
await nextTick();
// check that B was not rendered after being destroyed
expect([]).toBeLogged();
});
// test.skip("components with shouldUpdate=false", async () => { // test.skip("components with shouldUpdate=false", async () => {
// const state = { p: 1, cc: 10 }; // const state = { p: 1, cc: 10 };
+6 -2
View File
@@ -1444,6 +1444,7 @@ describe("can catch errors", () => {
"Parent:willPatch", "Parent:willPatch",
"Child:willUnmount", "Child:willUnmount",
"Child:willDestroy", "Child:willDestroy",
"Parent:patched",
"Parent:willRender", "Parent:willRender",
"Parent:rendered", "Parent:rendered",
"Parent:willPatch", "Parent:willPatch",
@@ -1506,12 +1507,15 @@ describe("can catch errors", () => {
parent.state.hasChild = false; parent.state.hasChild = false;
await nextTick(); await nextTick();
expect([ expect([
"Parent:willRender",
"Parent:rendered",
"Child:willDestroy", "Child:willDestroy",
"Parent:willRender", "Parent:willRender",
"Parent:rendered", "Parent:rendered",
"Parent:willPatch",
"Parent:patched",
]).toBeLogged(); ]).toBeLogged();
expect(fixture.innerHTML).toBe("1");
await nextTick();
expect(["Parent:willPatch", "Parent:patched"]).toBeLogged();
expect(fixture.innerHTML).toBe("2"); expect(fixture.innerHTML).toBe("2");
}); });
}); });