mirror of
https://github.com/odoo/owl.git
synced 2025-10-06 19:59:41 +07:00
4c77132ae2
Previously, components would automatically call useState on their props, so that changes deeply within props would automatically cause the component to be rendered. This can be useful when passing a piece of state to children or descendants. One problem with this is that all props implicitly become reactive, even if the object that was passed as a props was not. The problem with that being that since the original object is not reactive, any change made by the parent will not go through the reactivity system and the children won't be notified of the change, in essence, this reactive object is essentially useless, while having a real cost: traversing reactive objects creates more reactive objects, and those objects are all proxies. This is expensive for basically no benefit, while also making it more difficult to debug code that involves those objects. This commit fixes that by only calling useState on objects that are already reactive, allowing the usecase described in the first paragraph without the drawbacks described in the second.
235 lines
7.1 KiB
TypeScript
235 lines
7.1 KiB
TypeScript
import {
|
|
Component,
|
|
mount,
|
|
onPatched,
|
|
onWillRender,
|
|
onWillPatch,
|
|
onWillUnmount,
|
|
useState,
|
|
xml,
|
|
} from "../../src";
|
|
import { makeTestFixture, nextTick, snapshotEverything, useLogLifecycle } from "../helpers";
|
|
|
|
let fixture: HTMLElement;
|
|
|
|
snapshotEverything();
|
|
|
|
beforeEach(() => {
|
|
fixture = makeTestFixture();
|
|
});
|
|
|
|
describe("reactivity in lifecycle", () => {
|
|
test("can use a state hook", async () => {
|
|
class Counter extends Component {
|
|
static template = xml`<div><t t-esc="counter.value"/></div>`;
|
|
counter = useState({ value: 42 });
|
|
}
|
|
const counter = await mount(Counter, fixture);
|
|
expect(fixture.innerHTML).toBe("<div>42</div>");
|
|
counter.counter.value = 3;
|
|
await nextTick();
|
|
expect(fixture.innerHTML).toBe("<div>3</div>");
|
|
});
|
|
|
|
test("can use a state hook 2", async () => {
|
|
let n = 0;
|
|
class Comp extends Component {
|
|
static template = xml`<div><t t-esc="state.a"/></div>`;
|
|
state = useState({ a: 5, b: 7 });
|
|
setup() {
|
|
onWillRender(() => n++);
|
|
}
|
|
}
|
|
const comp = await mount(Comp, fixture);
|
|
expect(fixture.innerHTML).toBe("<div>5</div>");
|
|
expect(n).toBe(1);
|
|
comp.state.a = 11;
|
|
await nextTick();
|
|
expect(fixture.innerHTML).toBe("<div>11</div>");
|
|
expect(n).toBe(2);
|
|
comp.state.b = 13;
|
|
await nextTick();
|
|
expect(fixture.innerHTML).toBe("<div>11</div>");
|
|
expect(n).toBe(2); // no new rendering occured: b was never read via state!
|
|
});
|
|
|
|
test("can use a state hook on Map", async () => {
|
|
class Counter extends Component {
|
|
static template = xml`<div><t t-esc="counter.get('value')"/></div>`;
|
|
counter = useState(new Map([["value", 42]]));
|
|
}
|
|
const counter = await mount(Counter, fixture);
|
|
expect(fixture.innerHTML).toBe("<div>42</div>");
|
|
counter.counter.set("value", 3);
|
|
await nextTick();
|
|
expect(fixture.innerHTML).toBe("<div>3</div>");
|
|
});
|
|
|
|
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 });
|
|
setup() {
|
|
onWillRender(() => {
|
|
steps.push("render");
|
|
});
|
|
onWillPatch(() => {
|
|
steps.push("willPatch");
|
|
});
|
|
onPatched(() => {
|
|
steps.push("patched");
|
|
});
|
|
onWillUnmount(() => {
|
|
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 parent = await mount(Parent, fixture);
|
|
expect(steps).toEqual(["render"]);
|
|
expect(fixture.innerHTML).toBe("<div><span>12</span></div>");
|
|
parent.state.flag = false;
|
|
await nextTick();
|
|
// we make sure here that no call to __render is done
|
|
expect(steps).toEqual(["render", "willUnmount"]);
|
|
});
|
|
|
|
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: any[] = [];
|
|
let STATE;
|
|
class Comp extends Component {
|
|
static template = xml`
|
|
<div><t t-esc="state.val"/></div>
|
|
`;
|
|
state = useState({ val: 1 });
|
|
setup() {
|
|
STATE = this.state;
|
|
onWillRender(() => {
|
|
steps.push(this.state.val);
|
|
});
|
|
}
|
|
}
|
|
const prom = mount(Comp, fixture);
|
|
(STATE as any).val = 2;
|
|
await prom;
|
|
expect(steps).toEqual([2]);
|
|
expect(fixture.innerHTML).toBe("<div>2</div>");
|
|
});
|
|
|
|
test("Child component doesn't render when state they depend on changes but their parent is about to unmount them", async () => {
|
|
class Child extends Component {
|
|
static template = xml`<t t-esc="props.state.content.a"/>`;
|
|
setup() {
|
|
useLogLifecycle();
|
|
}
|
|
}
|
|
class Parent extends Component {
|
|
static template = xml`<Child t-if="state.renderChild" state="state"/>`;
|
|
static components = { Child };
|
|
state: any = useState({ renderChild: true, content: { a: 2 } });
|
|
setup() {
|
|
useLogLifecycle();
|
|
}
|
|
}
|
|
|
|
const parent = await mount(Parent, fixture);
|
|
expect(fixture.innerHTML).toBe("2");
|
|
expect([
|
|
"Parent:setup",
|
|
"Parent:willStart",
|
|
"Parent:willRender",
|
|
"Child:setup",
|
|
"Child:willStart",
|
|
"Parent:rendered",
|
|
"Child:willRender",
|
|
"Child:rendered",
|
|
"Child:mounted",
|
|
"Parent:mounted",
|
|
]).toBeLogged();
|
|
|
|
parent.state.content = null;
|
|
parent.state.renderChild = false;
|
|
await nextTick();
|
|
expect([
|
|
"Parent:willRender",
|
|
"Parent:rendered",
|
|
"Parent:willPatch",
|
|
"Child:willUnmount",
|
|
"Child:willDestroy",
|
|
"Parent:patched",
|
|
]).toBeLogged();
|
|
});
|
|
|
|
test("Component is automatically subscribed to reactive object received as prop", async () => {
|
|
let childRenderCount = 0;
|
|
let parentRenderCount = 0;
|
|
class Child extends Component {
|
|
static template = xml`<t t-esc="props.obj.a"/><t t-esc="props.reactiveObj.b"/>`;
|
|
setup() {
|
|
onWillRender(() => childRenderCount++);
|
|
}
|
|
}
|
|
class Parent extends Component {
|
|
static template = xml`<Child obj="obj" reactiveObj="reactiveObj"/>`;
|
|
static components = { Child };
|
|
obj = { a: 1 };
|
|
reactiveObj = useState({ b: 2 });
|
|
setup() {
|
|
onWillRender(() => parentRenderCount++);
|
|
}
|
|
}
|
|
const comp = await mount(Parent, fixture);
|
|
expect([parentRenderCount, childRenderCount]).toEqual([1, 1]);
|
|
expect(fixture.innerHTML).toBe("12");
|
|
comp.obj.a = 3; // non reactive object, shouldn't cause render
|
|
await nextTick();
|
|
expect([parentRenderCount, childRenderCount]).toEqual([1, 1]);
|
|
expect(fixture.innerHTML).toBe("12");
|
|
comp.reactiveObj.b = 4;
|
|
await nextTick();
|
|
// Only child should be rendered: the parent never read the b key in reactiveObj
|
|
expect([parentRenderCount, childRenderCount]).toEqual([1, 2]);
|
|
expect(fixture.innerHTML).toBe("34");
|
|
});
|
|
});
|