diff --git a/src/accessDeep.test.ts b/src/accessDeep.test.ts index 5e64fad1..a5ffedc1 100644 --- a/src/accessDeep.test.ts +++ b/src/accessDeep.test.ts @@ -1,4 +1,4 @@ -import { setDeep } from './accessDeep.js'; +import { setDeep, type AccessDeepContext } from './accessDeep.js'; import { describe, it, expect } from 'vitest'; @@ -7,10 +7,11 @@ describe('setDeep', () => { const obj = { a: new Map([[new Set(['NaN']), [[1, 'undefined']]]]), }; + const context: AccessDeepContext = new WeakMap(); - setDeep(obj, ['a', 0, 0, 0], Number); - setDeep(obj, ['a', 0, 1], entries => new Map(entries)); - setDeep(obj, ['a', 0, 1, 0, 1], () => undefined); + setDeep(obj, ['a', 0, 0, 0], Number, context); + setDeep(obj, ['a', 0, 1], entries => new Map(entries), context); + setDeep(obj, ['a', 0, 1, 0, 1], () => undefined, context); expect(obj).toEqual({ a: new Map([[new Set([NaN]), new Map([[1, undefined]])]]), @@ -21,8 +22,9 @@ describe('setDeep', () => { const obj = { a: new Set([10, new Set(['NaN'])]), }; + const context: AccessDeepContext = new WeakMap(); - setDeep(obj, ['a', 1, 0], Number); + setDeep(obj, ['a', 1, 0], Number, context); expect(obj).toEqual({ a: new Set([10, new Set([NaN])]), diff --git a/src/accessDeep.ts b/src/accessDeep.ts index ea986130..fed8f1f2 100644 --- a/src/accessDeep.ts +++ b/src/accessDeep.ts @@ -1,15 +1,33 @@ import { isMap, isArray, isPlainObject, isSet } from './is.js'; import { includes } from './util.js'; -const getNthKey = (value: Map | Set, n: number): any => { - if (n > value.size) throw new Error('index out of bounds'); - const keys = value.keys(); - while (n > 0) { - keys.next(); - n--; +export type AccessDeepContext = WeakMap; + +const getIndexedKeys = ( + value: Map | Set, + context: AccessDeepContext +): any[] => { + let indexed = context.get(value); + if (!indexed) { + indexed = Array.from(value.keys()); + context.set(value, indexed); } - return keys.next().value; + return indexed; +}; + +const getNthKey = ( + value: Map | Set, + n: number, + context: AccessDeepContext +): any => { + const indexed = getIndexedKeys(value, context); + + if (!Number.isInteger(n) || n < 0 || n >= indexed.length) { + throw new Error('index out of bounds'); + } + + return indexed[n]; }; function validatePath(path: (string | number)[]) { @@ -24,18 +42,22 @@ function validatePath(path: (string | number)[]) { } } -export const getDeep = (object: object, path: (string | number)[]): object => { +export const getDeep = ( + object: object, + path: (string | number)[], + context: AccessDeepContext +): object => { validatePath(path); for (let i = 0; i < path.length; i++) { const key = path[i]; if (isSet(object)) { - object = getNthKey(object, +key); + object = getNthKey(object, +key, context); } else if (isMap(object)) { const row = +key; const type = +path[++i] === 0 ? 'key' : 'value'; - const keyOfRow = getNthKey(object, row); + const keyOfRow = getNthKey(object, row, context); switch (type) { case 'key': object = keyOfRow; @@ -55,12 +77,13 @@ export const getDeep = (object: object, path: (string | number)[]): object => { export const setDeep = ( object: any, path: (string | number)[], - mapper: (v: any) => any + mapper: (v: any, context: AccessDeepContext) => any, + context: AccessDeepContext ): any => { validatePath(path); if (path.length === 0) { - return mapper(object); + return mapper(object, context); } let parent = object; @@ -75,7 +98,7 @@ export const setDeep = ( parent = parent[key]; } else if (isSet(parent)) { const row = +key; - parent = getNthKey(parent, row); + parent = getNthKey(parent, row, context); } else if (isMap(parent)) { const isEnd = i === path.length - 2; if (isEnd) { @@ -85,7 +108,7 @@ export const setDeep = ( const row = +key; const type = +path[++i] === 0 ? 'key' : 'value'; - const keyOfRow = getNthKey(parent, row); + const keyOfRow = getNthKey(parent, row, context); switch (type) { case 'key': parent = keyOfRow; @@ -100,38 +123,62 @@ export const setDeep = ( const lastKey = path[path.length - 1]; if (isArray(parent)) { - parent[+lastKey] = mapper(parent[+lastKey]); + const oldValue = parent[+lastKey]; + const newValue = mapper(oldValue, context); + parent[+lastKey] = newValue; } else if (isPlainObject(parent)) { - parent[lastKey] = mapper(parent[lastKey]); + const oldValue = parent[lastKey]; + const newValue = mapper(oldValue, context); + parent[lastKey] = newValue; } if (isSet(parent)) { - const oldValue = getNthKey(parent, +lastKey); - const newValue = mapper(oldValue); + const row = +lastKey; + const indexed = getIndexedKeys(parent, context); + if (!Number.isInteger(row) || row < 0 || row >= indexed.length) { + throw new Error('index out of bounds'); + } + const oldValue = indexed[row]; + + const newValue = mapper(oldValue, context); + if (oldValue !== newValue) { - parent.delete(oldValue); + if (row < parent.size) { + parent.delete(oldValue); + } parent.add(newValue); + indexed[row] = newValue; } } if (isMap(parent)) { const row = +path[path.length - 2]; - const keyToRow = getNthKey(parent, row); + const indexed = getIndexedKeys(parent, context); + if (!Number.isInteger(row) || row < 0 || row >= indexed.length) { + throw new Error('index out of bounds'); + } const type = +lastKey === 0 ? 'key' : 'value'; + const keyToRow = indexed[row]; + const isVirtualRow = row >= parent.size; + switch (type) { case 'key': { - const newKey = mapper(keyToRow); + const newKey = mapper(keyToRow, context); parent.set(newKey, parent.get(keyToRow)); - if (newKey !== keyToRow) { + if (!isVirtualRow && newKey !== keyToRow) { parent.delete(keyToRow); } + + indexed[row] = newKey; break; } case 'value': { - parent.set(keyToRow, mapper(parent.get(keyToRow))); + const oldValue = parent.get(keyToRow); + const newValue = mapper(oldValue, context); + parent.set(keyToRow, newValue); break; } } diff --git a/src/index.test.ts b/src/index.test.ts index daea0194..d97fd6b5 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -495,6 +495,35 @@ describe('stringify & parse', () => { }, }, + 'maintains referential equality between Set elements and master array': { + input: () => { + const objA = { name: 'A' }; + const objB = { name: 'B' }; + return { + master: [objA, objB], + testSet: new Set([objA, objB]) + }; + }, + output: { + master: [{ name: 'A' }, { name: 'B' }], + testSet: [{ name: 'A' }, { name: 'B' }], + }, + outputAnnotations: { + values: { + testSet: ['set'], + }, + referentialEqualities: { + 'master.0': ['testSet.0'], + 'master.1': ['testSet.1'], + }, + }, + customExpectations: value => { + const setArr = Array.from(value.testSet); + expect(setArr[0]).toBe(value.master[0]); + expect(setArr[1]).toBe(value.master[1]); + }, + }, + 'works for symbols': { skipOnNode10: true, input: () => { @@ -730,6 +759,113 @@ describe('stringify & parse', () => { }, }, }, + 'regression #347: shared regex': { + input: () => { + const regex = /shared-regex/g; + return { + a: regex, + b: regex, + }; + }, + output: { + a: '/shared-regex/g', + b: '/shared-regex/g', + }, + outputAnnotations: { + values: { + a: ['regexp'], + b: ['regexp'], + }, + referentialEqualities: { + a: ['b'], + }, + }, + customExpectations: output => { + expect(output.a).toBe(output.b); + }, + }, + 'regression #347: circular set and map': { + input: () => { + const set = new Set(); + set.add(set); + + const map = new Map(); + map.set(map, map); + return { + a: set, + b: map, + }; + }, + output: { + a: [null], + b: [[null, null]] + }, + outputAnnotations: { + values: { + a: ['set'], + b: ['map'], + }, + referentialEqualities: { + 'a': ['a.0'], + 'b': ['b.0.0', 'b.0.1'] + }, + }, + customExpectations: output => { + expect(output.a.values().next().value).toBe(output.a); + expect(output.b.values().next().value).toBe(output.b); + }, + }, + 'regression #347: circular set in root': { + input: () => { + const set = new Set(); + set.add(set); + return set; + }, + output: [null], + outputAnnotations: { + values: ['set'], + referentialEqualities: [['0']], + }, + customExpectations: output => { + expect(output.values().next().value).toBe(output); + }, + }, + 'regression #347: circular map in root': { + input: () => { + const map = new Map(); + map.set(map, map); + return map; + }, + output: [[null, null]], + outputAnnotations: { + values: ['map'], + referentialEqualities: [['0.0', '0.1']], + }, + customExpectations: output => { + expect(output.values().next().value).toBe(output); + expect(output.keys().next().value).toBe(output); + }, + }, + 'regression #347: only referential equalities': { + input: () => { + const a = {}; + a['a'] = a; + return { + a: a, + }; + }, + output: { + a: { a: null }, + }, + outputAnnotations: { + referentialEqualities: { + 'a': ['a.a'], + }, + }, + customExpectations: output => { + expect(output.a).toBe(output.a.a); + }, + } }; function deepFreeze(object: any, alreadySeenObjects = new Set()) { @@ -845,8 +981,8 @@ describe('stringify & parse', () => { const { json, meta } = SuperJSON.serialize({ s7: new Train(100, 'yellow', 'Bombardier', new Set([new Carriage('front'), new Carriage('back')])) as any, - }); - +}); + expect(json).toEqual({ s7: { topSpeed: 100, @@ -904,8 +1040,8 @@ describe('stringify & parse', () => { const price: Currency = result.price; expect(price.inUSD).toBe(100); - }); - }); + }); +}); }); describe('when given a non-SuperJSON object', () => { @@ -1043,6 +1179,41 @@ test('regression https://github.com/blitz-js/babel-plugin-superjson-next/issues/ expect(typeof (serialized.json as any).topics[0].post_count).toBe('string'); }); + +test('handles Set with circular reference that collapses', () => { + const set = new Set(); + const root = { back: set }; + set.add(null); + set.add(root); + + const serialized = SuperJSON.serialize(root); + const deserialized = SuperJSON.deserialize(serialized); + + expect(deserialized).toEqual(root); +}); + +test('handles Map with circular reference mapping to undefined', () => { + const map = new Map(); + map.set(null, undefined); + map.set(map, undefined); + + const serialized = SuperJSON.serialize(map); + const deserialized = SuperJSON.deserialize(serialized); + + expect(deserialized).toEqual(map); +}); + +test('handles Map with circular reference mapping to null', () => { + const map = new Map(); + map.set(null, undefined); + map.set(map, null); + + const serialized = SuperJSON.serialize(map); + const deserialized = SuperJSON.deserialize(serialized); + + expect(deserialized).toEqual(map); +}); + test('performance regression', () => { const data: any[] = []; for (let i = 0; i < 100; i++) { diff --git a/src/index.ts b/src/index.ts index 9a11ad74..7e9fb018 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { generateReferentialEqualityAnnotations, walker, } from './plainer.js'; +import { type AccessDeepContext } from './accessDeep.js'; import { copy } from 'copy-anything'; export default class SuperJSON { @@ -65,15 +66,18 @@ export default class SuperJSON { let result: T = options?.inPlace ? json : copy(json) as any; + const context: AccessDeepContext = new WeakMap(); + if (meta?.values) { - result = applyValueAnnotations(result, meta.values, meta.v ?? 0, this); + result = applyValueAnnotations(result, meta.values, meta.v ?? 0, this, context); } if (meta?.referentialEqualities) { result = applyReferentialEqualityAnnotations( result, meta.referentialEqualities, - meta.v ?? 0 + meta.v ?? 0, + context ); } diff --git a/src/plainer.ts b/src/plainer.ts index 8e9059f1..2eaa3f39 100644 --- a/src/plainer.ts +++ b/src/plainer.ts @@ -16,7 +16,7 @@ import { } from './transformer.js'; import { includes, forEach } from './util.js'; import { parsePath } from './pathstringifier.js'; -import { getDeep, setDeep } from './accessDeep.js'; +import { getDeep, setDeep, type AccessDeepContext } from './accessDeep.js'; import SuperJSON from './index.js'; type Tree = InnerNode | Leaf; @@ -65,12 +65,19 @@ export function applyValueAnnotations( plain: any, annotations: MinimisedTree, version: number, - superJson: SuperJSON + superJson: SuperJSON, + context: AccessDeepContext ) { traverse( annotations, (type, path) => { - plain = setDeep(plain, path, v => untransformValue(v, type, superJson)); + plain = setDeep( + plain, + path, + (v, mapperContext) => + untransformValue(v, type, superJson, mapperContext), + context + ); }, version ); @@ -81,16 +88,17 @@ export function applyValueAnnotations( export function applyReferentialEqualityAnnotations( plain: any, annotations: ReferentialEqualityAnnotations, - version: number + version: number, + context: AccessDeepContext ) { const legacyPaths = enableLegacyPaths(version); function apply(identicalPaths: string[], path: string) { - const object = getDeep(plain, parsePath(path, legacyPaths)); + const object = getDeep(plain, parsePath(path, legacyPaths), context); identicalPaths .map(path => parsePath(path, legacyPaths)) .forEach(identicalObjectPath => { - plain = setDeep(plain, identicalObjectPath, () => object); + plain = setDeep(plain, identicalObjectPath, () => object, context); }); } @@ -100,7 +108,8 @@ export function applyReferentialEqualityAnnotations( plain = setDeep( plain, parsePath(identicalPath, legacyPaths), - () => plain + () => plain, + context ); }); diff --git a/src/transformer.ts b/src/transformer.ts index c48a015e..539f9e65 100644 --- a/src/transformer.ts +++ b/src/transformer.ts @@ -16,6 +16,7 @@ import { } from './is.js'; import { findArr } from './util.js'; import SuperJSON from './index.js'; +import type { AccessDeepContext } from './accessDeep.js'; export type PrimitiveTypeAnnotation = 'number' | 'undefined' | 'bigint'; @@ -40,7 +41,11 @@ function simpleTransformation( isApplicable: (v: any, superJson: SuperJSON) => v is I, annotation: A, transform: (v: I, superJson: SuperJSON) => O, - untransform: (v: O, superJson: SuperJSON) => I + untransform: ( + v: O, + superJson: SuperJSON, + context: AccessDeepContext + ) => I ) { return { isApplicable, @@ -127,13 +132,24 @@ const simpleRules = [ // (sets only exist in es6+) // eslint-disable-next-line es5/no-es6-methods v => [...v.values()], - v => new Set(v) + (v, _, context) => { + const untransformed = new Set(v); + context?.set(untransformed, v.slice()); + return untransformed; + } ), simpleTransformation( isMap, 'map', v => [...v.entries()], - v => new Map(v) + (v, _, context) => { + const untransformed = new Map(v); + context?.set( + untransformed, + v.map(entry => (isArray(entry) ? entry[0] : undefined)) + ); + return untransformed; + } ), simpleTransformation( @@ -345,7 +361,8 @@ simpleRules.forEach(rule => { export const untransformValue = ( json: any, type: TypeAnnotation, - superJson: SuperJSON + superJson: SuperJSON, + context: AccessDeepContext ) => { if (isArray(type)) { switch (type[0]) { @@ -366,6 +383,6 @@ export const untransformValue = ( throw new Error('Unknown transformation: ' + type); } - return transformation.untransform(json as never, superJson); + return transformation.untransform(json as never, superJson, context); } };