diff --git a/CHANGELOG.md b/CHANGELOG.md index ceb577e..80a5e6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +## Added + +- feat: support nested objects and slice pattern #7 + ## [0.3.0] - 2024-07-13 ## Added diff --git a/README.md b/README.md index 67c32f2..e153dda 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ You can also try them directly: [01](https://stackblitz.com/github/zustandjs/zustand-valtio/tree/main/examples/01_counter) [02](https://stackblitz.com/github/zustandjs/zustand-valtio/tree/main/examples/02_methods) [03](https://stackblitz.com/github/zustandjs/zustand-valtio/tree/main/examples/03_middleware) +[04](https://stackblitz.com/github/zustandjs/zustand-valtio/tree/main/examples/04_slices) ## Tweets diff --git a/examples/04_slices/index.html b/examples/04_slices/index.html new file mode 100644 index 0000000..ec005a3 --- /dev/null +++ b/examples/04_slices/index.html @@ -0,0 +1,9 @@ + + + example + + +
+ + + diff --git a/examples/04_slices/package.json b/examples/04_slices/package.json new file mode 100644 index 0000000..12e720a --- /dev/null +++ b/examples/04_slices/package.json @@ -0,0 +1,22 @@ +{ + "name": "example", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "react": "latest", + "react-dom": "latest", + "valtio": "latest", + "zustand": "latest", + "zustand-valtio": "latest" + }, + "devDependencies": { + "@types/react": "latest", + "@types/react-dom": "latest", + "typescript": "latest", + "vite": "latest" + }, + "scripts": { + "dev": "vite" + } +} diff --git a/examples/04_slices/src/app.tsx b/examples/04_slices/src/app.tsx new file mode 100644 index 0000000..60ca1c0 --- /dev/null +++ b/examples/04_slices/src/app.tsx @@ -0,0 +1,67 @@ +import { create } from 'zustand'; +import { withProxy } from 'zustand-valtio'; + +const countSlice = { + value: 0, + inc() { + this.value++; + }, + reset() { + this.value = 0; + }, +}; + +const textSlice = { + value: 'Hello', + updateText(newText: string) { + this.value = newText; + }, + reset() { + this.value = 'Hello'; + }, +}; + +const useCounterState = create( + withProxy({ + count: countSlice, + text: textSlice, + reset() { + this.count.reset(); + this.text.reset(); + }, + }), +); + +const Counter = () => { + const count = useCounterState((state) => state.count.value); + const inc = useCounterState((state) => state.count.inc); + const text = useCounterState((state) => state.text.value); + const updateText = useCounterState((state) => state.text.updateText); + const reset = useCounterState((state) => state.reset); + return ( + <> +

+ Count: {count} + +

+

+ updateText(e.target.value)} /> +

+

+ +

+ + ); +}; + +const App = () => ( +
+ +
+); + +export default App; diff --git a/examples/04_slices/src/main.tsx b/examples/04_slices/src/main.tsx new file mode 100644 index 0000000..1a72c01 --- /dev/null +++ b/examples/04_slices/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import App from './app'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/examples/04_slices/tsconfig.json b/examples/04_slices/tsconfig.json new file mode 100644 index 0000000..f9e0a7e --- /dev/null +++ b/examples/04_slices/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "strict": true, + "target": "es2018", + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "skipLibCheck": true, + "allowJs": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "jsx": "react-jsx" + } +} diff --git a/package.json b/package.json index f60c185..d5948ae 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "test:spec": "vitest run", "examples:01_counter": "DIR=01_counter vite", "examples:02_methods": "DIR=02_methods vite", - "examples:03_middleware": "DIR=03_middleware vite" + "examples:03_middleware": "DIR=03_middleware vite", + "examples:04_slices": "DIR=04_slices vite" }, "keywords": [ "react", diff --git a/src/index.ts b/src/index.ts index 99c6768..5052f31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,20 @@ import type { StateCreator, StoreApi, StoreMutatorIdentifier } from 'zustand'; import { proxy, snapshot, subscribe } from 'valtio/vanilla'; +type AnyFunction = (...args: never[]) => unknown; + +type DeepWritable = T extends object + ? { -readonly [K in keyof T]: DeepWritable } + : T; + // TODO replace with Valtio's Snapshot type in v2 -type Snapshot = T extends (...args: never[]) => unknown +type Snapshot = T extends AnyFunction ? T : T extends object ? { readonly [K in keyof T]: Snapshot } : T; type StoreWithProxy = { - setState: never; getProxyState: () => T; }; @@ -19,10 +24,6 @@ type WithWithProxy = S extends { getState: () => Snapshot } ? Write>> : never; -type DeepWritable = T extends object - ? { -readonly [K in keyof T]: DeepWritable } - : T; - declare module 'zustand/vanilla' { interface StoreMutators { 'zustand-valtio': WithWithProxy; @@ -41,8 +42,51 @@ type WithProxyImpl = ( initialObject: T, ) => StateCreator, [], []>; +const isObject = (x: unknown): x is object => + typeof x === 'object' && x !== null && !(x instanceof Promise); + +const patchObjectMethods = ( + proxyObject: T, + fn: (obj: object, key: string, fn: AnyFunction) => void, +) => { + (Object.getOwnPropertyNames(proxyObject) as (keyof T)[]).forEach((key) => { + const value = proxyObject[key]; + if (typeof value === 'function') { + fn(proxyObject, key as string, value as AnyFunction); + } else if (isObject(value)) { + patchObjectMethods(value, fn); + } + }); +}; + +const applyChanges = (proxyObject: T, prev: T, next: T) => { + (Object.getOwnPropertyNames(prev) as (keyof T)[]).forEach((key) => { + if (!(key in next)) { + delete proxyObject[key]; + } else if (Object.is(prev[key], next[key])) { + // unchanged + } else if ( + isObject(proxyObject[key]) && + isObject(prev[key]) && + isObject(next[key]) + ) { + applyChanges( + proxyObject[key] as unknown as object, + prev[key] as unknown as object, + next[key] as unknown as object, + ); + } else { + proxyObject[key] = next[key]; + } + }); + (Object.keys(next) as (keyof T)[]).forEach((key) => { + if (!(key in prev)) { + proxyObject[key] = next[key]; + } + }); +}; + const withProxyImpl: WithProxyImpl = (initialObject) => (set, get, api) => { - type AnyObject = Record; const proxyState = proxy(initialObject); let mutating = 0; let updating = 0; @@ -53,37 +97,30 @@ const withProxyImpl: WithProxyImpl = (initialObject) => (set, get, api) => { --updating; } }; - Object.keys(proxyState).forEach((key) => { - const fn = (proxyState as AnyObject)[key]; - // TODO this doesn't handle nested objects - if (typeof fn === 'function') { - (proxyState as AnyObject)[key] = (...args: never[]) => { + patchObjectMethods(proxyState, (obj, key, fn) => { + Object.defineProperty(obj, key, { + value: (...args: never[]) => { try { ++mutating; - return fn.apply(proxyState, args); + return fn.apply(obj, args); } finally { --mutating; updateState(); } - }; - } + }, + }); }); type Api = StoreApi & StoreWithProxy; - delete (api as Api).setState; (api as Api).getProxyState = () => proxyState; subscribe(proxyState, updateState, true); api.subscribe(() => { if (!updating) { // HACK for persist hydration - const state = get() as AnyObject; - Object.keys(state).forEach((key) => { - const val = state[key]; - // TODO this doesn't handle nested objects - if (typeof val !== 'function') { - // XXX this will throw if val is a snapshot - (proxyState as AnyObject)[key] = val; - } - }); + applyChanges( + proxyState, + snapshot(proxyState) as typeof proxyState, + get() as typeof proxyState, + ); } }); return snapshot(proxyState) as Snapshot;