fast-json-stringify 7.1.5 → 7.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -625,13 +625,10 @@ In order to improve that the user can set the `largeArrayMechanism` and
625
625
 
626
626
  - `default` - This option keeps the full feature set of this lib: every element
627
627
  is still coerced to the type declared in `items`, and type errors are still
628
- thrown. Arrays whose `items` schema is a plain `{ type: 'string' }` (no
629
- `format`, `nullable`, `const`, or combinator keyword) are serialized at or
630
- above `largeArraySize` by copying the elements and calling the native
631
- `JSON.stringify` on the copy, which is several times faster than appending one
632
- element at a time. If an element is not a string, that element and the ones
633
- after it go through the regular per-element coercion, so the output is the
634
- same as before.
628
+ thrown. Arrays of plain primitives (`string`, `number`, `integer`) use a
629
+ copy-then-`JSON.stringify` strategy with their own length cutoffs, see
630
+ [Primitive arrays](#primitive-arrays) below; `largeArraySize` does not affect
631
+ them.
635
632
  - `json-stringify` - This option will remove support for schema validation
636
633
  within **large arrays** completely. By doing so the overhead previously
637
634
  mentioned is nulled, greatly improving execution time. Mind there's no change
@@ -646,27 +643,31 @@ integer-like values, such as:
646
643
  - `'2e4'` - _note this will be converted to `2`, not `20000`_
647
644
  - `1.5` - _note this will be converted to `1`_
648
645
 
649
- ##### Number and integer arrays
646
+ <a name="primitive-arrays"></a>
647
+ ##### Primitive arrays
650
648
 
651
649
  With `largeArrayMechanism: 'default'`, arrays whose `items` schema is a plain
652
- `{ type: 'number' }` or `{ type: 'integer' }` (no `nullable`, `const`, `$ref`,
653
- or combinator keyword) use the same copy-then-`JSON.stringify` strategy, but
654
- with their own length cutoffs that do not depend on `largeArraySize`. The
655
- cutoffs are the array lengths above which the copy beats the per-element loop,
656
- and they depend on the V8 version:
650
+ `{ type: 'string' }`, `{ type: 'number' }` or `{ type: 'integer' }` (no
651
+ `format`, `nullable`, `const`, or combinator keyword; a `$ref` to a plain
652
+ string schema also qualifies, a `$ref` for `number`/`integer` does not) are
653
+ serialized by copying the elements into a fresh array and calling the native
654
+ `JSON.stringify` on the copy, which is several times faster than appending one
655
+ element at a time. This happens at or above a length cutoff that does not
656
+ depend on `largeArraySize`. The cutoffs are the array lengths above which the
657
+ copy beats the per-element loop, and they depend on the V8 version:
657
658
 
658
- | Node.js | `number` | `integer` |
659
- |---|---:|---:|
660
- | 22 and later | 128 elements | 512 elements |
661
- | 20 | 300000 elements | 300000 elements |
659
+ | Node.js | `string` | `number` | `integer` |
660
+ |---|---:|---:|---:|
661
+ | 22 and later | 8 elements | 128 elements | 512 elements |
662
+ | 20 | 128 elements | 300000 elements | 300000 elements |
662
663
 
663
664
  Below the cutoff the array is serialized element by element as before. Above
664
- it, the elements are copied until one is found that is not already a `number`
665
- (or not already an integer, for `integer`); that element and the ones after it
666
- go through the regular per-element coercion and validation (`null` still
667
- becomes `0`, `rounding` still applies, `NaN` still throws). The output is
668
- byte-identical either way. Arrays of `boolean`, `null`, objects, or any other
669
- `items` schema are not affected.
665
+ it, the elements are copied until one is found that is not already of the
666
+ declared type (not a string, not a `number`, or not an integer for `integer`);
667
+ that element and the ones after it go through the regular per-element coercion
668
+ and validation (`null` still becomes `""` or `0`, `rounding` still applies,
669
+ `NaN` still throws). The output is byte-identical either way. Arrays of
670
+ `boolean`, `null`, objects, or any other `items` schema are not affected.
670
671
 
671
672
  In [standalone mode](#standalone) the cutoffs are baked into the generated
672
673
  code, so the generated module inherits the cutoffs of the Node.js version that
package/index.js CHANGED
@@ -28,12 +28,21 @@ const NAMED_FRAGMENT_REF = /^#[a-z_][-\w._]*$/i
28
28
  // Node 20: JSON.stringify only beats the loop for every element kind from
29
29
  // ~3e5 (1e5 is machine-dependent), so both kinds wait for 3e5 there.
30
30
  //
31
+ // Strings: the generated loop escapes each element in JS (asString), which
32
+ // is far slower than the native serializer, so the copy wins almost
33
+ // immediately. Node 22+: from 2 elements for every string length and content
34
+ // (2-6x faster from 8 elements on). Node 20: from 32 elements for short
35
+ // strings, but only from 128 for long (~120 chars) strings without escapes,
36
+ // so Node 20 waits for 128. Measured on Node 20, 22, 24 and 26 (arm64) with
37
+ // 8, 40 and 120 char strings, with and without characters to escape.
38
+ //
31
39
  // Booleans never win and keep the loop. The array lookup instead of a
32
40
  // ternary keeps both branches covered on every Node.
33
41
  // TODO: drop the Node 20 tier (and this version check) once Node 20 is no
34
42
  // longer supported.
35
43
  const nodeMajor = Number.parseInt(process.versions.node, 10)
36
- const PRIMITIVE_FAST_PATH_MIN_LENGTH = [{ integer: 3e5, number: 3e5 }, { integer: 512, number: 128 }][+(nodeMajor >= 22)]
44
+ const PRIMITIVE_FAST_PATH_MIN_LENGTH = [{ integer: 3e5, number: 3e5, string: 128 }, { integer: 512, number: 128, string: 8 }][+(nodeMajor >= 22)]
45
+ const STRING_ARRAY_FAST_PATH_MIN_LENGTH = PRIMITIVE_FAST_PATH_MIN_LENGTH.string
37
46
  const INTEGER_ARRAY_FAST_PATH = { condition: 'Number.isInteger(v)', minLength: PRIMITIVE_FAST_PATH_MIN_LENGTH.integer }
38
47
  const NUMBER_ARRAY_FAST_PATH = { condition: 'typeof v === \'number\' && v === v', minLength: PRIMITIVE_FAST_PATH_MIN_LENGTH.number }
39
48
 
@@ -1095,7 +1104,7 @@ function buildArray (context, location, input) {
1095
1104
  } else if (plainStringFastPath) {
1096
1105
  // Copy strings once; the helper resumes ordered coercion on the first miss.
1097
1106
  functionCode += `
1098
- if (typeof arrayLength === 'number' && arrayLength >= ${context.largeArraySize} && Number.isInteger(arrayLength) && arrayLength <= 0xffffffff) {
1107
+ if (typeof arrayLength === 'number' && arrayLength >= ${STRING_ARRAY_FAST_PATH_MIN_LENGTH} && Number.isInteger(arrayLength) && arrayLength <= 0xffffffff) {
1099
1108
  return asPlainStringArray(obj, arrayLength)
1100
1109
  }
1101
1110
  `
@@ -1221,7 +1230,7 @@ function buildArray (context, location, input) {
1221
1230
  } else if (plainStringFastPath) {
1222
1231
  inlinedCode += `
1223
1232
  let fjsPlain_${objVar}
1224
- if (typeof arrayLength_${objVar} === 'number' && arrayLength_${objVar} >= ${context.largeArraySize} && Number.isInteger(arrayLength_${objVar}) && arrayLength_${objVar} <= 0xffffffff) fjsPlain_${objVar} = asPlainStringArray(${objVar}, arrayLength_${objVar})
1233
+ if (typeof arrayLength_${objVar} === 'number' && arrayLength_${objVar} >= ${STRING_ARRAY_FAST_PATH_MIN_LENGTH} && Number.isInteger(arrayLength_${objVar}) && arrayLength_${objVar} <= 0xffffffff) fjsPlain_${objVar} = asPlainStringArray(${objVar}, arrayLength_${objVar})
1225
1234
  if (fjsPlain_${objVar} !== undefined) json += fjsPlain_${objVar}
1226
1235
  else {
1227
1236
  `
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fast-json-stringify",
3
- "version": "7.1.5",
3
+ "version": "7.2.0",
4
4
  "description": "Stringify your JSON at max speed",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
@@ -0,0 +1,62 @@
1
+ 'use strict'
2
+
3
+ const { test } = require('node:test')
4
+ const build = require('..')
5
+
6
+ // Plain string arrays use their own copy-then-JSON.stringify cutoff, independent
7
+ // of largeArraySize (which only drives largeArrayMechanism: 'json-stringify').
8
+ const nodeMajor = Number.parseInt(process.versions.node, 10)
9
+ const CUTOFF = nodeMajor >= 22 ? 8 : 128
10
+
11
+ const schema = { type: 'array', items: { type: 'string' } }
12
+ const nested = { type: 'object', properties: { tags: schema } }
13
+ const strings = (n) => Array.from({ length: n }, (_, i) => 'item-' + i)
14
+
15
+ test('string array cutoff does not depend on largeArraySize', t => {
16
+ t.plan(2)
17
+ const code = build(schema, { mode: 'debug', largeArraySize: 5000 }).code
18
+ t.assert.ok(code.includes(`arrayLength >= ${CUTOFF} `), 'uses the string cutoff')
19
+ t.assert.ok(!code.includes('arrayLength >= 5000'), 'ignores largeArraySize')
20
+ })
21
+
22
+ test('string arrays at and above the cutoff are byte-identical to JSON.stringify', t => {
23
+ const stringify = build(schema)
24
+ const lengths = [CUTOFF - 1, CUTOFF, CUTOFF + 1, CUTOFF * 10]
25
+ t.plan(lengths.length)
26
+ for (const n of lengths) {
27
+ t.assert.equal(stringify(strings(n)), JSON.stringify(strings(n)))
28
+ }
29
+ })
30
+
31
+ test('nested string arrays use the same cutoff', t => {
32
+ t.plan(2)
33
+ const code = build(nested, { mode: 'debug', largeArraySize: 5000 }).code
34
+ t.assert.ok(code.includes(`>= ${CUTOFF} `))
35
+ t.assert.equal(build(nested)({ tags: strings(CUTOFF) }), JSON.stringify({ tags: strings(CUTOFF) }))
36
+ })
37
+
38
+ test('escapes and non-string elements above the cutoff keep per-element semantics', t => {
39
+ t.plan(4)
40
+ const stringify = build(schema)
41
+ const withEscapes = strings(CUTOFF).map((s, i) => i % 2 ? s + '"\n\u0000' : s)
42
+ t.assert.equal(stringify(withEscapes), JSON.stringify(withEscapes))
43
+ const mixed = strings(CUTOFF)
44
+ mixed[1] = 42
45
+ mixed[2] = null
46
+ // asString semantics: non-strings are coerced via toString, null becomes ""
47
+ t.assert.equal(stringify(mixed), JSON.stringify(mixed.map((v) => v === null ? '' : String(v))))
48
+ const dates = strings(CUTOFF)
49
+ dates[0] = new Date(0)
50
+ t.assert.equal(stringify(dates), JSON.stringify(dates.map((v) => v instanceof Date ? v.toISOString() : v)))
51
+ const regexps = strings(CUTOFF)
52
+ regexps[3] = /a"b/
53
+ regexps[5] = /c/g
54
+ t.assert.equal(stringify(regexps), JSON.stringify(regexps.map((v) => v instanceof RegExp ? v.source : v)))
55
+ })
56
+
57
+ test('largeArraySize still drives the json-stringify mechanism for strings', t => {
58
+ t.plan(2)
59
+ const code = build(schema, { mode: 'debug', largeArrayMechanism: 'json-stringify', largeArraySize: 5000 }).code
60
+ t.assert.ok(code.includes('arrayLength >= 5000'))
61
+ t.assert.ok(!code.includes('asPlainStringArray(obj'))
62
+ })
package/types/index.d.ts CHANGED
@@ -181,7 +181,8 @@ declare namespace build {
181
181
  /**
182
182
  * Large arrays are defined as arrays containing, by default, `20000`
183
183
  * elements or more. That value can be adjusted via the option parameter
184
- * `largeArraySize`.
184
+ * `largeArraySize`. Only used with `largeArrayMechanism: 'json-stringify'`;
185
+ * arrays of plain primitives have their own, runtime-dependent cutoffs.
185
186
  *
186
187
  * @default 20000
187
188
  */