From 921c3b18569403a70bb4cdde0e3b0f7c1e89cd76 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Fri, 23 Jun 2023 15:19:44 +0200 Subject: [PATCH 01/10] Add failing tests --- packages/deepsignal/core/test/index.test.tsx | 165 +++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/packages/deepsignal/core/test/index.test.tsx b/packages/deepsignal/core/test/index.test.tsx index 5af72e6..1d9da29 100644 --- a/packages/deepsignal/core/test/index.test.tsx +++ b/packages/deepsignal/core/test/index.test.tsx @@ -326,7 +326,172 @@ describe("deepsignal/core", () => { }); }); + describe("ownKeys", () => { + it("should return own properties in objects", () => { + const state: Record = { a: 1, b: 2 }; + const store = deepSignal(state); + let sum = 0; + + for (const property in store) { + sum += store[property]; + } + + expect(sum).to.equal(3); + }); + + it("should return own properties in arrays", () => { + const state: number[] = [1, 2]; + const store = deepSignal(state); + let sum = 0; + + for (const property of store) { + sum += property; + } + + expect(sum).to.equal(3); + }); + + it("should spread objects correctly", () => { + const store2 = { ...store }; + expect(store2.a).to.equal(1); + expect(store2.nested.b).to.equal(2); + expect(store2.array[0]).to.equal(3); + expect(typeof store2.array[1] === "object" && store2.array[1].b).to.equal( + 2 + ); + }); + + it("should spread arrays correctly", () => { + const array2 = [...store.array]; + expect(array2[0]).to.equal(3); + expect(typeof array2[1] === "object" && array2[1].b).to.equal(2); + }); + }); + describe("computations", () => { + it("should subscribe to changes to for..in loops", () => { + const state: Record = { a: 1, b: 2 }; + const store = deepSignal(state); + let sum = 0; + + effect(() => { + sum = 0; + for (const key in store) { + sum += store[key]; + } + }); + + expect(sum).to.equal(3); + + store.c = 3; + expect(sum).to.equal(6); + + delete store.a; + expect(sum).to.equal(5); + }); + + it("should subscribe to changes for Object.getOwnPropertyNames()", () => { + const state: Record = { a: 1, b: 2 }; + const store = deepSignal(state); + let sum = 0; + + effect(() => { + sum = 0; + const keys = Object.getOwnPropertyNames(store); + for (const key of keys) { + sum += store[key]; + } + }); + + expect(sum).to.equal(3); + + store.c = 3; + expect(sum).to.equal(6); + + delete store.a; + expect(sum).to.equal(5); + }); + + it("should subscribe to changes to Object.keys/values/entries()", () => { + const state: Record = { a: 1, b: 2 }; + const store = deepSignal(state); + let keys = 0; + let values = 0; + let entries = 0; + + effect(() => { + keys = 0; + Object.keys(store).forEach(key => { + keys += store[key]; + }); + }); + + effect(() => { + values = 0; + Object.values(store as typeof state).forEach(value => { + values += value; + }); + }); + + effect(() => { + entries = 0; + Object.entries(store as typeof state).forEach(([_, value]) => { + entries += value; + }); + }); + + expect(keys).to.equal(3); + expect(values).to.equal(3); + expect(entries).to.equal(3); + + store.c = 3; + expect(keys).to.equal(6); + expect(values).to.equal(6); + expect(entries).to.equal(6); + + delete store.a; + expect(keys).to.equal(5); + expect(values).to.equal(5); + expect(entries).to.equal(5); + }); + + it("should subscribe to changes to for..of loops", () => { + const store = deepSignal([1, 2]); + let sum = 0; + + effect(() => { + sum = 0; + for (const property of store) { + sum += !!property ? property : 0; + } + }); + + expect(sum).to.equal(3); + + store.push(3); + expect(sum).to.equal(6); + + store.splice(0, 1); + expect(sum).to.equal(5); + }); + + it("should subscribe to implicit changes in length", () => { + const store = deepSignal(["foo", "bar"]); + let x = ""; + + effect(() => { + x = store.join(" "); + }); + + expect(x).to.equal("foo bar"); + + store.push("baz"); + expect(x).to.equal("foo bar baz"); + + store.splice(0, 1); + expect(x).to.equal("bar baz"); + }); + it("should subscribe to changes when deleting properties", () => { let x, y; From 4f5ac86783e44fed8cb334b85fa3cc1a18061400 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Fri, 23 Jun 2023 15:33:34 +0200 Subject: [PATCH 02/10] Fix tests --- packages/deepsignal/core/src/index.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/deepsignal/core/src/index.ts b/packages/deepsignal/core/src/index.ts index 0acb2bb..4961ea6 100644 --- a/packages/deepsignal/core/src/index.ts +++ b/packages/deepsignal/core/src/index.ts @@ -4,6 +4,7 @@ const proxyToSignals = new WeakMap(); const objToProxy = new WeakMap(); const arrayToArrayOfSignals = new WeakMap(); const proxies = new WeakSet(); +const objToIterable = new WeakMap(); const rg = /^\$/; let peeking = false; @@ -94,9 +95,11 @@ const objectHandlers = { objToProxy.set(val, createProxy(val, objectHandlers)); internal = objToProxy.get(val); } - if (!signals.has(fullKey)) signals.set(fullKey, signal(internal)); - else signals.get(fullKey).value = internal; const result = Reflect.set(target, fullKey, val, receiver); + if (!signals.has(fullKey)) { + signals.set(fullKey, signal(internal)); + objToIterable.has(target) && objToIterable.get(target).value++; + } else signals.get(fullKey).value = internal; if (Array.isArray(target) && signals.has("length")) signals.get("length").value = target.length; return result; @@ -105,8 +108,17 @@ const objectHandlers = { deleteProperty(target: object, key: string): boolean { if (key[0] === "$") throwOnMutation(); const signals = proxyToSignals.get(objToProxy.get(target)); - if (signals && signals.has(key)) signals.get(key).value = undefined; - return Reflect.deleteProperty(target, key); + const result = Reflect.deleteProperty(target, key); + if (signals && signals.has(key)) { + signals.get(key).value = undefined; + objToIterable.has(target) && objToIterable.get(target).value++; + } + return result; + }, + ownKeys(target: object): (string | symbol)[] { + if (!objToIterable.has(target)) objToIterable.set(target, signal(0)); + objToIterable.get(target).value; + return Reflect.ownKeys(target); }, }; From 0dde471c5fd98514aeaa2816dbcdbd5f72f5c99e Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Fri, 23 Jun 2023 15:35:43 +0200 Subject: [PATCH 03/10] Export RevertDeepSignal type --- README.md | 26 ++++++++++++++------ packages/deepsignal/core/src/index.ts | 2 +- packages/deepsignal/core/test/index.test.tsx | 17 ++++++++----- 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index d862c7e..80e58c8 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,14 @@ Use [Preact signals](https://github.com/preactjs/signals) with the interface of --- - Try it on Stackblitz - - [Preact](https://stackblitz.com/edit/vitejs-vite-6qfchy?file=src%2Fmain.jsx) - - [Preact & TypeScript](https://stackblitz.com/edit/vitejs-vite-hktyyf?file=src%2Fmain.tsx) - - [React](https://stackblitz.com/edit/vitejs-vite-zoh464?file=src%2Fmain.jsx) - - [React & TypeScript](https://stackblitz.com/edit/vitejs-vite-r2stgq?file=src%2Fmain.tsx) + - [Preact](https://stackblitz.com/edit/vitejs-vite-6qfchy?file=src%2Fmain.jsx) + - [Preact & TypeScript](https://stackblitz.com/edit/vitejs-vite-hktyyf?file=src%2Fmain.tsx) + - [React](https://stackblitz.com/edit/vitejs-vite-zoh464?file=src%2Fmain.jsx) + - [React & TypeScript](https://stackblitz.com/edit/vitejs-vite-r2stgq?file=src%2Fmain.tsx) - Or on Codesandbox - - [Preact](https://codesandbox.io/s/deepsignal-demo-hv1i1p) - - [Preact & TypeScript](https://codesandbox.io/s/deepsignal-demo-typescript-os7ox0?file=/src/index.tsx) - - [React](https://codesandbox.io/s/deepsignal-demo-react-fupt1x?file=/src/index.js) + - [Preact](https://codesandbox.io/s/deepsignal-demo-hv1i1p) + - [Preact & TypeScript](https://codesandbox.io/s/deepsignal-demo-typescript-os7ox0?file=/src/index.tsx) + - [React](https://codesandbox.io/s/deepsignal-demo-react-fupt1x?file=/src/index.js) - [React & TypeScript](https://codesandbox.io/s/deepsignal-demo-react-typescript-jszfjw?file=/src/index.tsx) --- @@ -426,6 +426,18 @@ console.log(array.$![0].value); // 1 Note that here the position of the non-null assertion operator changes because `array.$` is an object in itself. +### DeepSignal and RevertDeepSignal types + +DeepSignal exports two types, one to convert from a raw state/store to a `deepSignal` instance, and other to revert from a `deepSignal` instance back to the raw store. + +These types are handy when manual casting is needed, like when you try to use `Object.values()`: + +```ts +import type { RevertDeepSignal } from "deepsignal"; + +const values = Object.values(store as RevertDeepSignal); +``` + ## License `MIT`, see the [LICENSE](./LICENSE) file. diff --git a/packages/deepsignal/core/src/index.ts b/packages/deepsignal/core/src/index.ts index 4961ea6..1fbd52b 100644 --- a/packages/deepsignal/core/src/index.ts +++ b/packages/deepsignal/core/src/index.ts @@ -272,7 +272,7 @@ type FilterSignals = K extends `$${infer P}` ? never : K; type RevertDeepSignalObject = Pick>; type RevertDeepSignalArray = Omit; -type RevertDeepSignal = T extends Array +export type RevertDeepSignal = T extends Array ? RevertDeepSignalArray : T extends object ? RevertDeepSignalObject diff --git a/packages/deepsignal/core/test/index.test.tsx b/packages/deepsignal/core/test/index.test.tsx index 1d9da29..ced7ca0 100644 --- a/packages/deepsignal/core/test/index.test.tsx +++ b/packages/deepsignal/core/test/index.test.tsx @@ -1,5 +1,6 @@ import { Signal, effect, signal } from "@preact/signals-core"; import { deepSignal, peek } from "deepsignal/core"; +import type { RevertDeepSignal } from "deepsignal/core"; type Store = { a?: number; @@ -428,16 +429,20 @@ describe("deepsignal/core", () => { effect(() => { values = 0; - Object.values(store as typeof state).forEach(value => { - values += value; - }); + Object.values(store as RevertDeepSignal).forEach( + value => { + values += value; + } + ); }); effect(() => { entries = 0; - Object.entries(store as typeof state).forEach(([_, value]) => { - entries += value; - }); + Object.entries(store as RevertDeepSignal).forEach( + ([_, value]) => { + entries += value; + } + ); }); expect(keys).to.equal(3); From fbd72115fa53c18180be1612a12bca6a18136429 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Fri, 23 Jun 2023 15:44:40 +0200 Subject: [PATCH 04/10] Add missing package.json exports --- packages/deepsignal/package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/deepsignal/package.json b/packages/deepsignal/package.json index 5654c9a..0f293f7 100644 --- a/packages/deepsignal/package.json +++ b/packages/deepsignal/package.json @@ -36,7 +36,10 @@ "browser": "./react/dist/deepsignal-react.module.js", "import": "./react/dist/deepsignal-react.mjs", "require": "./react/dist/deepsignal-react.js" - } + }, + "./package.json": "./package.json", + "./core/package.json": "./core/package.json", + "./react/package.json": "./react/package.json" }, "scripts": { "prepublishOnly": "cp ../../README.md . && cd ../.. && pnpm build" From 033b03280d87fe1ec3a517a71ab999fa75d59d41 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Fri, 23 Jun 2023 15:45:00 +0200 Subject: [PATCH 05/10] Fix a couple of issues in the tests --- packages/deepsignal/core/test/index.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/deepsignal/core/test/index.test.tsx b/packages/deepsignal/core/test/index.test.tsx index ced7ca0..895fa5c 100644 --- a/packages/deepsignal/core/test/index.test.tsx +++ b/packages/deepsignal/core/test/index.test.tsx @@ -323,7 +323,7 @@ describe("deepsignal/core", () => { }); it("should throw when trying to delete the array signals", () => { - expect(() => delete store.array.$?.[1]).to.throw(); + expect(() => delete store.array.$![1]).to.throw(); }); }); @@ -466,8 +466,8 @@ describe("deepsignal/core", () => { effect(() => { sum = 0; - for (const property of store) { - sum += !!property ? property : 0; + for (const value of store) { + sum += value; } }); From 106b106c546ce4c85587dba95f180f813f0504ee Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Fri, 23 Jun 2023 15:56:37 +0200 Subject: [PATCH 06/10] Add a test specific to deleting items in arrays --- packages/deepsignal/core/test/index.test.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/deepsignal/core/test/index.test.tsx b/packages/deepsignal/core/test/index.test.tsx index 895fa5c..258a012 100644 --- a/packages/deepsignal/core/test/index.test.tsx +++ b/packages/deepsignal/core/test/index.test.tsx @@ -370,6 +370,20 @@ describe("deepsignal/core", () => { }); describe("computations", () => { + it("should subscribe to changes when an item is removed from the array", () => { + const store = deepSignal([1, 2, 3]); + let sum = 0; + + effect(() => { + sum = 0; + sum = store.reduce((sum, item) => sum + item, 0); + }); + + expect(sum).to.equal(6); + store.splice(2, 1); + expect(sum).to.equal(3); + }); + it("should subscribe to changes to for..in loops", () => { const state: Record = { a: 1, b: 2 }; const store = deepSignal(state); From afeacef1f1f1cf6f2f936f20faee38a225aeb2ff Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Fri, 23 Jun 2023 15:57:39 +0200 Subject: [PATCH 07/10] Add changeset --- .changeset/brave-meals-end.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/brave-meals-end.md diff --git a/.changeset/brave-meals-end.md b/.changeset/brave-meals-end.md new file mode 100644 index 0000000..9427221 --- /dev/null +++ b/.changeset/brave-meals-end.md @@ -0,0 +1,5 @@ +--- +"deepsignal": patch +--- + +Add support for the `ownKeys` trap, which is used with `for..in`, `getOwnPropertyNames` or `Object.keys/values/entries`. From 43fda817218578ed821f2627c3416371d6f1aae7 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Fri, 23 Jun 2023 16:30:57 +0200 Subject: [PATCH 08/10] Don't access values when checking `ownKeys` and fix small bug --- packages/deepsignal/core/src/index.ts | 6 +- packages/deepsignal/core/test/index.test.tsx | 76 +++++++++----------- 2 files changed, 37 insertions(+), 45 deletions(-) diff --git a/packages/deepsignal/core/src/index.ts b/packages/deepsignal/core/src/index.ts index 1fbd52b..7be4e18 100644 --- a/packages/deepsignal/core/src/index.ts +++ b/packages/deepsignal/core/src/index.ts @@ -109,10 +109,8 @@ const objectHandlers = { if (key[0] === "$") throwOnMutation(); const signals = proxyToSignals.get(objToProxy.get(target)); const result = Reflect.deleteProperty(target, key); - if (signals && signals.has(key)) { - signals.get(key).value = undefined; - objToIterable.has(target) && objToIterable.get(target).value++; - } + if (signals && signals.has(key)) signals.get(key).value = undefined; + objToIterable.has(target) && objToIterable.get(target).value++; return result; }, ownKeys(target: object): (string | symbol)[] { diff --git a/packages/deepsignal/core/test/index.test.tsx b/packages/deepsignal/core/test/index.test.tsx index 258a012..0130d62 100644 --- a/packages/deepsignal/core/test/index.test.tsx +++ b/packages/deepsignal/core/test/index.test.tsx @@ -371,38 +371,38 @@ describe("deepsignal/core", () => { describe("computations", () => { it("should subscribe to changes when an item is removed from the array", () => { - const store = deepSignal([1, 2, 3]); + const store = deepSignal([0, 0, 0]); let sum = 0; effect(() => { sum = 0; - sum = store.reduce((sum, item) => sum + item, 0); + sum = store.reduce(sum => sum + 1, 0); }); - expect(sum).to.equal(6); - store.splice(2, 1); expect(sum).to.equal(3); + store.splice(2, 1); + expect(sum).to.equal(2); }); it("should subscribe to changes to for..in loops", () => { - const state: Record = { a: 1, b: 2 }; + const state: Record = { a: 0, b: 0 }; const store = deepSignal(state); let sum = 0; effect(() => { sum = 0; - for (const key in store) { - sum += store[key]; + for (const _ in store) { + sum += 1; } }); - expect(sum).to.equal(3); + expect(sum).to.equal(2); - store.c = 3; - expect(sum).to.equal(6); + store.c = 0; + expect(sum).to.equal(3); delete store.a; - expect(sum).to.equal(5); + expect(sum).to.equal(2); }); it("should subscribe to changes for Object.getOwnPropertyNames()", () => { @@ -413,18 +413,18 @@ describe("deepsignal/core", () => { effect(() => { sum = 0; const keys = Object.getOwnPropertyNames(store); - for (const key of keys) { - sum += store[key]; + for (const _ of keys) { + sum += 1; } }); - expect(sum).to.equal(3); + expect(sum).to.equal(2); - store.c = 3; - expect(sum).to.equal(6); + store.c = 0; + expect(sum).to.equal(3); delete store.a; - expect(sum).to.equal(5); + expect(sum).to.equal(2); }); it("should subscribe to changes to Object.keys/values/entries()", () => { @@ -436,62 +436,56 @@ describe("deepsignal/core", () => { effect(() => { keys = 0; - Object.keys(store).forEach(key => { - keys += store[key]; - }); + Object.keys(store).forEach(() => (keys += 1)); }); effect(() => { values = 0; Object.values(store as RevertDeepSignal).forEach( - value => { - values += value; - } + () => (values += 1) ); }); effect(() => { entries = 0; Object.entries(store as RevertDeepSignal).forEach( - ([_, value]) => { - entries += value; - } + () => (entries += 1) ); }); + expect(keys).to.equal(2); + expect(values).to.equal(2); + expect(entries).to.equal(2); + + store.c = 0; expect(keys).to.equal(3); expect(values).to.equal(3); expect(entries).to.equal(3); - store.c = 3; - expect(keys).to.equal(6); - expect(values).to.equal(6); - expect(entries).to.equal(6); - delete store.a; - expect(keys).to.equal(5); - expect(values).to.equal(5); - expect(entries).to.equal(5); + expect(keys).to.equal(2); + expect(values).to.equal(2); + expect(entries).to.equal(2); }); it("should subscribe to changes to for..of loops", () => { - const store = deepSignal([1, 2]); + const store = deepSignal([0, 0]); let sum = 0; effect(() => { sum = 0; - for (const value of store) { - sum += value; + for (const _ of store) { + sum += 1; } }); - expect(sum).to.equal(3); + expect(sum).to.equal(2); - store.push(3); - expect(sum).to.equal(6); + store.push(0); + expect(sum).to.equal(3); store.splice(0, 1); - expect(sum).to.equal(5); + expect(sum).to.equal(2); }); it("should subscribe to implicit changes in length", () => { From d15daa3ae0e889db548eeac358a0364c3a0147f7 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Fri, 23 Jun 2023 17:48:27 +0200 Subject: [PATCH 09/10] Fix bug when restoring deleted properties --- packages/deepsignal/core/src/index.ts | 3 ++- packages/deepsignal/core/test/index.test.tsx | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/deepsignal/core/src/index.ts b/packages/deepsignal/core/src/index.ts index 7be4e18..f747ef8 100644 --- a/packages/deepsignal/core/src/index.ts +++ b/packages/deepsignal/core/src/index.ts @@ -95,11 +95,12 @@ const objectHandlers = { objToProxy.set(val, createProxy(val, objectHandlers)); internal = objToProxy.get(val); } + const isNew = !(fullKey in target); const result = Reflect.set(target, fullKey, val, receiver); if (!signals.has(fullKey)) { signals.set(fullKey, signal(internal)); - objToIterable.has(target) && objToIterable.get(target).value++; } else signals.get(fullKey).value = internal; + if (isNew && objToIterable.has(target)) objToIterable.get(target).value++; if (Array.isArray(target) && signals.has("length")) signals.get("length").value = target.length; return result; diff --git a/packages/deepsignal/core/test/index.test.tsx b/packages/deepsignal/core/test/index.test.tsx index 0130d62..1a6e81d 100644 --- a/packages/deepsignal/core/test/index.test.tsx +++ b/packages/deepsignal/core/test/index.test.tsx @@ -401,8 +401,11 @@ describe("deepsignal/core", () => { store.c = 0; expect(sum).to.equal(3); - delete store.a; + delete store.c; expect(sum).to.equal(2); + + store.c = 0; + expect(sum).to.equal(3); }); it("should subscribe to changes for Object.getOwnPropertyNames()", () => { From 5395ba47868649f24420819cf232e132b865fca6 Mon Sep 17 00:00:00 2001 From: Luis Herranz Date: Fri, 23 Jun 2023 17:51:16 +0200 Subject: [PATCH 10/10] Remove unnecessary brakets --- packages/deepsignal/core/src/index.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/deepsignal/core/src/index.ts b/packages/deepsignal/core/src/index.ts index f747ef8..6451b35 100644 --- a/packages/deepsignal/core/src/index.ts +++ b/packages/deepsignal/core/src/index.ts @@ -97,9 +97,8 @@ const objectHandlers = { } const isNew = !(fullKey in target); const result = Reflect.set(target, fullKey, val, receiver); - if (!signals.has(fullKey)) { - signals.set(fullKey, signal(internal)); - } else signals.get(fullKey).value = internal; + if (!signals.has(fullKey)) signals.set(fullKey, signal(internal)); + else signals.get(fullKey).value = internal; if (isNew && objToIterable.has(target)) objToIterable.get(target).value++; if (Array.isArray(target) && signals.has("length")) signals.get("length").value = target.length;