Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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})) {
Expand Down
5 changes: 4 additions & 1 deletion lib/serializer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
51 changes: 51 additions & 0 deletions test/primitive-array-prototype.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
}
Loading