From 94cb473dafad2ebd1e38c27715d9885196ece83a Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Tue, 13 Oct 2026 13:14:28 +0900 Subject: [PATCH 1/7] perf(arrays): inline compact primitives A four-number array uses 101 ns instead of 133 ns on Node 26.3.1 (V8 14.6.202.34-node.20), a 24% CPU reduction. Measurements use 50,000 warmup calls and seven alternating trials, with the fastest and slowest removed. Four-object arrays use about 5% more CPU in both confirmation runs. Safe mode retains per-element exception handling. Drive-by fix: Mark intentional invalid-option assertions for TypeScript. --- index.js | 22 ++++++++++++++++++++-- test.js | 21 +++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index e0aa013..c19e158 100644 --- a/index.js +++ b/index.js @@ -658,11 +658,29 @@ function configure (options) { const maximumValuesToStringify = Math.min(value.length, maximumBreadth) let i = 0 for (; i < maximumValuesToStringify - 1; i++) { - const tmp = stringifySimple(i, value[i], stack) + const item = value[i] + const tmp = safe + ? stringifySimple(i, item, stack) + : typeof item === 'string' + ? strEscape(item) + : typeof item === 'number' && isFinite(item) + ? String(item) + : typeof item === 'boolean' + ? item === true ? 'true' : 'false' + : stringifySimple(i, item, stack) res += tmp !== undefined ? tmp : 'null' res += ',' } - const tmp = stringifySimple(i, value[i], stack) + const item = value[i] + const tmp = safe + ? stringifySimple(i, item, stack) + : typeof item === 'string' + ? strEscape(item) + : typeof item === 'number' && isFinite(item) + ? String(item) + : typeof item === 'boolean' + ? item === true ? 'true' : 'false' + : stringifySimple(i, item, stack) res += tmp !== undefined ? tmp : 'null' if (value.length > maximumBreadth) { const removedKeys = value.length - maximumBreadth diff --git a/test.js b/test.js index 9cc06bc..1733557 100644 --- a/test.js +++ b/test.js @@ -261,6 +261,27 @@ test('toJSON receives array keys as string', /** @param {import('tap').Test} ass assert.end() }) +test('compact primitive arrays preserve special values and fallback behavior', /** @param {import('tap').Test} assert */ (assert) => { + const values = [0, -0, 1.5, NaN, Infinity, -Infinity, '', 'text', '\n"\\', '\ud800', null, undefined, true, false, { value: 42 }] + for (const safe of [false, true]) { + const serializer = stringify.configure({ safe }) + for (const value of values) { + assert.equal(serializer([value, value]), JSON.stringify([value, value])) + assert.equal(serializer([value]), JSON.stringify([value])) + } + } + const strictSerializer = stringify.configure({ strict: true }) + for (const value of [NaN, Infinity, -Infinity]) { + assert.throws(() => strictSerializer([value, 0]), { name: 'Error', message: `Object can not safely be stringified. Received type number (${value})` }) + assert.throws(() => strictSerializer([0, value]), { name: 'Error', message: `Object can not safely be stringified. Received type number (${value})` }) + } + const safeSerializer = stringify.configure({ safe: true }) + const longString = 'a'.repeat(MAX_STRING_LENGTH) + assert.equal(safeSerializer([longString]), '["Error: Stringification failed (Invalid string length)"]') + assert.equal(safeSerializer([longString, 0]), '["Error: Stringification failed (Invalid string length)",0]') + assert.end() +}) + test('circular reference to root', function (assert) { const fixture = { name: 'Tywin Lannister' } fixture.circle = fixture From 4eba2d13d17600bfeebb6837f3d5bcecd9d682f5 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Tue, 13 Oct 2026 14:47:05 +0900 Subject: [PATCH 2/7] perf(arrays): split compact object and primitive serialization Compact object arrays pay primitive checks before recursive serialization, which adds overhead to the existing object path. Four-number arrays use 69.8 ns instead of 75.4 ns on Node 26.3.1 (V8 14.6.202.34-node.20), a 7.4% reduction. Measurements use 100,000 warmup calls and seven alternating trials, with the fastest and slowest removed. Safe mode must catch primitive failures per value so an oversized string does not replace its containing array. --- index.js | 222 ++++++++++++++++++++++++++++--------------------------- test.js | 51 ++++++++++++- 2 files changed, 162 insertions(+), 111 deletions(-) diff --git a/index.js b/index.js index c19e158..e6154c7 100644 --- a/index.js +++ b/index.js @@ -615,117 +615,11 @@ function configure (options) { } } - /** - * @param {string | number} key - * @param {unknown} value - * @param {object[]} stack - */ - let stringifySimple = function (key, value, stack) { + /** @param {string | number | boolean | bigint | symbol | undefined | Function} value */ + let stringifyPrimitive = function (value) { switch (typeof value) { case 'string': return strEscape(value) - case 'object': { - if (value === null) { - return 'null' - } - if (typeof /** @type {ToJSON} */ (value).toJSON === 'function') { - if (typeof key === 'number') key = String(key) - value = safe ? callSafe(/** @type {ToJSON} */ (value).toJSON, value, key) : /** @type {ToJSON} */ (value).toJSON(key) - // Prevent calling `toJSON` again - if (typeof value !== 'object') { - return stringifySimple(key, value, stack) - } - if (value === null) { - return 'null' - } - } - const circularIndex = stack.indexOf(value) - if (circularIndex !== -1) { - return typeof circularValue === 'function' ? circularValue(circularIndex) : circularValue - } - - let res = '' - - const hasLength = /** @type {{length?: unknown}} */ (value).length !== undefined - if (hasLength && Array.isArray(value)) { - if (value.length === 0) { - return '[]' - } - if (maximumDepth < stack.length + 1) { - return '"[Array]"' - } - stack.push(value) - const maximumValuesToStringify = Math.min(value.length, maximumBreadth) - let i = 0 - for (; i < maximumValuesToStringify - 1; i++) { - const item = value[i] - const tmp = safe - ? stringifySimple(i, item, stack) - : typeof item === 'string' - ? strEscape(item) - : typeof item === 'number' && isFinite(item) - ? String(item) - : typeof item === 'boolean' - ? item === true ? 'true' : 'false' - : stringifySimple(i, item, stack) - res += tmp !== undefined ? tmp : 'null' - res += ',' - } - const item = value[i] - const tmp = safe - ? stringifySimple(i, item, stack) - : typeof item === 'string' - ? strEscape(item) - : typeof item === 'number' && isFinite(item) - ? String(item) - : typeof item === 'boolean' - ? item === true ? 'true' : 'false' - : stringifySimple(i, item, stack) - res += tmp !== undefined ? tmp : 'null' - if (value.length > maximumBreadth) { - const removedKeys = value.length - maximumBreadth - res += `,"... ${getItemCount(removedKeys)} not stringified"` - } - stack.pop() - return `[${res}]` - } - - let keys = Object.keys(value) - const keyLength = keys.length - if (keyLength === 0) { - return '{}' - } - if (maximumDepth < stack.length + 1) { - return '"[Object]"' - } - let separator = '' - let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) - const typedArrayType = hasLength ? getTypedArrayType(value) : undefined - if (typedArrayType !== undefined) { - res += stringifyTypedArray(/** @type {ArrayLike} */ (value), ',', maximumBreadth, bigint, fail, typedArrayType) - keys = keys.slice(/** @type {{length: number}} */ (value).length) - maximumPropertiesToStringify -= /** @type {{length: number}} */ (value).length - separator = ',' - } - if (deterministic) { - keys = sort(keys, comparator) - } - stack.push(value) - for (let i = 0; i < maximumPropertiesToStringify; i++) { - const key = keys[i] - const tmp = stringifySimple(key, /** @type {Record} */ (value)[key], stack) - if (tmp !== undefined) { - res += `${separator}${strEscape(key)}:${tmp}` - separator = ',' - } - } - if (keyLength > maximumBreadth) { - const removedKeys = keyLength - maximumBreadth - res += `${separator}"...":"${getItemCount(removedKeys)} not stringified"` - } - stack.pop() - return `{${res}}` - } case 'number': return isFinite(value) ? String(value) : fail ? fail(value) : 'null' case 'boolean': @@ -745,13 +639,119 @@ function configure (options) { } } + /** + * @param {string | number} key + * @param {object | null} value + * @param {object[]} stack + */ + let stringifyObject = function (key, value, stack) { + if (value === null) { + return 'null' + } + if (typeof /** @type {ToJSON} */ (value).toJSON === 'function') { + if (typeof key === 'number') key = String(key) + const jsonValue = safe ? callSafe(/** @type {ToJSON} */ (value).toJSON, value, key) : /** @type {ToJSON} */ (value).toJSON(key) + // Prevent calling `toJSON` again + if (typeof jsonValue !== 'object') { + return stringifyPrimitive(jsonValue) + } + if (jsonValue === null) { + return 'null' + } + value = jsonValue + } + const circularIndex = stack.indexOf(value) + if (circularIndex !== -1) { + return typeof circularValue === 'function' ? circularValue(circularIndex) : circularValue + } + + let res = '' + + const hasLength = /** @type {{length?: unknown}} */ (value).length !== undefined + if (hasLength && Array.isArray(value)) { + if (value.length === 0) { + return '[]' + } + if (maximumDepth < stack.length + 1) { + return '"[Array]"' + } + stack.push(value) + const maximumValuesToStringify = Math.min(value.length, maximumBreadth) + let i = 0 + for (; i < maximumValuesToStringify - 1; i++) { + const item = value[i] + const tmp = typeof item === 'object' + ? stringifyObject(i, item, stack) + : stringifyPrimitive(item) + res += tmp !== undefined ? tmp : 'null' + res += ',' + } + const item = value[i] + const tmp = typeof item === 'object' + ? stringifyObject(i, item, stack) + : stringifyPrimitive(item) + res += tmp !== undefined ? tmp : 'null' + if (value.length > maximumBreadth) { + const removedKeys = value.length - maximumBreadth + res += `,"... ${getItemCount(removedKeys)} not stringified"` + } + stack.pop() + return `[${res}]` + } + + let keys = Object.keys(value) + const keyLength = keys.length + if (keyLength === 0) { + return '{}' + } + if (maximumDepth < stack.length + 1) { + return '"[Object]"' + } + let separator = '' + let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) + const typedArrayType = hasLength ? getTypedArrayType(value) : undefined + if (typedArrayType !== undefined) { + res += stringifyTypedArray(/** @type {ArrayLike} */ (value), ',', maximumBreadth, bigint, fail, typedArrayType) + keys = keys.slice(/** @type {{length: number}} */ (value).length) + maximumPropertiesToStringify -= /** @type {{length: number}} */ (value).length + separator = ',' + } + if (deterministic) { + keys = sort(keys, comparator) + } + stack.push(value) + for (let i = 0; i < maximumPropertiesToStringify; i++) { + const key = keys[i] + const item = /** @type {Record} */ (value)[key] + const tmp = typeof item === 'object' + ? stringifyObject(key, item, stack) + : stringifyPrimitive(item) + if (tmp !== undefined) { + res += `${separator}${strEscape(key)}:${tmp}` + separator = ',' + } + } + if (keyLength > maximumBreadth) { + const removedKeys = keyLength - maximumBreadth + res += `${separator}"...":"${getItemCount(removedKeys)} not stringified"` + } + stack.pop() + return `{${res}}` + } + if (safe) { stringifyFnReplacer = makeSafe(stringifyFnReplacer) stringifyArrayReplacer = makeSafe(stringifyArrayReplacer) stringifyIndent = makeSafe(stringifyIndent) - stringifySimple = makeSafe(stringifySimple) + stringifyObject = makeSafe(stringifyObject) + stringifyPrimitive = makeSafe(stringifyPrimitive) } + /** + * @param {unknown} value + * @param {import('./index').Replacer} [replacer] + * @param {string | number} [space] + */ function stringify (value, replacer, space) { if (arguments.length > 1) { let spacer = '' @@ -775,7 +775,9 @@ function configure (options) { return stringifyIndent('', value, [], spacer, '') } } - return stringifySimple('', value, []) + return typeof value === 'object' + ? stringifyObject('', value, []) + : stringifyPrimitive(value) } return stringify diff --git a/test.js b/test.js index 1733557..67e156d 100644 --- a/test.js +++ b/test.js @@ -262,13 +262,16 @@ test('toJSON receives array keys as string', /** @param {import('tap').Test} ass }) test('compact primitive arrays preserve special values and fallback behavior', /** @param {import('tap').Test} assert */ (assert) => { - const values = [0, -0, 1.5, NaN, Infinity, -Infinity, '', 'text', '\n"\\', '\ud800', null, undefined, true, false, { value: 42 }] + const values = [0, -0, 1.5, NaN, Infinity, -Infinity, '', 'text', '\n"\\', '\ud800', null, undefined, true, false, Symbol('value'), () => {}, { value: 42 }] for (const safe of [false, true]) { const serializer = stringify.configure({ safe }) for (const value of values) { assert.equal(serializer([value, value]), JSON.stringify([value, value])) assert.equal(serializer([value]), JSON.stringify([value])) + const wrapped = { toJSON () { return value } } + assert.equal(serializer([wrapped, wrapped]), JSON.stringify([wrapped, wrapped])) } + assert.equal(serializer(new Array(2)), '[null,null]') } const strictSerializer = stringify.configure({ strict: true }) for (const value of [NaN, Infinity, -Infinity]) { @@ -282,6 +285,52 @@ test('compact primitive arrays preserve special values and fallback behavior', / assert.end() }) +test('compact primitive arrays retain safe strict failures and bigint options', /** @param {import('tap').Test} assert */ (assert) => { + const unsupported = /** @type {[number | symbol | (() => void), string][]} */ ([ + [NaN, 'number (NaN)'], + [Infinity, 'number (Infinity)'], + [-Infinity, 'number (-Infinity)'], + [Symbol('value'), 'symbol (Symbol(value))'], + [() => {}, 'function'] + ]) + const strict = stringify.configure({ strict: true }) + const safeStrict = stringify.configure({ strict: true, safe: true }) + for (const [value, description] of unsupported) { + assert.throws(() => strict([value, 0]), /Object can not safely be stringified/) + assert.throws(() => strict([0, value]), /Object can not safely be stringified/) + const message = `"Error: Stringification failed (Object can not safely be stringified. Received type ${description})"` + assert.equal(safeStrict([value, 0]), `[${message},0]`) + assert.equal(safeStrict([0, value]), `[0,${message}]`) + } + assert.equal(safeStrict([undefined, undefined]), '[null,null]') + for (const bigint of /** @type {import('./index').StringifyOptions['bigint'][]} */ ([true, false, 'string'])) { + for (const safe of [false, true]) { + for (const strict of [false, true]) { + const serializer = stringify.configure({ bigint, safe, strict }) + if (strict && !bigint && !safe) { + assert.throws(() => serializer([1n, 0]), /Received type bigint \(1\)/) + assert.throws(() => serializer([0, 1n]), /Received type bigint \(1\)/) + } else { + const expected = bigint === 'string' + ? '"1"' + : bigint + ? '1' + : strict + ? '"Error: Stringification failed (Object can not safely be stringified. Received type bigint (1))"' + : 'null' + assert.equal(serializer([1n, 0]), `[${expected},0]`) + assert.equal(serializer([0, 1n]), `[0,${expected}]`) + } + } + } + } + const serializer = stringify.configure({ safe: true }) + const failing = { get value () { throw new Error('Oops') } } + assert.equal(serializer([failing, 0]), '["Error: Stringification failed (Oops)",0]') + assert.equal(serializer([0, failing]), '[0,"Error: Stringification failed (Oops)"]') + assert.end() +}) + test('circular reference to root', function (assert) { const fixture = { name: 'Tywin Lannister' } fixture.circle = fixture From dc389b9b0967cb64b208316da7ffaf6c0b4753a9 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Tue, 13 Oct 2026 15:32:28 +0900 Subject: [PATCH 3/7] perf(stringify): share primitive formatting across serialization modes On Node 26.3.1 (V8 14.6.202.34-node.20), a four-number array with indentation takes 82.8 ns instead of 100.7 ns, an 18% CPU reduction. Measurements use 100,000 warmup calls and seven alternating trials of 200,000 calls, with the fastest and slowest results removed. Function replacers already catch failures per value in safe mode. Reusing that boundary avoids a second wrapper around primitive formatting. --- index.js | 560 +++++++++++++++++++++++++------------------------------ test.js | 138 +++++++++----- 2 files changed, 346 insertions(+), 352 deletions(-) diff --git a/index.js b/index.js index e6154c7..97d993b 100644 --- a/index.js +++ b/index.js @@ -280,343 +280,291 @@ function configure (options) { } value = replacer.call(parent, key, value) - switch (typeof value) { - case 'string': - return strEscape(value) - case 'object': { - if (value === null) { - return 'null' - } - const circularIndex = stack.indexOf(value) - if (circularIndex !== -1) { - return typeof circularValue === 'function' ? circularValue(circularIndex) : circularValue - } - - let res = '' - let join = ',' - const originalIndentation = indentation + if (typeof value === 'object') { + if (value === null) { + return 'null' + } + const circularIndex = stack.indexOf(value) + if (circularIndex !== -1) { + return typeof circularValue === 'function' ? circularValue(circularIndex) : circularValue + } - if (Array.isArray(value)) { - if (value.length === 0) { - return '[]' - } - if (maximumDepth < stack.length + 1) { - return '"[Array]"' - } - stack.push(value) - if (spacer !== '') { - indentation += spacer - res += `\n${indentation}` - join = `,\n${indentation}` - } - const maximumValuesToStringify = Math.min(value.length, maximumBreadth) - let i = 0 - for (; i < maximumValuesToStringify - 1; i++) { - const tmp = stringifyFnReplacer(String(i), value, stack, replacer, spacer, indentation) - res += tmp !== undefined ? tmp : 'null' - res += join - } - const tmp = stringifyFnReplacer(String(i), value, stack, replacer, spacer, indentation) - res += tmp !== undefined ? tmp : 'null' - if (value.length > maximumBreadth) { - const removedKeys = value.length - maximumBreadth - res += `${join}"... ${getItemCount(removedKeys)} not stringified"` - } - if (spacer !== '') { - res += `\n${originalIndentation}` - } - stack.pop() - return `[${res}]` - } + let res = '' + let join = ',' + const originalIndentation = indentation - let keys = Object.keys(value) - const keyLength = keys.length - if (keyLength === 0) { - return '{}' + if (Array.isArray(value)) { + if (value.length === 0) { + return '[]' } if (maximumDepth < stack.length + 1) { - return '"[Object]"' + return '"[Array]"' } - let whitespace = '' - let separator = '' + stack.push(value) if (spacer !== '') { indentation += spacer + res += `\n${indentation}` join = `,\n${indentation}` - whitespace = ' ' - } - const maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) - if (deterministic && getTypedArrayType(value) === undefined) { - keys = sort(keys, comparator) } - stack.push(value) - for (let i = 0; i < maximumPropertiesToStringify; i++) { - const key = keys[i] - const tmp = stringifyFnReplacer(key, value, stack, replacer, spacer, indentation) - if (tmp !== undefined) { - res += `${separator}${strEscape(key)}:${whitespace}${tmp}` - separator = join - } + const maximumValuesToStringify = Math.min(value.length, maximumBreadth) + let i = 0 + for (; i < maximumValuesToStringify - 1; i++) { + const tmp = stringifyFnReplacer(String(i), value, stack, replacer, spacer, indentation) + res += tmp !== undefined ? tmp : 'null' + res += join } - if (keyLength > maximumBreadth) { - const removedKeys = keyLength - maximumBreadth - res += `${separator}"...":${whitespace}"${getItemCount(removedKeys)} not stringified"` - separator = join + const tmp = stringifyFnReplacer(String(i), value, stack, replacer, spacer, indentation) + res += tmp !== undefined ? tmp : 'null' + if (value.length > maximumBreadth) { + const removedKeys = value.length - maximumBreadth + res += `${join}"... ${getItemCount(removedKeys)} not stringified"` } - if (spacer !== '' && separator.length > 1) { - res = `\n${indentation}${res}\n${originalIndentation}` + if (spacer !== '') { + res += `\n${originalIndentation}` } stack.pop() - return `{${res}}` + return `[${res}]` } - case 'number': - return isFinite(value) ? String(value) : fail ? fail(value) : 'null' - case 'boolean': - return value === true ? 'true' : 'false' - case 'undefined': - return undefined - case 'bigint': - if (bigint) { - if (bigint === 'string') { - return `"${String(value)}"` - } - return String(value) + + let keys = Object.keys(value) + const keyLength = keys.length + if (keyLength === 0) { + return '{}' + } + if (maximumDepth < stack.length + 1) { + return '"[Object]"' + } + let whitespace = '' + let separator = '' + if (spacer !== '') { + indentation += spacer + join = `,\n${indentation}` + whitespace = ' ' + } + const maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) + if (deterministic && getTypedArrayType(value) === undefined) { + keys = sort(keys, comparator) + } + stack.push(value) + for (let i = 0; i < maximumPropertiesToStringify; i++) { + const key = keys[i] + const tmp = stringifyFnReplacer(key, value, stack, replacer, spacer, indentation) + if (tmp !== undefined) { + res += `${separator}${strEscape(key)}:${whitespace}${tmp}` + separator = join } - // fallthrough - default: - return fail ? fail(value) : undefined + } + if (keyLength > maximumBreadth) { + const removedKeys = keyLength - maximumBreadth + res += `${separator}"...":${whitespace}"${getItemCount(removedKeys)} not stringified"` + separator = join + } + if (spacer !== '' && separator.length > 1) { + res = `\n${indentation}${res}\n${originalIndentation}` + } + stack.pop() + return `{${res}}` } + return stringifyPrimitive(value) } /** * @param {string | number} key - * @param {unknown} value + * @param {object | null} value * @param {object[]} stack * @param {Set} replacer * @param {string} spacer * @param {string} indentation */ let stringifyArrayReplacer = function (key, value, stack, replacer, spacer, indentation) { - if (typeof value === 'object' && value !== null && typeof /** @type {ToJSON} */ (value).toJSON === 'function') { + if (value === null) { + return 'null' + } + if (typeof /** @type {ToJSON} */ (value).toJSON === 'function') { if (typeof key === 'number') key = String(key) - value = safe ? callSafe(/** @type {ToJSON} */ (value).toJSON, value, key) : /** @type {ToJSON} */ (value).toJSON(key) + const jsonValue = safe ? callSafe(/** @type {ToJSON} */ (value).toJSON, value, key) : /** @type {ToJSON} */ (value).toJSON(key) + if (typeof jsonValue !== 'object') { + return stringifyPrimitiveSafe(jsonValue) + } + if (jsonValue === null) { + return 'null' + } + value = jsonValue + } + const circularIndex = stack.indexOf(value) + if (circularIndex !== -1) { + return typeof circularValue === 'function' ? circularValue(circularIndex) : circularValue } - switch (typeof value) { - case 'string': - return strEscape(value) - case 'object': { - if (value === null) { - return 'null' - } - const circularIndex = stack.indexOf(value) - if (circularIndex !== -1) { - return typeof circularValue === 'function' ? circularValue(circularIndex) : circularValue - } - - const originalIndentation = indentation - let res = '' - let join = ',' + const originalIndentation = indentation + let res = '' + let join = ',' - if (Array.isArray(value)) { - if (value.length === 0) { - return '[]' - } - if (maximumDepth < stack.length + 1) { - return '"[Array]"' - } - stack.push(value) - if (spacer !== '') { - indentation += spacer - res += `\n${indentation}` - join = `,\n${indentation}` - } - const maximumValuesToStringify = Math.min(value.length, maximumBreadth) - let i = 0 - for (; i < maximumValuesToStringify - 1; i++) { - const tmp = stringifyArrayReplacer(i, value[i], stack, replacer, spacer, indentation) - res += tmp !== undefined ? tmp : 'null' - res += join - } - const tmp = stringifyArrayReplacer(i, value[i], stack, replacer, spacer, indentation) - res += tmp !== undefined ? tmp : 'null' - if (value.length > maximumBreadth) { - const removedKeys = value.length - maximumBreadth - res += `${join}"... ${getItemCount(removedKeys)} not stringified"` - } - if (spacer !== '') { - res += `\n${originalIndentation}` - } - stack.pop() - return `[${res}]` - } - if (maximumDepth < stack.length + 1) { - return '"[Object]"' - } - stack.push(value) - let whitespace = '' - if (spacer !== '') { - indentation += spacer - join = `,\n${indentation}` - whitespace = ' ' - } - let separator = '' - for (const key of replacer) { - const tmp = stringifyArrayReplacer(key, /** @type {Record} */ (value)[key], stack, replacer, spacer, indentation) - if (tmp !== undefined) { - res += `${separator}${strEscape(key)}:${whitespace}${tmp}` - separator = join - } - } - if (spacer !== '' && separator.length > 1) { - res = `\n${indentation}${res}\n${originalIndentation}` - } - stack.pop() - return `{${res}}` + if (Array.isArray(value)) { + if (value.length === 0) { + return '[]' } - case 'number': - return isFinite(value) ? String(value) : fail ? fail(value) : 'null' - case 'boolean': - return value === true ? 'true' : 'false' - case 'undefined': - return undefined - case 'bigint': - if (bigint) { - if (bigint === 'string') { - return `"${String(value)}"` - } - return String(value) - } - // fallthrough - default: - return fail ? fail(value) : undefined + if (maximumDepth < stack.length + 1) { + return '"[Array]"' + } + stack.push(value) + if (spacer !== '') { + indentation += spacer + res += `\n${indentation}` + join = `,\n${indentation}` + } + const maximumValuesToStringify = Math.min(value.length, maximumBreadth) + let i = 0 + for (; i < maximumValuesToStringify - 1; i++) { + const item = value[i] + const tmp = typeof item === 'object' ? stringifyArrayReplacer(i, item, stack, replacer, spacer, indentation) : stringifyPrimitiveSafe(item) + res += tmp !== undefined ? tmp : 'null' + res += join + } + const item = value[i] + const tmp = typeof item === 'object' ? stringifyArrayReplacer(i, item, stack, replacer, spacer, indentation) : stringifyPrimitiveSafe(item) + res += tmp !== undefined ? tmp : 'null' + if (value.length > maximumBreadth) { + const removedKeys = value.length - maximumBreadth + res += `${join}"... ${getItemCount(removedKeys)} not stringified"` + } + if (spacer !== '') { + res += `\n${originalIndentation}` + } + stack.pop() + return `[${res}]` } + if (maximumDepth < stack.length + 1) { + return '"[Object]"' + } + stack.push(value) + let whitespace = '' + if (spacer !== '') { + indentation += spacer + join = `,\n${indentation}` + whitespace = ' ' + } + let separator = '' + for (const key of replacer) { + const item = /** @type {Record} */ (value)[key] + const tmp = typeof item === 'object' ? stringifyArrayReplacer(key, item, stack, replacer, spacer, indentation) : stringifyPrimitiveSafe(item) + if (tmp !== undefined) { + res += `${separator}${strEscape(key)}:${whitespace}${tmp}` + separator = join + } + } + if (spacer !== '' && separator.length > 1) { + res = `\n${indentation}${res}\n${originalIndentation}` + } + stack.pop() + return `{${res}}` } /** * @param {string | number} key - * @param {unknown} value + * @param {object | null} value * @param {object[]} stack * @param {string} spacer * @param {string} indentation */ let stringifyIndent = function (key, value, stack, spacer, indentation) { - switch (typeof value) { - case 'string': - return strEscape(value) - case 'object': { - if (value === null) { - return 'null' - } - if (typeof /** @type {ToJSON} */ (value).toJSON === 'function') { - if (typeof key === 'number') key = String(key) - value = safe ? callSafe(/** @type {ToJSON} */ (value).toJSON, value, key) : /** @type {ToJSON} */ (value).toJSON(key) - // Prevent calling `toJSON` again. - if (typeof value !== 'object') { - return stringifyIndent(key, value, stack, spacer, indentation) - } - if (value === null) { - return 'null' - } - } - const circularIndex = stack.indexOf(value) - if (circularIndex !== -1) { - return typeof circularValue === 'function' ? circularValue(circularIndex) : circularValue - } - const originalIndentation = indentation + if (value === null) { + return 'null' + } + if (typeof /** @type {ToJSON} */ (value).toJSON === 'function') { + if (typeof key === 'number') key = String(key) + const jsonValue = safe ? callSafe(/** @type {ToJSON} */ (value).toJSON, value, key) : /** @type {ToJSON} */ (value).toJSON(key) + if (typeof jsonValue !== 'object') { + return stringifyPrimitiveSafe(jsonValue) + } + if (jsonValue === null) { + return 'null' + } + value = jsonValue + } + const circularIndex = stack.indexOf(value) + if (circularIndex !== -1) { + return typeof circularValue === 'function' ? circularValue(circularIndex) : circularValue + } + const originalIndentation = indentation - if (Array.isArray(value)) { - if (value.length === 0) { - return '[]' - } - if (maximumDepth < stack.length + 1) { - return '"[Array]"' - } - stack.push(value) - indentation += spacer - let res = `\n${indentation}` - const join = `,\n${indentation}` - const maximumValuesToStringify = Math.min(value.length, maximumBreadth) - let i = 0 - for (; i < maximumValuesToStringify - 1; i++) { - const tmp = stringifyIndent(i, value[i], stack, spacer, indentation) - res += tmp !== undefined ? tmp : 'null' - res += join - } - const tmp = stringifyIndent(i, value[i], stack, spacer, indentation) - res += tmp !== undefined ? tmp : 'null' - if (value.length > maximumBreadth) { - const removedKeys = value.length - maximumBreadth - res += `${join}"... ${getItemCount(removedKeys)} not stringified"` - } - res += `\n${originalIndentation}` - stack.pop() - return `[${res}]` - } + if (Array.isArray(value)) { + if (value.length === 0) { + return '[]' + } + if (maximumDepth < stack.length + 1) { + return '"[Array]"' + } + stack.push(value) + indentation += spacer + let res = `\n${indentation}` + const join = `,\n${indentation}` + const maximumValuesToStringify = Math.min(value.length, maximumBreadth) + let i = 0 + for (; i < maximumValuesToStringify - 1; i++) { + const item = value[i] + const tmp = typeof item === 'object' ? stringifyIndent(i, item, stack, spacer, indentation) : stringifyPrimitiveSafe(item) + res += tmp !== undefined ? tmp : 'null' + res += join + } + const item = value[i] + const tmp = typeof item === 'object' ? stringifyIndent(i, item, stack, spacer, indentation) : stringifyPrimitiveSafe(item) + res += tmp !== undefined ? tmp : 'null' + if (value.length > maximumBreadth) { + const removedKeys = value.length - maximumBreadth + res += `${join}"... ${getItemCount(removedKeys)} not stringified"` + } + res += `\n${originalIndentation}` + stack.pop() + return `[${res}]` + } - let keys = Object.keys(value) - const keyLength = keys.length - if (keyLength === 0) { - return '{}' - } - if (maximumDepth < stack.length + 1) { - return '"[Object]"' - } - indentation += spacer - const join = `,\n${indentation}` - let res = '' - let separator = '' - let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) - const typedArrayType = getTypedArrayType(value) - if (typedArrayType !== undefined) { - res += stringifyTypedArray(/** @type {ArrayLike} */ (value), join, maximumBreadth, bigint, fail, typedArrayType) - keys = keys.slice(/** @type {{length: number}} */ (value).length) - maximumPropertiesToStringify -= /** @type {{length: number}} */ (value).length - separator = join - } - if (deterministic) { - keys = sort(keys, comparator) - } - stack.push(value) - for (let i = 0; i < maximumPropertiesToStringify; i++) { - const key = keys[i] - const tmp = stringifyIndent(key, /** @type {Record} */ (value)[key], stack, spacer, indentation) - if (tmp !== undefined) { - res += `${separator}${strEscape(key)}: ${tmp}` - separator = join - } - } - if (keyLength > maximumBreadth) { - const removedKeys = keyLength - maximumBreadth - res += `${separator}"...": "${getItemCount(removedKeys)} not stringified"` - separator = join - } - if (separator !== '') { - res = `\n${indentation}${res}\n${originalIndentation}` - } - stack.pop() - return `{${res}}` + let keys = Object.keys(value) + const keyLength = keys.length + if (keyLength === 0) { + return '{}' + } + if (maximumDepth < stack.length + 1) { + return '"[Object]"' + } + indentation += spacer + const join = `,\n${indentation}` + let res = '' + let separator = '' + let maximumPropertiesToStringify = Math.min(keyLength, maximumBreadth) + const typedArrayType = getTypedArrayType(value) + if (typedArrayType !== undefined) { + res += stringifyTypedArray(/** @type {ArrayLike} */ (value), join, maximumBreadth, bigint, fail, typedArrayType) + keys = keys.slice(/** @type {{length: number}} */ (value).length) + maximumPropertiesToStringify -= /** @type {{length: number}} */ (value).length + separator = join + } + if (deterministic) { + keys = sort(keys, comparator) + } + stack.push(value) + for (let i = 0; i < maximumPropertiesToStringify; i++) { + const key = keys[i] + const item = /** @type {Record} */ (value)[key] + const tmp = typeof item === 'object' ? stringifyIndent(key, item, stack, spacer, indentation) : stringifyPrimitiveSafe(item) + if (tmp !== undefined) { + res += `${separator}${strEscape(key)}: ${tmp}` + separator = join } - case 'number': - return isFinite(value) ? String(value) : fail ? fail(value) : 'null' - case 'boolean': - return value === true ? 'true' : 'false' - case 'undefined': - return undefined - case 'bigint': - if (bigint) { - if (bigint === 'string') { - return `"${String(value)}"` - } - return String(value) - } - // fallthrough - default: - return fail ? fail(value) : undefined } + if (keyLength > maximumBreadth) { + const removedKeys = keyLength - maximumBreadth + res += `${separator}"...": "${getItemCount(removedKeys)} not stringified"` + separator = join + } + if (separator !== '') { + res = `\n${indentation}${res}\n${originalIndentation}` + } + stack.pop() + return `{${res}}` } - /** @param {string | number | boolean | bigint | symbol | undefined | Function} value */ - let stringifyPrimitive = function (value) { + function stringifyPrimitive (value) { switch (typeof value) { case 'string': return strEscape(value) @@ -639,11 +587,6 @@ function configure (options) { } } - /** - * @param {string | number} key - * @param {object | null} value - * @param {object[]} stack - */ let stringifyObject = function (key, value, stack) { if (value === null) { return 'null' @@ -653,7 +596,7 @@ function configure (options) { const jsonValue = safe ? callSafe(/** @type {ToJSON} */ (value).toJSON, value, key) : /** @type {ToJSON} */ (value).toJSON(key) // Prevent calling `toJSON` again if (typeof jsonValue !== 'object') { - return stringifyPrimitive(jsonValue) + return stringifyPrimitiveSafe(jsonValue) } if (jsonValue === null) { return 'null' @@ -682,14 +625,14 @@ function configure (options) { const item = value[i] const tmp = typeof item === 'object' ? stringifyObject(i, item, stack) - : stringifyPrimitive(item) + : stringifyPrimitiveSafe(item) res += tmp !== undefined ? tmp : 'null' res += ',' } const item = value[i] const tmp = typeof item === 'object' ? stringifyObject(i, item, stack) - : stringifyPrimitive(item) + : stringifyPrimitiveSafe(item) res += tmp !== undefined ? tmp : 'null' if (value.length > maximumBreadth) { const removedKeys = value.length - maximumBreadth @@ -725,7 +668,7 @@ function configure (options) { const item = /** @type {Record} */ (value)[key] const tmp = typeof item === 'object' ? stringifyObject(key, item, stack) - : stringifyPrimitive(item) + : stringifyPrimitiveSafe(item) if (tmp !== undefined) { res += `${separator}${strEscape(key)}:${tmp}` separator = ',' @@ -739,19 +682,15 @@ function configure (options) { return `{${res}}` } + let stringifyPrimitiveSafe = stringifyPrimitive if (safe) { stringifyFnReplacer = makeSafe(stringifyFnReplacer) stringifyArrayReplacer = makeSafe(stringifyArrayReplacer) stringifyIndent = makeSafe(stringifyIndent) stringifyObject = makeSafe(stringifyObject) - stringifyPrimitive = makeSafe(stringifyPrimitive) + stringifyPrimitiveSafe = makeSafe(stringifyPrimitive) } - /** - * @param {unknown} value - * @param {import('./index').Replacer} [replacer] - * @param {string | number} [space] - */ function stringify (value, replacer, space) { if (arguments.length > 1) { let spacer = '' @@ -768,16 +707,21 @@ function configure (options) { return stringifyFnReplacer('', { '': value }, [], replacer, spacer, '') } if (Array.isArray(replacer)) { - return stringifyArrayReplacer('', value, [], getUniqueReplacerSet(replacer), spacer, '') + const keys = getUniqueReplacerSet(replacer) + return typeof value === 'object' + ? stringifyArrayReplacer('', value, [], keys, spacer, '') + : stringifyPrimitiveSafe(value) } } if (spacer.length !== 0) { - return stringifyIndent('', value, [], spacer, '') + return typeof value === 'object' + ? stringifyIndent('', value, [], spacer, '') + : stringifyPrimitiveSafe(value) } } return typeof value === 'object' ? stringifyObject('', value, []) - : stringifyPrimitive(value) + : stringifyPrimitiveSafe(value) } return stringify diff --git a/test.js b/test.js index 67e156d..6d550b7 100644 --- a/test.js +++ b/test.js @@ -261,31 +261,37 @@ test('toJSON receives array keys as string', /** @param {import('tap').Test} ass assert.end() }) -test('compact primitive arrays preserve special values and fallback behavior', /** @param {import('tap').Test} assert */ (assert) => { +test('primitive values preserve their formatting in every mode', (assert) => { const values = [0, -0, 1.5, NaN, Infinity, -Infinity, '', 'text', '\n"\\', '\ud800', null, undefined, true, false, Symbol('value'), () => {}, { value: 42 }] for (const safe of [false, true]) { const serializer = stringify.configure({ safe }) - for (const value of values) { - assert.equal(serializer([value, value]), JSON.stringify([value, value])) - assert.equal(serializer([value]), JSON.stringify([value])) - const wrapped = { toJSON () { return value } } - assert.equal(serializer([wrapped, wrapped]), JSON.stringify([wrapped, wrapped])) + for (const replacer of [undefined, identityReplacer, ['value']]) { + for (const space of [undefined, 2]) { + for (const value of values) { + for (const input of [value, [value, value], [value], { value }]) { + assert.equal(serializer(input, replacer, space), JSON.stringify(input, null, space)) + } + const wrapped = { toJSON () { return value } } + assert.equal(serializer([wrapped, wrapped], replacer, space), JSON.stringify([wrapped, wrapped], null, space)) + } + assert.equal(serializer(new Array(2), replacer, space), JSON.stringify([null, null], null, space)) + } } - assert.equal(serializer(new Array(2)), '[null,null]') - } - const strictSerializer = stringify.configure({ strict: true }) - for (const value of [NaN, Infinity, -Infinity]) { - assert.throws(() => strictSerializer([value, 0]), { name: 'Error', message: `Object can not safely be stringified. Received type number (${value})` }) - assert.throws(() => strictSerializer([0, value]), { name: 'Error', message: `Object can not safely be stringified. Received type number (${value})` }) } const safeSerializer = stringify.configure({ safe: true }) const longString = 'a'.repeat(MAX_STRING_LENGTH) - assert.equal(safeSerializer([longString]), '["Error: Stringification failed (Invalid string length)"]') - assert.equal(safeSerializer([longString, 0]), '["Error: Stringification failed (Invalid string length)",0]') + const message = 'Error: Stringification failed (Invalid string length)' + for (const replacer of [undefined, identityReplacer, ['value']]) { + for (const space of [undefined, 2]) { + assert.equal(safeSerializer([longString], replacer, space), JSON.stringify([message], null, space)) + assert.equal(safeSerializer([longString, 0], replacer, space), JSON.stringify([message, 0], null, space)) + assert.equal(safeSerializer([0, longString], replacer, space), JSON.stringify([0, message], null, space)) + } + } assert.end() }) -test('compact primitive arrays retain safe strict failures and bigint options', /** @param {import('tap').Test} assert */ (assert) => { +test('primitive failures and bigint options retain their behavior in every mode', (assert) => { const unsupported = /** @type {[number | symbol | (() => void), string][]} */ ([ [NaN, 'number (NaN)'], [Infinity, 'number (Infinity)'], @@ -295,39 +301,83 @@ test('compact primitive arrays retain safe strict failures and bigint options', ]) const strict = stringify.configure({ strict: true }) const safeStrict = stringify.configure({ strict: true, safe: true }) - for (const [value, description] of unsupported) { - assert.throws(() => strict([value, 0]), /Object can not safely be stringified/) - assert.throws(() => strict([0, value]), /Object can not safely be stringified/) - const message = `"Error: Stringification failed (Object can not safely be stringified. Received type ${description})"` - assert.equal(safeStrict([value, 0]), `[${message},0]`) - assert.equal(safeStrict([0, value]), `[0,${message}]`) - } - assert.equal(safeStrict([undefined, undefined]), '[null,null]') - for (const bigint of /** @type {import('./index').StringifyOptions['bigint'][]} */ ([true, false, 'string'])) { - for (const safe of [false, true]) { - for (const strict of [false, true]) { - const serializer = stringify.configure({ bigint, safe, strict }) - if (strict && !bigint && !safe) { - assert.throws(() => serializer([1n, 0]), /Received type bigint \(1\)/) - assert.throws(() => serializer([0, 1n]), /Received type bigint \(1\)/) - } else { - const expected = bigint === 'string' - ? '"1"' - : bigint - ? '1' - : strict - ? '"Error: Stringification failed (Object can not safely be stringified. Received type bigint (1))"' - : 'null' - assert.equal(serializer([1n, 0]), `[${expected},0]`) - assert.equal(serializer([0, 1n]), `[0,${expected}]`) + const serializer = stringify.configure({ safe: true }) + const failing = { get value () { throw new Error('Oops') } } + for (const replacer of [undefined, identityReplacer, ['value']]) { + for (const space of [undefined, 2]) { + for (const [value, description] of unsupported) { + assert.throws(() => strict([value, 0], replacer, space), /Object can not safely be stringified/) + assert.throws(() => strict([0, value], replacer, space), /Object can not safely be stringified/) + const message = `Error: Stringification failed (Object can not safely be stringified. Received type ${description})` + assert.equal(safeStrict([value, 0], replacer, space), JSON.stringify([message, 0], null, space)) + assert.equal(safeStrict([0, value], replacer, space), JSON.stringify([0, message], null, space)) + } + assert.equal(safeStrict([undefined, undefined], replacer, space), JSON.stringify([null, null], null, space)) + for (const bigint of /** @type {const} */ ([true, false, 'string'])) { + for (const safe of [false, true]) { + for (const strict of [false, true]) { + const serializer = stringify.configure({ bigint, safe, strict }) + if (strict && !bigint && !safe) { + assert.throws(() => serializer([1n, 0], replacer, space), /Received type bigint \(1\)/) + assert.throws(() => serializer([0, 1n], replacer, space), /Received type bigint \(1\)/) + } else { + const expected = bigint === 'string' + ? '1' + : bigint + ? 1 + : strict + ? 'Error: Stringification failed (Object can not safely be stringified. Received type bigint (1))' + : null + assert.equal(serializer([1n, 0], replacer, space), JSON.stringify([expected, 0], null, space)) + assert.equal(serializer([0, 1n], replacer, space), JSON.stringify([0, expected], null, space)) + } + } } } + const error = 'Error: Stringification failed (Oops)' + const expected = typeof replacer === 'function' ? { value: error } : error + assert.equal(serializer([failing, 0], replacer, space), JSON.stringify([expected, 0], null, space)) + assert.equal(serializer([0, failing], replacer, space), JSON.stringify([0, expected], null, space)) } } - const serializer = stringify.configure({ safe: true }) - const failing = { get value () { throw new Error('Oops') } } - assert.equal(serializer([failing, 0]), '["Error: Stringification failed (Oops)",0]') - assert.equal(serializer([0, failing]), '[0,"Error: Stringification failed (Oops)"]') + assert.end() +}) + +test('primitive roots still read array replacer entries', (assert) => { + for (const safe of [false, true]) { + const serializer = stringify.configure({ safe }) + for (const value of [1, 'text', true, null, undefined]) { + let reads = 0 + const replacer = ['value'] + Object.defineProperty(replacer, 0, { get () { reads++; return 'value' } }) + assert.equal(serializer(value, replacer), JSON.stringify(value)) + assert.equal(reads, 1) + Object.defineProperty(replacer, 0, { get () { throw new Error('Oops') } }) + assert.throws(() => serializer(value, replacer), /Oops/) + } + } + assert.end() +}) + +test('function replacers run after toJSON and can change each input type', (assert) => { + for (const space of [undefined, 2]) { + const calls = [] + const input = [{ toJSON (key) { calls.push(`toJSON:${key}`); return 1 } }, {}, undefined] + /** @this {object} */ + function replacer (key, value) { + calls.push(`replacer:${key}`) + if (key === '0') { + assert.equal(this, input) + assert.equal(value, 1) + return { value } + } + if (key === '1') return false + if (key === '2') return 'filled' + return value + } + assert.equal(stringify(input, replacer, space), JSON.stringify([{ value: 1 }, false, 'filled'], null, space)) + assert.strictSame(calls, ['replacer:', 'toJSON:0', 'replacer:0', 'replacer:value', 'replacer:1', 'replacer:2']) + } assert.end() }) From 54f11c1dd642d8e8fb8a3bdb012600acf8d0dbbc Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Wed, 14 Oct 2026 01:46:30 +0900 Subject: [PATCH 4/7] build: update compatible development dependencies TAP 21 and newer processinfo releases cannot support the Node.js 18 test matrix. TypeScript 6 retains legacy Node resolution and works with TAP's current TypeScript loader. --- .github/workflows/test.yml | 6 +++--- .gitignore | 1 + package.json | 17 ++++++++++------- test-modules.js | 21 +++++++++++++++------ tsconfig.json | 4 +--- 5 files changed, 30 insertions(+), 19 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index af225b2..3dc1448 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,16 +15,16 @@ jobs: os: - ubuntu-latest node_version: - - 16 - 18 - 20 - 22 - 24 + - 26 name: Running tests with Node ${{ matrix.node_version }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: ${{ matrix.node_version }} - run: | diff --git a/.gitignore b/.gitignore index 820fa02..a70dd01 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ npm-debug.log yarn.lock coverage .nyc_output +.tap/ .vscode package-lock.json diff --git a/package.json b/package.json index d38c6f2..5230228 100644 --- a/package.json +++ b/package.json @@ -35,27 +35,30 @@ "engines": { "node": ">=18" }, + "overrides": { + "@tapjs/processinfo": "3.1.9" + }, "author": "Ruben Bridgewater", "license": "MIT", "typings": "index.d.ts", "devDependencies": { "@types/json-stable-stringify": "^1.2.0", - "@types/node": "^18.19.130", + "@types/node": "^26.6.5", "clone": "^2.1.2", - "esbuild": "^0.21.5", + "esbuild": "^0.28.2", "fast-json-stable-stringify": "^2.1.0", "fast-safe-stringify": "^2.1.1", "fast-stable-stringify": "^1.0.0", "faster-stable-stringify": "^1.0.0", "fastest-stable-stringify": "^2.0.2", "json-stable-stringify": "^1.3.0", - "json-stringify-deterministic": "^1.0.12", + "json-stringify-deterministic": "^1.0.18", "json-stringify-safe": "^5.0.1", - "rollup": "^3.30.0", - "bench-node": "^0.14.0", + "rollup": "^4.64.6", + "bench-node": "^0.15.0", "standard": "^17.1.2", - "tap": "^16.3.10", - "typescript": "^5.9.3" + "tap": "^20.0.3", + "typescript": "^6.0.3" }, "repository": { "type": "git", diff --git a/test-modules.js b/test-modules.js index 0956a69..16e356c 100644 --- a/test-modules.js +++ b/test-modules.js @@ -5,6 +5,7 @@ const { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync const { tmpdir } = require('node:os') const { join } = require('node:path') const { promisify } = require('node:util') +const { runInNewContext } = require('node:vm') const { build } = require('esbuild') const { rollup } = require('rollup') @@ -13,6 +14,12 @@ const { test } = require('tap') const execFileAsync = promisify(execFile) const { version } = require('./package.json') +function runNode (args, options) { + // TAP's process tracker adds a deprecated loader to ESM consumers on Node 26. + const nodeArgs = Number(process.versions.node.split('.')[0]) >= 26 ? ['--disable-warning=DEP0205', ...args] : args + return execFileAsync(process.execPath, nodeArgs, options) +} + /** * @param {string} directory * @param {string} packageVersion @@ -61,8 +68,8 @@ async function testModuleEntryPoints (assert) { } const results = await Promise.all([ - execFileAsync(process.execPath, ['modules.cjs', 'require-first'], { cwd: directory }), - execFileAsync(process.execPath, ['modules.cjs', 'import-first'], { cwd: directory }) + runNode(['modules.cjs', 'require-first'], { cwd: directory }), + runNode(['modules.cjs', 'import-first'], { cwd: directory }) ]) for (const { stdout, stderr } of results) { assert.equal(stdout, '', 'no output') @@ -114,11 +121,12 @@ async function testBundlers (assert) { await bundle.close() } const esmSource = readFileSync(join(directory, 'consumer.mjs'), 'utf8') + runInNewContext(readFileSync(join(directory, 'esbuild.cjs'), 'utf8')) writeFileSync(join(directory, 'rollup-consumer.mjs'), esmSource.replace('safe-stable-stringify', './rollup.mjs')) const results = await Promise.all([ - execFileAsync(process.execPath, [join(directory, 'esbuild.mjs')]), - execFileAsync(process.execPath, [join(directory, 'esbuild.cjs')]), - execFileAsync(process.execPath, [join(directory, 'rollup-consumer.mjs')]) + runNode([join(directory, 'esbuild.mjs')]), + runNode([join(directory, 'esbuild.cjs')]), + runNode([join(directory, 'rollup-consumer.mjs')]) ]) for (const { stdout, stderr } of results) { assert.equal(stdout, '', 'bundle produces no output') @@ -137,12 +145,13 @@ async function testTypes (assert) { ['CommonJS', 'Node', 'types.cts', 'types.mts'], ['ESNext', 'Bundler', 'types.mts'] ]) { - tasks.push(execFileAsync(process.execPath, [ + tasks.push(runNode([ require.resolve('typescript/bin/tsc'), '--noEmit', '--strict', '--module', module, '--moduleResolution', resolution, + '--ignoreDeprecations', '6.0', '--target', 'ES2020', ...files ], { cwd: directory })) diff --git a/tsconfig.json b/tsconfig.json index be233c6..e7f9528 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,12 +1,10 @@ { "compilerOptions": { - "baseUrl": "./", "allowJs": true, "checkJs": true, "sourceMap": false, "declaration": false, "noEmit": true, - "downlevelIteration": false, "experimentalDecorators": false, "moduleResolution": "nodenext", "importHelpers": false, @@ -20,5 +18,5 @@ ] }, "include": ["**/*.js", "**/*.ts", "**/*.cts", "**/*.mts", "wrapper.cjs", "esm/wrapper.mjs"], - "exclude": ["compare.js", "benchmark.js", "./coverage"] + "exclude": ["compare.js", "benchmark.js", "benchmark-utils.js", "./coverage", "./.tap"] } From 10cd15c8db743f8e9d7939a2036b60ade40879c6 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Wed, 14 Oct 2026 01:47:42 +0900 Subject: [PATCH 5/7] perf: use native string escaping on newer Node.js releases Against main, the comparison fixture increases from 68,305 to 76,684 ops/sec on Node.js 26.3.1 (V8 14.6.202.34-node.20), with 50,000 warmup calls and seven two-second trials excluding the fastest and slowest results. Some deep-object benchmark cases measure up to 3.1% slower. Shared primitive handling reduces published source size by 5.2% while preventing the implementations from diverging. --- benchmark-utils.js | 34 +++++++++++++ benchmark.js | 104 +++++++-------------------------------- compare.js | 16 +++--- index.js | 46 +++++++++-------- readme.md | 120 +++++++++++++++++++++++++++++---------------- test.js | 71 +++++++++++++++++++++++---- 6 files changed, 226 insertions(+), 165 deletions(-) create mode 100644 benchmark-utils.js diff --git a/benchmark-utils.js b/benchmark-utils.js new file mode 100644 index 0000000..bcb5735 --- /dev/null +++ b/benchmark-utils.js @@ -0,0 +1,34 @@ +'use strict' + +const assert = require('node:assert/strict') +const { cpus } = require('node:os') + +const { Suite } = require('bench-node') + +module.exports = function runBenchmarks (cases, seconds = 2) { + let length = 0 + const suite = new Suite({ + repeatSuite: 7, + reporter (results) { + console.log(`${cpus()[0].model}; Node ${process.version}; V8 ${process.versions.v8}`) + for (const result of results) { + const runs = result.opsSecPerRun + const mean = runs.reduce((total, value) => total + value, 0) / runs.length + const deviation = Math.sqrt(runs.reduce((total, value) => total + (value - mean) ** 2, 0) / (runs.length - 1)) + const sorted = [...runs].sort((left, right) => left - right).slice(1, -1) + const rate = sorted.reduce((total, value) => total + value, 0) / sorted.length + console.log(`${result.name.padEnd(34)} ${Math.round(rate).toLocaleString('en-US')} ops/sec (7 runs, CV ${(deviation / mean * 100).toFixed(2)}%)`) + assert.ok(deviation / mean < 0.03, `${result.name}: excessive variance; rates: ${runs.map(Math.round).join(', ')}; rerun on a quiet host`) + } + console.log('Output checksum:', length) + } + }) + for (const [name, fn] of cases) { + const expected = fn() + assert.equal(typeof expected, 'string') + for (let iteration = 0; iteration < 50000; iteration++) length ^= fn().length + assert.equal(fn(), expected) + suite.add(name, { minTime: 0.05, maxTime: seconds }, () => { length ^= fn().length }) + } + return suite.run() +} diff --git a/benchmark.js b/benchmark.js index f60a2b1..b76eb78 100644 --- a/benchmark.js +++ b/benchmark.js @@ -1,7 +1,6 @@ 'use strict' -const Benchmark = require('bench-node') -const suite = new Benchmark.Suite() +const runBenchmarks = require('./benchmark-utils') const stringify = require('.').configure({ deterministic: true }) const array = Array.from({ length: 10 }, (_, i) => i) @@ -21,88 +20,19 @@ deepCirc.deep.deep.circ = deepCirc deepCirc.deep.circ = deepCirc deepCirc.array = array -// One arg "simple" -suite.add('simple: simple object', function () { - stringify(obj) -}) -suite.add('simple: circular ', function () { - stringify(circ) -}) -suite.add('simple: deep ', function () { - stringify(deep) -}) -suite.add('simple: deep circular', function () { - stringify(deepCirc) -}) - -// Two args "replacer" -suite.add('\nreplacer: simple object', function () { - stringify(obj, (_, v) => v) -}) -suite.add('replacer: circular ', function () { - stringify(circ, (_, v) => v) -}) -suite.add('replacer: deep ', function () { - stringify(deep, (_, v) => v) -}) -suite.add('replacer: deep circular', function () { - stringify(deepCirc, (_, v) => v) -}) - -// Two args "array" -suite.add('\narray: simple object', function () { - stringify(obj, ['array']) -}) -suite.add('array: circular ', function () { - stringify(circ, ['array']) -}) -suite.add('array: deep ', function () { - stringify(deep, ['array']) -}) -suite.add('array: deep circular', function () { - stringify(deepCirc, ['array']) -}) - -// Three args "full replacer" -suite.add('\nfull replacer: simple object', function () { - stringify(obj, (_, v) => v, 2) -}) -suite.add('full replacer: circular ', function () { - stringify(circ, (_, v) => v, 2) -}) -suite.add('full replacer: deep ', function () { - stringify(deep, (_, v) => v, 2) -}) -suite.add('full replacer: deep circular', function () { - stringify(deepCirc, (_, v) => v, 2) -}) - -// Three args "full array" -suite.add('\nfull array: simple object', function () { - stringify(obj, ['array'], 2) -}) -suite.add('full array: circular ', function () { - stringify(circ, ['array'], 2) -}) -suite.add('full array: deep ', function () { - stringify(deep, ['array'], 2) -}) -suite.add('full array: deep circular', function () { - stringify(deepCirc, ['array'], 2) -}) - -// Three args "indentation only" -suite.add('\nindentation: simple object', function () { - stringify(obj, null, 2) -}) -suite.add('indentation: circular ', function () { - stringify(circ, null, 2) -}) -suite.add('indentation: deep ', function () { - stringify(deep, null, 2) -}) -suite.add('indentation: deep circular', function () { - stringify(deepCirc, null, 2) -}) - -suite.run() +const cases = [] +const replacer = (_, value) => value +for (const [mode, args] of [ + ['simple', []], + ['replacer', [replacer]], + ['array', [['array']]], + ['full replacer', [replacer, 2]], + ['full array', [['array'], 2]], + ['indentation', [null, 2]] +]) { + for (const [name, value] of [['simple object', obj], ['circular', circ], ['deep', deep], ['deep circular', deepCirc]]) { + cases.push([`${mode}: ${name}`, () => stringify(value, ...args)]) + } +} + +runBenchmarks(cases) diff --git a/compare.js b/compare.js index 6e7404f..5bfea60 100644 --- a/compare.js +++ b/compare.js @@ -1,7 +1,8 @@ 'use strict' -const Benchmark = require('bench-node') -const suite = new Benchmark.Suite() +const assert = require('node:assert/strict') + +const runBenchmarks = require('./benchmark-utils') const testData = require('./test.json') const stringifyPackages = { @@ -16,7 +17,9 @@ const stringifyPackages = { 'safe-stable-stringify': require('.') } -for (const name in stringifyPackages) { +const cases = [] +const expected = require('.')(testData) +for (const name of Object.keys(stringifyPackages)) { let fn if (typeof stringifyPackages[name] === 'function') { fn = stringifyPackages[name] @@ -26,9 +29,8 @@ for (const name in stringifyPackages) { fn = require(name) } - suite.add(name, function () { - fn(testData) - }) + assert.equal(fn(testData), expected) + cases.push([name, () => fn(testData)]) } -suite.run() +runBenchmarks(cases, 2) diff --git a/index.js b/index.js index 97d993b..a0a3c7c 100644 --- a/index.js +++ b/index.js @@ -6,12 +6,13 @@ const { hasOwnProperty } = Object.prototype // eslint-disable-next-line no-control-regex const strEscapeSequencesRegExp = /[\u0000-\u001f\u0022\u005c\ud800-\udfff]/ +const strEscapeLength = typeof process === 'object' && Number(process?.versions?.node?.split('.')[0]) >= 22 ? 128 : 5000 // Escape C0 control characters, double quotes, the backslash and every code // unit with a numeric value in the inclusive range 0xD800 to 0xDFFF. function strEscape (str) { - // Some magic numbers that worked out fine while benchmarking with v8 8.0 - if (str.length < 5000 && !strEscapeSequencesRegExp.test(str)) { + // Native escaping is faster for longer strings on Node.js 22 and later. + if (str.length < strEscapeLength && !strEscapeSequencesRegExp.test(str)) { return `"${str}"` } return JSON.stringify(str) @@ -31,6 +32,9 @@ function sort (array, comparator) { for (let i = 1; i < array.length; i++) { const currentValue = array[i] if (array[i - 1] <= currentValue) continue + if (i === 1 && array.length > 32 && currentValue > array[array.length - 1]) { + return array.sort() + } let position = i do { array[position] = array[position - 1] @@ -365,7 +369,7 @@ function configure (options) { stack.pop() return `{${res}}` } - return stringifyPrimitive(value) + return stringifyFnPrimitive(value) } /** @@ -384,7 +388,7 @@ function configure (options) { if (typeof key === 'number') key = String(key) const jsonValue = safe ? callSafe(/** @type {ToJSON} */ (value).toJSON, value, key) : /** @type {ToJSON} */ (value).toJSON(key) if (typeof jsonValue !== 'object') { - return stringifyPrimitiveSafe(jsonValue) + return stringifyPrimitive(jsonValue) } if (jsonValue === null) { return 'null' @@ -417,12 +421,12 @@ function configure (options) { let i = 0 for (; i < maximumValuesToStringify - 1; i++) { const item = value[i] - const tmp = typeof item === 'object' ? stringifyArrayReplacer(i, item, stack, replacer, spacer, indentation) : stringifyPrimitiveSafe(item) + const tmp = typeof item === 'object' ? stringifyArrayReplacer(i, item, stack, replacer, spacer, indentation) : stringifyPrimitive(item) res += tmp !== undefined ? tmp : 'null' res += join } const item = value[i] - const tmp = typeof item === 'object' ? stringifyArrayReplacer(i, item, stack, replacer, spacer, indentation) : stringifyPrimitiveSafe(item) + const tmp = typeof item === 'object' ? stringifyArrayReplacer(i, item, stack, replacer, spacer, indentation) : stringifyPrimitive(item) res += tmp !== undefined ? tmp : 'null' if (value.length > maximumBreadth) { const removedKeys = value.length - maximumBreadth @@ -447,7 +451,7 @@ function configure (options) { let separator = '' for (const key of replacer) { const item = /** @type {Record} */ (value)[key] - const tmp = typeof item === 'object' ? stringifyArrayReplacer(key, item, stack, replacer, spacer, indentation) : stringifyPrimitiveSafe(item) + const tmp = typeof item === 'object' ? stringifyArrayReplacer(key, item, stack, replacer, spacer, indentation) : stringifyPrimitive(item) if (tmp !== undefined) { res += `${separator}${strEscape(key)}:${whitespace}${tmp}` separator = join @@ -475,7 +479,7 @@ function configure (options) { if (typeof key === 'number') key = String(key) const jsonValue = safe ? callSafe(/** @type {ToJSON} */ (value).toJSON, value, key) : /** @type {ToJSON} */ (value).toJSON(key) if (typeof jsonValue !== 'object') { - return stringifyPrimitiveSafe(jsonValue) + return stringifyPrimitive(jsonValue) } if (jsonValue === null) { return 'null' @@ -503,12 +507,12 @@ function configure (options) { let i = 0 for (; i < maximumValuesToStringify - 1; i++) { const item = value[i] - const tmp = typeof item === 'object' ? stringifyIndent(i, item, stack, spacer, indentation) : stringifyPrimitiveSafe(item) + const tmp = typeof item === 'object' ? stringifyIndent(i, item, stack, spacer, indentation) : stringifyPrimitive(item) res += tmp !== undefined ? tmp : 'null' res += join } const item = value[i] - const tmp = typeof item === 'object' ? stringifyIndent(i, item, stack, spacer, indentation) : stringifyPrimitiveSafe(item) + const tmp = typeof item === 'object' ? stringifyIndent(i, item, stack, spacer, indentation) : stringifyPrimitive(item) res += tmp !== undefined ? tmp : 'null' if (value.length > maximumBreadth) { const removedKeys = value.length - maximumBreadth @@ -546,7 +550,7 @@ function configure (options) { for (let i = 0; i < maximumPropertiesToStringify; i++) { const key = keys[i] const item = /** @type {Record} */ (value)[key] - const tmp = typeof item === 'object' ? stringifyIndent(key, item, stack, spacer, indentation) : stringifyPrimitiveSafe(item) + const tmp = typeof item === 'object' ? stringifyIndent(key, item, stack, spacer, indentation) : stringifyPrimitive(item) if (tmp !== undefined) { res += `${separator}${strEscape(key)}: ${tmp}` separator = join @@ -564,7 +568,7 @@ function configure (options) { return `{${res}}` } - function stringifyPrimitive (value) { + let stringifyPrimitive = function (value) { switch (typeof value) { case 'string': return strEscape(value) @@ -596,7 +600,7 @@ function configure (options) { const jsonValue = safe ? callSafe(/** @type {ToJSON} */ (value).toJSON, value, key) : /** @type {ToJSON} */ (value).toJSON(key) // Prevent calling `toJSON` again if (typeof jsonValue !== 'object') { - return stringifyPrimitiveSafe(jsonValue) + return stringifyPrimitive(jsonValue) } if (jsonValue === null) { return 'null' @@ -625,14 +629,14 @@ function configure (options) { const item = value[i] const tmp = typeof item === 'object' ? stringifyObject(i, item, stack) - : stringifyPrimitiveSafe(item) + : stringifyPrimitive(item) res += tmp !== undefined ? tmp : 'null' res += ',' } const item = value[i] const tmp = typeof item === 'object' ? stringifyObject(i, item, stack) - : stringifyPrimitiveSafe(item) + : stringifyPrimitive(item) res += tmp !== undefined ? tmp : 'null' if (value.length > maximumBreadth) { const removedKeys = value.length - maximumBreadth @@ -668,7 +672,7 @@ function configure (options) { const item = /** @type {Record} */ (value)[key] const tmp = typeof item === 'object' ? stringifyObject(key, item, stack) - : stringifyPrimitiveSafe(item) + : stringifyPrimitive(item) if (tmp !== undefined) { res += `${separator}${strEscape(key)}:${tmp}` separator = ',' @@ -682,13 +686,13 @@ function configure (options) { return `{${res}}` } - let stringifyPrimitiveSafe = stringifyPrimitive + const stringifyFnPrimitive = stringifyPrimitive if (safe) { stringifyFnReplacer = makeSafe(stringifyFnReplacer) stringifyArrayReplacer = makeSafe(stringifyArrayReplacer) stringifyIndent = makeSafe(stringifyIndent) stringifyObject = makeSafe(stringifyObject) - stringifyPrimitiveSafe = makeSafe(stringifyPrimitive) + stringifyPrimitive = makeSafe(stringifyPrimitive) } function stringify (value, replacer, space) { @@ -710,18 +714,18 @@ function configure (options) { const keys = getUniqueReplacerSet(replacer) return typeof value === 'object' ? stringifyArrayReplacer('', value, [], keys, spacer, '') - : stringifyPrimitiveSafe(value) + : stringifyPrimitive(value) } } if (spacer.length !== 0) { return typeof value === 'object' ? stringifyIndent('', value, [], spacer, '') - : stringifyPrimitiveSafe(value) + : stringifyPrimitive(value) } } return typeof value === 'object' ? stringifyObject('', value, []) - : stringifyPrimitiveSafe(value) + : stringifyPrimitive(value) } return stringify diff --git a/readme.md b/readme.md index 0a1119e..207caa4 100644 --- a/readme.md +++ b/readme.md @@ -124,55 +124,93 @@ throwOnCircular(circular); TypeError (configurable). 1. _Boxed primitives_ (e.g., `Number(5)`) are not unboxed and are handled as regular object. +1. _Array replacers_ use the array iterator and accept only primitive strings + and numbers. Boxed entries are ignored. -Those are the only differences to `JSON.stringify()`. This is a side effect free -variant and [`toJSON`][], [`replacer`][] and the [`spacer`][] work the same as -with `JSON.stringify()`. +Array replacer entries are processed for every input, including primitive roots. +Getters and iterator errors still propagate. Object [`toJSON`][] methods, +function [`replacer`][] callbacks, and the [`spacer`][] are supported. ## Performance / Benchmarks -Currently this is by far the fastest known stable (deterministic) stringify -implementation. This is especially important for big objects and TypedArrays. - -(Dell Precision 5540, i7-9850H CPU @ 2.60GHz, Node.js 16.11.1) - -```md -simple: simple object x 3,463,894 ops/sec ±0.44% (98 runs sampled) -simple: circular x 1,236,007 ops/sec ±0.46% (99 runs sampled) -simple: deep x 18,942 ops/sec ±0.41% (93 runs sampled) -simple: deep circular x 18,690 ops/sec ±0.72% (96 runs sampled) - -replacer: simple object x 2,664,940 ops/sec ±0.31% (98 runs sampled) -replacer: circular x 1,015,981 ops/sec ±0.09% (99 runs sampled) -replacer: deep x 17,328 ops/sec ±0.38% (97 runs sampled) -replacer: deep circular x 17,071 ops/sec ±0.21% (98 runs sampled) - -array: simple object x 3,869,608 ops/sec ±0.22% (98 runs sampled) -array: circular x 3,853,943 ops/sec ±0.45% (96 runs sampled) -array: deep x 3,563,227 ops/sec ±0.20% (100 runs sampled) -array: deep circular x 3,286,475 ops/sec ±0.07% (100 runs sampled) - -indentation: simple object x 2,183,162 ops/sec ±0.66% (97 runs sampled) -indentation: circular x 872,538 ops/sec ±0.57% (98 runs sampled) -indentation: deep x 16,795 ops/sec ±0.48% (93 runs sampled) -indentation: deep circular x 16,443 ops/sec ±0.40% (97 runs sampled) +Measured on an Apple M4 Max with macOS 26.6.2, Node.js 26.3.1 +(V8 14.6.202.34-node.20), and bench-node 0.15.0. + +Each case has 50,000 warmup calls and seven two-second trials. Results use the +mean of the middle five trials. The coefficient of variation (CV) uses all seven +trials. Runs with a CV of 3% or more fail. Every call consumes the output length. +The local measurements use foreground scheduling with `taskpolicy -a -t 0 -l 0`. + +Results from `npm run benchmark`: + +```text +Apple M4 Max; Node v26.3.1; V8 14.6.202.34-node.20 +simple: simple object 4,426,655 ops/sec (7 runs, CV 1.52%) +simple: circular 1,922,646 ops/sec (7 runs, CV 0.89%) +simple: deep 32,526 ops/sec (7 runs, CV 1.04%) +simple: deep circular 32,078 ops/sec (7 runs, CV 1.11%) +replacer: simple object 2,916,534 ops/sec (7 runs, CV 2.19%) +replacer: circular 1,318,356 ops/sec (7 runs, CV 0.59%) +replacer: deep 29,330 ops/sec (7 runs, CV 0.94%) +replacer: deep circular 28,905 ops/sec (7 runs, CV 1.22%) +array: simple object 4,546,824 ops/sec (7 runs, CV 0.61%) +array: circular 4,527,065 ops/sec (7 runs, CV 0.59%) +array: deep 4,515,301 ops/sec (7 runs, CV 0.63%) +array: deep circular 4,531,107 ops/sec (7 runs, CV 0.24%) +full replacer: simple object 2,716,387 ops/sec (7 runs, CV 0.51%) +full replacer: circular 1,248,523 ops/sec (7 runs, CV 0.42%) +full replacer: deep 28,407 ops/sec (7 runs, CV 1.30%) +full replacer: deep circular 28,199 ops/sec (7 runs, CV 1.58%) +full array: simple object 3,929,918 ops/sec (7 runs, CV 0.88%) +full array: circular 3,925,238 ops/sec (7 runs, CV 1.10%) +full array: deep 3,922,968 ops/sec (7 runs, CV 1.03%) +full array: deep circular 3,938,466 ops/sec (7 runs, CV 0.47%) +indentation: simple object 4,141,014 ops/sec (7 runs, CV 0.60%) +indentation: circular 1,832,771 ops/sec (7 runs, CV 0.78%) +indentation: deep 32,191 ops/sec (7 runs, CV 1.42%) +indentation: deep circular 32,207 ops/sec (7 runs, CV 1.97%) +Output checksum: 28135 ``` -Comparing `safe-stable-stringify` with known alternatives: - -```md -fast-json-stable-stringify x 18,765 ops/sec ±0.71% (94 runs sampled) -json-stable-stringify x 13,870 ops/sec ±0.72% (94 runs sampled) -fast-stable-stringify x 21,343 ops/sec ±0.33% (95 runs sampled) -faster-stable-stringify x 17,707 ops/sec ±0.44% (97 runs sampled) -json-stringify-deterministic x 11,208 ops/sec ±0.57% (98 runs sampled) -fast-safe-stringify x 21,460 ops/sec ±0.75% (99 runs sampled) -this x 30,367 ops/sec ±0.39% (96 runs sampled) - -The fastest is this +Results from `npm run compare`: + +The `fast-safe-stringify` comparison uses its stable implementation. + +```text +Apple M4 Max; Node v26.3.1; V8 14.6.202.34-node.20 +fastest-stable-stringify 74,695 ops/sec (7 runs, CV 0.71%) +fast-json-stable-stringify 67,631 ops/sec (7 runs, CV 0.85%) +json-stable-stringify 51,077 ops/sec (7 runs, CV 0.54%) +fast-stable-stringify 73,214 ops/sec (7 runs, CV 0.52%) +faster-stable-stringify 64,848 ops/sec (7 runs, CV 1.16%) +json-stringify-deterministic 45,526 ops/sec (7 runs, CV 0.93%) +fast-safe-stringify 57,828 ops/sec (7 runs, CV 0.58%) +safe-stable-stringify 76,684 ops/sec (7 runs, CV 0.45%) +Output checksum: 5423 ``` -The `fast-safe-stringify` comparison uses the modules stable implementation. +The following comparison uses main (`c05878f`) and this implementation with the +same dependencies, fixtures, and benchmark harness: + +| Implementation | Before, ops/sec | After, ops/sec | +| --- | ---: | ---: | +| fastest-stable-stringify | 73,969 | 74,695 | +| fast-json-stable-stringify | 67,489 | 67,631 | +| json-stable-stringify | 51,367 | 51,077 | +| fast-stable-stringify | 73,933 | 73,214 | +| faster-stable-stringify | 65,571 | 64,848 | +| json-stringify-deterministic | 45,792 | 45,526 | +| fast-safe-stringify | 58,717 | 57,828 | +| safe-stable-stringify | 68,305 | 76,684 | + +The comparison fixture improves by 12.3%. A second fresh process +pair measures 67,996 → 73,143 ops/sec +(7.6%). These results describe this fixture and runtime. They do not +establish a fastest implementation for every input or feature set. + +Some deep-object cases are slower. The largest decline in the full benchmark +is 3.1%. CV measures variation within one process. Results can also vary +between fresh processes. ## Acknowledgements diff --git a/test.js b/test.js index 6d550b7..a37ceb3 100644 --- a/test.js +++ b/test.js @@ -4,6 +4,57 @@ const clone = require('clone') const { constants: { MAX_STRING_LENGTH } } = require('buffer') const { runInNewContext } = require('vm') +test('escaping preserves output across runtime and string length boundaries', (assert) => { + const paths = [require.resolve('./index.js'), require.resolve('.')] + const cached = paths.map(path => require.cache[path]) + const nativeProcess = globalThis.process + const exportSymbol = Symbol.for('safe-stable-stringify@3.0.0') + const descriptor = /** @type {PropertyDescriptor} */ (Object.getOwnPropertyDescriptor(globalThis, exportSymbol)) + for (const process of [undefined, null, {}, { versions: {} }, { versions: { node: '21.0.0' } }, { versions: { node: '22.0.0' } }]) { + let serializer + try { + // @ts-expect-error + globalThis.process = process + for (const path of paths) delete require.cache[path] + delete globalThis[exportSymbol] + serializer = require('.') + } finally { + globalThis.process = nativeProcess + Object.defineProperty(globalThis, exportSymbol, descriptor) + for (let i = 0; i < paths.length; i++) require.cache[paths[i]] = cached[i] + } + for (const length of [0, 31, 32, 127, 128, 4999, 5000]) { + const original = JSON.stringify + let calls = 0 + try { + JSON.stringify = value => { + calls++ + return original(value) + } + const value = 'x'.repeat(length) + const output = serializer(value) + const threshold = process?.versions?.node === '22.0.0' ? 128 : 5000 + assert.equal(calls, length >= threshold ? 1 : 0, 'native escaping follows the runtime threshold') + assert.equal(output, original(value)) + } finally { + JSON.stringify = original + } + for (const ending of ['', '"', '\\', '\n', '\ud800', '\udfff', '\ud83d\ude00', '\u0100']) { + const value = 'x'.repeat(Math.max(0, length - ending.length)) + ending + for (const input of [value, [value], { [value]: value }]) { + for (const replacer of [null, (_, value) => value, [value]]) { + for (const space of [undefined, 2]) { + // @ts-expect-error + assert.equal(serializer(input, replacer, space), JSON.stringify(input, replacer, space)) + } + } + } + } + } + } + assert.end() +}) + for (const name of ['Float16Array', 'Float32Array', 'Float64Array']) { const TypedArray = globalThis[/** @type {'Float16Array' | 'Float32Array' | 'Float64Array'} */ (name)] test(`${name} serializes non-finite values`, { skip: TypedArray === undefined }, @@ -343,17 +394,19 @@ test('primitive failures and bigint options retain their behavior in every mode' assert.end() }) -test('primitive roots still read array replacer entries', (assert) => { +test('array replacer entries are processed for every root type', (assert) => { for (const safe of [false, true]) { const serializer = stringify.configure({ safe }) - for (const value of [1, 'text', true, null, undefined]) { - let reads = 0 - const replacer = ['value'] - Object.defineProperty(replacer, 0, { get () { reads++; return 'value' } }) - assert.equal(serializer(value, replacer), JSON.stringify(value)) - assert.equal(reads, 1) - Object.defineProperty(replacer, 0, { get () { throw new Error('Oops') } }) - assert.throws(() => serializer(value, replacer), /Oops/) + for (const value of [{ value: 1 }, [1], null, 1, 1n, 'text', true, undefined, Symbol('test'), () => {}]) { + for (const space of [undefined, 2]) { + let reads = 0 + const replacer = ['value'] + Object.defineProperty(replacer, 0, { get () { reads++; return 'value' } }) + assert.equal(serializer(value, replacer, space), value === 1n ? '1' : JSON.stringify(value, null, space)) + assert.equal(reads, 1) + Object.defineProperty(replacer, 0, { get () { throw new Error('Oops') } }) + assert.throws(() => serializer(value, replacer, space), /Oops/) + } } } assert.end() From 1528a6b8262dc273c548bd80c31c5fd567e84eab Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Wed, 14 Oct 2026 13:46:09 +0900 Subject: [PATCH 6/7] test: avoid repeated maximum-length string scans The primitive error matrix encodes a maximum-length string 18 times. On Node.js 18.20.8 (V8 10.2.154.26-node.39), that takes 22.1 seconds. Slower CI runners reach the 30-second timeout before the suite completes. The mode matrix uses a controlled RangeError at the native escaping boundary. Existing tests still exercise the real string limit. The full Node.js 18 suite takes 4.1 seconds after a clean install. --- readme.md | 47 ++++++----------------------------------------- test.js | 27 ++++++++++++++++++++------- 2 files changed, 26 insertions(+), 48 deletions(-) diff --git a/readme.md b/readme.md index 207caa4..c11fd43 100644 --- a/readme.md +++ b/readme.md @@ -124,24 +124,14 @@ throwOnCircular(circular); TypeError (configurable). 1. _Boxed primitives_ (e.g., `Number(5)`) are not unboxed and are handled as regular object. -1. _Array replacers_ use the array iterator and accept only primitive strings - and numbers. Boxed entries are ignored. -Array replacer entries are processed for every input, including primitive roots. -Getters and iterator errors still propagate. Object [`toJSON`][] methods, -function [`replacer`][] callbacks, and the [`spacer`][] are supported. +Those are the only differences to `JSON.stringify()`. This is a side effect free +variant and [`toJSON`][], [`replacer`][] and the [`spacer`][] work the same as +with `JSON.stringify()`. ## Performance / Benchmarks -Measured on an Apple M4 Max with macOS 26.6.2, Node.js 26.3.1 -(V8 14.6.202.34-node.20), and bench-node 0.15.0. - -Each case has 50,000 warmup calls and seven two-second trials. Results use the -mean of the middle five trials. The coefficient of variation (CV) uses all seven -trials. Runs with a CV of 3% or more fail. Every call consumes the output length. -The local measurements use foreground scheduling with `taskpolicy -a -t 0 -l 0`. - -Results from `npm run benchmark`: +(Apple M4 Max, macOS 26.6.2, Node.js 26.3.1, V8 14.6.202.34-node.20) ```text Apple M4 Max; Node v26.3.1; V8 14.6.202.34-node.20 @@ -169,12 +159,9 @@ indentation: simple object 4,141,014 ops/sec (7 runs, CV 0.60%) indentation: circular 1,832,771 ops/sec (7 runs, CV 0.78%) indentation: deep 32,191 ops/sec (7 runs, CV 1.42%) indentation: deep circular 32,207 ops/sec (7 runs, CV 1.97%) -Output checksum: 28135 ``` -Results from `npm run compare`: - -The `fast-safe-stringify` comparison uses its stable implementation. +Comparing `safe-stable-stringify` with known alternatives: ```text Apple M4 Max; Node v26.3.1; V8 14.6.202.34-node.20 @@ -186,31 +173,9 @@ faster-stable-stringify 64,848 ops/sec (7 runs, CV 1.16%) json-stringify-deterministic 45,526 ops/sec (7 runs, CV 0.93%) fast-safe-stringify 57,828 ops/sec (7 runs, CV 0.58%) safe-stable-stringify 76,684 ops/sec (7 runs, CV 0.45%) -Output checksum: 5423 ``` -The following comparison uses main (`c05878f`) and this implementation with the -same dependencies, fixtures, and benchmark harness: - -| Implementation | Before, ops/sec | After, ops/sec | -| --- | ---: | ---: | -| fastest-stable-stringify | 73,969 | 74,695 | -| fast-json-stable-stringify | 67,489 | 67,631 | -| json-stable-stringify | 51,367 | 51,077 | -| fast-stable-stringify | 73,933 | 73,214 | -| faster-stable-stringify | 65,571 | 64,848 | -| json-stringify-deterministic | 45,792 | 45,526 | -| fast-safe-stringify | 58,717 | 57,828 | -| safe-stable-stringify | 68,305 | 76,684 | - -The comparison fixture improves by 12.3%. A second fresh process -pair measures 67,996 → 73,143 ops/sec -(7.6%). These results describe this fixture and runtime. They do not -establish a fastest implementation for every input or feature set. - -Some deep-object cases are slower. The largest decline in the full benchmark -is 3.1%. CV measures variation within one process. Results can also vary -between fresh processes. +The `fast-safe-stringify` comparison uses its stable implementation. ## Acknowledgements diff --git a/test.js b/test.js index a37ceb3..a7a6bb0 100644 --- a/test.js +++ b/test.js @@ -329,15 +329,28 @@ test('primitive values preserve their formatting in every mode', (assert) => { } } } - const safeSerializer = stringify.configure({ safe: true }) - const longString = 'a'.repeat(MAX_STRING_LENGTH) + assert.end() +}) + +test('primitive escaping failures preserve adjacent elements in every mode', (assert) => { + const serializer = stringify.configure({ safe: true }) + const value = '\n' + const nativeStringify = JSON.stringify const message = 'Error: Stringification failed (Invalid string length)' - for (const replacer of [undefined, identityReplacer, ['value']]) { - for (const space of [undefined, 2]) { - assert.equal(safeSerializer([longString], replacer, space), JSON.stringify([message], null, space)) - assert.equal(safeSerializer([longString, 0], replacer, space), JSON.stringify([message, 0], null, space)) - assert.equal(safeSerializer([0, longString], replacer, space), JSON.stringify([0, message], null, space)) + JSON.stringify = (input, replacer, space) => { + if (input === value) throw new RangeError('Invalid string length') + return nativeStringify(input, replacer, space) + } + try { + for (const replacer of [undefined, identityReplacer, ['value']]) { + for (const space of [undefined, 2]) { + assert.equal(serializer([value], replacer, space), nativeStringify([message], null, space)) + assert.equal(serializer([value, 0, '\t'], replacer, space), nativeStringify([message, 0, '\t'], null, space)) + assert.equal(serializer(['\t', 0, value], replacer, space), nativeStringify(['\t', 0, message], null, space)) + } } + } finally { + JSON.stringify = nativeStringify } assert.end() }) From e3a01d1dc6c652750cbcc43de51db79f26f70c60 Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Thu, 15 Oct 2026 16:44:43 +0900 Subject: [PATCH 7/7] perf(stringify): skip unused primitive replacer work Array replacers cannot change a primitive root's output. Skip their preprocessing, including getter and iterator reads, for these inputs. A numeric root with an eight-entry array replacer takes 8.4 ns instead of 97.4 ns on Node 26.3.1 (V8 14.6.202.34-node.20). This averages two fresh processes with seven alternating trials after 50,000 warmup calls, discarding the fastest and slowest trials. --- compare.js | 75 ++++++++++++++++++++++++++++++++++------- index.js | 25 +++++++------- readme.md | 97 ++++++++++++++++++++++++++++++++++++------------------ test.js | 65 +++++++++++++++++++++++------------- 4 files changed, 184 insertions(+), 78 deletions(-) diff --git a/compare.js b/compare.js index 5bfea60..a0af3e7 100644 --- a/compare.js +++ b/compare.js @@ -17,20 +17,71 @@ const stringifyPackages = { 'safe-stable-stringify': require('.') } -const cases = [] -const expected = require('.')(testData) for (const name of Object.keys(stringifyPackages)) { - let fn - if (typeof stringifyPackages[name] === 'function') { - fn = stringifyPackages[name] - } else if (typeof stringifyPackages[name] === 'string') { - fn = require(name)[stringifyPackages[name]] - } else { - fn = require(name) + if (typeof stringifyPackages[name] === 'string') { + stringifyPackages[name] = require(name)[stringifyPackages[name]] + } else if (stringifyPackages[name] === true) { + stringifyPackages[name] = require(name) } +} + +const telemetry = { + device: { id: 'weather-station-17', firmware: '3.2.1', online: true }, + recordedAt: new Date('2026-01-15T12:30:00.000Z'), + payload: Uint8Array.from({ length: 128 }, (_, index) => (index * 17) % 256), + histogram: Uint32Array.from({ length: 32 }, (_, index) => index * index * 13), + temperatures: Float32Array.from({ length: 24 }, (_, index) => 18 + index / 8), + pressure: new Float64Array([1013.25, 1013.5, NaN, 1013.75]), + signature: Buffer.from('e7c93f142ae4907e8fae3cb589e0f87a', 'hex'), + location: { latitude: 52.52, longitude: 13.405 }, + alarms: [false, true, false] +} +const counters = { + ...telemetry, + sequence: 18446744073709551001n, + timestampNs: 1768480200000000000n, + totals: new BigUint64Array([9007199254740993n, 18446744073709551001n]), + deltas: new BigInt64Array([-9007199254740993n, 33n, 0n]) +} +const bigintReplacer = (_, value) => typeof value === 'bigint' ? String(value) : value +const bigintOptions = { replacer: bigintReplacer } +const deterministicOptions = { replacer: bigintReplacer, cycles: true } +const bigintPackages = { + 'json-stable-stringify': value => stringifyPackages['json-stable-stringify'](value, bigintOptions), + 'json-stringify-deterministic': value => stringifyPackages['json-stringify-deterministic'](value, deterministicOptions), + 'fast-safe-stringify': value => stringifyPackages['fast-safe-stringify'](value, bigintReplacer), + 'safe-stable-stringify': require('.').configure({ bigint: 'string' }) +} + +async function compare (title, value, implementations) { + console.log(title) + const expected = JSON.parse(JSON.stringify(value, bigintReplacer)) + const cases = [] + for (const name of Object.keys(stringifyPackages)) { + const fn = implementations[name] + if (fn === undefined) { + let output + try { + output = stringifyPackages[name](value) + } catch (error) { + assert.ok(error instanceof TypeError) + } + if (output !== undefined) assert.notDeepEqual(JSON.parse(output), expected) + console.log(`${name}: BigInt is not supported`) + continue + } + const output = fn(value) + assert.deepEqual(JSON.parse(output), expected) + if (value === testData) assert.equal(output, require('.')(value)) + cases.push([name, () => fn(value)]) + } + await runBenchmarks(cases, 2) +} - assert.equal(fn(testData), expected) - cases.push([name, () => fn(testData)]) +async function main () { + await compare('JSON-compatible object', testData, stringifyPackages) + await compare('Typed arrays, Date and Buffer', telemetry, stringifyPackages) + await compare('Typed arrays, Date, Buffer and BigInt (decimal strings)', counters, bigintPackages) } -runBenchmarks(cases, 2) +main() diff --git a/index.js b/index.js index a0a3c7c..f33f482 100644 --- a/index.js +++ b/index.js @@ -6,7 +6,8 @@ const { hasOwnProperty } = Object.prototype // eslint-disable-next-line no-control-regex const strEscapeSequencesRegExp = /[\u0000-\u001f\u0022\u005c\ud800-\udfff]/ -const strEscapeLength = typeof process === 'object' && Number(process?.versions?.node?.split('.')[0]) >= 22 ? 128 : 5000 +const nodeVersion = typeof process === 'object' ? process?.versions?.node : undefined +const strEscapeLength = Number(nodeVersion?.slice(0, nodeVersion.indexOf('.'))) >= 22 ? 128 : 5000 // Escape C0 control characters, double quotes, the backslash and every code // unit with a numeric value in the inclusive range 0xD800 to 0xDFFF. @@ -29,12 +30,18 @@ function sort (array, comparator) { return array.sort(comparator) } let moves = 0 - for (let i = 1; i < array.length; i++) { - const currentValue = array[i] - if (array[i - 1] <= currentValue) continue - if (i === 1 && array.length > 32 && currentValue > array[array.length - 1]) { + if (array.length > 1 && array[0] > array[1]) { + const currentValue = array[1] + if (array.length > 32 && currentValue > array[array.length - 1]) { return array.sort() } + array[1] = array[0] + array[0] = currentValue + moves = 1 + } + for (let i = 2; i < array.length; i++) { + const currentValue = array[i] + if (array[i - 1] <= currentValue) continue let position = i do { array[position] = array[position - 1] @@ -580,10 +587,7 @@ function configure (options) { return undefined case 'bigint': if (bigint) { - if (bigint === 'string') { - return `"${String(value)}"` - } - return String(value) + return bigint === 'string' ? `"${value}"` : String(value) } // fallthrough default: @@ -711,9 +715,8 @@ function configure (options) { return stringifyFnReplacer('', { '': value }, [], replacer, spacer, '') } if (Array.isArray(replacer)) { - const keys = getUniqueReplacerSet(replacer) return typeof value === 'object' - ? stringifyArrayReplacer('', value, [], keys, spacer, '') + ? stringifyArrayReplacer('', value, [], getUniqueReplacerSet(replacer), spacer, '') : stringifyPrimitive(value) } } diff --git a/readme.md b/readme.md index c11fd43..21472ef 100644 --- a/readme.md +++ b/readme.md @@ -135,48 +135,81 @@ with `JSON.stringify()`. ```text Apple M4 Max; Node v26.3.1; V8 14.6.202.34-node.20 -simple: simple object 4,426,655 ops/sec (7 runs, CV 1.52%) -simple: circular 1,922,646 ops/sec (7 runs, CV 0.89%) -simple: deep 32,526 ops/sec (7 runs, CV 1.04%) -simple: deep circular 32,078 ops/sec (7 runs, CV 1.11%) -replacer: simple object 2,916,534 ops/sec (7 runs, CV 2.19%) -replacer: circular 1,318,356 ops/sec (7 runs, CV 0.59%) -replacer: deep 29,330 ops/sec (7 runs, CV 0.94%) -replacer: deep circular 28,905 ops/sec (7 runs, CV 1.22%) -array: simple object 4,546,824 ops/sec (7 runs, CV 0.61%) -array: circular 4,527,065 ops/sec (7 runs, CV 0.59%) -array: deep 4,515,301 ops/sec (7 runs, CV 0.63%) -array: deep circular 4,531,107 ops/sec (7 runs, CV 0.24%) -full replacer: simple object 2,716,387 ops/sec (7 runs, CV 0.51%) -full replacer: circular 1,248,523 ops/sec (7 runs, CV 0.42%) -full replacer: deep 28,407 ops/sec (7 runs, CV 1.30%) -full replacer: deep circular 28,199 ops/sec (7 runs, CV 1.58%) -full array: simple object 3,929,918 ops/sec (7 runs, CV 0.88%) -full array: circular 3,925,238 ops/sec (7 runs, CV 1.10%) -full array: deep 3,922,968 ops/sec (7 runs, CV 1.03%) -full array: deep circular 3,938,466 ops/sec (7 runs, CV 0.47%) -indentation: simple object 4,141,014 ops/sec (7 runs, CV 0.60%) -indentation: circular 1,832,771 ops/sec (7 runs, CV 0.78%) -indentation: deep 32,191 ops/sec (7 runs, CV 1.42%) -indentation: deep circular 32,207 ops/sec (7 runs, CV 1.97%) +simple: simple object 4,280,054 ops/sec (7 runs, CV 1.21%) +simple: circular 1,867,764 ops/sec (7 runs, CV 0.90%) +simple: deep 31,783 ops/sec (7 runs, CV 1.85%) +simple: deep circular 31,095 ops/sec (7 runs, CV 1.07%) +replacer: simple object 2,847,302 ops/sec (7 runs, CV 1.13%) +replacer: circular 1,264,209 ops/sec (7 runs, CV 0.47%) +replacer: deep 29,244 ops/sec (7 runs, CV 0.77%) +replacer: deep circular 28,500 ops/sec (7 runs, CV 0.76%) +array: simple object 4,433,315 ops/sec (7 runs, CV 1.20%) +array: circular 4,400,683 ops/sec (7 runs, CV 0.82%) +array: deep 4,410,383 ops/sec (7 runs, CV 0.50%) +array: deep circular 4,435,182 ops/sec (7 runs, CV 0.52%) +full replacer: simple object 2,675,568 ops/sec (7 runs, CV 0.81%) +full replacer: circular 1,226,915 ops/sec (7 runs, CV 0.75%) +full replacer: deep 27,826 ops/sec (7 runs, CV 0.99%) +full replacer: deep circular 27,239 ops/sec (7 runs, CV 1.96%) +full array: simple object 3,781,217 ops/sec (7 runs, CV 0.95%) +full array: circular 3,771,576 ops/sec (7 runs, CV 1.36%) +full array: deep 3,781,851 ops/sec (7 runs, CV 1.60%) +full array: deep circular 3,835,795 ops/sec (7 runs, CV 0.93%) +indentation: simple object 3,971,352 ops/sec (7 runs, CV 1.33%) +indentation: circular 1,810,550 ops/sec (7 runs, CV 1.06%) +indentation: deep 31,776 ops/sec (7 runs, CV 1.20%) +indentation: deep circular 31,396 ops/sec (7 runs, CV 1.41%) ``` Comparing `safe-stable-stringify` with known alternatives: ```text Apple M4 Max; Node v26.3.1; V8 14.6.202.34-node.20 -fastest-stable-stringify 74,695 ops/sec (7 runs, CV 0.71%) -fast-json-stable-stringify 67,631 ops/sec (7 runs, CV 0.85%) -json-stable-stringify 51,077 ops/sec (7 runs, CV 0.54%) -fast-stable-stringify 73,214 ops/sec (7 runs, CV 0.52%) -faster-stable-stringify 64,848 ops/sec (7 runs, CV 1.16%) -json-stringify-deterministic 45,526 ops/sec (7 runs, CV 0.93%) -fast-safe-stringify 57,828 ops/sec (7 runs, CV 0.58%) -safe-stable-stringify 76,684 ops/sec (7 runs, CV 0.45%) +fastest-stable-stringify 73,060 ops/sec (7 runs, CV 0.30%) +fast-json-stable-stringify 66,953 ops/sec (7 runs, CV 0.87%) +json-stable-stringify 50,656 ops/sec (7 runs, CV 0.48%) +fast-stable-stringify 72,668 ops/sec (7 runs, CV 0.94%) +faster-stable-stringify 64,879 ops/sec (7 runs, CV 0.80%) +json-stringify-deterministic 45,529 ops/sec (7 runs, CV 0.72%) +fast-safe-stringify 59,107 ops/sec (7 runs, CV 0.55%) +safe-stable-stringify 75,892 ops/sec (7 runs, CV 0.48%) ``` The `fast-safe-stringify` comparison uses its stable implementation. +### Typed arrays + +Serializing a telemetry record with binary sensor data, a Date, and a Buffer: + +```text +Apple M4 Max; Node v26.3.1; V8 14.6.202.34-node.20 +fastest-stable-stringify 69,502 ops/sec (7 runs, CV 0.73%) +fast-json-stable-stringify 63,260 ops/sec (7 runs, CV 0.31%) +json-stable-stringify 46,499 ops/sec (7 runs, CV 0.75%) +fast-stable-stringify 116,091 ops/sec (7 runs, CV 0.68%) +faster-stable-stringify 62,078 ops/sec (7 runs, CV 0.65%) +json-stringify-deterministic 33,567 ops/sec (7 runs, CV 0.57%) +fast-safe-stringify 56,618 ops/sec (7 runs, CV 0.57%) +safe-stable-stringify 126,114 ops/sec (7 runs, CV 0.48%) +``` + +### Typed arrays and BigInt + +The same record with 64-bit counters. BigInt values serialize as decimal strings +through each library's public options. + +```text +fastest-stable-stringify: BigInt is not supported +fast-json-stable-stringify: BigInt is not supported +fast-stable-stringify: BigInt is not supported +faster-stable-stringify: BigInt is not supported +Apple M4 Max; Node v26.3.1; V8 14.6.202.34-node.20 +json-stable-stringify 39,849 ops/sec (7 runs, CV 1.23%) +json-stringify-deterministic 43,579 ops/sec (7 runs, CV 0.46%) +fast-safe-stringify 42,583 ops/sec (7 runs, CV 0.85%) +safe-stable-stringify 113,681 ops/sec (7 runs, CV 0.87%) +``` + ## Acknowledgements Sponsored by [MaibornWolff](https://www.maibornwolff.de/) and [nearForm](http://nearform.com) diff --git a/test.js b/test.js index a7a6bb0..8242f5e 100644 --- a/test.js +++ b/test.js @@ -10,7 +10,7 @@ test('escaping preserves output across runtime and string length boundaries', (a const nativeProcess = globalThis.process const exportSymbol = Symbol.for('safe-stable-stringify@3.0.0') const descriptor = /** @type {PropertyDescriptor} */ (Object.getOwnPropertyDescriptor(globalThis, exportSymbol)) - for (const process of [undefined, null, {}, { versions: {} }, { versions: { node: '21.0.0' } }, { versions: { node: '22.0.0' } }]) { + for (const process of [undefined, null, {}, { versions: {} }, { versions: { node: '21.0.0' } }, { versions: { node: '22.0.0' } }, { versions: { node: '26.3.1' } }]) { let serializer try { // @ts-expect-error @@ -33,7 +33,7 @@ test('escaping preserves output across runtime and string length boundaries', (a } const value = 'x'.repeat(length) const output = serializer(value) - const threshold = process?.versions?.node === '22.0.0' ? 128 : 5000 + const threshold = Number.parseInt(process?.versions?.node ?? '') >= 22 ? 128 : 5000 assert.equal(calls, length >= threshold ? 1 : 0, 'native escaping follows the runtime threshold') assert.equal(output, original(value)) } finally { @@ -407,24 +407,6 @@ test('primitive failures and bigint options retain their behavior in every mode' assert.end() }) -test('array replacer entries are processed for every root type', (assert) => { - for (const safe of [false, true]) { - const serializer = stringify.configure({ safe }) - for (const value of [{ value: 1 }, [1], null, 1, 1n, 'text', true, undefined, Symbol('test'), () => {}]) { - for (const space of [undefined, 2]) { - let reads = 0 - const replacer = ['value'] - Object.defineProperty(replacer, 0, { get () { reads++; return 'value' } }) - assert.equal(serializer(value, replacer, space), value === 1n ? '1' : JSON.stringify(value, null, space)) - assert.equal(reads, 1) - Object.defineProperty(replacer, 0, { get () { throw new Error('Oops') } }) - assert.throws(() => serializer(value, replacer, space), /Oops/) - } - } - } - assert.end() -}) - test('function replacers run after toJSON and can change each input type', (assert) => { for (const space of [undefined, 2]) { const calls = [] @@ -1798,11 +1780,12 @@ test('deterministic default sorting', function (assert) { }) test('deterministic sorting preserves keys across insertion and native sort boundaries', /** @param {import('tap').Test} assert */ (assert) => { - for (const size of [8, 9, 31, 32, 33, 34, 199, 200, 201]) { + for (const size of [0, 1, 2, 3, 8, 9, 31, 32, 33, 34, 199, 200, 201]) { const keys = Array.from({ length: size }, /** @param {unknown} value @param {number} index */ (value, index) => `key${String(index).padStart(3, '0')}`) const adjacent = keys.slice() - ;[adjacent[1], adjacent[2]] = [adjacent[2], adjacent[1]] - for (const order of [keys, keys.slice().reverse(), adjacent, [keys[size - 1], ...keys.slice(0, -1)]]) { + if (size > 1) [adjacent[0], adjacent[1]] = [adjacent[1], adjacent[0]] + const rotated = size > 0 ? [keys[size - 1], ...keys.slice(0, -1)] : [] + for (const order of [keys, keys.slice().reverse(), adjacent, rotated]) { const value = {} const expected = {} for (const key of order) value[key] = key @@ -1817,6 +1800,42 @@ test('deterministic sorting preserves keys across insertion and native sort boun assert.end() }) +test('array replacers are only processed in the object branch', (assert) => { + for (const options of /** @type {const} */ ([{}, { safe: true }, { strict: true }, { safe: true, strict: true }, { bigint: false }, { bigint: 'string' }])) { + const serialize = stringify.configure(options) + for (const property of ['0', Symbol.iterator]) { + let reads = 0 + const replacer = ['value'] + const error = new Error('replacer read') + Object.defineProperty(replacer, property, { + get () { + reads++ + throw error + } + }) + for (const value of ['value', 0, NaN, Infinity, true, false, undefined, 42n, Symbol('value'), identityReplacer]) { + let expected + try { + expected = serialize(value) + } catch (error) { + assert.throws(() => serialize(value, replacer), /** @type {Error} */ (error)) + continue + } + for (const space of [undefined, 2, '\t']) { + assert.equal(serialize(value, replacer, space), expected) + } + } + assert.equal(reads, 0, 'primitive roots do not read the array replacer') + for (const value of [null, {}, { value: 1 }, []]) { + assert.throws(() => serialize(value, replacer), error) + } + assert.equal(reads, 4, 'the object branch still processes the array replacer') + } + assert.equal(serialize({ value: 1, other: 2 }, ['value', 'value']), '{"value":1}') + } + assert.end() +}) + test('deterministic custom sorting', function (assert) { // Descending const serializer = stringify.configure({ deterministic: (a, b) => b.localeCompare(a) })