[IMP] reactivity: new primitives for reactivity

fine grained reactivity:

existing key in source changes --> only observer having read the key are notified

add/delete key in source changes --> all source observers are notified

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>
This commit is contained in:
Mathieu Duckerts-Antoine
2021-10-26 14:51:57 +02:00
committed by Géry Debongnie
parent 6479631983
commit 4e4b85e2a9
6 changed files with 604 additions and 372 deletions
+133 -127
View File
@@ -1,144 +1,150 @@
import { getCurrent } from "./component/component_node"; import { getCurrent } from "./component/component_node";
import { onWillUnmount } from "./component/lifecycle_hooks";
export function useState<T>(state: T): T { type Atom = any; // proxy linked to a unique observer and source
const node = getCurrent()!; type Source = any; // trackable that is not an atom
return observe(state, () => node.render()); type Observer = () => void;
type Keys = Set<any>;
const sourceAtoms: WeakMap<Source, Set<Atom>> = new WeakMap();
const observerAtoms: WeakMap<Observer, Set<Atom>> = new WeakMap();
const SOURCE = Symbol("source");
const OBSERVER = Symbol("observer");
const KEYS = Symbol("keys");
export function atom(source: any, observer: Observer) {
if (isTrackable(source) && observerAtoms.has(observer)) {
source = source[SOURCE] || source;
const oldAtom = getObserverSourceAtom(observer, source);
if (oldAtom) {
return oldAtom;
}
registerSource(source);
return createAtom(source, observer);
}
return source;
} }
type CB = () => void; function createAtom(source: Source, observer: Observer): Atom {
const observers: WeakMap<any, PSet<CB>> = new WeakMap(); const keys: Keys = new Set();
const newAtom: Atom = new Proxy(source as any, {
/** set(target: any, key: string, value: any): boolean {
* PSet (for Prototypal Set) are sets that can lookup in their "parent sets", if if (!(key in target)) {
* any. target[key] = value;
*/ notifySourceObservers(source);
return true;
class PSet<T> extends Set<T> {
parent?: PSet<T>;
static createChild<T>(parent: PSet<T>): PSet<T> {
const pset: PSet<T> = new PSet();
pset.parent = parent;
return pset;
}
has(key: T): boolean {
if (super.has(key)) {
return true;
}
return this.parent ? this.parent.has(key) : false;
}
*[Symbol.iterator](): Generator<T> {
let iterator = super[Symbol.iterator]();
for (let elem of iterator) {
yield elem;
}
if (this.parent) {
for (let elem of this.parent) {
yield elem;
} }
const current = target[key];
if (current !== value) {
target[key] = value;
notifySourceKeyOBservers(source, key);
}
return true;
},
deleteProperty(target: any, key: string): boolean {
if (key in target) {
delete target[key];
notifySourceObservers(source);
deleteKeyFromKeys(source, key);
}
return true;
},
get(target: any, key: any): any {
switch (key) {
case OBSERVER:
return observer;
case SOURCE:
return source;
case KEYS:
return keys;
default:
const value = target[key];
keys.add(key);
return atom(value, observer);
}
},
});
getObserverAtoms(observer).add(newAtom);
getSourceAtoms(source).add(newAtom);
return newAtom;
}
function deleteKeyFromKeys(source: Source, key: any) {
for (const atom of getSourceAtoms(source)) {
atom[KEYS].delete(key);
}
}
function getObserverAtoms(observer: Observer): Set<Atom> {
return observerAtoms.get(observer)!;
}
function getObserverSourceAtom(observer: Observer, source: Source): Atom | null {
for (const atom of getObserverAtoms(observer)) {
if (atom[SOURCE] === source) {
return atom;
} }
} }
return null;
} }
// ----------------------------------------------------------------------------- function getSourceAtoms(source: Source): Set<Atom> {
return sourceAtoms.get(source)!;
export function observe<T>(value: T, cb: CB): T {
if (isNotObservable(value)) {
return value;
}
if (observers.has(value)) {
const callbacks = observers.get(value)!;
callbacks.add(cb);
return value;
}
const callbacks: PSet<CB> = new PSet();
callbacks.add(cb);
return observeValue(value, callbacks);
} }
export function unobserve<T>(value: T, cb: () => void) { function isTrackable(value: any): boolean {
if (isNotObservable(value)) {
return;
}
if (observers.has(value)) {
const callbacks = observers.get(value)!;
callbacks.delete(cb);
}
}
function isNotObservable(value: any): boolean {
return ( return (
value === null || typeof value !== "object" || value instanceof Date || value instanceof Promise value !== null &&
typeof value === "object" &&
!(value instanceof Date) &&
!(value instanceof Promise)
); );
} }
/** function notifySourceKeyOBservers(source: Source, key: any) {
* value should for (const atom of getSourceAtoms(source)) {
* 1. be observable if (atom[KEYS].has(key)) {
* 2. not yet be observed atom[OBSERVER]();
*/ }
function observeValue(value: any, callbacks: PSet<CB>): any {
const proxy = new Proxy(value as any, {
get(target: any, key: any): any {
const current = target[key];
if (isNotObservable(current)) {
return current;
}
if (observers.has(current)) {
// this is wrong ?
observers.get(current)!.parent = callbacks;
return current;
}
const subCallbacks = PSet.createChild(callbacks);
const subValue = observeValue(current, subCallbacks);
target[key] = subValue;
return subValue;
},
set(target: any, key: any, value: any): boolean {
// TODO: check if current !== target or proxy ??
const current = target[key];
if (current !== value) {
if (isNotObservable(value)) {
target[key] = value;
} else {
// TODO: test following scenario:
// 1. obj1 = observer({a:1}, somecb);
// 2. unobserve(obj1, somecb)
// 3. obj1.a = {b: 2};
// check that somecb was not called
// obj1.a.b = 3;
// check again that somecb was not called
if (observers.has(value)) {
const pset = observers.get(value)!;
pset.parent = callbacks;
target[key] = value;
} else {
const subCallbacks = PSet.createChild(callbacks);
target[key] = observeValue(value, subCallbacks);
}
}
notify(target);
}
return true;
},
deleteProperty(target: any, key: any) {
if (key in target) {
delete target[key];
notify(target);
}
return true;
},
});
observers.set(value, callbacks);
observers.set(proxy, callbacks);
return proxy;
}
function notify(value: any) {
const cbs = observers.get(value)!;
for (let cb of cbs) {
cb();
} }
} }
function notifySourceObservers(source: Source) {
for (const atom of getSourceAtoms(source)) {
atom[OBSERVER]();
}
}
export function registerObserver(observer: Observer) {
if (!observerAtoms.get(observer)) {
observerAtoms.set(observer, new Set());
}
return unregisterObserver.bind(null, observer);
}
function registerSource(source: Source) {
if (!sourceAtoms.get(source)) {
sourceAtoms.set(source, new Set());
}
}
function unregisterObserver(observer: Observer) {
for (const atom of getObserverAtoms(observer)) {
const source = atom[SOURCE];
const sourceAtoms = getSourceAtoms(source);
sourceAtoms.delete(atom);
}
observerAtoms.delete(observer);
}
export function useState(state: any): Atom {
if (!isTrackable(state)) {
throw new Error("Argument is not trackable");
}
const node = getCurrent()!;
const observer = () => node.render();
const unregisterObserver = registerObserver(observer);
onWillUnmount(() => unregisterObserver());
return atom(state, observer);
}
@@ -594,10 +594,11 @@ exports[`lifecycle hooks patched hook is called after updating State 1`] = `
let { text, createBlock, list, multi, html, toggler, component } = bdom; let { text, createBlock, list, multi, html, toggler, component } = bdom;
let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue } = helpers; let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue } = helpers;
let block1 = createBlock(\`<div/>\`); let block1 = createBlock(\`<div><block-text-0/></div>\`);
return function template(ctx, node, key = \\"\\") { return function template(ctx, node, key = \\"\\") {
return block1(); let d1 = ctx['state'].a;
return block1([d1]);
} }
}" }"
`; `;
@@ -15,6 +15,21 @@ exports[`reactivity in lifecycle can use a state hook 1`] = `
}" }"
`; `;
exports[`reactivity in lifecycle can use a state hook 2 1`] = `
"function anonymous(bdom, helpers
) {
let { text, createBlock, list, multi, html, toggler, component } = bdom;
let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue } = helpers;
let block1 = createBlock(\`<div><block-text-0/></div>\`);
return function template(ctx, node, key = \\"\\") {
let d1 = ctx['state'].a;
return block1([d1]);
}
}"
`;
exports[`reactivity in lifecycle change state while mounting component 1`] = ` exports[`reactivity in lifecycle change state while mounting component 1`] = `
"function anonymous(bdom, helpers "function anonymous(bdom, helpers
) { ) {
+1 -1
View File
@@ -493,7 +493,7 @@ describe("lifecycle hooks", () => {
let n = 0; let n = 0;
class Test extends Component { class Test extends Component {
static template = xml`<div/>`; static template = xml`<div><t t-esc="state.a"/></div>`;
state = useState({ a: 1 }); state = useState({ a: 1 });
setup() { setup() {
+22
View File
@@ -31,6 +31,28 @@ describe("reactivity in lifecycle", () => {
expect(fixture.innerHTML).toBe("<div>3</div>"); expect(fixture.innerHTML).toBe("<div>3</div>");
}); });
test("can use a state hook 2", async () => {
let n = 0;
class Comp extends Component {
static template = xml`<div><t t-esc="state.a"/></div>`;
state = useState({ a: 5, b: 7 });
setup() {
onRender(() => n++);
}
}
const comp = await mount(Comp, fixture);
expect(fixture.innerHTML).toBe("<div>5</div>");
expect(n).toBe(1);
comp.state.a = 11;
await nextTick();
expect(fixture.innerHTML).toBe("<div>11</div>");
expect(n).toBe(2);
comp.state.b = 13;
await nextTick();
expect(fixture.innerHTML).toBe("<div>11</div>");
expect(n).toBe(2); // no new rendering occured: b was never read via state!
});
test("state changes in willUnmount do not trigger rerender", async () => { test("state changes in willUnmount do not trigger rerender", async () => {
const steps: string[] = []; const steps: string[] = [];
class Child extends Component { class Child extends Component {
+430 -242
View File
@@ -1,107 +1,248 @@
import { observe, unobserve } from "../src/reactivity"; import { atom, registerObserver } from "../src/reactivity";
describe("observe", () => { function createAtom(atom1: any, observer?: any) {
test("basic properties", () => { observer = observer || (() => {});
registerObserver(observer);
return atom(atom1, observer);
}
describe("getAtom", () => {
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", () => {
let n = 0; let n = 0;
const obj = observe({} as any, () => n++); const atom1 = createAtom({ a: 1 }, () => n++);
expect(typeof obj).toBe("object"); atom1.a = 2;
expect(n).toBe(0); expect(n).toBe(0); // key has not be read yet.
atom1.a = atom1.a + 5; // key is read and then modified
obj.a = 3;
expect(n).toBe(1);
expect(obj.a).toBe(3);
expect(n).toBe(1); expect(n).toBe(1);
}); });
test("setting property to same value does not trigger callback", () => { test("setting property to same value does not trigger callback", () => {
let n = 0; let n = 0;
const obj = observe({} as any, () => n++); const atom1 = createAtom({ a: 1 }, () => n++);
atom1.a = atom1.a + 5; // read and modifies property a to have value 6
obj.a = 3; expect(n).toBe(1);
obj.a = 3; atom1.a = 6; // same value
expect(n).toBe(1); expect(n).toBe(1);
}); });
test("observe cycles", () => {
const a = { b: {} };
a.b = a;
let n = 0;
const atom1 = createAtom(a, () => n++);
atom1.k = 2;
expect(n).toBe(1);
delete atom1.l;
expect(n).toBe(1);
delete atom1.k;
expect(n).toBe(2);
atom1.b = 1;
expect(n).toBe(2);
atom1.b = atom1.b + 5;
expect(n).toBe(3);
});
test("two observers for same source", () => {
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;
expect(m).toBe(0);
expect(n).toBe(0);
atom1.new = 2; // already exists!
expect(m).toBe(0);
expect(n).toBe(0);
atom1.veryNew = 2;
expect(m).toBe(1);
expect(n).toBe(1);
atom1.a = atom1.a + 5;
expect(m).toBe(2);
expect(n).toBe(1);
atom2.a = atom2.a + 5;
expect(m).toBe(3);
expect(n).toBe(2);
delete atom2.veryNew;
expect(m).toBe(4);
expect(n).toBe(3);
});
test("create atom from another atom", () => {
let n = 0;
const atom1 = createAtom({ a: 1 });
const atom2 = createAtom(atom1, () => n++);
atom2.a = atom2.a + 5;
expect(n).toBe(1);
atom1.a = 2;
expect(n).toBe(2);
});
test("create atom from another atom 2", () => {
let n = 0;
const atom1 = createAtom({ a: 1 });
const atom2 = createAtom(atom1, () => n++);
atom1.a = atom2.a + 5;
expect(n).toBe(1);
atom2.a = atom2.a + 5;
expect(n).toBe(2);
});
test("create atom from another atom 3", () => {
let n = 0;
const atom1 = createAtom({ a: 1 });
const atom2 = createAtom(atom1, () => n++);
atom1.a = atom1.a + 5;
expect(n).toBe(0); // atom2.a was not yet read
atom2.a = atom2.a + 5;
expect(n).toBe(1); // atom2.a has been read and is now observed
atom1.a = atom1.a + 5;
expect(n).toBe(2);
});
test("immediately returns primitive values", () => { test("immediately returns primitive values", () => {
expect(observe(1, () => {})).toBe(1); expect(createAtom(1)).toBe(1);
expect(observe("asf", () => {})).toBe("asf"); expect(createAtom("asf")).toBe("asf");
expect(observe(true, () => {})).toBe(true); expect(createAtom(true)).toBe(true);
expect(observe(null, () => {})).toBe(null); expect(createAtom(null)).toBe(null);
expect(observe(undefined, () => {})).toBe(undefined); expect(createAtom(undefined)).toBe(undefined);
}); });
test("immediately returns dates", () => { test("immediately returns dates", () => {
const d = new Date(); const date = new Date();
expect(observe(d, () => {})).toBe(d); expect(createAtom(date)).toBe(date);
}); });
test("can observe object with some key set to null", () => { test("can observe object with some key set to null", () => {
let n = 0; let n = 0;
const obj = observe({ a: { b: null } } as any, () => n++); const atom1 = createAtom({ a: { b: null } } as any, () => n++);
expect(n).toBe(0); expect(n).toBe(0);
obj.a.b = 3; atom1.a.b = Boolean(atom1.a.b);
expect(n).toBe(1); expect(n).toBe(1);
}); });
test("can reobserve object with some key set to null", () => { test("can reobserve object with some key set to null", () => {
let n = 0; let n = 0;
const fn = () => n++; const fn = () => n++;
const obj = observe({ a: { b: null } } as any, fn); const unregisterObserver = registerObserver(fn);
const obj2 = observe(obj, fn); const atom1 = createAtom({ a: { b: null } } as any, fn);
expect(obj2).toBe(obj); const atom2 = createAtom(atom1, fn);
expect(atom2).toBe(atom1);
expect(atom2).toEqual(atom1);
expect(n).toBe(0); expect(n).toBe(0);
obj.a.b = 3; atom1.a.b = Boolean(atom1.a.b);
expect(n).toBe(1); expect(n).toBe(1);
unobserve(obj, fn); unregisterObserver();
obj.a.b = 5; atom1.a.b = !atom1.a.b;
expect(n).toBe(1); expect(n).toBe(1);
}); });
test("contains initial values", () => { test("contains initial values", () => {
const obj = observe({ a: 1, b: 2 }, () => {}); const atom1 = createAtom({ a: 1, b: 2 });
expect(obj.a).toBe(1); expect(atom1.a).toBe(1);
expect(obj.b).toBe(2); expect(atom1.b).toBe(2);
expect((obj as any).c).toBe(undefined); expect((atom1 as any).c).toBeUndefined();
}); });
test("detect object value changes", () => { test("detect object value changes", () => {
let n = 0; let n = 0;
const obj = observe({ a: 1 }, () => n++) as any; const atom1 = createAtom({ a: 1 }, () => n++) as any;
expect(n).toBe(0); expect(n).toBe(0);
obj.a = 3; atom1.a = atom1.a + 5;
expect(n).toBe(1); expect(n).toBe(1);
obj.b = 5; atom1.b = atom1.b + 5;
expect(n).toBe(2); expect(n).toBe(2);
obj.a = null; atom1.a = null;
obj.b = undefined; atom1.b = undefined;
expect(n).toBe(4); expect(n).toBe(4);
expect(obj).toEqual({ a: null, b: undefined }); expect(atom1).toEqual({ a: null, b: undefined });
}); });
test("properly handle dates", () => { test("properly handle dates", () => {
const date = new Date(); const date = new Date();
let n = 0; let n = 0;
const obj = observe({ date }, () => n++); const atom1 = createAtom({ date }, () => n++);
expect(typeof obj.date.getFullYear()).toBe("number"); expect(typeof atom1.date.getFullYear()).toBe("number");
expect(obj.date).toBe(date); expect(atom1.date).toBe(date);
obj.date = new Date(); atom1.date = new Date();
expect(n).toBe(1); expect(n).toBe(1);
expect(obj.date).not.toBe(date); expect(atom1.date).not.toBe(date);
}); });
test("properly handle promise", async () => { test("properly handle promise", async () => {
let resolved = false; let resolved = false;
const prom = new Promise((r) => r()); const prom = new Promise((r) => r());
let n = 0; let n = 0;
const obj = observe({ prom }, () => n++); const atom1 = createAtom({ prom }, () => n++);
expect(obj.prom).toBeInstanceOf(Promise); expect(atom1.prom).toBeInstanceOf(Promise);
obj.prom.then(() => (resolved = true)); atom1.prom.then(() => (resolved = true));
expect(n).toBe(0); expect(n).toBe(0);
expect(resolved).toBe(false); expect(resolved).toBe(false);
await Promise.resolve(); await Promise.resolve();
@@ -111,133 +252,150 @@ describe("observe", () => {
test("can observe value change in array in an object", () => { test("can observe value change in array in an object", () => {
let n = 0; let n = 0;
const obj = observe({ arr: [1, 2] }, () => n++) as any; const atom1 = createAtom({ arr: [1, 2] }, () => n++) as any;
expect(Array.isArray(obj.arr)).toBe(true); expect(Array.isArray(atom1.arr)).toBe(true);
expect(n).toBe(0); expect(n).toBe(0);
obj.arr[0] = "nope"; atom1.arr[0] = atom1.arr[0] + "nope";
expect(n).toBe(1); expect(n).toBe(1);
expect(obj.arr[0]).toBe("nope"); expect(atom1.arr[0]).toBe("1nope");
expect(obj.arr).toEqual(["nope", 2]); expect(atom1.arr).toEqual(["1nope", 2]);
}); });
test("can observe: changing array in object to another array", () => { test("can observe: changing array in object to another array", () => {
let n = 0; let n = 0;
const obj = observe({ arr: [1, 2] }, () => n++) as any; const atom1 = createAtom({ arr: [1, 2] }, () => n++) as any;
expect(Array.isArray(obj.arr)).toBe(true); expect(Array.isArray(atom1.arr)).toBe(true);
expect(n).toBe(0); expect(n).toBe(0);
obj.arr = [2, 1]; atom1.arr = [2, 1];
expect(n).toBe(1); expect(n).toBe(1);
expect(obj.arr[0]).toBe(2); expect(atom1.arr[0]).toBe(2);
expect(obj.arr).toEqual([2, 1]); expect(atom1.arr).toEqual([2, 1]);
}); });
test("getting twice an object properties return same object", () => { test("getting twice an object properties return same object", () => {
const obj = observe({ a: { b: 1 } }, () => {}); const atom1 = createAtom({ a: { b: 1 } });
const a1 = atom1.a;
const a1 = obj.a; const a2 = atom1.a;
const a2 = obj.a;
expect(a1).toBe(a2); expect(a1).toBe(a2);
}); });
test("various object property changes", () => { test("various object property changes", () => {
let n = 0; let n = 0;
const obj = observe({ a: 1 }, () => n++) as any; const atom1 = createAtom({ a: 1 }, () => n++) as any;
expect(n).toBe(0); expect(n).toBe(0);
obj.a = 2; atom1.a = atom1.a + 2;
expect(n).toBe(1); expect(n).toBe(1);
// same value again // same value again
obj.a = 2; atom1.a = 3;
expect(n).toBe(1); expect(n).toBe(1);
obj.a = 3; atom1.a = 4;
expect(n).toBe(2); expect(n).toBe(2);
}); });
test("properly observe arrays", () => { test("properly observe arrays", () => {
let n = 0; let n = 0;
const arr = observe([], () => n++) as any; const atom1 = createAtom([], () => n++) as any;
expect(Array.isArray(arr)).toBe(true); expect(Array.isArray(atom1)).toBe(true);
expect(arr.length).toBe(0); expect(atom1.length).toBe(0);
expect(n).toBe(0); expect(n).toBe(0);
arr.push(1); atom1.push(1);
expect(n).toBe(1); expect(n).toBe(1);
expect(arr.length).toBe(1); expect(atom1.length).toBe(1);
expect(arr).toEqual([1]); expect(atom1).toEqual([1]);
arr.splice(1, 0, "hey"); atom1.splice(1, 0, "hey");
expect(n).toBe(2); expect(n).toBe(2);
expect(arr).toEqual([1, "hey"]); expect(atom1).toEqual([1, "hey"]);
expect(arr.length).toBe(2); expect(atom1.length).toBe(2);
arr.unshift("lindemans"); atom1.unshift("lindemans");
//it generates 3 primitive operations //it generates 3 primitive operations
expect(n).toBe(5); expect(n).toBe(5);
expect(arr).toEqual(["lindemans", 1, "hey"]); expect(atom1).toEqual(["lindemans", 1, "hey"]);
expect(arr.length).toBe(3); expect(atom1.length).toBe(3);
arr.reverse(); atom1.reverse();
//it generates 2 primitive operations //it generates 2 primitive operations
expect(n).toBe(7); expect(n).toBe(7);
expect(arr).toEqual(["hey", 1, "lindemans"]); expect(atom1).toEqual(["hey", 1, "lindemans"]);
expect(arr.length).toBe(3); expect(atom1.length).toBe(3);
arr.pop(); // one set, one delete atom1.pop(); // one set, one delete
expect(n).toBe(9); expect(n).toBe(9);
expect(arr).toEqual(["hey", 1]); expect(atom1).toEqual(["hey", 1]);
expect(arr.length).toBe(2); expect(atom1.length).toBe(2);
arr.shift(); // 2 sets, 1 delete atom1.shift(); // 2 sets, 1 delete
expect(n).toBe(12); expect(n).toBe(12);
expect(arr).toEqual([1]); expect(atom1).toEqual([1]);
expect(arr.length).toBe(1); expect(atom1.length).toBe(1);
}); });
test("object pushed into arrays are observed", () => { test("object pushed into arrays are observed", () => {
let n = 0; let n = 0;
const arr: any = observe([], () => n++); const arr: any = createAtom([], () => n++);
arr.push({ kriek: 5 }); arr.push({ kriek: 5 });
expect(n).toBe(1); expect(n).toBe(1);
arr[0].kriek = 6; arr[0].kriek = 6;
expect(n).toBe(1);
arr[0].kriek = arr[0].kriek + 6;
expect(n).toBe(2); expect(n).toBe(2);
}); });
test("set new property on observed object", async () => { test("set new property on observed object", async () => {
let n = 0; let n = 0;
const state = observe({}, () => n++) as any; const atom1 = createAtom({}, () => n++) as any;
expect(n).toBe(0); expect(n).toBe(0);
state.b = 8; atom1.b = 8;
expect(n).toBe(1); expect(n).toBe(1);
expect(state.b).toBe(8); expect(atom1.b).toBe(8);
}); });
test("delete property from observed object", async () => { test("delete property from observed object", async () => {
let n = 0; let n = 0;
const obj = observe({ a: 1, b: 8 }, () => n++) as any; const atom1 = createAtom({ a: 1, b: 8 }, () => n++) as any;
expect(n).toBe(0); expect(n).toBe(0);
delete obj.b; delete atom1.b;
expect(n).toBe(1); expect(n).toBe(1);
expect(obj).toEqual({ a: 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;
// key "a" is no longer observed
expect(n).toBe(1);
atom1.new = 2; // but { b: 1 } is still observed!
expect(n).toBe(2);
}); });
test("set element in observed array", async () => { test("set element in observed array", async () => {
let n = 0; let n = 0;
const arr = observe(["a"], () => n++); const arr = createAtom(["a"], () => n++);
arr[1] = "b"; arr[1] = "b";
expect(n).toBe(1); expect(n).toBe(1);
@@ -246,69 +404,74 @@ describe("observe", () => {
test("properly observe arrays in object", () => { test("properly observe arrays in object", () => {
let n = 0; let n = 0;
const obj = observe({ arr: [] }, () => n++) as any; const atom1 = createAtom({ arr: [] }, () => n++) as any;
expect(obj.arr.length).toBe(0); expect(atom1.arr.length).toBe(0);
expect(n).toBe(0);
obj.arr.push(1); atom1.arr.push(1);
expect(n).toBe(1); expect(n).toBe(1);
expect(obj.arr.length).toBe(1); expect(atom1.arr.length).toBe(1);
}); });
test("properly observe objects in array", () => { test("properly observe objects in array", () => {
let n = 0; let n = 0;
const obj = observe({ arr: [{ something: 1 }] }, () => n++) as any; const atom1 = createAtom({ arr: [{ something: 1 }] }, () => n++) as any;
expect(n).toBe(0); expect(n).toBe(0);
obj.arr[0].something = 2; atom1.arr[0].something = atom1.arr[0].something + 1;
expect(n).toBe(1); expect(n).toBe(1);
expect(obj.arr[0].something).toBe(2); expect(atom1.arr[0].something).toBe(2);
}); });
test("properly observe objects in object", () => { test("properly observe objects in object", () => {
let n = 0; let n = 0;
const obj = observe({ a: { b: 1 } }, () => n++) as any; const atom1 = createAtom({ a: { b: 1 } }, () => n++) as any;
expect(n).toBe(0); expect(n).toBe(0);
obj.a.b = 2; atom1.a.b = atom1.a.b + 2;
expect(n).toBe(1); expect(n).toBe(1);
}); });
test("reobserve new object values", () => { test("reobserve new object values", () => {
let n = 0; let n = 0;
const obj = observe({ a: 1 }, () => n++) as any; const atom1 = createAtom({ a: 1 }, () => n++) as any;
expect(n).toBe(0); expect(n).toBe(0);
obj.a = { b: 2 }; atom1.a++;
expect(n).toBe(1); expect(n).toBe(1);
atom1.a = { b: 2 };
obj.a.b = 3;
expect(n).toBe(2); expect(n).toBe(2);
atom1.a.b = atom1.a.b + 3;
expect(n).toBe(3);
}); });
test("deep observe misc changes", () => { test("deep observe misc changes", () => {
let n = 0; let n = 0;
const obj = observe({ o: { a: 1 }, arr: [1], n: 13 }, () => n++) as any; const atom1 = createAtom({ o: { a: 1 }, arr: [1], n: 13 }, () => n++) as any;
expect(n).toBe(0); expect(n).toBe(0);
obj.o.a = 2; atom1.o.a = atom1.o.a + 2;
expect(n).toBe(1); expect(n).toBe(1);
obj.arr.push(2); atom1.arr.push(2);
expect(n).toBe(2); expect(n).toBe(2);
obj.n = 155; atom1.n = 155;
expect(n).toBe(2);
atom1.n = atom1.n + 1;
expect(n).toBe(3); expect(n).toBe(3);
}); });
test("properly handle already observed state", () => { test("properly handle already observed atom", () => {
let n1 = 0; let n1 = 0;
let n2 = 0; let n2 = 0;
const obj1 = observe({ a: 1 }, () => n1++) as any; const obj1 = createAtom({ a: 1 }, () => n1++) as any;
const obj2 = observe({ b: 1 }, () => n2++) as any; const obj2 = createAtom({ b: 1 }, () => n2++) as any;
obj1.a = 2; obj1.a = obj1.a + 2;
obj2.b = 3; obj2.b = obj2.b + 3;
expect(n1).toBe(1); expect(n1).toBe(1);
expect(n2).toBe(1); expect(n2).toBe(1);
@@ -318,36 +481,42 @@ describe("observe", () => {
obj1.a = 33; obj1.a = 33;
expect(n1).toBe(2); expect(n1).toBe(2);
expect(n2).toBe(2);
obj2.b.a = obj2.b.a + 2;
expect(n1).toBe(3);
expect(n2).toBe(3); expect(n2).toBe(3);
}); });
test("properly handle already observed state in observed state", () => { test("properly handle already observed atom in observed atom", () => {
let n1 = 0; let n1 = 0;
let n2 = 0; let n2 = 0;
const obj1 = observe({ a: { c: 2 } }, () => n1++) as any; const obj1 = createAtom({ a: { c: 2 } }, () => n1++) as any;
const obj2 = observe({ b: 1 }, () => n2++) as any; const obj2 = createAtom({ b: 1 }, () => n2++) as any;
obj2.c = obj1; obj2.c = obj1;
expect(n1).toBe(0); expect(n1).toBe(0);
expect(n2).toBe(1); expect(n2).toBe(1);
obj1.a.c = 33; obj1.a.c = obj1.a.c + 33;
expect(n1).toBe(1); expect(n1).toBe(1);
expect(n2).toBe(1);
obj2.c.a.c = obj2.c.a.c + 3;
expect(n1).toBe(2);
expect(n2).toBe(2); expect(n2).toBe(2);
}); });
test("can reobserve object", () => { test("can reobserve object", () => {
let n1 = 0; let n1 = 0;
let n2 = 0; let n2 = 0;
const obj = observe({ a: 0 }, () => n1++) as any; const atom1 = createAtom({ a: 0 }, () => n1++) as any;
obj.a = 1; atom1.a = atom1.a + 1;
expect(n1).toBe(1); expect(n1).toBe(1);
expect(n2).toBe(0); expect(n2).toBe(0);
const obj2 = observe(obj, () => n2++) as any; const atom2 = createAtom(atom1, () => n2++) as any;
expect(obj).toBe(obj2); expect(atom1).toEqual(atom2);
obj.a = 2; atom2.a = 2;
expect(n1).toBe(2); expect(n1).toBe(2);
expect(n2).toBe(1); expect(n2).toBe(1);
}); });
@@ -355,205 +524,224 @@ describe("observe", () => {
test("can reobserve nested properties in object", () => { test("can reobserve nested properties in object", () => {
let n1 = 0; let n1 = 0;
let n2 = 0; let n2 = 0;
const obj = observe({ a: [{ b: 1 }] }, () => n1++) as any; const atom1 = createAtom({ a: [{ b: 1 }] }, () => n1++) as any;
observe(obj, () => n2++) as any; const atom2 = createAtom(atom1, () => n2++) as any;
obj.a[0].b = 2; atom1.a[0].b = atom1.a[0].b + 2;
expect(n1).toBe(1); expect(n1).toBe(1);
expect(n2).toBe(0);
atom2.c = 2;
expect(n1).toBe(2);
expect(n2).toBe(1); 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", () => { test("can reobserve new properties in object", () => {
let n1 = 0; let n1 = 0;
let n2 = 0; let n2 = 0;
const obj = observe({ a: [{ b: 1 }] }, () => n1++) as any; const atom1 = createAtom({ a: [{ b: 1 }] }, () => n1++) as any;
observe(obj, () => n2++) as any; createAtom(atom1, () => n2++) as any;
obj.a[0].b = { c: 1 }; atom1.a[0].b = { c: 1 };
expect(n1).toBe(0);
expect(n2).toBe(0);
atom1.a[0].b.c = atom1.a[0].b.c + 2;
expect(n1).toBe(1); expect(n1).toBe(1);
expect(n2).toBe(1); expect(n2).toBe(0);
obj.a[0].b.c = 2;
expect(n1).toBe(2);
expect(n2).toBe(2);
}); });
test("can observe sub property of observed object", () => { test("can observe sub property of observed object", () => {
let n1 = 0; let n1 = 0;
let n2 = 0; let n2 = 0;
const obj = observe({ a: { b: 1 }, c: 1 }, () => n1++) as any; const atom1 = createAtom({ a: { b: 1 }, c: 1 }, () => n1++) as any;
observe(obj.a, () => n2++) as any; const atom2 = createAtom(atom1.a, () => n2++) as any;
obj.a.b = 2; atom1.a.b = atom1.a.b + 2;
expect(n1).toBe(1); expect(n1).toBe(1);
expect(n2).toBe(0);
atom1.l = 2;
expect(n1).toBe(2);
expect(n2).toBe(0);
atom1.a.k = 3;
expect(n1).toBe(3);
expect(n2).toBe(1); expect(n2).toBe(1);
obj.c = 14; atom1.c = 14;
expect(n1).toBe(2); expect(n1).toBe(3);
expect(n2).toBe(1); expect(n2).toBe(1);
atom2.b = atom2.b + 3;
expect(n1).toBe(4);
expect(n2).toBe(2);
}); });
test("can set a property more than once", () => { test("can set a property more than once", () => {
let n = 0; let n = 0;
const obj = observe({}, () => n++) as any; const atom1 = createAtom({}, () => n++) as any;
obj.aku = "always finds annoying problems"; atom1.aky = atom1.aku;
expect(n).toBe(1);
obj.aku = "always finds good problems"; atom1.aku = "always finds annoying problems";
expect(n).toBe(2); expect(n).toBe(2);
atom1.aku = "always finds good problems";
expect(n).toBe(3);
}); });
test("properly handle swapping elements", () => { test("properly handle swapping elements", () => {
let n = 0; let n = 0;
const obj = observe({ a: { arr: [] }, b: 1 }, () => n++) as any; const atom1 = createAtom({ a: { arr: [] }, b: 1 }, () => n++) as any;
// swap a and b // swap a and b
const b = obj.b; const b = atom1.b;
obj.b = obj.a; atom1.b = atom1.a;
obj.a = b; atom1.a = b;
expect(n).toBe(2); expect(n).toBe(2);
// push something into array to make sure it works // push something into array to make sure it works
obj.b.arr.push("blanche"); atom1.b.arr.push("blanche");
expect(n).toBe(3); expect(n).toBe(3);
}); });
test("properly handle assigning observed obj containing array", () => { test("properly handle assigning observed atom containing array", () => {
let n = 0; let n = 0;
const obj = observe({ a: { arr: [], val: "test" } }, () => n++) as any; const atom1 = createAtom({ a: { arr: [], val: "test" } }, () => n++) as any;
expect(n).toBe(0); expect(n).toBe(0);
obj.a = { ...obj.a, val: "test2" }; atom1.a = { ...atom1.a, val: "test2" };
expect(n).toBe(1); expect(n).toBe(1);
// push something into array to make sure it works // push something into array to make sure it works
obj.a.arr.push("blanche"); atom1.a.arr.push("blanche");
expect(n).toBe(2); expect(n).toBe(2);
}); });
test("accept cycles in observed state", () => { test("accept cycles in observed atom", () => {
let n = 0; let n = 0;
let obj1: any = {}; let obj1: any = {};
let obj2: any = { b: obj1, key: 1 }; let obj2: any = { b: obj1, key: 1 };
obj1.a = obj2; obj1.a = obj2;
obj1 = observe(obj1, () => n++) as any; obj1 = createAtom(obj1, () => n++) as any;
obj2 = obj1.a; obj2 = obj1.a;
expect(n).toBe(0); expect(n).toBe(0);
obj2.key = 3; obj1.key = 3;
expect(n).toBe(1); expect(n).toBe(1);
}); });
test.skip("call callback when state is changed", async () => { test("call callback when atom is changed", async () => {
/* const observer = new Observer(); let n = 0;
observer.notifyCB = jest.fn(); const atom1: any = createAtom({ a: 1, b: { c: 2 }, d: [{ e: 3 }], f: 4 }, () => n++);
const obj: any = observer.observe({ a: 1, b: { c: 2 }, d: [{ e: 3 }], f: 4 }); expect(n).toBe(0);
atom1.a = atom1.a + 2;
expect(observer.notifyCB).toBeCalledTimes(0); expect(n).toBe(1);
atom1.b.c = atom1.b.c + 3;
obj.a = 2; expect(n).toBe(2);
await nextMicroTick(); atom1.d[0].e = atom1.d[0].e + 5;
expect(observer.notifyCB).toBeCalledTimes(1); expect(n).toBe(3);
atom1.a = 111;
obj.b.c = 3; atom1.f = 222;
await nextMicroTick(); expect(n).toBe(4);
expect(observer.notifyCB).toBeCalledTimes(2);
obj.d[0].e = 5;
await nextMicroTick();
expect(observer.notifyCB).toBeCalledTimes(3);
obj.a = 111;
obj.f = 222;
await nextMicroTick();
expect(observer.notifyCB).toBeCalledTimes(5);*/
}); });
test.skip("throw error when state is mutated in object if allowMutation=false", async () => {
/*const observer = new Observer();
observer.allowMutations = false;
const obj: any = observer.observe({ a: 1 });
expect(() => {
obj.a = 2;
}).toThrow('Observed state cannot be changed here! (key: "a", val: "2")');*/
});
test.skip("throw error when state is mutated in array if allowMutation=false", async () => {
/* const observer = new Observer();
observer.allowMutations = false;
const obj: any = observer.observe({ a: [1] });
expect(() => {
obj.a.push(2);
}).toThrow('Observed state cannot be changed here! (key: "1", val: "2")');*/
});
});
describe("unobserve", () => {
test("can unobserve a value", () => { test("can unobserve a value", () => {
let n = 0; let n = 0;
const cb = () => n++; const cb = () => n++;
const obj = observe({ a: 1 }, cb); const unregisterObserver = registerObserver(cb);
obj.a = 3; const atom1 = createAtom({ a: 1 }, cb);
atom1.a = atom1.a + 3;
expect(n).toBe(1); expect(n).toBe(1);
unobserve(obj, cb);
obj.a = 4; unregisterObserver();
atom1.a = 4;
expect(n).toBe(1); expect(n).toBe(1);
}); });
test("can 'unobserve' primitive values, and dates", () => { test("observing some observed atom", () => {
unobserve(null, () => {});
unobserve(undefined, () => {});
unobserve(false, () => {});
unobserve("iroh", () => {});
unobserve(32, () => {});
unobserve(new Date(), () => {});
});
test("rereading some property again give exactly same result", () => {
const obj = observe({ a: { b: 1 } }, () => {});
const obj1 = obj.a;
const obj2 = obj.a;
expect(obj1).toBe(obj2);
});
test("observing some observed state", () => {
let n1 = 0; let n1 = 0;
let n2 = 0; let n2 = 0;
const inner = observe({ a: 1 }, () => n1++); const inner = createAtom({ a: 1 }, () => n1++);
const outer = observe({ b: inner }, () => n2++); const outer = createAtom({ b: inner }, () => n2++);
expect(n1).toBe(0); expect(n1).toBe(0);
expect(n2).toBe(0); expect(n2).toBe(0);
outer.b.a = 2; outer.b.a = outer.b.a + 2;
expect(n1).toBe(0);
expect(n2).toBe(1);
});
test("observing some observed atom, variant", () => {
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;
expect(n1).toBe(1);
expect(n2).toBe(0);
outer.c = outer.c + 3;
expect(n1).toBe(1); expect(n1).toBe(1);
expect(n2).toBe(1); expect(n2).toBe(1);
}); });
test.skip("observing some observed state, variant", () => { test("observing some observed atom, variant 2", () => {
let n1 = 0; let n1 = 0;
let n2 = 0; let n2 = 0;
const inner = observe({ a: 1 }, () => n1++); let n3 = 0;
const outer = observe({ b: inner, c: 0 }, () => n2++); const obj1 = createAtom({ a: 1 }, () => n1++);
expect(n1).toBe(0); const obj2 = createAtom({ b: {} }, () => n2++);
expect(n2).toBe(0); const obj3 = createAtom({ c: {} }, () => n3++);
inner.a = 2;
expect(n1).toBe(1);
expect(n2).toBe(1);
outer.c = 3; obj2.b = obj2.b;
obj3.c = obj3.c;
expect(n1).toBe(0);
expect(n2).toBe(1);
expect(n3).toBe(1);
obj2.b = obj1;
obj3.c = obj1;
expect(n1).toBe(0);
expect(n2).toBe(2);
expect(n3).toBe(2);
obj1.a = obj1.a + 2;
expect(n1).toBe(1); expect(n1).toBe(1);
expect(n2).toBe(2); expect(n2).toBe(2);
expect(n3).toBe(2);
obj2.b.a = obj2.b.a + 1;
expect(n1).toBe(2);
expect(n2).toBe(3);
expect(n3).toBe(2);
});
test("notification is not done after unregistration", () => {
let n = 0;
const observer = () => n++;
const unregisterObserver = registerObserver(observer);
const state = atom({ a: 1 }, observer);
state.a = state.a;
expect(n).toBe(0);
unregisterObserver();
state.a = { b: 2 };
expect(n).toBe(0);
state.a.b = state.a.b + 3;
expect(n).toBe(0);
}); });
}); });
// note: does not work!!!
// if we have
// obj1 = {a:1}
// obj2 = {b: obj1}
// obj3 = {c: obj1}
// modifying obj1 => should call cb for obj2 and obj3