Repository: pmndrs/valtio (ts)

Issue:
Immer-like patches
Hi. Any chance this library could support something like immer patches? It would be an efficient mechanism to implement undo history and distribute state changes (e.g. via websocket), since only state deltas are stored.

Something similar was mentioned here https://github.com/pmndrs/valtio/issues/89.

Immer patches for references https://immerjs.github.io/immer/patches/.

Pull request description:
feat: subscribe ops
close #155 

I have been hesitating to add a feature to core. I kept saying it's not how valtio works.

@zcaudate suggested an api https://github.com/pmndrs/valtio/issues/155#issuecomment-881150875
and it's fairly reasonable.

Note again that this change is not necessary for useMutableSource, and for useSnapshot, this is a pure overhead (in terms of both cpu and bundle size).
Nevertheless, this is something that only core can do, so I'd accept it.

Hope someone can create a history(undo/redo) feature in utils.

Patch:
diff --git a/.size-snapshot.json b/.size-snapshot.json
index 5740a6c..4d23198 100644
--- a/.size-snapshot.json
+++ b/.size-snapshot.json
@@ -14,9 +14,9 @@
     }
   },
   "vanilla.js": {
-    "bundled": 5875,
-    "minified": 2857,
-    "gzipped": 1104,
+    "bundled": 6624,
+    "minified": 3127,
+    "gzipped": 1218,
     "treeshaked": {
       "rollup": {
         "code": 22,
@@ -28,9 +28,9 @@
     }
   },
   "utils.js": {
-    "bundled": 5867,
-    "minified": 2678,
-    "gzipped": 1197,
+    "bundled": 5769,
+    "minified": 2653,
+    "gzipped": 1202,
     "treeshaked": {
       "rollup": {
         "code": 45,
diff --git a/src/utils.ts b/src/utils.ts
index aadc7f4..e790d33 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -18,19 +18,16 @@ export const subscribeKey = <T extends object>(
   key: keyof T,
   callback: (value: T[typeof key]) => void,
   notifyInSync?: boolean
-) => {
-  let prevValue = proxyObject[key]
-  return subscribe(
+) =>
+  subscribe(
     proxyObject,
-    () => {
-      const nextValue = proxyObject[key]
-      if (!Object.is(prevValue, nextValue)) {
-        callback((prevValue = nextValue))
+    (ops) => {
+      if (ops.some((op) => op[1][0] === key)) {
+        callback(proxyObject[key])
       }
     },
     notifyInSync
   )
-}
 
 /**
  * devtools
diff --git a/src/vanilla.ts b/src/vanilla.ts
index 579ad7b..9570a81 100644
--- a/src/vanilla.ts
+++ b/src/vanilla.ts
@@ -29,6 +29,14 @@ const isSupportedObject = (x: unknown): x is object =>
 type ProxyObject = object
 const proxyCache = new WeakMap<object, ProxyObject>()
 
+type Path = (string | symbol)[]
+type Op =
+  | [op: 'set', path: Path, value: unknown, prevValue: unknown]
+  | [op: 'delete', path: Path, prevValue: unknown]
+  | [op: 'resolve', path: Path, value: unknown]
+  | [op: 'reject', path: Path, error: unknown]
+type Listener = (op: Op, nextVersion: number) => void
+
 let globalVersion = 1
 const snapshotCache = new WeakMap<
   object,
@@ -44,16 +52,34 @@ export const proxy = <T extends object>(initialObject: T = {} as T): T => {
     return found
   }
   let version = globalVersion
-  const listeners = new Set<(nextVersion: number) => void>()
-  const notifyUpdate = (nextVersion?: number) => {
+  const listeners = new Set<Listener>()
+  const notifyUpdate = (op: Op, nextVersion?: number) => {
     if (!nextVersion) {
       nextVersion = ++globalVersion
     }
     if (version !== nextVersion) {
       version = nextVersion
-      listeners.forEach((listener) => listener(nextVersion as number))
+      listeners.forEach((listener) => listener(op, nextVersion as number))
     }
   }
+  const propListeners = new Map<string | symbol, Listener>()
+  const getPropListener = (prop: string | symbol) => {
+    let propListener = propListeners.get(prop)
+    if (!propListener) {
+      propListener = (op, nextVersion) => {
+        const newOp: Op = [...op]
+        newOp[1] = [prop, ...(newOp[1] as Path)]
+        notifyUpdate(newOp, nextVersion)
+      }
+      propListeners.set(prop, propListener)
+    }
+    return propListener
+  }
+  const popPropListener = (prop: string | symbol) => {
+    const propListener = propListeners.get(prop)
+    propListeners.delete(prop)
+    return propListener
+  }
   const createSnapshot = (target: any, receiver: any) => {
     const cache = snapshotCache.get(receiver)
     if (cache?.[0] === version) {
@@ -111,11 +137,11 @@ export const proxy = <T extends object>(initialObject: T = {} as T): T => {
       const prevValue = target[prop]
       const childListeners = (prevValue as any)?.[LISTENERS]
       if (childListeners) {
-        childListeners.delete(notifyUpdate)
+        childListeners.delete(popPropListener(prop))
       }
       const deleted = Reflect.deleteProperty(target, prop)
       if (deleted) {
-        notifyUpdate()
+        notifyUpdate(['delete', [prop], prevValue])
       }
       return deleted
     },
@@ -126,7 +152,7 @@ export const proxy = <T extends object>(initialObject: T = {} as T): T => {
       }
       const childListeners = (prevValue as any)?.[LISTENERS]
       if (childListeners) {
-        childListeners.delete(notifyUpdate)
+        childListeners.delete(popPropListener(prop))
       }
       if (
         refSet.has(value) ||
@@ -138,12 +164,12 @@ export const proxy = <T extends object>(initialObject: T = {} as T): T => {
         target[prop] = value
           .then((v) => {
             target[prop][PROMISE_RESULT] = v
-            notifyUpdate()
+            notifyUpdate(['resolve', [prop], v])
             return v
           })
           .catch((e) => {
             target[prop][PROMISE_ERROR] = e
-            notifyUpdate()
+            notifyUpdate(['reject', [prop], e])
           })
       } else {
         value = getUntracked(value) || value
@@ -152,9 +178,9 @@ export const proxy = <T extends object>(initialObject: T = {} as T): T => {
         } else {
           target[prop] = proxy(value)
         }
-        target[prop][LISTENERS].add(notifyUpdate)
+        target[prop][LISTENERS].add(getPropListener(prop))
       }
-      notifyUpdate()
+      notifyUpdate(['set', [prop], value, prevValue])
       return true
     },
   })
@@ -186,7 +212,7 @@ export const getVersion = (proxyObject: any): number => {
 
 export const subscribe = (
   proxyObject: any,
-  callback: () => void,
+  callback: (ops: Op[]) => void,
   notifyInSync?: boolean
 ) => {
   if (
@@ -197,15 +223,17 @@ export const subscribe = (
     throw new Error('Please use proxy object')
   }
   let pendingVersion = 0
-  const listener = (nextVersion: number) => {
+  const ops: Op[] = []
+  const listener: Listener = (op, nextVersion) => {
+    ops.push(op)
     if (notifyInSync) {
-      callback()
+      callback(ops.splice(0))
       return
     }
     pendingVersion = nextVersion
     Promise.resolve().then(() => {
       if (nextVersion === pendingVersion) {
-        callback()
+        callback(ops.splice(0))
       }
     })
   }


Test patch:
diff --git a/tests/subscribe.test.tsx b/tests/subscribe.test.tsx
index 320b2cc..c4b2357 100644
--- a/tests/subscribe.test.tsx
+++ b/tests/subscribe.test.tsx
@@ -1,4 +1,5 @@
 import { proxy, ref, subscribe } from '../src/index'
+import { subscribeKey } from '../src/utils'
 
 describe('subscribe', () => {
   it('should call subscription', async () => {
@@ -95,4 +96,74 @@ describe('subscribe', () => {
     await Promise.resolve()
     expect(handler).toBeCalledTimes(0)
   })
+
+  it('should notify ops', async () => {
+    const obj = proxy<{ count1: number; count2?: number }>({
+      count1: 0,
+      count2: 0,
+    })
+    const handler = jest.fn()
+
+    subscribe(obj, handler)
+
+    obj.count1 += 1
+    obj.count2 = 2
+
+    await Promise.resolve()
+    expect(handler).toBeCalledTimes(1)
+    expect(handler).lastCalledWith([
+      ['set', ['count1'], 1, 0],
+      ['set', ['count2'], 2, 0],
+    ])
+
+    delete obj.count2
+
+    await Promise.resolve()
+    expect(handler).toBeCalledTimes(2)
+    expect(handler).lastCalledWith([['delete', ['count2'], 2]])
+  })
+
+  it('should notify nested ops', async () => {
+    const obj = proxy<{ nested: { count?: number } }>({ nested: { count: 0 } })
+    const handler = jest.fn()
+
+    subscribe(obj, handler)
+
+    obj.nested.count = 1
+
+    await Promise.resolve()
+    expect(handler).toBeCalledTimes(1)
+    expect(handler).lastCalledWith([['set', ['nested', 'count'], 1, 0]])
+
+    delete obj.nested.count
+
+    await Promise.resolve()
+    expect(handler).toBeCalledTimes(2)
+    expect(handler).lastCalledWith([['delete', ['nested', 'count'], 1]])
+  })
+})
+
+describe('subscribeKey', () => {
+  it('should call subscription', async () => {
+    const obj = proxy({ count1: 0, count2: 0 })
+    const handler1 = jest.fn()
+    const handler2 = jest.fn()
+
+    subscribeKey(obj, 'count1', handler1)
+    subscribeKey(obj, 'count2', handler2)
+
+    obj.count1 += 10
+
+    await Promise.resolve()
+    expect(handler1).toBeCalledTimes(1)
+    expect(handler1).lastCalledWith(10)
+    expect(handler2).toBeCalledTimes(0)
+
+    obj.count2 += 20
+
+    await Promise.resolve()
+    expect(handler1).toBeCalledTimes(1)
+    expect(handler2).toBeCalledTimes(1)
+    expect(handler2).lastCalledWith(20)
+  })
 })