import { App, Component, mount, onMounted, onWillStart, useState, xml } from "../../src"; import { onWillPatch, onWillUnmount, onPatched, onWillUpdateProps, onWillRender, onWillDestroy, onRendered, } from "../../src/component/lifecycle_hooks"; import { status } from "../../src/component/status"; import { elem, logStep, makeDeferred, makeTestFixture, nextTick, snapshotEverything, useLogLifecycle, } from "../helpers"; let fixture: HTMLElement; snapshotEverything(); beforeEach(() => { fixture = makeTestFixture(); }); describe("lifecycle hooks", () => { test("basic checks for a component", async () => { expect.assertions(6); // 1 for snapshots class Test extends Component { static template = xml`test`; setup() { expect(status(this)).toBe("new"); } } const app = new App(Test); const component = await app.mount(fixture); expect(fixture.innerHTML).toBe("test"); expect(status(component)).toBe("mounted"); app.destroy(); expect(fixture.innerHTML).toBe(""); expect(status(component)).toBe("destroyed"); }); test("willStart is called", async () => { let willstart = false; class Test extends Component { static template = xml`simple vnode`; setup() { onWillStart(() => { willstart = true; }); } } await mount(Test, fixture); expect(willstart).toBe(true); }); test("willStart hook is called on sub component", async () => { let ok = false; class Child extends Component { static template = xml`
`; setup() { onWillStart(() => { ok = true; }); } } class Parent extends Component { static template = xml``; static components = { Child }; } await mount(Parent, fixture); expect(ok).toBe(true); }); test("willStart is called with component as this", async () => { expect.assertions(3); let comp: any; class Test extends Component { static template = xml`simple vnode`; setup() { comp = this; onWillStart(this.willStart); } willStart() { expect(this).toBeInstanceOf(Test); expect(this).toBe(comp); } } await mount(Test, fixture); }); test("mounted hook is called if mounted in DOM", async () => { let mounted = false; class Test extends Component { static template = xml`
`; setup() { onMounted(() => { mounted = true; }); } } await mount(Test, fixture); expect(mounted).toBe(true); }); test("mounted hook is called on subcomponents, in proper order", async () => { const steps: any[] = []; class Child extends Component { static template = xml`
`; setup() { onMounted(() => { expect(document.body.contains(elem(this))).toBe(true); steps.push("child:mounted"); }); } } class Parent extends Component { static template = xml`
`; static components = { Child }; setup() { onMounted(() => { steps.push("parent:mounted"); }); } } await mount(Parent, fixture); expect(steps).toEqual(["child:mounted", "parent:mounted"]); Object.freeze(steps); }); test("mounted hook is called on subsubcomponents, in proper order", async () => { const steps: any[] = []; class ChildChild extends Component { static template = xml`
`; setup() { onMounted(() => { steps.push("childchild:mounted"); }); onWillUnmount(() => { steps.push("childchild:willUnmount"); }); } } class Child extends Component { static template = xml`
`; static components = { ChildChild }; setup() { onMounted(() => { steps.push("child:mounted"); }); onWillUnmount(() => { steps.push("child:willUnmount"); }); } } class Parent extends Component { static template = xml`
`; static components = { Child }; state = useState({ flag: false }); setup() { onMounted(() => { steps.push("parent:mounted"); }); onWillUnmount(() => { steps.push("parent:willUnmount"); }); } } const app = new App(Parent); const widget = await app.mount(fixture); expect(steps).toEqual(["parent:mounted"]); widget.state.flag = true; await nextTick(); app.destroy(); expect(steps).toEqual([ "parent:mounted", "childchild:mounted", "child:mounted", "parent:willUnmount", "child:willUnmount", "childchild:willUnmount", ]); Object.freeze(steps); }); test("willPatch, patched hook are called on subsubcomponents, in proper order", async () => { const steps: any[] = []; class ChildChild extends Component { static template = xml`
`; setup() { onWillPatch(() => { steps.push("childchild:willPatch"); }); onPatched(() => { steps.push("childchild:patched"); }); } } class Child extends Component { static template = xml`
`; static components = { ChildChild }; setup() { onWillPatch(() => { steps.push("child:willPatch"); }); onPatched(() => { steps.push("child:patched"); }); } } class Parent extends Component { static template = xml`
`; static components = { Child }; state = useState({ n: 1 }); setup() { onWillPatch(() => { steps.push("parent:willPatch"); }); onPatched(() => { steps.push("parent:patched"); }); } } const app = new App(Parent); const parent = await app.mount(fixture); expect(steps).toEqual([]); parent.state.n = 2; await nextTick(); app.destroy(); expect(steps).toEqual([ "parent:willPatch", "child:willPatch", "childchild:willPatch", "childchild:patched", "child:patched", "parent:patched", ]); Object.freeze(steps); }); test("willStart, mounted on subwidget rendered after main is mounted in some other position", async () => { const steps: string[] = []; class Child extends Component { static template = xml`
`; setup() { onWillStart(() => { steps.push("child:willStart"); }); onMounted(() => { steps.push("child:mounted"); }); } } class Parent extends Component { // the t-else part in the template is important. This is // necessary to have a situation that could confuse the vdom // patching algorithm static template = xml`
`; static components = { Child }; state = useState({ ok: false }); } const parent = await mount(Parent, fixture); expect(steps).toEqual([]); parent.state.ok = true; await nextTick(); expect(steps).toEqual(["child:willStart", "child:mounted"]); Object.freeze(steps); Object.freeze(steps); }); test("components are unmounted destroyed if no longer in DOM", async () => { let steps: string[] = []; class Child extends Component { static template = xml`
`; setup() { steps.push("setup"); onWillStart(() => { steps.push("willstart"); }); onMounted(() => { steps.push("mounted"); }); onWillUnmount(() => { steps.push("willunmount"); }); } } class Parent extends Component { static template = xml``; static components = { Child }; state = useState({ ok: true }); } const parent = await mount(Parent, fixture); expect(steps).toEqual(["setup", "willstart", "mounted"]); parent.state.ok = false; await nextTick(); expect(steps).toEqual(["setup", "willstart", "mounted", "willunmount"]); Object.freeze(steps); }); test("components are unmounted and destroyed if no longer in DOM, even after updateprops", async () => { class Child extends Component { static template = xml``; setup() { useLogLifecycle(); } } class Parent extends Component { static template = xml`
`; static components = { Child }; setup() { useLogLifecycle(); } state = useState({ n: 0, flag: true }); increment() { this.state.n += 1; } toggleSubWidget() { this.state.flag = !this.state.flag; } } const parent = await mount(Parent, fixture); expect(fixture.innerHTML).toBe("
0
"); expect([ "Parent:setup", "Parent:willStart", "Parent:willRender", "Child:setup", "Child:willStart", "Parent:rendered", "Child:willRender", "Child:rendered", "Child:mounted", "Parent:mounted", ]).toBeLogged(); parent.increment(); await nextTick(); expect(fixture.innerHTML).toBe("
1
"); expect([ "Parent:willRender", "Child:willUpdateProps", "Parent:rendered", "Child:willRender", "Child:rendered", "Parent:willPatch", "Child:willPatch", "Child:patched", "Parent:patched", ]).toBeLogged(); parent.toggleSubWidget(); await nextTick(); expect(fixture.innerHTML).toBe(""); expect([ "Parent:willRender", "Parent:rendered", "Parent:willPatch", "Child:willUnmount", "Child:willDestroy", "Parent:patched", ]).toBeLogged(); }); test("hooks are called in proper order in widget creation/destruction", async () => { class Child extends Component { static template = xml`
`; setup() { useLogLifecycle(); } } class Parent extends Component { static template = xml`
`; static components = { Child }; setup() { useLogLifecycle(); } } const app = new App(Parent); await app.mount(fixture); expect([ "Parent:setup", "Parent:willStart", "Parent:willRender", "Child:setup", "Child:willStart", "Parent:rendered", "Child:willRender", "Child:rendered", "Child:mounted", "Parent:mounted", ]).toBeLogged(); app.destroy(); expect([ "Parent:willUnmount", "Child:willUnmount", "Child:willDestroy", "Parent:willDestroy", ]).toBeLogged(); }); test("willUpdateProps hook is called", async () => { let def = makeDeferred(); class Child extends Component { static template = xml``; setup() { onWillUpdateProps((nextProps) => { expect(nextProps.n).toBe(2); return def; }); } } class Parent extends Component { static template = xml``; static components = { Child }; state = useState({ n: 1 }); } const parent = await mount(Parent, fixture); expect(fixture.innerHTML).toBe("1"); parent.state.n = 2; await nextTick(); expect(fixture.innerHTML).toBe("1"); def.resolve(); await nextTick(); expect(fixture.innerHTML).toBe("2"); }); test("patched hook is called after updating State", async () => { let n = 0; class Test extends Component { static template = xml`
`; state = useState({ a: 1 }); setup() { onPatched(() => n++); } } const widget = await mount(Test, fixture); expect(n).toBe(0); widget.state.a = 1; // empty update, should do nothing await nextTick(); expect(n).toBe(0); widget.state.a = 3; await nextTick(); expect(n).toBe(1); }); test("patched hook is called after updateProps", async () => { let n = 0; class Child extends Component { static template = xml`
`; setup() { onWillUpdateProps(() => { n++; }); } } class Parent extends Component { static template = xml`
`; state = useState({ a: 1 }); static components = { Child }; } const widget = await mount(Parent, fixture); expect(n).toBe(0); widget.state.a = 2; await nextTick(); expect(n).toBe(1); }); test("lifecycle semantics", async () => { class Child extends Component { static template = xml`
`; setup() { useLogLifecycle(); } } class Parent extends Component { static template = xml`
`; static components = { Child }; state = useState({ a: 1 }); setup() { useLogLifecycle(); } } const app = new App(Parent); await app.mount(fixture); expect([ "Parent:setup", "Parent:willStart", "Parent:willRender", "Child:setup", "Child:willStart", "Parent:rendered", "Child:willRender", "Child:rendered", "Child:mounted", "Parent:mounted", ]).toBeLogged(); app.destroy(); expect([ "Parent:willUnmount", "Child:willUnmount", "Child:willDestroy", "Parent:willDestroy", ]).toBeLogged(); }); test("lifecycle semantics, part 2", async () => { class GrandChild extends Component { static template = xml`
`; setup() { useLogLifecycle(); } } class Child extends Component { static template = xml``; static components = { GrandChild }; setup() { useLogLifecycle(); } } class Parent extends Component { static template = xml``; static components = { Child }; state = useState({ hasChild: false }); setup() { useLogLifecycle(); } } const app = new App(Parent); const parent = await app.mount(fixture); expect([ "Parent:setup", "Parent:willStart", "Parent:willRender", "Parent:rendered", "Parent:mounted", ]).toBeLogged(); parent.state.hasChild = true; await nextTick(); expect([ "Parent:willRender", "Child:setup", "Child:willStart", "Parent:rendered", "Child:willRender", "GrandChild:setup", "GrandChild:willStart", "Child:rendered", "GrandChild:willRender", "GrandChild:rendered", "Parent:willPatch", "GrandChild:mounted", "Child:mounted", "Parent:patched", ]).toBeLogged(); app.destroy(); expect([ "Parent:willUnmount", "Child:willUnmount", "GrandChild:willUnmount", "GrandChild:willDestroy", "Child:willDestroy", "Parent:willDestroy", ]).toBeLogged(); }); test("lifecycle semantics, part 3", async () => { class GrandChild extends Component { static template = xml`
`; setup() { useLogLifecycle(); } } class Child extends Component { static template = xml``; static components = { GrandChild }; setup() { useLogLifecycle(); } } class Parent extends Component { static template = xml``; static components = { Child }; state = useState({ hasChild: false }); setup() { useLogLifecycle(); } } const app = new App(Parent); const parent = await app.mount(fixture); expect([ "Parent:setup", "Parent:willStart", "Parent:willRender", "Parent:rendered", "Parent:mounted", ]).toBeLogged(); parent.state.hasChild = true; // immediately destroy everything app.destroy(); await nextTick(); expect(["Parent:willUnmount", "Parent:willDestroy"]).toBeLogged(); }); test("lifecycle semantics, part 4", async () => { let def = makeDeferred(); class GrandChild extends Component { static template = xml`
`; setup() { useLogLifecycle(); onWillStart(() => def); } } class Child extends Component { static template = xml``; static components = { GrandChild }; setup() { useLogLifecycle(); } } class Parent extends Component { static template = xml``; static components = { Child }; state = useState({ hasChild: false }); setup() { useLogLifecycle(); } } const app = new App(Parent); const parent = await app.mount(fixture); expect([ "Parent:setup", "Parent:willStart", "Parent:willRender", "Parent:rendered", "Parent:mounted", ]).toBeLogged(); parent.state.hasChild = true; await nextTick(); expect([ "Parent:willRender", "Child:setup", "Child:willStart", "Parent:rendered", "Child:willRender", "GrandChild:setup", "GrandChild:willStart", "Child:rendered", ]).toBeLogged(); app.destroy(); expect([ "Parent:willUnmount", "GrandChild:willDestroy", "Child:willDestroy", "Parent:willDestroy", ]).toBeLogged(); }); test("lifecycle semantics, part 5", async () => { class Child extends Component { static template = xml`
`; setup() { useLogLifecycle(); } } class Parent extends Component { static template = xml``; static components = { Child }; state = useState({ hasChild: true }); setup() { useLogLifecycle(); } } const parent = await mount(Parent, fixture); expect([ "Parent:setup", "Parent:willStart", "Parent:willRender", "Child:setup", "Child:willStart", "Parent:rendered", "Child:willRender", "Child:rendered", "Child:mounted", "Parent:mounted", ]).toBeLogged(); parent.state.hasChild = false; await nextTick(); expect([ "Parent:willRender", "Parent:rendered", "Parent:willPatch", "Child:willUnmount", "Child:willDestroy", "Parent:patched", ]).toBeLogged(); }); test("lifecycle semantics, part 6", async () => { class Child extends Component { static template = xml`
`; setup() { useLogLifecycle(); } } class Parent extends Component { static template = xml``; static components = { Child }; state = useState({ value: 1 }); setup() { useLogLifecycle(); } } const parent = await mount(Parent, fixture); expect([ "Parent:setup", "Parent:willStart", "Parent:willRender", "Child:setup", "Child:willStart", "Parent:rendered", "Child:willRender", "Child:rendered", "Child:mounted", "Parent:mounted", ]).toBeLogged(); parent.state.value = 2; await nextTick(); expect([ "Parent:willRender", "Child:willUpdateProps", "Parent:rendered", "Child:willRender", "Child:rendered", "Parent:willPatch", "Child:willPatch", "Child:patched", "Parent:patched", ]).toBeLogged(); }); test("onWillRender", async () => { const def = makeDeferred(); class Child extends Component { static template = xml``; state = useState({ value: 1 }); visibleState = this.state.value; setup() { useLogLifecycle(); onWillUpdateProps(() => def); onWillRender(() => (this.visibleState = this.state.value)); } increment() { this.state.value++; } } class Parent extends Component { static template = xml` `; static components = { Child }; state = useState({ value: 1 }); setup() { useLogLifecycle(); } } const parent = await mount(Parent, fixture); expect(fixture.innerHTML).toBe(""); expect([ "Parent:setup", "Parent:willStart", "Parent:willRender", "Child:setup", "Child:willStart", "Parent:rendered", "Child:willRender", "Child:rendered", "Child:mounted", "Parent:mounted", ]).toBeLogged(); parent.state.value++; // to block child render await nextTick(); expect(["Parent:willRender", "Child:willUpdateProps", "Parent:rendered"]).toBeLogged(); fixture.querySelector("button")!.click(); await nextTick(); expect([]).toBeLogged(); fixture.querySelector("button")!.click(); await nextTick(); expect([]).toBeLogged(); expect(fixture.innerHTML).toBe(""); def.resolve(); await nextTick(); expect(fixture.innerHTML).toBe(""); expect([ "Child:willRender", "Child:rendered", "Parent:willPatch", "Child:willPatch", "Child:patched", "Parent:patched", ]).toBeLogged(); }); // TODO: rename (remove? seems covered by lifecycle semantics) test("sub widget (inside sub node): hooks are correctly called", async () => { let created = false; let mounted = false; class Child extends Component { static template = xml`
`; setup() { created = true; onMounted(() => { mounted = true; }); } } class Parent extends Component { static template = xml``; static components = { Child }; state = useState({ flag: false }); } const parent = await mount(Parent, fixture); expect(created).toBe(false); expect(mounted).toBe(false); parent.state.flag = true; await nextTick(); expect(mounted).toBe(true); expect(created).toBe(true); }); // TODO: rename (corresponds to https://github.com/odoo/owl/blob/master/doc/reference/concurrency_model.md#semantics) test("component semantics", async () => { class TestWidget extends Component { name: string = "test"; setup() { useLogLifecycle(); } } class B extends TestWidget { static template = xml`
B
`; name = "B"; } class D extends TestWidget { static template = xml`
D
`; name = "D"; } class E extends TestWidget { static template = xml`
E
`; name = "E"; } class F extends TestWidget { static template = xml`
F
`; name = "F"; } let c: C; class C extends TestWidget { static template = xml`
C
`; static components = { D, E, F }; name = "C"; state = useState({ flag: true }); setup() { c = this; super.setup(); } } class A extends TestWidget { static template = xml`
A
`; static components = { B, C }; name = "A"; } await mount(A, fixture); expect(fixture.innerHTML).toBe(`
A
B
C
D
E
`); expect([ "A:setup", "A:willStart", "A:willRender", "B:setup", "B:willStart", "C:setup", "C:willStart", "A:rendered", "B:willRender", "B:rendered", "C:willRender", "D:setup", "D:willStart", "E:setup", "E:willStart", "C:rendered", "D:willRender", "D:rendered", "E:willRender", "E:rendered", "E:mounted", "D:mounted", "C:mounted", "B:mounted", "A:mounted", ]).toBeLogged(); // update c!.state.flag = false; await nextTick(); expect([ "C:willRender", "F:setup", "F:willStart", "C:rendered", "F:willRender", "F:rendered", "C:willPatch", "E:willUnmount", "E:willDestroy", "F:mounted", "C:patched", ]).toBeLogged(); }); test("mounted hook is called on every mount, not just the first one", async () => { class Child extends Component { static template = xml`
child
`; setup() { useLogLifecycle(); } } class Parent extends Component { static template = xml``; static components = { Child }; state = useState({ hasChild: true }); setup() { useLogLifecycle(); } } const parent = await mount(Parent, fixture); expect([ "Parent:setup", "Parent:willStart", "Parent:willRender", "Child:setup", "Child:willStart", "Parent:rendered", "Child:willRender", "Child:rendered", "Child:mounted", "Parent:mounted", ]).toBeLogged(); parent.state.hasChild = false; await nextTick(); expect([ "Parent:willRender", "Parent:rendered", "Parent:willPatch", "Child:willUnmount", "Child:willDestroy", "Parent:patched", ]).toBeLogged(); parent.state.hasChild = true; await nextTick(); expect([ "Parent:willRender", "Child:setup", "Child:willStart", "Parent:rendered", "Child:willRender", "Child:rendered", "Parent:willPatch", "Child:mounted", "Parent:patched", ]).toBeLogged(); }); test("render in mounted", async () => { class Parent extends Component { static template = xml``; patched: any; setup() { useLogLifecycle(); onMounted(() => { this.patched = "Patched"; this.render(); }); } } await mount(Parent, fixture); expect(fixture.innerHTML).toBe(""); expect([ "Parent:setup", "Parent:willStart", "Parent:willRender", "Parent:rendered", "Parent:mounted", "Parent:willRender", "Parent:rendered", ]).toBeLogged(); await nextTick(); expect(fixture.innerHTML).toBe("Patched"); expect(["Parent:willPatch", "Parent:patched"]).toBeLogged(); }); test("render in patched", async () => { class Parent extends Component { static template = xml``; patched: any; setup() { useLogLifecycle(); onPatched(() => { if (this.patched === "Patched") { return; } this.patched = "Patched"; this.render(); }); } } const parent = await mount(Parent, fixture); expect(fixture.innerHTML).toBe(""); expect([ "Parent:setup", "Parent:willStart", "Parent:willRender", "Parent:rendered", "Parent:mounted", ]).toBeLogged(); parent.render(); await nextTick(); expect(fixture.innerHTML).toBe(""); expect([ "Parent:willRender", "Parent:rendered", "Parent:willPatch", "Parent:patched", "Parent:willRender", "Parent:rendered", ]).toBeLogged(); await nextTick(); expect(fixture.innerHTML).toBe("Patched"); expect(["Parent:willPatch", "Parent:patched"]).toBeLogged(); }); test("render in willPatch", async () => { class Parent extends Component { static template = xml``; patched: any; setup() { useLogLifecycle(); onWillPatch(() => { if (this.patched === "Patched") { return; } this.patched = "Patched"; this.render(); }); } } const parent = await mount(Parent, fixture); expect(fixture.innerHTML).toBe(""); expect([ "Parent:setup", "Parent:willStart", "Parent:willRender", "Parent:rendered", "Parent:mounted", ]).toBeLogged(); parent.render(); await nextTick(); expect(fixture.innerHTML).toBe(""); expect([ "Parent:willRender", "Parent:rendered", "Parent:willPatch", "Parent:patched", "Parent:willRender", "Parent:rendered", ]).toBeLogged(); await nextTick(); expect(["Parent:willPatch", "Parent:patched"]).toBeLogged(); expect(fixture.innerHTML).toBe("Patched"); }); test("lifecycle callbacks are bound to component", async () => { expect.assertions(14); let instance: any; class Test extends Component { static template = xml``; setup() { instance = this; onWillStart(this.logger("onWillStart")); onMounted(this.logger("onMounted")); onWillUpdateProps(this.logger("onWillUpdateProps")); onWillPatch(this.logger("onWillPatch")); onPatched(this.logger("onPatched")); onWillUnmount(this.logger("onWillUnmount")); onWillDestroy(this.logger("onWillDestroy")); onWillRender(this.logger("onWillRender")); onRendered(this.logger("onRendered")); } logger(hookName: string) { return function (this: Test) { logStep(hookName); expect(this === instance).toBe(true); }; } } class Parent extends Component { static template = xml``; static components = { Test }; rev = 0; } const app = new App(Parent); const comp = await app.mount(fixture); comp.rev++; comp.render(); await nextTick(); app.destroy(); expect([ "onWillStart", "onWillRender", "onRendered", "onMounted", "onWillUpdateProps", "onWillRender", "onRendered", "onWillPatch", "onPatched", "onWillUnmount", "onWillDestroy", ]).toBeLogged(); }); test("destroy new children before being mountged", async () => { class Child extends Component { static template = xml`child`; setup() { useLogLifecycle(); } } class Parent extends Component { static template = xml`beforeafter`; static components = { Child }; state = useState({ flag: false }); setup() { useLogLifecycle(); onRendered(async () => { // we destroy here the app after the new child component has been // created, but before this rendering has been patched to the DOM if (this.state.flag) { await Promise.resolve(); app.destroy(); } }); } } const app = new App(Parent); const parent = await app.mount(fixture); expect(fixture.innerHTML).toBe("beforeafter"); expect([ "Parent:setup", "Parent:willStart", "Parent:willRender", "Parent:rendered", "Parent:mounted", ]).toBeLogged(); parent.state.flag = true; await nextTick(); expect(fixture.innerHTML).toBe(""); expect([ "Parent:willRender", "Child:setup", "Child:willStart", "Parent:rendered", "Parent:willUnmount", "Child:willDestroy", "Parent:willDestroy", ]).toBeLogged(); }); });