import { App, Component, mount, useRef, useState, useComponent, useEnv, useSubEnv, useEffect, onMounted, onPatched, onWillStart, onWillUpdateProps, onWillPatch, xml, onWillUnmount, } from "../../src/index"; import { makeTestFixture, nextTick, snapshotEverything } from "../helpers"; let fixture: HTMLElement; snapshotEverything(); beforeEach(() => { fixture = makeTestFixture(); }); describe("hooks", () => { test("useRef hook: basic use", async () => { let counter: Counter; class Counter extends Component { static template = xml`
`; button = useRef("button"); value = 0; setup() { counter = this; } increment() { this.value++; this.button.el!.innerHTML = String(this.value); } } const mounted = mount(Counter, fixture); expect(counter!.button.el).toBe(null); await mounted; expect(fixture.innerHTML).toBe("
"); expect(counter!.button.el).not.toBe(null); expect(counter!.button.el).toBe(fixture.querySelector("button")); counter!.increment(); expect(fixture.innerHTML).toBe("
"); }); // TODO: rename like next test (ensures willPatch/patched calls are symmetrical) test("two different call to willPatch/patched should work", async () => { const steps: string[] = []; function useMyHook(i: number) { onPatched(() => { steps.push("hook:patched" + i); }); onWillPatch(() => { steps.push("hook:willPatch" + i); }); } class Test extends Component { static template = xml`
hey
`; state = useState({ value: 1 }); setup() { useMyHook(1); useMyHook(2); } } const component = await mount(Test, fixture); expect(fixture.innerHTML).toBe("
hey1
"); component.state.value++; await nextTick(); expect(fixture.innerHTML).toBe("
hey2
"); expect(steps).toEqual(["hook:willPatch2", "hook:willPatch1", "hook:patched1", "hook:patched2"]); }); test("mounted callbacks should be called in reverse order from willUnmount callbacks", async () => { const steps: string[] = []; function useMyHook(i: number) { onMounted(() => { steps.push("hook:mounted" + i); }); onWillUnmount(() => { steps.push("hook:willUnmount" + i); }); } class Test extends Component { static template = xml`
hey
`; state = useState({ value: 1 }); setup() { useMyHook(1); useMyHook(2); } } const app = new App(Test); await app.mount(fixture); app.destroy(); expect(steps).toEqual([ "hook:mounted1", "hook:mounted2", "hook:willUnmount2", "hook:willUnmount1", ]); }); describe("autofocus hook", () => { function useAutofocus(name: string) { let ref = useRef(name); let isInDom = false; function updateFocus() { if (!isInDom && ref.el) { isInDom = true; ref.el.focus(); } else if (isInDom && !ref.el) { isInDom = false; } } onPatched(updateFocus); onMounted(updateFocus); } test("simple input", async () => { class Test extends Component { static template = xml`
`; setup() { useAutofocus("input2"); } } await mount(Test, fixture); expect(fixture.innerHTML).toBe("
"); const input2 = fixture.querySelectorAll("input")[1]; expect(input2).toBe(document.activeElement); }); test("input in a t-if", async () => { class Test extends Component { static template = xml`
`; state = useState({ flag: false }); setup() { useAutofocus("input2"); } } const component = await mount(Test, fixture); expect(fixture.innerHTML).toBe("
"); expect(document.activeElement).toBe(document.body); component.state.flag = true; await nextTick(); const input2 = fixture.querySelectorAll("input")[1]; expect(input2).toBe(document.activeElement); }); }); test("can use useEnv", async () => { expect.assertions(3); class Test extends Component { static template = xml`
`; setup() { expect(useEnv()).toBe(this.env); } } const env = { val: 1 }; await new App(Test).configure({ env }).mount(fixture); expect(fixture.innerHTML).toBe("
1
"); }); test("use sub env does not pollute user env", async () => { class Test extends Component { static template = xml`
`; setup() { useSubEnv({ val2: 1 }); } } const env = { val: 3 }; const component = await new App(Test).configure({ env }).mount(fixture); expect(fixture.innerHTML).toBe("
3
"); expect(component.env).not.toHaveProperty("val2"); expect(component.env).toHaveProperty("val"); }); test("can use useComponent", async () => { expect.assertions(2); class Test extends Component { static template = xml`
`; setup() { expect(useComponent()).toBe(this); } } await mount(Test, fixture); }); test("parent and child env", async () => { class Child extends Component { static template = xml`
`; } class Parent extends Component { static template = xml``; static components = { Child }; setup() { useSubEnv({ val: 5 }); } } const env = { val: 3 }; await new App(Parent).configure({ env }).mount(fixture); expect(fixture.innerHTML).toBe("3
5
"); }); test("can use onWillStart, onWillUpdateProps", async () => { const steps: string[] = []; async function slow(): Promise { return new Promise((resolve) => { setTimeout(() => { resolve("slow"); }, 0); }); } function useMyHook() { onWillStart(async () => { steps.push(await slow()); steps.push("onWillStart"); }); onWillUpdateProps(async (nextProps) => { expect(nextProps).toEqual({ value: 2 }); steps.push(await slow()); steps.push("onWillUpdateProps"); }); } function use2ndHook() { onWillStart(() => { steps.push("on2ndStart"); }); onWillUpdateProps((nextProps) => { expect(nextProps).toEqual({ value: 2 }); steps.push("on2ndUpdate"); }); } class MyComponent extends Component { static template = xml``; setup() { useMyHook(); use2ndHook(); } } class App extends Component { static template = xml``; static components = { MyComponent }; state = useState({ value: 1 }); } const app = await mount(App, fixture); expect(fixture.innerHTML).toBe("1"); // NOTE: 'on2ndStart' appears first in the list even though // the 'use2ndHook' is declared after 'useMyHook'. This is // because Promise.all is used to call the callbacks specified // in the hooks, which runs them simultaneously. // Additionally, 'slow' should be listed before 'onWillStart' // because call to `slow` is awaited. expect(steps).toEqual(["on2ndStart", "slow", "onWillStart"]); app.state.value = 2; await nextTick(); expect(fixture.innerHTML).toBe("2"); expect(steps).toEqual([ "on2ndStart", "slow", "onWillStart", "on2ndUpdate", "slow", "onWillUpdateProps", ]); }); test.skip("useExternalListener", async () => { let n = 0; class MyComponent extends Component { static template = xml``; setup() { //useExternalListener(window as any, "click", this.increment); } increment() { n++; } } class App extends Component { static template = xml``; static components = { MyComponent }; state = useState({ flag: false }); } const app = await mount(App, fixture); expect(n).toBe(0); window.dispatchEvent(new Event("click")); expect(n).toBe(0); app.state.flag = true; await nextTick(); window.dispatchEvent(new Event("click")); expect(n).toBe(1); app.state.flag = false; await nextTick(); window.dispatchEvent(new Event("click")); expect(n).toBe(1); }); describe("useEffect hook", () => { test("effect runs on mount, is reapplied on patch, and is cleaned up on unmount and before reapplying", async () => { let cleanupRun = 0; let steps = []; class MyComponent extends Component { state = useState({ value: 0, }); setup() { useEffect(() => { steps.push(`value is ${this.state.value}`); return () => steps.push(`cleaning up for value = ${this.state.value} (cleanup ${cleanupRun++})`); }); } } MyComponent.template = xml`
`; const component = await mount(MyComponent, fixture); steps.push("before state mutation"); component.state.value++; // Wait for an owl render await new Promise((resolve) => requestAnimationFrame(resolve)); steps.push("after state mutation"); await component.__owl__.destroy(); expect(steps).toEqual([ "value is 0", "before state mutation", // While one might expect value to be 0 at cleanup, because the value is // read during cleanup from the state rather than captured by a dependency // it already has the new value. Having this in business code is a symptom // of a missing dependency and can lead to bugs. "cleaning up for value = 1 (cleanup 0)", "value is 1", "after state mutation", "cleaning up for value = 1 (cleanup 1)", ]); }); test("dependencies prevent effects from rerunning when unchanged", async () => { let steps = []; class MyComponent extends Component { state = useState({ a: 0, b: 0, }); setup() { useEffect( (a) => { steps.push(`Effect a: ${a}`); return () => steps.push(`cleaning up for a: ${a}`); }, () => [this.state.a] ); useEffect( (b) => { steps.push(`Effect b: ${b}`); return () => steps.push(`cleaning up for b: ${b}`); }, () => [this.state.b] ); useEffect( (a, b) => { steps.push(`Effect ab: {a: ${a}, b: ${b}}`); return () => steps.push(`cleaning up for ab: {a: ${a}, b: ${b}}`); }, () => [this.state.a, this.state.b] ); } } MyComponent.template = xml`
`; steps.push("before mount"); const component = await mount(MyComponent, fixture); steps.push("after mount"); steps.push("before state mutation: a"); component.state.a++; // Wait for an owl render await new Promise((resolve) => requestAnimationFrame(resolve)); steps.push("after state mutation: a"); steps.push("before state mutation: b"); component.state.b++; // Wait for an owl render await new Promise((resolve) => requestAnimationFrame(resolve)); steps.push("after state mutation: b"); await component.__owl__.destroy(); expect(steps).toEqual([ // All effects run on mount "before mount", "Effect a: 0", "Effect b: 0", "Effect ab: {a: 0, b: 0}", "after mount", "before state mutation: a", // Cleanups run in reverse order "cleaning up for ab: {a: 0, b: 0}", // Cleanup for b is not run "cleaning up for a: 0", "Effect a: 1", // Effect b is not run "Effect ab: {a: 1, b: 0}", "after state mutation: a", "before state mutation: b", "cleaning up for ab: {a: 1, b: 0}", "cleaning up for b: 0", // Cleanup for a is not run // Effect a is not run "Effect b: 1", "Effect ab: {a: 1, b: 1}", "after state mutation: b", // All cleanups run on unmount "cleaning up for ab: {a: 1, b: 1}", "cleaning up for b: 1", "cleaning up for a: 1", ]); }); test("effect with empty dependency list never reruns", async () => { let steps = []; class MyComponent extends Component { state = useState({ value: 0, }); setup() { useEffect( () => { steps.push(`value is ${this.state.value}`); return () => steps.push(`cleaning up for ${this.state.value}`); }, () => [] ); } } MyComponent.template = xml`
`; const component = await mount(MyComponent, fixture); steps.push("before state mutation"); component.state.value++; // Wait for an owl render await new Promise((resolve) => requestAnimationFrame(resolve)); // Value was correctly changed inside the component expect(component.el!.textContent).toBe("1"); steps.push("after state mutation"); await component.__owl__.destroy(); expect(steps).toEqual([ "value is 0", "before state mutation", // no cleanup or effect caused by mutation "after state mutation", // Value being clean "cleaning up for 1", ]); }); }); });