From 4e4b85e2a9c11e016a81adf54ca2fe3f5e020093 Mon Sep 17 00:00:00 2001 From: Mathieu Duckerts-Antoine Date: Tue, 26 Oct 2021 14:51:57 +0200 Subject: [PATCH] [IMP] reactivity: new primitives for reactivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Géry Debongnie Co-authored-by: Mathieu Duckerts-Antoine --- src/reactivity.ts | 260 +++---- .../__snapshots__/lifecycle.test.ts.snap | 5 +- .../__snapshots__/reactivity.test.ts.snap | 15 + tests/components/lifecycle.test.ts | 2 +- tests/components/reactivity.test.ts | 22 + tests/reactivity.test.ts | 672 +++++++++++------- 6 files changed, 604 insertions(+), 372 deletions(-) diff --git a/src/reactivity.ts b/src/reactivity.ts index c54ba58c..421f6351 100644 --- a/src/reactivity.ts +++ b/src/reactivity.ts @@ -1,144 +1,150 @@ import { getCurrent } from "./component/component_node"; +import { onWillUnmount } from "./component/lifecycle_hooks"; -export function useState(state: T): T { - const node = getCurrent()!; - return observe(state, () => node.render()); +type Atom = any; // proxy linked to a unique observer and source +type Source = any; // trackable that is not an atom +type Observer = () => void; +type Keys = Set; + +const sourceAtoms: WeakMap> = new WeakMap(); +const observerAtoms: WeakMap> = 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; -const observers: WeakMap> = new WeakMap(); - -/** - * PSet (for Prototypal Set) are sets that can lookup in their "parent sets", if - * any. - */ - -class PSet extends Set { - parent?: PSet; - - static createChild(parent: PSet): PSet { - const pset: PSet = 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 { - let iterator = super[Symbol.iterator](); - for (let elem of iterator) { - yield elem; - } - if (this.parent) { - for (let elem of this.parent) { - yield elem; +function createAtom(source: Source, observer: Observer): Atom { + const keys: Keys = new Set(); + const newAtom: Atom = new Proxy(source as any, { + set(target: any, key: string, value: any): boolean { + if (!(key in target)) { + target[key] = value; + notifySourceObservers(source); + return true; } + 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 { + 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; } -// ----------------------------------------------------------------------------- - -export function observe(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 = new PSet(); - callbacks.add(cb); - return observeValue(value, callbacks); +function getSourceAtoms(source: Source): Set { + return sourceAtoms.get(source)!; } -export function unobserve(value: T, cb: () => void) { - if (isNotObservable(value)) { - return; - } - if (observers.has(value)) { - const callbacks = observers.get(value)!; - callbacks.delete(cb); - } -} - -function isNotObservable(value: any): boolean { +function isTrackable(value: any): boolean { return ( - value === null || typeof value !== "object" || value instanceof Date || value instanceof Promise + value !== null && + typeof value === "object" && + !(value instanceof Date) && + !(value instanceof Promise) ); } -/** - * value should - * 1. be observable - * 2. not yet be observed - */ -function observeValue(value: any, callbacks: PSet): 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 notifySourceKeyOBservers(source: Source, key: any) { + for (const atom of getSourceAtoms(source)) { + if (atom[KEYS].has(key)) { + atom[OBSERVER](); + } } } + +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); +} diff --git a/tests/components/__snapshots__/lifecycle.test.ts.snap b/tests/components/__snapshots__/lifecycle.test.ts.snap index 642ae2b0..7a60ad8c 100644 --- a/tests/components/__snapshots__/lifecycle.test.ts.snap +++ b/tests/components/__snapshots__/lifecycle.test.ts.snap @@ -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 { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, isBoundary, shallowEqual, setContextValue } = helpers; - let block1 = createBlock(\`
\`); + let block1 = createBlock(\`
\`); return function template(ctx, node, key = \\"\\") { - return block1(); + let d1 = ctx['state'].a; + return block1([d1]); } }" `; diff --git a/tests/components/__snapshots__/reactivity.test.ts.snap b/tests/components/__snapshots__/reactivity.test.ts.snap index 0d05e657..6bb44ef1 100644 --- a/tests/components/__snapshots__/reactivity.test.ts.snap +++ b/tests/components/__snapshots__/reactivity.test.ts.snap @@ -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(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let d1 = ctx['state'].a; + return block1([d1]); + } +}" +`; + exports[`reactivity in lifecycle change state while mounting component 1`] = ` "function anonymous(bdom, helpers ) { diff --git a/tests/components/lifecycle.test.ts b/tests/components/lifecycle.test.ts index f119c259..92382375 100644 --- a/tests/components/lifecycle.test.ts +++ b/tests/components/lifecycle.test.ts @@ -493,7 +493,7 @@ describe("lifecycle hooks", () => { let n = 0; class Test extends Component { - static template = xml`
`; + static template = xml`
`; state = useState({ a: 1 }); setup() { diff --git a/tests/components/reactivity.test.ts b/tests/components/reactivity.test.ts index 14dcec63..cfab26b3 100644 --- a/tests/components/reactivity.test.ts +++ b/tests/components/reactivity.test.ts @@ -31,6 +31,28 @@ describe("reactivity in lifecycle", () => { expect(fixture.innerHTML).toBe("
3
"); }); + test("can use a state hook 2", async () => { + let n = 0; + class Comp extends Component { + static template = xml`
`; + state = useState({ a: 5, b: 7 }); + setup() { + onRender(() => n++); + } + } + const comp = await mount(Comp, fixture); + expect(fixture.innerHTML).toBe("
5
"); + expect(n).toBe(1); + comp.state.a = 11; + await nextTick(); + expect(fixture.innerHTML).toBe("
11
"); + expect(n).toBe(2); + comp.state.b = 13; + await nextTick(); + expect(fixture.innerHTML).toBe("
11
"); + expect(n).toBe(2); // no new rendering occured: b was never read via state! + }); + test("state changes in willUnmount do not trigger rerender", async () => { const steps: string[] = []; class Child extends Component { diff --git a/tests/reactivity.test.ts b/tests/reactivity.test.ts index f22cf6d4..2970c589 100644 --- a/tests/reactivity.test.ts +++ b/tests/reactivity.test.ts @@ -1,107 +1,248 @@ -import { observe, unobserve } from "../src/reactivity"; +import { atom, registerObserver } from "../src/reactivity"; -describe("observe", () => { - test("basic properties", () => { +function createAtom(atom1: any, observer?: any) { + 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; - const obj = observe({} as any, () => n++); - expect(typeof obj).toBe("object"); - expect(n).toBe(0); - - obj.a = 3; - expect(n).toBe(1); - expect(obj.a).toBe(3); + const atom1 = createAtom({ a: 1 }, () => n++); + atom1.a = 2; + expect(n).toBe(0); // key has not be read yet. + atom1.a = atom1.a + 5; // key is read and then modified expect(n).toBe(1); }); test("setting property to same value does not trigger callback", () => { let n = 0; - const obj = observe({} as any, () => n++); - - obj.a = 3; - obj.a = 3; + const atom1 = createAtom({ a: 1 }, () => n++); + atom1.a = atom1.a + 5; // read and modifies property a to have value 6 + expect(n).toBe(1); + atom1.a = 6; // same value 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", () => { - expect(observe(1, () => {})).toBe(1); - expect(observe("asf", () => {})).toBe("asf"); - expect(observe(true, () => {})).toBe(true); - expect(observe(null, () => {})).toBe(null); - expect(observe(undefined, () => {})).toBe(undefined); + 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 d = new Date(); - expect(observe(d, () => {})).toBe(d); + const date = new Date(); + expect(createAtom(date)).toBe(date); }); test("can observe object with some key set to null", () => { 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); - obj.a.b = 3; + atom1.a.b = Boolean(atom1.a.b); expect(n).toBe(1); }); test("can reobserve object with some key set to null", () => { let n = 0; const fn = () => n++; - const obj = observe({ a: { b: null } } as any, fn); - const obj2 = observe(obj, fn); - expect(obj2).toBe(obj); + 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); expect(n).toBe(0); - obj.a.b = 3; + atom1.a.b = Boolean(atom1.a.b); expect(n).toBe(1); - unobserve(obj, fn); - obj.a.b = 5; + unregisterObserver(); + atom1.a.b = !atom1.a.b; expect(n).toBe(1); }); test("contains initial values", () => { - const obj = observe({ a: 1, b: 2 }, () => {}); - expect(obj.a).toBe(1); - expect(obj.b).toBe(2); - expect((obj as any).c).toBe(undefined); + 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", () => { let n = 0; - const obj = observe({ a: 1 }, () => n++) as any; + const atom1 = createAtom({ a: 1 }, () => n++) as any; expect(n).toBe(0); - obj.a = 3; + atom1.a = atom1.a + 5; expect(n).toBe(1); - obj.b = 5; + atom1.b = atom1.b + 5; expect(n).toBe(2); - obj.a = null; - obj.b = undefined; + atom1.a = null; + atom1.b = undefined; expect(n).toBe(4); - expect(obj).toEqual({ a: null, b: undefined }); + expect(atom1).toEqual({ a: null, b: undefined }); }); test("properly handle dates", () => { const date = new Date(); let n = 0; - const obj = observe({ date }, () => n++); + const atom1 = createAtom({ date }, () => n++); - expect(typeof obj.date.getFullYear()).toBe("number"); - expect(obj.date).toBe(date); - obj.date = new Date(); + expect(typeof atom1.date.getFullYear()).toBe("number"); + expect(atom1.date).toBe(date); + atom1.date = new Date(); expect(n).toBe(1); - expect(obj.date).not.toBe(date); + expect(atom1.date).not.toBe(date); }); test("properly handle promise", async () => { let resolved = false; const prom = new Promise((r) => r()); let n = 0; - const obj = observe({ prom }, () => n++); + const atom1 = createAtom({ prom }, () => n++); - expect(obj.prom).toBeInstanceOf(Promise); - obj.prom.then(() => (resolved = true)); + expect(atom1.prom).toBeInstanceOf(Promise); + atom1.prom.then(() => (resolved = true)); expect(n).toBe(0); expect(resolved).toBe(false); await Promise.resolve(); @@ -111,133 +252,150 @@ describe("observe", () => { test("can observe value change in array in an object", () => { 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); - obj.arr[0] = "nope"; + atom1.arr[0] = atom1.arr[0] + "nope"; expect(n).toBe(1); - expect(obj.arr[0]).toBe("nope"); - expect(obj.arr).toEqual(["nope", 2]); + expect(atom1.arr[0]).toBe("1nope"); + expect(atom1.arr).toEqual(["1nope", 2]); }); test("can observe: changing array in object to another array", () => { 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); - obj.arr = [2, 1]; + atom1.arr = [2, 1]; expect(n).toBe(1); - expect(obj.arr[0]).toBe(2); - expect(obj.arr).toEqual([2, 1]); + expect(atom1.arr[0]).toBe(2); + expect(atom1.arr).toEqual([2, 1]); }); test("getting twice an object properties return same object", () => { - const obj = observe({ a: { b: 1 } }, () => {}); - - const a1 = obj.a; - const a2 = obj.a; + const atom1 = createAtom({ a: { b: 1 } }); + const a1 = atom1.a; + const a2 = atom1.a; expect(a1).toBe(a2); }); test("various object property changes", () => { let n = 0; - const obj = observe({ a: 1 }, () => n++) as any; + const atom1 = createAtom({ a: 1 }, () => n++) as any; expect(n).toBe(0); - obj.a = 2; + atom1.a = atom1.a + 2; expect(n).toBe(1); // same value again - obj.a = 2; + atom1.a = 3; expect(n).toBe(1); - obj.a = 3; + atom1.a = 4; expect(n).toBe(2); }); test("properly observe arrays", () => { let n = 0; - const arr = observe([], () => n++) as any; + const atom1 = createAtom([], () => n++) as any; - expect(Array.isArray(arr)).toBe(true); - expect(arr.length).toBe(0); + expect(Array.isArray(atom1)).toBe(true); + expect(atom1.length).toBe(0); expect(n).toBe(0); - arr.push(1); + atom1.push(1); expect(n).toBe(1); - expect(arr.length).toBe(1); - expect(arr).toEqual([1]); + expect(atom1.length).toBe(1); + expect(atom1).toEqual([1]); - arr.splice(1, 0, "hey"); + atom1.splice(1, 0, "hey"); expect(n).toBe(2); - expect(arr).toEqual([1, "hey"]); - expect(arr.length).toBe(2); + expect(atom1).toEqual([1, "hey"]); + expect(atom1.length).toBe(2); - arr.unshift("lindemans"); + atom1.unshift("lindemans"); //it generates 3 primitive operations expect(n).toBe(5); - expect(arr).toEqual(["lindemans", 1, "hey"]); - expect(arr.length).toBe(3); + expect(atom1).toEqual(["lindemans", 1, "hey"]); + expect(atom1.length).toBe(3); - arr.reverse(); + atom1.reverse(); //it generates 2 primitive operations expect(n).toBe(7); - expect(arr).toEqual(["hey", 1, "lindemans"]); - expect(arr.length).toBe(3); + expect(atom1).toEqual(["hey", 1, "lindemans"]); + expect(atom1.length).toBe(3); - arr.pop(); // one set, one delete + atom1.pop(); // one set, one delete expect(n).toBe(9); - expect(arr).toEqual(["hey", 1]); - expect(arr.length).toBe(2); + expect(atom1).toEqual(["hey", 1]); + expect(atom1.length).toBe(2); - arr.shift(); // 2 sets, 1 delete + atom1.shift(); // 2 sets, 1 delete expect(n).toBe(12); - expect(arr).toEqual([1]); - expect(arr.length).toBe(1); + expect(atom1).toEqual([1]); + expect(atom1.length).toBe(1); }); test("object pushed into arrays are observed", () => { let n = 0; - const arr: any = observe([], () => n++); + const arr: any = createAtom([], () => n++); arr.push({ kriek: 5 }); expect(n).toBe(1); arr[0].kriek = 6; + expect(n).toBe(1); + arr[0].kriek = arr[0].kriek + 6; expect(n).toBe(2); }); test("set new property on observed object", async () => { let n = 0; - const state = observe({}, () => n++) as any; + const atom1 = createAtom({}, () => n++) as any; expect(n).toBe(0); - state.b = 8; + atom1.b = 8; expect(n).toBe(1); - expect(state.b).toBe(8); + expect(atom1.b).toBe(8); }); test("delete property from observed object", async () => { 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); - delete obj.b; + delete atom1.b; 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 () => { let n = 0; - const arr = observe(["a"], () => n++); + const arr = createAtom(["a"], () => n++); arr[1] = "b"; expect(n).toBe(1); @@ -246,69 +404,74 @@ describe("observe", () => { test("properly observe arrays in object", () => { 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(obj.arr.length).toBe(1); + expect(atom1.arr.length).toBe(1); }); test("properly observe objects in array", () => { 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); - obj.arr[0].something = 2; + atom1.arr[0].something = atom1.arr[0].something + 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", () => { 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); - obj.a.b = 2; + atom1.a.b = atom1.a.b + 2; expect(n).toBe(1); }); test("reobserve new object values", () => { let n = 0; - const obj = observe({ a: 1 }, () => n++) as any; + const atom1 = createAtom({ a: 1 }, () => n++) as any; expect(n).toBe(0); - obj.a = { b: 2 }; + atom1.a++; expect(n).toBe(1); - - obj.a.b = 3; + atom1.a = { b: 2 }; expect(n).toBe(2); + + atom1.a.b = atom1.a.b + 3; + expect(n).toBe(3); }); test("deep observe misc changes", () => { 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); - obj.o.a = 2; + atom1.o.a = atom1.o.a + 2; expect(n).toBe(1); - obj.arr.push(2); + atom1.arr.push(2); expect(n).toBe(2); - obj.n = 155; + atom1.n = 155; + expect(n).toBe(2); + atom1.n = atom1.n + 1; expect(n).toBe(3); }); - test("properly handle already observed state", () => { + test("properly handle already observed atom", () => { let n1 = 0; let n2 = 0; - const obj1 = observe({ a: 1 }, () => n1++) as any; - const obj2 = observe({ b: 1 }, () => n2++) as any; + const obj1 = createAtom({ a: 1 }, () => n1++) as any; + const obj2 = createAtom({ b: 1 }, () => n2++) as any; - obj1.a = 2; - obj2.b = 3; + obj1.a = obj1.a + 2; + obj2.b = obj2.b + 3; expect(n1).toBe(1); expect(n2).toBe(1); @@ -318,36 +481,42 @@ describe("observe", () => { obj1.a = 33; expect(n1).toBe(2); + expect(n2).toBe(2); + obj2.b.a = obj2.b.a + 2; + expect(n1).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 n2 = 0; - const obj1 = observe({ a: { c: 2 } }, () => n1++) as any; - const obj2 = observe({ b: 1 }, () => n2++) as any; + const obj1 = createAtom({ a: { c: 2 } }, () => n1++) as any; + const obj2 = createAtom({ b: 1 }, () => n2++) as any; obj2.c = obj1; expect(n1).toBe(0); expect(n2).toBe(1); - obj1.a.c = 33; + obj1.a.c = obj1.a.c + 33; 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); }); test("can reobserve object", () => { let n1 = 0; let n2 = 0; - const obj = observe({ a: 0 }, () => n1++) as any; - obj.a = 1; + const atom1 = createAtom({ a: 0 }, () => n1++) as any; + atom1.a = atom1.a + 1; expect(n1).toBe(1); expect(n2).toBe(0); - const obj2 = observe(obj, () => n2++) as any; - expect(obj).toBe(obj2); + const atom2 = createAtom(atom1, () => n2++) as any; + expect(atom1).toEqual(atom2); - obj.a = 2; + atom2.a = 2; expect(n1).toBe(2); expect(n2).toBe(1); }); @@ -355,205 +524,224 @@ describe("observe", () => { test("can reobserve nested properties in object", () => { let n1 = 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(n2).toBe(0); + atom2.c = 2; + 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", () => { let n1 = 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(n2).toBe(1); - - obj.a[0].b.c = 2; - expect(n1).toBe(2); - expect(n2).toBe(2); + expect(n2).toBe(0); }); test("can observe sub property of observed object", () => { let n1 = 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(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); - obj.c = 14; - expect(n1).toBe(2); + atom1.c = 14; + expect(n1).toBe(3); 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", () => { let n = 0; - const obj = observe({}, () => n++) as any; + const atom1 = createAtom({}, () => n++) as any; - obj.aku = "always finds annoying problems"; - expect(n).toBe(1); + atom1.aky = atom1.aku; - obj.aku = "always finds good problems"; + atom1.aku = "always finds annoying problems"; expect(n).toBe(2); + + atom1.aku = "always finds good problems"; + expect(n).toBe(3); }); test("properly handle swapping elements", () => { 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 - const b = obj.b; - obj.b = obj.a; - obj.a = b; + const b = atom1.b; + atom1.b = atom1.a; + atom1.a = b; expect(n).toBe(2); // push something into array to make sure it works - obj.b.arr.push("blanche"); + atom1.b.arr.push("blanche"); expect(n).toBe(3); }); - test("properly handle assigning observed obj containing array", () => { + test("properly handle assigning observed atom containing array", () => { 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); - obj.a = { ...obj.a, val: "test2" }; + atom1.a = { ...atom1.a, val: "test2" }; expect(n).toBe(1); // push something into array to make sure it works - obj.a.arr.push("blanche"); + atom1.a.arr.push("blanche"); expect(n).toBe(2); }); - test("accept cycles in observed state", () => { + test("accept cycles in observed atom", () => { let n = 0; let obj1: any = {}; let obj2: any = { b: obj1, key: 1 }; obj1.a = obj2; - obj1 = observe(obj1, () => n++) as any; + obj1 = createAtom(obj1, () => n++) as any; obj2 = obj1.a; expect(n).toBe(0); - obj2.key = 3; + obj1.key = 3; expect(n).toBe(1); }); - test.skip("call callback when state is changed", async () => { - /* const observer = new Observer(); - observer.notifyCB = jest.fn(); - const obj: any = observer.observe({ a: 1, b: { c: 2 }, d: [{ e: 3 }], f: 4 }); - - expect(observer.notifyCB).toBeCalledTimes(0); - - obj.a = 2; - await nextMicroTick(); - expect(observer.notifyCB).toBeCalledTimes(1); - - obj.b.c = 3; - await nextMicroTick(); - 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("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; + expect(n).toBe(1); + atom1.b.c = atom1.b.c + 3; + expect(n).toBe(2); + atom1.d[0].e = atom1.d[0].e + 5; + expect(n).toBe(3); + atom1.a = 111; + atom1.f = 222; + expect(n).toBe(4); }); - 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", () => { let n = 0; 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); - unobserve(obj, cb); - obj.a = 4; + + unregisterObserver(); + + atom1.a = 4; expect(n).toBe(1); }); - test("can 'unobserve' primitive values, and dates", () => { - 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", () => { + test("observing some observed atom", () => { let n1 = 0; let n2 = 0; - const inner = observe({ a: 1 }, () => n1++); - const outer = observe({ b: inner }, () => n2++); + const inner = createAtom({ a: 1 }, () => n1++); + const outer = createAtom({ b: inner }, () => n2++); expect(n1).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(n2).toBe(1); }); - test.skip("observing some observed state, variant", () => { + test("observing some observed atom, variant 2", () => { let n1 = 0; let n2 = 0; - const inner = observe({ a: 1 }, () => n1++); - const outer = observe({ b: inner, c: 0 }, () => n2++); - expect(n1).toBe(0); - expect(n2).toBe(0); - inner.a = 2; - expect(n1).toBe(1); - expect(n2).toBe(1); + let n3 = 0; + const obj1 = createAtom({ a: 1 }, () => n1++); + const obj2 = createAtom({ b: {} }, () => n2++); + const obj3 = createAtom({ c: {} }, () => n3++); - 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(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