[IMP] web: add useEffect hook
This commit adds the useEffect hook, inspired by React. This hook replaces the onMounted, onWillPatch, onPatched and onWillUnmount hook. The idea behind useEffect is to unify the way we add side-effects to the components lifecycle, while also making it easier to clean up those side effects when needed. A lot of the time, when you want to do something once the component is mounted, there is also a good chance you would like to do it again when the component's state or props changes, in that case you may also want to clean up things that you did on the previous render. useEffect covers all these use cases and more. The API is like that of React, with an important difference: the dependencies of the effects is a function returning an array instead of a plain array. This is because in React, hooks will run on every render while in OWL, hooks are only run once during the component setup phase. This means that in React, dependencies are computed on every render, and doing that in owl requires having a callback to recompute them. Because computing your dependencies can be expensive, the dependencies computed by the callback will be passed to the effect function so that it doesn't have to recompute them. (In React, since you're writing in the render function, the dependencies are already in the effect's closure).
This commit is contained in:
committed by
Aaron Bohy
parent
79d0923df0
commit
328190f735
@@ -0,0 +1,47 @@
|
||||
/** @odoo-module */
|
||||
const { onMounted, onWillPatch, onPatched, onWillUnmount } = owl.hooks;
|
||||
|
||||
const NO_OP = () => {};
|
||||
/**
|
||||
* @callback Effect
|
||||
* @param {...any} dependencies the dependecies computed by computeDependencies
|
||||
* @returns {()=>void)?} a cleanup function that reverses the side
|
||||
* effects of the effect callback.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This hook will run a callback when a component is mounted and patched, and
|
||||
* will run a cleanup function before patching and before unmounting the
|
||||
* the component.
|
||||
*
|
||||
* @param {Effect} effect the effect to run on component mount and/or patch
|
||||
* @param {()=>any[]} [computeDependencies=()=>[NaN]] a callback to compute
|
||||
* dependencies that will decide if the effect needs to be cleaned up and
|
||||
* run again. If the dependencies did not change, the effect will not run
|
||||
* again. The default value returns an array containing only NaN because
|
||||
* NaN !== NaN, which will cause the effect to rerun on every patch.
|
||||
*/
|
||||
export function useEffect(effect, computeDependencies = () => [NaN]) {
|
||||
let cleanup, dependencies;
|
||||
onMounted(() => {
|
||||
dependencies = computeDependencies();
|
||||
cleanup = effect(...dependencies) || NO_OP;
|
||||
});
|
||||
|
||||
let shouldReapplyOnPatch = false;
|
||||
onWillPatch(() => {
|
||||
const newDeps = computeDependencies();
|
||||
shouldReapplyOnPatch = newDeps.some((val, i) => val !== dependencies[i]);
|
||||
if (shouldReapplyOnPatch) {
|
||||
cleanup();
|
||||
dependencies = newDeps;
|
||||
}
|
||||
});
|
||||
onPatched(() => {
|
||||
if (shouldReapplyOnPatch) {
|
||||
cleanup = effect(...dependencies) || NO_OP;
|
||||
}
|
||||
});
|
||||
|
||||
onWillUnmount(() => cleanup());
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/** @odoo-module **/
|
||||
import { useEffect } from "@web/core/effect_hook";
|
||||
import { makeTestEnv } from "../helpers/mock_env";
|
||||
import { getFixture } from "../helpers/utils";
|
||||
|
||||
const { Component, tags, hooks, mount } = owl;
|
||||
const { useState } = hooks;
|
||||
|
||||
QUnit.module("useEffect");
|
||||
|
||||
QUnit.test("useEffect: effect runs on mount, is reapplied on patch, and is cleaned up on unmount and before reapplying", async function (assert) {
|
||||
assert.expect(7);
|
||||
|
||||
let cleanupRun = 0;
|
||||
class MyComponent extends Component {
|
||||
setup() {
|
||||
this.state = useState({
|
||||
value: 0,
|
||||
});
|
||||
useEffect(
|
||||
() => {
|
||||
assert.step(`value is ${this.state.value}`);
|
||||
return () => assert.step(`cleaning up for value = ${this.state.value} (cleanup ${cleanupRun++})`);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
MyComponent.template = tags.xml`<div/>`;
|
||||
|
||||
const env = await makeTestEnv();
|
||||
const target = getFixture();
|
||||
const component = await mount(MyComponent, { env, target });
|
||||
|
||||
assert.step("before state mutation");
|
||||
component.state.value++;
|
||||
// Wait for an owl render
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
assert.step("after state mutation");
|
||||
await component.unmount();
|
||||
|
||||
assert.verifySteps([
|
||||
"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)",
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test("useEffect: dependencies prevent effects from rerunning when unchanged", async function (assert) {
|
||||
assert.expect(21);
|
||||
|
||||
class MyComponent extends Component {
|
||||
setup() {
|
||||
this.state = useState({
|
||||
a: 0,
|
||||
b: 0,
|
||||
});
|
||||
useEffect(
|
||||
(a) => {
|
||||
assert.step(`Effect a: ${a}`);
|
||||
return () => assert.step(`cleaning up for a: ${a}`);
|
||||
},
|
||||
() => [this.state.a]
|
||||
);
|
||||
useEffect(
|
||||
(b) => {
|
||||
assert.step(`Effect b: ${b}`);
|
||||
return () => assert.step(`cleaning up for b: ${b}`);
|
||||
},
|
||||
() => [this.state.b]
|
||||
);
|
||||
useEffect(
|
||||
(a, b) => {
|
||||
assert.step(`Effect ab: {a: ${a}, b: ${b}}`);
|
||||
return () => assert.step(`cleaning up for ab: {a: ${a}, b: ${b}}`);
|
||||
},
|
||||
() => [this.state.a, this.state.b]
|
||||
);
|
||||
}
|
||||
}
|
||||
MyComponent.template = tags.xml`<div/>`;
|
||||
|
||||
const env = await makeTestEnv();
|
||||
const target = getFixture();
|
||||
assert.step("before mount");
|
||||
const component = await mount(MyComponent, { env, target });
|
||||
assert.step("after mount");
|
||||
|
||||
assert.step("before state mutation: a");
|
||||
component.state.a++;
|
||||
// Wait for an owl render
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
assert.step("after state mutation: a");
|
||||
|
||||
assert.step("before state mutation: b");
|
||||
component.state.b++;
|
||||
// Wait for an owl render
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
assert.step("after state mutation: b");
|
||||
await component.unmount();
|
||||
|
||||
assert.verifySteps([
|
||||
// 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",
|
||||
]);
|
||||
});
|
||||
|
||||
QUnit.test("useEffect: effect with empty dependency list never reruns", async function (assert) {
|
||||
assert.expect(6);
|
||||
|
||||
class MyComponent extends Component {
|
||||
setup() {
|
||||
this.state = useState({
|
||||
value: 0,
|
||||
});
|
||||
useEffect(
|
||||
() => {
|
||||
assert.step(`value is ${this.state.value}`);
|
||||
return () => assert.step(`cleaning up for ${this.state.value}`);
|
||||
},
|
||||
() => []
|
||||
);
|
||||
}
|
||||
}
|
||||
MyComponent.template = tags.xml`<div t-esc="state.value"/>`;
|
||||
|
||||
const env = await makeTestEnv();
|
||||
const target = getFixture();
|
||||
const component = await mount(MyComponent, { env, target });
|
||||
|
||||
assert.step("before state mutation");
|
||||
component.state.value++;
|
||||
// Wait for an owl render
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
assert.equal(component.el.textContent, 1, "Value was correctly changed inside the component");
|
||||
assert.step("after state mutation");
|
||||
await component.unmount();
|
||||
|
||||
assert.verifySteps([
|
||||
"value is 0",
|
||||
"before state mutation",
|
||||
// no cleanup or effect caused by mutation
|
||||
"after state mutation",
|
||||
// Value being clean
|
||||
"cleaning up for 1",
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user