From 081b6d402490aec4ba12e7946b3de7a2163530c0 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Mon, 12 Oct 2026 13:43:04 +0900 Subject: [PATCH] feat(circular)!: include ancestor depth in default markers Circular references to different ancestors currently produce the same marker, which hides where each cycle closes. The new marker identifies the referenced ancestor's depth, starting at 0 for the root. BREAKING CHANGE: The default marker changes from `[Circular]` to `[Circular: N]`. Set `circularValue: '[Circular]'` to retain the previous output. --- CHANGELOG.md | 5 +- index.d.ts | 1 + index.js | 28 +++++--- readme.md | 9 ++- test.js | 177 ++++++++++++++++++++++++++++++++++++++++++++++----- 5 files changed, 190 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c9311f..83290b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Next +- Changed the default circular marker to `'[Circular: N]'`, where `N` is the referenced ancestor's depth, starting at 0 for the root. + Set `circularValue: '[Circular]'` to retain the previous format. This changes the serialized output for circular references. + - Added `'string'` as possible `bigint` option value. Bigint values are serialized as JSON strings using it. ```js @@ -156,4 +159,4 @@ configure({ circularValue: undefined })(object) - Fix typescript definition (https://github.com/BridgeAR/safe-stable-stringify/commit/7a87478) - Improve code coverage (https://github.com/BridgeAR/safe-stable-stringify/commit/ed8cadc, https://github.com/BridgeAR/safe-stable-stringify/commit/b58c494) - Update dev dependencies (https://github.com/BridgeAR/safe-stable-stringify/commit/b857ea8) -- Improve docs \ No newline at end of file +- Improve docs diff --git a/index.d.ts b/index.d.ts index 9a16006..c6f078f 100644 --- a/index.d.ts +++ b/index.d.ts @@ -6,6 +6,7 @@ export function stringify(value: unknown, replacer?: ((key: string, value: unkno export interface StringifyOptions { bigint?: boolean | 'string', + /** Defaults to '[Circular: N]', where N is the ancestor depth, starting at 0 for the root. */ circularValue?: string | null | TypeErrorConstructor | ErrorConstructor, deterministic?: boolean | ((a: string, b: string) => number), maximumBreadth?: number, diff --git a/index.js b/index.js index f952c03..51e764a 100644 --- a/index.js +++ b/index.js @@ -85,6 +85,12 @@ function stringifyTypedArray (array, separator, maximumBreadth, bigint) { return res } +/** @param {number} index */ +function stringifyCircularDepth (index) { + return `"[Circular: ${index}]"` +} + +/** @param {Record} options */ function getCircularValueOption (options) { if (hasOwnProperty.call(options, 'circularValue')) { const circularValue = options.circularValue @@ -103,7 +109,7 @@ function getCircularValueOption (options) { } throw new TypeError('The "circularValue" argument must be of type string or the value null or undefined') } - return '"[Circular]"' + return stringifyCircularDepth } function getDeterministicOption (options) { @@ -268,8 +274,9 @@ function configure (options) { if (value === null) { return 'null' } - if (stack.indexOf(value) !== -1) { - return circularValue + const circularIndex = stack.indexOf(value) + if (circularIndex !== -1) { + return typeof circularValue === 'function' ? circularValue(circularIndex) : circularValue } let res = '' @@ -379,8 +386,9 @@ function configure (options) { if (value === null) { return 'null' } - if (stack.indexOf(value) !== -1) { - return circularValue + const circularIndex = stack.indexOf(value) + if (circularIndex !== -1) { + return typeof circularValue === 'function' ? circularValue(circularIndex) : circularValue } const originalIndentation = indentation @@ -480,8 +488,9 @@ function configure (options) { return 'null' } } - if (stack.indexOf(value) !== -1) { - return circularValue + const circularIndex = stack.indexOf(value) + if (circularIndex !== -1) { + return typeof circularValue === 'function' ? circularValue(circularIndex) : circularValue } const originalIndentation = indentation @@ -593,8 +602,9 @@ function configure (options) { return 'null' } } - if (stack.indexOf(value) !== -1) { - return circularValue + const circularIndex = stack.indexOf(value) + if (circularIndex !== -1) { + return typeof circularValue === 'function' ? circularValue(circularIndex) : circularValue } let res = '' diff --git a/readme.md b/readme.md index 0eedc37..e9ab913 100644 --- a/readme.md +++ b/readme.md @@ -31,7 +31,7 @@ const circular = { b: 1, a: 0 } circular.circular = circular stringify(circular) -// '{"a":0,"b":1,"circular":"[Circular]"}' +// '{"a":0,"b":1,"circular":"[Circular: 0]"}' JSON.stringify(circular) // TypeError: Converting circular structure to JSON @@ -50,7 +50,9 @@ stringify(circular, ['a', 'b'], 2) * `circularValue` {string|null|undefined|ErrorConstructor} Defines the value for circular references. Set to `undefined`, circular properties are not serialized (array entries are replaced with `null`). Set to `Error`, to throw - on circular references. **Default:** `'[Circular]'`. + on circular references. **Default:** `'[Circular: N]'`, where `N` is the + referenced ancestor's depth, starting at 0 for the root. Set to `'[Circular]'` + to retain the previous default format. * `deterministic` {boolean|function} If `true` or a `Array#sort(comparator)` comparator method, guarantee a deterministic key order instead of relying on the insertion order. **Default:** `true`. @@ -113,7 +115,8 @@ throwOnCircular(circular); ## Differences to JSON.stringify -1. _Circular values_ are replaced with the string `[Circular]` (configurable). +1. _Circular values_ identify the referenced ancestor's depth as `[Circular: N]` + (configurable), starting at 0 for the root. 1. _Object keys_ are sorted instead of using the insertion order (configurable). 1. _BigInt_ values are stringified as regular number instead of throwing a TypeError (configurable). diff --git a/test.js b/test.js index 773aee2..2bb26a6 100644 --- a/test.js +++ b/test.js @@ -3,6 +3,149 @@ const { stringify } = require('./index.js') const clone = require('clone') const { constants: { MAX_STRING_LENGTH } } = require('buffer') +/** + * @param {string} key + * @param {unknown} value + */ +function identityReplacer (key, value) { + return value +} + +/** + * @param {import('tap').Test} assert + * @param {typeof stringify} serializer + * @param {object} value + * @param {object} expected + * @param {string[]} keys + */ +function assertCircularOutput (assert, serializer, value, expected, keys) { + for (const replacer of [undefined, identityReplacer, keys]) { + for (const space of [undefined, 2]) { + assert.equal(serializer(value, replacer, space), JSON.stringify(expected, null, space)) + } + } +} + +test('circular markers report ancestor depth in every serialization mode', /** @param {import('tap').Test} assert */ (assert) => { + const root = { child: { array: [] } } + const child = root.child + const array = child.array + root.root = root + child.root = root + child.self = child + array.push(root, child, array) + + const expected = { + child: { + array: ['[Circular: 0]', '[Circular: 1]', '[Circular: 2]'], + root: '[Circular: 0]', + self: '[Circular: 1]' + }, + root: '[Circular: 0]' + } + const arrayRoot = [] + const nestedArray = [] + arrayRoot.push(nestedArray, arrayRoot) + nestedArray.push(arrayRoot, nestedArray) + for (const serializer of [stringify, stringify.configure({ deterministic: false }), stringify.configure({ safe: true })]) { + assertCircularOutput(assert, serializer, root, expected, ['child', 'array', 'root', 'self']) + assertCircularOutput(assert, serializer, arrayRoot, [['[Circular: 0]', '[Circular: 1]'], '[Circular: 0]'], []) + } + assert.end() +}) + +test('shared objects use the depth of their current branch', /** @param {import('tap').Test} assert */ (assert) => { + const child = {} + child.self = child + const root = { child, nested: { child } } + const expected = { + child: { self: '[Circular: 1]' }, + nested: { child: { self: '[Circular: 2]' } } + } + assertCircularOutput(assert, stringify, root, expected, ['child', 'nested', 'self']) + assert.end() +}) + +test('circular depth takes precedence over maximumDepth', /** @param {import('tap').Test} assert */ (assert) => { + const root = { array: /** @type {object[]} */ ([]), child: {} } + root.array.push(root, root.array) + root.child.root = root + root.child.self = root.child + const keys = ['array', 'child', 'root', 'self'] + assertCircularOutput(assert, stringify.configure({ maximumDepth: 1 }), root, { + array: '[Array]', child: '[Object]' + }, keys) + assertCircularOutput(assert, stringify.configure({ maximumDepth: 2 }), root, { + array: ['[Circular: 0]', '[Circular: 1]'], + child: { root: '[Circular: 0]', self: '[Circular: 1]' } + }, keys) + assert.end() +}) + +test('toJSON results refer to serialized ancestor depths', /** @param {import('tap').Test} assert */ (assert) => { + const root = { child: { link: { toJSON () { return root } } } } + const wrapper = { toJSON () { return root } } + assertCircularOutput(assert, stringify, wrapper, { + child: { link: '[Circular: 0]' } + }, ['child', 'link']) + assert.end() +}) + +test('function replacers can create circular references to ancestors', /** @param {import('tap').Test} assert */ (assert) => { + /** + * @this {object} + * @param {string} key + * @param {unknown} value + */ + function circularReplacer (key, value) { + return key === 'self' ? this : value + } + const root = { child: { self: 0 }, self: 0 } + const expected = { child: { self: '[Circular: 1]' }, self: '[Circular: 0]' } + for (const space of [undefined, 2]) { + assert.equal(stringify(root, circularReplacer, space), JSON.stringify(expected, null, space)) + } + assert.end() +}) + +test('explicit circular values retain their behavior in every mode', /** @param {import('tap').Test} assert */ (assert) => { + const root = { array: /** @type {object[]} */ ([]) } + root.array.push(root) + root.self = root + for (const circularValue of ['', '[Circular]', 'custom', null, undefined]) { + const expected = circularValue === undefined + ? { array: [null] } + : { array: [circularValue], self: circularValue } + assertCircularOutput(assert, stringify.configure({ circularValue }), root, expected, ['array', 'self']) + } + for (const circularValue of [Error, TypeError]) { + const serializer = stringify.configure({ circularValue }) + for (const replacer of [undefined, identityReplacer, ['array', 'self']]) { + for (const space of [undefined, 2]) { + assert.throws(() => serializer(root, replacer, space), TypeError) + } + } + } + assert.end() +}) + +test('circular depths can have multiple digits', /** @param {import('tap').Test} assert */ (assert) => { + const root = {} + let child = root + const expected = {} + let expectedChild = expected + for (let i = 1; i < 12; i++) { + child.next = {} + child = child.next + expectedChild.next = {} + expectedChild = expectedChild.next + } + child.self = child + expectedChild.self = '[Circular: 11]' + assertCircularOutput(assert, stringify, root, expected, ['next', 'self']) + assert.end() +}) + test('toJSON receives array keys as string', function (assert) { const object = [{ toJSON (key) { @@ -33,7 +176,7 @@ test('circular reference to root', function (assert) { const fixture = { name: 'Tywin Lannister' } fixture.circle = fixture const expected = JSON.stringify( - { circle: '[Circular]', name: 'Tywin Lannister' } + { circle: '[Circular: 0]', name: 'Tywin Lannister' } ) const actual = stringify(fixture) assert.equal(actual, expected) @@ -44,7 +187,7 @@ test('nested circular reference to root', function (assert) { const fixture = { name: 'Tywin\n\t"Lannister' } fixture.id = { circle: fixture } const expected = JSON.stringify( - { id: { circle: '[Circular]' }, name: 'Tywin\n\t"Lannister' } + { id: { circle: '[Circular: 0]' }, name: 'Tywin\n\t"Lannister' } ) const actual = stringify(fixture) assert.equal(actual, expected) @@ -74,7 +217,7 @@ test('child circular reference', function (assert) { fixture.child.dinklage = fixture.child const expected = JSON.stringify({ child: { - dinklage: '[Circular]', name: 'Tyrion\n\t"Lannister'.repeat(20) + dinklage: '[Circular: 1]', name: 'Tyrion\n\t"Lannister'.repeat(20) }, name: 'Tywin Lannister' }) @@ -88,7 +231,7 @@ test('nested child circular reference', function (assert) { fixture.child.actor = { dinklage: fixture.child } const expected = JSON.stringify({ child: { - actor: { dinklage: '[Circular]' }, name: 'Tyrion Lannister' + actor: { dinklage: '[Circular: 1]' }, name: 'Tyrion Lannister' }, name: 'Tywin Lannister' }) @@ -101,7 +244,7 @@ test('circular objects in an array', function (assert) { const fixture = { name: 'Tywin Lannister' } fixture.hand = [fixture, fixture] const expected = JSON.stringify({ - hand: ['[Circular]', '[Circular]'], name: 'Tywin Lannister' + hand: ['[Circular: 0]', '[Circular: 0]'], name: 'Tywin Lannister' }) const actual = stringify(fixture) assert.equal(actual, expected) @@ -119,8 +262,8 @@ test('nested circular references in an array', function (assert) { const expected = JSON.stringify({ name: 'Tywin Lannister', offspring: [ - { dinklage: '[Circular]', name: 'Tyrion Lannister' }, - { headey: '[Circular]', name: 'Cersei Lannister' } + { dinklage: '[Circular: 2]', name: 'Tyrion Lannister' }, + { headey: '[Circular: 2]', name: 'Cersei Lannister' } ] }) const actual = stringify(fixture) @@ -131,7 +274,7 @@ test('nested circular references in an array', function (assert) { test('circular arrays', function (assert) { const fixture = [] fixture.push(fixture, fixture) - const expected = JSON.stringify(['[Circular]', '[Circular]']) + const expected = JSON.stringify(['[Circular: 0]', '[Circular: 0]']) const actual = stringify(fixture) assert.equal(actual, expected) assert.end() @@ -144,8 +287,8 @@ test('nested circular arrays', function (assert) { { name: 'Ramsay Bolton', circular: fixture } ) const expected = JSON.stringify([ - { circular: '[Circular]', name: 'Jon Snow' }, - { circular: '[Circular]', name: 'Ramsay Bolton' } + { circular: '[Circular: 0]', name: 'Jon Snow' }, + { circular: '[Circular: 0]', name: 'Ramsay Bolton' } ]) const actual = stringify(fixture) assert.equal(actual, expected) @@ -183,10 +326,10 @@ test('double child circular reference', function (assert) { const cloned = clone(fixture) const expected = JSON.stringify({ childA: { - dinklage: '[Circular]', name: 'Tyrion Lannister' + dinklage: '[Circular: 1]', name: 'Tyrion Lannister' }, childB: { - dinklage: '[Circular]', name: 'Tyrion Lannister' + dinklage: '[Circular: 1]', name: 'Tyrion Lannister' }, name: 'Tywin Lannister' }) @@ -291,7 +434,7 @@ test('nested child circular reference in toJSON', function (assert) { bar: { b: 2, baz: { - circle: '[Circular]', + circle: '[Circular: 2]', some: 'data' } } @@ -335,7 +478,7 @@ test('replacer removing elements', function (assert) { obj.obj = obj actual = stringify(obj, replacer) - assert.equal(actual, '{"f":null,"obj":"[Circular]","typed":{"0":0}}') + assert.equal(actual, '{"f":null,"obj":"[Circular: 0]","typed":{"0":0}}') assert.end() }) @@ -397,7 +540,7 @@ test('array replacer', function (assert) { // @ts-expect-error actual = stringify({ toJSON () { return obj } }, replacer) - assert.equal(actual, expected.replace('null', '"[Circular]"')) + assert.equal(actual, expected.replace('null', '"[Circular: 0]"')) assert.end() }) @@ -640,7 +783,7 @@ test('circular value option should allow strings and null', function (assert) { const expected = '{"circular":"YEAH!!!"}' const actual = stringifyCircularValue(obj) assert.equal(actual, expected) - assert.equal(stringify(obj), '{"circular":"[Circular]"}') + assert.equal(stringify(obj), '{"circular":"[Circular: 0]"}') stringifyCircularValue = stringify.configure({ circularValue: null }) assert.equal(stringifyCircularValue(obj), '{"circular":null}') @@ -822,7 +965,7 @@ test('indent properly; regression test for issue #16', function (assert) { o.items[0].circular = o - const circularReplacement = '"items": [\n {\n "circular": "[Circular]",\n' + const circularReplacement = '"items": [\n {\n "circular": "[Circular: 0]",\n' const circularIdentifier = '"items": [\n {\n' assert.equal(