Repository: blitz-js/superjson (ts)

Issue:
Support circular references
CC https://github.com/blitz-js/superjson/pull/6#issuecomment-659243309

At the moment, the following throws a `Error: Circular Reference detected`.

```ts
const a = { role: 'parent', children: [] as any[] };
const b = { role: 'child', parent: [a] };
a.children.push(b);
SuperJSON.serialize(a); // throws
```

We could leverage the behaviour proposed in #8 to make it return the following, instead:

```js
{
  value: {
    a: {
      role: 'parent',
      children: [
        { role: 'child', parent: [null] }
      ]
    },
  },
  meta: {
    identities: {
      a: ["a.children.0.parent.0"]
    }
  }
}
```

This should then be easily revivable.

This is a payload that the regular `JSON.stringify` can't serialise, so I don't think it's a big problem we change the `value` payload here.

Pull request description:
Support circular references
Closes #9, #30 needs to be merged before.

Support for circular references basically just requires cutting off the JSON tree at some point. See the following example:

```ts
const p = { role: 'parent', children: [] as any[] };
const c = { role: 'child', parents: [p] };
p.children.push(c);
SuperJSON.stringify({ p });
```

```json
{
    "value": {
        "p": {
            "role": "parent",
            "children": [{
                "role": "child",
                "parents": [null]
            }]    
        }
    },
    "meta": {
        "referentialEqualities": {
            "p": [
                "p.children.0.parents.0"
            ]
        }
    }   
}
```
    
To be honest, the output is not super-intuitive for non-SuperJSON-readers to understand b/c the `null`-cutoff can occur in kind-of weird places. But it's the best we can do - and since `JSON.stringify` doesn't even support anything like this at all, I guess it's fine.


Patch:
diff --git a/src/annotator.ts b/src/annotator.ts
index af3a6ee..3ae71ad 100644
--- a/src/annotator.ts
+++ b/src/annotator.ts
@@ -19,6 +19,7 @@ export interface Annotations {
   root?: TypeAnnotation;
   values?: Record<StringifiedPath, TypeAnnotation>;
   referentialEqualities?: Record<StringifiedPath, StringifiedPath[]>;
+  referentialEqualitiesRoot?: StringifiedPath[];
 }
 
 export function isAnnotations(object: any): object is Annotations {
@@ -87,11 +88,16 @@ export const makeAnnotator = () => {
           .sort((a, b) => a.length - b.length)
           .map(stringifyPath);
 
-        if (!annotations.referentialEqualities) {
-          annotations.referentialEqualities = {};
-        }
+        const isRoot = shortestPath.length === 0;
+        if (isRoot) {
+          annotations.referentialEqualitiesRoot = identityPaths;
+        } else {
+          if (!annotations.referentialEqualities) {
+            annotations.referentialEqualities = {};
+          }
 
-        annotations.referentialEqualities[shortestPath] = identityPaths;
+          annotations.referentialEqualities[shortestPath] = identityPaths;
+        }
       }
     });
 
@@ -134,5 +140,13 @@ export const applyAnnotations = (plain: any, annotations: Annotations): any => {
     }
   }
 
+  if (annotations.referentialEqualitiesRoot) {
+    for (const identicalObjectPath of annotations.referentialEqualitiesRoot.map(
+      parsePath
+    )) {
+      setDeep(plain, identicalObjectPath, () => plain);
+    }
+  }
+
   return plain;
 };
diff --git a/src/plainer.ts b/src/plainer.ts
index 6be46b1..78b263f 100644
--- a/src/plainer.ts
+++ b/src/plainer.ts
@@ -34,16 +34,16 @@ export const plainer = (
     return walker({ isLeaf: true, node: object, path });
   }
 
+  walker({ isLeaf: false, path, node: object });
+
   if (alreadySeenObjects.includes(object)) {
-    throw new TypeError('Circular Reference');
+    return null;
   }
 
   if (!isPrimitive(object)) {
     alreadySeenObjects = [...alreadySeenObjects, object];
   }
 
-  walker({ isLeaf: false, path, node: object });
-
   if (isArray(object) || isSet(object)) {
     return IteratorUtils.map(object.values(), (value, index) =>
       plainer(value, walker, [...path, index], alreadySeenObjects)


Test patch:
diff --git a/src/index.test.ts b/src/index.test.ts
index e989e68..eb4e0e4 100644
--- a/src/index.test.ts
+++ b/src/index.test.ts
@@ -253,25 +253,56 @@ describe('stringify & parse', () => {
         },
       },
     },
+
+    'works for self-referencing objects': {
+      input: () => {
+        const a = { role: 'parent', children: [] as any[] };
+        const b = { role: 'child', parents: [a] };
+        a.children.push(b);
+        return a;
+      },
+      output: {
+        role: 'parent',
+        children: [
+          {
+            role: 'child',
+            parents: [null],
+          },
+        ],
+      },
+      outputAnnotations: {
+        referentialEqualitiesRoot: ['children.0.parents.0'],
+      },
+    },
   };
 
-  function deepFreeze(object: any) {
+  function deepFreeze(object: any, alreadySeenObjects = new Set()) {
     if (isPrimitive(object)) {
       return;
     }
 
+    if (alreadySeenObjects.has(object)) {
+      return;
+    } else {
+      alreadySeenObjects.add(object);
+    }
+
     if (isPlainObject(object)) {
-      Object.values(object).forEach(deepFreeze);
+      Object.values(object).forEach(o => deepFreeze(o, alreadySeenObjects));
+    }
+
+    if (isSet(object)) {
+      object.forEach(o => deepFreeze(o, alreadySeenObjects));
     }
 
-    if (isArray(object) || isSet(object)) {
-      object.forEach(deepFreeze);
+    if (isArray(object)) {
+      object.forEach(o => deepFreeze(o, alreadySeenObjects));
     }
 
     if (isMap(object)) {
       object.forEach((value, key) => {
-        deepFreeze(key);
-        deepFreeze(value);
+        deepFreeze(key, alreadySeenObjects);
+        deepFreeze(value, alreadySeenObjects);
       });
     }
 
@@ -303,18 +334,6 @@ describe('stringify & parse', () => {
     });
   }
 
-  describe('when given a self-referencing object', () => {
-    it('throws', () => {
-      const a = { role: 'parent', children: [] as any[] };
-      const b = { role: 'child', parent: [a] };
-      a.children.push(b);
-
-      expect(() => {
-        SuperJSON.stringify(a);
-      }).toThrow(TypeError);
-    });
-  });
-
   describe('when given a non-SuperJSON object', () => {
     it('throws', () => {
       expect(() => {