Repository: blitz-js/superjson
Pull request #30 (2020-07-23 18:29:21)

Add support for preserving object identity
Given the following code, we expect SuperJSON to preserve `options[0] === selected`.

```js
const a = { id: "a" }
const b = { id: "b" }
SuperJSON.stringify({
    options: [a, b],
    selected: a
})
```

This is achieved by adding the new `referentialEqualities` meta field.
The above yields the following output:

```json
{
  "value": {
    "options": [
      { "id": "a" },
      { "id": "b" }
    ],
    "selected": { "id": "a" }
  },
  "meta": {
    "referentialEqualities": {
        "selected": ["options.0"]
    }
  },
}
```

`value` is easily understood by JSON.parse, while SuperJSON can
revive it to preserve referential equality.

This PR should provide a good starting point to #9.

merelinguist: @Skn0tt Can you resolve the conflicts?

Patch:
diff --git a/src/accessDeep.ts b/src/accessDeep.ts
new file mode 100644
index 0000000..68bb94c
--- /dev/null
+++ b/src/accessDeep.ts
@@ -0,0 +1,26 @@
+export const getDeep = (object: object, path: (string | number)[]): object => {
+  for (const key of path) {
+    object = (object as any)[key];
+  }
+
+  return object;
+};
+
+export const setDeep = (
+  object: object,
+  path: (string | number)[],
+  mapper: (v: any) => any
+): object => {
+  if (path.length === 0) {
+    return mapper(object);
+  }
+
+  const front = path.slice(0, path.length - 1);
+  const last = path[path.length - 1];
+
+  const parent: any = getDeep(object, front);
+
+  parent[last] = mapper(parent[last]);
+
+  return object;
+};
diff --git a/src/annotator.ts b/src/annotator.ts
index 02c1a73..af3a6ee 100644
--- a/src/annotator.ts
+++ b/src/annotator.ts
@@ -1,4 +1,6 @@
-import { mapDeep } from './mapDeep';
+import { getDeep, setDeep } from './accessDeep';
+import { isPrimitive } from './is';
+import * as IteratorUtils from './iteratorutils';
 import {
   StringifiedPath,
   isStringifiedPath,
@@ -16,26 +18,49 @@ import {
 export interface Annotations {
   root?: TypeAnnotation;
   values?: Record<StringifiedPath, TypeAnnotation>;
+  referentialEqualities?: Record<StringifiedPath, StringifiedPath[]>;
 }
 
-export const isAnnotations = (object: any): object is Annotations => {
-  if (!!object.root && !isTypeAnnotation(object.root)) {
-    return false;
-  }
+export function isAnnotations(object: any): object is Annotations {
+  try {
+    if (!!object.root && !isTypeAnnotation(object.root)) {
+      return false;
+    }
 
-  if (!!object.values) {
-    return Object.entries(object.values).every(
-      ([key, value]) => isStringifiedPath(key) && isTypeAnnotation(value)
-    );
-  }
+    if (!!object.values) {
+      return Object.entries(object.values).every(
+        ([key, value]) => isStringifiedPath(key) && isTypeAnnotation(value)
+      );
+    }
 
-  return true;
-};
+    if (!!object.referentialEqualities) {
+      return Object.entries(object.referentialEqualities).every(
+        ([key, value]) =>
+          isStringifiedPath(key) && (value as string[]).every(isStringifiedPath)
+      );
+    }
+
+    return true;
+  } catch (error) {
+    return false;
+  }
+}
 
 export const makeAnnotator = () => {
   const annotations: Annotations = {};
 
+  const objectIdentities = new Map<any, any[][]>();
+  function registerObjectPath(object: any, path: any[]) {
+    const paths = objectIdentities.get(object) ?? [];
+    paths.push(path);
+    objectIdentities.set(object, paths);
+  }
+
   const annotator: Walker = ({ path, node }) => {
+    if (!isPrimitive(node)) {
+      registerObjectPath(node, path);
+    }
+
     const transformed = transformValue(node);
 
     if (transformed) {
@@ -55,7 +80,25 @@ export const makeAnnotator = () => {
     }
   };
 
-  return { annotations, annotator };
+  function getAnnotations(): Annotations {
+    IteratorUtils.forEach(objectIdentities.values(), paths => {
+      if (paths.length > 1) {
+        const [shortestPath, ...identityPaths] = paths
+          .sort((a, b) => a.length - b.length)
+          .map(stringifyPath);
+
+        if (!annotations.referentialEqualities) {
+          annotations.referentialEqualities = {};
+        }
+
+        annotations.referentialEqualities[shortestPath] = identityPaths;
+      }
+    });
+
+    return annotations;
+  }
+
+  return { getAnnotations, annotator };
 };
 
 export const applyAnnotations = (plain: any, annotations: Annotations): any => {
@@ -69,7 +112,7 @@ export const applyAnnotations = (plain: any, annotations: Annotations): any => {
     );
 
     for (const [path, type] of annotationsWithPathsLeavesToRoot) {
-      plain = mapDeep(plain, path, v =>
+      plain = setDeep(plain, path, v =>
         untransformValue(v, type as TypeAnnotation)
       );
     }
@@ -79,5 +122,17 @@ export const applyAnnotations = (plain: any, annotations: Annotations): any => {
     plain = untransformValue(plain, annotations.root);
   }
 
+  if (annotations.referentialEqualities) {
+    for (const [objectPath, identicalObjectsPaths] of Object.entries(
+      annotations.referentialEqualities
+    )) {
+      const object = getDeep(plain, parsePath(objectPath));
+
+      for (const identicalObjectPath of identicalObjectsPaths.map(parsePath)) {
+        setDeep(plain, identicalObjectPath, () => object);
+      }
+    }
+  }
+
   return plain;
 };
diff --git a/src/index.ts b/src/index.ts
index d495e39..ed19ca5 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -4,9 +4,11 @@ import { plainer } from './plainer';
 import { SuperJSONResult, SuperJSONValue, isSuperJSONResult } from './types';
 
 export const serialize = (object: SuperJSONValue): SuperJSONResult => {
-  const { annotations, annotator } = makeAnnotator();
+  const { getAnnotations, annotator } = makeAnnotator();
   const output = plainer(object, annotator);
 
+  const annotations = getAnnotations();
+
   return {
     json: output,
     meta: isEmptyObject(annotations) ? undefined : annotations,
diff --git a/src/mapDeep.ts b/src/mapDeep.ts
deleted file mode 100644
index 7eb94d5..0000000
--- a/src/mapDeep.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { isArray } from './is';
-
-export const mapDeep = (
-  object: object,
-  path: (string | number)[],
-  mapper: (v: any) => any
-): object => {
-  if (path.length === 0) {
-    return mapper(object);
-  }
-
-  const [head, ...tail] = path;
-
-  if (isArray(object)) {
-    return object.map((v, i) => {
-      if (i === head) {
-        return mapDeep(v, tail, mapper);
-      }
-
-      return v;
-    });
-  } else {
-    return {
-      ...object,
-      [head]: mapDeep((object as any)[head], tail, mapper),
-    };
-  }
-};
diff --git a/src/plainer.ts b/src/plainer.ts
index e1c8f53..6be46b1 100644
--- a/src/plainer.ts
+++ b/src/plainer.ts
@@ -1,5 +1,5 @@
-import * as IteratorUtils from './iteratorutils';
 import { isArray, isMap, isPlainObject, isPrimitive, isSet } from './is';
+import * as IteratorUtils from './iteratorutils';
 
 interface WalkerValue {
   isLeaf: boolean;
@@ -28,18 +28,18 @@ export const plainer = (
   object: any,
   walker: Walker,
   path: any[] = [],
-  alreadySeenObjects = new Set<any>()
+  alreadySeenObjects: any[] = []
 ): any => {
   if (!isDeep(object)) {
     return walker({ isLeaf: true, node: object, path });
   }
 
-  if (alreadySeenObjects.has(object)) {
+  if (alreadySeenObjects.includes(object)) {
     throw new TypeError('Circular Reference');
   }
 
   if (!isPrimitive(object)) {
-    alreadySeenObjects.add(object);
+    alreadySeenObjects = [...alreadySeenObjects, object];
   }
 
   walker({ isLeaf: false, path, node: object });
diff --git a/src/transformer.ts b/src/transformer.ts
index a137c0f..f8acd53 100644
--- a/src/transformer.ts
+++ b/src/transformer.ts
@@ -8,8 +8,8 @@ import {
   isNumber,
   isRegExp,
   isSet,
-  isUndefined,
   isString,
+  isUndefined,
 } from './is';
 
 export type PrimitiveTypeAnnotation =


Test patch:
diff --git a/src/annotator.test.ts b/src/annotator.test.ts
index 1763f2c..c229dcc 100644
--- a/src/annotator.test.ts
+++ b/src/annotator.test.ts
@@ -2,7 +2,7 @@ import { makeAnnotator } from './annotator';
 
 describe('annotator', () => {
   test('simple example', () => {
-    const { annotations, annotator } = makeAnnotator();
+    const { getAnnotations, annotator } = makeAnnotator();
 
     expect(
       annotator({
@@ -12,7 +12,7 @@ describe('annotator', () => {
       })
     ).toBe(undefined);
 
-    expect(annotations).toEqual({
+    expect(getAnnotations()).toEqual({
       values: {
         'a.1.b': 'undefined',
       },
diff --git a/src/index.test.ts b/src/index.test.ts
index 69b9ba3..e989e68 100644
--- a/src/index.test.ts
+++ b/src/index.test.ts
@@ -1,14 +1,16 @@
 import * as SuperJSON from './';
-import { JSONValue, SuperJSONValue } from './types';
 import { Annotations } from './annotator';
+import { isArray, isMap, isPlainObject, isPrimitive, isSet } from './is';
+import { JSONValue, SuperJSONValue } from './types';
 
 describe('stringify & parse', () => {
   const cases: Record<
     string,
     {
-      input: SuperJSONValue;
+      input: (() => SuperJSONValue) | SuperJSONValue;
       output: JSONValue;
       outputAnnotations?: Annotations;
+      customExpectations?: (value: any) => void;
     }
   > = {
     'works for objects': {
@@ -105,6 +107,29 @@ describe('stringify & parse', () => {
       },
     },
 
+    'preserves object identity': {
+      input: () => {
+        const a = { id: 'a' };
+        const b = { id: 'b' };
+        return {
+          options: [a, b],
+          selected: a,
+        };
+      },
+      output: {
+        options: [{ id: 'a' }, { id: 'b' }],
+        selected: { id: 'a' },
+      },
+      outputAnnotations: {
+        referentialEqualities: {
+          selected: ['options.0'],
+        },
+      },
+      customExpectations: output => {
+        expect(output.selected).toBe(output.options[0]);
+      },
+    },
+
     'works for paths containing dots': {
       input: {
         'a.1': {
@@ -230,22 +255,51 @@ describe('stringify & parse', () => {
     },
   };
 
+  function deepFreeze(object: any) {
+    if (isPrimitive(object)) {
+      return;
+    }
+
+    if (isPlainObject(object)) {
+      Object.values(object).forEach(deepFreeze);
+    }
+
+    if (isArray(object) || isSet(object)) {
+      object.forEach(deepFreeze);
+    }
+
+    if (isMap(object)) {
+      object.forEach((value, key) => {
+        deepFreeze(key);
+        deepFreeze(value);
+      });
+    }
+
+    Object.freeze(object);
+  }
+
   for (const [
     testName,
     {
       input,
       output: expectedOutput,
       outputAnnotations: expectedOutputAnnotations,
+      customExpectations,
     },
   ] of Object.entries(cases)) {
     test(testName, () => {
-      const { json, meta } = SuperJSON.serialize(input);
+      const inputValue = typeof input === 'function' ? input() : input;
+
+      // let's make sure SuperJSON doesn't mutate our input!
+      deepFreeze(inputValue);
+      const { json, meta } = SuperJSON.serialize(inputValue);
 
       expect(json).toEqual(expectedOutput);
       expect(meta).toEqual(expectedOutputAnnotations);
 
       const untransformed = SuperJSON.deserialize({ json, meta });
-      expect(untransformed).toEqual(input);
+      expect(untransformed).toEqual(inputValue);
+      customExpectations?.(untransformed);
     });
   }
 
diff --git a/src/is.test.ts b/src/is.test.ts
index 3bb48e9..aada70b 100644
--- a/src/is.test.ts
+++ b/src/is.test.ts
@@ -16,7 +16,7 @@ test('Basic true tests', () => {
   expect(isNull(null)).toBe(true);
 
   expect(isArray([])).toBe(true);
-  expect(isArray(new Array())).toBe(true);
+  expect(isArray([])).toBe(true);
   expect(isString('')).toBe(true);
   expect(isString('_')).toBe(true);
 
@@ -57,7 +57,7 @@ test('Primitive tests', () => {
 
   expect(isPrimitive(NaN)).toBe(false);
   expect(isPrimitive([])).toBe(false);
-  expect(isPrimitive(new Array())).toBe(false);
+  expect(isPrimitive([])).toBe(false);
   expect(isPrimitive({})).toBe(false);
   expect(isPrimitive(new Object())).toBe(false);
   expect(isPrimitive(new Date())).toBe(false);