import { Component, Env } from "../../src/component/component";
import { QWeb } from "../../src/qweb/qweb";
import { xml } from "../../src/tags";
import { useState, useRef } from "../../src/hooks";
import { EventBus } from "../../src/core/event_bus";
import {
makeDeferred,
makeTestFixture,
makeTestEnv,
nextMicroTick,
nextTick,
normalize,
editInput
} from "../helpers";
//------------------------------------------------------------------------------
// 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();
env.qweb.addTemplate("Widget", "
");
env.qweb.addTemplate(
"Counter",
`
`
);
env.qweb.addTemplate("WidgetA", `
Hello
`);
env.qweb.addTemplate("WidgetB", `
world
`);
Component.env = env;
});
afterEach(() => {
fixture.remove();
});
class Widget extends Component {}
function children(w: Widget): Widget[] {
const childrenMap = w.__owl__.children;
return Object.keys(childrenMap).map(id => childrenMap[id]);
}
// Test components
class Counter extends Widget {
state = useState({
counter: 0
});
inc() {
this.state.counter++;
}
}
class WidgetB extends Widget {}
class WidgetA extends Widget {
static components = { b: WidgetB };
}
//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------
describe("basic widget properties", () => {
test("props is set on root components", async () => {
const widget = new Widget(null, {});
expect(widget.props).toEqual({});
});
test("has no el after creation", async () => {
const widget = new Widget();
expect(widget.el).toBe(null);
});
test("can be mounted", async () => {
class SomeWidget extends Component {
static template = xml`
");
});
test("display a nice message if mounted on a non existing node", async () => {
class SomeWidget extends Component {
static template = xml`
content
`;
}
const widget = new SomeWidget();
let error;
try {
await widget.mount(null as any);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe(
"Component 'SomeWidget' cannot be mounted: the target is not a valid DOM node.\nMaybe the DOM is not ready yet? (in that case, you can use owl.utils.whenReady)"
);
});
test("display an error message if result of rendering is empty", async () => {
class SomeWidget extends Component {
static template = xml``;
}
const widget = new SomeWidget();
let error;
try {
await widget.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe("Rendering 'SomeWidget' did not return anything");
});
test("crashes if it cannot find a template", async () => {
expect.assertions(1);
class SomeWidget extends Component {}
try {
new SomeWidget();
} catch (e) {
expect(e.message).toBe('Could not find template for component "SomeWidget"');
}
});
test("can be clicked on and updated", async () => {
const counter = new Counter();
counter.mount(fixture);
await nextTick();
expect(fixture.innerHTML).toBe("
");
});
test("cannot be clicked on and updated if not in DOM", async () => {
const counter = new Counter();
const target = document.createElement("div");
await counter.mount(target);
expect(target.innerHTML).toBe("
`);
});
test("reconciliation alg is not confused in some specific situation", async () => {
// in this test, we set t-key to 4 because it was in conflict with the
// template id corresponding to the first child.
class Child extends Component {
static template = xml`child`;
}
class Parent extends Component {
static template = xml`
");
});
test("t-key on a component with t-if, and a sibling component", async () => {
class Child extends Component {
static template = xml`child`;
}
class Parent extends Component {
static template = xml`
`);
class ParentWidget extends Widget {
static components = { child: ChildWidget };
state = useState({ n: 1 });
willPatch() {
steps.push("parent:willPatch");
}
patched() {
steps.push("parent:patched");
}
}
const widget = new ParentWidget();
await widget.mount(fixture);
expect(steps).toEqual([]);
widget.state.n = 2;
await nextTick();
widget.destroy();
expect(steps).toEqual([
"parent:willPatch",
"child:willPatch",
"childchild:willPatch",
"childchild:patched",
"child:patched",
"parent:patched"
]);
});
test("willStart, mounted on subwidget rendered after main is mounted in some other position", async () => {
const steps: string[] = [];
// the t-else part in the template is important. This is
// necessary to have a situation that could confuse the vdom
// patching algorithm
env.qweb.addTemplate(
"ParentWidget",
`
`
);
class ChildWidget extends Widget {
async willStart() {
steps.push("child:willStart");
}
mounted() {
steps.push("child:mounted");
}
}
class ParentWidget extends Widget {
state = useState({ ok: false });
static components = { child: ChildWidget };
}
const widget = new ParentWidget();
await widget.mount(fixture);
expect(steps).toEqual([]);
widget.state.ok = true;
await nextTick();
expect(steps).toEqual(["child:willStart", "child:mounted"]);
});
test("mounted hook is correctly called on subcomponents created in mounted hook", async () => {
// the issue here is that the parent widget creates in the
// mounted hook a new widget, which means that it modifies
// in place its list of children. But this list of children is currently
// being visited, so the mount action of the parent could cause a mount
// action of the new child widget, even though it is not ready yet.
expect.assertions(1);
class ParentWidget extends Widget {
mounted() {
const child = new ChildWidget(this);
child.mount(this.el!);
}
}
class ChildWidget extends Widget {
mounted() {
expect(this.el).toBeTruthy();
}
}
const widget = new ParentWidget();
await widget.mount(fixture); // wait for ParentWidget
await nextTick(); // wait for ChildWidget
});
test("components are unmounted and destroyed if no longer in DOM", async () => {
let steps: string[] = [];
class ChildWidget extends Widget {
constructor(parent) {
super(parent);
steps.push("init");
}
async willStart() {
steps.push("willstart");
}
mounted() {
steps.push("mounted");
}
willUnmount() {
steps.push("willunmount");
}
}
class ParentWidget extends Widget {
static template = xml`
`;
static components = { ChildWidget };
state = useState({ ok: true });
}
const widget = new ParentWidget();
await widget.mount(fixture);
expect(steps).toEqual(["init", "willstart", "mounted"]);
widget.state.ok = false;
await nextTick();
expect(steps).toEqual(["init", "willstart", "mounted", "willunmount"]);
});
test("components are unmounted and destroyed if no longer in DOM, even after updateprops", async () => {
let childUnmounted = false;
class ChildWidget extends Widget {
static template = xml``;
willUnmount() {
childUnmounted = true;
}
}
class ParentWidget extends Widget {
static template = xml`
");
});
test("destroying a widget before being mounted (2)", async () => {
const steps: string[] = [];
class Child extends Component {
static template = xml``;
willStart() {
steps.push("willStart");
return makeDeferred();
}
__destroy(parent) {
steps.push("__destroy");
super.__destroy(parent);
}
}
class Parent extends Component {
static template = xml`
`;
static components = { Child };
state = useState({ flag: true });
}
const parent = new Parent();
const prom = parent.mount(fixture);
await nextTick(); // wait for Child to be instantiated
parent.state.flag = false;
await prom;
expect(fixture.innerHTML).toBe("");
expect(steps).toEqual(["willStart", "__destroy"]);
});
});
describe("composition", () => {
test("a widget with a sub widget", async () => {
const widget = new WidgetA();
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("
Hello
world
");
expect(children(widget)[0].__owl__.parent).toBe(widget);
});
test("can use components from the global registry", async () => {
QWeb.registerComponent("WidgetB", WidgetB);
env.qweb.addTemplate("ParentWidget", `
`);
class ParentWidget extends Widget {}
const widget = new ParentWidget();
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("
world
");
delete QWeb.components["WidgetB"];
});
test("can use dynamic components (the class) if given", async () => {
class A extends Component {
static template = xml`child a`;
}
class B extends Component {
static template = xml`child b`;
}
class App extends Component {
static template = xml``;
state = useState({
child: "a"
});
get myComponent() {
return this.state.child === "a" ? A : B;
}
}
const widget = new App();
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("child a");
widget.state.child = "b";
await nextTick();
expect(fixture.innerHTML).toBe("child b");
});
test("can use dynamic components (the class) if given (with different root tagname)", async () => {
class A extends Component {
static template = xml`child a`;
}
class B extends Component {
static template = xml`
child b
`;
}
class App extends Component {
static template = xml``;
state = useState({
child: "a"
});
get myComponent() {
return this.state.child === "a" ? A : B;
}
}
const widget = new App();
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("child a");
widget.state.child = "b";
await nextTick();
expect(fixture.innerHTML).toBe("
child b
");
});
test("don't fallback to global registry if widget defined locally", async () => {
QWeb.registerComponent("WidgetB", WidgetB); // should not use this widget
env.qweb.addTemplate("ParentWidget", `
`);
env.qweb.addTemplate("AnotherWidgetB", `Belgium`);
class AnotherWidgetB extends Widget {}
class ParentWidget extends Widget {
static components = { WidgetB: AnotherWidgetB };
}
const widget = new ParentWidget();
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("
Belgium
");
delete QWeb.components["WidgetB"];
});
test("can define components in template without t-component", async () => {
env.qweb.addTemplates(`
`);
class C extends Widget {}
class P extends Widget {
static components = { C };
}
const parent = new P();
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("
1
");
});
test("display a nice error if it cannot find component", async () => {
const consoleError = console.error;
console.error = jest.fn();
env.qweb.addTemplate("Parent", `
`);
class Parent extends Widget {
static components = { SomeWidget: Widget };
}
const parent = new Parent();
let error;
try {
await parent.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe('Cannot find the definition of component "SomeMispelledWidget"');
expect(console.error).toBeCalledTimes(0);
console.error = consoleError;
});
test("modifying a sub widget", async () => {
env.qweb.addTemplate("ParentWidget", `
`)
);
});
test("sub components with some state rendered in a loop", async () => {
let n = 1;
env.qweb.addTemplate("ChildWidget", ``);
class ChildWidget extends Widget {
state: any;
constructor(parent) {
super(parent);
this.state = useState({ n });
n++;
}
}
env.qweb.addTemplate(
"parent",
`
`)
);
});
test("list of sub components inside other nodes", async () => {
// this confuses the patching algorithm...
env.qweb.addTemplate("ChildWidget", `child`);
env.qweb.addTemplates(`
"
);
});
test("list of two sub components inside other nodes", async () => {
// this confuses the patching algorithm...
env.qweb.addTemplate("ChildWidget", `child`);
env.qweb.addTemplates(`
asdf`);
class SubWidget extends Widget {}
class Parent extends Widget {
static components = { SubWidget };
state = useState({ blips: [{ a: "a", id: 1 }] });
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("
`);
class ParentWidget extends Widget {
static components = { WidgetB };
state = useState({ widget: "WidgetB" });
}
const widget = new ParentWidget();
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("
world
");
expect(env.qweb.templates.ParentWidget.fn.toString()).toMatchSnapshot();
});
test("t-component with dynamic value 2", async () => {
env.qweb.addTemplate("ParentWidget", `
`);
class ParentWidget extends Widget {
static components = { WidgetB };
state = useState({ widget: "B" });
}
const widget = new ParentWidget();
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("
world
");
expect(env.qweb.templates.ParentWidget.fn.toString()).toMatchSnapshot();
});
test("t-component not on a node", async () => {
class Child extends Component {
static template = xml`1`;
}
class Parent extends Component {
static components = { Child };
static template = xml`
`;
}
const parent = new Parent();
let error;
try {
await parent.mount(fixture);
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toBe(
`Directive 't-component' can only be used on nodes (used on a
");
});
test("t-foreach with t-component, and update", async () => {
class Child extends Widget {
static template = xml`
`;
state = useState({ val: "A" });
mounted() {
this.state.val = "B";
}
}
class ParentWidget extends Widget {
static components = { Child };
static template = xml`
`;
}
const widget = new ParentWidget();
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("
A0A1
");
await nextTick(); // wait for changes triggered in mounted to be applied
expect(fixture.innerHTML).toBe("
B0B1
");
});
});
describe("random stuff/miscellaneous", () => {
test("widget after a t-foreach", async () => {
// this test makes sure that the foreach directive does not pollute sub
// context with the inLoop variable, which is then used in the t-component
// directive as a key
env.qweb.addTemplate(
"Test",
`
txt
`
);
class Test extends Widget {
static components = { widget: Widget };
}
const widget = new Test();
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("
txttxt
");
});
test("t-on with handler bound to dynamic argument on a t-foreach", async () => {
expect.assertions(3);
class Child extends Widget {}
class ParentWidget extends Widget {
static template = xml`
`;
static components = { Child };
items = [1, 2, 3, 4];
onEv(n, ev) {
expect(n).toBe(1);
expect(ev.detail).toBe(43);
}
}
const widget = new ParentWidget();
await widget.mount(fixture);
children(widget)[0].trigger("ev", 43);
expect(env.qweb.templates[ParentWidget.template].fn.toString()).toMatchSnapshot();
});
test("updating widget immediately", async () => {
// in this situation, we protect against a bug that occurred: because of the
// interplay between components and vnodes, a sub widget vnode was patched
// twice.
env.qweb.addTemplate("Parent", `
`);
class Child extends Widget {}
class Parent extends Widget {
static components = { child: Child };
state = useState({ flag: false });
}
env.qweb.addTemplate("Child", `abcdef`);
const widget = new Parent();
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("
`);
class B extends TestWidget {
name = "B";
constructor(parent, props) {
super(parent, props);
steps.push("B:constructor");
}
}
env.qweb.addTemplate("D", `
D
`);
class D extends TestWidget {
name = "D";
constructor(parent, props) {
super(parent, props);
steps.push("D:constructor");
}
}
env.qweb.addTemplate("E", `
E
`);
class E extends TestWidget {
name = "E";
constructor(parent, props) {
super(parent, props);
steps.push("E:constructor");
}
}
env.qweb.addTemplate("F", `
F
`);
class F extends TestWidget {
name = "F";
constructor(parent, props) {
super(parent, props);
steps.push("F:constructor");
}
}
env.qweb.addTemplate(
"C",
`
C
`
);
class C extends TestWidget {
static components = { D, E, F };
name = "C";
state = useState({ flag: true });
constructor(parent, props) {
super(parent, props);
c = this;
steps.push("C:constructor");
}
}
env.qweb.addTemplate("A", `
A
`);
class A extends TestWidget {
static components = { B, C };
name = "A";
}
const a = new A();
await a.mount(fixture);
expect(fixture.innerHTML).toBe(`
");
parent.state.valA = 2;
await nextMicroTick();
expect(steps).toEqual(["render"]);
await nextMicroTick();
// For an unknown reason, this test fails on windows without the next microtick. It works
// in linux and osx, but fails on at least this machine.
// I do not see anything harmful in waiting an extra tick. But it is annoying to not
// know what is different.
await nextMicroTick();
expect(steps).toEqual(["render", "render"]);
expect(fixture.innerHTML).toBe("
1
");
parent.state.valA = 3;
await nextMicroTick();
expect(steps).toEqual(["render", "render"]);
await nextMicroTick();
// same as above
await nextMicroTick();
expect(steps).toEqual(["render", "render", "render"]);
expect(fixture.innerHTML).toBe("
");
// this change triggers a rendering of the parent. This rendering is delayed,
// because child is now waiting for def to be resolved
parent.state.val = "Framboise Girardin";
await nextTick();
expect(fixture.innerHTML).toBe("
");
// with this, we remove child, and subchild, even though it is not finished
// rendering from previous changes
parent.state.flag = false;
await nextTick();
expect(fixture.innerHTML).toBe("");
// we now resolve def, so the child rendering is now complete.
(def).resolve();
await nextTick();
expect(fixture.innerHTML).toBe("");
});
test.skip("reuse widget if possible, in some async situation", async () => {
// this optimization has been temporarily deactivated
env.qweb.addTemplates(`
ab
`);
let destroyCount = 0;
class ChildA extends Widget {
destroy() {
destroyCount++;
super.destroy();
}
}
class ChildB extends Widget {
willStart(): any {
return new Promise(function() {});
}
}
class Parent extends Widget {
static components = { ChildA, ChildB };
state = useState({ valA: 1, valB: 2, flag: false });
}
const parent = new Parent();
await parent.mount(fixture);
expect(destroyCount).toBe(0);
parent.state.flag = true;
await nextTick();
expect(destroyCount).toBe(0);
parent.state.valB = 3;
await nextTick();
expect(destroyCount).toBe(0);
});
test("rendering component again in next microtick", async () => {
class Child extends Widget {
static template = xml`
");
});
test("concurrent renderings scenario 2", async () => {
// this test asserts that a rendering initiated before another one, and that
// ends after it, is re-mapped to that second rendering
const defs = [makeDeferred(), makeDeferred()];
let index = 0;
let stateB;
class ComponentC extends Component {
static template = xml``;
willUpdateProps() {
return defs[index++];
}
}
class ComponentB extends Component {
static template = xml`
");
defs[0].resolve(); // resolve rendering initiated in A
await nextTick();
expect(fixture.innerHTML).toBe("
1b
"); // TODO: is this what we want?? 2b could be ok too
defs[1].resolve(); // resolve rendering initiated in B
await nextTick();
expect(fixture.innerHTML).toBe("
");
defB.resolve(); // resolve rendering initiated in A (still blocked in D)
await nextTick();
expect(fixture.innerHTML).toBe("
1c
");
defsD[0].resolve(); // resolve rendering initiated in C (should be ignored)
await nextTick();
expect(ComponentD.prototype.someValue).toBeCalledTimes(1);
expect(fixture.innerHTML).toBe("
1c
");
defsD[1].resolve(); // completely resolve rendering initiated in A
await nextTick();
expect(fixture.innerHTML).toBe("
");
defB.resolve(); // resolve rendering initiated in A (still blocked in D)
await nextTick();
expect(fixture.innerHTML).toBe("
1c
");
defsD[1].resolve(); // completely resolve rendering initiated in A
await nextTick();
expect(fixture.innerHTML).toBe("
2d
");
expect(ComponentD.prototype.someValue).toBeCalledTimes(2);
defsD[0].resolve(); // resolve rendering initiated in C (should be ignored)
await nextTick();
expect(fixture.innerHTML).toBe("
2d
");
expect(ComponentD.prototype.someValue).toBeCalledTimes(2);
});
test("concurrent renderings scenario 5", async () => {
const defsB = [makeDeferred(), makeDeferred()];
let index = 0;
class ComponentB extends Component {
static template = xml`
");
});
test("concurrent renderings scenario 9", async () => {
// Here is the global idea of this scenario:
// A
// / \
// B C
// |
// D
// A state is updated, triggering a whole re-rendering
// B is async, and blocked
// C (and D) are rendered
// C state is updated, producing a re-rendering of C and D
// this last re-rendering of C should be correctly re-mapped to the whole
// re-rendering
const def = makeDeferred();
let stateC;
class ComponentD extends Component {
static template = xml``;
}
class ComponentC extends Component {
static template = xml`
");
});
test("concurrent renderings scenario 10", async () => {
// Here is the global idea of this scenario:
// A
// |
// B <- async willUpdateProps
// ----- <- conditional (initialy false)
// |
// C <- async willStart
// Render A and B normally
// Change the condition on B to trigger a re-rendering with C (async willStart)
// Change the state on A to trigger a global re-rendering, which is blocked
// in B (async willUpdateProps)
// Resolve the willStart of C: the first re-rendering has been cancelled by
// the global re-rendering, but handlers waiting for the rendering promise to
// resolve might execute and we don't want them to crash/do anything
const defB = makeDeferred();
const defC = makeDeferred();
let stateB;
class ComponentC extends Component {
static template = xml``;
willStart() {
return defC;
}
}
ComponentC.prototype.__render = jest.fn(ComponentC.prototype.__render);
class ComponentB extends Component {
static template = xml`
");
expect(ComponentC.prototype.__render).toHaveBeenCalledTimes(1);
});
test("concurrent renderings scenario 11", async () => {
// This scenario is the following: we have a component being updated (by props),
// and then rendered (render method), but before the willUpdateProps resolves.
// We check that in that case, the return value of the render method is a promise
// that is resolved when the component is completely rendered (so, properly
// remapped to the promise of the ambient rendering)
const def = makeDeferred();
let child;
class Child extends Component {
static template = xml`|`;
val = 3;
willUpdateProps() {
child = this;
return def;
}
}
class Parent extends Component {
static template = xml`
");
});
test("concurrent renderings scenario 12", async () => {
// In this scenario, we have a parent component that will be re-rendered
// several times simultaneously:
// - once in a tick: it will create a new fiber, render it, but will have
// to wait for its child (blocking) to be completed
// - twice in the next tick: it will twice reuse the same fiber (as it is
// rendered but not completed yet)
const def = makeDeferred();
class Child extends Component {
static template = xml``;
willUpdateProps() {
return def;
}
}
class Parent extends Component {
static template = xml`
");
await nextTick(); // wait for changes triggered in mounted to be applied
expect(fixture.innerHTML).toBe("
1
");
parent.state.bool = true;
await nextTick(); // wait for this change to be applied
await nextTick(); // wait for changes triggered in mounted to be applied
expect(fixture.innerHTML).toBe("
01
");
});
test("change state and call manually render: no unnecessary rendering", async () => {
class Widget extends Component {
static template = xml`
`;
state = useState({ val: 1 });
}
Widget.prototype.__render = jest.fn(Widget.prototype.__render);
const widget = new Widget();
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("
");
expect(Widget.prototype.__render).toHaveBeenCalledTimes(2);
});
});
describe("widget and observable state", () => {
test("widget is rerendered when its state is changed", async () => {
class TestWidget extends Widget {
static template = xml`
`;
state = useState({ drink: "water" });
}
const widget = new TestWidget();
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("
'
);
expect(QWeb.slots["1_footer"].toString()).toMatchSnapshot();
});
test("content is the default slot", async () => {
env.qweb.addTemplates(`
`);
class Dialog extends Widget {}
class Parent extends Widget {
static components = { Dialog };
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("
sts rocks
");
expect(QWeb.slots["1_default"].toString()).toMatchSnapshot();
});
test("default slot work with text nodes", async () => {
env.qweb.addTemplates(`
`);
class Dialog extends Widget {}
class Parent extends Widget {
static components = { Dialog };
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("
sts rocks
");
expect(QWeb.slots["1_default"].toString()).toMatchSnapshot();
});
test("multiple roots are allowed in a named slot", async () => {
env.qweb.addTemplates(`
`);
class Dialog extends Widget {}
class Parent extends Widget {
static components = { Dialog };
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("
stsrocks
");
expect(QWeb.slots["1_content"].toString()).toMatchSnapshot();
});
test("multiple roots are allowed in a default slot", async () => {
env.qweb.addTemplates(`
`);
class Dialog extends Widget {}
class Parent extends Widget {
static components = { Dialog };
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("
");
expect(env.qweb.templates[SomeComponent.template].fn.toString()).toMatchSnapshot();
});
test("basic use, on another key in component", async () => {
env.qweb.addTemplates(`
`);
class SomeComponent extends Widget {
some = useState({ text: "" });
}
const comp = new SomeComponent();
await comp.mount(fixture);
expect(fixture.innerHTML).toBe("
");
});
test("can define specific env for root components", async () => {
class App1 extends Component {
static template = xml``;
}
App1.env = { test: 1 };
class App2 extends Component {
static template = xml``;
}
App2.env = { test: 2 };
class App1B extends App1 {}
class App2B extends App2 {}
App2B.env = { test: 3 };
const app1 = new App1();
const app2 = new App2();
const app1B = new App1B();
const app2B = new App2B();
expect(app1.env.qweb).toBeDefined();
expect(app1.env.test).toBe(1);
expect(app2.env.test).toBe(2);
expect(app1B.env.test).toBe(1);
expect(app2B.env.test).toBe(3);
});
});
describe("component error handling (catchError)", () => {
/**
* This test suite requires often to wait for 3 ticks. Here is why:
* - First tick is to let the app render and crash.
* - When we crash, we call the catchError handler in a setTimeout (because we
* need to wait for the previous rendering to be completely stopped). So, we
* need to wait for the second tick.
* - Then, when the handler changes the state, we need to wait for the interface
* to be rerendered.
* */
test("can catch an error in a component render function", async () => {
const consoleError = console.error;
console.error = jest.fn();
const handler = jest.fn();
env.qweb.on("error", null, handler);
class ErrorComponent extends Widget {
static template = xml`
");
expect(console.error).toBeCalledTimes(0);
console.error = consoleError;
expect(handler).toBeCalledTimes(1);
});
test("can catch an error in the constructor call of a component render function", async () => {
const handler = jest.fn();
env.qweb.on("error", null, handler);
const consoleError = console.error;
console.error = jest.fn();
env.qweb.addTemplates(`
Error handled
Some text
`);
class ErrorComponent extends Widget {
constructor(parent) {
super(parent);
throw new Error("NOOOOO");
}
}
class ErrorBoundary extends Widget {
state = useState({ error: false });
catchError() {
this.state.error = true;
}
}
class App extends Widget {
static components = { ErrorBoundary, ErrorComponent };
}
const app = new App();
await app.mount(fixture);
expect(fixture.innerHTML).toBe("
Error handled
");
expect(console.error).toBeCalledTimes(0);
console.error = consoleError;
expect(handler).toBeCalledTimes(1);
});
test("can catch an error in the willStart call", async () => {
const consoleError = console.error;
console.error = jest.fn();
class ErrorComponent extends Widget {
static template = xml`
Some text
`;
async willStart() {
// we wait a little bit to be in a different stack frame
await nextTick();
throw new Error("NOOOOO");
}
}
class ErrorBoundary extends Widget {
static template = xml`
");
expect(console.error).toBeCalledTimes(0);
console.error = consoleError;
});
test.skip("can catch an error in the mounted call", async () => {
// we do not catch error in mounted anymore
console.error = jest.fn();
env.qweb.addTemplates(`
Error handled
Some text
`);
class ErrorComponent extends Widget {
mounted() {
throw new Error("NOOOOO");
}
}
class ErrorBoundary extends Widget {
state = useState({ error: false });
catchError() {
this.state.error = true;
}
}
class App extends Widget {
static components = { ErrorBoundary, ErrorComponent };
}
const app = new App();
await app.mount(fixture);
await nextTick();
await nextTick();
await nextTick();
expect(fixture.innerHTML).toBe("
Error handled
");
});
test.skip("can catch an error in the willPatch call", async () => {
// we do not catch error in willPatch anymore
const consoleError = console.error;
console.error = jest.fn();
class ErrorComponent extends Widget {
static template = xml`
`;
willPatch() {
throw new Error("NOOOOO");
}
}
class ErrorBoundary extends Widget {
static template = xml`
");
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 Widget {
static template = xml`
");
widget.unmount();
expect(fixture.innerHTML).toBe("");
await nextTick(); // wait for changes to be detected before remounting
await widget.mount(fixture);
expect(fixture.innerHTML).toBe("
3
");
// 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`
");
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("
3
");
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`
");
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("
3
");
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``;
state = useState({
val: "C1"
});
constructor(parent, props) {
super(parent, props);
child = this;
}
}
class Parent extends Component {
static components = { Child };
static template = xml`
`;
state = useState({ val: "P1" });
}
const parent = new Parent();
await parent.mount(fixture);
expect(fixture.innerHTML).toBe("