Repository: instantdb/instant
Pull request #980: [presence] ensure we don't mutate `this._presence`
Author: login: stopachka
Created: 2025-03-05T00:30:37Z | Merged: 2025-03-05T00:33:54Z

**Bug** 

If a user published:

```
publishPresence({ item: { n: 1 } })
publishPresence({ item: { n: 2 } })
```

The `patch-presence` for `n: 2` would not get propagated to `subscribePresence`. 

**Root cause** 

We received a `patch-presence` command. We would run: 

```
sessions = assocIn(sessions, path, value);
```

The path would look something like `['item', 'n']`

This would mutate the original `item`. 

Then, when `hasPresenceResponseChanged` would run, we would see that in fact nothing had changed, and skip the update. 

**This only happened when the user had a deeply nested presence.** 

**Fix** 

1. Updated it so that `assocIn` and `dissocIn` are explicit about being mutative
2. Then, used `mutative` to generate new `session` objects.

--- 

@dwwoelfel @nezaj @tonsky 









Diff:
diff --git a/client/packages/core/__tests__/src/instaql.test.js b/client/packages/core/__tests__/src/instaql.test.js
index b0e6143ff6..619ced7e94 100644
--- a/client/packages/core/__tests__/src/instaql.test.js
+++ b/client/packages/core/__tests__/src/instaql.test.js
@@ -1203,22 +1203,6 @@ test('fields', () => {
     ],
   });
 
-  console.log(
-    JSON.stringify(
-      query(
-        { store },
-        {
-          users: {
-            $: { where: { handle: 'alex' }, fields: ['handle'] },
-            bookshelves: { $: { fields: ['name'] } },
-          },
-        },
-      ).data,
-      null,
-      2,
-    ),
-  );
-
   expect(
     query(
       { store },
diff --git a/client/packages/core/__tests__/src/utils/object.test.js b/client/packages/core/__tests__/src/utils/object.test.js
new file mode 100644
index 0000000000..a099fd1604
--- /dev/null
+++ b/client/packages/core/__tests__/src/utils/object.test.js
@@ -0,0 +1,31 @@
+import { test, expect, describe } from 'vitest';
+
+import { assocInMutative, dissocInMutative } from '../../../src/utils/object';
+
+describe('assocInMutative', () => {
+  test('adds value at a shallow path', () => {
+    const obj = { a: 1 };
+    assocInMutative(obj, ['b'], 2);
+    expect(obj).toEqual({ a: 1, b: 2 });
+  });
+
+  test('adds value at a nested path', () => {
+    const obj = { a: {} };
+    assocInMutative(obj, ['a', 'b', 'c'], 3);
+    expect(obj).toEqual({ a: { b: { c: 3 } } });
+  });
+});
+
+describe('dissocInMutative', () => {
+  test('deletes a shallow property', () => {
+    const obj = { a: 1, b: 2 };
+    dissocInMutative(obj, ['a']);
+    expect(obj).toEqual({ b: 2 });
+  });
+
+  test('deletes a nested property', () => {
+    const obj = { a: { b: { c: 3, d: 4 } } };
+    dissocInMutative(obj, ['a', 'b', 'c']);
+    expect(obj).toEqual({ a: { b: { d: 4 } } });
+  });
+});
diff --git a/client/packages/core/src/Reactor.js b/client/packages/core/src/Reactor.js
index c035a220df..dd0b41b478 100644
--- a/client/packages/core/src/Reactor.js
+++ b/client/packages/core/src/Reactor.js
@@ -13,9 +13,14 @@ import { buildPresenceSlice, hasPresenceResponseChanged } from './presence';
 import { Deferred } from './utils/Deferred';
 import { PersistedObject } from './utils/PersistedObject';
 import { extractTriples } from './model/instaqlResult';
-import { areObjectsDeepEqual, assocIn, dissocIn } from './utils/object';
+import {
+  areObjectsDeepEqual,
+  assocInMutative,
+  dissocInMutative,
+} from './utils/object';
 import { createLinkIndex } from './utils/linkIndex';
 import version from './version';
+import { create } from 'mutative';
 
 const STATUS = {
   CONNECTING: 'connecting',
@@ -1708,17 +1713,19 @@ export default class Reactor {
     let sessions = Object.fromEntries(
       Object.entries(peers).map(([k, v]) => [k, { data: v }]),
     );
-    sessions[this._sessionId] = { data: this._presence[roomId]?.result?.user };
-    for (let [path, op, value] of edits) {
-      if (op === '+' || op === 'r') {
-        sessions = assocIn(sessions, path, value);
-      }
-      if (op === '-') {
-        sessions = dissocIn(sessions, path);
+    const newSessions = create(sessions, (draft) => {
+      draft[this._sessionId] = { data: this._presence[roomId]?.result?.user };
+      for (let [path, op, value] of edits) {
+        if (op === '+' || op === 'r') {
+          assocInMutative(draft, path, value);
+        }
+        if (op === '-') {
+          dissocInMutative(draft, path);
+        }
       }
-    }
+    });
 
-    this._setPresencePeers(roomId, sessions);
+    this._setPresencePeers(roomId, newSessions);
   }
 
   _setPresencePeers(roomId, data) {
@@ -1729,11 +1736,9 @@ export default class Reactor {
       Object.entries(sessions).map(([k, v]) => [k, v.data]),
     );
 
-    this._presence = assocIn(
-      this._presence,
-      [roomId, 'result', 'peers'],
-      peers,
-    );
+    this._presence = create(this._presence, (draft) => {
+      assocInMutative(draft, [roomId, 'result', 'peers'], peers);
+    });
   }
 
   // --------
diff --git a/client/packages/core/src/utils/object.js b/client/packages/core/src/utils/object.js
index 99bbd6bf1e..8eed7d9e43 100644
--- a/client/packages/core/src/utils/object.js
+++ b/client/packages/core/src/utils/object.js
@@ -82,9 +82,12 @@ export function isObject(val) {
   return typeof val === 'object' && val !== null && !Array.isArray(val);
 }
 
-export function assocIn(obj, path, value) {
+export function assocInMutative(obj, path, value) {
+  if (!obj) {
+    return;
+  }
   if (path.length === 0) {
-    return value;
+    return;
   }
 
   let current = obj || {};
@@ -97,33 +100,34 @@ export function assocIn(obj, path, value) {
   }
 
   current[path[path.length - 1]] = value;
-  return obj;
+  return;
 }
 
-export function dissocIn(obj, path) {
+export function dissocInMutative(obj, path) {
+  if (!obj) {
+    return;
+  }
   if (path.length === 0) {
-    return undefined;
+    return;
   }
 
   const [key, ...restPath] = path;
 
   if (!(key in obj)) {
-    return obj;
+    return;
   }
 
   if (restPath.length === 0) {
     delete obj[key];
-    return isEmpty(obj) ? undefined : obj;
+    return;
   }
 
-  const child = dissocIn(obj[key], restPath);
-
-  if (child === undefined) {
+  dissocInMutative(obj[key], restPath);
+  if (isEmpty(obj[key])) {
     delete obj[key];
-    return isEmpty(obj) ? undefined : obj;
   }
 
-  return obj;
+  return;
 }
 
 function isEmpty(obj) {
diff --git a/client/sandbox/react-nextjs/pages/play/ephemeral-counts.tsx b/client/sandbox/react-nextjs/pages/play/ephemeral-counts.tsx
new file mode 100644
index 0000000000..1f5c8c4857
--- /dev/null
+++ b/client/sandbox/react-nextjs/pages/play/ephemeral-counts.tsx
@@ -0,0 +1,55 @@
+import React, { useEffect, useRef } from 'react';
+import Head from 'next/head';
+import { init, Cursors } from '@instantdb/react';
+import config from '../../config';
+
+const db = init(config);
+const room = db.room('main', '123');
+
+function App() {
+  const interval = useRef<NodeJS.Timer | null>(null);
+  const { peers, user, publishPresence } = db.rooms.usePresence(room);
+  const sendUpdates = () => {
+    if (interval.current) clearInterval(interval.current);
+    let n = 0;
+    interval.current = setInterval(() => {
+      n++;
+      publishPresence({ count: { n } });
+    }, 1000);
+  };
+  return (
+    <div>
+      <button className="bg-black text-white" onClick={sendUpdates}>
+        Send Updates
+      </button>
+      <pre>Me: {JSON.stringify(user, null, 2)}</pre>
+      <pre>Peers: {JSON.stringify(Object.values(peers), null, 2)}</pre>
+      <div className="space-y-2">
+        <div>Open up two tabs. Click "send update" on one tab.</div>
+        <div>
+          <strong>
+            Make sure that the updates show up. They may not show up if Reactor
+            mutates `peers`.
+          </strong>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function Page() {
+  return (
+    <div>
+      <Head>
+        <title>Instant Example App: Cursors Counts</title>
+        <meta
+          name="description"
+          content="Relational Database, on the client."
+        />
+      </Head>
+      <App />
+    </div>
+  );
+}
+
+export default Page;


Reviews:
user: login: stopachka
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T00:31:32Z
user: login: dwwoelfel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T00:31:40Z
user: login: stopachka
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T00:32:42Z
user: login: dwwoelfel
body: Nice debugging!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-05T00:32:48Z

Review comments:
diff_hunk: @@ -1203,22 +1203,6 @@ test('fields', () => {
     ],
   });
 
-  console.log(
path: client/packages/core/__tests__/src/instaql.test.js
user: login: stopachka
body: (Noticed this when running and test and thought it wasn't intentional)
created_at: 2025-03-05T00:31:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1203,22 +1203,6 @@ test('fields', () => {
     ],
   });
 
-  console.log(
path: client/packages/core/__tests__/src/instaql.test.js
user: login: dwwoelfel
body: whoops!
created_at: 2025-03-05T00:31:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1203,22 +1203,6 @@ test('fields', () => {
     ],
   });
 
-  console.log(
path: client/packages/core/__tests__/src/instaql.test.js
user: login: stopachka
body: 🤜🤛
created_at: 2025-03-05T00:32:42Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-05T00:32:35Z
body: View Vercel preview at [instant-www-js-patchbugfix-jsv.vercel.app](https://instant-www-js-patchbugfix-jsv.vercel.app).
author_association: CONTRIBUTOR
user: login: tonsky
created_at: 2025-03-05T15:41:16Z
body: Oops. Mutability is hard!
author_association: CONTRIBUTOR