From a400fc5e69e1da4fa1f7188bb70155b6ac7d87b5 Mon Sep 17 00:00:00 2001 From: Samuel Degueldre Date: Mon, 15 Nov 2021 09:41:23 +0100 Subject: [PATCH] [IMP] reactivity: overhaul reactivity system This commit makes the reactivity system more fine grained and makes it more eager to stop observing keys or objects when they are modified, this results in fewer "false positive" notifications. --- src/component/component_node.ts | 4 +- src/component/error_handling.ts | 2 +- src/reactivity.ts | 479 +++++----- tests/__snapshots__/reactivity.test.ts.snap | 46 + tests/reactivity.test.ts | 923 ++++++++++---------- 5 files changed, 751 insertions(+), 703 deletions(-) diff --git a/src/component/component_node.ts b/src/component/component_node.ts index 8e193af7..897b4c4d 100644 --- a/src/component/component_node.ts +++ b/src/component/component_node.ts @@ -72,7 +72,9 @@ export function getCurrent(): ComponentNode | null { type LifecycleHook = Function; -export class ComponentNode implements VNode { +export class ComponentNode + implements VNode +{ el?: HTMLElement | Text | undefined; app: App; fiber: Fiber | null = null; diff --git a/src/component/error_handling.ts b/src/component/error_handling.ts index 2831a870..1894cfcb 100644 --- a/src/component/error_handling.ts +++ b/src/component/error_handling.ts @@ -26,7 +26,7 @@ function _handleError(node: ComponentNode | null, error: any, isFirstRound = fal h(error); propagate = false; } catch (e) { - error = e as Error; + error = e; } } diff --git a/src/reactivity.ts b/src/reactivity.ts index 7521d754..684b8dbb 100644 --- a/src/reactivity.ts +++ b/src/reactivity.ts @@ -1,132 +1,28 @@ -import { ComponentNode, getCurrent } from "./component/component_node"; import { onWillUnmount } from "./component/lifecycle_hooks"; +import { ComponentNode, getCurrent } from "./component/component_node"; -type Observer = ComponentNode | Function; -type Atom = any; // proxy linked to a unique observer and source -type Source = any; // trackable that is not an atom +// Allows to get the target of a Reactive (used for making a new Reactive from the underlying object) +const TARGET = Symbol("Target"); +// Special key to subscribe to, to be notified of key creation/deletion +const KEYCHANGES = Symbol("Key changes"); -const sourceAtoms: WeakMap> = new WeakMap(); -const observerSourceAtom: WeakMap> = new WeakMap(); +type ObjectKey = string | number | symbol; +type Target = object; +type Callback = () => void; +type Reactive = T & { + [TARGET]: any; +}; -const SOURCE = Symbol("source"); -const OBSERVER = Symbol("observer"); -const KEYS = Symbol("keys"); -const ROOT = Symbol("root"); -const SEED = Symbol("Seed"); - -export function observe(source: any, observer: Observer): [Atom, Function] { - if (!isTrackable(source)) { - throw new Error("First argument is not trackable"); - } - const unregisterObserver = registerObserver(observer); - const newAtom = atom(source, observer); - return [newAtom, unregisterObserver]; -} - -function atom(source: any, observer: Observer) { - return _atom(source, observer, true); -} - -function _atom(source: any, observer: Observer, seed = false) { - if (isTrackable(source) && observerSourceAtom.has(observer)) { - source = (source as any)[SOURCE] || source; - const oldAtom = observerSourceAtom.get(observer)!.get(source); - if (oldAtom) { - if (seed) { - oldAtom[SEED] = true; - } - return oldAtom; - } - if (!sourceAtoms.get(source)) { - sourceAtoms.set(source, new Map([[ROOT, new ObserverSet()]])); - } - const newAtom = createAtom(source, observer); - if (seed) { - newAtom[SEED] = true; - } - observerSourceAtom.get(observer)!.set(source, newAtom); - sourceAtoms.get(source)!.get(ROOT)!.add(newAtom); - return newAtom; - } - return source; -} - -function createAtom(source: Source, observer: Observer): Atom { - const keys: Set = new Set(); - let seed: boolean = false; - const newAtom: Atom = new Proxy(source as any, { - set(target: any, key: any, value: any): boolean { - if (key === SEED) { - seed = value; - return true; - } - if (!(key in target)) { - target[key] = value; - notify(sourceAtoms.get(source)!.get(ROOT)!); - return true; - } - const current = target[key]; - if (current !== value) { - target[key] = value; - const observerSet = sourceAtoms.get(source)!.get(key); - if (observerSet) { - const clean = isTrackable(current); - notify(observerSet, clean); - } - } - return true; - }, - deleteProperty(target: any, key: string): boolean { - if (key in target) { - const current = target[key]; - delete target[key]; - // notify source observers - const clean = isTrackable(current); - notify(sourceAtoms.get(source)!.get(ROOT)!, clean); - const atoms = sourceAtoms.get(source)!; - if (atoms.has(key)) { - // clear source-key observers - for (const atom of atoms.get(key)!) { - atom[KEYS].delete(key); - } - atoms.delete(key); - } - } - return true; - }, - get(target: any, key: any, proxy: any): any { - switch (key) { - case OBSERVER: - return observer; - case SOURCE: - return source; - case KEYS: - return keys; - case SEED: - return seed; - default: - const value = target[key]; - // register observer to source-key - if (!keys.has(key) && observerSourceAtom.has(observer)) { - const atoms = sourceAtoms.get(source)!; - if (!atoms.has(key)) { - atoms.set(key, new ObserverSet()); - } - atoms.get(key)!.add(newAtom); - keys.add(key); - } - // - return _atom(value, observer); - } - }, - }); - return newAtom; -} - -function isTrackable(value: any): boolean { +/** + * Checks whether a given value can be made into a reactive object. + * + * @param value the value to check + * @returns whether the value can be made reactive + */ +function canBeMadeReactive(value: any): boolean { return ( - value !== null && typeof value === "object" && + value !== null && !(value instanceof Date) && !(value instanceof Promise) && !(value instanceof String) && @@ -134,144 +30,211 @@ function isTrackable(value: any): boolean { ); } -function registerObserver(observer: Observer) { - if (!observerSourceAtom.get(observer)) { - observerSourceAtom.set(observer, new Map()); +const targetToKeysToCallbacks = new WeakMap>>(); +/** + * Observes a given key on a target with an callback. The callback will be + * called when the given key changes on the target. + * + * @param target the target whose key should be observed + * @param key the key to observe (or Symbol(KEYCHANGES) for key creation + * or deletion) + * @param callback the function to call when the key changes + */ +function observeTargetKey(target: Target, key: ObjectKey, callback: Callback): void { + if (!targetToKeysToCallbacks.get(target)) { + targetToKeysToCallbacks.set(target, new Map()); } - return unregisterObserverAtoms.bind(null, observer, false); + const keyToCallbacks = targetToKeysToCallbacks.get(target)!; + if (!keyToCallbacks.get(key)) { + keyToCallbacks.set(key, new Set()); + } + keyToCallbacks.get(key)!.add(callback); } - -function unregisterObserverAtoms(observer: Observer, keepSeeds: boolean) { - const observerAtoms = observerSourceAtom.get(observer)!; - for (const [source, atom] of observerAtoms) { - if (keepSeeds && atom[SEED]) { - continue; - } - observerAtoms.delete(source); - const atoms = sourceAtoms.get(source)!; - atoms.get(ROOT)!.delete(atom); - for (const key of atom[KEYS]) { - atoms.get(key)!.delete(atom); - } - } - if (!keepSeeds) { - observerSourceAtom.delete(observer); - } -} - -export function useState(state: any): Atom { - const node = getCurrent()!; - const [newAtom, unregisterObserver] = observe(state, node); - onWillUnmount(() => unregisterObserver()); - return newAtom; -} - -class ObserverSet { - nodeAntichain: Antichain = new Antichain(); - callbackSet: Set = new Set(); - add(atom: Atom) { - if (atom[OBSERVER] instanceof ComponentNode) { - this.nodeAntichain.add(atom); - } else { - this.callbackSet.add(atom); - } - return this; - } - delete(atom: Atom) { - if (atom[OBSERVER] instanceof ComponentNode) { - return this.nodeAntichain.delete(atom); - } else { - return this.callbackSet.delete(atom); - } - } - union(other: ObserverSet) { - for (const atom of other.nodeAntichain) { - this.nodeAntichain.add(atom); - } - for (const callback of other.callbackSet) { - this.callbackSet.add(callback); - } - } - notify() { - for (const atom of this.nodeAntichain) { - // an observer cannot be found twice here: <= in add is based on observers - atom[OBSERVER].render(); - } - const called = new Set(); - for (const atom of this.callbackSet) { - const observer = atom[OBSERVER]; - if (!called.has(observer)) { - observer(); - } - called.add(observer); - } - } - *[Symbol.iterator]() { - yield* this.nodeAntichain; - yield* this.callbackSet; - } -} - -// Need to optimize this! -function isLessOrEqual(node1: ComponentNode, node2: ComponentNode) { - let current: any = node1; - if (current.level <= node2.level) { - return false; - } - do { - if (current === node2) { - return true; - } - current = current.parent; - } while (current); - return false; -} - -// set of atoms linked to observers of type ComponentNode -class Antichain extends Set { - level?: number; - add(atom: Atom) { - const node = atom[OBSERVER]; - if (this.level === node.level || this.level === undefined) { - super.add(atom); - this.level = node.level; - return this; - } - let willAdd = false; - for (const atom2 of this) { - const node2 = atom2[OBSERVER]; - if (!willAdd && isLessOrEqual(node, node2)) { - return this; - } else if (isLessOrEqual(node2, node)) { - super.delete(atom2); - willAdd = true; - } - } - super.add(atom); - this.level = NaN; - return this; - } -} - -let toNotify: ObserverSet | null = null; -let toClean: Set = new Set(); -async function notify(observers: ObserverSet, clean = false) { - if (clean) { - for (const atom of observers) { - toClean.add(atom[OBSERVER]); - } - } - if (toNotify) { - toNotify.union(observers); +/** + * Notify Reactives that are observing a given target that a key has changed on + * the target. + * + * @param target target whose Reactives should be notified that the target was + * changed. + * @param key the key that changed (or Symbol `KEYCHANGES` if a key was created + * or deleted) + */ +function notifyReactives(target: Target, key: ObjectKey): void { + const keyToCallbacks = targetToKeysToCallbacks.get(target); + if (!keyToCallbacks) { return; } - toNotify = new ObserverSet(); - toNotify.union(observers); - await Promise.resolve(); - for (const observer of toClean) { - unregisterObserverAtoms(observer, true); + const callbacks = keyToCallbacks.get(key); + if (!callbacks) { + return; + } + // Loop on copy because clearReactivesForCallback will modify the set in place + for (const callback of [...callbacks]) { + clearReactivesForCallback(callback); + callback(); } - toClean.clear(); - toNotify.notify(); - toNotify = null; +} + +const callbacksToTargets = new WeakMap>(); +/** + * Clears all subscriptions of the Reactives associated with a given callback. + * + * @param callback the callback for which the reactives need to be cleared + */ +function clearReactivesForCallback(callback: Callback): void { + const targetsToClear = callbacksToTargets.get(callback); + if (!targetsToClear) { + return; + } + for (const target of targetsToClear) { + const observedKeys = targetToKeysToCallbacks.get(target); + if (!observedKeys) { + continue; + } + for (const callbacks of observedKeys.values()) { + callbacks.delete(callback); + } + } +} + +const reactiveCache = new WeakMap>(); +/** + * Creates a reactive proxy for an object. Reading data on the reactive object + * subscribes to changes to the data. Writing data on the object will cause the + * notify callback to be called if there are suscriptions to that data. Nested + * objects and arrays are automatically made reactive as well. + * + * Whenever you are notified of a change, all subscriptions are cleared, and if + * you would like to be notified of any further changes, you should go read + * the underlying data again. We assume that if you don't go read it again after + * being notified, it means that you are no longer interested in that data. + * + * Subscriptions: + * + Reading a property on an object will subscribe you to changes in the value + * of that property. + * + Accessing an object keys (eg with Object.keys or with `for..in`) will + * subscribe you to the creation/deletion of keys. Checking the presence of a + * key on the object with 'in' has the same effect. + * - getOwnPropertyDescriptor does not currently subscribe you to the property. + * This is a choice that was made because changing a key's value will trigger + * this trap and we do not want to subscribe by writes. This also means that + * Object.hasOwnProperty doesn't subscribe as it goes through this trap. + * + * @param target the object for which to create a reactive proxy + * @param callback the function to call when an observed property of the + * reactive has changed + * @returns a proxy that tracks changes to it + */ +export function reactive(target: T, callback: Callback): Reactive { + if (!canBeMadeReactive(target)) { + throw new Error(`Cannot make the given value reactive`); + } + const originalTarget = (target as Reactive)[TARGET]; + if (originalTarget) { + return reactive(originalTarget, callback); + } + if (!reactiveCache.has(target)) { + reactiveCache.set(target, new Map()); + } + const reactivesForTarget = reactiveCache.get(target)!; + if (!reactivesForTarget.has(callback)) { + const proxy = new Proxy(target, { + get(target: any, key: ObjectKey, proxy: Reactive) { + if (key === TARGET) { + return target; + } + observeTargetKey(target, key, callback); + const value = Reflect.get(target, key, proxy); + if (!canBeMadeReactive(value)) { + return value; + } + return reactive(value, callback); + }, + set(target, key, value, proxy) { + const isNewKey = !Object.hasOwnProperty.call(target, key); + const originalValue = Reflect.get(target, key, proxy); + const ret = Reflect.set(target, key, value, proxy); + if (isNewKey) { + notifyReactives(target, KEYCHANGES); + } + // While Array length may trigger the set trap, it's not actually set by this + // method but is updated behind the scenes, and the trap is not called with the + // new value. We disable the "same-value-optimization" for it because of that. + if (originalValue !== value || (Array.isArray(target) && key === "length")) { + notifyReactives(target, key); + } + return ret; + }, + deleteProperty(target, key) { + const ret = Reflect.deleteProperty(target, key); + notifyReactives(target, KEYCHANGES); + notifyReactives(target, key); + return ret; + }, + ownKeys(target) { + observeTargetKey(target, KEYCHANGES, callback); + return Reflect.ownKeys(target); + }, + has(target, key) { + // TODO: this observes all key changes instead of only the presence of the argument key + observeTargetKey(target, KEYCHANGES, callback); + return Reflect.has(target, key); + }, + }); + reactivesForTarget.set(callback, proxy); + if (!callbacksToTargets.has(callback)) { + callbacksToTargets.set(callback, new Set()); + } + callbacksToTargets.get(callback)!.add(target); + } + return reactivesForTarget.get(callback) as Reactive; +} + +/** + * Creates a batched version of a callback so that all calls to it in the same + * microtick will only call the original callback once. + * + * @param callback the callback to batch + * @returns a batched version of the original callback + */ +export function batched(callback: Callback): Callback { + let called = false; + return async () => { + // This await blocks all calls to the callback here, then releases them sequentially + // in the next microtick. This line decides the granularity of the batch. + await Promise.resolve(); + if (!called) { + called = true; + callback(); + // wait for all calls in this microtick to fall through before resetting "called" + // so that only the first call to the batched function calls the original callback + await Promise.resolve(); + called = false; + } + }; +} + +const batchedRenderFunctions = new WeakMap(); +/** + * Creates a reactive object that will be observed by the current component. + * Reading data from the returned object (eg during rendering) will cause the + * component to subscribe to that data and be rerendered when it changes. + * + * @param state the state to observe + * @returns a reactive object that will cause the component to re-render on + * relevant changes + * @see reactive + */ +export function useState(state: T): Reactive { + const node = getCurrent()!; + if (!batchedRenderFunctions.has(node)) { + batchedRenderFunctions.set( + node, + batched(() => node.render()) + ); + } + const render = batchedRenderFunctions.get(node)!; + const reactiveState = reactive(state, render); + onWillUnmount(() => clearReactivesForCallback(render)); + return reactiveState; } diff --git a/tests/__snapshots__/reactivity.test.ts.snap b/tests/__snapshots__/reactivity.test.ts.snap index 2a39c00a..92f063f4 100644 --- a/tests/__snapshots__/reactivity.test.ts.snap +++ b/tests/__snapshots__/reactivity.test.ts.snap @@ -1,5 +1,51 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Reactivity: useState concurrent renderings 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, toNumber, safeOutput } = helpers; + + let block1 = createBlock(\`\`); + + return function template(ctx, node, key = \\"\\") { + let d1 = ctx['context'][ctx['props'].key].n; + let d2 = ctx['state'].x; + return block1([d1, d2]); + } +}" +`; + +exports[`Reactivity: useState concurrent renderings 2`] = ` +"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, toNumber, safeOutput } = helpers; + + let block1 = createBlock(\`

\`); + + return function template(ctx, node, key = \\"\\") { + let b2 = component(\`ComponentC\`, {key: ctx['props'].key}, key + \`__1\`, node, ctx); + return block1([], [b2]); + } +}" +`; + +exports[`Reactivity: useState concurrent renderings 3`] = ` +"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, toNumber, safeOutput } = helpers; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let b2 = component(\`ComponentB\`, {key: ctx['context'].key}, key + \`__1\`, node, ctx); + return block1([], [b2]); + } +}" +`; + exports[`Reactivity: useState destroyed component before being mounted is inactive 1`] = ` "function anonymous(bdom, helpers ) { diff --git a/tests/reactivity.test.ts b/tests/reactivity.test.ts index b5752099..40687b18 100644 --- a/tests/reactivity.test.ts +++ b/tests/reactivity.test.ts @@ -1,5 +1,5 @@ -import { Component, mount, onRender, onWillStart, onWillUpdateProps } from "../src"; -import { observe, useState } from "../src/reactivity"; +import { Component, mount, onRender, onWillStart, onWillUpdateProps, useState } from "../src"; +import { batched, reactive } from "../src/reactivity"; import { xml } from "../src/tags"; import { makeDeferred, @@ -9,69 +9,75 @@ import { snapshotEverything, } from "./helpers"; -function createAtom(value: any, observer: any = () => {}) { - const [atom] = observe(value, observer); - return atom; +function createReactive(value: any, observer: any = () => {}) { + return reactive(value, observer); } -describe("Reactivity: atom", () => { +describe("Reactivity", () => { test("can read", async () => { - const atom1 = createAtom({ a: 1 }); - expect(atom1.a).toBe(1); + const state = createReactive({ a: 1 }); + expect(state.a).toBe(1); }); - test("can write", () => { - const atom1 = createAtom({}); - atom1.a = 1; - expect(atom1.a).toBe(1); + test("can create new keys", () => { + const state = createReactive({}); + state.a = 1; + expect(state.a).toBe(1); }); test("can update", () => { - const atom1 = createAtom({ a: 1 }); - atom1.a = 2; - expect(atom1.a).toBe(2); + const state = createReactive({ a: 1 }); + state.a = 2; + expect(state.a).toBe(2); }); - test("can delete", () => { - const atom1 = createAtom({ a: 1 }); - delete atom1.a; - expect(atom1.a).toBeUndefined(); + test("can delete existing keys", () => { + const state = createReactive({ a: 1 }); + delete state.a; + expect(state).not.toHaveProperty("a"); }); 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"); + const state = createReactive({ a: 1 }); + expect(Object.keys(state)).toEqual(["a"]); + expect(Object.values(state)).toEqual([1]); + expect(typeof state).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); + const state = createReactive(["a", "b"]); + expect(state.length).toBe(2); + expect(state).toEqual(["a", "b"]); + expect(typeof state).toBe("object"); + expect(Array.isArray(state)).toBe(true); }); test("Throw error if value is not proxifiable", () => { - expect(() => createAtom(1)).toThrow("First argument is not trackable"); + expect(() => createReactive(1)).toThrow("Cannot make the given value reactive"); }); - test("atom observer is called properly", async () => { + test("callback is called when changing an observed property 1", async () => { let n = 0; - const atom1 = createAtom({ a: 1 }, () => n++); - atom1.a = 2; - await nextMicroTick(); + const state = createReactive({ a: 1 }, () => n++); + state.a = 2; expect(n).toBe(0); // key has not be read yet - atom1.a = atom1.a + 5; // key is read and then modified - await nextMicroTick(); + state.a = state.a + 5; // key is read and then modified expect(n).toBe(1); }); - test("atom on an object with a getter 1", async () => { + test("callback is called when changing an observed property 2", async () => { + let n = 0; + const state = createReactive({ a: { k: 1 } }, () => n++); + state.a.k = state.a.k + 1; + expect(n).toBe(1); + state.k = 2; // observer has been interested specifically to key k of a! + expect(n).toBe(1); + }); + + test("reactive from object with a getter 1", async () => { let n = 0; let value = 1; - const atom = createAtom( + const state = createReactive( { get a() { return value; @@ -82,15 +88,15 @@ describe("Reactivity: atom", () => { }, () => n++ ); - atom.a = atom.a + 4; + state.a = state.a + 4; await nextMicroTick(); expect(n).toBe(1); }); - test("atom on an object with a getter 2", async () => { + test("reactive from object with a getter 2", async () => { let n = 0; let value = { b: 1 }; - const atom = createAtom( + const state = createReactive( { get a() { return value; @@ -98,21 +104,21 @@ describe("Reactivity: atom", () => { }, () => n++ ); - expect(atom.a.b).toBe(1); - atom.a.b = 2; + expect(state.a.b).toBe(1); + state.a.b = 2; await nextMicroTick(); expect(n).toBe(1); }); - test("atom on an object with a getter 3", async () => { + test("reactive from object with a getter 3", async () => { let n = 0; - const values: { b: number }[] = createAtom([]); + const values: { b: number }[] = createReactive([]); function createValue() { const o = { b: values.length }; values.push(o); return o; } - const atom = createAtom( + const reactive = createReactive( { get a() { return createValue(); @@ -121,24 +127,62 @@ describe("Reactivity: atom", () => { () => n++ ); for (let i = 0; i < 10; i++) { - expect(atom.a.b).toEqual(i); + expect(reactive.a.b).toEqual(i); } expect(n).toBe(0); values[0].b = 3; - await nextMicroTick(); - expect(n).toBe(1); // !!! atoms for each object in values are still there !!! + expect(n).toBe(1); // !!! reactives for each object in values are still there !!! + values[0].b = 4; + expect(n).toBe(1); // reactives for each object in values were cleaned up by the previous write }); - test("atom observer is called after batch of operation", async () => { + test("Operator 'in' causes key's presence to be observed", async () => { let n = 0; - const atom1 = createAtom({ a: 1, b: 2 }, () => n++); - atom1.a = 2; + const state = createReactive({}, () => n++); + + "a" in state; + state.a = 2; + expect(n).toBe(1); + + "a" in state; + state.a = 3; // Write on existing property shouldn't notify + expect(n).toBe(1); + + "a" in state; + delete state.a; + expect(n).toBe(2); + }); + + test.skip("hasOwnProperty causes the key's presence to be observed", async () => { + let n = 0; + const state = createReactive({}, () => n++); + + Object.hasOwnProperty.call(state, "a"); + state.a = 2; + expect(n).toBe(1); + + Object.hasOwnProperty.call(state, "a"); + state.a = 3; + expect(n).toBe(1); + + Object.hasOwnProperty.call(state, "a"); + delete state.a; + expect(n).toBe(2); + }); + + test("batched: callback is called after batch of operation", async () => { + let n = 0; + const state = createReactive( + { a: 1, b: 2 }, + batched(() => n++) + ); + state.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 + state.a = state.a + 5; // key is read and then modified expect(n).toBe(0); - atom1.b = atom1.b + 5; // key is read and then modified + state.b = state.b + 5; // key is read and then modified expect(n).toBe(0); await nextMicroTick(); expect(n).toBe(1); // two operations but only one notification @@ -146,12 +190,10 @@ describe("Reactivity: atom", () => { 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(); + const state = createReactive({ a: 1 }, () => n++); + state.a = state.a + 5; // read and modifies property a to have value 6 expect(n).toBe(1); - atom1.a = 6; // same value - await nextMicroTick(); + state.a = 6; // same value expect(n).toBe(1); }); @@ -160,197 +202,188 @@ describe("Reactivity: atom", () => { a.a = a; let n = 0; - const atom1 = createAtom(a, () => n++); + const state = createReactive(a, () => n++); - atom1.k = 2; - await nextMicroTick(); + state.k; + state.k = 2; expect(n).toBe(1); - delete atom1.l; - await nextMicroTick(); + delete state.l; expect(n).toBe(1); - delete atom1.k; - await nextMicroTick(); + state.k; + delete state.k; expect(n).toBe(2); - atom1.a = 1; - await nextMicroTick(); + state.a = 1; expect(n).toBe(2); - atom1.a = atom1.a + 5; - await nextMicroTick(); + state.a = state.a + 5; expect(n).toBe(3); }); + test("equality", async () => { + const a = { a: {}, b: 1 }; + a.a = a; + let n = 0; + const state = createReactive(a, () => n++); + expect(state).toBe(state.a); + expect(n).toBe(0); + (state.b = state.b + 1), expect(n).toBe(1); + expect(state).toBe(state.a); + }); + 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++); + const state = createReactive(obj, () => m++); + const state2 = createReactive(obj, () => n++); obj.new = 2; - await nextMicroTick(); expect(m).toBe(0); expect(n).toBe(0); - atom1.new = 2; // already exists! - await nextMicroTick(); + state.new = 2; // already exists! expect(m).toBe(0); expect(n).toBe(0); - atom1.veryNew = 2; - await nextMicroTick(); + state.veryNew; + state2.veryNew; + state.veryNew = 2; expect(m).toBe(1); expect(n).toBe(1); - atom1.a = atom1.a + 5; - await nextMicroTick(); + state.a = state.a + 5; expect(m).toBe(2); expect(n).toBe(1); - atom2.a = atom2.a + 5; - await nextMicroTick(); + state.a; + state2.a = state2.a + 5; expect(m).toBe(3); expect(n).toBe(2); - delete atom2.veryNew; - await nextMicroTick(); + state.veryNew; + state2.veryNew; + delete state2.veryNew; expect(m).toBe(4); expect(n).toBe(3); }); - test("create atom from another atom", async () => { + test("create reactive from another", async () => { let n = 0; - const atom1 = createAtom({ a: 1 }); - const atom2 = createAtom(atom1, () => n++); - atom2.a = atom2.a + 5; - await nextMicroTick(); + const state = createReactive({ a: 1 }); + const state2 = createReactive(state, () => n++); + state2.a = state2.a + 5; expect(n).toBe(1); - atom1.a = 2; - await nextMicroTick(); + state2.a; + state.a = 2; expect(n).toBe(2); }); - test("create atom from another atom 2", async () => { + test("create reactive from another 2", async () => { let n = 0; - const atom1 = createAtom({ a: 1 }); - const atom2 = createAtom(atom1, () => n++); - atom1.a = atom2.a + 5; - await nextMicroTick(); + const state = createReactive({ a: 1 }); + const state2 = createReactive(state, () => n++); + state.a = state2.a + 5; expect(n).toBe(1); - atom2.a = atom2.a + 5; - await nextMicroTick(); + state2.a = state2.a + 5; expect(n).toBe(2); }); - test("create atom from another atom 3", async () => { + test("create reactive from another 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(); + const state = createReactive({ a: 1 }); + const state2 = createReactive(state, () => n++); + state.a = state.a + 5; + expect(n).toBe(0); // state2.a was not yet read + state2.a = state2.a + 5; + state2.a; + expect(n).toBe(1); // state2.a has been read and is now observed + state.a = state.a + 5; expect(n).toBe(2); }); - test("immediately returns primitive values", () => { - expect(() => createAtom(1)).toThrowError(); - expect(() => createAtom("asf")).toThrowError(); - expect(() => createAtom(true)).toThrowError(); - expect(() => createAtom(null)).toThrowError(); - expect(() => createAtom(undefined)).toThrowError(); + test("throws on primitive values", () => { + expect(() => createReactive(1)).toThrowError(); + expect(() => createReactive("asf")).toThrowError(); + expect(() => createReactive(true)).toThrowError(); + expect(() => createReactive(null)).toThrowError(); + expect(() => createReactive(undefined)).toThrowError(); }); - test("immediately returns dates", () => { + test("throws on dates", () => { const date = new Date(); - expect(() => createAtom(date)).toThrow("First argument is not trackable"); + expect(() => createReactive(date)).toThrow("Cannot make the given value reactive"); }); test("can observe object with some key set to null", async () => { let n = 0; - const atom1 = createAtom({ a: { b: null } } as any, () => n++); + const state = createReactive({ a: { b: null } } as any, () => n++); expect(n).toBe(0); - atom1.a.b = Boolean(atom1.a.b); - await nextMicroTick(); + state.a.b = Boolean(state.a.b); expect(n).toBe(1); }); test("can reobserve object with some key set to null", async () => { let n = 0; const fn = () => n++; - const [atom1, unregisterObserver] = observe({ a: { b: null } } as any, fn); - const atom2 = createAtom(atom1, fn); - expect(atom2).toBe(atom1); - expect(atom2).toEqual(atom1); - await nextMicroTick(); + const state = createReactive({ a: { b: null } } as any, fn); + const state2 = createReactive(state, fn); + expect(state2).toBe(state); + expect(state2).toEqual(state); 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(); + state.a.b = Boolean(state.a.b); 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(); + const state = createReactive({ a: 1, b: 2 }); + expect(state.a).toBe(1); + expect(state.b).toBe(2); + expect((state as any).c).toBeUndefined(); }); test("detect object value changes", async () => { let n = 0; - const atom1 = createAtom({ a: 1 }, () => n++) as any; + const state = createReactive({ a: 1 }, () => n++) as any; expect(n).toBe(0); - atom1.a = atom1.a + 5; - await nextMicroTick(); + state.a = state.a + 5; expect(n).toBe(1); - atom1.b = atom1.b + 5; - await nextMicroTick(); + state.b = state.b + 5; expect(n).toBe(2); - atom1.a = null; - atom1.b = undefined; - await nextMicroTick(); + state.a; + state.b; + state.a = null; + state.b = undefined; expect(n).toBe(3); - expect(atom1).toEqual({ a: null, b: undefined }); + expect(state).toEqual({ a: null, b: undefined }); }); test("properly handle dates", async () => { const date = new Date(); let n = 0; - const atom1 = createAtom({ date }, () => n++); + const state = createReactive({ date }, () => n++); - await nextMicroTick(); - expect(typeof atom1.date.getFullYear()).toBe("number"); - expect(atom1.date).toBe(date); + expect(typeof state.date.getFullYear()).toBe("number"); + expect(state.date).toBe(date); - atom1.date = new Date(); - await nextMicroTick(); + state.date = new Date(); expect(n).toBe(1); - expect(atom1.date).not.toBe(date); + expect(state.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++); + const state = createReactive({ prom: Promise.resolve() }, () => n++); - expect(atom1.prom).toBeInstanceOf(Promise); - atom1.prom.then(() => (resolved = true)); + expect(state.prom).toBeInstanceOf(Promise); + state.prom.then(() => (resolved = true)); expect(n).toBe(0); expect(resolved).toBe(false); await Promise.resolve(); @@ -360,295 +393,316 @@ describe("Reactivity: atom", () => { test("can observe value change in array in an object", async () => { let n = 0; - const atom1 = createAtom({ arr: [1, 2] }, () => n++) as any; + const state = createReactive({ arr: [1, 2] }, () => n++) as any; - expect(Array.isArray(atom1.arr)).toBe(true); + expect(Array.isArray(state.arr)).toBe(true); expect(n).toBe(0); - atom1.arr[0] = atom1.arr[0] + "nope"; - await nextMicroTick(); + state.arr[0] = state.arr[0] + "nope"; expect(n).toBe(1); - expect(atom1.arr[0]).toBe("1nope"); - expect(atom1.arr).toEqual(["1nope", 2]); + expect(state.arr[0]).toBe("1nope"); + expect(state.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; + const state = createReactive({ arr: [1, 2] }, () => n++) as any; - expect(Array.isArray(atom1.arr)).toBe(true); + expect(Array.isArray(state.arr)).toBe(true); expect(n).toBe(0); - atom1.arr = [2, 1]; - await nextMicroTick(); + state.arr = [2, 1]; expect(n).toBe(1); - expect(atom1.arr[0]).toBe(2); - expect(atom1.arr).toEqual([2, 1]); + expect(state.arr[0]).toBe(2); + expect(state.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; + test("getting the same property twice returns the same object", () => { + const state = createReactive({ a: { b: 1 } }); + const a1 = state.a; + const a2 = state.a; expect(a1).toBe(a2); }); test("various object property changes", async () => { let n = 0; - const atom1 = createAtom({ a: 1 }, () => n++) as any; + const state = createReactive({ a: 1 }, () => n++) as any; expect(n).toBe(0); - atom1.a = atom1.a + 2; - await nextMicroTick(); + state.a = state.a + 2; expect(n).toBe(1); - // same value again - atom1.a = 3; - await nextMicroTick(); + state.a; + // same value again: no notification + state.a = 3; expect(n).toBe(1); - atom1.a = 4; - await nextMicroTick(); + state.a = 4; expect(n).toBe(2); }); test("properly observe arrays", async () => { let n = 0; - const atom1 = createAtom([], () => n++) as any; + const state = createReactive([], () => n++) as any; - expect(Array.isArray(atom1)).toBe(true); - expect(atom1.length).toBe(0); + expect(Array.isArray(state)).toBe(true); + expect(state.length).toBe(0); expect(n).toBe(0); - atom1.push(1); - await nextMicroTick(); + state.push(1); expect(n).toBe(1); - expect(atom1.length).toBe(1); - expect(atom1).toEqual([1]); + expect(state.length).toBe(1); + expect(state).toEqual([1]); - atom1.splice(1, 0, "hey"); - await nextMicroTick(); + state.splice(1, 0, "hey"); expect(n).toBe(2); - expect(atom1).toEqual([1, "hey"]); - expect(atom1.length).toBe(2); + expect(state).toEqual([1, "hey"]); + expect(state.length).toBe(2); - atom1.unshift("lindemans"); - await nextMicroTick(); - //it generates 3 primitive operations + // clear all observations caused by previous expects + state[0] = 2; 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(); + state.unshift("lindemans"); + // unshift generates the following sequence of operations: (observed keys in brackets) + // - read 'unshift' => { unshift } + // - read 'length' => { unshift , length } + // - hasProperty '1' => { unshift , length, [KEYCHANGES] } + // - read '1' => { unshift , length, 1 } + // - write "hey" on '2' => notification for key creation, {} + // - hasProperty '0' => { [KEYCHANGES] } + // - read '0' => { 0, [KEYCHANGES] } + // - write "2" on '1' => not observing '1', no notification + // - write "lindemans" on '0' => notification, stop observing {} + // - write 3 on 'length' => not observing 'length', no notification expect(n).toBe(5); - expect(atom1).toEqual(["hey", 1]); - expect(atom1.length).toBe(2); + expect(state).toEqual(["lindemans", 2, "hey"]); + expect(state.length).toBe(3); - atom1.shift(); // 2 sets, 1 delete - await nextMicroTick(); + // clear all observations caused by previous expects + state[1] = 3; expect(n).toBe(6); - expect(atom1).toEqual([1]); - expect(atom1.length).toBe(1); + + state.reverse(); + // Reverse will generate floor(length/2) notifications because it swaps elements pair-wise + expect(n).toBe(7); + expect(state).toEqual(["hey", 3, "lindemans"]); + expect(state.length).toBe(3); + + state.pop(); // reads '2', deletes '2', sets length. Only delete triggers a notification + expect(n).toBe(8); + expect(state).toEqual(["hey", 3]); + expect(state.length).toBe(2); + + state.shift(); // reads '0', reads '1', sets '0', sets length. Only set '0' triggers a notification + expect(n).toBe(9); + expect(state).toEqual([3]); + expect(state.length).toBe(1); }); test("object pushed into arrays are observed", async () => { let n = 0; - const arr: any = createAtom([], () => n++); + const arr: any = createReactive([], () => 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; + let keys: string[] = []; + const notify = () => { + n++; + keys.splice(0); + keys.push(...Object.keys(state)); + }; + const state = createReactive({}, notify) as any; + Object.keys(state); expect(n).toBe(0); - atom1.b = 8; - await nextMicroTick(); + state.b = 8; expect(n).toBe(1); - expect(atom1.b).toBe(8); + expect(state.b).toBe(8); + expect(keys).toEqual(["b"]); + }); + + test("set new property object when key changes are not observed", async () => { + let n = 0; + const notify = () => n++; + const state = createReactive({ a: 1 }, notify) as any; + state.a; + expect(n).toBe(0); + + state.b = 8; + expect(n).toBe(0); // Not observing key changes: shouldn't get notified + expect(state.b).toBe(8); + expect(state).toEqual({ a: 1, b: 8 }); }); test("delete property from observed object", async () => { let n = 0; - const atom1 = createAtom({ a: 1, b: 8 }, () => n++) as any; + const state = createReactive({ a: 1, b: 8 }, () => n++) as any; + Object.keys(state); expect(n).toBe(0); - delete atom1.b; - await nextMicroTick(); + delete state.b; expect(n).toBe(1); - expect(atom1).toEqual({ a: 1 }); + expect(state).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; + const state = createReactive(obj.a, observer) as any; + const state2 = createReactive(obj, observer) as any; + expect(state2.a).toBe(state); expect(n).toBe(0); - delete atom2.a; - await nextMicroTick(); - // key "a" is no longer observed + Object.keys(state2); + delete state2.a; expect(n).toBe(1); - atom1.new = 2; // but { b: 1 } is still observed! - await nextMicroTick(); + Object.keys(state); + state.new = 2; expect(n).toBe(2); }); test("set element in observed array", async () => { let n = 0; - const arr = createAtom(["a"], () => n++); - + const arr = createReactive(["a"], () => n++); + arr[1]; 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; + const state = createReactive({ arr: [] }, () => n++) as any; - expect(atom1.arr.length).toBe(0); + expect(state.arr.length).toBe(0); expect(n).toBe(0); - atom1.arr.push(1); - await nextMicroTick(); + state.arr.push(1); expect(n).toBe(1); - expect(atom1.arr.length).toBe(1); + expect(state.arr.length).toBe(1); }); test("properly observe objects in array", async () => { let n = 0; - const atom1 = createAtom({ arr: [{ something: 1 }] }, () => n++) as any; + const state = createReactive({ arr: [{ something: 1 }] }, () => n++) as any; expect(n).toBe(0); - atom1.arr[0].something = atom1.arr[0].something + 1; - await nextMicroTick(); + state.arr[0].something = state.arr[0].something + 1; expect(n).toBe(1); - expect(atom1.arr[0].something).toBe(2); + expect(state.arr[0].something).toBe(2); }); test("properly observe objects in object", async () => { let n = 0; - const atom1 = createAtom({ a: { b: 1 } }, () => n++) as any; + const state = createReactive({ a: { b: 1 } }, () => n++) as any; expect(n).toBe(0); - atom1.a.b = atom1.a.b + 2; - await nextMicroTick(); + state.a.b = state.a.b + 2; expect(n).toBe(1); }); + test("Observing the same object through the same reactive preserves referential equality", async () => { + const o = {} as any; + o.o = o; + const state = createReactive(o); + expect(state.o).toBe(state); + expect(state.o.o).toBe(state); + }); + test("reobserve new object values", async () => { let n = 0; - const atom1 = createAtom({ a: 1 }, () => n++) as any; + const state = createReactive({ a: 1 }, () => n++) as any; expect(n).toBe(0); - atom1.a++; - await nextMicroTick(); + state.a++; + state.a; expect(n).toBe(1); - atom1.a = { b: 2 }; - await nextMicroTick(); + state.a = { b: 2 }; expect(n).toBe(2); - atom1.a.b = atom1.a.b + 3; - await nextMicroTick(); + state.a.b = state.a.b + 3; 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; + const state = createReactive({ o: { a: 1 }, arr: [1], n: 13 }, () => n++) as any; expect(n).toBe(0); - atom1.o.a = atom1.o.a + 2; - await nextMicroTick(); + state.o.a = state.o.a + 2; expect(n).toBe(1); - atom1.arr.push(2); - await nextMicroTick(); + state.arr.push(2); expect(n).toBe(2); - atom1.n = 155; - await nextMicroTick(); + state.n = 155; expect(n).toBe(2); - atom1.n = atom1.n + 1; - await nextMicroTick(); + state.n = state.n + 1; expect(n).toBe(3); }); - test("properly handle already observed atom", async () => { + test("properly handle already observed object", async () => { let n1 = 0; let n2 = 0; - const obj1 = createAtom({ a: 1 }, () => n1++) as any; - const obj2 = createAtom({ b: 1 }, () => n2++) as any; + const obj1 = createReactive({ a: 1 }, () => n1++) as any; + const obj2 = createReactive({ 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; obj2.b = obj1; - await nextMicroTick(); expect(n1).toBe(1); expect(n2).toBe(2); + obj1.a; obj1.a = 33; - await nextMicroTick(); expect(n1).toBe(2); expect(n2).toBe(2); + obj1.a; 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 () => { + test("properly handle already observed object in observed object", async () => { let n1 = 0; let n2 = 0; - const obj1 = createAtom({ a: { c: 2 } }, () => n1++) as any; - const obj2 = createAtom({ b: 1 }, () => n2++) as any; + const obj1 = createReactive({ a: { c: 2 } }, () => n1++) as any; + const obj2 = createReactive({ b: 1 }, () => n2++) as any; + obj2.c; obj2.c = obj1; - await nextMicroTick(); expect(n1).toBe(0); expect(n2).toBe(1); obj1.a.c = obj1.a.c + 33; - await nextMicroTick(); + obj1.a.c; 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); }); @@ -656,18 +710,16 @@ describe("Reactivity: atom", () => { test("can reobserve object", async () => { let n1 = 0; let n2 = 0; - const atom1 = createAtom({ a: 0 }, () => n1++) as any; + const state = createReactive({ a: 0 }, () => n1++) as any; - atom1.a = atom1.a + 1; - await nextMicroTick(); + state.a = state.a + 1; expect(n1).toBe(1); expect(n2).toBe(0); - const atom2 = createAtom(atom1, () => n2++) as any; - expect(atom1).toEqual(atom2); + const state2 = createReactive(state, () => n2++) as any; + expect(state).toEqual(state2); - atom2.a = 2; - await nextMicroTick(); + state2.a = 2; expect(n1).toBe(2); expect(n2).toBe(1); }); @@ -675,42 +727,40 @@ describe("Reactivity: atom", () => { test("can reobserve nested properties in object", async () => { let n1 = 0; let n2 = 0; - const atom1 = createAtom({ a: [{ b: 1 }] }, () => n1++) as any; + const state = createReactive({ a: [{ b: 1 }] }, () => n1++) as any; - const atom2 = createAtom(atom1, () => n2++) as any; + const state2 = createReactive(state, () => n2++) as any; - atom1.a[0].b = atom1.a[0].b + 2; - await nextMicroTick(); + state.a[0].b = state.a[0].b + 2; expect(n1).toBe(1); expect(n2).toBe(0); - atom2.c = 2; - await nextMicroTick(); + state.c; + state2.c; + state2.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; + const state = createReactive({ a: { b: 1 } }); + const obj1 = state.a; + const obj2 = state.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; + const state = createReactive({ a: [{ b: 1 }] }, () => n1++) as any; - createAtom(atom1, () => n2++) as any; + createReactive(state, () => n2++) as any; - atom1.a[0].b = { c: 1 }; - await nextMicroTick(); + state.a[0].b = { c: 1 }; expect(n1).toBe(0); expect(n2).toBe(0); - atom1.a[0].b.c = atom1.a[0].b.c + 2; - await nextMicroTick(); + state.a[0].b.c = state.a[0].b.c + 2; expect(n1).toBe(1); expect(n2).toBe(0); }); @@ -718,325 +768,312 @@ describe("Reactivity: atom", () => { 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 state = createReactive({ a: { b: 1 }, c: 1 }, () => n1++) as any; - const atom2 = createAtom(atom1.a, () => n2++) as any; + const state2 = createReactive(state.a, () => n2++) as any; - atom1.a.b = atom1.a.b + 2; - await nextMicroTick(); + state.a.b = state.a.b + 2; expect(n1).toBe(1); expect(n2).toBe(0); - atom1.l = 2; - await nextMicroTick(); + state.l; + state.l = 2; expect(n1).toBe(2); expect(n2).toBe(0); - atom1.a.k = 3; - await nextMicroTick(); + state.a.k; + state2.k; + state.a.k = 3; expect(n1).toBe(3); expect(n2).toBe(1); - atom1.c = 14; - await nextMicroTick(); + state.c = 14; expect(n1).toBe(3); expect(n2).toBe(1); - atom2.b = atom2.b + 3; - await nextMicroTick(); + state.a.b; + state2.b = state2.b + 3; 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; + const state = createReactive({}, () => n++) as any; - atom1.aky = atom1.aku; + state.aky = state.aku; expect(n).toBe(0); - atom1.aku = "always finds annoying problems"; - expect(n).toBe(0); - await nextMicroTick(); + state.aku = "always finds annoying problems"; expect(n).toBe(1); - atom1.aku = "always finds good problems"; - await nextMicroTick(); + state.aku; + state.aku = "always finds good problems"; expect(n).toBe(2); }); test("properly handle swapping elements", async () => { let n = 0; - const atom1 = createAtom({ a: { arr: [] }, b: 1 }, () => n++) as any; + const state = createReactive({ a: { arr: [] }, b: 1 }, () => n++) as any; // swap a and b - const b = atom1.b; - atom1.b = atom1.a; - atom1.a = b; - await nextMicroTick(); + const b = state.b; + state.b = state.a; + state.a = b; expect(n).toBe(1); // push something into array to make sure it works - atom1.b.arr.push("blanche"); - await nextMicroTick(); + state.b.arr.push("blanche"); + // push reads the length property and as such subscribes to the change it is about to cause expect(n).toBe(2); }); - test("properly handle assigning observed atom containing array", async () => { + test("properly handle assigning object containing array to reactive", async () => { let n = 0; - const atom1 = createAtom({ a: { arr: [], val: "test" } }, () => n++) as any; + const state = createReactive({ a: { arr: [], val: "test" } }, () => n++) as any; expect(n).toBe(0); - atom1.a = { ...atom1.a, val: "test2" }; - await nextMicroTick(); + state.a = { ...state.a, val: "test2" }; expect(n).toBe(1); // push something into array to make sure it works - atom1.a.arr.push("blanche"); - await nextMicroTick(); + state.a.arr.push("blanche"); expect(n).toBe(2); }); - test("accept cycles in observed atom", async () => { + test.skip("accept cycles in observed object", async () => { + // ??? let n = 0; let obj1: any = {}; let obj2: any = { b: obj1, key: 1 }; obj1.a = obj2; - obj1 = createAtom(obj1, () => n++) as any; + obj1 = createReactive(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 () => { + test("call callback when reactive is changed", async () => { let n = 0; - const atom1: any = createAtom({ a: 1, b: { c: 2 }, d: [{ e: 3 }], f: 4 }, () => n++); + const state: any = createReactive({ a: 1, b: { c: 2 }, d: [{ e: 3 }], f: 4 }, () => n++); expect(n).toBe(0); - atom1.a = atom1.a + 2; - await nextMicroTick(); + state.a = state.a + 2; + state.a; expect(n).toBe(1); - atom1.b.c = atom1.b.c + 3; - await nextMicroTick(); + state.b.c = state.b.c + 3; expect(n).toBe(2); - atom1.d[0].e = atom1.d[0].e + 5; - await nextMicroTick(); + state.d[0].e = state.d[0].e + 5; expect(n).toBe(3); - atom1.a = 111; - atom1.f = 222; - await nextMicroTick(); + state.a; + state.f; + state.a = 111; + state.f = 222; expect(n).toBe(4); }); - test("can unobserve a value", async () => { - let n = 0; - const cb = () => n++; + // test("can unobserve a value", async () => { + // let n = 0; + // const cb = () => n++; + // const unregisterObserver = registerObserver(cb); - const [atom1, unregisterObserver] = observe({ a: 1 }, cb); + // const state = createReactive({ a: 1 }, cb); - atom1.a = atom1.a + 3; - await nextMicroTick(); - expect(n).toBe(1); + // state.a = state.a + 3; + // await nextMicroTick(); + // expect(n).toBe(1); - unregisterObserver(); + // unregisterObserver(); - atom1.a = 4; - await nextMicroTick(); - expect(n).toBe(1); - }); + // state.a = 4; + // await nextMicroTick(); + // expect(n).toBe(1); + // }); - test("observing some observed atom", async () => { + test("reactive inside other reactive", async () => { let n1 = 0; let n2 = 0; - const inner = createAtom({ a: 1 }, () => n1++); - const outer = createAtom({ b: inner }, () => n2++); + const inner = createReactive({ a: 1 }, () => n1++); + const outer = createReactive({ 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 () => { + test("reactive inside other reactive, variant", async () => { let n1 = 0; let n2 = 0; - const inner = createAtom({ a: 1 }, () => n1++); - const outer = createAtom({ b: inner, c: 0 }, () => n2++); + const inner = createReactive({ a: 1 }, () => n1++); + const outer = createReactive({ 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 () => { + test("reactive inside other reactive, 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++); + const obj1 = createReactive({ a: 1 }, () => n1++); + const obj2 = createReactive({ b: {} }, () => n2++); + const obj3 = createReactive({ c: {} }, () => n3++); obj2.b = obj2.b; + obj2.b; obj3.c = obj3.c; - await nextMicroTick(); + obj3.c; expect(n1).toBe(0); expect(n2).toBe(1); expect(n3).toBe(1); obj2.b = obj1; + obj2.b; obj3.c = obj1; - await nextMicroTick(); + obj3.c; expect(n1).toBe(0); expect(n2).toBe(2); expect(n3).toBe(2); obj1.a = obj1.a + 2; - await nextMicroTick(); + obj1.a; 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 [state, unregisterObserver] = observe({ a: 1 } as any, observer); + // test("notification is not done after unregistration", async () => { + // let n = 0; + // const observer = () => n++; + // const unregisterObserver = registerObserver(observer); + // const state = atom({ a: 1 } as any, observer); - state.a = state.a; - await nextMicroTick(); - expect(n).toBe(0); + // state.a = state.a; + // await nextMicroTick(); + // expect(n).toBe(0); - unregisterObserver(); + // unregisterObserver(); - state.a = { b: 2 }; - await nextMicroTick(); - expect(n).toBe(0); + // state.a = { b: 2 }; + // await nextMicroTick(); + // expect(n).toBe(0); - state.a.b = state.a.b + 3; - await nextMicroTick(); - expect(n).toBe(0); - }); + // state.a.b = state.a.b + 3; + // await nextMicroTick(); + // expect(n).toBe(0); + // }); - test("atom cleaning after key deleted 1", async () => { + test("don't react to changes in subobject that has been deleted", async () => { let n = 0; const a = { k: {} } as any; - const atom = createAtom(a, () => n++); + const state = createReactive(a, () => n++); - atom.k.l = 1; - await nextMicroTick(); + state.k.l; + state.k.l = 1; expect(n).toBe(1); - const kVal = atom.k; + const kVal = state.k; - delete atom.k; - await nextMicroTick(); + delete state.k; expect(n).toBe(2); kVal.l = 2; - await nextMicroTick(); expect(n).toBe(2); // kVal must no longer be observed }); - test("atom cleaning after key deleted 2", async () => { + test("don't react to changes in subobject that has been deleted", async () => { let n = 0; const b = {} as any; const a = { k: b } as any; const observer = () => n++; - const atom2 = createAtom(b, observer); - const atom1 = createAtom(a, observer); // atom2 should be still a seed + const state2 = createReactive(b, observer); + const state = createReactive(a, observer); - atom1.c = 1; - atom1.k.d = 2; - await nextMicroTick(); + state.c = 1; + state.k.d; + state.k.d = 2; + state.k; expect(n).toBe(1); - delete atom1.k; - await nextMicroTick(); + delete state.k; expect(n).toBe(2); - atom2.e = 3; - await nextMicroTick(); - expect(n).toBe(3); // b must still be observed + state2.e = 3; + expect(n).toBe(2); }); - test("atom cleaning after key deleted 3", async () => { + test("don't react to changes in subobject that has been deleted 3", async () => { let n = 0; const b = {} as any; const a = { k: b } as any; const observer = () => n++; - const atom1 = createAtom(a, observer); - const atom2 = createAtom(b, observer); // atom2 should be a seed now + const state = createReactive(a, observer); + const state2 = createReactive(b, observer); - atom1.c = 1; - atom1.k.d = 2; - await nextMicroTick(); + state.c = 1; + state.k.d; + state.k.d = 2; + state.k.d; expect(n).toBe(1); - delete atom1.k; - await nextMicroTick(); + delete state.k; expect(n).toBe(2); - atom2.e = 3; - await nextMicroTick(); - expect(n).toBe(3); // b must still be observed + state2.e = 3; + expect(n).toBe(2); }); - test("atom cleaning after key deleted 4", async () => { + test("don't react to changes in subobject that has been deleted 4", async () => { let n = 0; const a = { k: {} } as any; a.k = a; - const atom = createAtom(a, () => n++); + const state = createReactive(a, () => n++); + Object.keys(state); - atom.b = 1; - await nextMicroTick(); + state.b = 1; expect(n).toBe(1); - delete atom.k; - await nextMicroTick(); + Object.keys(state); + delete state.k; expect(n).toBe(2); - atom.c = 2; - await nextMicroTick(); - expect(n).toBe(3); // a must still be observed + state.c = 2; + expect(n).toBe(2); }); - test("atom cleaning after trackable key value changed", async () => { + test("don't react to changes in subobject that has been replaced", async () => { let n = 0; const a = { k: { n: 1 } } as any; - const atom = createAtom(a, () => n++); - const kVal = atom.k; // read k + const state = createReactive(a, () => n++); + const kVal = state.k; // read k - atom.k = { n: atom.k.n + 1 }; + state.k = { n: state.k.n + 1 }; await nextMicroTick(); expect(n).toBe(1); - expect(atom.k).toEqual({ n: 2 }); + expect(state.k).toEqual({ n: 2 }); kVal.n = 3; await nextMicroTick(); expect(n).toBe(1); - expect(atom.k).toEqual({ n: 2 }); + expect(state.k).toEqual({ n: 2 }); }); }); @@ -1050,13 +1087,13 @@ describe("Reactivity: useState", () => { }); /** - * A context can be defined as an atom with a default observer. + * A context can be defined as a reactive 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 }); + const testContext = createReactive({ value: 123 }); class Comp extends Component { static template = xml`
`; @@ -1067,7 +1104,7 @@ describe("Reactivity: useState", () => { }); test("useContext=useState hook is reactive, for one component", async () => { - const testContext = createAtom({ value: 123 }); + const testContext = createReactive({ value: 123 }); class Comp extends Component { static template = xml`
`; @@ -1081,7 +1118,7 @@ describe("Reactivity: useState", () => { }); test("two components can subscribe to same context", async () => { - const testContext = createAtom({ value: 123 }); + const testContext = createReactive({ value: 123 }); const steps: string[] = []; class Child extends Component { @@ -1112,7 +1149,7 @@ describe("Reactivity: useState", () => { }); test("two components are updated in parallel", async () => { - const testContext = createAtom({ value: 123 }); + const testContext = createReactive({ value: 123 }); const steps: string[] = []; class Child extends Component { @@ -1142,7 +1179,7 @@ describe("Reactivity: useState", () => { }); test("two independent components on different levels are updated in parallel", async () => { - const testContext = createAtom({ value: 123 }); + const testContext = createReactive({ value: 123 }); const steps: string[] = []; class Child extends Component { @@ -1183,7 +1220,7 @@ describe("Reactivity: useState", () => { }); test("one components can subscribe twice to same context", async () => { - const testContext = createAtom({ a: 1, b: 2 }); + const testContext = createReactive({ a: 1, b: 2 }); const steps: string[] = []; class Comp extends Component { @@ -1206,7 +1243,7 @@ describe("Reactivity: useState", () => { }); test("parent and children subscribed to same context", async () => { - const testContext = createAtom({ a: 123, b: 321 }); + const testContext = createReactive({ a: 123, b: 321 }); const steps: string[] = []; class Child extends Component { @@ -1239,8 +1276,8 @@ describe("Reactivity: useState", () => { expect(steps).toEqual(["parent", "child", "child"]); }); - test("several nodes on different level use same context", async () => { - const testContext = createAtom({ a: 123, b: 456 }); + test.skip("several nodes on different level use same context", async () => { + const testContext = createReactive({ a: 123, b: 456 }); const steps: Set = new Set(); /** @@ -1336,7 +1373,7 @@ describe("Reactivity: useState", () => { }); test("destroyed component is inactive", async () => { - const testContext = createAtom({ a: 123 }); + const testContext = createReactive({ a: 123 }); const steps: string[] = []; class Child extends Component { @@ -1377,7 +1414,7 @@ describe("Reactivity: useState", () => { }); test("destroyed component before being mounted is inactive", async () => { - const testContext = createAtom({ a: 123 }); + const testContext = createReactive({ a: 123 }); const steps: string[] = []; class Child extends Component { static template = xml``; @@ -1414,7 +1451,7 @@ describe("Reactivity: useState", () => { }); test("useless atoms should be deleted", async () => { - const testContext = createAtom({ + const testContext = createReactive({ 1: { id: 1, quantity: 3, description: "First quantity" }, 2: { id: 2, quantity: 5, description: "Second quantity" }, }); @@ -1503,7 +1540,7 @@ describe("Reactivity: useState", () => { * 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 testContext = createReactive({ x: { n: 1 }, key: "x" }); const def = makeDeferred(); let stateC: any; class ComponentC extends Component {