diff --git a/packages/core/src/utils/safeJsonStringify.test.ts b/packages/core/src/utils/safeJsonStringify.test.ts index 9a38c048810..fade21f9db1 100644 --- a/packages/core/src/utils/safeJsonStringify.test.ts +++ b/packages/core/src/utils/safeJsonStringify.test.ts @@ -70,4 +70,115 @@ describe('safeJsonStringify', () => { expect(safeJsonStringify(42)).toBe('42'); expect(safeJsonStringify(true)).toBe('true'); }); + + it('should preserve duplicate sibling references as full copies', () => { + // The same object referenced from two sibling properties is not a cycle: + // both branches must serialize in full, matching native JSON.stringify. + const shared = { name: 'shared', n: 1 }; + const obj = { a: shared, b: shared }; + + const result = safeJsonStringify(obj); + expect(result).toBe( + '{"a":{"name":"shared","n":1},"b":{"name":"shared","n":1}}', + ); + expect(result).not.toContain('[Circular]'); + }); + + it('should preserve duplicate references repeated in an array', () => { + const shared = { id: 1 }; + const arr = [shared, shared, shared]; + + const result = safeJsonStringify(arr); + expect(result).toBe('[{"id":1},{"id":1},{"id":1}]'); + expect(result).not.toContain('[Circular]'); + }); + + it('should preserve a shared leaf appearing on multiple branches', () => { + const leaf = { kind: 'leaf' }; + const tree = { left: { sub: leaf }, right: { sub: leaf } }; + + const result = safeJsonStringify(tree); + expect(result).toBe( + '{"left":{"sub":{"kind":"leaf"}},"right":{"sub":{"kind":"leaf"}}}', + ); + expect(result).not.toContain('[Circular]'); + }); + + it('should detect indirect cycles via an intermediate object', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parent: any = { name: 'parent' }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const child: any = { name: 'child' }; + parent.child = child; + child.parent = parent; + + const result = safeJsonStringify(parent); + expect(result).toBe( + '{"name":"parent","child":{"name":"child","parent":"[Circular]"}}', + ); + }); + + it('should preserve a shared subtree alongside a real cycle', () => { + const shared = { tag: 'shared' }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const root: any = { a: shared, b: shared }; + root.self = root; + + const result = safeJsonStringify(root); + expect(result).toBe( + '{"a":{"tag":"shared"},"b":{"tag":"shared"},"self":"[Circular]"}', + ); + }); + + it('should preserve a shared leaf reached through deep ancestor chains', () => { + // Forces the unwind loop to pop five frames between the deep branch and + // the sibling branch. Without the pop, the second occurrence of `shared` + // would still see `shared` on the stack and emit [Circular]. + const shared = { tag: 'shared' }; + const root = { + l1: { l2: { l3: { l4: { l5: { leaf: shared } } } } }, + sibling: { leaf: shared }, + }; + + const result = safeJsonStringify(root); + expect(result).toBe( + '{"l1":{"l2":{"l3":{"l4":{"l5":{"leaf":{"tag":"shared"}}}}}},"sibling":{"leaf":{"tag":"shared"}}}', + ); + expect(result).not.toContain('[Circular]'); + }); + + it('should detect a real cycle through deep ancestor chains', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const root: any = { l1: { l2: { l3: { l4: { l5: { back: null } } } } } }; + root.l1.l2.l3.l4.l5.back = root; + + const result = safeJsonStringify(root); + expect(result).toBe( + '{"l1":{"l2":{"l3":{"l4":{"l5":{"back":"[Circular]"}}}}}}', + ); + }); + + it('should preserve a shared object returned by toJSON from sibling positions', () => { + // JSON.stringify calls toJSON() before invoking the replacer, so the + // replacer sees the post-toJSON value. Two siblings whose toJSON returns + // the same object are duplicate refs, not a cycle. + const shared = { tag: 'shared' }; + const root = { + a: { toJSON: () => shared }, + b: { toJSON: () => shared }, + }; + + const result = safeJsonStringify(root); + expect(result).toBe('{"a":{"tag":"shared"},"b":{"tag":"shared"}}'); + expect(result).not.toContain('[Circular]'); + }); + + it('should detect a cycle when toJSON returns an ancestor', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const root: any = { name: 'root' }; + root.child = { toJSON: () => root }; + + const result = safeJsonStringify(root); + expect(result).toBe('{"name":"root","child":"[Circular]"}'); + }); }); diff --git a/packages/core/src/utils/safeJsonStringify.ts b/packages/core/src/utils/safeJsonStringify.ts index f439bcea1e9..0b73a6085ac 100644 --- a/packages/core/src/utils/safeJsonStringify.ts +++ b/packages/core/src/utils/safeJsonStringify.ts @@ -7,6 +7,11 @@ /** * Safely stringifies an object to JSON, handling circular references by replacing them with [Circular]. * + * Only true cycles (an object reachable from itself along the current ancestor + * path) are replaced. Duplicate references (the same object appearing in + * multiple sibling positions) are preserved as full copies, matching the + * behavior of `JSON.stringify` on acyclic graphs. + * * @param obj - The object to stringify * @param space - Optional space parameter for formatting (defaults to no formatting) * @returns JSON string with circular references replaced by [Circular] @@ -15,16 +20,23 @@ export function safeJsonStringify( obj: unknown, space?: string | number, ): string { - const seen = new WeakSet(); + const ancestors: object[] = []; return JSON.stringify( obj, - (key, value) => { - if (typeof value === 'object' && value !== null) { - if (seen.has(value)) { - return '[Circular]'; - } - seen.add(value); + function (this: unknown, _key, value) { + if (typeof value !== 'object' || value === null) { + return value; + } + // `this` is the parent of `value`. As JSON.stringify's DFS walk unwinds + // back up the tree, pop any ancestors that are no longer on the path + // to `this` so the stack reflects only the current chain of ancestors. + while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) { + ancestors.pop(); + } + if (ancestors.includes(value as object)) { + return '[Circular]'; } + ancestors.push(value as object); return value; }, space,