Files
owl/tests/reactivity.test.ts
T
Mathieu Duckerts-Antoine d60a5a414e [IMP] reactivity: Context replacement
Aim to replace the abstraction "Context" from OWL 1 with the new primitives
"atom" and "useState":

- notification is done only after a batch of modifications.
- observers are notified at most once for a batch.
- an observer of type component is notified (and rerendered)
  only if it does not have an ancestor that has to be notified for the
  same batch of operations (anywhere in the web of references!).
- notification of components is done on all levels "simultaneously".

Co-authored-by: Aaron Bohy <aab@odoo.com>
Co-authored-by: Géry Debongnie <ged@odoo.com>
Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
2021-11-10 15:08:08 +01:00

1401 lines
38 KiB
TypeScript

import { Component, mount, onRender, onWillStart, onWillUpdateProps, useState } from "../src";
import { atom, registerObserver } from "../src/reactivity";
import { xml } from "../src/tags";
import {
makeDeferred,
makeTestFixture,
nextMicroTick,
nextTick,
snapshotEverything,
} from "./helpers";
function createAtom(value: any, observer?: any) {
observer = observer || (() => {});
registerObserver(observer);
return atom(value, observer);
}
describe("Reactivity: atom", () => {
test("can read", async () => {
const atom1 = createAtom({ a: 1 });
expect(atom1.a).toBe(1);
});
test("can write", () => {
const atom1 = createAtom({});
atom1.a = 1;
expect(atom1.a).toBe(1);
});
test("can update", () => {
const atom1 = createAtom({ a: 1 });
atom1.a = 2;
expect(atom1.a).toBe(2);
});
test("can delete", () => {
const atom1 = createAtom({ a: 1 });
delete atom1.a;
expect(atom1.a).toBeUndefined();
});
test("act like an object", () => {
const atom1 = createAtom({ a: 1 });
expect(Object.keys(atom1)).toEqual(["a"]);
expect(Object.values(atom1)).toEqual([1]);
expect(typeof atom1).toBe("object");
});
test("act like an array", () => {
const atom1 = createAtom(["a", "b"]);
expect(atom1.length).toBe(2);
expect(atom1).toEqual(["a", "b"]);
expect(typeof atom1).toBe("object");
expect(Array.isArray(atom1)).toBe(true);
});
test("return value if not proxifiable", () => {
const atom1 = createAtom(1);
expect(atom1).toBe(1);
});
test("return value if observer is not registered", () => {
const obj = { a: 1 };
const atom1 = atom(obj, () => {});
expect(atom1).toBe(obj);
});
test("atom observer is called properly", async () => {
let n = 0;
const atom1 = createAtom({ a: 1 }, () => n++);
atom1.a = 2;
await nextMicroTick();
expect(n).toBe(0); // key has not be read yet
atom1.a = atom1.a + 5; // key is read and then modified
await nextMicroTick();
expect(n).toBe(1);
});
test("atom observer is called after batch of operation", async () => {
let n = 0;
const atom1 = createAtom({ a: 1, b: 2 }, () => n++);
atom1.a = 2;
expect(n).toBe(0);
await nextMicroTick();
expect(n).toBe(0); // key has not be read yet
atom1.a = atom1.a + 5; // key is read and then modified
expect(n).toBe(0);
atom1.b = atom1.b + 5; // key is read and then modified
expect(n).toBe(0);
await nextMicroTick();
expect(n).toBe(1); // two operations but only one notification
});
test("setting property to same value does not trigger callback", async () => {
let n = 0;
const atom1 = createAtom({ a: 1 }, () => n++);
atom1.a = atom1.a + 5; // read and modifies property a to have value 6
await nextMicroTick();
expect(n).toBe(1);
atom1.a = 6; // same value
await nextMicroTick();
expect(n).toBe(1);
});
test("observe cycles", async () => {
const a = { a: {} };
a.a = a;
let n = 0;
const atom1 = createAtom(a, () => n++);
atom1.k = 2;
await nextMicroTick();
expect(n).toBe(1);
delete atom1.l;
await nextMicroTick();
expect(n).toBe(1);
delete atom1.k;
await nextMicroTick();
expect(n).toBe(2);
atom1.a = 1;
await nextMicroTick();
expect(n).toBe(2);
atom1.a = atom1.a + 5;
await nextMicroTick();
expect(n).toBe(3);
});
test("two observers for same source", async () => {
let m = 0;
let n = 0;
const obj = { a: 1 } as any;
const atom1 = createAtom(obj, () => m++);
const atom2 = createAtom(obj, () => n++);
obj.new = 2;
await nextMicroTick();
expect(m).toBe(0);
expect(n).toBe(0);
atom1.new = 2; // already exists!
await nextMicroTick();
expect(m).toBe(0);
expect(n).toBe(0);
atom1.veryNew = 2;
await nextMicroTick();
expect(m).toBe(1);
expect(n).toBe(1);
atom1.a = atom1.a + 5;
await nextMicroTick();
expect(m).toBe(2);
expect(n).toBe(1);
atom2.a = atom2.a + 5;
await nextMicroTick();
expect(m).toBe(3);
expect(n).toBe(2);
delete atom2.veryNew;
await nextMicroTick();
expect(m).toBe(4);
expect(n).toBe(3);
});
test("create atom from another atom", async () => {
let n = 0;
const atom1 = createAtom({ a: 1 });
const atom2 = createAtom(atom1, () => n++);
atom2.a = atom2.a + 5;
await nextMicroTick();
expect(n).toBe(1);
atom1.a = 2;
await nextMicroTick();
expect(n).toBe(2);
});
test("create atom from another atom 2", async () => {
let n = 0;
const atom1 = createAtom({ a: 1 });
const atom2 = createAtom(atom1, () => n++);
atom1.a = atom2.a + 5;
await nextMicroTick();
expect(n).toBe(1);
atom2.a = atom2.a + 5;
await nextMicroTick();
expect(n).toBe(2);
});
test("create atom from another atom 3", async () => {
let n = 0;
const atom1 = createAtom({ a: 1 });
const atom2 = createAtom(atom1, () => n++);
atom1.a = atom1.a + 5;
await nextMicroTick();
expect(n).toBe(0); // atom2.a was not yet read
atom2.a = atom2.a + 5;
await nextMicroTick();
expect(n).toBe(1); // atom2.a has been read and is now observed
atom1.a = atom1.a + 5;
await nextMicroTick();
expect(n).toBe(2);
});
test("immediately returns primitive values", () => {
expect(createAtom(1)).toBe(1);
expect(createAtom("asf")).toBe("asf");
expect(createAtom(true)).toBe(true);
expect(createAtom(null)).toBe(null);
expect(createAtom(undefined)).toBe(undefined);
});
test("immediately returns dates", () => {
const date = new Date();
expect(createAtom(date)).toBe(date);
});
test("can observe object with some key set to null", async () => {
let n = 0;
const atom1 = createAtom({ a: { b: null } } as any, () => n++);
expect(n).toBe(0);
atom1.a.b = Boolean(atom1.a.b);
await nextMicroTick();
expect(n).toBe(1);
});
test("can reobserve object with some key set to null", async () => {
let n = 0;
const fn = () => n++;
const unregisterObserver = registerObserver(fn);
const atom1 = createAtom({ a: { b: null } } as any, fn);
const atom2 = createAtom(atom1, fn);
expect(atom2).toBe(atom1);
expect(atom2).toEqual(atom1);
await nextMicroTick();
expect(n).toBe(0);
atom1.a.b = Boolean(atom1.a.b);
await nextMicroTick();
expect(n).toBe(1);
unregisterObserver();
atom1.a.b = !atom1.a.b;
await nextMicroTick();
expect(n).toBe(1);
});
test("contains initial values", () => {
const atom1 = createAtom({ a: 1, b: 2 });
expect(atom1.a).toBe(1);
expect(atom1.b).toBe(2);
expect((atom1 as any).c).toBeUndefined();
});
test("detect object value changes", async () => {
let n = 0;
const atom1 = createAtom({ a: 1 }, () => n++) as any;
expect(n).toBe(0);
atom1.a = atom1.a + 5;
await nextMicroTick();
expect(n).toBe(1);
atom1.b = atom1.b + 5;
await nextMicroTick();
expect(n).toBe(2);
atom1.a = null;
atom1.b = undefined;
await nextMicroTick();
expect(n).toBe(3);
expect(atom1).toEqual({ a: null, b: undefined });
});
test("properly handle dates", async () => {
const date = new Date();
let n = 0;
const atom1 = createAtom({ date }, () => n++);
await nextMicroTick();
expect(typeof atom1.date.getFullYear()).toBe("number");
expect(atom1.date).toBe(date);
atom1.date = new Date();
await nextMicroTick();
expect(n).toBe(1);
expect(atom1.date).not.toBe(date);
});
test("properly handle promise", async () => {
let resolved = false;
const prom = new Promise((r) => r());
let n = 0;
const atom1 = createAtom({ prom }, () => n++);
expect(atom1.prom).toBeInstanceOf(Promise);
atom1.prom.then(() => (resolved = true));
expect(n).toBe(0);
expect(resolved).toBe(false);
await Promise.resolve();
expect(resolved).toBe(true);
expect(n).toBe(0);
});
test("can observe value change in array in an object", async () => {
let n = 0;
const atom1 = createAtom({ arr: [1, 2] }, () => n++) as any;
expect(Array.isArray(atom1.arr)).toBe(true);
expect(n).toBe(0);
atom1.arr[0] = atom1.arr[0] + "nope";
await nextMicroTick();
expect(n).toBe(1);
expect(atom1.arr[0]).toBe("1nope");
expect(atom1.arr).toEqual(["1nope", 2]);
});
test("can observe: changing array in object to another array", async () => {
let n = 0;
const atom1 = createAtom({ arr: [1, 2] }, () => n++) as any;
expect(Array.isArray(atom1.arr)).toBe(true);
expect(n).toBe(0);
atom1.arr = [2, 1];
await nextMicroTick();
expect(n).toBe(1);
expect(atom1.arr[0]).toBe(2);
expect(atom1.arr).toEqual([2, 1]);
});
test("getting twice an object properties return same object", () => {
const atom1 = createAtom({ a: { b: 1 } });
const a1 = atom1.a;
const a2 = atom1.a;
expect(a1).toBe(a2);
});
test("various object property changes", async () => {
let n = 0;
const atom1 = createAtom({ a: 1 }, () => n++) as any;
expect(n).toBe(0);
atom1.a = atom1.a + 2;
await nextMicroTick();
expect(n).toBe(1);
// same value again
atom1.a = 3;
await nextMicroTick();
expect(n).toBe(1);
atom1.a = 4;
await nextMicroTick();
expect(n).toBe(2);
});
test("properly observe arrays", async () => {
let n = 0;
const atom1 = createAtom([], () => n++) as any;
expect(Array.isArray(atom1)).toBe(true);
expect(atom1.length).toBe(0);
expect(n).toBe(0);
atom1.push(1);
await nextMicroTick();
expect(n).toBe(1);
expect(atom1.length).toBe(1);
expect(atom1).toEqual([1]);
atom1.splice(1, 0, "hey");
await nextMicroTick();
expect(n).toBe(2);
expect(atom1).toEqual([1, "hey"]);
expect(atom1.length).toBe(2);
atom1.unshift("lindemans");
await nextMicroTick();
//it generates 3 primitive operations
expect(n).toBe(3);
expect(atom1).toEqual(["lindemans", 1, "hey"]);
expect(atom1.length).toBe(3);
atom1.reverse();
await nextMicroTick();
//it generates 2 primitive operations
expect(n).toBe(4);
expect(atom1).toEqual(["hey", 1, "lindemans"]);
expect(atom1.length).toBe(3);
atom1.pop(); // one set, one delete
await nextMicroTick();
expect(n).toBe(5);
expect(atom1).toEqual(["hey", 1]);
expect(atom1.length).toBe(2);
atom1.shift(); // 2 sets, 1 delete
await nextMicroTick();
expect(n).toBe(6);
expect(atom1).toEqual([1]);
expect(atom1.length).toBe(1);
});
test("object pushed into arrays are observed", async () => {
let n = 0;
const arr: any = createAtom([], () => n++);
arr.push({ kriek: 5 });
await nextMicroTick();
expect(n).toBe(1);
arr[0].kriek = 6;
await nextMicroTick();
expect(n).toBe(1);
arr[0].kriek = arr[0].kriek + 6;
await nextMicroTick();
expect(n).toBe(2);
});
test("set new property on observed object", async () => {
let n = 0;
const atom1 = createAtom({}, () => n++) as any;
expect(n).toBe(0);
atom1.b = 8;
await nextMicroTick();
expect(n).toBe(1);
expect(atom1.b).toBe(8);
});
test("delete property from observed object", async () => {
let n = 0;
const atom1 = createAtom({ a: 1, b: 8 }, () => n++) as any;
expect(n).toBe(0);
delete atom1.b;
await nextMicroTick();
expect(n).toBe(1);
expect(atom1).toEqual({ a: 1 });
});
test("delete property from observed object 2", async () => {
let n = 0;
const observer = () => n++;
const obj = { a: { b: 1 } };
const atom1 = createAtom(obj.a, observer) as any;
const atom2 = createAtom(obj, observer) as any;
expect(n).toBe(0);
delete atom2.a;
await nextMicroTick();
// key "a" is no longer observed
expect(n).toBe(1);
atom1.new = 2; // but { b: 1 } is still observed!
await nextMicroTick();
expect(n).toBe(2);
});
test("set element in observed array", async () => {
let n = 0;
const arr = createAtom(["a"], () => n++);
arr[1] = "b";
await nextMicroTick();
expect(n).toBe(1);
expect(arr).toEqual(["a", "b"]);
});
test("properly observe arrays in object", async () => {
let n = 0;
const atom1 = createAtom({ arr: [] }, () => n++) as any;
expect(atom1.arr.length).toBe(0);
expect(n).toBe(0);
atom1.arr.push(1);
await nextMicroTick();
expect(n).toBe(1);
expect(atom1.arr.length).toBe(1);
});
test("properly observe objects in array", async () => {
let n = 0;
const atom1 = createAtom({ arr: [{ something: 1 }] }, () => n++) as any;
expect(n).toBe(0);
atom1.arr[0].something = atom1.arr[0].something + 1;
await nextMicroTick();
expect(n).toBe(1);
expect(atom1.arr[0].something).toBe(2);
});
test("properly observe objects in object", async () => {
let n = 0;
const atom1 = createAtom({ a: { b: 1 } }, () => n++) as any;
expect(n).toBe(0);
atom1.a.b = atom1.a.b + 2;
await nextMicroTick();
expect(n).toBe(1);
});
test("reobserve new object values", async () => {
let n = 0;
const atom1 = createAtom({ a: 1 }, () => n++) as any;
expect(n).toBe(0);
atom1.a++;
await nextMicroTick();
expect(n).toBe(1);
atom1.a = { b: 2 };
await nextMicroTick();
expect(n).toBe(2);
atom1.a.b = atom1.a.b + 3;
await nextMicroTick();
expect(n).toBe(3);
});
test("deep observe misc changes", async () => {
let n = 0;
const atom1 = createAtom({ o: { a: 1 }, arr: [1], n: 13 }, () => n++) as any;
expect(n).toBe(0);
atom1.o.a = atom1.o.a + 2;
await nextMicroTick();
expect(n).toBe(1);
atom1.arr.push(2);
await nextMicroTick();
expect(n).toBe(2);
atom1.n = 155;
await nextMicroTick();
expect(n).toBe(2);
atom1.n = atom1.n + 1;
await nextMicroTick();
expect(n).toBe(3);
});
test("properly handle already observed atom", async () => {
let n1 = 0;
let n2 = 0;
const obj1 = createAtom({ a: 1 }, () => n1++) as any;
const obj2 = createAtom({ b: 1 }, () => n2++) as any;
obj1.a = obj1.a + 2;
obj2.b = obj2.b + 3;
await nextMicroTick();
expect(n1).toBe(1);
expect(n2).toBe(1);
obj2.b = obj1;
await nextMicroTick();
expect(n1).toBe(1);
expect(n2).toBe(2);
obj1.a = 33;
await nextMicroTick();
expect(n1).toBe(2);
expect(n2).toBe(2);
obj2.b.a = obj2.b.a + 2;
await nextMicroTick();
expect(n1).toBe(3);
expect(n2).toBe(3);
});
test("properly handle already observed atom in observed atom", async () => {
let n1 = 0;
let n2 = 0;
const obj1 = createAtom({ a: { c: 2 } }, () => n1++) as any;
const obj2 = createAtom({ b: 1 }, () => n2++) as any;
obj2.c = obj1;
await nextMicroTick();
expect(n1).toBe(0);
expect(n2).toBe(1);
obj1.a.c = obj1.a.c + 33;
await nextMicroTick();
expect(n1).toBe(1);
expect(n2).toBe(1);
obj2.c.a.c = obj2.c.a.c + 3;
await nextMicroTick();
expect(n1).toBe(2);
expect(n2).toBe(2);
});
test("can reobserve object", async () => {
let n1 = 0;
let n2 = 0;
const atom1 = createAtom({ a: 0 }, () => n1++) as any;
atom1.a = atom1.a + 1;
await nextMicroTick();
expect(n1).toBe(1);
expect(n2).toBe(0);
const atom2 = createAtom(atom1, () => n2++) as any;
expect(atom1).toEqual(atom2);
atom2.a = 2;
await nextMicroTick();
expect(n1).toBe(2);
expect(n2).toBe(1);
});
test("can reobserve nested properties in object", async () => {
let n1 = 0;
let n2 = 0;
const atom1 = createAtom({ a: [{ b: 1 }] }, () => n1++) as any;
const atom2 = createAtom(atom1, () => n2++) as any;
atom1.a[0].b = atom1.a[0].b + 2;
await nextMicroTick();
expect(n1).toBe(1);
expect(n2).toBe(0);
atom2.c = 2;
await nextMicroTick();
expect(n1).toBe(2);
expect(n2).toBe(1);
});
test("rereading some property again give exactly same result", () => {
const atom1 = createAtom({ a: { b: 1 } });
const obj1 = atom1.a;
const obj2 = atom1.a;
expect(obj1).toBe(obj2);
});
test("can reobserve new properties in object", async () => {
let n1 = 0;
let n2 = 0;
const atom1 = createAtom({ a: [{ b: 1 }] }, () => n1++) as any;
createAtom(atom1, () => n2++) as any;
atom1.a[0].b = { c: 1 };
await nextMicroTick();
expect(n1).toBe(0);
expect(n2).toBe(0);
atom1.a[0].b.c = atom1.a[0].b.c + 2;
await nextMicroTick();
expect(n1).toBe(1);
expect(n2).toBe(0);
});
test("can observe sub property of observed object", async () => {
let n1 = 0;
let n2 = 0;
const atom1 = createAtom({ a: { b: 1 }, c: 1 }, () => n1++) as any;
const atom2 = createAtom(atom1.a, () => n2++) as any;
atom1.a.b = atom1.a.b + 2;
await nextMicroTick();
expect(n1).toBe(1);
expect(n2).toBe(0);
atom1.l = 2;
await nextMicroTick();
expect(n1).toBe(2);
expect(n2).toBe(0);
atom1.a.k = 3;
await nextMicroTick();
expect(n1).toBe(3);
expect(n2).toBe(1);
atom1.c = 14;
await nextMicroTick();
expect(n1).toBe(3);
expect(n2).toBe(1);
atom2.b = atom2.b + 3;
await nextMicroTick();
expect(n1).toBe(4);
expect(n2).toBe(2);
});
test("can set a property more than once", async () => {
let n = 0;
const atom1 = createAtom({}, () => n++) as any;
atom1.aky = atom1.aku;
expect(n).toBe(0);
atom1.aku = "always finds annoying problems";
expect(n).toBe(0);
await nextMicroTick();
expect(n).toBe(1);
atom1.aku = "always finds good problems";
await nextMicroTick();
expect(n).toBe(2);
});
test("properly handle swapping elements", async () => {
let n = 0;
const atom1 = createAtom({ a: { arr: [] }, b: 1 }, () => n++) as any;
// swap a and b
const b = atom1.b;
atom1.b = atom1.a;
atom1.a = b;
await nextMicroTick();
expect(n).toBe(1);
// push something into array to make sure it works
atom1.b.arr.push("blanche");
await nextMicroTick();
expect(n).toBe(2);
});
test("properly handle assigning observed atom containing array", async () => {
let n = 0;
const atom1 = createAtom({ a: { arr: [], val: "test" } }, () => n++) as any;
expect(n).toBe(0);
atom1.a = { ...atom1.a, val: "test2" };
await nextMicroTick();
expect(n).toBe(1);
// push something into array to make sure it works
atom1.a.arr.push("blanche");
await nextMicroTick();
expect(n).toBe(2);
});
test("accept cycles in observed atom", async () => {
let n = 0;
let obj1: any = {};
let obj2: any = { b: obj1, key: 1 };
obj1.a = obj2;
obj1 = createAtom(obj1, () => n++) as any;
obj2 = obj1.a;
await nextMicroTick();
expect(n).toBe(0);
obj1.key = 3;
await nextMicroTick();
expect(n).toBe(1);
});
test("call callback when atom is changed", async () => {
let n = 0;
const atom1: any = createAtom({ a: 1, b: { c: 2 }, d: [{ e: 3 }], f: 4 }, () => n++);
expect(n).toBe(0);
atom1.a = atom1.a + 2;
await nextMicroTick();
expect(n).toBe(1);
atom1.b.c = atom1.b.c + 3;
await nextMicroTick();
expect(n).toBe(2);
atom1.d[0].e = atom1.d[0].e + 5;
await nextMicroTick();
expect(n).toBe(3);
atom1.a = 111;
atom1.f = 222;
await nextMicroTick();
expect(n).toBe(4);
});
test("can unobserve a value", async () => {
let n = 0;
const cb = () => n++;
const unregisterObserver = registerObserver(cb);
const atom1 = createAtom({ a: 1 }, cb);
atom1.a = atom1.a + 3;
await nextMicroTick();
expect(n).toBe(1);
unregisterObserver();
atom1.a = 4;
await nextMicroTick();
expect(n).toBe(1);
});
test("observing some observed atom", async () => {
let n1 = 0;
let n2 = 0;
const inner = createAtom({ a: 1 }, () => n1++);
const outer = createAtom({ b: inner }, () => n2++);
expect(n1).toBe(0);
expect(n2).toBe(0);
outer.b.a = outer.b.a + 2;
await nextMicroTick();
expect(n1).toBe(0);
expect(n2).toBe(1);
});
test("observing some observed atom, variant", async () => {
let n1 = 0;
let n2 = 0;
const inner = createAtom({ a: 1 }, () => n1++);
const outer = createAtom({ b: inner, c: 0 }, () => n2++);
expect(n1).toBe(0);
expect(n2).toBe(0);
inner.a = inner.a + 2;
await nextMicroTick();
expect(n1).toBe(1);
expect(n2).toBe(0);
outer.c = outer.c + 3;
await nextMicroTick();
expect(n1).toBe(1);
expect(n2).toBe(1);
});
test("observing some observed atom, variant 2", async () => {
let n1 = 0;
let n2 = 0;
let n3 = 0;
const obj1 = createAtom({ a: 1 }, () => n1++);
const obj2 = createAtom({ b: {} }, () => n2++);
const obj3 = createAtom({ c: {} }, () => n3++);
obj2.b = obj2.b;
obj3.c = obj3.c;
await nextMicroTick();
expect(n1).toBe(0);
expect(n2).toBe(1);
expect(n3).toBe(1);
obj2.b = obj1;
obj3.c = obj1;
await nextMicroTick();
expect(n1).toBe(0);
expect(n2).toBe(2);
expect(n3).toBe(2);
obj1.a = obj1.a + 2;
await nextMicroTick();
expect(n1).toBe(1);
expect(n2).toBe(2);
expect(n3).toBe(2);
obj2.b.a = obj2.b.a + 1;
await nextMicroTick();
expect(n1).toBe(2);
expect(n2).toBe(3);
expect(n3).toBe(2);
});
test("notification is not done after unregistration", async () => {
let n = 0;
const observer = () => n++;
const unregisterObserver = registerObserver(observer);
const state = atom({ a: 1 }, observer);
state.a = state.a;
await nextMicroTick();
expect(n).toBe(0);
unregisterObserver();
state.a = { b: 2 };
await nextMicroTick();
expect(n).toBe(0);
state.a.b = state.a.b + 3;
await nextMicroTick();
expect(n).toBe(0);
});
});
describe("Reactivity: useState", () => {
let fixture: HTMLElement;
snapshotEverything();
beforeEach(() => {
fixture = makeTestFixture();
});
/**
* A context can be defined as an atom with a default observer.
* It can be exposed and share by multiple components or other objects
* (via useState for instance)
*/
test("very simple use, with initial value", async () => {
const testContext = createAtom({ value: 123 });
class Comp extends Component {
static template = xml`<div><t t-esc="contextObj.value"/></div>`;
contextObj = useState(testContext);
}
await mount(Comp, fixture);
expect(fixture.innerHTML).toBe("<div>123</div>");
});
test("useContext=useState hook is reactive, for one component", async () => {
const testContext = createAtom({ value: 123 });
class Comp extends Component {
static template = xml`<div><t t-esc="contextObj.value"/></div>`;
contextObj = useState(testContext);
}
const comp = await mount(Comp, fixture);
expect(fixture.innerHTML).toBe("<div>123</div>");
(comp as any).contextObj.value = 321;
await nextTick();
expect(fixture.innerHTML).toBe("<div>321</div>");
});
test("two components can subscribe to same context", async () => {
const testContext = createAtom({ value: 123 });
const steps: string[] = [];
class Child extends Component {
static template = xml`<span><t t-esc="contextObj.value"/></span>`;
contextObj = useState(testContext);
setup() {
onRender(() => {
steps.push("child");
});
}
}
class Parent extends Component {
static template = xml`<div><Child /><Child /></div>`;
static components = { Child };
setup() {
onRender(() => {
steps.push("parent");
});
}
}
await mount(Parent, fixture);
expect(steps).toEqual(["parent", "child", "child"]);
expect(fixture.innerHTML).toBe("<div><span>123</span><span>123</span></div>");
testContext.value = 321;
await nextTick();
expect(steps).toEqual(["parent", "child", "child", "child", "child"]);
expect(fixture.innerHTML).toBe("<div><span>321</span><span>321</span></div>");
});
test("two components are updated in parallel", async () => {
const testContext = createAtom({ value: 123 });
const steps: string[] = [];
class Child extends Component {
static template = xml`<span><t t-esc="contextObj.value"/></span>`;
contextObj = useState(testContext);
setup() {
onRender(async () => {
steps.push("render");
});
}
}
class Parent extends Component {
static template = xml`<div><Child /><Child /></div>`;
static components = { Child };
}
await mount(Parent, fixture);
expect(steps).toEqual(["render", "render"]);
expect(fixture.innerHTML).toBe("<div><span>123</span><span>123</span></div>");
testContext.value = 321;
await nextMicroTick();
await nextMicroTick();
expect(steps).toEqual(["render", "render", "render", "render"]);
expect(fixture.innerHTML).toBe("<div><span>123</span><span>123</span></div>");
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>321</span><span>321</span></div>");
});
test("two independent components on different levels are updated in parallel", async () => {
const testContext = createAtom({ value: 123 });
const steps: string[] = [];
class Child extends Component {
static template = xml`<span><t t-esc="contextObj.value"/></span>`;
static components = {};
contextObj = useState(testContext);
setup() {
onRender(() => {
steps.push("render");
});
}
}
class Parent extends Component {
static template = xml`<div><Child /></div>`;
static components = { Child };
}
class GrandFather extends Component {
static template = xml`<div><Child /><Parent /></div>`;
static components = { Child, Parent };
}
await mount(GrandFather, fixture);
expect(fixture.innerHTML).toBe("<div><span>123</span><div><span>123</span></div></div>");
expect(steps).toEqual(["render", "render"]);
testContext.value = 321;
await nextMicroTick();
await nextMicroTick();
expect(steps).toEqual(["render", "render", "render", "render"]);
expect(fixture.innerHTML).toBe("<div><span>123</span><div><span>123</span></div></div>");
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>321</span><div><span>321</span></div></div>");
});
test("one components can subscribe twice to same context", async () => {
const testContext = createAtom({ a: 1, b: 2 });
const steps: string[] = [];
class Comp extends Component {
static template = xml`<div><t t-esc="contextObj1.a"/><t t-esc="contextObj2.b"/></div>`;
contextObj1 = useState(testContext);
contextObj2 = useState(testContext);
setup() {
onRender(() => {
steps.push("comp");
});
}
}
await mount(Comp, fixture);
expect(fixture.innerHTML).toBe("<div>12</div>");
expect(steps).toEqual(["comp"]);
testContext.a = 3;
await nextTick();
expect(fixture.innerHTML).toBe("<div>32</div>");
expect(steps).toEqual(["comp", "comp"]);
});
test("parent and children subscribed to same context", async () => {
const testContext = createAtom({ a: 123, b: 321 });
const steps: string[] = [];
class Child extends Component {
static template = xml`<span><t t-esc="contextObj.a"/></span>`;
contextObj = useState(testContext);
setup() {
onRender(() => {
steps.push("child");
});
}
}
class Parent extends Component {
static template = xml`<div><Child /><t t-esc="contextObj.b"/></div>`;
static components = { Child };
contextObj = useState(testContext);
setup() {
onRender(() => {
steps.push("parent");
});
}
}
const parent = await mount(Parent, fixture);
expect(fixture.innerHTML).toBe("<div><span>123</span>321</div>");
expect(steps).toEqual(["parent", "child"]);
(parent as any).contextObj.a = 124;
await nextTick();
expect(fixture.innerHTML).toBe("<div><span>124</span>321</div>");
expect(steps).toEqual(["parent", "child", "child"]);
});
test("several nodes on different level use same context", async () => {
const testContext = createAtom({ a: 123, b: 456 });
const steps: Set<string> = new Set();
/**
* Scheme:
* L1A
* / \
* L2A L2B
* |
* L3A
*/
class L3A extends Component {
static template = xml`<div><t t-esc="contextObj.a"/> <t t-esc="contextObj.b"/></div>`;
contextObj = useState(testContext);
setup() {
onRender(() => {
steps.add("L3A");
});
}
}
class L2B extends Component {
static template = xml`<div><t t-esc="contextObj.b"/></div>`;
contextObj = useState(testContext);
setup() {
onRender(() => {
steps.add("L2B");
});
}
}
class L2A extends Component {
static template = xml`<div><t t-esc="contextObj.a"/><L3A /></div>`;
static components = { L3A };
contextObj = useState(testContext);
setup() {
onRender(() => {
steps.add("L2A");
});
}
}
class L1A extends Component {
static template = xml`<div><L2A /><L2B /></div>`;
static components = { L2A, L2B };
contextObj = useState(testContext);
setup() {
onRender(() => {
steps.add("L1A");
});
}
}
await mount(L1A, fixture);
expect(fixture.innerHTML).toBe("<div><div>123<div>123 456</div></div><div>456</div></div>");
expect([...steps]).toEqual(["L1A", "L2A", "L2B", "L3A"]);
steps.clear();
testContext.a = 321;
await nextMicroTick();
await nextMicroTick();
expect([...steps]).toEqual(["L2A"]);
await nextTick();
expect([...steps]).toEqual(["L2A", "L3A"]);
expect(fixture.innerHTML).toBe("<div><div>321<div>321 456</div></div><div>456</div></div>");
steps.clear();
testContext.b = 654;
await nextMicroTick();
await nextMicroTick();
expect([...steps]).toEqual(["L2B", "L3A"]);
await nextTick();
expect([...steps]).toEqual(["L2B", "L3A"]);
expect(fixture.innerHTML).toBe("<div><div>321<div>321 654</div></div><div>654</div></div>");
steps.clear();
testContext.a = 777;
testContext.b = 777;
await nextMicroTick();
await nextMicroTick();
expect([...steps]).toEqual(["L2A", "L2B"]);
await nextTick();
expect([...steps]).toEqual(["L2A", "L2B", "L3A"]);
expect(fixture.innerHTML).toBe("<div><div>777<div>777 777</div></div><div>777</div></div>");
steps.clear();
testContext.c = 444;
await nextMicroTick();
await nextMicroTick();
expect([...steps]).toEqual(["L1A"]);
await nextTick();
expect([...steps]).toEqual(["L1A", "L2A", "L2B", "L3A"]);
});
test("destroyed component is inactive", async () => {
const testContext = createAtom({ a: 123 });
const steps: string[] = [];
class Child extends Component {
static template = xml`<span><t t-esc="contextObj.a"/></span>`;
contextObj = useState(testContext);
setup() {
onRender(() => {
steps.push("child");
});
}
}
class Parent extends Component {
static template = xml`<div><Child t-if="state.flag"/></div>`;
static components = { Child };
state = useState({ flag: true });
setup() {
onRender(() => {
steps.push("parent");
});
}
}
const parent = await mount(Parent, fixture);
expect(fixture.innerHTML).toBe("<div><span>123</span></div>");
expect(steps).toEqual(["parent", "child"]);
testContext.a = 321;
await nextTick();
expect(steps).toEqual(["parent", "child", "child"]);
parent.state.flag = false;
await nextTick();
expect(fixture.innerHTML).toBe("<div></div>");
expect(steps).toEqual(["parent", "child", "child", "parent"]);
testContext.a = 456;
await nextTick();
expect(steps).toEqual(["parent", "child", "child", "parent"]);
});
test("destroyed component before being mounted is inactive", async () => {
const testContext = createAtom({ a: 123 });
const steps: string[] = [];
class Child extends Component {
static template = xml`<span><t t-esc="contextObj.a"/></span>`;
contextObj = useState(testContext);
setup() {
onWillStart(() => {
return makeDeferred();
});
onRender(() => {
steps.push("child");
});
}
}
let parent: any;
class Parent extends Component {
static template = xml`<div><Child t-if="state.flag"/></div>`;
static components = { Child };
state = useState({ flag: true });
setup() {
parent = this;
}
}
const prom = mount(Parent, fixture); // cannot work
await nextTick(); // wait for Child to be instantiated
testContext.a = 321;
await nextMicroTick();
parent.state.flag = false;
await prom;
expect(fixture.innerHTML).toBe("<div></div>");
testContext.a = 456;
await nextMicroTick();
expect(steps).toEqual([]);
});
test("useless atoms should be deleted", async () => {
const testContext = createAtom({
1: { id: 1, quantity: 3, description: "First quantity" },
2: { id: 2, quantity: 5, description: "Second quantity" },
});
const secondQuantity = testContext[2];
const steps: Set<string> = new Set();
class Quantity extends Component {
static template = xml`<div><t t-esc="state.quantity"/></div>`;
state = useState(testContext[this.props.id]);
setup() {
onRender(() => {
steps.add(`quantity${this.props.id}`);
});
}
}
class ListOfQuantities extends Component {
static template = xml`
<div>
<t t-foreach="Object.keys(state)" t-as="id" t-key="id">
<Quantity id="id"/>
</t>
Total: <t t-esc="total"/>
Count: <t t-esc="Object.keys(state).length"/>
</div>`;
static components = { Quantity };
state = useState(testContext);
setup() {
onRender(() => {
steps.add("list");
});
}
get total() {
let total = 0;
for (const { quantity } of Object.values(this.state) as any) {
total += quantity;
}
return total;
}
}
await mount(ListOfQuantities, fixture);
expect(fixture.innerHTML).toBe("<div><div>3</div><div>5</div> Total: 8 Count: 2</div>");
expect([...steps]).toEqual(["list", "quantity1", "quantity2"]);
steps.clear();
delete testContext[2];
await nextMicroTick();
await nextMicroTick();
expect([...steps]).toEqual(["list"]);
await nextTick();
expect(fixture.innerHTML).toBe("<div><div>3</div> Total: 3 Count: 1</div>");
expect([...steps]).toEqual(["list", "quantity1"]);
steps.clear();
// secondQuantity is no longer accessible by List! But list react to changes
// --> this prooff that a useless atom continues to exist
secondQuantity.quantity = 2;
await nextMicroTick();
await nextMicroTick();
expect(fixture.innerHTML).toBe("<div><div>3</div> Total: 3 Count: 1</div>");
expect([...steps]).toEqual(["list"]); // should be avoided!!!
steps.clear();
});
test.skip("concurrent renderings", async () => {
/**
* Note: this test is interesting, but sadly just an incomplete attempt at
* protecting users against themselves. With the context API, it is not
* possible for the framework to protect completely against crashes. Maybe
* like in this case, when a component is in a simple hierarchy where all
* renderings come from the context changes, but in a real case, where some
* code can trigger a rendering independently, it is insufficient.
*
* The main problem is that the sub component depends on some external state,
* which may be modified, and then incompatible with the component actual
* state (for example, if the sub component has an id key related to some
* object that has been removed from the context).
*
* For now, sadly, the only solution is that components that depends on external
* state should guarantee their own integrity themselves. Then maybe this
* could be solved at the level of a state management solution that has a
* more advanced API, to let components determine if they should be updated
* or not (so, something slightly more advanced that the useStore hook).
*/
const testContext = createAtom({ x: { n: 1 }, key: "x" });
const def = makeDeferred();
let stateC: any;
class ComponentC extends Component {
static template = xml`<span><t t-esc="context[props.key].n"/><t t-esc="state.x"/></span>`;
context = useState(testContext);
state = useState({ x: "a" });
setup() {
stateC = this.state;
}
}
class ComponentB extends Component {
static components = { ComponentC };
static template = xml`<p><ComponentC key="props.key"/></p>`;
setup() {
onWillUpdateProps(() => def);
}
}
class ComponentA extends Component {
static components = { ComponentB };
static template = xml`<div><ComponentB key="context.key"/></div>`;
context = useState(testContext);
}
await mount(ComponentA, fixture);
expect(fixture.innerHTML).toBe("<div><p><span>1a</span></p></div>");
testContext.key = "y";
testContext.y = { n: 2 };
delete testContext.x;
await nextTick();
expect(fixture.innerHTML).toBe("<div><p><span>1a</span></p></div>");
stateC.x = "b";
await nextTick();
expect(fixture.innerHTML).toBe("<div><p><span>1a</span></p></div>");
def.resolve();
await nextTick();
expect(fixture.innerHTML).toBe("<div><p><span>2b</span></p></div>");
});
});