diff --git a/src/accessDeep.test.ts b/src/accessDeep.test.ts deleted file mode 100644 index 5e64fad..0000000 --- a/src/accessDeep.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { setDeep } from './accessDeep.js'; - -import { describe, it, expect } from 'vitest'; - -describe('setDeep', () => { - it('correctly sets values in maps', () => { - const obj = { - a: new Map([[new Set(['NaN']), [[1, 'undefined']]]]), - }; - - setDeep(obj, ['a', 0, 0, 0], Number); - setDeep(obj, ['a', 0, 1], entries => new Map(entries)); - setDeep(obj, ['a', 0, 1, 0, 1], () => undefined); - - expect(obj).toEqual({ - a: new Map([[new Set([NaN]), new Map([[1, undefined]])]]), - }); - }); - - it('correctly sets values in sets', () => { - const obj = { - a: new Set([10, new Set(['NaN'])]), - }; - - setDeep(obj, ['a', 1, 0], Number); - - expect(obj).toEqual({ - a: new Set([10, new Set([NaN])]), - }); - }); -}); diff --git a/src/accessDeep.ts b/src/accessDeep.ts index ea98613..05c5966 100644 --- a/src/accessDeep.ts +++ b/src/accessDeep.ts @@ -1,17 +1,6 @@ -import { isMap, isArray, isPlainObject, isSet } from './is.js'; +import { isArray, isPlainObject } 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--; - } - - return keys.next().value; -}; - function validatePath(path: (string | number)[]) { if (includes(path, '__proto__')) { throw new Error('__proto__ is not allowed as a property'); @@ -29,24 +18,7 @@ export const getDeep = (object: object, path: (string | number)[]): object => { for (let i = 0; i < path.length; i++) { const key = path[i]; - if (isSet(object)) { - object = getNthKey(object, +key); - } else if (isMap(object)) { - const row = +key; - const type = +path[++i] === 0 ? 'key' : 'value'; - - const keyOfRow = getNthKey(object, row); - switch (type) { - case 'key': - object = keyOfRow; - break; - case 'value': - object = object.get(keyOfRow); - break; - } - } else { - object = (object as any)[key]; - } + object = (object as any)[key]; } return object; @@ -73,27 +45,6 @@ export const setDeep = ( parent = parent[index]; } else if (isPlainObject(parent)) { parent = parent[key]; - } else if (isSet(parent)) { - const row = +key; - parent = getNthKey(parent, row); - } else if (isMap(parent)) { - const isEnd = i === path.length - 2; - if (isEnd) { - break; - } - - const row = +key; - const type = +path[++i] === 0 ? 'key' : 'value'; - - const keyOfRow = getNthKey(parent, row); - switch (type) { - case 'key': - parent = keyOfRow; - break; - case 'value': - parent = parent.get(keyOfRow); - break; - } } } @@ -105,37 +56,5 @@ export const setDeep = ( parent[lastKey] = mapper(parent[lastKey]); } - if (isSet(parent)) { - const oldValue = getNthKey(parent, +lastKey); - const newValue = mapper(oldValue); - if (oldValue !== newValue) { - parent.delete(oldValue); - parent.add(newValue); - } - } - - if (isMap(parent)) { - const row = +path[path.length - 2]; - const keyToRow = getNthKey(parent, row); - - const type = +lastKey === 0 ? 'key' : 'value'; - switch (type) { - case 'key': { - const newKey = mapper(keyToRow); - parent.set(newKey, parent.get(keyToRow)); - - if (newKey !== keyToRow) { - parent.delete(keyToRow); - } - break; - } - - case 'value': { - parent.set(keyToRow, mapper(parent.get(keyToRow))); - break; - } - } - } - return object; }; diff --git a/src/index.test.ts b/src/index.test.ts index daea019..272f677 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -730,6 +730,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: onyl 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()) { @@ -846,7 +953,7 @@ 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, @@ -1296,18 +1403,6 @@ test('dedupe=true on a large complicated schema', () => { expect(dedupedOut).toEqual(deserialized); }); -test('doesnt iterate to keys that dont exist', () => { - const robbyBubble = { id: 5 }; - const highscores = new Map([[robbyBubble, 5000]]); - const objectWithReferentialEquality = { highscores, topScorer: robbyBubble }; - const res = SuperJSON.serialize(objectWithReferentialEquality); - - expect(res.meta.referentialEqualities.topScorer).toEqual(['highscores.0.0']); - res.meta.referentialEqualities.topScorer = ['highscores.99999.0']; - - expect(() => SuperJSON.deserialize(res)).toThrowError('index out of bounds'); -}); - // https://github.com/flightcontrolhq/superjson/issues/319 test('deserialize in place', () => { const serialized = SuperJSON.serialize({ a: new Date() }); diff --git a/src/index.ts b/src/index.ts index 9a11ad7..ee16f7a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,6 @@ import { CustomTransformerRegistry, } from './custom-transformer-registry.js'; import { - applyReferentialEqualityAnnotations, applyValueAnnotations, generateReferentialEqualityAnnotations, walker, @@ -65,17 +64,7 @@ export default class SuperJSON { let result: T = options?.inPlace ? json : copy(json) as any; - if (meta?.values) { - result = applyValueAnnotations(result, meta.values, meta.v ?? 0, this); - } - - if (meta?.referentialEqualities) { - result = applyReferentialEqualityAnnotations( - result, - meta.referentialEqualities, - meta.v ?? 0 - ); - } + result = applyValueAnnotations(result, meta?.values, meta?.referentialEqualities, meta?.v ?? 0, this); return result; } diff --git a/src/plainer.ts b/src/plainer.ts index 8e9059f..98d5384 100644 --- a/src/plainer.ts +++ b/src/plainer.ts @@ -64,52 +64,135 @@ function traverse( export function applyValueAnnotations( plain: any, annotations: MinimisedTree, + referentialEqualityAnnotations: ReferentialEqualityAnnotations | undefined, version: number, superJson: SuperJSON ) { + const byproduct = new Map; + let rootIdentities: string[] | undefined; + + if (referentialEqualityAnnotations !== undefined) { + function apply(identicalPaths: string[], path: string) { + byproduct.set(path, identicalPaths); + } + + if (isArray(referentialEqualityAnnotations)) { + const [root, other] = referentialEqualityAnnotations; + rootIdentities = root; + if (other) { + forEach(other, apply); + } + } else { + forEach(referentialEqualityAnnotations, apply); + } + } + + const seen = byproduct.size ? new Set() : undefined; + + const pathsWithValueAnnotation = new Set(); + let rootValueAnnotation: TypeAnnotation | undefined = undefined; traverse( annotations, (type, path) => { - plain = setDeep(plain, path, v => untransformValue(v, type, superJson)); + if (path.length === 0) + rootValueAnnotation = type; + else + pathsWithValueAnnotation.add(stringifyPath(path)) }, version - ); + ) + + for (const [path, identicalPaths] of byproduct) { + if (pathsWithValueAnnotation.has(path)) + continue; + const original = getDeep(plain, parsePath(path, true)) as any; + for (const other of identicalPaths) + plain = setDeep(plain, parsePath(other, true), () => original) + } + if (rootIdentities && !rootValueAnnotation) { + for (const other of rootIdentities) + plain = setDeep(plain, parsePath(other, true), () => plain) + } - return plain; -} + traverse( + annotations, + (type, path) => { + if (path.length === 0) { + if (rootIdentities) { + if (type === 'set') { + const newValue = new Set(); + for (const other of rootIdentities) { + plain = setDeep(plain, parsePath(other, false), () => newValue); + } + for (const value of plain) { + newValue.add(value) + } + plain = newValue; + return; + } + if (type === 'map') { + const newValue = new Map(); + for (const other of rootIdentities) { + plain = setDeep(plain, parsePath(other, false), () => newValue); + } + for (const [key, value] of plain) { + newValue.set(key, value); + } + plain = newValue; + return; + } -export function applyReferentialEqualityAnnotations( - plain: any, - annotations: ReferentialEqualityAnnotations, - version: number -) { - const legacyPaths = enableLegacyPaths(version); - function apply(identicalPaths: string[], path: string) { - const object = getDeep(plain, parsePath(path, legacyPaths)); + throw new Error("If my understanding of the code is correct, this is unreachable") + } else { + plain = untransformValue(plain, type, superJson) + } + return; + } - identicalPaths - .map(path => parsePath(path, legacyPaths)) - .forEach(identicalObjectPath => { - plain = setDeep(plain, identicalObjectPath, () => object); - }); - } + if (seen?.has(stringifyPath(path))) + return; + + const identical = byproduct.get(stringifyPath(path)); + if (identical) { + identical.forEach(p => seen?.add(p)); + if (type === 'set') { + const oldValue = getDeep(plain, path) as any[]; + const newValue = new Set(); + for (const other of identical) { + plain = setDeep(plain, parsePath(other, false), () => newValue); + } + for (const value of oldValue) { + newValue.add(value); + } + plain = setDeep(plain, path, () => newValue); + return; + } - if (isArray(annotations)) { - const [root, other] = annotations; - root.forEach(identicalPath => { - plain = setDeep( - plain, - parsePath(identicalPath, legacyPaths), - () => plain - ); - }); + if (type === 'map') { + const oldValue = getDeep(plain, path) as [any, any][]; + const newValue = new Map(); + for (const other of identical) { + plain = setDeep(plain, parsePath(other, false), () => newValue); + } + for (const [key, value] of oldValue) { + newValue.set(key, value); + } + plain = setDeep(plain, path, () => newValue); + return; + } - if (other) { - forEach(other, apply); - } - } else { - forEach(annotations, apply); - } + const oldValue = getDeep(plain, path); + const newValue = untransformValue(oldValue, type, superJson); + plain = setDeep(plain, path, () => newValue); + for (const other of identical) { + plain = setDeep(plain, parsePath(other, false), () => newValue) + } + } else { + plain = setDeep(plain, path, v => untransformValue(v, type, superJson)); + } + }, + version + ); return plain; }