diff --git a/src/reactivity.ts b/src/reactivity.ts index 2c783324..c7dc3cb2 100644 --- a/src/reactivity.ts +++ b/src/reactivity.ts @@ -7,9 +7,9 @@ const SKIP = Symbol("Skip"); // Special key to subscribe to, to be notified of key creation/deletion const KEYCHANGES = Symbol("Key changes"); -type ObjectKey = string | number | symbol; - type Target = object; +type Collection = Set | Map | WeakMap; +type CollectionRawType = "Set" | "Map" | "WeakMap"; export type Reactive = T & { [TARGET]: any; @@ -18,8 +18,25 @@ export type Reactive = T & { export type NonReactive = T & { [SKIP]: any; }; -const objectToString = Object.prototype.toString; +const objectToString = Object.prototype.toString; +const objectHasOwnProperty = Object.prototype.hasOwnProperty; + +const SUPPORTED_RAW_TYPES = new Set(["Object", "Array", "Set", "Map", "WeakMap"]); +const COLLECTION_RAWTYPES = new Set(["Set", "Map", "WeakMap"]); + +/** + * extract "RawType" from strings like "[object RawType]" => this lets us ignore + * many native objects such as Promise (whose toString is [object Promise]) + * or Date ([object Date]), while also supporting collections without using + * instanceof in a loop + * + * @param obj the object to check + * @returns the raw type of the object + */ +function rawType(obj: any) { + return objectToString.call(obj).slice(8, -1); +} /** * Checks whether a given value can be made into a reactive object. * @@ -30,11 +47,17 @@ function canBeMadeReactive(value: any): boolean { if (typeof value !== "object") { return false; } - // extract "RawType" from strings like "[object RawType]" => this lets us - // ignore many native objects such as Promise (whose toString is [object Promise]) - // or Date ([object Date]). - const rawType = objectToString.call(value).slice(8, -1); - return rawType === "Object" || rawType === "Array"; + return SUPPORTED_RAW_TYPES.has(rawType(value)); +} +/** + * Creates a reactive from the given object/callback if possible and returns it, + * returns the original object otherwise. + * + * @param value the value make reactive + * @returns a reactive for the given object when possible, the original otherwise + */ +function possiblyReactive(val: any, cb: Callback) { + return canBeMadeReactive(val) ? reactive(val, cb) : val; } /** @@ -58,7 +81,7 @@ export function toRaw(value: Reactive): T { return value[TARGET] || value; } -const targetToKeysToCallbacks = new WeakMap>>(); +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. @@ -68,7 +91,7 @@ const targetToKeysToCallbacks = new WeakMap * or deletion) * @param callback the function to call when the key changes */ -function observeTargetKey(target: Target, key: ObjectKey, callback: Callback): void { +function observeTargetKey(target: Target, key: PropertyKey, callback: Callback): void { if (!targetToKeysToCallbacks.get(target)) { targetToKeysToCallbacks.set(target, new Map()); } @@ -91,7 +114,7 @@ function observeTargetKey(target: Target, key: ObjectKey, callback: Callback): v * @param key the key that changed (or Symbol `KEYCHANGES` if a key was created * or deleted) */ -function notifyReactives(target: Target, key: ObjectKey): void { +function notifyReactives(target: Target, key: PropertyKey): void { const keyToCallbacks = targetToKeysToCallbacks.get(target); if (!keyToCallbacks) { return; @@ -177,50 +200,218 @@ export function reactive( } 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); - }, - }); + const targetRawType = rawType(target); + const handler = COLLECTION_RAWTYPES.has(targetRawType) + ? collectionsProxyHandler(target as Collection, callback, targetRawType as CollectionRawType) + : basicProxyHandler(callback); + const proxy = new Proxy(target, handler as ProxyHandler) as Reactive; reactivesForTarget.set(callback, proxy); } return reactivesForTarget.get(callback) as Reactive; } +/** + * Creates a basic proxy handler for regular objects and arrays. + * + * @param callback @see reactive + * @returns a proxy handler object + */ +function basicProxyHandler(callback: Callback): ProxyHandler { + return { + get(target: any, key: PropertyKey, proxy: Reactive) { + if (key === TARGET) { + return target; + } + observeTargetKey(target, key, callback); + return possiblyReactive(Reflect.get(target, key, proxy), callback); + }, + set(target, key, value, proxy) { + const isNewKey = !objectHasOwnProperty.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); + // TODO: only notify when something was actually deleted + 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 + // observing the key itself would observe value changes instead of presence changes + // so we may need a finer grained system to distinguish observing value vs presence. + observeTargetKey(target, KEYCHANGES, callback); + return Reflect.has(target, key); + }, + } as ProxyHandler; +} +/** + * Creates a function that will observe the key that is passed to it when called + * and delegates to the underlying method. + * + * @param methodName name of the method to delegate to + * @param target @see reactive + * @param callback @see reactive + */ +function makeKeyObserver(methodName: "has" | "get", target: any, callback: Callback) { + return (key: any) => { + key = toRaw(key); + observeTargetKey(target, key, callback); + return possiblyReactive(target[methodName](key), callback); + }; +} +/** + * Creates an iterable that will delegate to the underlying iteration method and + * observe keys as necessary. + * + * @param methodName name of the method to delegate to + * @param target @see reactive + * @param callback @see reactive + */ +function makeIteratorObserver( + methodName: "keys" | "values" | "entries" | typeof Symbol.iterator, + target: any, + callback: Callback +) { + return function* () { + observeTargetKey(target, KEYCHANGES, callback); + const keys = target.keys(); + for (const item of target[methodName]()) { + const key = keys.next().value; + observeTargetKey(target, key, callback); + yield possiblyReactive(item, callback); + } + }; +} +/** + * Creates a function that will delegate to an underlying method, and check if + * that method has modified the presence or value of a key, and notify the + * reactives appropriately. + * + * @param setterName name of the method to delegate to + * @param getterName name of the method which should be used to retrieve the + * value before calling the delegate method for comparison purposes + * @param target @see reactive + */ +function delegateAndNotify( + setterName: "set" | "add" | "delete", + getterName: "has" | "get", + target: any +) { + return (key: any, value: any) => { + key = toRaw(key); + const hadKey = target.has(key); + const originalValue = target[getterName](key); + const ret = target[setterName](key, value); + const hasKey = target.has(key); + if (hadKey !== hasKey) { + notifyReactives(target, KEYCHANGES); + } + if (originalValue !== value) { + notifyReactives(target, key); + } + return ret; + }; +} +/** + * Creates a function that will clear the underlying collection and notify that + * the keys of the collection have changed. + * + * @param target @see reactive + */ +function makeClearNotifier(target: Map | Set) { + return () => { + const allKeys = [...target.keys()]; + target.clear(); + notifyReactives(target, KEYCHANGES); + for (const key of allKeys) { + notifyReactives(target, key); + } + }; +} +/** + * Maps raw type of an object to an object containing functions that can be used + * to build an appropritate proxy handler for that raw type. Eg: when making a + * reactive set, calling the has method should mark the key that is being + * retrieved as observed, and calling the add or delete method should notify the + * reactives that the key which is being added or deleted has been modified. + */ +const rawTypeToFuncHandlers = { + Set: (target: any, callback: Callback) => ({ + has: makeKeyObserver("has", target, callback), + add: delegateAndNotify("add", "has", target), + delete: delegateAndNotify("delete", "has", target), + keys: makeIteratorObserver("keys", target, callback), + values: makeIteratorObserver("values", target, callback), + entries: makeIteratorObserver("entries", target, callback), + [Symbol.iterator]: makeIteratorObserver(Symbol.iterator, target, callback), + clear: makeClearNotifier(target), + get size() { + observeTargetKey(target, KEYCHANGES, callback); + return target.size; + }, + }), + Map: (target: any, callback: Callback) => ({ + has: makeKeyObserver("has", target, callback), + get: makeKeyObserver("get", target, callback), + set: delegateAndNotify("set", "get", target), + delete: delegateAndNotify("delete", "has", target), + keys: makeIteratorObserver("keys", target, callback), + values: makeIteratorObserver("values", target, callback), + entries: makeIteratorObserver("entries", target, callback), + [Symbol.iterator]: makeIteratorObserver(Symbol.iterator, target, callback), + clear: makeClearNotifier(target), + get size() { + observeTargetKey(target, KEYCHANGES, callback); + return target.size; + }, + }), + WeakMap: (target: any, callback: Callback) => ({ + has: makeKeyObserver("has", target, callback), + get: makeKeyObserver("get", target, callback), + set: delegateAndNotify("set", "get", target), + delete: delegateAndNotify("delete", "has", target), + }), +}; +/** + * Creates a proxy handler for collections (Set/Map/WeakMap) + * + * @param callback @see reactive + * @param target @see reactive + * @returns a proxy handler object + */ +function collectionsProxyHandler( + target: T, + callback: Callback, + targetRawType: CollectionRawType +): ProxyHandler { + // TODO: if performance is an issue we can create the special handlers lazily when each + // property is read. + const specialHandlers = rawTypeToFuncHandlers[targetRawType](target, callback); + return Object.assign(basicProxyHandler(callback), { + get(target: any, key: PropertyKey) { + if (key === TARGET) { + return target; + } + if (objectHasOwnProperty.call(specialHandlers, key)) { + return (specialHandlers as any)[key]; + } + observeTargetKey(target, key, callback); + return possiblyReactive(target[key], callback); + }, + }) as ProxyHandler; +} diff --git a/tests/components/__snapshots__/reactivity.test.ts.snap b/tests/components/__snapshots__/reactivity.test.ts.snap index 9da16602..c6aec89e 100644 --- a/tests/components/__snapshots__/reactivity.test.ts.snap +++ b/tests/components/__snapshots__/reactivity.test.ts.snap @@ -28,6 +28,20 @@ exports[`reactivity in lifecycle can use a state hook 2 1`] = ` }" `; +exports[`reactivity in lifecycle can use a state hook on Map 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component, comment } = bdom; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let txt1 = ctx['counter'].get('value'); + return block1([txt1]); + } +}" +`; + exports[`reactivity in lifecycle change state while mounting component 1`] = ` "function anonymous(bdom, helpers ) { diff --git a/tests/components/reactivity.test.ts b/tests/components/reactivity.test.ts index 236e049f..a987ef5a 100644 --- a/tests/components/reactivity.test.ts +++ b/tests/components/reactivity.test.ts @@ -53,6 +53,18 @@ describe("reactivity in lifecycle", () => { expect(n).toBe(2); // no new rendering occured: b was never read via state! }); + test("can use a state hook on Map", async () => { + class Counter extends Component { + static template = xml`
`; + counter = useState(new Map([["value", 42]])); + } + const counter = await mount(Counter, fixture); + expect(fixture.innerHTML).toBe("
42
"); + counter.counter.set("value", 3); + await nextTick(); + expect(fixture.innerHTML).toBe("
3
"); + }); + 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 1c9ff8d3..00a48ab8 100644 --- a/tests/reactivity.test.ts +++ b/tests/reactivity.test.ts @@ -171,6 +171,8 @@ describe("Reactivity", () => { expect(n).toBe(2); }); + // Skipped because the hasOwnProperty trap is tripped by *writing*. We + // (probably) do not want to subscribe to changes on writes. test.skip("hasOwnProperty causes the key's presence to be observed", async () => { let n = 0; const state = createReactive({}, () => n++); @@ -1093,18 +1095,482 @@ describe("Reactivity", () => { expect(n).toBe(1); expect(state.k).toEqual({ n: 2 }); }); +}); - test("can add collections set/weakset/map/weakmap in a reactive object", () => { - const rawSet = new Set(); - const rawWeakSet = new WeakSet(); - const rawMap = new Map(); - const rawWeakMap = new WeakMap(); +describe("Collections", () => { + describe("Set", () => { + test("can make reactive Set", () => { + const set = new Set(); + const obj = reactive(set); + expect(obj).not.toBe(set); + }); - const obj = reactive({ rawSet, rawWeakSet, rawMap, rawWeakMap }); - expect(obj.rawSet).toBe(rawSet); - expect(obj.rawWeakSet).toBe(rawWeakSet); - expect(obj.rawMap).toBe(rawMap); - expect(obj.rawWeakMap).toBe(rawWeakMap); + test("can read", async () => { + const state = reactive(new Set([1])); + expect(state.has(1)).toBe(true); + expect(state.has(0)).toBe(false); + }); + + test("can add entries", () => { + const state = reactive(new Set()); + state.add(1); + expect(state.has(1)).toBe(true); + }); + + test("can remove entries", () => { + const state = reactive(new Set([1])); + state.delete(1); + expect(state.has(1)).toBe(false); + }); + + test("can clear entries", () => { + const state = reactive(new Set([1])); + expect(state.size).toBe(1); + state.clear(); + expect(state.size).toBe(0); + }); + + test("act like a Set", () => { + const state = reactive(new Set([1])); + expect([...state.entries()]).toEqual([[1, 1]]); + expect([...state.values()]).toEqual([1]); + expect([...state.keys()]).toEqual([1]); + expect([...state]).toEqual([1]); // Checks Symbol.iterator + expect(state.size).toBe(1); + expect(typeof state).toBe("object"); + expect(state).toBeInstanceOf(Set); + }); + + test("reactive Set contains its keys", () => { + const state = reactive(new Set([{}])); + expect(state.has(state.keys().next().value)).toBe(true); + }); + + test("reactive Set contains its values", () => { + const state = reactive(new Set([{}])); + expect(state.has(state.values().next().value)).toBe(true); + }); + + test("reactive Set contains its entries' keys and values", () => { + const state = reactive(new Set([{}])); + const [key, val] = state.entries().next().value; + expect(state.has(key)).toBe(true); + expect(state.has(val)).toBe(true); + }); + + test("checking for a key subscribes the callback to changes to that key", () => { + const observer = jest.fn(); + const state = reactive(new Set([1]), observer); + + expect(state.has(2)).toBe(false); // subscribe to 2 + expect(observer).toHaveBeenCalledTimes(0); + state.add(2); + expect(observer).toHaveBeenCalledTimes(1); + expect(state.has(2)).toBe(true); // subscribe to 2 + state.delete(2); + expect(observer).toHaveBeenCalledTimes(2); + state.add(2); + expect(state.has(2)).toBe(true); // subscribe to 2 + state.clear(); + expect(observer).toHaveBeenCalledTimes(3); + expect(state.has(2)).toBe(false); // subscribe to 2 + state.clear(); // clearing again doesn't notify again + expect(observer).toHaveBeenCalledTimes(3); + + state.add(3); // setting unobserved key doesn't notify + expect(observer).toHaveBeenCalledTimes(3); + }); + + test("iterating on keys returns reactives", async () => { + const obj = { a: 2 }; + const observer = jest.fn(); + const state = reactive(new Set([obj]), observer); + const reactiveObj = state.keys().next().value; + expect(reactiveObj).not.toBe(obj); + expect(toRaw(reactiveObj as any)).toBe(obj); + reactiveObj.a = 0; + expect(observer).toHaveBeenCalledTimes(0); + reactiveObj.a; // observe key "a" in sub-reactive; + reactiveObj.a = 1; + expect(observer).toHaveBeenCalledTimes(1); + reactiveObj.a = 1; // setting same value again shouldn't notify + expect(observer).toHaveBeenCalledTimes(1); + }); + + test("iterating on values returns reactives", async () => { + const obj = { a: 2 }; + const observer = jest.fn(); + const state = reactive(new Set([obj]), observer); + const reactiveObj = state.values().next().value; + expect(reactiveObj).not.toBe(obj); + expect(toRaw(reactiveObj as any)).toBe(obj); + reactiveObj.a = 0; + expect(observer).toHaveBeenCalledTimes(0); + reactiveObj.a; // observe key "a" in sub-reactive; + reactiveObj.a = 1; + expect(observer).toHaveBeenCalledTimes(1); + reactiveObj.a = 1; // setting same value again shouldn't notify + expect(observer).toHaveBeenCalledTimes(1); + }); + + test("iterating on entries returns reactives", async () => { + const obj = { a: 2 }; + const observer = jest.fn(); + const state = reactive(new Set([obj]), observer); + const [reactiveObj, reactiveObj2] = state.entries().next().value; + expect(reactiveObj2).toBe(reactiveObj); + expect(reactiveObj).not.toBe(obj); + expect(toRaw(reactiveObj as any)).toBe(obj); + reactiveObj.a = 0; + expect(observer).toHaveBeenCalledTimes(0); + reactiveObj.a; // observe key "a" in sub-reactive; + reactiveObj.a = 1; + expect(observer).toHaveBeenCalledTimes(1); + reactiveObj.a = 1; // setting same value again shouldn't notify + expect(observer).toHaveBeenCalledTimes(1); + }); + + test("iterating on reactive Set returns reactives", async () => { + const obj = { a: 2 }; + const observer = jest.fn(); + const state = reactive(new Set([obj]), observer); + const reactiveObj = state[Symbol.iterator]().next().value; + expect(reactiveObj).not.toBe(obj); + expect(toRaw(reactiveObj as any)).toBe(obj); + reactiveObj.a = 0; + expect(observer).toHaveBeenCalledTimes(0); + reactiveObj.a; // observe key "a" in sub-reactive; + reactiveObj.a = 1; + expect(observer).toHaveBeenCalledTimes(1); + reactiveObj.a = 1; // setting same value again shouldn't notify + expect(observer).toHaveBeenCalledTimes(1); + }); + }); + + describe("WeakSet", () => { + test("cannot make reactive WeakSet", () => { + const set = new WeakSet(); + expect(() => reactive(set)).toThrowError("Cannot make the given value reactive"); + }); + + test("WeakSet in reactive is original WeakSet", () => { + const obj = { set: new WeakSet() }; + const state = reactive(obj); + expect(state.set).toBe(obj.set); + }); + }); + + describe("Map", () => { + test("can make reactive Map", () => { + const map = new Map(); + const obj = reactive(map); + expect(obj).not.toBe(map); + }); + + test("can read", async () => { + const state = reactive(new Map([[1, 0]])); + expect(state.has(1)).toBe(true); + expect(state.has(0)).toBe(false); + expect(state.get(1)).toBe(0); + expect(state.get(0)).toBeUndefined(); + }); + + test("can add entries", () => { + const state = reactive(new Map()); + state.set(1, 2); + expect(state.has(1)).toBe(true); + expect(state.get(1)).toBe(2); + }); + + test("can remove entries", () => { + const state = reactive(new Map([[1, 2]])); + state.delete(1); + expect(state.has(1)).toBe(false); + expect(state.get(1)).toBeUndefined(); + }); + + test("can clear entries", () => { + const state = reactive(new Map([[1, 2]])); + expect(state.size).toBe(1); + state.clear(); + expect(state.size).toBe(0); + }); + + test("act like a Map", () => { + const state = reactive(new Map([[1, 2]])); + expect([...state.entries()]).toEqual([[1, 2]]); + expect([...state.values()]).toEqual([2]); + expect([...state.keys()]).toEqual([1]); + expect([...state]).toEqual([[1, 2]]); // Checks Symbol.iterator + expect(state.size).toBe(1); + expect(typeof state).toBe("object"); + expect(state).toBeInstanceOf(Map); + }); + + test("reactive Map contains its keys", () => { + const state = reactive(new Map([[{}, 1]])); + expect(state.has(state.keys().next().value)).toBe(true); + }); + + test("reactive Map values are equal to doing a get on the appropriate key", () => { + const state = reactive(new Map([[1, {}]])); + expect(state.get(1)).toBe(state.values().next().value); + }); + + test("reactive Map contains its entries' keys, and the associated value is the same as doing get", () => { + const state = reactive(new Map([[{}, {}]])); + const [key, val] = state.entries().next().value; + expect(state.has(key)).toBe(true); + expect(val).toBe(state.get(key)); + }); + + test("checking for a key with 'has' subscribes the callback to changes to that key", () => { + const observer = jest.fn(); + const state = reactive(new Map([[1, 2]]), observer); + + expect(state.has(2)).toBe(false); // subscribe to 2 + expect(observer).toHaveBeenCalledTimes(0); + state.set(2, 3); + expect(observer).toHaveBeenCalledTimes(1); + expect(state.has(2)).toBe(true); // subscribe to 2 + state.delete(2); + expect(observer).toHaveBeenCalledTimes(2); + state.set(2, 3); + expect(state.has(2)).toBe(true); // subscribe to 2 + state.clear(); + expect(observer).toHaveBeenCalledTimes(3); + expect(state.has(2)).toBe(false); // subscribe to 2 + state.clear(); // clearing again doesn't notify again + expect(observer).toHaveBeenCalledTimes(3); + + state.set(3, 4); // setting unobserved key doesn't notify + expect(observer).toHaveBeenCalledTimes(3); + }); + + test("checking for a key with 'get' subscribes the callback to changes to that key", () => { + const observer = jest.fn(); + const state = reactive(new Map([[1, 2]]), observer); + + expect(state.get(2)).toBeUndefined(); // subscribe to 2 + expect(observer).toHaveBeenCalledTimes(0); + state.set(2, 3); + expect(observer).toHaveBeenCalledTimes(1); + expect(state.get(2)).toBe(3); // subscribe to 2 + state.delete(2); + expect(observer).toHaveBeenCalledTimes(2); + state.delete(2); // deleting again doesn't notify again + expect(observer).toHaveBeenCalledTimes(2); + state.set(2, 3); + expect(state.get(2)).toBe(3); // subscribe to 2 + state.clear(); + expect(observer).toHaveBeenCalledTimes(3); + expect(state.get(2)).toBeUndefined(); // subscribe to 2 + state.clear(); // clearing again doesn't notify again + expect(observer).toHaveBeenCalledTimes(3); + + state.set(3, 4); // setting unobserved key doesn't notify + expect(observer).toHaveBeenCalledTimes(3); + }); + + test("getting values returns a reactive", async () => { + const obj = { a: 2 }; + const observer = jest.fn(); + const state = reactive(new Map([[1, obj]]), observer); + const reactiveObj = state.get(1)!; + expect(reactiveObj).not.toBe(obj); + expect(toRaw(reactiveObj as any)).toBe(obj); + reactiveObj.a = 0; + expect(observer).toHaveBeenCalledTimes(0); + reactiveObj.a; // observe key "a" in sub-reactive; + reactiveObj.a = 1; + expect(observer).toHaveBeenCalledTimes(1); + reactiveObj.a = 1; // setting same value again shouldn't notify + expect(observer).toHaveBeenCalledTimes(1); + }); + + test("iterating on values returns reactives", async () => { + const obj = { a: 2 }; + const observer = jest.fn(); + const state = reactive(new Map([[1, obj]]), observer); + const reactiveObj = state.values().next().value; + expect(reactiveObj).not.toBe(obj); + expect(toRaw(reactiveObj as any)).toBe(obj); + reactiveObj.a = 0; + expect(observer).toHaveBeenCalledTimes(0); + reactiveObj.a; // observe key "a" in sub-reactive; + reactiveObj.a = 1; + expect(observer).toHaveBeenCalledTimes(1); + reactiveObj.a = 1; // setting same value again shouldn't notify + expect(observer).toHaveBeenCalledTimes(1); + }); + + test("iterating on keys returns reactives", async () => { + const obj = { a: 2 }; + const observer = jest.fn(); + const state = reactive(new Map([[obj, 1]]), observer); + const reactiveObj = state.keys().next().value; + expect(reactiveObj).not.toBe(obj); + expect(toRaw(reactiveObj as any)).toBe(obj); + reactiveObj.a = 0; + expect(observer).toHaveBeenCalledTimes(0); + reactiveObj.a; // observe key "a" in sub-reactive; + reactiveObj.a = 1; + expect(observer).toHaveBeenCalledTimes(1); + reactiveObj.a = 1; // setting same value again shouldn't notify + expect(observer).toHaveBeenCalledTimes(1); + }); + + test("iterating on reactive map returns reactives", async () => { + const keyObj = { a: 2 }; + const valObj = { a: 2 }; + const observer = jest.fn(); + const state = reactive(new Map([[keyObj, valObj]]), observer); + const [reactiveKeyObj, reactiveValObj] = state[Symbol.iterator]().next().value; + expect(reactiveKeyObj).not.toBe(keyObj); + expect(reactiveValObj).not.toBe(valObj); + expect(toRaw(reactiveKeyObj as any)).toBe(keyObj); + expect(toRaw(reactiveValObj as any)).toBe(valObj); + reactiveKeyObj.a = 0; + reactiveValObj.a = 0; + expect(observer).toHaveBeenCalledTimes(0); + reactiveKeyObj.a; // observe key "a" in key sub-reactive; + reactiveKeyObj.a = 1; + expect(observer).toHaveBeenCalledTimes(1); + reactiveValObj.a; // observe key "a" in val sub-reactive; + reactiveValObj.a = 1; + expect(observer).toHaveBeenCalledTimes(2); + reactiveKeyObj.a = 1; // setting same value again shouldn't notify + reactiveValObj.a = 1; + expect(observer).toHaveBeenCalledTimes(2); + }); + + test("iterating on entries returns reactives", async () => { + const keyObj = { a: 2 }; + const valObj = { a: 2 }; + const observer = jest.fn(); + const state = reactive(new Map([[keyObj, valObj]]), observer); + const [reactiveKeyObj, reactiveValObj] = state.entries().next().value; + expect(reactiveKeyObj).not.toBe(keyObj); + expect(reactiveValObj).not.toBe(valObj); + expect(toRaw(reactiveKeyObj as any)).toBe(keyObj); + expect(toRaw(reactiveValObj as any)).toBe(valObj); + reactiveKeyObj.a = 0; + reactiveValObj.a = 0; + expect(observer).toHaveBeenCalledTimes(0); + reactiveKeyObj.a; // observe key "a" in key sub-reactive; + reactiveKeyObj.a = 1; + expect(observer).toHaveBeenCalledTimes(1); + reactiveValObj.a; // observe key "a" in val sub-reactive; + reactiveValObj.a = 1; + expect(observer).toHaveBeenCalledTimes(2); + reactiveKeyObj.a = 1; // setting same value again shouldn't notify + reactiveValObj.a = 1; + expect(observer).toHaveBeenCalledTimes(2); + }); + }); + + describe("WeakMap", () => { + test("can make reactive WeakMap", () => { + const map = new WeakMap(); + const obj = reactive(map); + expect(obj).not.toBe(map); + }); + + test("can read", async () => { + const obj = {}; + const obj2 = {}; + const state = reactive(new WeakMap([[obj, 0]])); + expect(state.has(obj)).toBe(true); + expect(state.has(obj2)).toBe(false); + expect(state.get(obj)).toBe(0); + expect(state.get(obj2)).toBeUndefined(); + }); + + test("can add entries", () => { + const obj = {}; + const state = reactive(new WeakMap()); + state.set(obj, 2); + expect(state.has(obj)).toBe(true); + expect(state.get(obj)).toBe(2); + }); + + test("can remove entries", () => { + const obj = {}; + const state = reactive(new WeakMap([[obj, 2]])); + state.delete(obj); + expect(state.has(obj)).toBe(false); + expect(state.get(obj)).toBeUndefined(); + }); + + test("act like a WeakMap", () => { + const obj = {}; + const state = reactive(new WeakMap([[obj, 2]])); + expect(typeof state).toBe("object"); + expect(state).toBeInstanceOf(WeakMap); + }); + + test("checking for a key with 'has' subscribes the callback to changes to that key", () => { + const observer = jest.fn(); + const obj = {}; + const obj2 = {}; + const obj3 = {}; + const state = reactive(new WeakMap([[obj2, 2]]), observer); + + expect(state.has(obj)).toBe(false); // subscribe to obj + expect(observer).toHaveBeenCalledTimes(0); + state.set(obj, 3); + expect(observer).toHaveBeenCalledTimes(1); + expect(state.has(obj)).toBe(true); // subscribe to obj + state.delete(obj); + expect(observer).toHaveBeenCalledTimes(2); + state.set(obj, 3); + state.delete(obj); + expect(observer).toHaveBeenCalledTimes(2); + expect(state.has(obj)).toBe(false); // subscribe to obj + + state.set(obj3, 4); // setting unobserved key doesn't notify + expect(observer).toHaveBeenCalledTimes(2); + }); + + test("checking for a key with 'get' subscribes the callback to changes to that key", () => { + const observer = jest.fn(); + const obj = {}; + const obj2 = {}; + const obj3 = {}; + const state = reactive(new WeakMap([[obj2, 2]]), observer); + + expect(state.get(obj)).toBeUndefined(); // subscribe to obj + expect(observer).toHaveBeenCalledTimes(0); + state.set(obj, 3); + expect(observer).toHaveBeenCalledTimes(1); + expect(state.get(obj)).toBe(3); // subscribe to obj + state.delete(obj); + expect(observer).toHaveBeenCalledTimes(2); + state.set(obj, 3); + state.delete(obj); + expect(observer).toHaveBeenCalledTimes(2); + expect(state.get(obj)).toBeUndefined(); // subscribe to obj + + state.set(obj3, 4); // setting unobserved key doesn't notify + expect(observer).toHaveBeenCalledTimes(2); + }); + + test("getting values returns a reactive", async () => { + const keyObj = {}; + const valObj = { a: 2 }; + const observer = jest.fn(); + const state = reactive(new WeakMap([[keyObj, valObj]]), observer); + const reactiveObj = state.get(keyObj)!; + expect(reactiveObj).not.toBe(valObj); + expect(toRaw(reactiveObj as any)).toBe(valObj); + reactiveObj.a = 0; + expect(observer).toHaveBeenCalledTimes(0); + reactiveObj.a; // observe key "a" in sub-reactive; + reactiveObj.a = 1; + expect(observer).toHaveBeenCalledTimes(1); + reactiveObj.a = 1; // setting same value again shouldn't notify + expect(observer).toHaveBeenCalledTimes(1); + }); }); });