From 1c4f139cb57ce59f723001543fe8ba7cf47eac55 Mon Sep 17 00:00:00 2001 From: ZiadTaha62 Date: Mon, 16 Mar 2026 08:53:39 +0200 Subject: [PATCH 01/10] feat: add registerSerializableClass for self-serializing classes - New SerializableClassRegistry - registerSerializableClass method - New 'serializable-class' transformer rule - Update plainer.ts for deep traversal - Add 4 new tests --- src/index.test.ts | 188 +++++++++++++++++++++++++++-- src/index.ts | 20 ++- src/plainer.ts | 4 +- src/serializable-class-registry.ts | 25 ++++ src/transformer.ts | 75 ++++++++++-- 5 files changed, 289 insertions(+), 23 deletions(-) create mode 100644 src/serializable-class-registry.ts diff --git a/src/index.test.ts b/src/index.test.ts index 00917c97..f1a67a1a 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -757,6 +757,91 @@ describe('stringify & parse', () => { }, }, }, + 'works for serializable class': { + input: () => { + class User { + constructor(public name: string, public added: Date) {} + static fromSuperJSON(json: any) { + return new User(json.name, json.added); + } + toSuperJSON() { + return { + name: this.name, + added: this.added, + }; + } + } + SuperJSON.registerSerializableClass(User); + return new User('superjson', new Date(2020, 1, 1)); + }, + output: { + name: 'superjson', + added: new Date(2020, 1, 1).toISOString(), + }, + outputAnnotations: { + values: [ + ['serializable-class', 'User'], + { + added: ['Date'], + }, + ], + }, + }, + 'works for nested serializable class': { + input: () => { + class User { + constructor(public name: string, public added: Date) {} + static fromSuperJSON(json: any) { + return new User(json.name, json.added); + } + toSuperJSON() { + return { + name: this.name, + added: this.added, + }; + } + } + class Admin { + constructor(public adminId: string, public user: User) {} + static fromSuperJSON(json: any) { + return new Admin(json.adminId, json.user); + } + toSuperJSON() { + return { + adminId: this.adminId, + user: this.user, + }; + } + } + SuperJSON.registerSerializableClass(User); + SuperJSON.registerSerializableClass(Admin); + + const user = new User('superjson', new Date(2020, 1, 1)); + const admin = new Admin('some id', user); + + return admin; + }, + output: { + adminId: 'some id', + user: { + name: 'superjson', + added: new Date(2020, 1, 1).toISOString(), + }, + }, + outputAnnotations: { + values: [ + ['serializable-class', 'Admin'], + { + user: [ + ['serializable-class', 'User'], + { + added: ['Date'], + }, + ], + }, + ], + }, + }, }; function deepFreeze(object: any, alreadySeenObjects = new Set()) { @@ -860,7 +945,7 @@ describe('stringify & parse', () => { private topSpeed: number, private color: 'red' | 'blue' | 'yellow', private brand: string, - public carriages: Set, + public carriages: Set ) {} public brag() { @@ -871,25 +956,35 @@ describe('stringify & parse', () => { SuperJSON.registerClass(Train); const { json, meta } = SuperJSON.serialize({ - s7: new Train(100, 'yellow', 'Bombardier', new Set([new Carriage('front'), new Carriage('back')])) as any, + s7: new Train( + 100, + 'yellow', + 'Bombardier', + new Set([new Carriage('front'), new Carriage('back')]) + ) as any, }); - + expect(json).toEqual({ s7: { topSpeed: 100, color: 'yellow', brand: 'Bombardier', - carriages: [ - { name: 'front' }, - { name: 'back' }, - ], + carriages: [{ name: 'front' }, { name: 'back' }], }, }); expect(meta).toEqual({ v: 1, values: { - s7: [['class', 'Train'], { carriages: ["set", { 0: [['class', 'Carriage']], 1: [['class', 'Carriage']] }] }], + s7: [ + ['class', 'Train'], + { + carriages: [ + 'set', + { 0: [['class', 'Carriage']], 1: [['class', 'Carriage']] }, + ], + }, + ], }, }); @@ -1339,7 +1434,9 @@ test('doesnt iterate to keys that dont exist', () => { test('deserialize in place', () => { const serialized = SuperJSON.serialize({ a: new Date() }); const deserializedCopy = SuperJSON.deserialize(serialized); - const deserializedInPlace = SuperJSON.deserialize(serialized, { inPlace: true }); + const deserializedInPlace = SuperJSON.deserialize(serialized, { + inPlace: true, + }); expect(deserializedInPlace).toBe(serialized.json); expect(deserializedCopy).not.toBe(serialized.json); expect(deserializedCopy).toEqual(deserializedInPlace); @@ -1384,3 +1481,76 @@ test('#310 fixes backwards compat', () => { }, }); }); + +test('constructor side-effects & initialization', () => { + let objectsCreated = 0; + + class User { + constructor(public name: string, public added: Date) { + objectsCreated++; + } + static fromSuperJSON(json: any) { + return new User(json.name, json.added); + } + toSuperJSON() { + return { + name: this.name, + added: this.added, + }; + } + } + SuperJSON.registerSerializableClass(User); + + const user = new User('superjson', new Date()); + + expect(objectsCreated).toBe(1); + + SuperJSON.parse(SuperJSON.stringify(user)); + + expect(objectsCreated).toBe(2); +}); + +test('external json props in serializable classes', () => { + class User { + constructor(public name: string, public added: Date) {} + + static fromSuperJSON(json: any) { + const { data } = json; + return new User(data.name, data.added); + } + + toSuperJSON() { + return { + label: 'USER', + created: new Date(), + data: { + name: this.name, + added: this.added, + }, + }; + } + } + SuperJSON.registerSerializableClass(User); + + expect( + SuperJSON.parse( + SuperJSON.stringify(new User('superjson', new Date(2020, 1, 1))) + ) + ).toEqual(new User('superjson', new Date(2020, 1, 1))); +}); + +test('throw if non-serializable class is passed to registerSerializableClass', () => { + // Missing static fromSuperJSON + class NonSerializable { + toSuperJSON() { + return ''; + } + } + + expect(() => { + // @ts-expect-error Passed class is not serializable and will throw + SuperJSON.registerSerializableClass(NonSerializable); + }).toThrow( + "Class 'NonSerializable' must define static 'fromJSON()' and instance 'toJSON()' methods" + ); +}); diff --git a/src/index.ts b/src/index.ts index 9a11ad74..e12e9f53 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,9 @@ import { Class, JSONValue, SuperJSONResult, SuperJSONValue } from './types.js'; import { ClassRegistry, RegisterOptions } from './class-registry.js'; +import { + SerializableClassRegistry, + SerializableClass, +} from './serializable-class-registry.js'; import { Registry } from './registry.js'; import { CustomTransfomer, @@ -60,10 +64,13 @@ export default class SuperJSON { return res; } - deserialize(payload: SuperJSONResult, options?: { inPlace?: boolean }): T { + deserialize( + payload: SuperJSONResult, + options?: { inPlace?: boolean } + ): T { const { json, meta } = payload; - let result: T = options?.inPlace ? json : copy(json) as any; + let result: T = options?.inPlace ? json : (copy(json) as any); if (meta?.values) { result = applyValueAnnotations(result, meta.values, meta.v ?? 0, this); @@ -93,6 +100,11 @@ export default class SuperJSON { this.classRegistry.register(v, options); } + readonly serializableClassRegistry = new SerializableClassRegistry(); + registerSerializableClass(v: SerializableClass, identifier?: string) { + this.serializableClassRegistry.register(v, identifier); + } + readonly symbolRegistry = new Registry(s => s.description ?? ''); registerSymbol(v: Symbol, identifier?: string) { this.symbolRegistry.register(v, identifier); @@ -130,6 +142,9 @@ export default class SuperJSON { static registerClass = SuperJSON.defaultInstance.registerClass.bind( SuperJSON.defaultInstance ); + static registerSerializableClass = SuperJSON.defaultInstance.registerSerializableClass.bind( + SuperJSON.defaultInstance + ); static registerSymbol = SuperJSON.defaultInstance.registerSymbol.bind( SuperJSON.defaultInstance ); @@ -150,6 +165,7 @@ export const stringify = SuperJSON.stringify; export const parse = SuperJSON.parse; export const registerClass = SuperJSON.registerClass; +export const registerSerializableClass = SuperJSON.registerSerializableClass; export const registerCustom = SuperJSON.registerCustom; export const registerSymbol = SuperJSON.registerSymbol; export const allowErrorProps = SuperJSON.allowErrorProps; diff --git a/src/plainer.ts b/src/plainer.ts index 8e9059f1..3eca96d5 100644 --- a/src/plainer.ts +++ b/src/plainer.ts @@ -10,6 +10,7 @@ import { import { escapeKey, stringifyPath } from './pathstringifier.js'; import { isInstanceOfRegisteredClass, + isInstanceOfSerializableClass, transformValue, TypeAnnotation, untransformValue, @@ -120,7 +121,8 @@ const isDeep = (object: any, superJson: SuperJSON): boolean => isMap(object) || isSet(object) || isError(object) || - isInstanceOfRegisteredClass(object, superJson); + isInstanceOfRegisteredClass(object, superJson) || + isInstanceOfSerializableClass(object, superJson); function addIdentity(object: any, path: any[], identities: Map) { const existingSet = identities.get(object); diff --git a/src/serializable-class-registry.ts b/src/serializable-class-registry.ts new file mode 100644 index 00000000..3c26fe99 --- /dev/null +++ b/src/serializable-class-registry.ts @@ -0,0 +1,25 @@ +import { Registry } from './registry.js'; +import { SuperJSONValue } from './types.js'; + +export interface SerializableClass { + fromSuperJSON(json: SuperJSONValue): InstanceType; + new (...args: any[]): { toSuperJSON(): SuperJSONValue }; +} + +export class SerializableClassRegistry extends Registry { + constructor() { + super(c => c.name); + } + register(value: SerializableClass, identifier?: string): void { + const id = identifier ?? value.name; + if ( + typeof value?.fromSuperJSON !== 'function' || + typeof value?.prototype.toSuperJSON !== 'function' + ) { + throw new Error( + `Class '${id}' must define static 'fromJSON()' and instance 'toJSON()' methods` + ); + } + super.register(value, id); + } +} diff --git a/src/transformer.ts b/src/transformer.ts index 69dedef1..86812db1 100644 --- a/src/transformer.ts +++ b/src/transformer.ts @@ -15,6 +15,7 @@ import { isURL, } from './is.js'; import { findArr } from './util.js'; +import { SerializableClass } from './serializable-class-registry.js'; import SuperJSON from './index.js'; export type PrimitiveTypeAnnotation = 'number' | 'undefined' | 'bigint'; @@ -25,6 +26,7 @@ type TypedArrayAnnotation = ['typed-array', string]; type ClassTypeAnnotation = ['class', string]; type SymbolTypeAnnotation = ['symbol', string]; type CustomTypeAnnotation = ['custom', string]; +type SerializableClassTypeAnnotation = ['serializable-class', string]; type SimpleTypeAnnotation = LeafTypeAnnotation | 'map' | 'set' | 'Error'; @@ -32,7 +34,8 @@ type CompositeTypeAnnotation = | TypedArrayAnnotation | ClassTypeAnnotation | SymbolTypeAnnotation - | CustomTypeAnnotation; + | CustomTypeAnnotation + | SerializableClassTypeAnnotation; export type TypeAnnotation = SimpleTypeAnnotation | CompositeTypeAnnotation; @@ -224,15 +227,16 @@ const constructorToName = [ const typedArrayRule = compositeTransformation( isTypedArray, v => ['typed-array', v.constructor.name], - v => [...v].map(n => { - // Handle special float values that JSON.stringify converts to null - if (typeof n === 'number') { - if (Number.isNaN(n)) return 'NaN'; - if (n === Infinity) return 'Infinity'; - if (n === -Infinity) return '-Infinity'; - } - return n; - }), + v => + [...v].map(n => { + // Handle special float values that JSON.stringify converts to null + if (typeof n === 'number') { + if (Number.isNaN(n)) return 'NaN'; + if (n === Infinity) return 'Infinity'; + if (n === -Infinity) return '-Infinity'; + } + return n; + }), (v, a) => { const ctor = constructorToName[a[1]]; @@ -298,6 +302,42 @@ const classRule = compositeTransformation( } ); +export function isInstanceOfSerializableClass( + potentialClass: any, + superJson: SuperJSON +): potentialClass is InstanceType { + if (potentialClass?.constructor) { + const isRegistered = !!superJson.serializableClassRegistry.getIdentifier( + potentialClass.constructor + ); + return isRegistered; + } + return false; +} +const serializableClassRule = compositeTransformation( + isInstanceOfSerializableClass, + (clazz, superJson) => { + const identifier = superJson.serializableClassRegistry.getIdentifier( + clazz.constructor as any + ); + return ['serializable-class', identifier!]; + }, + (clazz, superJson) => { + return clazz.toSuperJSON(); + }, + (v, a, superJson) => { + const clazz = superJson.serializableClassRegistry.getValue(a[1]); + + if (!clazz) { + throw new Error( + `Trying to deserialize unknown class '${a[1]}' - check https://github.com/blitz-js/superjson/issues/116#issuecomment-773996564` + ); + } + + return clazz.fromSuperJSON(v); + } +); + const customRule = compositeTransformation( (value, superJson): value is any => { return !!superJson.customTransformerRegistry.findApplicable(value); @@ -323,7 +363,18 @@ const customRule = compositeTransformation( } ); -const compositeRules = [classRule, symbolRule, customRule, typedArrayRule]; +// -------------- +// This array is order sensitive +// if same class is passed to both class registry and +// serializable class registry 'classRule' is applied +// -------------- +const compositeRules = [ + classRule, + serializableClassRule, + symbolRule, + customRule, + typedArrayRule, +]; export const transformValue = ( value: any, @@ -369,6 +420,8 @@ export const untransformValue = ( return symbolRule.untransform(json, type, superJson); case 'class': return classRule.untransform(json, type, superJson); + case 'serializable-class': + return serializableClassRule.untransform(json, type, superJson); case 'custom': return customRule.untransform(json, type, superJson); case 'typed-array': From d8830a6a209f63476ec9864ebb630146454cb7c9 Mon Sep 17 00:00:00 2001 From: ZiadTaha62 Date: Tue, 17 Mar 2026 15:23:55 +0200 Subject: [PATCH 02/10] feat: added method names option in serializable class fix: fixed issue where serializable class return non deep value --- src/index.test.ts | 45 ++++++++++++++++++++++------- src/index.ts | 9 ++++-- src/plainer.ts | 14 +++++++++ src/serializable-class-registry.ts | 46 ++++++++++++++++++++---------- src/transformer.ts | 37 +++++++++++++++++++++--- 5 files changed, 119 insertions(+), 32 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index f1a67a1a..079ffefe 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -842,6 +842,27 @@ describe('stringify & parse', () => { ], }, }, + 'works for custom serialization method names': { + input: () => { + class User { + constructor(public name: string) {} + static deserialize(json: any) { + return new User(json); + } + serialize() { + return this.name; + } + } + + SuperJSON.registerSerializableClass(User, { + methodNames: { serialize: 'serialize', deserialize: 'deserialize' }, + }); + + return new User('superjson'); + }, + output: 'superjson', + outputAnnotations: { values: [['serializable-class', 'User']] }, + }, }; function deepFreeze(object: any, alreadySeenObjects = new Set()) { @@ -1539,18 +1560,22 @@ test('external json props in serializable classes', () => { ).toEqual(new User('superjson', new Date(2020, 1, 1))); }); -test('throw if non-serializable class is passed to registerSerializableClass', () => { - // Missing static fromSuperJSON - class NonSerializable { - toSuperJSON() { - return ''; - } - } +test('throw if serilization/deserialization method is missing', () => { + class NonSerializable {} + SuperJSON.registerSerializableClass(NonSerializable); + + expect(() => { + SuperJSON.serialize(new NonSerializable()); + }).toThrow( + 'Class NonSerializable has no serialize method (must provide toSuperJSON)' + ); expect(() => { - // @ts-expect-error Passed class is not serializable and will throw - SuperJSON.registerSerializableClass(NonSerializable); + SuperJSON.deserialize({ + json: {}, + meta: { values: [['serializable-class', 'NonSerializable']], v: 1 }, + }); }).toThrow( - "Class 'NonSerializable' must define static 'fromJSON()' and instance 'toJSON()' methods" + 'Class NonSerializable has no deserialize method (must provide fromSuperJSON)' ); }); diff --git a/src/index.ts b/src/index.ts index e12e9f53..2308c5d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import { Class, JSONValue, SuperJSONResult, SuperJSONValue } from './types.js'; import { ClassRegistry, RegisterOptions } from './class-registry.js'; import { SerializableClassRegistry, - SerializableClass, + RegisterSerializableOptions, } from './serializable-class-registry.js'; import { Registry } from './registry.js'; import { @@ -101,8 +101,11 @@ export default class SuperJSON { } readonly serializableClassRegistry = new SerializableClassRegistry(); - registerSerializableClass(v: SerializableClass, identifier?: string) { - this.serializableClassRegistry.register(v, identifier); + registerSerializableClass( + v: Class, + options?: RegisterSerializableOptions | string + ) { + this.serializableClassRegistry.register(v, options); } readonly symbolRegistry = new Registry(s => s.description ?? ''); diff --git a/src/plainer.ts b/src/plainer.ts index 3eca96d5..a92d77ac 100644 --- a/src/plainer.ts +++ b/src/plainer.ts @@ -239,6 +239,20 @@ export const walker = ( const transformationResult = transformValue(object, superJson); const transformed = transformationResult?.value ?? object; + // Serializable class may return non-serializable values + if (!isDeep(transformed, superJson)) { + const type = transformationResult?.type; + const result: Result = type + ? { + transformedValue: transformed, + annotations: [type], + } + : { + transformedValue: transformed, + }; + return result; + } + const transformedValue: any = isArray(transformed) ? [] : {}; const innerAnnotations: Record> = {}; diff --git a/src/serializable-class-registry.ts b/src/serializable-class-registry.ts index 3c26fe99..b52a98a4 100644 --- a/src/serializable-class-registry.ts +++ b/src/serializable-class-registry.ts @@ -1,25 +1,41 @@ import { Registry } from './registry.js'; -import { SuperJSONValue } from './types.js'; +import { Class } from './types.js'; -export interface SerializableClass { - fromSuperJSON(json: SuperJSONValue): InstanceType; - new (...args: any[]): { toSuperJSON(): SuperJSONValue }; +export type SerializationMethodNames = { + serialize: string; + deserialize: string; +}; + +export interface RegisterSerializableOptions { + identifier?: string; + methodNames?: SerializationMethodNames; } -export class SerializableClassRegistry extends Registry { +export const DEFAULT_SERIALIZE_METHOD_NAMES: SerializationMethodNames = { + serialize: 'toSuperJSON', + deserialize: 'fromSuperJSON', +}; + +export class SerializableClassRegistry extends Registry { + private classToMethods: Map = new Map(); + constructor() { super(c => c.name); } - register(value: SerializableClass, identifier?: string): void { - const id = identifier ?? value.name; - if ( - typeof value?.fromSuperJSON !== 'function' || - typeof value?.prototype.toSuperJSON !== 'function' - ) { - throw new Error( - `Class '${id}' must define static 'fromJSON()' and instance 'toJSON()' methods` - ); + + register(value: Class, options?: RegisterSerializableOptions | string): void { + if (typeof options === 'object') { + if (options.methodNames) { + this.classToMethods.set(value, options.methodNames); + } + + super.register(value, options.identifier); + } else { + super.register(value, options); } - super.register(value, id); + } + + getMethodNames(value: Class): SerializationMethodNames | undefined { + return this.classToMethods.get(value); } } diff --git a/src/transformer.ts b/src/transformer.ts index 86812db1..67ee638a 100644 --- a/src/transformer.ts +++ b/src/transformer.ts @@ -15,8 +15,11 @@ import { isURL, } from './is.js'; import { findArr } from './util.js'; -import { SerializableClass } from './serializable-class-registry.js'; import SuperJSON from './index.js'; +import { + DEFAULT_SERIALIZE_METHOD_NAMES, + SerializationMethodNames, +} from './serializable-class-registry.js'; export type PrimitiveTypeAnnotation = 'number' | 'undefined' | 'bigint'; @@ -305,7 +308,7 @@ const classRule = compositeTransformation( export function isInstanceOfSerializableClass( potentialClass: any, superJson: SuperJSON -): potentialClass is InstanceType { +): potentialClass is any { if (potentialClass?.constructor) { const isRegistered = !!superJson.serializableClassRegistry.getIdentifier( potentialClass.constructor @@ -314,6 +317,30 @@ export function isInstanceOfSerializableClass( } return false; } + +function getMethodName( + clazz: any, + superJson: SuperJSON, + method: keyof SerializationMethodNames +) { + const ctor = method === 'serialize' ? clazz.constructor : clazz; + + const methodNames = + superJson.serializableClassRegistry.getMethodNames(ctor) ?? + DEFAULT_SERIALIZE_METHOD_NAMES; + + const name = methodNames[method]; + + if (typeof clazz[name] !== 'function') { + const id = superJson.serializableClassRegistry.getIdentifier(ctor); + throw new Error( + `Class ${id} has no ${method} method (must provide ${name})` + ); + } + + return name; +} + const serializableClassRule = compositeTransformation( isInstanceOfSerializableClass, (clazz, superJson) => { @@ -323,7 +350,8 @@ const serializableClassRule = compositeTransformation( return ['serializable-class', identifier!]; }, (clazz, superJson) => { - return clazz.toSuperJSON(); + const methodName = getMethodName(clazz, superJson, 'serialize'); + return clazz[methodName](); }, (v, a, superJson) => { const clazz = superJson.serializableClassRegistry.getValue(a[1]); @@ -334,7 +362,8 @@ const serializableClassRule = compositeTransformation( ); } - return clazz.fromSuperJSON(v); + const methodName = getMethodName(clazz, superJson, 'deserialize'); + return (clazz as any)[methodName](v); } ); From efd0b7d4273222105ca2d22bc873bf7d1c44e362 Mon Sep 17 00:00:00 2001 From: ZiadTaha62 Date: Wed, 18 Mar 2026 03:17:32 +0200 Subject: [PATCH 03/10] fix: recursive walker on return of deep transformation added: recursive option in custom transformation --- src/custom-transformer-registry.ts | 9 +- src/index.test.ts | 62 +++++++++- src/plainer.ts | 187 ++++++++++++++--------------- src/transformer.ts | 62 +++++++--- 4 files changed, 200 insertions(+), 120 deletions(-) diff --git a/src/custom-transformer-registry.ts b/src/custom-transformer-registry.ts index ebd3db6a..29a95ae9 100644 --- a/src/custom-transformer-registry.ts +++ b/src/custom-transformer-registry.ts @@ -1,24 +1,25 @@ -import { JSONValue } from './types.js'; +import { SuperJSONValue } from './types.js'; import { find } from './util.js'; -export interface CustomTransfomer { +export interface CustomTransfomer { name: string; isApplicable: (v: any) => v is I; serialize: (v: I) => O; deserialize: (v: O) => I; + recursive?: boolean; } export class CustomTransformerRegistry { private transfomers: Record> = {}; - register(transformer: CustomTransfomer) { + register(transformer: CustomTransfomer) { this.transfomers[transformer.name] = transformer; } findApplicable(v: T) { return find(this.transfomers, transformer => transformer.isApplicable(v) - ) as CustomTransfomer | undefined; + ) as CustomTransfomer | undefined; } findByName(name: string) { diff --git a/src/index.test.ts b/src/index.test.ts index 079ffefe..58543d81 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -847,10 +847,10 @@ describe('stringify & parse', () => { class User { constructor(public name: string) {} static deserialize(json: any) { - return new User(json); + return new User(json.name); } serialize() { - return this.name; + return { name: this.name }; } } @@ -860,8 +860,31 @@ describe('stringify & parse', () => { return new User('superjson'); }, - output: 'superjson', - outputAnnotations: { values: [['serializable-class', 'User']] }, + output: { name: 'superjson' }, + outputAnnotations: { + values: [['serializable-class', 'User']], + }, + }, + 'works for recrusive custom registry': { + input: () => { + class Custom { + constructor(public date: Date) {} + } + SuperJSON.registerCustom( + { + isApplicable: v => v instanceof Custom, + serialize: (v: Custom) => v.date, + deserialize: (v: any) => new Custom(v), + recursive: true, + }, + 'OurCustom' + ); + return new Custom(new Date(2020, 1, 1)); + }, + output: new Date(2020, 1, 1).toISOString(), + outputAnnotations: { + values: [['custom', 'OurCustom'], ['Date']], + }, }, }; @@ -1579,3 +1602,34 @@ test('throw if serilization/deserialization method is missing', () => { 'Class NonSerializable has no deserialize method (must provide fromSuperJSON)' ); }); + +test('Handles recrusive primitives and non-serilizable classes', () => { + class PrimitiveTest { + constructor(public name: string = 'superjson') {} + toSuperJSON() { + return this.name; + } + } + + SuperJSON.registerSerializableClass(PrimitiveTest); + + expect(SuperJSON.serialize(new PrimitiveTest())).toEqual({ + json: 'superjson', + meta: { values: [['serializable-class', 'PrimitiveTest']], v: 1 }, + }); + + class NonSerializable {} + class ClassTest { + constructor(public cls: NonSerializable = new NonSerializable()) {} + toSuperJSON() { + return this.cls; + } + } + + SuperJSON.registerSerializableClass(ClassTest); + + expect(SuperJSON.serialize(new ClassTest())).toEqual({ + json: new NonSerializable(), + meta: { values: [['serializable-class', 'ClassTest']], v: 1 }, + }); +}); diff --git a/src/plainer.ts b/src/plainer.ts index a92d77ac..a4bb29b6 100644 --- a/src/plainer.ts +++ b/src/plainer.ts @@ -1,16 +1,6 @@ -import { - isArray, - isEmptyObject, - isError, - isMap, - isPlainObject, - isPrimitive, - isSet, -} from './is.js'; +import { isArray, isEmptyObject, isPlainObject, isPrimitive } from './is.js'; import { escapeKey, stringifyPath } from './pathstringifier.js'; import { - isInstanceOfRegisteredClass, - isInstanceOfSerializableClass, transformValue, TypeAnnotation, untransformValue, @@ -22,7 +12,7 @@ import SuperJSON from './index.js'; type Tree = InnerNode | Leaf; type Leaf = [T]; -type InnerNode = [T, Record>]; +type InnerNode = [T, Tree | Record>]; export type MinimisedTree = Tree | Record> | undefined; @@ -51,12 +41,16 @@ function traverse( const [nodeValue, children] = tree; if (children) { - forEach(children, (child, key) => { - traverse(child, walker, version, [ - ...origin, - ...parsePath(key, legacyPaths), - ]); - }); + if (isArray(children)) { + traverse(children, walker, version, origin); + } else { + forEach(children, (child, key) => { + traverse(child, walker, version, [ + ...origin, + ...parsePath(key, legacyPaths), + ]); + }); + } } walker(nodeValue, origin); @@ -115,15 +109,6 @@ export function applyReferentialEqualityAnnotations( return plain; } -const isDeep = (object: any, superJson: SuperJSON): boolean => - isPlainObject(object) || - isArray(object) || - isMap(object) || - isSet(object) || - isError(object) || - isInstanceOfRegisteredClass(object, superJson) || - isInstanceOfSerializableClass(object, superJson); - function addIdentity(object: any, path: any[], identities: Map) { const existingSet = identities.get(object); @@ -187,6 +172,9 @@ export function generateReferentialEqualityAnnotations( } } +const isPlainObjectOrArray = (object: any) => + isPlainObject(object) || isArray(object); + export const walker = ( object: any, identities: Map, @@ -212,23 +200,6 @@ export const walker = ( } } - if (!isDeep(object, superJson)) { - const transformed = transformValue(object, superJson); - - const result: Result = transformed - ? { - transformedValue: transformed.value, - annotations: [transformed.type], - } - : { - transformedValue: object, - }; - if (!primitive) { - seenObjects.set(object, result); - } - return result; - } - if (includes(objectsInThisPath, object)) { // prevent circular references return { @@ -236,74 +207,102 @@ export const walker = ( }; } + // Try to tansform object (apply composite or simple rule if applicable) const transformationResult = transformValue(object, superJson); - const transformed = transformationResult?.value ?? object; - // Serializable class may return non-serializable values - if (!isDeep(transformed, superJson)) { - const type = transformationResult?.type; - const result: Result = type - ? { - transformedValue: transformed, - annotations: [type], - } - : { - transformedValue: transformed, - }; - return result; - } + // Handle value if transformed + if (transformationResult) { + const { value, type, isDeep } = transformationResult; - const transformedValue: any = isArray(transformed) ? [] : {}; - const innerAnnotations: Record> = {}; - - forEach(transformed, (value, index) => { - if ( - index === '__proto__' || - index === 'constructor' || - index === 'prototype' - ) { - throw new Error( - `Detected property ${index}. This is a prototype pollution risk, please remove it from your object.` - ); + // If transformer mark value as non deep return it + if (!isDeep) { + const result: Result = { + transformedValue: value, + annotations: [type], + }; + if (!primitive) seenObjects.set(object, result); + return result; } + // recurse if transformer mark value as deep const recursiveResult = walker( value, identities, superJson, dedupe, - [...path, index], + path, [...objectsInThisPath, object], seenObjects ); - transformedValue[index] = recursiveResult.transformedValue; + const result: Result = recursiveResult.annotations + ? { + transformedValue: recursiveResult.transformedValue, + annotations: [type, recursiveResult.annotations], + } + : { + transformedValue: recursiveResult.transformedValue, + annotations: [type], + }; - if (isArray(recursiveResult.annotations)) { - innerAnnotations[escapeKey(index)] = recursiveResult.annotations; - } else if (isPlainObject(recursiveResult.annotations)) { - forEach(recursiveResult.annotations, (tree, key) => { - innerAnnotations[escapeKey(index) + '.' + key] = tree; - }); - } - }); + if (!primitive) seenObjects.set(object, result); + return result; + } - const result: Result = isEmptyObject(innerAnnotations) - ? { - transformedValue, - annotations: !!transformationResult - ? [transformationResult.type] - : undefined, + // Handle value if plain object or array + if (isPlainObjectOrArray(object)) { + const transformedValue: any = isArray(object) ? [] : {}; + const innerAnnotations: Record> = {}; + + forEach(object, (value, index) => { + if ( + index === '__proto__' || + index === 'constructor' || + index === 'prototype' + ) { + throw new Error( + `Detected property ${index}. This is a prototype pollution risk, please remove it from your object.` + ); } - : { - transformedValue, - annotations: !!transformationResult - ? [transformationResult.type, innerAnnotations] - : innerAnnotations, - }; - if (!primitive) { - seenObjects.set(object, result); + + const recursiveResult = walker( + value, + identities, + superJson, + dedupe, + [...path, index], + [...objectsInThisPath, object], + seenObjects + ); + + transformedValue[index] = recursiveResult.transformedValue; + + if (isArray(recursiveResult.annotations)) { + innerAnnotations[escapeKey(index)] = recursiveResult.annotations; + } else if (isPlainObject(recursiveResult.annotations)) { + forEach(recursiveResult.annotations, (tree, key) => { + innerAnnotations[escapeKey(index) + '.' + key] = tree; + }); + } + }); + + const result: Result = isEmptyObject(innerAnnotations) + ? { + transformedValue, + } + : { + transformedValue, + annotations: innerAnnotations, + }; + + if (!primitive) seenObjects.set(object, result); + return result; } + // Return value as is + const result = { + transformedValue: object, + }; + if (!primitive) seenObjects.set(object, result); return result; }; diff --git a/src/transformer.ts b/src/transformer.ts index 67ee638a..26a17ea5 100644 --- a/src/transformer.ts +++ b/src/transformer.ts @@ -46,13 +46,15 @@ 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) => I, + isDeep: boolean ) { return { isApplicable, annotation, transform, untransform, + isDeep, }; } @@ -61,7 +63,8 @@ const simpleRules = [ isUndefined, 'undefined', () => null, - () => undefined + () => undefined, + false ), simpleTransformation( isBigint, @@ -75,13 +78,15 @@ const simpleRules = [ console.error('Please add a BigInt polyfill.'); return v as any; - } + }, + false ), simpleTransformation( isDate, 'Date', v => v.toISOString(), - v => new Date(v) + v => new Date(v), + false ), simpleTransformation( @@ -113,7 +118,8 @@ const simpleRules = [ }); return e; - } + }, + true ), simpleTransformation( @@ -124,7 +130,8 @@ const simpleRules = [ const body = regex.slice(1, regex.lastIndexOf('/')); const flags = regex.slice(regex.lastIndexOf('/') + 1); return new RegExp(body, flags); - } + }, + false ), simpleTransformation( @@ -133,13 +140,15 @@ const simpleRules = [ // (sets only exist in es6+) // eslint-disable-next-line es5/no-es6-methods v => [...v.values()], - v => new Set(v) + v => new Set(v), + true ), simpleTransformation( isMap, 'map', v => [...v.entries()], - v => new Map(v) + v => new Map(v), + true ), simpleTransformation( @@ -156,7 +165,8 @@ const simpleRules = [ return '-Infinity'; } }, - Number + Number, + false ), simpleTransformation( @@ -165,14 +175,16 @@ const simpleRules = [ () => { return '-0'; }, - Number + Number, + false ), simpleTransformation( isURL, 'URL', v => v.toString(), - v => new URL(v) + v => new URL(v), + false ), ]; @@ -180,13 +192,15 @@ function compositeTransformation( isApplicable: (v: any, superJson: SuperJSON) => v is I, annotation: (v: I, superJson: SuperJSON) => A, transform: (v: I, superJson: SuperJSON) => O, - untransform: (v: O, a: A, superJson: SuperJSON) => I + untransform: (v: O, a: A, superJson: SuperJSON) => I, + isDeep: (v: O, superJson: SuperJSON) => boolean ) { return { isApplicable, annotation, transform, untransform, + isDeep, }; } @@ -209,7 +223,8 @@ const symbolRule = compositeTransformation( throw new Error('Trying to deserialize unknown symbol'); } return value; - } + }, + () => false ); const constructorToName = [ @@ -256,7 +271,8 @@ const typedArrayRule = compositeTransformation( }); return new ctor(values as number[]); - } + }, + () => false ); export function isInstanceOfRegisteredClass( @@ -302,7 +318,8 @@ const classRule = compositeTransformation( } return Object.assign(Object.create(clazz.prototype), v); - } + }, + () => true ); export function isInstanceOfSerializableClass( @@ -364,7 +381,8 @@ const serializableClassRule = compositeTransformation( const methodName = getMethodName(clazz, superJson, 'deserialize'); return (clazz as any)[methodName](v); - } + }, + () => true ); const customRule = compositeTransformation( @@ -389,6 +407,12 @@ const customRule = compositeTransformation( throw new Error('Trying to deserialize unknown custom value'); } return transformer.deserialize(v); + }, + (value, superJson) => { + const transformer = superJson.customTransformerRegistry.findApplicable( + value + )!; + return !!transformer.recursive; } ); @@ -408,7 +432,7 @@ const compositeRules = [ export const transformValue = ( value: any, superJson: SuperJSON -): { value: any; type: TypeAnnotation } | undefined => { +): { value: any; type: TypeAnnotation; isDeep: boolean } | undefined => { const applicableCompositeRule = findArr(compositeRules, rule => rule.isApplicable(value, superJson) ); @@ -416,6 +440,7 @@ export const transformValue = ( return { value: applicableCompositeRule.transform(value as never, superJson), type: applicableCompositeRule.annotation(value, superJson), + isDeep: applicableCompositeRule.isDeep(value, superJson), }; } @@ -427,10 +452,11 @@ export const transformValue = ( return { value: applicableSimpleRule.transform(value as never, superJson), type: applicableSimpleRule.annotation, + isDeep: applicableSimpleRule.isDeep, }; } - return undefined; + return; }; const simpleRulesByAnnotation: Record = {}; From 36e37cbae8ff1b0c82f268e04e3839660440f51a Mon Sep 17 00:00:00 2001 From: ZiadTaha62 Date: Wed, 18 Mar 2026 03:38:50 +0200 Subject: [PATCH 04/10] fix: added isRecursive option in walker to prevent overwrite of identity in recursive paths --- src/plainer.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/plainer.ts b/src/plainer.ts index a4bb29b6..9fd23efc 100644 --- a/src/plainer.ts +++ b/src/plainer.ts @@ -182,11 +182,12 @@ export const walker = ( dedupe: boolean, path: any[] = [], objectsInThisPath: any[] = [], - seenObjects = new Map() + seenObjects = new Map(), + isRecursive: boolean = false // Prevent overwrite of original object of identities map in recursive transformtion ): Result => { const primitive = isPrimitive(object); - if (!primitive) { + if (!primitive && !isRecursive) { addIdentity(object, path, identities); const seen = seenObjects.get(object); @@ -232,7 +233,8 @@ export const walker = ( dedupe, path, [...objectsInThisPath, object], - seenObjects + seenObjects, + true ); const result: Result = recursiveResult.annotations From 50a0aeb55112797d26dd92e81ceeb1057f666877 Mon Sep 17 00:00:00 2001 From: ZiadTaha62 Date: Fri, 20 Mar 2026 04:05:02 +0200 Subject: [PATCH 05/10] fix: fixed bug in plain.ts where seenObjects is skipped --- src/plainer.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plainer.ts b/src/plainer.ts index 9fd23efc..909857ed 100644 --- a/src/plainer.ts +++ b/src/plainer.ts @@ -183,12 +183,12 @@ export const walker = ( path: any[] = [], objectsInThisPath: any[] = [], seenObjects = new Map(), - isRecursive: boolean = false // Prevent overwrite of original object of identities map in recursive transformtion + isTransformation: boolean = false // Prevent overwrite of original object in identities map in recursive transformtion ): Result => { const primitive = isPrimitive(object); - if (!primitive && !isRecursive) { - addIdentity(object, path, identities); + if (!primitive) { + if (!isTransformation) addIdentity(object, path, identities); const seen = seenObjects.get(object); if (seen) { From 956c3002df8b30f1d0a2690e87739a11e6326caa Mon Sep 17 00:00:00 2001 From: ZiadTaha62 Date: Tue, 24 Mar 2026 02:34:10 +0200 Subject: [PATCH 06/10] fix: Updated plainer logic feat: removed serializable class registry to focus on one thing a time --- src/accessDeep.ts | 13 +- src/custom-transformer-registry.ts | 36 +++-- src/index.test.ts | 234 ++++------------------------ src/index.ts | 50 +++--- src/plainer.ts | 235 ++++++++++++++++++++--------- src/serializable-class-registry.ts | 41 ----- src/transformer.ts | 86 +---------- src/types.ts | 9 +- 8 files changed, 254 insertions(+), 450 deletions(-) delete mode 100644 src/serializable-class-registry.ts diff --git a/src/accessDeep.ts b/src/accessDeep.ts index ea986130..3bf9a922 100644 --- a/src/accessDeep.ts +++ b/src/accessDeep.ts @@ -1,8 +1,10 @@ import { isMap, isArray, isPlainObject, isSet } from './is.js'; import { includes } from './util.js'; +const OUT_OF_BOUNDS_ERROR = 'index out of bounds'; + const getNthKey = (value: Map | Set, n: number): any => { - if (n > value.size) throw new Error('index out of bounds'); + if (n > value.size) throw new Error(OUT_OF_BOUNDS_ERROR); const keys = value.keys(); while (n > 0) { keys.next(); @@ -70,8 +72,11 @@ export const setDeep = ( if (isArray(parent)) { const index = +key; + if (index > parent.length - 1) throw new Error(OUT_OF_BOUNDS_ERROR); parent = parent[index]; } else if (isPlainObject(parent)) { + // Should use (key in parent) here and throw 'OUT_OF_BOUNDS_ERROR' if not present + // but it may affect performance if not really needed parent = parent[key]; } else if (isSet(parent)) { const row = +key; @@ -100,8 +105,12 @@ export const setDeep = ( const lastKey = path[path.length - 1]; if (isArray(parent)) { - parent[+lastKey] = mapper(parent[+lastKey]); + const index = +lastKey; + if (index > parent.length - 1) throw new Error(OUT_OF_BOUNDS_ERROR); + parent[index] = mapper(parent[index]); } else if (isPlainObject(parent)) { + // Should use (key in parent) here and throw 'OUT_OF_BOUNDS_ERROR' if not present + // but it may affect performance if not really needed parent[lastKey] = mapper(parent[lastKey]); } diff --git a/src/custom-transformer-registry.ts b/src/custom-transformer-registry.ts index 29a95ae9..52c792ac 100644 --- a/src/custom-transformer-registry.ts +++ b/src/custom-transformer-registry.ts @@ -1,28 +1,46 @@ -import { SuperJSONValue } from './types.js'; +import { SuperJSONValue, JSONValue } from './types.js'; import { find } from './util.js'; -export interface CustomTransfomer { +export interface NonRecursiveCustomTransfomer { name: string; isApplicable: (v: any) => v is I; serialize: (v: I) => O; deserialize: (v: O) => I; - recursive?: boolean; + recursive?: false; } +export interface RecursiveCustomTransfomer { + name: string; + isApplicable: (v: any) => v is I; + serialize: (v: I) => O; + deserialize: (v: O) => I; + recursive: true; +} + +export type AnyCustomTransformer = + | NonRecursiveCustomTransfomer + | RecursiveCustomTransfomer; + export class CustomTransformerRegistry { - private transfomers: Record> = {}; + private transformers: Record = {}; - register(transformer: CustomTransfomer) { - this.transfomers[transformer.name] = transformer; + register( + transformer: NonRecursiveCustomTransfomer + ): void; + register( + transformer: RecursiveCustomTransfomer + ): void; + register(transformer: AnyCustomTransformer) { + this.transformers[transformer.name] = transformer; } findApplicable(v: T) { - return find(this.transfomers, transformer => + return find(this.transformers, transformer => transformer.isApplicable(v) - ) as CustomTransfomer | undefined; + ) as AnyCustomTransformer | undefined; } findByName(name: string) { - return this.transfomers[name]; + return this.transformers[name]; } } diff --git a/src/index.test.ts b/src/index.test.ts index 58543d81..c72ee9a9 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -757,114 +757,6 @@ describe('stringify & parse', () => { }, }, }, - 'works for serializable class': { - input: () => { - class User { - constructor(public name: string, public added: Date) {} - static fromSuperJSON(json: any) { - return new User(json.name, json.added); - } - toSuperJSON() { - return { - name: this.name, - added: this.added, - }; - } - } - SuperJSON.registerSerializableClass(User); - return new User('superjson', new Date(2020, 1, 1)); - }, - output: { - name: 'superjson', - added: new Date(2020, 1, 1).toISOString(), - }, - outputAnnotations: { - values: [ - ['serializable-class', 'User'], - { - added: ['Date'], - }, - ], - }, - }, - 'works for nested serializable class': { - input: () => { - class User { - constructor(public name: string, public added: Date) {} - static fromSuperJSON(json: any) { - return new User(json.name, json.added); - } - toSuperJSON() { - return { - name: this.name, - added: this.added, - }; - } - } - class Admin { - constructor(public adminId: string, public user: User) {} - static fromSuperJSON(json: any) { - return new Admin(json.adminId, json.user); - } - toSuperJSON() { - return { - adminId: this.adminId, - user: this.user, - }; - } - } - SuperJSON.registerSerializableClass(User); - SuperJSON.registerSerializableClass(Admin); - - const user = new User('superjson', new Date(2020, 1, 1)); - const admin = new Admin('some id', user); - - return admin; - }, - output: { - adminId: 'some id', - user: { - name: 'superjson', - added: new Date(2020, 1, 1).toISOString(), - }, - }, - outputAnnotations: { - values: [ - ['serializable-class', 'Admin'], - { - user: [ - ['serializable-class', 'User'], - { - added: ['Date'], - }, - ], - }, - ], - }, - }, - 'works for custom serialization method names': { - input: () => { - class User { - constructor(public name: string) {} - static deserialize(json: any) { - return new User(json.name); - } - serialize() { - return { name: this.name }; - } - } - - SuperJSON.registerSerializableClass(User, { - methodNames: { serialize: 'serialize', deserialize: 'deserialize' }, - }); - - return new User('superjson'); - }, - output: { name: 'superjson' }, - outputAnnotations: { - values: [['serializable-class', 'User']], - }, - }, 'works for recrusive custom registry': { input: () => { class Custom { @@ -1526,110 +1418,48 @@ test('#310 fixes backwards compat', () => { }); }); -test('constructor side-effects & initialization', () => { - let objectsCreated = 0; - - class User { - constructor(public name: string, public added: Date) { - objectsCreated++; - } - static fromSuperJSON(json: any) { - return new User(json.name, json.added); - } - toSuperJSON() { - return { - name: this.name, - added: this.added, - }; - } +test('recursive custom transformer does NOT preserve external referential equality (known limitation)', () => { + class Box { + constructor(public value: any) {} } - SuperJSON.registerSerializableClass(User); - const user = new User('superjson', new Date()); - - expect(objectsCreated).toBe(1); + const shared = { when: new Date('2024-01-01T00:00:00.000Z') }; + const input = { + a: new Box(shared), + b: shared, + }; - SuperJSON.parse(SuperJSON.stringify(user)); + const serialized = SuperJSON.serialize(input); + const result: any = SuperJSON.deserialize(serialized); - expect(objectsCreated).toBe(2); + expect(result.a.value).toEqual(result.b); // Same value + expect(result.a.value).not.toBe(result.b); // Not same reference }); -test('external json props in serializable classes', () => { - class User { - constructor(public name: string, public added: Date) {} - - static fromSuperJSON(json: any) { - const { data } = json; - return new User(data.name, data.added); - } - - toSuperJSON() { - return { - label: 'USER', - created: new Date(), - data: { - name: this.name, - added: this.added, - }, - }; - } +test('dedupe=true with recursive custom transformer', () => { + class Box { + constructor(public value: any) {} } - SuperJSON.registerSerializableClass(User); - - expect( - SuperJSON.parse( - SuperJSON.stringify(new User('superjson', new Date(2020, 1, 1))) - ) - ).toEqual(new User('superjson', new Date(2020, 1, 1))); -}); -test('throw if serilization/deserialization method is missing', () => { - class NonSerializable {} - SuperJSON.registerSerializableClass(NonSerializable); - - expect(() => { - SuperJSON.serialize(new NonSerializable()); - }).toThrow( - 'Class NonSerializable has no serialize method (must provide toSuperJSON)' - ); - - expect(() => { - SuperJSON.deserialize({ - json: {}, - meta: { values: [['serializable-class', 'NonSerializable']], v: 1 }, - }); - }).toThrow( - 'Class NonSerializable has no deserialize method (must provide fromSuperJSON)' + SuperJSON.registerCustom( + { + isApplicable: (v): v is Box => v instanceof Box, + serialize: (v: Box) => v.value, + deserialize: (v: any) => new Box(v), + recursive: true, + }, + 'box-dedupe' ); -}); -test('Handles recrusive primitives and non-serilizable classes', () => { - class PrimitiveTest { - constructor(public name: string = 'superjson') {} - toSuperJSON() { - return this.name; - } - } - - SuperJSON.registerSerializableClass(PrimitiveTest); - - expect(SuperJSON.serialize(new PrimitiveTest())).toEqual({ - json: 'superjson', - meta: { values: [['serializable-class', 'PrimitiveTest']], v: 1 }, - }); - - class NonSerializable {} - class ClassTest { - constructor(public cls: NonSerializable = new NonSerializable()) {} - toSuperJSON() { - return this.cls; - } - } + const shared = { date: new Date('2024-01-01T00:00:00.000Z') }; + const input = { + first: new Box(shared), + second: new Box(shared), + }; - SuperJSON.registerSerializableClass(ClassTest); + const instance = new SuperJSON({ dedupe: true }); + const serialized = instance.serialize(input); + const result: any = instance.deserialize(serialized); - expect(SuperJSON.serialize(new ClassTest())).toEqual({ - json: new NonSerializable(), - meta: { values: [['serializable-class', 'ClassTest']], v: 1 }, - }); + expect(result.first.value).toBe(result.second.value); }); diff --git a/src/index.ts b/src/index.ts index 2308c5d8..7523b4a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,17 +1,14 @@ import { Class, JSONValue, SuperJSONResult, SuperJSONValue } from './types.js'; import { ClassRegistry, RegisterOptions } from './class-registry.js'; -import { - SerializableClassRegistry, - RegisterSerializableOptions, -} from './serializable-class-registry.js'; import { Registry } from './registry.js'; import { - CustomTransfomer, + NonRecursiveCustomTransfomer, + RecursiveCustomTransfomer, + AnyCustomTransformer, CustomTransformerRegistry, } from './custom-transformer-registry.js'; import { - applyReferentialEqualityAnnotations, - applyValueAnnotations, + applyMeta, generateReferentialEqualityAnnotations, walker, } from './plainer.js'; @@ -35,7 +32,7 @@ export default class SuperJSON { } serialize(object: SuperJSONValue): SuperJSONResult { - const identities = new Map(); + const identities = new Map(); const output = walker(object, identities, this, this.dedupe); const res: SuperJSONResult = { json: output.transformedValue, @@ -52,6 +49,7 @@ export default class SuperJSON { identities, this.dedupe ); + if (equalityAnnotations) { res.meta = { ...res.meta, @@ -72,16 +70,8 @@ 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 - ); + if (meta) { + result = applyMeta(result, meta, this); } return result; @@ -100,14 +90,6 @@ export default class SuperJSON { this.classRegistry.register(v, options); } - readonly serializableClassRegistry = new SerializableClassRegistry(); - registerSerializableClass( - v: Class, - options?: RegisterSerializableOptions | string - ) { - this.serializableClassRegistry.register(v, options); - } - readonly symbolRegistry = new Registry(s => s.description ?? ''); registerSymbol(v: Symbol, identifier?: string) { this.symbolRegistry.register(v, identifier); @@ -115,13 +97,21 @@ export default class SuperJSON { readonly customTransformerRegistry = new CustomTransformerRegistry(); registerCustom( - transformer: Omit, 'name'>, + transformer: Omit, 'name'>, + name: string + ): void; + registerCustom( + transformer: Omit, 'name'>, + name: string + ): void; + registerCustom( + transformer: Omit, name: string ) { this.customTransformerRegistry.register({ name, ...transformer, - }); + } as any); } readonly allowedErrorProps: string[] = []; @@ -145,9 +135,6 @@ export default class SuperJSON { static registerClass = SuperJSON.defaultInstance.registerClass.bind( SuperJSON.defaultInstance ); - static registerSerializableClass = SuperJSON.defaultInstance.registerSerializableClass.bind( - SuperJSON.defaultInstance - ); static registerSymbol = SuperJSON.defaultInstance.registerSymbol.bind( SuperJSON.defaultInstance ); @@ -168,7 +155,6 @@ export const stringify = SuperJSON.stringify; export const parse = SuperJSON.parse; export const registerClass = SuperJSON.registerClass; -export const registerSerializableClass = SuperJSON.registerSerializableClass; export const registerCustom = SuperJSON.registerCustom; export const registerSymbol = SuperJSON.registerSymbol; export const allowErrorProps = SuperJSON.allowErrorProps; diff --git a/src/plainer.ts b/src/plainer.ts index 909857ed..e8df464d 100644 --- a/src/plainer.ts +++ b/src/plainer.ts @@ -12,101 +12,174 @@ import SuperJSON from './index.js'; type Tree = InnerNode | Leaf; type Leaf = [T]; -type InnerNode = [T, Tree | Record>]; +type InnerNode = [T, MinimisedTree]; -export type MinimisedTree = Tree | Record> | undefined; +export type MinimisedTree = + | Tree + | { [k: string]: MinimisedTree } + | undefined; const enableLegacyPaths = (version: number) => version < 1; +function traverseObject( + tree: { [k: string]: MinimisedTree }, + walker: (type: any, path: string[], strPath?: string) => void, + legacyPaths: boolean, + origin: string[], + strOrigin: string | undefined = undefined +) { + forEach(tree, (subtree, key) => { + traverse( + subtree, + walker, + legacyPaths, + [...origin, ...parsePath(key, legacyPaths)], + strOrigin !== undefined ? strOrigin + '.' + key : key + ); + }); +} + function traverse( tree: MinimisedTree, - walker: (v: T, path: string[]) => void, - version: number, - origin: string[] = [] + walker: (type: any, path: string[], strPath?: string) => void, + legacyPaths: boolean, + origin: string[] = [], + strOrigin: string | undefined = undefined ): void { if (!tree) { return; } - const legacyPaths = enableLegacyPaths(version); if (!isArray(tree)) { - forEach(tree, (subtree, key) => - traverse(subtree, walker, version, [ - ...origin, - ...parsePath(key, legacyPaths), - ]) - ); + traverseObject(tree, walker, legacyPaths, origin, strOrigin); return; } const [nodeValue, children] = tree; if (children) { if (isArray(children)) { - traverse(children, walker, version, origin); + traverse(children, walker, legacyPaths, origin); } else { - forEach(children, (child, key) => { - traverse(child, walker, version, [ - ...origin, - ...parsePath(key, legacyPaths), - ]); - }); + traverseObject(children, walker, legacyPaths, origin, strOrigin); } } - walker(nodeValue, origin); + walker(nodeValue, origin, strOrigin); } -export function applyValueAnnotations( - plain: any, - annotations: MinimisedTree, - version: number, - superJson: SuperJSON +/** + * Function to parse referential equalities object into: + * - root: Array of root referential equalities to loop and apply them at the end + * - other: Map of 'representative paths' -> 'parsed duplicate paths' + * - duplicate: Set of all duplicate (non-representative) paths to skip them in walker + * when applying value annotations + */ +function parseReferentialEqualities( + referentialEqualities: ReferentialEqualityAnnotations | undefined, + legacyPaths: boolean ) { - traverse( - annotations, - (type, path) => { - plain = setDeep(plain, path, v => untransformValue(v, type, superJson)); - }, - version - ); + // Extract root and other from referentialEqualities + let root: string[] | undefined; + let other: Record | undefined; + if (isArray(referentialEqualities)) { + const [r, o] = referentialEqualities; + root = r; + other = o; + } else { + other = referentialEqualities; + } + + const rootArray = root?.map(p => parsePath(p, legacyPaths)) ?? []; + + const otherMap = new Map(); + const duplicateSet = new Set(); + if (other) { + for (const [rep, iden] of Object.entries(other)) { + otherMap.set( + rep, + iden.map(p => parsePath(p, legacyPaths)) + ); + for (const p of iden) duplicateSet.add(p); + } + } - return plain; + // Return root array and other map + return { root: rootArray, other: otherMap, duplicate: duplicateSet }; } -export function applyReferentialEqualityAnnotations( - plain: any, - annotations: ReferentialEqualityAnnotations, - version: number -) { +export type MetaObject = { + values?: MinimisedTree; + referentialEqualities?: ReferentialEqualityAnnotations; + v?: number; +}; + +/** + * This function apply meta object (value and referential equalities annotations) to JSON input. + * + * Behavior: + * - 1. Apply all non-root referential equalities first so recursive value annotations get proper input even with dedupe=true + * - 2. Apply value annotations, while also check referential equality: + * - A. If node is duplicate skip the annotation + * - B. If node is representative update all duplicate nodes + * - C. If not referentially equal to any other node apply annotation normally + * - 3. Apply root referential equalities + * + * @returns Modified JSON after applying value and referential equalities annotations + */ +export function applyMeta( + json: any, + meta: MetaObject, + superJson: SuperJSON +): any { + // Handle version + const version = meta.v ?? 0; const legacyPaths = enableLegacyPaths(version); - function apply(identicalPaths: string[], path: string) { - const object = getDeep(plain, parsePath(path, legacyPaths)); - - identicalPaths - .map(path => parsePath(path, legacyPaths)) - .forEach(identicalObjectPath => { - plain = setDeep(plain, identicalObjectPath, () => object); - }); + + // Parse referenial equality object (parsed once for performance) + const { root, other, duplicate } = parseReferentialEqualities( + meta.referentialEqualities, + legacyPaths + ); + + // Function to set referential equality + const setReferentialEqualityFn = ( + representativePath: string[], + identicalPaths: string[][] + ) => { + const object = getDeep(json, representativePath); + for (const p of identicalPaths) json = setDeep(json, p, () => object); + }; + + // Function to set value annotation, It also update referential equality if needed + const setValueAnnotationsFn = ( + type: any, + path: string[], + strPath?: string + ) => { + // Skip on duplicate paths (Will be updated by representative path) + if (strPath && duplicate.has(strPath)) return; + // Update json + json = setDeep(json, path, v => untransformValue(v, type, superJson)); + // If node is representative update all duplicate paths + const refPaths = strPath && other.get(strPath); + if (refPaths) setReferentialEqualityFn(path, refPaths); + }; + + // Apply other referential equality + for (const [rep, iden] of other) { + setReferentialEqualityFn(parsePath(rep, legacyPaths), iden); } - if (isArray(annotations)) { - const [root, other] = annotations; - root.forEach(identicalPath => { - plain = setDeep( - plain, - parsePath(identicalPath, legacyPaths), - () => plain - ); - }); + // Apply value annotations and in-place referential equality if node is updated + traverse(meta.values, setValueAnnotationsFn, legacyPaths); - if (other) { - forEach(other, apply); - } - } else { - forEach(annotations, apply); + // Apply root referential equalities + for (const p of root) { + json = setDeep(json, p, () => json); } - return plain; + // return modified json + return json; } function addIdentity(object: any, path: any[], identities: Map) { @@ -175,20 +248,31 @@ export function generateReferentialEqualityAnnotations( const isPlainObjectOrArray = (object: any) => isPlainObject(object) || isArray(object); +/** + * Walker to serialize input. It supports recursive custom transformations so 'walker' also applied to return + * of transformations if needed. + * + * Known limitation: + * - Return of recursive custom will always be considered new object even if defined in the tree else where (referential + * equality is blocked on the same path) so it will be linked in referential equality nor be deduped, This limitation is + * intentional to avoid making code too complex where we need to store and compare depths if multple objects are defined + * at the same path during transformation. + */ export const walker = ( object: any, identities: Map, superJson: SuperJSON, dedupe: boolean, - path: any[] = [], + path: string[] = [], objectsInThisPath: any[] = [], seenObjects = new Map(), - isTransformation: boolean = false // Prevent overwrite of original object in identities map in recursive transformtion + isSamePath: boolean = false ): Result => { const primitive = isPrimitive(object); + const registerSeen = !primitive && !isSamePath; - if (!primitive) { - if (!isTransformation) addIdentity(object, path, identities); + if (registerSeen) { + addIdentity(object, path, identities); const seen = seenObjects.get(object); if (seen) { @@ -221,21 +305,23 @@ export const walker = ( transformedValue: value, annotations: [type], }; - if (!primitive) seenObjects.set(object, result); + if (registerSeen) seenObjects.set(object, result); return result; } // recurse if transformer mark value as deep + objectsInThisPath.push(object); const recursiveResult = walker( value, identities, superJson, dedupe, path, - [...objectsInThisPath, object], + objectsInThisPath, seenObjects, true ); + objectsInThisPath.pop(); const result: Result = recursiveResult.annotations ? { @@ -247,14 +333,14 @@ export const walker = ( annotations: [type], }; - if (!primitive) seenObjects.set(object, result); + if (registerSeen) seenObjects.set(object, result); return result; } // Handle value if plain object or array if (isPlainObjectOrArray(object)) { const transformedValue: any = isArray(object) ? [] : {}; - const innerAnnotations: Record> = {}; + const innerAnnotations: Record> = {}; forEach(object, (value, index) => { if ( @@ -267,15 +353,18 @@ export const walker = ( ); } + objectsInThisPath.push(object); const recursiveResult = walker( value, identities, superJson, dedupe, [...path, index], - [...objectsInThisPath, object], - seenObjects + objectsInThisPath, + seenObjects, + false ); + objectsInThisPath.pop(); transformedValue[index] = recursiveResult.transformedValue; @@ -297,7 +386,7 @@ export const walker = ( annotations: innerAnnotations, }; - if (!primitive) seenObjects.set(object, result); + if (registerSeen) seenObjects.set(object, result); return result; } @@ -305,6 +394,6 @@ export const walker = ( const result = { transformedValue: object, }; - if (!primitive) seenObjects.set(object, result); + if (registerSeen) seenObjects.set(object, result); return result; }; diff --git a/src/serializable-class-registry.ts b/src/serializable-class-registry.ts deleted file mode 100644 index b52a98a4..00000000 --- a/src/serializable-class-registry.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Registry } from './registry.js'; -import { Class } from './types.js'; - -export type SerializationMethodNames = { - serialize: string; - deserialize: string; -}; - -export interface RegisterSerializableOptions { - identifier?: string; - methodNames?: SerializationMethodNames; -} - -export const DEFAULT_SERIALIZE_METHOD_NAMES: SerializationMethodNames = { - serialize: 'toSuperJSON', - deserialize: 'fromSuperJSON', -}; - -export class SerializableClassRegistry extends Registry { - private classToMethods: Map = new Map(); - - constructor() { - super(c => c.name); - } - - register(value: Class, options?: RegisterSerializableOptions | string): void { - if (typeof options === 'object') { - if (options.methodNames) { - this.classToMethods.set(value, options.methodNames); - } - - super.register(value, options.identifier); - } else { - super.register(value, options); - } - } - - getMethodNames(value: Class): SerializationMethodNames | undefined { - return this.classToMethods.get(value); - } -} diff --git a/src/transformer.ts b/src/transformer.ts index 26a17ea5..a93bf022 100644 --- a/src/transformer.ts +++ b/src/transformer.ts @@ -16,10 +16,6 @@ import { } from './is.js'; import { findArr } from './util.js'; import SuperJSON from './index.js'; -import { - DEFAULT_SERIALIZE_METHOD_NAMES, - SerializationMethodNames, -} from './serializable-class-registry.js'; export type PrimitiveTypeAnnotation = 'number' | 'undefined' | 'bigint'; @@ -29,7 +25,6 @@ type TypedArrayAnnotation = ['typed-array', string]; type ClassTypeAnnotation = ['class', string]; type SymbolTypeAnnotation = ['symbol', string]; type CustomTypeAnnotation = ['custom', string]; -type SerializableClassTypeAnnotation = ['serializable-class', string]; type SimpleTypeAnnotation = LeafTypeAnnotation | 'map' | 'set' | 'Error'; @@ -37,8 +32,7 @@ type CompositeTypeAnnotation = | TypedArrayAnnotation | ClassTypeAnnotation | SymbolTypeAnnotation - | CustomTypeAnnotation - | SerializableClassTypeAnnotation; + | CustomTypeAnnotation; export type TypeAnnotation = SimpleTypeAnnotation | CompositeTypeAnnotation; @@ -322,69 +316,6 @@ const classRule = compositeTransformation( () => true ); -export function isInstanceOfSerializableClass( - potentialClass: any, - superJson: SuperJSON -): potentialClass is any { - if (potentialClass?.constructor) { - const isRegistered = !!superJson.serializableClassRegistry.getIdentifier( - potentialClass.constructor - ); - return isRegistered; - } - return false; -} - -function getMethodName( - clazz: any, - superJson: SuperJSON, - method: keyof SerializationMethodNames -) { - const ctor = method === 'serialize' ? clazz.constructor : clazz; - - const methodNames = - superJson.serializableClassRegistry.getMethodNames(ctor) ?? - DEFAULT_SERIALIZE_METHOD_NAMES; - - const name = methodNames[method]; - - if (typeof clazz[name] !== 'function') { - const id = superJson.serializableClassRegistry.getIdentifier(ctor); - throw new Error( - `Class ${id} has no ${method} method (must provide ${name})` - ); - } - - return name; -} - -const serializableClassRule = compositeTransformation( - isInstanceOfSerializableClass, - (clazz, superJson) => { - const identifier = superJson.serializableClassRegistry.getIdentifier( - clazz.constructor as any - ); - return ['serializable-class', identifier!]; - }, - (clazz, superJson) => { - const methodName = getMethodName(clazz, superJson, 'serialize'); - return clazz[methodName](); - }, - (v, a, superJson) => { - const clazz = superJson.serializableClassRegistry.getValue(a[1]); - - if (!clazz) { - throw new Error( - `Trying to deserialize unknown class '${a[1]}' - check https://github.com/blitz-js/superjson/issues/116#issuecomment-773996564` - ); - } - - const methodName = getMethodName(clazz, superJson, 'deserialize'); - return (clazz as any)[methodName](v); - }, - () => true -); - const customRule = compositeTransformation( (value, superJson): value is any => { return !!superJson.customTransformerRegistry.findApplicable(value); @@ -416,18 +347,7 @@ const customRule = compositeTransformation( } ); -// -------------- -// This array is order sensitive -// if same class is passed to both class registry and -// serializable class registry 'classRule' is applied -// -------------- -const compositeRules = [ - classRule, - serializableClassRule, - symbolRule, - customRule, - typedArrayRule, -]; +const compositeRules = [classRule, symbolRule, customRule, typedArrayRule]; export const transformValue = ( value: any, @@ -475,8 +395,6 @@ export const untransformValue = ( return symbolRule.untransform(json, type, superJson); case 'class': return classRule.untransform(json, type, superJson); - case 'serializable-class': - return serializableClassRule.untransform(json, type, superJson); case 'custom': return customRule.untransform(json, type, superJson); case 'typed-array': diff --git a/src/types.ts b/src/types.ts index c25bf745..238261f7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,4 @@ -import { TypeAnnotation } from './transformer.js'; -import { MinimisedTree, ReferentialEqualityAnnotations } from './plainer.js'; +import { MetaObject } from './plainer.js'; export type Class = { new (...args: any[]): any }; @@ -39,9 +38,5 @@ export interface SuperJSONObject { export interface SuperJSONResult { json: JSONValue; - meta?: { - values?: MinimisedTree; - referentialEqualities?: ReferentialEqualityAnnotations; - v?: number; - }; + meta?: MetaObject; } From 5496b12d9bb3173451d96d7c2e3b8793a266fd2e Mon Sep 17 00:00:00 2001 From: ZiadTaha62 Date: Tue, 24 Mar 2026 03:10:37 +0200 Subject: [PATCH 07/10] fix: added missing strOrigin in traverse --- src/plainer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plainer.ts b/src/plainer.ts index e8df464d..144d5b39 100644 --- a/src/plainer.ts +++ b/src/plainer.ts @@ -58,7 +58,7 @@ function traverse( const [nodeValue, children] = tree; if (children) { if (isArray(children)) { - traverse(children, walker, legacyPaths, origin); + traverse(children, walker, legacyPaths, origin, strOrigin); } else { traverseObject(children, walker, legacyPaths, origin, strOrigin); } From 47e947b05fc1fea831ccfc7213eb8c903165696f Mon Sep 17 00:00:00 2001 From: ZiadTaha62 Date: Thu, 26 Mar 2026 07:40:25 +0200 Subject: [PATCH 08/10] fix: referential equalities with tests to ensure correct behavior --- src/index.test.ts | 56 +---- src/index.ts | 4 +- src/pathstringifier.ts | 17 +- src/plainer.ts | 270 +++++++++++++-------- src/referentialEquality.test.ts | 402 ++++++++++++++++++++++++++++++++ src/transformer.ts | 2 +- 6 files changed, 590 insertions(+), 161 deletions(-) create mode 100644 src/referentialEquality.test.ts diff --git a/src/index.test.ts b/src/index.test.ts index c72ee9a9..79c1b970 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -853,7 +853,7 @@ describe('stringify & parse', () => { } if (meta) { const { v, ...rest } = meta; - expect(v).toBe(1); + expect(v).toBe(2); expect(rest).toEqual(expectedOutputAnnotations); } else { expect(meta).toEqual(expectedOutputAnnotations); @@ -910,7 +910,7 @@ describe('stringify & parse', () => { }); expect(meta).toEqual({ - v: 1, + v: 2, values: { s7: [ ['class', 'Train'], @@ -985,7 +985,7 @@ describe('stringify & parse', () => { a: '1000', }, meta: { - v: 1, + v: 2, values: { a: ['bigint'], }, @@ -1069,7 +1069,7 @@ test('regression #83: negative zero', () => { const stringified = SuperJSON.stringify(input); expect(stringified).toMatchInlineSnapshot( - `"{\\"json\\":\\"-0\\",\\"meta\\":{\\"values\\":[\\"number\\"],\\"v\\":1}}"` + `"{\\"json\\":\\"-0\\",\\"meta\\":{\\"values\\":[\\"number\\"],\\"v\\":2}}"` ); const parsed: number = SuperJSON.parse(stringified); @@ -1289,7 +1289,7 @@ test('regression #245: superjson referential equalities only use the top-most pa "b", ], }, - "v": 1, + "v": 2, } `); @@ -1417,49 +1417,3 @@ test('#310 fixes backwards compat', () => { }, }); }); - -test('recursive custom transformer does NOT preserve external referential equality (known limitation)', () => { - class Box { - constructor(public value: any) {} - } - - const shared = { when: new Date('2024-01-01T00:00:00.000Z') }; - const input = { - a: new Box(shared), - b: shared, - }; - - const serialized = SuperJSON.serialize(input); - const result: any = SuperJSON.deserialize(serialized); - - expect(result.a.value).toEqual(result.b); // Same value - expect(result.a.value).not.toBe(result.b); // Not same reference -}); - -test('dedupe=true with recursive custom transformer', () => { - class Box { - constructor(public value: any) {} - } - - SuperJSON.registerCustom( - { - isApplicable: (v): v is Box => v instanceof Box, - serialize: (v: Box) => v.value, - deserialize: (v: any) => new Box(v), - recursive: true, - }, - 'box-dedupe' - ); - - const shared = { date: new Date('2024-01-01T00:00:00.000Z') }; - const input = { - first: new Box(shared), - second: new Box(shared), - }; - - const instance = new SuperJSON({ dedupe: true }); - const serialized = instance.serialize(input); - const result: any = instance.deserialize(serialized); - - expect(result.first.value).toBe(result.second.value); -}); diff --git a/src/index.ts b/src/index.ts index 7523b4a5..1fab4d86 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,7 +32,7 @@ export default class SuperJSON { } serialize(object: SuperJSONValue): SuperJSONResult { - const identities = new Map(); + const identities = new Map(); const output = walker(object, identities, this, this.dedupe); const res: SuperJSONResult = { json: output.transformedValue, @@ -57,7 +57,7 @@ export default class SuperJSON { }; } - if (res.meta) res.meta.v = 1; + if (res.meta) res.meta.v = 2; return res; } diff --git a/src/pathstringifier.ts b/src/pathstringifier.ts index efbf867d..370d9a51 100644 --- a/src/pathstringifier.ts +++ b/src/pathstringifier.ts @@ -1,8 +1,11 @@ export type StringifiedPath = string; type Path = string[]; -export const escapeKey = (key: string) => - key.replace(/\\/g, '\\\\').replace(/\./g, '\\.'); +export const escapeKey = (key: string) => { + key = key.replace(/\\/g, '\\\\'); + if (key[0] === '$') key = '\\' + key; + return key.replace(/\./g, '\\.'); +}; export const stringifyPath = (path: Path): StringifiedPath => path @@ -10,7 +13,11 @@ export const stringifyPath = (path: Path): StringifiedPath => .map(escapeKey) .join('.'); -export const parsePath = (string: StringifiedPath, legacyPaths: boolean) => { +export const parsePath = ( + string: StringifiedPath, + legacyPaths: boolean, + depthSegment: boolean +) => { const result: string[] = []; let segment = ''; @@ -23,7 +30,7 @@ export const parsePath = (string: StringifiedPath, legacyPaths: boolean) => { segment += '\\'; i++; continue; - } else if (escaped !== '.') { + } else if (escaped !== '.' && escaped !== '$') { throw Error('invalid path'); } } @@ -46,7 +53,7 @@ export const parsePath = (string: StringifiedPath, legacyPaths: boolean) => { } const lastSegment = segment; - result.push(lastSegment); + if (!depthSegment || lastSegment[0] !== '$') result.push(lastSegment); return result; }; diff --git a/src/plainer.ts b/src/plainer.ts index 144d5b39..fbee2f07 100644 --- a/src/plainer.ts +++ b/src/plainer.ts @@ -20,91 +20,151 @@ export type MinimisedTree = | undefined; const enableLegacyPaths = (version: number) => version < 1; +const enableDepthSegment = (version: number) => version > 1; -function traverseObject( - tree: { [k: string]: MinimisedTree }, - walker: (type: any, path: string[], strPath?: string) => void, - legacyPaths: boolean, - origin: string[], - strOrigin: string | undefined = undefined -) { - forEach(tree, (subtree, key) => { - traverse( - subtree, - walker, - legacyPaths, - [...origin, ...parsePath(key, legacyPaths)], - strOrigin !== undefined ? strOrigin + '.' + key : key - ); - }); +function stringifyPathWithDepth(value: [any[], number]) { + const path = value[0]; + const depth = value[1]; + return stringifyPath(path) + (depth ? '.$' + depth : ''); } function traverse( tree: MinimisedTree, - walker: (type: any, path: string[], strPath?: string) => void, + walker: ( + type: any, + path: string[], + equalityGroup: ReferentialEqualityGroup | undefined + ) => void, + equalityGroups: Map, legacyPaths: boolean, + depthSegment: boolean, origin: string[] = [], - strOrigin: string | undefined = undefined + samePathDepth: number = 0 ): void { if (!tree) { return; } + const equalityGroup = equalityGroups.get( + stringifyPathWithDepth([origin, samePathDepth]) + ); + + if (equalityGroup) { + if (equalityGroup.resolved) return; + equalityGroup.resolved = true; + } + if (!isArray(tree)) { - traverseObject(tree, walker, legacyPaths, origin, strOrigin); + forEach(tree, (subtree, key) => { + const parsedKey = parsePath(key, legacyPaths, depthSegment); + + const childPath = [...origin]; + for (let i = 0; i < parsedKey.length - 1; i++) { + childPath.push(parsedKey[i]); + + const equalityGroup = equalityGroups.get( + stringifyPathWithDepth([childPath, 0]) + ); + + if (equalityGroup) { + if (equalityGroup.resolved) return; + equalityGroup.resolved = true; + } + } + + childPath.push(parsedKey[parsedKey.length - 1]); + + traverse( + subtree, + walker, + equalityGroups, + legacyPaths, + depthSegment, + childPath, + 0 + ); + }); return; } const [nodeValue, children] = tree; if (children) { - if (isArray(children)) { - traverse(children, walker, legacyPaths, origin, strOrigin); - } else { - traverseObject(children, walker, legacyPaths, origin, strOrigin); - } + traverse( + children, + walker, + equalityGroups, + legacyPaths, + depthSegment, + origin, + samePathDepth + 1 + ); } - walker(nodeValue, origin, strOrigin); + walker(nodeValue, origin, equalityGroup); } -/** - * Function to parse referential equalities object into: - * - root: Array of root referential equalities to loop and apply them at the end - * - other: Map of 'representative paths' -> 'parsed duplicate paths' - * - duplicate: Set of all duplicate (non-representative) paths to skip them in walker - * when applying value annotations - */ +type ReferentialEqualityGroup = { + representative: string[]; + duplicates: string[][]; + resolved: boolean; +}; + function parseReferentialEqualities( referentialEqualities: ReferentialEqualityAnnotations | undefined, - legacyPaths: boolean + legacyPaths: boolean, + depthSegment: boolean ) { - // Extract root and other from referentialEqualities - let root: string[] | undefined; - let other: Record | undefined; + let rootEqualityPaths: string[] | undefined; + let nonRootGroups: Record | undefined; if (isArray(referentialEqualities)) { - const [r, o] = referentialEqualities; - root = r; - other = o; + const [root, nonRoot] = referentialEqualities; + rootEqualityPaths = root; + nonRootGroups = nonRoot; } else { - other = referentialEqualities; + nonRootGroups = referentialEqualities; } - const rootArray = root?.map(p => parsePath(p, legacyPaths)) ?? []; - - const otherMap = new Map(); - const duplicateSet = new Set(); - if (other) { - for (const [rep, iden] of Object.entries(other)) { - otherMap.set( - rep, - iden.map(p => parsePath(p, legacyPaths)) + const parsedRootPaths = + rootEqualityPaths?.map(path => + parsePath(path, legacyPaths, depthSegment) + ) ?? []; + + const equalityGroupsByPath = new Map(); + const representativePaths = new Set(); + + if (nonRootGroups) { + for (const [representativePath, duplicateOriginalPaths] of Object.entries( + nonRootGroups + )) { + const parsedRepresentative = parsePath( + representativePath, + legacyPaths, + depthSegment ); - for (const p of iden) duplicateSet.add(p); + + const group: ReferentialEqualityGroup = { + representative: parsedRepresentative, + duplicates: [parsedRepresentative], + resolved: false, + }; + + equalityGroupsByPath.set(representativePath, group); + representativePaths.add(representativePath); + + for (const duplicateOriginalPath of duplicateOriginalPaths) { + group.duplicates.push( + parsePath(duplicateOriginalPath, legacyPaths, depthSegment) + ); + equalityGroupsByPath.set(duplicateOriginalPath, group); + } } } - // Return root array and other map - return { root: rootArray, other: otherMap, duplicate: duplicateSet }; + return { + rootEqualities: parsedRootPaths, + equalityGroups: equalityGroupsByPath, + representatives: representativePaths, + }; } export type MetaObject = { @@ -118,10 +178,7 @@ export type MetaObject = { * * Behavior: * - 1. Apply all non-root referential equalities first so recursive value annotations get proper input even with dedupe=true - * - 2. Apply value annotations, while also check referential equality: - * - A. If node is duplicate skip the annotation - * - B. If node is representative update all duplicate nodes - * - C. If not referentially equal to any other node apply annotation normally + * - 2. Apply value annotations, while also updating referential equality nodes * - 3. Apply root referential equalities * * @returns Modified JSON after applying value and referential equalities annotations @@ -134,11 +191,17 @@ export function applyMeta( // Handle version const version = meta.v ?? 0; const legacyPaths = enableLegacyPaths(version); + const depthSegment = enableDepthSegment(version); // Parse referenial equality object (parsed once for performance) - const { root, other, duplicate } = parseReferentialEqualities( + const { + rootEqualities, + equalityGroups, + representatives, + } = parseReferentialEqualities( meta.referentialEqualities, - legacyPaths + legacyPaths, + depthSegment ); // Function to set referential equality @@ -154,27 +217,35 @@ export function applyMeta( const setValueAnnotationsFn = ( type: any, path: string[], - strPath?: string + equalityGroup: ReferentialEqualityGroup | undefined ) => { - // Skip on duplicate paths (Will be updated by representative path) - if (strPath && duplicate.has(strPath)) return; - // Update json json = setDeep(json, path, v => untransformValue(v, type, superJson)); - // If node is representative update all duplicate paths - const refPaths = strPath && other.get(strPath); - if (refPaths) setReferentialEqualityFn(path, refPaths); + + // Set other referential equalities if present + if (!equalityGroup) return; + setReferentialEqualityFn(path, [...equalityGroup.duplicates]); }; // Apply other referential equality - for (const [rep, iden] of other) { - setReferentialEqualityFn(parsePath(rep, legacyPaths), iden); + for (const path of representatives) { + const equalityGroup = equalityGroups.get(path)!; + setReferentialEqualityFn( + equalityGroup.representative, + equalityGroup.duplicates + ); } // Apply value annotations and in-place referential equality if node is updated - traverse(meta.values, setValueAnnotationsFn, legacyPaths); + traverse( + meta.values, + setValueAnnotationsFn, + equalityGroups, + legacyPaths, + depthSegment + ); // Apply root referential equalities - for (const p of root) { + for (const p of rootEqualities) { json = setDeep(json, p, () => json); } @@ -182,13 +253,18 @@ export function applyMeta( return json; } -function addIdentity(object: any, path: any[], identities: Map) { +function addIdentity( + object: any, + path: any[], + samePathDepth: number, + identities: Map +) { const existingSet = identities.get(object); if (existingSet) { - existingSet.push(path); + existingSet.push([path, samePathDepth]); } else { - identities.set(object, [path]); + identities.set(object, [[path, samePathDepth]]); } } @@ -203,13 +279,13 @@ export type ReferentialEqualityAnnotations = | [string[], Record]; export function generateReferentialEqualityAnnotations( - identitites: Map, + identitities: Map, dedupe: boolean ): ReferentialEqualityAnnotations | undefined { const result: Record = {}; let rootEqualityPaths: string[] | undefined = undefined; - identitites.forEach(paths => { + identitities.forEach(paths => { if (paths.length <= 1) { return; } @@ -218,18 +294,19 @@ export function generateReferentialEqualityAnnotations( // putting the shortest path first makes it easier to parse for humans // if we're deduping though, only the first entry will still exist, so we can't do this optimisation. if (!dedupe) { - paths = paths - .map(path => path.map(String)) - .sort((a, b) => a.length - b.length); + paths = paths.sort((a, b) => { + if (a[1] !== b[1]) return a[1] - b[1]; // prefer depth 0 + return a[0].length - b[0].length; + }); } const [representativePath, ...identicalPaths] = paths; - if (representativePath.length === 0) { - rootEqualityPaths = identicalPaths.map(stringifyPath); + if (representativePath[0].length === 0) { + rootEqualityPaths = identicalPaths.map(stringifyPathWithDepth); } else { - result[stringifyPath(representativePath)] = identicalPaths.map( - stringifyPath + result[stringifyPathWithDepth(representativePath)] = identicalPaths.map( + stringifyPathWithDepth ); } }); @@ -248,31 +325,20 @@ export function generateReferentialEqualityAnnotations( const isPlainObjectOrArray = (object: any) => isPlainObject(object) || isArray(object); -/** - * Walker to serialize input. It supports recursive custom transformations so 'walker' also applied to return - * of transformations if needed. - * - * Known limitation: - * - Return of recursive custom will always be considered new object even if defined in the tree else where (referential - * equality is blocked on the same path) so it will be linked in referential equality nor be deduped, This limitation is - * intentional to avoid making code too complex where we need to store and compare depths if multple objects are defined - * at the same path during transformation. - */ export const walker = ( object: any, - identities: Map, + identities: Map, superJson: SuperJSON, dedupe: boolean, path: string[] = [], objectsInThisPath: any[] = [], seenObjects = new Map(), - isSamePath: boolean = false + samePathDepth: number = 0 ): Result => { const primitive = isPrimitive(object); - const registerSeen = !primitive && !isSamePath; - if (registerSeen) { - addIdentity(object, path, identities); + if (!primitive) { + addIdentity(object, path, samePathDepth, identities); const seen = seenObjects.get(object); if (seen) { @@ -305,7 +371,7 @@ export const walker = ( transformedValue: value, annotations: [type], }; - if (registerSeen) seenObjects.set(object, result); + if (!primitive) seenObjects.set(object, result); return result; } @@ -319,7 +385,7 @@ export const walker = ( path, objectsInThisPath, seenObjects, - true + samePathDepth + 1 ); objectsInThisPath.pop(); @@ -333,7 +399,7 @@ export const walker = ( annotations: [type], }; - if (registerSeen) seenObjects.set(object, result); + if (!primitive) seenObjects.set(object, result); return result; } @@ -362,7 +428,7 @@ export const walker = ( [...path, index], objectsInThisPath, seenObjects, - false + 0 ); objectsInThisPath.pop(); @@ -386,7 +452,7 @@ export const walker = ( annotations: innerAnnotations, }; - if (registerSeen) seenObjects.set(object, result); + if (!primitive) seenObjects.set(object, result); return result; } @@ -394,6 +460,6 @@ export const walker = ( const result = { transformedValue: object, }; - if (registerSeen) seenObjects.set(object, result); + if (!primitive) seenObjects.set(object, result); return result; }; diff --git a/src/referentialEquality.test.ts b/src/referentialEquality.test.ts new file mode 100644 index 00000000..d3e2aa4f --- /dev/null +++ b/src/referentialEquality.test.ts @@ -0,0 +1,402 @@ +import SuperJSON from './index.js'; +import { RecursiveCustomTransfomer } from './custom-transformer-registry.js'; + +import { expect, test, describe } from 'vitest'; + +/** ---------------------------- + * Tests + * ---------------------------- */ + +describe('Referential equality tests', () => { + test('basic referential equalities', () => { + const shared = { key: 'value' }; + const input = { a: shared, b: shared, c: { ...shared, d: shared } }; + equalityTest(input); + }); + + test('preserves external (input) recursive referential equality', () => { + class Box { + constructor(public value: any) {} + } + + registerCustom( + { + isApplicable: (v): v is Box => v instanceof Box, + serialize: v => v.value, + deserialize: v => new Box(v), + recursive: true, + }, + 'Box' + ); + + const shared = { when: new Date('2024-01-01T00:00:00.000Z') }; + const input = { + a: new Box(shared), + b: shared, + }; + equalityTest(input); + }); + + test('shared object with recursive transformation inside', () => { + class Horse { + constructor() {} + } + + registerCustom( + { + isApplicable: (v): v is Horse => v instanceof Horse, + serialize: (v: Horse) => 'Horse', + deserialize: (v: string) => new Horse(), + recursive: true, + }, + 'Horse' + ); + + const shared = { nest: { horse: new Horse() } }; + const input = { + first: shared, + second: shared, + }; + equalityTest(input); + }); + + test('custom transformation returns same object', () => { + class Animal { + constructor(public value: any) {} + } + class Cow { + constructor(public value: any) {} + } + + registerCustom( + { + isApplicable: (v): v is Animal => v instanceof Animal, + serialize: v => v.value, + deserialize: v => new Animal(v), + recursive: true, + }, + 'Animal' + ); + registerCustom( + { + isApplicable: (v): v is Cow => v instanceof Cow, + serialize: v => v.value, + deserialize: v => new Cow(v), + recursive: true, + }, + 'Cow' + ); + + const shared = { when: new Date() }; + const input = new Animal(new Cow(shared)); + equalityTest(input); + }); + + test('object inside custom transformation', () => { + class Boxer { + constructor(public value: any) {} + } + class Wrestler { + constructor(public value: any) {} + } + + registerCustom( + { + isApplicable: (v): v is Boxer => v instanceof Boxer, + serialize: v => v.value, + deserialize: v => new Boxer(v), + recursive: true, + }, + 'Boxer' + ); + registerCustom( + { + isApplicable: (v): v is Wrestler => v instanceof Wrestler, + serialize: v => v.value, + deserialize: v => new Wrestler(v), + recursive: true, + }, + 'Wrestler' + ); + + const boxer = { boxer: new Boxer({ when: new Date() }) }; + const input = new Wrestler(boxer); + equalityTest(input); + }); + + test('root shared reference', () => { + const input: any = { when: new Date() }; + input.ref = input; + equalityTest(input); + }); + + test('shared references inside arrays', () => { + const shared = { key: 'value' }; + const input = { + arr: [shared, shared, { nested: shared }], + arr2: [1, shared, shared], + }; + equalityTest(input); + }); + + test('circular reference NOT at root', () => { + const input: any = { a: { b: { c: {} } } }; + input.a.b.c.back = input.a; // cycle deeper in the tree + input.a.b.d = input.a.b; // another cycle + equalityTest(input); + }); + + test('shared built-in objects (Date, RegExp, Error)', () => { + const sharedDate = new Date('2024-01-01T00:00:00.000Z'); + const sharedRegExp = /test/gi; + const sharedError = new Error('shared error'); + const input = { + dates: [sharedDate, sharedDate], + regexps: { a: sharedRegExp, b: sharedRegExp }, + errors: { e1: sharedError, e2: sharedError, nested: { e3: sharedError } }, + }; + equalityTest(input); + }); + + test('shared recursive custom transformation', () => { + class MyClass { + constructor(public data: any) {} + } + + registerCustom( + { + isApplicable: (v): v is MyClass => v instanceof MyClass, + serialize: v => ({ data: v.data }), + deserialize: v => new MyClass(v.data), + recursive: true, + }, + 'MyClass' + ); + + const sharedData = { foo: 'bar' }; + const sharedInstance = new MyClass(sharedData); + const input = { + d: sharedData, + x: sharedInstance, + y: sharedInstance, + nested: { z: sharedInstance }, + }; + equalityTest(input); + }); + + test('multiple independent shared groups', () => { + const groupA = { id: 'A' }; + const groupB = { id: 'B' }; + const groupC = { id: 'C' }; + const input = { + a1: groupA, + a2: groupA, + b1: groupB, + b2: groupB, + c: groupC, + nested: { + a3: groupA, + b3: groupB, + c2: groupC, + }, + }; + equalityTest(input); + }); + + test('shared values inside Maps and Sets', () => { + const shared = { key: 'shared-value' }; + const input = { + set1: new Set([shared, { other: 1 }]), + set2: new Set([shared, { other: 2 }]), + map1: new Map([['k1', shared]]), + map2: new Map([['k2', shared]]), + mixed: { + set3: new Set([shared]), + map3: new Map([['k3', shared]]), + }, + }; + equalityTest(input); + }); + + test('shared object used as Map key', () => { + const sharedKey = { id: 42 }; + const input = { + map1: new Map([[sharedKey, 'value1']]), + map2: new Map([[sharedKey, 'value2']]), + nested: new Map([['inner', sharedKey]]), + }; + equalityTest(input); + }); + + test('All of the above', () => { + class A { + constructor(public value: any) {} + } + class B { + constructor(public value: any) {} + } + class C { + constructor(public value: any) {} + } + + registerCustom( + { + isApplicable: (v): v is A => v instanceof A, + serialize: v => v.value, + deserialize: v => new A(v), + recursive: true, + }, + 'A' + ); + registerCustom( + { + isApplicable: (v): v is B => v instanceof B, + serialize: v => v.value, + deserialize: v => new B(v), + recursive: true, + }, + 'B' + ); + registerCustom( + { + isApplicable: (v): v is C => v instanceof C, + serialize: v => v.value, + deserialize: v => new C(v), + recursive: true, + }, + 'C' + ); + + const sharedDate = new Date(); + const sharedObject = { when: sharedDate }; + const sharedClass = { a: new A(sharedObject) }; + const input: any = { + obj1: { ...sharedObject, b: { c: {} } }, + obj2: sharedObject, + date: sharedDate, + cls: sharedClass, + value: new C(new B(sharedClass)), + set: new Set([sharedClass]), + map: new Map([[sharedDate, sharedObject]]), + }; + input.ref = input; + input.obj1.b.c.back = input.obj; + equalityTest(input); + }); +}); + +/** ---------------------------- + * Helpers + * ---------------------------- */ + +type Path = string[]; + +function collectReferencePairs(obj: any): [Path, Path][] { + if (obj == null || typeof obj !== 'object') { + return []; + } + + const referenceMap = new Map(); + const recursionStack = new Set(); + + referenceMap.set(obj, [[]]); + + traverse(obj, [], referenceMap, recursionStack); + + const pairs: [Path, Path][] = []; + for (const paths of referenceMap.values()) { + if (paths.length >= 2) { + for (let i = 0; i < paths.length; i++) { + for (let j = i + 1; j < paths.length; j++) { + pairs.push([paths[i], paths[j]]); + } + } + } + } + + return pairs; +} + +function traverse( + currentObj: any, + currentPath: Path, + referenceMap: Map, + recursionStack: Set +): void { + const entries = Object.entries(parseMapAndSet(currentObj)); + + for (const [key, value] of entries) { + const path: Path = [...currentPath, key]; + + if (value !== null && typeof value === 'object') { + if (!referenceMap.has(value)) { + referenceMap.set(value, []); + } + referenceMap.get(value)!.push(path); + + if (!recursionStack.has(value)) { + recursionStack.add(value); + traverse(value, path, referenceMap, recursionStack); + recursionStack.delete(value); + } + } + } +} + +function getValueAtPath(obj: any, path: Path): any { + let current = obj; + for (const key of path) { + if (current == null || typeof current !== 'object') { + throw new Error('Out of bounds error'); + } + current = parseMapAndSet(current); + current = current[key]; + } + return current; +} + +function parseMapAndSet(object: any) { + if (object instanceof Map || object instanceof Set) return [...object]; + return object; +} + +function expectSameReferenceGraph(a: any, b: any) { + const pairs = collectReferencePairs(a); + + for (const [path1, path2] of pairs) { + const val1 = getValueAtPath(b, path1); + const val2 = getValueAtPath(b, path2); + expect(val1).toBe(val2); + } +} + +function createEqualityTests() { + const dedupeTrue = new SuperJSON({ dedupe: true }); + const dedupeFalse = new SuperJSON({ dedupe: false }); + + function registerCustom( + transformer: Omit, 'name'>, + name: string + ) { + dedupeTrue.registerCustom(transformer, name); + dedupeFalse.registerCustom(transformer, name); + } + + function equalityTest(input: any) { + const dedupeTrueResult = dedupeTrue.deserialize( + dedupeTrue.serialize(input) + ); + const dedupeFalseResult = dedupeFalse.deserialize( + dedupeFalse.serialize(input) + ); + + expect(dedupeTrueResult).toEqual(input); + expect(dedupeFalseResult).toEqual(input); + expectSameReferenceGraph(dedupeTrueResult, input); + expectSameReferenceGraph(dedupeFalseResult, input); + } + + return { registerCustom, equalityTest }; +} + +const { registerCustom, equalityTest } = createEqualityTests(); diff --git a/src/transformer.ts b/src/transformer.ts index a93bf022..b610b8ec 100644 --- a/src/transformer.ts +++ b/src/transformer.ts @@ -187,7 +187,7 @@ function compositeTransformation( annotation: (v: I, superJson: SuperJSON) => A, transform: (v: I, superJson: SuperJSON) => O, untransform: (v: O, a: A, superJson: SuperJSON) => I, - isDeep: (v: O, superJson: SuperJSON) => boolean + isDeep: (v: I, superJson: SuperJSON) => boolean ) { return { isApplicable, From b2dfbe0557776dd962b484fdacd6aa46f03520d3 Mon Sep 17 00:00:00 2001 From: ZiadTaha62 Date: Thu, 26 Mar 2026 09:39:30 +0200 Subject: [PATCH 09/10] fix: Intermediate-path equality resolution drops child annotations --- src/plainer.ts | 27 +++++++++++++++++++++++---- src/referentialEquality.test.ts | 17 +++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/plainer.ts b/src/plainer.ts index fbee2f07..f1dc1041 100644 --- a/src/plainer.ts +++ b/src/plainer.ts @@ -55,23 +55,41 @@ function traverse( } if (!isArray(tree)) { + // Map to store each path and resolved state when looping forEach + const seenPaths = new Map(); + // Loop tree forEach(tree, (subtree, key) => { + // parse key const parsedKey = parsePath(key, legacyPaths, depthSegment); + // initiate child path const childPath = [...origin]; + + // As keys can be 'key1.key2.key3' loop parsed key and ensure to path is duplicate and so should be skipped + // We skip last key as it will be handled separetly in next traverse function for (let i = 0; i < parsedKey.length - 1; i++) { + // Update child path and stringify it childPath.push(parsedKey[i]); + const path = stringifyPathWithDepth([childPath, 0]); - const equalityGroup = equalityGroups.get( - stringifyPathWithDepth([childPath, 0]) - ); + // check if path already seen, if yes handle according to already stored value + let resolved; + if ((resolved = seenPaths.get(path)) !== undefined) { + if (resolved) return; + continue; + } + // check if path is in equality group, if yes handle it and stored resolved state in seen paths + const equalityGroup = equalityGroups.get(path); if (equalityGroup) { - if (equalityGroup.resolved) return; + const resolved = equalityGroup.resolved; equalityGroup.resolved = true; + seenPaths.set(path, resolved); + if (resolved) return; } } + // Add last key to the child path childPath.push(parsedKey[parsedKey.length - 1]); traverse( @@ -84,6 +102,7 @@ function traverse( 0 ); }); + return; } diff --git a/src/referentialEquality.test.ts b/src/referentialEquality.test.ts index d3e2aa4f..e3693295 100644 --- a/src/referentialEquality.test.ts +++ b/src/referentialEquality.test.ts @@ -124,6 +124,23 @@ describe('Referential equality tests', () => { equalityTest(input); }); + test('Intermediate-path equality resolution does not drop child annotations', () => { + const sharedDate = new Date('2024-01-01'); + const shared = { + date1: sharedDate, + date2: new Date('2025-01-01'), + date3: sharedDate, + regexp: /test/gi, + }; + + const input = { + a: shared, + b: shared, + }; + + equalityTest(input); + }); + test('root shared reference', () => { const input: any = { when: new Date() }; input.ref = input; From 1936b37cefdddcf95bede4ef70fd033b589d84e6 Mon Sep 17 00:00:00 2001 From: ZiadTaha62 Date: Fri, 27 Mar 2026 20:56:56 +0200 Subject: [PATCH 10/10] fix: handle '$' in parse function --- src/index.test.ts | 23 ++++ src/pathstringifier.ts | 6 +- src/plainer.ts | 237 +++++++++++++++++------------------------ 3 files changed, 126 insertions(+), 140 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 79c1b970..c7870939 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1417,3 +1417,26 @@ test('#310 fixes backwards compat', () => { }, }); }); + +test('Handle Doller at start of string correctly', () => { + class Van { + constructor(public value: any) {} + } + + SuperJSON.registerCustom( + { + isApplicable: v => v instanceof Van, + serialize: inst => inst.value, + deserialize: v => new Van(v), + recursive: true, + }, + 'Van' + ); + + const input = { + $key: 'value', + nested: { $nestedKey: new Van({ $van: 'van' }) }, + }; + + expect(SuperJSON.deserialize(SuperJSON.serialize(input))).toEqual(input); +}); diff --git a/src/pathstringifier.ts b/src/pathstringifier.ts index 370d9a51..03828d9b 100644 --- a/src/pathstringifier.ts +++ b/src/pathstringifier.ts @@ -52,8 +52,10 @@ export const parsePath = ( segment += char; } - const lastSegment = segment; - if (!depthSegment || lastSegment[0] !== '$') result.push(lastSegment); + let lastSegment = segment; + if (!depthSegment || lastSegment[0] !== '$') { + result.push(segment.slice(0, 2) === '\\$' ? segment.slice(1) : segment); + } return result; }; diff --git a/src/plainer.ts b/src/plainer.ts index f1dc1041..0196b9a2 100644 --- a/src/plainer.ts +++ b/src/plainer.ts @@ -22,10 +22,9 @@ export type MinimisedTree = const enableLegacyPaths = (version: number) => version < 1; const enableDepthSegment = (version: number) => version > 1; -function stringifyPathWithDepth(value: [any[], number]) { - const path = value[0]; - const depth = value[1]; - return stringifyPath(path) + (depth ? '.$' + depth : ''); +function stringifyPathWithDepth(path: string[], depth: number) { + if (!depth) return stringifyPath(path); + return stringifyPath(path) + '.$' + depth; } function traverse( @@ -35,9 +34,8 @@ function traverse( path: string[], equalityGroup: ReferentialEqualityGroup | undefined ) => void, - equalityGroups: Map, - legacyPaths: boolean, - depthSegment: boolean, + equalityGroupsByPath: Map, + version: number, origin: string[] = [], samePathDepth: number = 0 ): void { @@ -45,8 +43,8 @@ function traverse( return; } - const equalityGroup = equalityGroups.get( - stringifyPathWithDepth([origin, samePathDepth]) + const equalityGroup = equalityGroupsByPath.get( + stringifyPathWithDepth(origin, samePathDepth) ); if (equalityGroup) { @@ -56,51 +54,40 @@ function traverse( if (!isArray(tree)) { // Map to store each path and resolved state when looping forEach - const seenPaths = new Map(); - // Loop tree + const seenPathsResolvedState = new Map(); + forEach(tree, (subtree, key) => { - // parse key - const parsedKey = parsePath(key, legacyPaths, depthSegment); + const parsedKey = parsePath( + key, + enableLegacyPaths(version), + enableDepthSegment(version) + ); - // initiate child path const childPath = [...origin]; - // As keys can be 'key1.key2.key3' loop parsed key and ensure to path is duplicate and so should be skipped - // We skip last key as it will be handled separetly in next traverse function for (let i = 0; i < parsedKey.length - 1; i++) { - // Update child path and stringify it childPath.push(parsedKey[i]); - const path = stringifyPathWithDepth([childPath, 0]); + const path = stringifyPathWithDepth(childPath, 0); - // check if path already seen, if yes handle according to already stored value let resolved; - if ((resolved = seenPaths.get(path)) !== undefined) { + + if ((resolved = seenPathsResolvedState.get(path)) !== undefined) { if (resolved) return; continue; } - // check if path is in equality group, if yes handle it and stored resolved state in seen paths - const equalityGroup = equalityGroups.get(path); + const equalityGroup = equalityGroupsByPath.get(path); if (equalityGroup) { - const resolved = equalityGroup.resolved; + resolved = equalityGroup.resolved; equalityGroup.resolved = true; - seenPaths.set(path, resolved); + seenPathsResolvedState.set(path, resolved); if (resolved) return; } } - // Add last key to the child path childPath.push(parsedKey[parsedKey.length - 1]); - traverse( - subtree, - walker, - equalityGroups, - legacyPaths, - depthSegment, - childPath, - 0 - ); + traverse(subtree, walker, equalityGroupsByPath, version, childPath, 0); }); return; @@ -111,9 +98,8 @@ function traverse( traverse( children, walker, - equalityGroups, - legacyPaths, - depthSegment, + equalityGroupsByPath, + version, origin, samePathDepth + 1 ); @@ -122,6 +108,68 @@ function traverse( walker(nodeValue, origin, equalityGroup); } +export type MetaObject = { + values?: MinimisedTree; + referentialEqualities?: ReferentialEqualityAnnotations; + v?: number; +}; + +export function applyMeta( + json: any, + meta: MetaObject, + superJson: SuperJSON +): any { + // + // Parsing and function declarations + // + const version = meta.v ?? 0; + + const { + rootEqualities, + equalityGroupsByPath, + representativePaths, + } = parseReferentialEqualities(meta.referentialEqualities, version); + + const setReferentialEquality = ( + representativePath: string[], + identicalPaths: string[][] + ) => { + const object = getDeep(json, representativePath); + for (const p of identicalPaths) json = setDeep(json, p, () => object); + }; + + const setValueAnnotations = ( + type: any, + path: string[], + equalityGroup: ReferentialEqualityGroup | undefined + ) => { + json = setDeep(json, path, v => untransformValue(v, type, superJson)); + if (equalityGroup) { + setReferentialEquality(path, [...equalityGroup.duplicates]); + } + }; + + // + // Applying referential equalities and value annotations + // + + for (const path of representativePaths) { + const equalityGroup = equalityGroupsByPath.get(path)!; + setReferentialEquality( + equalityGroup.representative, + equalityGroup.duplicates + ); + } + + traverse(meta.values, setValueAnnotations, equalityGroupsByPath, version); + + for (const p of rootEqualities) { + json = setDeep(json, p, () => json); + } + + return json; +} + type ReferentialEqualityGroup = { representative: string[]; duplicates: string[][]; @@ -130,9 +178,11 @@ type ReferentialEqualityGroup = { function parseReferentialEqualities( referentialEqualities: ReferentialEqualityAnnotations | undefined, - legacyPaths: boolean, - depthSegment: boolean + version: number ) { + const legacyPaths = enableLegacyPaths(version); + const depthSegment = enableDepthSegment(version); + let rootEqualityPaths: string[] | undefined; let nonRootGroups: Record | undefined; if (isArray(referentialEqualities)) { @@ -143,7 +193,7 @@ function parseReferentialEqualities( nonRootGroups = referentialEqualities; } - const parsedRootPaths = + const rootEqualities = rootEqualityPaths?.map(path => parsePath(path, legacyPaths, depthSegment) ) ?? []; @@ -179,97 +229,7 @@ function parseReferentialEqualities( } } - return { - rootEqualities: parsedRootPaths, - equalityGroups: equalityGroupsByPath, - representatives: representativePaths, - }; -} - -export type MetaObject = { - values?: MinimisedTree; - referentialEqualities?: ReferentialEqualityAnnotations; - v?: number; -}; - -/** - * This function apply meta object (value and referential equalities annotations) to JSON input. - * - * Behavior: - * - 1. Apply all non-root referential equalities first so recursive value annotations get proper input even with dedupe=true - * - 2. Apply value annotations, while also updating referential equality nodes - * - 3. Apply root referential equalities - * - * @returns Modified JSON after applying value and referential equalities annotations - */ -export function applyMeta( - json: any, - meta: MetaObject, - superJson: SuperJSON -): any { - // Handle version - const version = meta.v ?? 0; - const legacyPaths = enableLegacyPaths(version); - const depthSegment = enableDepthSegment(version); - - // Parse referenial equality object (parsed once for performance) - const { - rootEqualities, - equalityGroups, - representatives, - } = parseReferentialEqualities( - meta.referentialEqualities, - legacyPaths, - depthSegment - ); - - // Function to set referential equality - const setReferentialEqualityFn = ( - representativePath: string[], - identicalPaths: string[][] - ) => { - const object = getDeep(json, representativePath); - for (const p of identicalPaths) json = setDeep(json, p, () => object); - }; - - // Function to set value annotation, It also update referential equality if needed - const setValueAnnotationsFn = ( - type: any, - path: string[], - equalityGroup: ReferentialEqualityGroup | undefined - ) => { - json = setDeep(json, path, v => untransformValue(v, type, superJson)); - - // Set other referential equalities if present - if (!equalityGroup) return; - setReferentialEqualityFn(path, [...equalityGroup.duplicates]); - }; - - // Apply other referential equality - for (const path of representatives) { - const equalityGroup = equalityGroups.get(path)!; - setReferentialEqualityFn( - equalityGroup.representative, - equalityGroup.duplicates - ); - } - - // Apply value annotations and in-place referential equality if node is updated - traverse( - meta.values, - setValueAnnotationsFn, - equalityGroups, - legacyPaths, - depthSegment - ); - - // Apply root referential equalities - for (const p of rootEqualities) { - json = setDeep(json, p, () => json); - } - - // return modified json - return json; + return { rootEqualities, equalityGroupsByPath, representativePaths }; } function addIdentity( @@ -313,19 +273,20 @@ export function generateReferentialEqualityAnnotations( // putting the shortest path first makes it easier to parse for humans // if we're deduping though, only the first entry will still exist, so we can't do this optimisation. if (!dedupe) { - paths = paths.sort((a, b) => { - if (a[1] !== b[1]) return a[1] - b[1]; // prefer depth 0 - return a[0].length - b[0].length; - }); + paths = paths.sort((a, b) => a[1] - b[1] || a[0].length - b[0].length); } const [representativePath, ...identicalPaths] = paths; if (representativePath[0].length === 0) { - rootEqualityPaths = identicalPaths.map(stringifyPathWithDepth); + rootEqualityPaths = identicalPaths.map(([path, depth]) => + stringifyPathWithDepth(path, depth) + ); } else { - result[stringifyPathWithDepth(representativePath)] = identicalPaths.map( - stringifyPathWithDepth + result[ + stringifyPathWithDepth(representativePath[0], representativePath[1]) + ] = identicalPaths.map(([path, depth]) => + stringifyPathWithDepth(path, depth) ); } });