Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for the ownKeys trap #26

Merged
merged 10 commits into from
Jun 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading