From 1600886e038cee12084783cf2e7c0c5dd7ce7376 Mon Sep 17 00:00:00 2001 From: leoca Date: Fri, 11 Sep 2026 01:00:22 +0200 Subject: [PATCH 1/2] Fix safeStringify treating shared references as circular safeStringify kept every object it had seen in a Set and never removed anything, so a value that appears in two sibling branches (a DAG, not a cycle) was replaced with "[Circular]" on every occurrence after the first. For example safeStringify([shared, { x: shared }]) returned [{"a":1},{"x":"[Circular]"}]. Track only the current ancestor path instead, using the replacer's `this` (the holder) to drop subtrees that are finished. True cycles are still reported as "[Circular]". This is the same change that was made to stableStringify in #327398. The existing safeStringify test encoded the old behaviour: obj2 is both obj1.friend and a sibling in `c`, so its second occurrence is shared, not circular. Updated that expectation and added a test for shared references in object and array positions. --- src/vs/base/common/objects.ts | 17 +++++++++++------ src/vs/base/test/common/objects.test.ts | 11 ++++++++++- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/vs/base/common/objects.ts b/src/vs/base/common/objects.ts index ee357967364036..e245d9af1c3406 100644 --- a/src/vs/base/common/objects.ts +++ b/src/vs/base/common/objects.ts @@ -168,14 +168,19 @@ export function equals(one: any, other: any): boolean { * "Uncaught TypeError: Converting circular structure to JSON" */ export function safeStringify(obj: any): string { - const seen = new Set(); - return JSON.stringify(obj, (key, value) => { - if (isObject(value) || Array.isArray(value)) { - if (seen.has(value)) { + // Only track the current ancestor path, so a value that is shared across + // sibling branches is serialized in full rather than reported as circular. + const ancestors: unknown[] = []; + return JSON.stringify(obj, function (this: unknown, key: string, value: unknown) { + if (typeof value === 'object' && value !== null) { + // `this` is the object holding `key`, pop the subtrees that are already done + while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) { + ancestors.pop(); + } + if (ancestors.includes(value)) { return '[Circular]'; - } else { - seen.add(value); } + ancestors.push(value); } if (typeof value === 'bigint') { return `[BigInt ${value.toString()}]`; diff --git a/src/vs/base/test/common/objects.test.ts b/src/vs/base/test/common/objects.test.ts index ab8018857f7649..9909e38c072c22 100644 --- a/src/vs/base/test/common/objects.test.ts +++ b/src/vs/base/test/common/objects.test.ts @@ -134,13 +134,22 @@ suite('Objects', () => { friend: '[Circular]' } }, - '[Circular]' + { + friend: { + friend: '[Circular]' + } + } ], d: [1, '[Circular]', '[Circular]'], e: '[BigInt 42]' }); }); + test('safeStringify does not treat shared references as circular', () => { + const shared = { a: 1 }; + assert.strictEqual(objects.safeStringify([shared, { x: shared, y: [shared] }]), '[{"a":1},{"x":{"a":1},"y":[{"a":1}]}]'); + }); + test('stableStringify', () => { // Stable key order regardless of insertion order const a = { b: 1, a: 2, c: { y: 1, x: 2 } }; From 252cc144506db3d0fd90c938ce2f721cf351680d Mon Sep 17 00:00:00 2001 From: Leo Camus Date: Fri, 11 Sep 2026 01:09:31 +0200 Subject: [PATCH 2/2] Refactor comment for clarity in safeStringify function Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/vs/base/common/objects.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/vs/base/common/objects.ts b/src/vs/base/common/objects.ts index e245d9af1c3406..d1125e67040e75 100644 --- a/src/vs/base/common/objects.ts +++ b/src/vs/base/common/objects.ts @@ -168,8 +168,7 @@ export function equals(one: any, other: any): boolean { * "Uncaught TypeError: Converting circular structure to JSON" */ export function safeStringify(obj: any): string { - // Only track the current ancestor path, so a value that is shared across - // sibling branches is serialized in full rather than reported as circular. + // Track only current ancestors so shared sibling references are serialized in full. const ancestors: unknown[] = []; return JSON.stringify(obj, function (this: unknown, key: string, value: unknown) { if (typeof value === 'object' && value !== null) {