mirror of
https://github.com/odoo/owl.git
synced 2025-10-06 19:59:41 +07:00
8d25bddda4
Owl has to manage a lot of interesting situations. One of them is when a rendering is initiated, which creates a sub component, but then another rendering starts, which invalidate the previous one, and will create another sub component. Since the first sub component was not ever in the DOM, we cannot rely on the vdom patching process to remove it, so we have to do it manually. Sadly, this is actually a very tricky situation, since there are other subtle situations where the code that remove an unmounted widget could be executed, in particular when the parent component is unmounted, then remounted, then modified to trigger yet another rendering. In this commit, we handle this case more carefully by making sure that the destroyed subcomponent properly configures its pvnode so the patch process happens as expected. joint work with the framework team, and in particular LPE for his work on finding a testcase! closes #724, #731
612 lines
18 KiB
TypeScript
612 lines
18 KiB
TypeScript
import { Component, Env } from "../../src/component/component";
|
|
import { useState } from "../../src/hooks";
|
|
import { xml } from "../../src/tags";
|
|
import { makeDeferred, makeTestEnv, makeTestFixture, nextTick, nextMicroTick } from "../helpers";
|
|
import { scheduler } from "../../src/component/scheduler";
|
|
|
|
//------------------------------------------------------------------------------
|
|
// Setup and helpers
|
|
//------------------------------------------------------------------------------
|
|
|
|
// We create before each test:
|
|
// - fixture: a div, appended to the DOM, intended to be the target of dom
|
|
// manipulations. Note that it is removed after each test.
|
|
// - env: a WEnv, necessary to create new components
|
|
|
|
let fixture: HTMLElement;
|
|
let env: Env;
|
|
|
|
beforeEach(() => {
|
|
fixture = makeTestFixture();
|
|
env = makeTestEnv();
|
|
Component.env = env;
|
|
});
|
|
|
|
afterEach(() => {
|
|
fixture.remove();
|
|
});
|
|
|
|
describe("mount targets", () => {
|
|
test("can attach a component to an existing node (if same tagname)", async () => {
|
|
class App extends Component {
|
|
static template = xml`<div t-att-class="state.customClass">app<p>another tag</p></div>`;
|
|
state = useState({ customClass: "custom" });
|
|
}
|
|
const div = document.createElement("div");
|
|
div.classList.add("arbitrary");
|
|
div.innerHTML = `<p>pre-existing</p>`;
|
|
fixture.appendChild(div);
|
|
|
|
const app = new App();
|
|
await app.mount(div, { position: "self" });
|
|
|
|
expect(fixture.innerHTML).toBe(
|
|
`<div class="arbitrary custom"><p>pre-existing</p>app<p>another tag</p></div>`
|
|
);
|
|
expect(div).toBe(app.el);
|
|
|
|
app.state.customClass = "custom2";
|
|
await nextTick();
|
|
expect(fixture.innerHTML).toBe(
|
|
`<div class="arbitrary custom2"><p>pre-existing</p>app<p>another tag</p></div>`
|
|
);
|
|
expect(div).toBe(app.el);
|
|
|
|
app.unmount();
|
|
// This assert is a best guess
|
|
// The use case it covers was not really thought through
|
|
// and may change in the future
|
|
expect(fixture.innerHTML).toBe("");
|
|
});
|
|
|
|
test("cannot attach a component to an existing node (if not same tagname)", async () => {
|
|
class App extends Component {
|
|
static template = xml`<span>app</span>`;
|
|
}
|
|
const div = document.createElement("div");
|
|
fixture.appendChild(div);
|
|
|
|
const app = new App();
|
|
let error;
|
|
try {
|
|
await app.mount(div, { position: "self" });
|
|
} catch (e) {
|
|
error = e;
|
|
}
|
|
expect(error).toBeDefined();
|
|
expect(error.message).toBe("Cannot attach 'App' to target node (not same tag name)");
|
|
});
|
|
|
|
test("can mount a component (with position='first-child')", async () => {
|
|
class App extends Component {
|
|
static template = xml`<div>app</div>`;
|
|
}
|
|
const span = document.createElement("span");
|
|
fixture.appendChild(span);
|
|
|
|
const app = new App();
|
|
await app.mount(fixture, { position: "first-child" });
|
|
expect(fixture.innerHTML).toBe("<div>app</div><span></span>");
|
|
});
|
|
|
|
test("can mount a component (with position='last-child')", async () => {
|
|
class App extends Component {
|
|
static template = xml`<div>app</div>`;
|
|
}
|
|
const span = document.createElement("span");
|
|
fixture.appendChild(span);
|
|
|
|
const app = new App();
|
|
await app.mount(fixture, { position: "last-child" });
|
|
expect(fixture.innerHTML).toBe("<span></span><div>app</div>");
|
|
});
|
|
|
|
test("default mount option is 'last-child'", async () => {
|
|
class App extends Component {
|
|
static template = xml`<div>app</div>`;
|
|
}
|
|
const span = document.createElement("span");
|
|
fixture.appendChild(span);
|
|
|
|
const app = new App();
|
|
await app.mount(fixture);
|
|
expect(fixture.innerHTML).toBe("<span></span><div>app</div>");
|
|
});
|
|
});
|
|
|
|
describe("unmounting and remounting", () => {
|
|
test("widget can be unmounted and remounted", async () => {
|
|
const steps: string[] = [];
|
|
class MyWidget extends Component {
|
|
static template = xml`<div>Hey</div>`;
|
|
async willStart() {
|
|
steps.push("willstart");
|
|
}
|
|
mounted() {
|
|
steps.push("mounted");
|
|
}
|
|
willUnmount() {
|
|
steps.push("willunmount");
|
|
}
|
|
patched() {
|
|
throw new Error("patched should not be called");
|
|
}
|
|
}
|
|
|
|
const w = new MyWidget();
|
|
await w.mount(fixture);
|
|
expect(fixture.innerHTML).toBe("<div>Hey</div>");
|
|
expect(steps).toEqual(["willstart", "mounted"]);
|
|
|
|
w.unmount();
|
|
expect(fixture.innerHTML).toBe("");
|
|
expect(steps).toEqual(["willstart", "mounted", "willunmount"]);
|
|
|
|
await w.mount(fixture);
|
|
expect(fixture.innerHTML).toBe("<div>Hey</div>");
|
|
expect(steps).toEqual(["willstart", "mounted", "willunmount", "mounted"]);
|
|
});
|
|
|
|
test("widget can be mounted twice without ill effect", async () => {
|
|
const steps: string[] = [];
|
|
class MyWidget extends Component {
|
|
static template = xml`<div>Hey</div>`;
|
|
async willStart() {
|
|
steps.push("willstart");
|
|
}
|
|
mounted() {
|
|
steps.push("mounted");
|
|
}
|
|
willUnmount() {
|
|
steps.push("willunmount");
|
|
}
|
|
}
|
|
|
|
const w = new MyWidget();
|
|
await w.mount(fixture);
|
|
await w.mount(fixture);
|
|
expect(fixture.innerHTML).toBe("<div>Hey</div>");
|
|
expect(steps).toEqual(["willstart", "mounted"]);
|
|
});
|
|
|
|
test("state changes in willUnmount do not trigger rerender", async () => {
|
|
const steps: string[] = [];
|
|
|
|
class Child extends Component {
|
|
static template = xml`
|
|
<span><t t-esc="props.val"/><t t-esc="state.n"/></span>
|
|
`;
|
|
state = useState({ n: 2 });
|
|
__render(f) {
|
|
steps.push("render");
|
|
return super.__render(f);
|
|
}
|
|
willPatch() {
|
|
steps.push("willPatch");
|
|
}
|
|
patched() {
|
|
steps.push("patched");
|
|
}
|
|
|
|
willUnmount() {
|
|
steps.push("willUnmount");
|
|
this.state.n = 3;
|
|
}
|
|
}
|
|
class Parent extends Component {
|
|
static template = xml`
|
|
<div>
|
|
<Child t-if="state.flag" val="state.val"/>
|
|
</div>
|
|
`;
|
|
static components = { Child };
|
|
state = useState({ val: 1, flag: true });
|
|
}
|
|
|
|
const widget = new Parent();
|
|
await widget.mount(fixture);
|
|
expect(steps).toEqual(["render"]);
|
|
expect(fixture.innerHTML).toBe("<div><span>12</span></div>");
|
|
widget.state.flag = false;
|
|
await nextTick();
|
|
// we make sure here that no call to __render is done
|
|
expect(steps).toEqual(["render", "willUnmount"]);
|
|
});
|
|
|
|
test("state changes in willUnmount will be applied on remount", async () => {
|
|
class TestWidget extends Component {
|
|
static template = xml`
|
|
<div><t t-esc="state.val"/></div>
|
|
`;
|
|
state = useState({ val: 1 });
|
|
willUnmount() {
|
|
this.state.val = 3;
|
|
}
|
|
}
|
|
|
|
const widget = new TestWidget();
|
|
await widget.mount(fixture);
|
|
expect(fixture.innerHTML).toBe("<div>1</div>");
|
|
widget.unmount();
|
|
expect(fixture.innerHTML).toBe("");
|
|
await nextTick(); // wait for changes to be detected before remounting
|
|
await widget.mount(fixture);
|
|
expect(fixture.innerHTML).toBe("<div>3</div>");
|
|
// we want to make sure that there are no remaining tasks left at this point.
|
|
expect(Component.scheduler.tasks.length).toBe(0);
|
|
});
|
|
|
|
test("sub component is still active after being unmounted and remounted", async () => {
|
|
class Child extends Component {
|
|
static template = xml`
|
|
<p t-on-click="state.value++">
|
|
<t t-esc="state.value"/>
|
|
</p>`;
|
|
|
|
state = useState({ value: 1 });
|
|
}
|
|
|
|
class Parent extends Component {
|
|
static components = { Child };
|
|
static template = xml`<div><Child/></div>`;
|
|
}
|
|
|
|
const w = new Parent();
|
|
await w.mount(fixture);
|
|
expect(fixture.innerHTML).toBe("<div><p>1</p></div>");
|
|
|
|
fixture.querySelector("p")!.click();
|
|
await nextTick();
|
|
expect(fixture.innerHTML).toBe("<div><p>2</p></div>");
|
|
w.unmount();
|
|
await nextTick();
|
|
await w.mount(fixture);
|
|
expect(fixture.innerHTML).toBe("<div><p>2</p></div>");
|
|
fixture.querySelector("p")!.click();
|
|
await nextTick();
|
|
expect(fixture.innerHTML).toBe("<div><p>3</p></div>");
|
|
});
|
|
|
|
test("change state just before mounting component", async () => {
|
|
const steps: number[] = [];
|
|
class TestWidget extends Component {
|
|
static template = xml`
|
|
<div><t t-esc="state.val"/></div>
|
|
`;
|
|
state = useState({ val: 1 });
|
|
__render(f) {
|
|
steps.push(this.state.val);
|
|
return super.__render(f);
|
|
}
|
|
}
|
|
TestWidget.prototype.__render = jest.fn(TestWidget.prototype.__render);
|
|
|
|
const widget = new TestWidget();
|
|
widget.state.val = 2;
|
|
await widget.mount(fixture);
|
|
expect(fixture.innerHTML).toBe("<div>2</div>");
|
|
expect(TestWidget.prototype.__render).toHaveBeenCalledTimes(1);
|
|
|
|
// unmount and re-mount, as in this case, willStart won't be called, so it's
|
|
// slightly different
|
|
widget.unmount();
|
|
widget.state.val = 3;
|
|
await widget.mount(fixture);
|
|
expect(fixture.innerHTML).toBe("<div>3</div>");
|
|
expect(TestWidget.prototype.__render).toHaveBeenCalledTimes(2);
|
|
expect(steps).toEqual([2, 3]);
|
|
});
|
|
|
|
test("change state while mounting component", async () => {
|
|
const steps: number[] = [];
|
|
class TestWidget extends Component {
|
|
static template = xml`
|
|
<div><t t-esc="state.val"/></div>
|
|
`;
|
|
state = useState({ val: 1 });
|
|
__render(f) {
|
|
steps.push(this.state.val);
|
|
return super.__render(f);
|
|
}
|
|
}
|
|
TestWidget.prototype.__render = jest.fn(TestWidget.prototype.__render);
|
|
TestWidget.prototype.__patch = jest.fn(TestWidget.prototype.__patch);
|
|
|
|
const widget = new TestWidget();
|
|
let prom = widget.mount(fixture);
|
|
widget.state.val = 2;
|
|
await prom;
|
|
expect(fixture.innerHTML).toBe("<div>2</div>");
|
|
expect(TestWidget.prototype.__render).toHaveBeenCalledTimes(1);
|
|
|
|
// unmount and re-mount, as in this case, willStart won't be called, so it's
|
|
// slightly different
|
|
widget.unmount();
|
|
prom = widget.mount(fixture);
|
|
widget.state.val = 3;
|
|
await prom;
|
|
expect(fixture.innerHTML).toBe("<div>3</div>");
|
|
expect(TestWidget.prototype.__render).toHaveBeenCalledTimes(3);
|
|
expect(TestWidget.prototype.__patch).toHaveBeenCalledTimes(2);
|
|
expect(steps).toEqual([2, 2, 3]);
|
|
});
|
|
|
|
test("change state while component is unmounted", async () => {
|
|
let child;
|
|
class Child extends Component {
|
|
static template = xml`<span t-esc="state.val"/>`;
|
|
state = useState({
|
|
val: "C1",
|
|
});
|
|
constructor(parent, props) {
|
|
super(parent, props);
|
|
child = this;
|
|
}
|
|
}
|
|
|
|
class Parent extends Component {
|
|
static components = { Child };
|
|
static template = xml`<div><t t-esc="state.val"/><Child/></div>`;
|
|
state = useState({ val: "P1" });
|
|
}
|
|
|
|
const parent = new Parent();
|
|
await parent.mount(fixture);
|
|
expect(fixture.innerHTML).toBe("<div>P1<span>C1</span></div>");
|
|
|
|
parent.unmount();
|
|
expect(fixture.innerHTML).toBe("");
|
|
|
|
parent.state.val = "P2";
|
|
child.state.val = "C2";
|
|
|
|
await parent.mount(fixture);
|
|
expect(fixture.innerHTML).toBe("<div>P2<span>C2</span></div>");
|
|
});
|
|
|
|
test("unmount component during a re-rendering", async () => {
|
|
const def = makeDeferred();
|
|
class Child extends Component {
|
|
static template = xml`<span><t t-esc="props.val"/></span>`;
|
|
willUpdateProps() {
|
|
return def;
|
|
}
|
|
}
|
|
Child.prototype.__render = jest.fn(Child.prototype.__render);
|
|
|
|
class Parent extends Component {
|
|
static template = xml`<div><Child val="state.val"/></div>`;
|
|
static components = { Child };
|
|
state = useState({ val: 1 });
|
|
}
|
|
|
|
const parent = new Parent();
|
|
await parent.mount(fixture);
|
|
expect(fixture.innerHTML).toBe("<div><span>1</span></div>");
|
|
expect(Child.prototype.__render).toBeCalledTimes(1);
|
|
|
|
parent.state.val = 2;
|
|
await nextTick();
|
|
expect(fixture.innerHTML).toBe("<div><span>1</span></div>");
|
|
|
|
parent.unmount();
|
|
expect(fixture.innerHTML).toBe("");
|
|
|
|
def.resolve();
|
|
await nextTick();
|
|
expect(fixture.innerHTML).toBe("");
|
|
expect(Child.prototype.__render).toBeCalledTimes(1);
|
|
});
|
|
|
|
test("widget can be mounted on different target", async () => {
|
|
class MyWidget extends Component {
|
|
static template = xml`<div>Hey</div>`;
|
|
patched() {
|
|
throw new Error("patched should not be called");
|
|
}
|
|
}
|
|
const div = document.createElement("div");
|
|
const span = document.createElement("span");
|
|
fixture.appendChild(div);
|
|
fixture.appendChild(span);
|
|
const w = new MyWidget();
|
|
await w.mount(div);
|
|
|
|
expect(fixture.innerHTML).toBe("<div><div>Hey</div></div><span></span>");
|
|
|
|
await w.mount(span);
|
|
expect(fixture.innerHTML).toBe("<div></div><span><div>Hey</div></span>");
|
|
});
|
|
|
|
test("widget can be mounted on different target, another situation", async () => {
|
|
const def = makeDeferred();
|
|
const steps: string[] = [];
|
|
|
|
class MyWidget extends Component {
|
|
static template = xml`<div>Hey</div>`;
|
|
async willStart() {
|
|
return def;
|
|
}
|
|
patched() {
|
|
throw new Error("patched should not be called");
|
|
}
|
|
}
|
|
const div = document.createElement("div");
|
|
const span = document.createElement("span");
|
|
fixture.appendChild(div);
|
|
fixture.appendChild(span);
|
|
const w = new MyWidget();
|
|
|
|
w.mount(div).catch(() => steps.push("1 catch"));
|
|
|
|
await nextTick();
|
|
expect(fixture.innerHTML).toBe("<div></div><span></span>");
|
|
|
|
w.mount(span).then(() => steps.push("2 resolved"));
|
|
|
|
// we wait two microticks because this is the number of internal promises
|
|
// that need to be resolved/rejected, and because we want to prove here
|
|
// that the first mount operation is cancelled immediately, and not after
|
|
// one full tick.
|
|
await nextMicroTick();
|
|
await nextMicroTick();
|
|
expect(steps).toEqual(["1 catch"]);
|
|
await nextTick();
|
|
expect(fixture.innerHTML).toBe("<div></div><span></span>");
|
|
|
|
def.resolve();
|
|
await nextTick();
|
|
expect(steps).toEqual(["1 catch", "2 resolved"]);
|
|
expect(fixture.innerHTML).toBe("<div></div><span><div>Hey</div></span>");
|
|
});
|
|
|
|
test("widget can be mounted on same target, another situation", async () => {
|
|
const def = makeDeferred();
|
|
const steps: string[] = [];
|
|
|
|
class MyWidget extends Component {
|
|
static template = xml`<div>Hey</div>`;
|
|
async willStart() {
|
|
return def;
|
|
}
|
|
patched() {
|
|
throw new Error("patched should not be called");
|
|
}
|
|
}
|
|
const w = new MyWidget();
|
|
|
|
w.mount(fixture).then(() => steps.push("1 resolved"));
|
|
|
|
await nextTick();
|
|
expect(fixture.innerHTML).toBe("");
|
|
|
|
w.mount(fixture).then(() => steps.push("2 resolved"));
|
|
|
|
await nextTick();
|
|
expect(steps).toEqual([]);
|
|
expect(fixture.innerHTML).toBe("");
|
|
|
|
def.resolve();
|
|
await nextTick();
|
|
expect(steps).toEqual(["1 resolved", "2 resolved"]);
|
|
expect(fixture.innerHTML).toBe("<div>Hey</div>");
|
|
});
|
|
|
|
test("mounting a destroyed widget", async () => {
|
|
class MyWidget extends Component {
|
|
static template = xml`<div>Hey</div>`;
|
|
}
|
|
const w = new MyWidget();
|
|
w.destroy(); // because, why not
|
|
|
|
let error;
|
|
try {
|
|
await w.mount(fixture);
|
|
} catch (e) {
|
|
error = e;
|
|
}
|
|
expect(scheduler.tasks.length).toBe(0);
|
|
expect(error).toBeDefined();
|
|
expect(error.message).toBe("Cannot mount a destroyed component");
|
|
});
|
|
|
|
test("destroying a sub-component cleans itself from parent's vnode", async () => {
|
|
class C1 extends Component {
|
|
static template = xml`<div><div><t t-esc="props.a"/></div></div>`;
|
|
}
|
|
class P extends Component {
|
|
static components = { C1 };
|
|
static template = xml`<div><div><C1 t-props="state" t-if="state.a"/></div></div>`;
|
|
state = {
|
|
a: "first",
|
|
};
|
|
}
|
|
const parent = new P();
|
|
await parent.mount(fixture);
|
|
expect(fixture.textContent).toBe("first");
|
|
parent.unmount();
|
|
parent.state.a = "";
|
|
parent.mount(fixture);
|
|
parent.state.a = "fixed";
|
|
await parent.render();
|
|
expect(fixture.textContent).toBe("fixed");
|
|
});
|
|
|
|
test("destroying a sub-component cleans itself from parent's vnode, part 2", async () => {
|
|
class C1 extends Component {
|
|
static template = xml`<div><div><t t-esc="props.a"/></div></div>`;
|
|
}
|
|
class P extends Component {
|
|
static components = { C1 };
|
|
static template = xml`<div><div><C1 t-props="state" t-if="state.a"/>some text</div></div>`;
|
|
state = {
|
|
a: "first",
|
|
};
|
|
}
|
|
const parent = new P();
|
|
await parent.mount(fixture);
|
|
expect(fixture.textContent).toBe("firstsome text");
|
|
parent.unmount();
|
|
parent.state.a = "";
|
|
parent.mount(fixture);
|
|
parent.state.a = "fixed";
|
|
await parent.render();
|
|
expect(fixture.textContent).toBe("fixedsome text");
|
|
});
|
|
|
|
test("destroying a sub-component cleans itself from parent's vnode, part 3", async () => {
|
|
class C1 extends Component {
|
|
static template = xml`<div><div><t t-esc="props.a"/></div></div>`;
|
|
}
|
|
|
|
class C2 extends Component {
|
|
static template = xml`<C1 a="props.a"/>`;
|
|
static components = { C1 };
|
|
}
|
|
|
|
class P extends Component {
|
|
static components = { C2 };
|
|
static template = xml`<div><div><C2 t-props="state" t-if="state.a"/></div></div>`;
|
|
state = {
|
|
a: "first",
|
|
};
|
|
}
|
|
const parent = new P();
|
|
await parent.mount(fixture);
|
|
expect(fixture.textContent).toBe("first");
|
|
parent.unmount();
|
|
parent.state.a = "";
|
|
parent.mount(fixture);
|
|
parent.state.a = "fixed";
|
|
await parent.render();
|
|
expect(fixture.textContent).toBe("fixed");
|
|
});
|
|
|
|
test("destroying a sub-component cleans itself from parent's vnode, part 4", async () => {
|
|
class C1 extends Component {
|
|
static template = xml`<div><div><t t-esc="props.a"/></div></div>`;
|
|
}
|
|
|
|
class C2 extends Component {
|
|
static template = xml`<C1 a="props.a"/>`;
|
|
static components = { C1 };
|
|
}
|
|
class P extends Component {
|
|
static components = { C2 };
|
|
static template = xml`<div><div><C2 t-props="state" t-if="state.a"/>some text</div></div>`;
|
|
state = {
|
|
a: "first",
|
|
};
|
|
}
|
|
const parent = new P();
|
|
await parent.mount(fixture);
|
|
expect(fixture.textContent).toBe("firstsome text");
|
|
parent.unmount();
|
|
parent.state.a = "";
|
|
parent.mount(fixture);
|
|
parent.state.a = "fixed";
|
|
await parent.render();
|
|
expect(fixture.textContent).toBe("fixedsome text");
|
|
});
|
|
});
|