Skip to content

Commit

Permalink
Merge pull request #38 from luisherranz/shallow
Browse files Browse the repository at this point in the history
Support storing shallow objects as part of the deepsignal
  • Loading branch information
luisherranz authored Jan 31, 2024
2 parents 5cf8d8f + 2e24d23 commit 8e7875c
Show file tree
Hide file tree
Showing 8 changed files with 382 additions and 108 deletions.
5 changes: 5 additions & 0 deletions .changeset/nice-spoons-kick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"deepsignal": minor
---

Support storing shallow objects as part of the deepsignal with `shallow`.
10 changes: 10 additions & 0 deletions .changeset/pre.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"mode": "exit",
"tag": "shallow",
"initialVersions": {
"deepsignal": "1.3.4"
},
"changesets": [
"nice-spoons-kick"
]
}
106 changes: 101 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Use [Preact signals](https://github.com/preactjs/signals) with the interface of
- [`array.$[index]`](#arrayindex)
- [`array.$length`](#arraylength)
- [`peek(state, "prop")`](#peekstate-prop)
- [`shallow(obj)`](#shallowobj)
- [`state.$prop = signal(value)`](#stateprop--signalvalue)
- [`useDeepSignal`](#usedeepsignal)
- [Common Patterns](#common-patterns)
Expand All @@ -46,6 +47,7 @@ Use [Preact signals](https://github.com/preactjs/signals) with the interface of
- [TypeScript](#typescript)
- [`DeepSignal`](#deepsignal-1)
- [`RevertDeepSignal`](#revertdeepsignal)
- [`Shallow`](#shallow)
- [License](#license)

## Features
Expand Down Expand Up @@ -352,6 +354,54 @@ Note that you should only use `peek()` if you really need it. Reading a signal's

_For primitive values, you can get away with using `store.$prop.peek()` instead of `peek(state, "prop")`. But in `deepsignal`, the underlying signals store the proxies, not the object. That means it's not safe to use `state.$prop.peek().nestedProp` if `prop` is an object. You should use `peek(state, "prop").nestedProp` instead._

### `shallow(obj)`

When using `deepsignal`, all nested objects and arrays are turned into deep signal objects/arrays. The `shallow` function is a utility that allows you to declare an object as shallow within the context of `deepsignal`. Shallow objects do not proxy their properties, meaning changes to their properties are not observed for reactivity. This can be useful for objects that you don't want to be reactive or when you have an object that should not trigger UI updates when changed.

```js
import { deepSignal, shallow } from "deepsignal";

const shallowObj = { key: "value" };
const store = deepSignal({
someData: shallow(shallowObj),
});

// Accessing `store.someData` gives you the original object.
console.log(store.someData === shallowObj); // true

// Mutating `store.someData` does NOT trigger reactivity.
store.someData.key = "newValue";
// No reactive update is triggered since `someData` is shallow.
```

In practice, this means you can have parts of your state that are mutable and changeable without causing rerenders or effects to run. This becomes particularly useful for large datasets or configuration objects that you might want to include in your global state but do not need to be reactive.

#### Observing reference changes

Although properties of a shallow object are not reactive, the reference to the shallow object itself is observed. If you replace the reference of a shallow object with another reference, it will trigger reactive updates:

```js
import { deepSignal, shallow } from "deepsignal";

const store = deepSignal({
someData: shallow({ key: "value" }),
});

effect(() => {
console.log(store.someData.key);
});

// Changing the properties of `someData` does not trigger the `effect`.
store.someData.key = "changed";
// No log output.

// But replacing `someData` with a new object triggers the `effect` because store.someData is still tracked.
store.someData = shallow({ key: "new value" });
// It will log 'new value'.
```

With `shallow`, you have control over the granularity of reactivity in your store, mixing both reactive deep structures with non-reactive shallow portions as needed.

### `state.$prop = signal(value)`

You can modify the underlying signal of an object's property by doing an assignment to the `$`-prefixed name.
Expand Down Expand Up @@ -510,16 +560,44 @@ DeepSignal exports two types, one to convert from a plain object/array to a `dee

### DeepSignal

You can use the `DeepSignal` type if you want to declare your type instead of inferring it.
You can use the `DeepSignal` type if you want to manually convert a type to a deep signal outside of the `deepSignal` function, but this is usually not required.

One scenario where this could be useful is when doing external assignments. Imagine this case:

```ts
import { deepSignal } from "deepsignal";

type State = { map: Record<string, boolean> };

const state = deepSignal<State>({ map: {} });
```

If you want to assign a new object to `state.map`, TypeScript will complain because it expects a deep signal type, not a plain object one:

```ts
const newMap: State["map"] = { someKey: true };

state.map = newMap; // This will fail because state.map expects a deep signal type.
```

You can use the `DeepSignal` type to convert a regular type into a deep signal one:

```ts
import type { DeepSignal } from "deepsignal";

type Store = DeepSignal<{
counter: boolean;
}>;
const newMap: DeepSignal<State["map"]> = { someKey: true };

state.map = newMap; // This is fine now.
```

You can also manually cast the type on the fly if you prefer:

const store = deepSignal<Store>({ counter: 0 });
```ts
state.map = newMap as DeepSignal<typeof newMap>;
```

```ts
state.map = { someKey: true } as DeepSignal<State["map"]>;
```

### RevertDeepSignal
Expand All @@ -532,6 +610,24 @@ import type { RevertDeepSignal } from "deepsignal";
const values = Object.values(store as RevertDeepSignal<typeof store>);
```

### Shallow

You can use the `Shallow` type if you want to type a store that contains a shallow object.

```ts
import type { Shallow } from "deepsignal";

type Store = {
obj: { a: number };
shallowObj: { b: number };
};

const store = deepSignal<Store>({
obj: { a: 1 },
shallowObj: shallow({ b: 2 }),
});
```

## License

`MIT`, see the [LICENSE](./LICENSE) file.
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@
"@babel/preset-env": "^7.19.1",
"@babel/preset-react": "^7.18.6",
"@babel/preset-typescript": "^7.18.6",
"@changesets/changelog-github": "^0.4.6",
"@changesets/cli": "^2.24.2",
"@changesets/changelog-github": "^0.4.8",
"@changesets/cli": "^2.26.2",
"@types/chai": "^4.3.3",
"@types/mocha": "^9.1.1",
"@types/node": "^18.6.5",
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 @@ -3,7 +3,7 @@ import { computed, signal, Signal } from "@preact/signals-core";
const proxyToSignals = new WeakMap();
const objToProxy = new WeakMap();
const arrayToArrayOfSignals = new WeakMap();
const proxies = new WeakSet();
const ignore = new WeakSet();
const objToIterable = new WeakMap();
const rg = /^\$/;
const descriptor = Object.getOwnPropertyDescriptor;
Expand Down Expand Up @@ -31,9 +31,15 @@ export const peek = <
return value as RevertDeepSignal<RevertDeepSignalObject<T>[K]>;
};

const isShallow = Symbol("shallow");
export function shallow<T extends object>(obj: T): Shallow<T> {
ignore.add(obj);
return obj as Shallow<T>;
}

const createProxy = (target: object, handlers: ProxyHandler<object>) => {
const proxy = new Proxy(target, handlers);
proxies.add(proxy);
ignore.add(proxy);
return proxy;
};

Expand Down Expand Up @@ -143,12 +149,14 @@ const shouldProxy = (val: any): boolean => {
typeof val.constructor === "function" &&
val.constructor.name in globalThis &&
(globalThis as any)[val.constructor.name] === val.constructor;
return (!isBuiltIn || supported.has(val.constructor)) && !proxies.has(val);
return (!isBuiltIn || supported.has(val.constructor)) && !ignore.has(val);
};

/** TYPES **/

export type DeepSignal<T> = T extends Function
? T
: T extends { [isShallow]: true }
? T
: T extends Array<unknown>
? DeepSignalArray<T>
Expand Down Expand Up @@ -269,6 +277,8 @@ type DeepSignalArray<T> = DeepArray<ArrayType<T>> & {
$length?: Signal<number>;
};

export type Shallow<T extends object> = T & { [isShallow]: true };

export declare const useDeepSignal: <T extends object>(obj: T) => DeepSignal<T>;

type FilterSignals<K> = K extends `$${infer P}` ? never : K;
Expand Down
74 changes: 73 additions & 1 deletion packages/deepsignal/core/test/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Signal, effect, signal } from "@preact/signals-core";
import { deepSignal, peek } from "deepsignal/core";
import { deepSignal, peek, shallow } from "deepsignal/core";
import type { RevertDeepSignal } from "deepsignal/core";

type Store = {
Expand Down Expand Up @@ -1039,4 +1039,76 @@ describe("deepsignal/core", () => {
expect(x).to.equal(undefined);
});
});

describe("shallow", () => {
it("should not proxy shallow objects", () => {
const shallowObj1 = { a: 1 };
let shallowObj2 = { b: 2 };
const deepObj = { c: 3 };
shallowObj2 = shallow(shallowObj2);
const store = deepSignal({
shallowObj1: shallow(shallowObj1),
shallowObj2,
deepObj,
});
expect(store.shallowObj1.a).to.equal(1);
expect(store.shallowObj2.b).to.equal(2);
expect(store.deepObj.c).to.equal(3);
expect(store.shallowObj1).to.equal(shallowObj1);
expect(store.shallowObj2).to.equal(shallowObj2);
expect(store.deepObj).to.not.equal(deepObj);
});

it("should not proxy shallow objects if shallow is called on the reference before accessing the property", () => {
const shallowObj = { a: 1 };
const deepObj = { c: 3 };
const store = deepSignal({ shallowObj, deepObj });
shallow(shallowObj);
expect(store.shallowObj.a).to.equal(1);
expect(store.deepObj.c).to.equal(3);
expect(store.shallowObj).to.equal(shallowObj);
expect(store.deepObj).to.not.equal(deepObj);
});

it("should observe changes in the shallow object if the reference changes", () => {
const obj = { a: 1 };
const shallowObj = shallow(obj);
const store = deepSignal({ shallowObj });
let x;
effect(() => {
x = store.shallowObj.a;
});
expect(x).to.equal(1);
store.shallowObj = shallow({ a: 2 });
expect(x).to.equal(2);
});

it("should stop observing changes in the shallow object if the reference changes and it's not shallow anymore", () => {
const obj = { a: 1 };
const shallowObj = shallow(obj);
const store = deepSignal<{ obj: typeof obj }>({ obj: shallowObj });
let x;
effect(() => {
x = store.obj.a;
});
expect(x).to.equal(1);
store.obj = { a: 2 };
expect(x).to.equal(2);
store.obj.a = 3;
expect(x).to.equal(3);
});

it("should not observe changes in the props of the shallow object", () => {
const obj = { a: 1 };
const shallowObj = shallow(obj);
const store = deepSignal({ shallowObj });
let x;
effect(() => {
x = store.shallowObj.a;
});
expect(x).to.equal(1);
store.shallowObj.a = 2;
expect(x).to.equal(1);
});
});
});
69 changes: 68 additions & 1 deletion packages/deepsignal/core/test/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { signal, Signal } from "@preact/signals-core";
import { deepSignal, peek } from "../src";
import { deepSignal, peek, shallow } from "../src";
import type { Shallow } from "../src";

// Arrays.
const array = deepSignal([{ a: 1 }, { a: 2 }]);
Expand Down Expand Up @@ -74,3 +75,69 @@ store2.a = a;
store2.$a = a;
const s5: number = store2.a!;
const s6: Signal<number | undefined> = store2.$a;

// Shallow
const store3 = deepSignal({
a: { b: 1 },
c: shallow({ b: 2 }),
});

store3.a.$b;
// @ts-expect-error
store3.c.$b;

store3.a = { b: 1 };
// @ts-expect-error
store3.c = { b: 2 };
store3.c = shallow({ b: 2 });

type Store4 = {
a: { b: number };
c: Shallow<{ b: number }>;
d: Shallow<{ b: number }>;
};

const store4 = deepSignal<Store4>({
a: { b: 1 },
c: shallow({ b: 2 }),
// @ts-expect-error
d: { b: 3 },
});

store4.a.$b;
// @ts-expect-error
store4.c.$b;

// Manual typings
type Store5 = {
a: { b: number };
c: { b: number };
};
const store5 = deepSignal<Store5>({
a: { b: 1 },
c: { b: 1 },
});

store5.a.b;
store5.a.$b;
store5.c.b;
store5.c.$b;
// @ts-expect-error
store5.d;
store5.c = { b: 2 };

type Store6 = {
[key: string]: { b: number };
};

const store6 = deepSignal<Store6>({
a: { b: 1 },
c: { b: 1 },
});

store6.a.b;
store6.a.$b;
store6.c.b;
store6.c.$b;
store6.d = { b: 2 };
store6.d.$b;
Loading

0 comments on commit 8e7875c

Please sign in to comment.