Skip to content

Commit

Permalink
Merge pull request #26 from luisherranz/add-support-for-own-keys
Browse files Browse the repository at this point in the history
Add support for the `ownKeys` trap
  • Loading branch information
luisherranz committed Jun 23, 2023
2 parents b7d70d1 + 5395ba4 commit 750d263
Show file tree
Hide file tree
Showing 5 changed files with 223 additions and 12 deletions.
5 changes: 5 additions & 0 deletions .changeset/brave-meals-end.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"deepsignal": patch
---

Add support for the `ownKeys` trap, which is used with `for..in`, `getOwnPropertyNames` or `Object.keys/values/entries`.
26 changes: 19 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---
Expand Down Expand Up @@ -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<typeof store>);
```

## License

`MIT`, see the [LICENSE](./LICENSE) file.
Expand Down
16 changes: 13 additions & 3 deletions packages/deepsignal/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -94,9 +95,11 @@ 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));
else signals.get(fullKey).value = internal;
const result = Reflect.set(target, fullKey, val, receiver);
if (isNew && objToIterable.has(target)) objToIterable.get(target).value++;
if (Array.isArray(target) && signals.has("length"))
signals.get("length").value = target.length;
return result;
Expand All @@ -105,8 +108,15 @@ const objectHandlers = {
deleteProperty(target: object, key: string): boolean {
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;
return Reflect.deleteProperty(target, key);
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);
},
};

Expand Down Expand Up @@ -260,7 +270,7 @@ type FilterSignals<K> = K extends `$${infer P}` ? never : K;
type RevertDeepSignalObject<T> = Pick<T, FilterSignals<keyof T>>;
type RevertDeepSignalArray<T> = Omit<T, "$" | "$length">;

type RevertDeepSignal<T> = T extends Array<unknown>
export type RevertDeepSignal<T> = T extends Array<unknown>
? RevertDeepSignalArray<T>
: T extends object
? RevertDeepSignalObject<T>
Expand Down
183 changes: 182 additions & 1 deletion packages/deepsignal/core/test/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -322,11 +323,191 @@ 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();
});
});

describe("ownKeys", () => {
it("should return own properties in objects", () => {
const state: Record<string, number> = { 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 when an item is removed from the array", () => {
const store = deepSignal([0, 0, 0]);
let sum = 0;

effect(() => {
sum = 0;
sum = store.reduce(sum => sum + 1, 0);
});

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<string, number> = { a: 0, b: 0 };
const store = deepSignal(state);
let sum = 0;

effect(() => {
sum = 0;
for (const _ in store) {
sum += 1;
}
});

expect(sum).to.equal(2);

store.c = 0;
expect(sum).to.equal(3);

delete store.c;
expect(sum).to.equal(2);

store.c = 0;
expect(sum).to.equal(3);
});

it("should subscribe to changes for Object.getOwnPropertyNames()", () => {
const state: Record<string, number> = { a: 1, b: 2 };
const store = deepSignal(state);
let sum = 0;

effect(() => {
sum = 0;
const keys = Object.getOwnPropertyNames(store);
for (const _ of keys) {
sum += 1;
}
});

expect(sum).to.equal(2);

store.c = 0;
expect(sum).to.equal(3);

delete store.a;
expect(sum).to.equal(2);
});

it("should subscribe to changes to Object.keys/values/entries()", () => {
const state: Record<string, number> = { a: 1, b: 2 };
const store = deepSignal(state);
let keys = 0;
let values = 0;
let entries = 0;

effect(() => {
keys = 0;
Object.keys(store).forEach(() => (keys += 1));
});

effect(() => {
values = 0;
Object.values(store as RevertDeepSignal<typeof store>).forEach(
() => (values += 1)
);
});

effect(() => {
entries = 0;
Object.entries(store as RevertDeepSignal<typeof store>).forEach(
() => (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);

delete store.a;
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([0, 0]);
let sum = 0;

effect(() => {
sum = 0;
for (const _ of store) {
sum += 1;
}
});

expect(sum).to.equal(2);

store.push(0);
expect(sum).to.equal(3);

store.splice(0, 1);
expect(sum).to.equal(2);
});

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;

Expand Down
5 changes: 4 additions & 1 deletion packages/deepsignal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

0 comments on commit 750d263

Please sign in to comment.