From 4d782b8aa68a9ddf5b673207d262bbae66f4e28b Mon Sep 17 00:00:00 2001 From: Ruben Bridgewater Date: Tue, 13 Oct 2026 13:08:57 +0900 Subject: [PATCH] perf(sort): bound insertion sort work Disordered objects with up to 200 keys can spend most of their serialization time shifting keys. A shuffled 200-key object uses 16.6 us instead of 45.6 us on Node 26.3.1 (V8 14.6.202.34-node.20), a 64% CPU reduction. Measurements use 50,000 warmup calls and seven alternating trials, with the fastest and slowest removed. Nearly ordered keys retain the insertion path. Drive-by fix: Mark intentional invalid-option assertions for TypeScript. --- index.js | 12 ++++++++++-- test.js | 27 +++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/index.js b/index.js index f58a2b4..2e93f40 100644 --- a/index.js +++ b/index.js @@ -32,20 +32,28 @@ function strEscape (str) { return JSON.stringify(str) } +/** + * @param {string[]} array + * @param {(a: string, b: string) => number} [comparator] + */ function sort (array, comparator) { // Insertion sort is very efficient for small input sizes, but it has a bad // worst case complexity. Thus, use native array sort for bigger values. if (array.length > 2e2 || 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 let position = i - while (position !== 0 && array[position - 1] > currentValue) { + do { array[position] = array[position - 1] position-- - } + } while (position !== 0 && array[position - 1] > currentValue) array[position] = currentValue + moves += i - position + if (moves > array.length && moves > 32) return array.sort() } return array } diff --git a/test.js b/test.js index 7da2985..3295c8e 100644 --- a/test.js +++ b/test.js @@ -800,8 +800,10 @@ test('bigint option as string', function (assert) { assert.end() }) -test('bigint option invalid values', function (assert) { +test('bigint option invalid values', /** @param {import('tap').Test} assert */ function (assert) { + // @ts-expect-error assert.throws(() => stringify.configure({ bigint: 'nope' }), /bigint/) + // @ts-expect-error assert.throws(() => stringify.configure({ bigint: 1 }), /bigint/) assert.end() }) @@ -1591,6 +1593,26 @@ test('deterministic default sorting', function (assert) { assert.end() }) +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]) { + 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)]]) { + const value = {} + const expected = {} + for (const key of order) value[key] = key + for (const key of keys) expected[key] = key + for (const replacer of [undefined, identityReplacer]) { + for (const space of [undefined, 2]) { + assert.equal(stringify({ nested: value }, replacer, space), JSON.stringify({ nested: expected }, null, space)) + } + } + } + } + assert.end() +}) + test('deterministic custom sorting', function (assert) { // Descending const serializer = stringify.configure({ deterministic: (a, b) => b.localeCompare(a) }) @@ -1613,7 +1635,8 @@ test('safe option defaults and explicit false', function (assert) { assert.end() }) -test('safe option must be boolean', function (assert) { +test('safe option must be boolean', /** @param {import('tap').Test} assert */ function (assert) { + // @ts-expect-error assert.throws(() => stringify.configure({ safe: 'yes' }), /safe/) assert.end() })