diff --git a/README.md b/README.md index 80e58c8..b3039f3 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Use [Preact signals](https://github.com/preactjs/signals) with the interface of - [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) + - [Lit](https://stackblitz.com/edit/lit-and-deepsignal?file=src%2Fmy-element.js) - 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) @@ -37,15 +38,19 @@ Use [Preact signals](https://github.com/preactjs/signals) with the interface of - [`peek(state, "prop")`](#peekstate-prop) - [`state.$prop = signal(value)`](#stateprop--signalvalue) - [`useDeepSignal`](#usedeepsignal) + - [Common Patterns](#common-patterns) + - [Resetting the store](#resetting-the-store) - [When do you need access to signals?](#when-do-you-need-access-to-signals) - [Passing the value of a signal directly to JSX](#passing-the-value-of-a-signal-directly-to-jsx) - [Passing a signal to a child component](#passing-a-signal-to-a-child-component) - [TypeScript](#typescript) + - [`DeepSignal`](#deepsignal-1) + - [`RevertDeepSignal`](#revertdeepsignal) - [License](#license) ## Features -- **Transparent**: `deepsignal` wraps the objects with proxies that intercept all property accesses, but does not modify the object. This means that you can still use the object as you normally would, and it will behave exactly as expected. Mutating the object updates the value of the underlying signals. +- **Transparent**: `deepsignal` wraps the object with a proxy that intercepts all property accesses, but does not modify how you interact with the object. This means that you can still use the object as you normally would, and it will behave exactly as you would expect, except that mutating the object also updates the value of the underlying signals. - **Tiny (less than 1kB)**: `deepsignal` is designed to be lightweight and has a minimal footprint, making it easy to include in your projects. It's just a small wrapper around `@preact/signals-core`. - **Full array support**: `deepsignal` fully supports arrays, including nested arrays. - **Deep**: `deepsignal` converts nested objects and arrays to deep signal objects/arrays, allowing you to create fully reactive data structures. @@ -59,36 +64,89 @@ The most important feature is that **it just works**. You don't need to do anyth ## Installation +### With Preact + ```sh -npm install deepsignal +npm install deepsignal @preact/signals ``` -If you are using `deepsignal` with Preact (`@preact/signals`), you should use the `deepsignal` import. You don't need to install or import `@preact/signals` anywhere in your code if you don't need it. +If you are using `deepsignal` with Preact (`@preact/signals`), you should use the `deepsignal` import. You also need to install `@preact/signals`. ```js import { deepSignal } from "deepsignal"; -const state = deepSignal({}); +const state = deepSignal({ + count: 0, +}); + +const Count = () =>
{state.$count}
; ``` ### With React -If you are using the library with React, you should use the `deepsignal/react` import. You don't need to install or import `@preact/signals-react` anywhere in your code if you don't need it. +```sh +npm install deepsignal @preact/signals-react +``` + +If you are using the library with React (`@preact/signals-react`), you should use the `deepsignal/react` import. You also need to install `@preact/signals-react`. ```js import { deepSignal } from "deepsignal/react"; -const state = deepSignal({}); +const state = deepSignal({ + count: 0, +}); + +const Count = () =>
{state.$count}
; +``` + +- If you want to use `deepSignal` outside of the components, please follow the [React integration guide of `@preact/signals-react`](https://github.com/preactjs/signals/blob/main/packages/react/README.md#react-integration) to choose one of the integration methods. +- For `useDeepSignal`, no integration is required. + +### With Lit + +Lit now [supports Preact Signals](https://lit.dev/blog/2023-10-10-lit-3.0/#preact-signals-integration), so you can also use `deepsignal` in Lit. + +```sh +npm install deepsignal @lit-labs/preact-signals +``` + +If you are using the library just with Lit, you should use the `deepsignal/core` import. You also need to install `@lit-labs/preact-signals` and use its `SignalWatcher` function. + +```js +import { SignalWatcher } from "@lit-labs/preact-signals"; +import { deepSignal } from "deepsignal/core"; + +const state = deepSignal({ + count: 0, +}); + +class Count extends SignalWatcher(LitElement) { + render() { + return html`
${state.$count}
`; + } +} ``` -### Without Preact/React +### Without Preact/React/Lit -If you are using the library just with `@preact/signals-core`, you should use the `deepsignal/core` import. +```sh +npm install deepsignal @preact/signals-core +``` + +If you are using the library just with `@preact/signals-core`, you should use the `deepsignal/core` import. You also need to install `@preact/signals-core`. ```js +import { effect } from "@preact/signals-core"; import { deepSignal } from "deepsignal/core"; -const state = deepSignal({}); +const state = deepSignal({ + count: 0, +}); + +effect(() => { + console.log(`Count: ${state.count}`); +}); ``` This is because the `deepsignal` import includes a dependency on `@preact/signals`, while the `deepsignal/core` import does not. This allows you to use deep signals with either `@preact/signals` or `@preact/signals-core`, depending on your needs. **Do not use both.** @@ -296,7 +354,7 @@ _For primitive values, you can get away with using `store.$prop.peek()` instead ### `state.$prop = signal(value)` -You can modify the underlying signal of an object's property doing an assignment to the `$`-prefixed name. +You can modify the underlying signal of an object's property by doing an assignment to the `$`-prefixed name. ```js const state = deepSignal({ counter: 0 }); @@ -338,6 +396,28 @@ function Counter() { } ``` +## Common Patterns + +### Resetting the store + +If you need to reset your store to some initial values, don't overwrite the reference. Instead, replace each value using something like `Object.assign`. + +```js +const initialState = { counter: 0 }; + +const store = deepSignal({ + ...initialState, + inc: () => { + store.counter++; + }, + reset: () => { + Object.assign(store, initialState); + }, +}); +``` + +Take into account that the object that you pass to `deepSignal` during the creation is also mutated when you mutate the deep signal. Therefore, if you need to keep a set of initial values, you need to store them in a different object or clone it before assigning it to the deepsignal. + ## When do you need access to signals? You will only need access to the underlying signals for performance optimizations. @@ -426,11 +506,25 @@ 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 plain object/array to a `deepSignal` instance, and other to revert from a `deepSignal` instance back to the plain object/array. + +### DeepSignal + +You can use the `DeepSignal` type if you want to declare your type instead of inferring it. + +```ts +import type { DeepSignal } from "deepsignal"; + +type Store = DeepSignal<{ + counter: boolean; +}>; + +const store = deepSignal({ counter: 0 }); +``` -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. +### RevertDeepSignal -These types are handy when manual casting is needed, like when you try to use `Object.values()`: +You can use the `RevertDeepSignal` type if you want to recover the type of the plain object/array using the type of the `deepSignal` instance. For example, when you need to use `Object.values()`. ```ts import type { RevertDeepSignal } from "deepsignal"; diff --git a/packages/deepsignal/CHANGELOG.md b/packages/deepsignal/CHANGELOG.md index aa9769d..34b41d8 100644 --- a/packages/deepsignal/CHANGELOG.md +++ b/packages/deepsignal/CHANGELOG.md @@ -1,10 +1,24 @@ # deepsignal -## 1.4.0-shallow.0 +## 1.4.0 ### Minor Changes -- [`18a098e`](https://github.com/luisherranz/deepsignal/commit/18a098e6671061ef5830fc89f6dee364f414573d) Thanks [@luisherranz](https://github.com/luisherranz)! - Support storing shallow objects as part of the deepsignal with `shallow`. +- [#62](https://github.com/luisherranz/deepsignal/pull/62) [`beee51e`](https://github.com/luisherranz/deepsignal/commit/beee51e38c56ff94ccb6b3b14583a34f629a006a) Thanks [@luisherranz](https://github.com/luisherranz)! - Add support for setters. + +* [#59](https://github.com/luisherranz/deepsignal/pull/59) [`9b0ebbb`](https://github.com/luisherranz/deepsignal/commit/9b0ebbba3707f4170596671e97975c15e1b7650c) Thanks [@luisherranz](https://github.com/luisherranz)! - Add support for @preact/signals-react 2.0.0 + +## 1.3.6 + +### Patch Changes + +- [#42](https://github.com/luisherranz/deepsignal/pull/42) [`79db35b`](https://github.com/luisherranz/deepsignal/commit/79db35bebe4002c5d4e4ad77156b9ba609e14633) Thanks [@luisherranz](https://github.com/luisherranz)! - Add `preact` as peer dependency back and mark them as optional. + +## 1.3.5 + +### Patch Changes + +- [#40](https://github.com/luisherranz/deepsignal/pull/40) [`6284cd6`](https://github.com/luisherranz/deepsignal/commit/6284cd6db785a4ec48a6e2987fd6ea745cc36bdd) Thanks [@luisherranz](https://github.com/luisherranz)! - Use `@preact/signals` dependencies as peer dependencies. ## 1.3.4 diff --git a/packages/deepsignal/core/src/index.ts b/packages/deepsignal/core/src/index.ts index 6c5e1e5..38bd66d 100644 --- a/packages/deepsignal/core/src/index.ts +++ b/packages/deepsignal/core/src/index.ts @@ -6,6 +6,7 @@ const arrayToArrayOfSignals = new WeakMap(); const ignore = new WeakSet(); const objToIterable = new WeakMap(); const rg = /^\$/; +const descriptor = Object.getOwnPropertyDescriptor; let peeking = false; export const deepSignal = (obj: T): DeepSignal => { @@ -71,7 +72,7 @@ const get = const key = returnSignal ? fullKey.replace(rg, "") : fullKey; if ( !signals.has(key) && - typeof Object.getOwnPropertyDescriptor(target, key)?.get === "function" + typeof descriptor(target, key)?.get === "function" ) { signals.set( key, @@ -96,6 +97,8 @@ const get = const objectHandlers = { get: get(false), set(target: object, fullKey: string, val: any, receiver: object): boolean { + if (typeof descriptor(target, fullKey)?.set === "function") + return Reflect.set(target, fullKey, val, receiver); if (!proxyToSignals.has(receiver)) proxyToSignals.set(receiver, new Map()); const signals = proxyToSignals.get(receiver); if (fullKey[0] === "$") { diff --git a/packages/deepsignal/core/test/index.test.tsx b/packages/deepsignal/core/test/index.test.tsx index 2f71ea9..ad63b75 100644 --- a/packages/deepsignal/core/test/index.test.tsx +++ b/packages/deepsignal/core/test/index.test.tsx @@ -189,6 +189,21 @@ describe("deepsignal/core", () => { expect(store.nested.b).to.equal(3); }); + it("should support setting values with setters", () => { + const store = deepSignal({ + counter: 1, + get double() { + return store.counter * 2; + }, + set double(val) { + store.counter = val / 2; + }, + }); + expect(store.counter).to.equal(1); + store.double = 4; + expect(store.counter).to.equal(2); + }); + it("should update array length", () => { expect(store.array.length).to.equal(2); store.array.push(4); @@ -296,6 +311,16 @@ describe("deepsignal/core", () => { expect(store.a.nested.id).to.equal(4); expect(store.b.nested.id).to.equal(4); }); + + it("should be able to reset values with Object.assign", () => { + const initialNested = { ...nested }; + const initialState = { ...state, nested: initialNested }; + store.a = 2; + store.nested.b = 3; + Object.assign(store, initialState); + expect(store.a).to.equal(1); + expect(store.nested.b).to.equal(2); + }); }); describe("delete", () => { @@ -384,6 +409,31 @@ describe("deepsignal/core", () => { }); describe("computations", () => { + it("should subscribe to values mutated with setters", () => { + const store = deepSignal({ + counter: 1, + get double() { + return store.counter * 2; + }, + set double(val) { + store.counter = val / 2; + }, + }); + let counter = 0; + let double = 0; + + effect(() => { + counter = store.counter; + double = store.double; + }); + + expect(counter).to.equal(1); + expect(double).to.equal(2); + store.double = 4; + expect(counter).to.equal(2); + expect(double).to.equal(4); + }); + it("should subscribe to changes when an item is removed from the array", () => { const store = deepSignal([0, 0, 0]); let sum = 0; @@ -754,6 +804,30 @@ describe("deepsignal/core", () => { expect(spy1).callCount(4); expect(spy2).callCount(4); }); + + it("should be able to reset values with Object.assign and still react to changes", () => { + const initialNested = { ...nested }; + const initialState = { ...state, nested: initialNested }; + let a, b; + + effect(() => { + a = store.a; + }); + effect(() => { + b = store.nested.b; + }); + + store.a = 2; + store.nested.b = 3; + + expect(a).to.equal(2); + expect(b).to.equal(3); + + Object.assign(store, initialState); + + expect(a).to.equal(1); + expect(b).to.equal(2); + }); }); describe("peek", () => { diff --git a/packages/deepsignal/package.json b/packages/deepsignal/package.json index 357247e..7d40fd1 100644 --- a/packages/deepsignal/package.json +++ b/packages/deepsignal/package.json @@ -1,6 +1,6 @@ { "name": "deepsignal", - "version": "1.4.0-shallow.0", + "version": "1.4.0", "license": "MIT", "description": "", "keywords": [], @@ -44,17 +44,32 @@ "scripts": { "prepublishOnly": "cp ../../README.md . && cd ../.. && pnpm build" }, - "dependencies": { - "@preact/signals-core": "^1.3.1", + "peerDependencies": { + "@preact/signals-core": "^1.5.1", "@preact/signals": "^1.1.4", - "@preact/signals-react": "^1.3.3" + "@preact/signals-react": "^1.3.8 || ^2.0.0", + "preact": "^10.16.0" }, - "peerDependencies": { - "preact": "10.x" + "peerDependenciesMeta": { + "@preact/signals-core": { + "optional": true + }, + "@preact/signals": { + "optional": true + }, + "@preact/signals-react": { + "optional": true + }, + "preact": { + "optional": true + } }, "devDependencies": { "preact": "10.9.0", "preact-render-to-string": "^5.2.4", + "@preact/signals-core": "^1.5.1", + "@preact/signals": "^1.1.4", + "@preact/signals-react": "^2.0.0", "@types/react": "^18.0.18", "@types/react-dom": "^18.0.6", "react": "^18.2.0", diff --git a/packages/deepsignal/react/package.json b/packages/deepsignal/react/package.json index 8212290..fb59152 100644 --- a/packages/deepsignal/react/package.json +++ b/packages/deepsignal/react/package.json @@ -9,8 +9,8 @@ "source": "src/index.ts", "license": "MIT", "dependencies": { - "@preact/signals-core": "^1.3.1", - "@preact/signals-react": "^1.3.3" + "@preact/signals-core": "^1.5.1", + "@preact/signals-react": "^2.0.0" }, "peerDependencies": { "react": "17.x || 18.x" diff --git a/packages/deepsignal/react/src/index.ts b/packages/deepsignal/react/src/index.ts index 5bf6ef5..8b3993a 100644 --- a/packages/deepsignal/react/src/index.ts +++ b/packages/deepsignal/react/src/index.ts @@ -1,8 +1,10 @@ import "@preact/signals-react"; import { useMemo } from "react"; import { deepSignal, type DeepSignal } from "../../core/src"; +import { useSignals } from "@preact/signals-react/runtime"; export const useDeepSignal = (obj: T): DeepSignal => { + useSignals(); return useMemo(() => deepSignal(obj), []); }; diff --git a/packages/deepsignal/react/test/index.test.tsx b/packages/deepsignal/react/test/index.test.tsx index e8f91e9..52463d3 100644 --- a/packages/deepsignal/react/test/index.test.tsx +++ b/packages/deepsignal/react/test/index.test.tsx @@ -10,19 +10,24 @@ import { useDeepSignal, type DeepSignal } from "deepsignal/react"; describe("deepsignal/react", () => { let scratch: HTMLDivElement; let root: Root; - function render(element: Parameters[0]) { - act(() => root.render(element)); - } + let render: Root["render"]; - const window = globalThis as any; + beforeEach(async () => { + scratch = document.createElement("div"); + document.body.appendChild(scratch); - beforeEach(() => { - scratch = window.document.createElement("div"); - root = createRoot(scratch); + const realRoot = createRoot(scratch); + root = { + render: element => act(() => realRoot.render(element)), + unmount: () => act(() => realRoot.unmount()), + }; + + render = root.render.bind(root); }); afterEach(() => { act(() => root.unmount()); + scratch.remove(); }); describe("useDeepSignal", () => { @@ -37,7 +42,7 @@ describe("deepsignal/react", () => { } // @ts-ignore - render(); + await render(); expect(scratch.textContent).to.equal("test"); expect(spy).to.be.calledOnce; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 243db43..792c577 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,17 +138,16 @@ importers: version: 4.7.4 packages/deepsignal: - dependencies: + devDependencies: '@preact/signals': specifier: ^1.1.4 version: 1.1.4(preact@10.9.0) '@preact/signals-core': - specifier: ^1.3.1 - version: 1.3.1 + specifier: ^1.5.1 + version: 1.5.1 '@preact/signals-react': - specifier: ^1.3.3 - version: 1.3.3(react@18.2.0) - devDependencies: + specifier: ^2.0.0 + version: 2.0.0(react@18.2.0) '@types/react': specifier: ^18.0.18 version: 18.0.27 @@ -1906,28 +1905,28 @@ packages: fastq: 1.13.0 dev: true - /@preact/signals-core@1.3.1: - resolution: {integrity: sha512-DL+3kDssZ3UOMz9HufwSYE/gK0+TnT1jzegfF5rstgyPrnyfjz4BHAoxmzQA6Mkp4UlKe8qjsgl3v5a/obzNig==} - dev: false + /@preact/signals-core@1.5.1: + resolution: {integrity: sha512-dE6f+WCX5ZUDwXzUIWNMhhglmuLpqJhuy3X3xHrhZYI0Hm2LyQwOu0l9mdPiWrVNsE+Q7txOnJPgtIqHCYoBVA==} + dev: true - /@preact/signals-react@1.3.3(react@18.2.0): - resolution: {integrity: sha512-Tbv+oWPcrWowAJp1U1eWFiFUJihulOAnL8g/hJ3fUsP0IcsKsj8U0OcIDrwemIPQe7+J/3FuudkZzted0MD/bA==} + /@preact/signals-react@2.0.0(react@18.2.0): + resolution: {integrity: sha512-tMVi2SXFXlojaiPNWa8dlYaidR/XvEgMSp+iymKJgMssBM/QVtUQrodKZek1BJju+dkVHiyeuQHmkuLOI9oyNw==} peerDependencies: react: ^16.14.0 || 17.x || 18.x dependencies: - '@preact/signals-core': 1.3.1 + '@preact/signals-core': 1.5.1 react: 18.2.0 use-sync-external-store: 1.2.0(react@18.2.0) - dev: false + dev: true /@preact/signals@1.1.4(preact@10.9.0): resolution: {integrity: sha512-4s0U8yHfy6OzBjTx8lgG0JZGFq1y+ye7xLmaaI8SXyJ5p+jAtMKW40Mg3GDLISM5WyKnqbfrQ4SOmbq1VTBtTw==} peerDependencies: preact: 10.x dependencies: - '@preact/signals-core': 1.3.1 + '@preact/signals-core': 1.5.1 preact: 10.9.0 - dev: false + dev: true /@rollup/plugin-alias@3.1.9(rollup@2.77.2): resolution: {integrity: sha512-QI5fsEvm9bDzt32k39wpOwZhVzRcL5ydcffUHMyLVaVaLeC70I8TJZ17F1z1eMoLu4E/UOcH9BWVkKpIKdrfiw==} @@ -4585,6 +4584,7 @@ packages: /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + dev: true /js-yaml@3.14.1: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} @@ -4934,6 +4934,7 @@ packages: hasBin: true dependencies: js-tokens: 4.0.0 + dev: true /loupe@2.3.4: resolution: {integrity: sha512-OvKfgCC2Ndby6aSTREl5aCCPTNIzlDfQZvZxNUrBrihDhL3xcrYegTblhmEiCrg2kKQz4XsFIaemE5BF4ybSaQ==} @@ -5938,6 +5939,7 @@ packages: /preact@10.9.0: resolution: {integrity: sha512-jO6/OvCRL+OT8gst/+Q2ir7dMybZAX8ioP02Zmzh3BkQMHLyqZSujvxbUriXvHi8qmhcHKC2Gwbog6Kt+YTh+Q==} + dev: true /preferred-pm@3.0.3: resolution: {integrity: sha512-+wZgbxNES/KlJs9q40F/1sfOd/j7f1O9JaHcW5Dsn3aUUOZg3L2bjpVUcKV2jvtElYfoTuQiNeMfQJ4kwUAhCQ==} @@ -6047,6 +6049,7 @@ packages: engines: {node: '>=0.10.0'} dependencies: loose-envify: 1.4.0 + dev: true /read-pkg-up@7.0.1: resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} @@ -7009,7 +7012,7 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 dependencies: react: 18.2.0 - dev: false + dev: true /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}