From 166719000cf3aaf203f14abd098ea884e4cb5526 Mon Sep 17 00:00:00 2001 From: Tony133 Date: Tue, 20 Oct 2026 15:23:45 +0200 Subject: [PATCH] perf: keep Array.prototype on the primitive-array copy so V8 stays on the JSON.stringify fast path --- index.js | 7 +++- lib/serializer.js | 5 ++- test/primitive-array-prototype.test.js | 51 ++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 test/primitive-array-prototype.test.js diff --git a/index.js b/index.js index 8d4e7710..d365375e 100644 --- a/index.js +++ b/index.js @@ -13,7 +13,7 @@ const mergeSchemas = require('./lib/merge-schemas') const DEFAULT_MAX_DEPTH = 100 const NAMED_FRAGMENT_REF = /^#[a-z_][-\w._]*$/i -// Minimum array lengths at which copying into a null-prototype array and +// Minimum array lengths at which copying into a plain array and // calling native JSON.stringify beats the generated per-item loop. Measured // against main with tinybench (alternating rounds, median ops/s, output // consumed with Buffer.byteLength) on Node 20, 22, 24 and 26 x64. @@ -1055,7 +1055,10 @@ function buildArray (context, location, input) { // Copying small arrays costs more than the existing loop. if (typeof arrayLength === 'number' && arrayLength >= ${fastPath.minLength} && Number.isInteger(arrayLength) && arrayLength <= 0xffffffff) { const copy = new Array(arrayLength) - Object.setPrototypeOf(copy, null) + // V8's side-effect-free JSON.stringify fast path (V8 >= 13.8) requires + // the standard Array.prototype; a null prototype halves its throughput. + // Only detach the prototype when a toJSON hook would otherwise be called. + if (Array.prototype.toJSON !== undefined) Object.setPrototypeOf(copy, null) for (let i = 0; i < arrayLength; i++) { const v = obj[i] if (!(${fastPath.condition})) { diff --git a/lib/serializer.js b/lib/serializer.js index 78fbbcff..0cb7f0e9 100644 --- a/lib/serializer.js +++ b/lib/serializer.js @@ -140,7 +140,10 @@ module.exports = class Serializer { // matching buildSingleTypeSerializer without rereading getters. asPlainStringArray (arr, len = arr.length) { const copy = new Array(len) - Object.setPrototypeOf(copy, null) + // V8's side-effect-free JSON.stringify fast path (V8 >= 13.8) requires the + // standard Array.prototype; a null prototype halves its throughput. Only + // detach the prototype when a toJSON hook would otherwise be called. + if (Array.prototype.toJSON !== undefined) Object.setPrototypeOf(copy, null) for (let i = 0; i < len; i++) { const value = arr[i] if (typeof value !== 'string') { diff --git a/test/primitive-array-prototype.test.js b/test/primitive-array-prototype.test.js new file mode 100644 index 00000000..649bd0eb --- /dev/null +++ b/test/primitive-array-prototype.test.js @@ -0,0 +1,51 @@ +'use strict' + +const { test } = require('node:test') +const build = require('..') + +// The copy-then-JSON.stringify fast path for primitive arrays must produce the +// same output whether or not Array.prototype.toJSON has been patched, and must +// never invoke such a hook. + +const numbers = Array.from({ length: 1000 }, (_, i) => i * 1.37) +const integers = Array.from({ length: 1000 }, (_, i) => i) +const strings = Array.from({ length: 1000 }, (_, i) => 'item-' + i) + +const cases = [ + ['number', { type: 'array', items: { type: 'number' } }, numbers, {}], + ['integer', { type: 'array', items: { type: 'integer' } }, integers, {}], + ['string', { type: 'array', items: { type: 'string' } }, strings, { largeArraySize: 100 }] +] + +for (const [name, schema, data, options] of cases) { + test(`${name} array fast path with pristine Array.prototype`, t => { + t.plan(2) + t.assert.equal(Array.prototype.toJSON, undefined) + const stringify = build(schema, options) + t.assert.equal(stringify(data), JSON.stringify(data)) + }) + + test(`${name} array fast path ignores a patched Array.prototype.toJSON`, t => { + t.plan(2) + const expected = JSON.stringify(data) + let called = 0 + // eslint-disable-next-line no-extend-native + Array.prototype.toJSON = function () { called++; return 'hooked' } + t.after(() => { delete Array.prototype.toJSON }) + const stringify = build(schema, options) + t.assert.equal(stringify(data), expected) + t.assert.equal(called, 0) + }) + + test(`${name} array fast path resumes per-item coercion after a miss, with patched toJSON`, t => { + t.plan(1) + const mixed = data.slice() + mixed[500] = name === 'string' ? 42 : '7' + const stringify = build(schema, options) + const expected = stringify(mixed) + // eslint-disable-next-line no-extend-native + Array.prototype.toJSON = function () { return 'hooked' } + t.after(() => { delete Array.prototype.toJSON }) + t.assert.equal(build(schema, options)(mixed), expected) + }) +}