From eae3fc18c92a6327d880884ba7936399aea60de7 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 15 Oct 2026 21:53:16 +0200 Subject: [PATCH 1/9] perf: isPrimitive and isSymbol without Object.prototype.toString isPrimitive runs on every value. Its last check, isSymbol, called Object.prototype.toString(...).slice(8, -1) for every object. A typeof switch plus a read of @@toStringTag gives the same answer: toString reports "Symbol" for an object only through that tag. NaN stays non-primitive, as before. --- src/is.ts | 39 ++++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/src/is.ts b/src/is.ts index a2d7504..0812ff4 100644 --- a/src/is.ts +++ b/src/is.ts @@ -1,6 +1,3 @@ -const getType = (payload: any): string => - Object.prototype.toString.call(payload).slice(8, -1); - export const isUndefined = (payload: any): payload is undefined => typeof payload === 'undefined'; @@ -11,9 +8,9 @@ export const isPlainObject = ( ): payload is { [key: string]: any } => { if (typeof payload !== 'object' || payload === null) return false; if (payload === Object.prototype) return false; - if (Object.getPrototypeOf(payload) === null) return true; + const prototype = Object.getPrototypeOf(payload); - return Object.getPrototypeOf(payload) === Object.prototype; + return prototype === null || prototype === Object.prototype; }; export const isEmptyObject = (payload: any): payload is {} => @@ -40,8 +37,14 @@ export const isMap = (payload: any): payload is Map => export const isSet = (payload: any): payload is Set => payload instanceof Set; +// Object.prototype.toString reports an object as "Symbol" only through its @@toStringTag. +const hasSymbolTag = (payload: any): boolean => + ((typeof payload === 'object' && payload !== null) || + typeof payload === 'function') && + payload[Symbol.toStringTag] === 'Symbol'; + export const isSymbol = (payload: any): payload is symbol => - getType(payload) === 'Symbol'; + typeof payload === 'symbol' || hasSymbolTag(payload); export const isDate = (payload: any): payload is Date => payload instanceof Date && !isNaN(payload.valueOf()); @@ -54,13 +57,23 @@ export const isNaNValue = (payload: any): payload is typeof NaN => export const isPrimitive = ( payload: any -): payload is boolean | null | undefined | number | string | symbol => - isBoolean(payload) || - isNull(payload) || - isUndefined(payload) || - isNumber(payload) || - isString(payload) || - isSymbol(payload); +): payload is boolean | null | undefined | number | string | symbol => { + switch (typeof payload) { + case 'boolean': + case 'string': + case 'undefined': + case 'symbol': + return true; + case 'number': + return !isNaN(payload); + case 'object': + return payload === null || hasSymbolTag(payload); + case 'function': + return hasSymbolTag(payload); + default: + return false; + } +}; export const isBigint = (payload: any): payload is bigint => typeof payload === 'bigint'; From 4a1152512a324efeb63f9411e609c155accd2aa9 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 15 Oct 2026 21:53:16 +0200 Subject: [PATCH 2/9] perf: Object.keys loop in forEach, fast paths in escapeKey and validatePath forEach no longer allocates an [key, value] pair per entry. escapeKey skips both regex replaces when the key has no dot and no backslash. validatePath scans the path once and only runs the three includes() checks (which pick the error message) when a suspicious segment exists. --- src/accessDeep.ts | 13 +++++++++++++ src/pathstringifier.ts | 4 +++- src/util.ts | 5 ++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/accessDeep.ts b/src/accessDeep.ts index ea98613..05c2ec2 100644 --- a/src/accessDeep.ts +++ b/src/accessDeep.ts @@ -13,6 +13,19 @@ const getNthKey = (value: Map | Set, n: number): any => { }; function validatePath(path: (string | number)[]) { + let suspicious = false; + for (let i = 0; i < path.length; i++) { + const key = path[i]; + if (key === '__proto__' || key === 'prototype' || key === 'constructor') { + suspicious = true; + break; + } + } + + if (!suspicious) { + return; + } + if (includes(path, '__proto__')) { throw new Error('__proto__ is not allowed as a property'); } diff --git a/src/pathstringifier.ts b/src/pathstringifier.ts index efbf867..729959a 100644 --- a/src/pathstringifier.ts +++ b/src/pathstringifier.ts @@ -2,7 +2,9 @@ export type StringifiedPath = string; type Path = string[]; export const escapeKey = (key: string) => - key.replace(/\\/g, '\\\\').replace(/\./g, '\\.'); + key.indexOf('\\') === -1 && key.indexOf('.') === -1 + ? key + : key.replace(/\\/g, '\\\\').replace(/\./g, '\\.'); export const stringifyPath = (path: Path): StringifiedPath => path diff --git a/src/util.ts b/src/util.ts index 13a5288..2431e45 100644 --- a/src/util.ts +++ b/src/util.ts @@ -42,7 +42,10 @@ export function forEach( record: Record, run: (v: T, key: string) => void ) { - Object.entries(record).forEach(([key, value]) => run(value, key)); + const keys = Object.keys(record); + for (let i = 0; i < keys.length; i++) { + run(record[keys[i]], keys[i]); + } } export function includes(arr: T[], value: T) { From 8b461cf9f9d7d4bbe5a7c72b9320d45817f6e755 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 15 Oct 2026 21:53:16 +0200 Subject: [PATCH 3/9] perf: parsePath fast path with an indexOf loop Without a backslash, both path modes reduce to splitting on dots. An indexOf + slice loop does that without String#split, which V8 runs slowly on short keys from JSON.parse. --- src/pathstringifier.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/pathstringifier.ts b/src/pathstringifier.ts index 729959a..7d62f16 100644 --- a/src/pathstringifier.ts +++ b/src/pathstringifier.ts @@ -12,7 +12,27 @@ export const stringifyPath = (path: Path): StringifiedPath => .map(escapeKey) .join('.'); +// V8's String.prototype.split is several times slower than this on short keys. +const splitOnDots = (string: string): string[] => { + const result: string[] = []; + let start = 0; + for (;;) { + const dot = string.indexOf('.', start); + if (dot === -1) { + result.push(start === 0 ? string : string.slice(start)); + return result; + } + + result.push(string.slice(start, dot)); + start = dot + 1; + } +}; + export const parsePath = (string: StringifiedPath, legacyPaths: boolean) => { + if (typeof string === 'string' && string.indexOf('\\') === -1) { + return splitOnDots(string); + } + const result: string[] = []; let segment = ''; From 96fddd7db2d75c6a3dd5822a0076691c25a8b46e Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 15 Oct 2026 21:53:16 +0200 Subject: [PATCH 4/9] perf: transformValue dispatches by typeof, empty registries are skipped transformValue tried 4 composite and 10 simple rules in a row for every value. Each simple rule accepts one typeof only, so a switch on typeof keeps the same order and the same result. The class, symbol and custom rules are skipped when their registry is empty. A custom transformer is looked up once per value instead of three times, from a cached list instead of Object.values(). --- src/custom-transformer-registry.ts | 18 +++-- src/registry.ts | 4 ++ src/transformer.ts | 102 ++++++++++++++++++++++++----- src/util.ts | 54 --------------- 4 files changed, 102 insertions(+), 76 deletions(-) diff --git a/src/custom-transformer-registry.ts b/src/custom-transformer-registry.ts index ebd3db6..83fff62 100644 --- a/src/custom-transformer-registry.ts +++ b/src/custom-transformer-registry.ts @@ -1,5 +1,4 @@ import { JSONValue } from './types.js'; -import { find } from './util.js'; export interface CustomTransfomer { name: string; @@ -10,15 +9,26 @@ export interface CustomTransfomer { export class CustomTransformerRegistry { private transfomers: Record> = {}; + private ordered: CustomTransfomer[] = []; register(transformer: CustomTransfomer) { this.transfomers[transformer.name] = transformer; + this.ordered = Object.values(this.transfomers); + } + + get size(): number { + return this.ordered.length; } findApplicable(v: T) { - return find(this.transfomers, transformer => - transformer.isApplicable(v) - ) as CustomTransfomer | undefined; + const transformers = this.ordered; + for (let i = 0; i < transformers.length; i++) { + if (transformers[i].isApplicable(v)) { + return transformers[i] as CustomTransfomer; + } + } + + return undefined; } findByName(name: string) { diff --git a/src/registry.ts b/src/registry.ts index a007127..662d457 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -21,6 +21,10 @@ export class Registry { this.kv.clear(); } + get size(): number { + return this.kv.valueToKey.size; + } + getIdentifier(value: T) { return this.kv.getByValue(value); } diff --git a/src/transformer.ts b/src/transformer.ts index cce7d55..c50c256 100644 --- a/src/transformer.ts +++ b/src/transformer.ts @@ -15,7 +15,6 @@ import { BigIntTypedArrayConstructor, isURL, } from './is.js'; -import { findArr } from './util.js'; import SuperJSON from './index.js'; export type PrimitiveTypeAnnotation = 'number' | 'undefined' | 'bigint'; @@ -190,7 +189,7 @@ function compositeTransformation( const symbolRule = compositeTransformation( (s, superJson): s is Symbol => { - if (isSymbol(s)) { + if (superJson.symbolRegistry.size > 0 && isSymbol(s)) { const isRegistered = !!superJson.symbolRegistry.getIdentifier(s); return isRegistered; } @@ -282,7 +281,7 @@ export function isInstanceOfRegisteredClass( potentialClass: any, superJson: SuperJSON ): potentialClass is any { - if (potentialClass?.constructor) { + if (superJson.classRegistry.size > 0 && potentialClass?.constructor) { const isRegistered = !!superJson.classRegistry.getIdentifier( potentialClass.constructor ); @@ -349,34 +348,101 @@ const customRule = compositeTransformation( } ); -const compositeRules = [classRule, symbolRule, customRule, typedArrayRule]; +const [ + undefinedRule, + bigintRule, + dateRule, + errorRule, + regexpRule, + setRule, + mapRule, + nonFiniteRule, + negativeZeroRule, + urlRule, +] = simpleRules; + +const objectRules = [dateRule, errorRule, regexpRule, setRule, mapRule, urlRule]; + +const applySimple = ( + rule: typeof simpleRules[number], + value: any, + superJson: SuperJSON +) => ({ + value: rule.transform(value as never, superJson), + type: rule.annotation, +}); +// Same rule order as before: class, symbol, custom, typed array, then the simple rules. The simple +// rules are split by typeof because each one only accepts a single typeof. export const transformValue = ( value: any, superJson: SuperJSON ): { value: any; type: TypeAnnotation } | undefined => { - const applicableCompositeRule = findArr(compositeRules, rule => - rule.isApplicable(value, superJson) - ); - if (applicableCompositeRule) { + if (classRule.isApplicable(value, superJson)) { return { - value: applicableCompositeRule.transform(value as never, superJson), - type: applicableCompositeRule.annotation(value, superJson), + value: classRule.transform(value, superJson), + type: classRule.annotation(value, superJson), }; } - const applicableSimpleRule = findArr(simpleRules, rule => - rule.isApplicable(value, superJson) - ); - - if (applicableSimpleRule) { + if (symbolRule.isApplicable(value, superJson)) { return { - value: applicableSimpleRule.transform(value as never, superJson), - type: applicableSimpleRule.annotation, + value: symbolRule.transform(value, superJson), + type: symbolRule.annotation(value, superJson), }; } - return undefined; + if (superJson.customTransformerRegistry.size > 0) { + const transformer = superJson.customTransformerRegistry.findApplicable( + value + ); + if (transformer) { + return { + value: transformer.serialize(value), + type: ['custom', transformer.name], + }; + } + } + + switch (typeof value) { + case 'undefined': + return applySimple(undefinedRule, value, superJson); + case 'bigint': + return applySimple(bigintRule, value, superJson); + case 'number': + if (nonFiniteRule.isApplicable(value, superJson)) { + return applySimple(nonFiniteRule, value, superJson); + } + + if (negativeZeroRule.isApplicable(value, superJson)) { + return applySimple(negativeZeroRule, value, superJson); + } + + return undefined; + case 'object': + case 'function': { + if (value === null) { + return undefined; + } + + if (typedArrayRule.isApplicable(value, superJson)) { + return { + value: typedArrayRule.transform(value, superJson), + type: typedArrayRule.annotation(value, superJson), + }; + } + + for (let i = 0; i < objectRules.length; i++) { + if (objectRules[i].isApplicable(value, superJson)) { + return applySimple(objectRules[i], value, superJson); + } + } + + return undefined; + } + default: + return undefined; + } }; const simpleRulesByAnnotation: Record = {}; diff --git a/src/util.ts b/src/util.ts index 2431e45..a1c3a9e 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,43 +1,3 @@ -function valuesOfObj(record: Record): T[] { - if ('values' in Object) { - // eslint-disable-next-line es5/no-es6-methods - return Object.values(record); - } - - const values: T[] = []; - - // eslint-disable-next-line no-restricted-syntax - for (const key in record) { - if (record.hasOwnProperty(key)) { - values.push(record[key]); - } - } - - return values; -} - -export function find( - record: Record, - predicate: (v: T) => boolean -): T | undefined { - const values = valuesOfObj(record); - if ('find' in values) { - // eslint-disable-next-line es5/no-es6-methods - return values.find(predicate); - } - - const valuesNotNever = values as T[]; - - for (let i = 0; i < valuesNotNever.length; i++) { - const value = valuesNotNever[i]; - if (predicate(value)) { - return value; - } - } - - return undefined; -} - export function forEach( record: Record, run: (v: T, key: string) => void @@ -51,17 +11,3 @@ export function forEach( export function includes(arr: T[], value: T) { return arr.indexOf(value) !== -1; } - -export function findArr( - record: T[], - predicate: (v: T) => boolean -): T | undefined { - for (let i = 0; i < record.length; i++) { - const value = record[i]; - if (predicate(value)) { - return value; - } - } - - return undefined; -} From 6d680c1e68e345d956bef350060867fdefcf85ce Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 15 Oct 2026 21:53:16 +0200 Subject: [PATCH 5/9] perf: walker without per-child path and ancestor copies The walker copied the whole path ([...path, index]) and the ancestor list ([...objectsInThisPath, object]) for every child, and kept two maps per object (identities and seen). Now paths are linked nodes that become arrays only for objects seen twice, one map holds identity and result, an unfinished entry marks an ancestor (the cycle check), and annotations pass through a context instead of a result object per value. The exported walker() keeps its signature. --- src/index.ts | 7 +- src/plainer.ts | 226 +++++++++++++++++++++++++++++++------------------ 2 files changed, 145 insertions(+), 88 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9a11ad7..f47c9c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,7 @@ import { applyReferentialEqualityAnnotations, applyValueAnnotations, generateReferentialEqualityAnnotations, - walker, + walkRoot, } from './plainer.js'; import { copy } from 'copy-anything'; @@ -31,8 +31,7 @@ export default class SuperJSON { } serialize(object: SuperJSONValue): SuperJSONResult { - const identities = new Map(); - const output = walker(object, identities, this, this.dedupe); + const output = walkRoot(object, this, this.dedupe); const res: SuperJSONResult = { json: output.transformedValue, }; @@ -45,7 +44,7 @@ export default class SuperJSON { } const equalityAnnotations = generateReferentialEqualityAnnotations( - identities, + output.identities, this.dedupe ); if (equalityAnnotations) { diff --git a/src/plainer.ts b/src/plainer.ts index 8e9059f..ff19bdd 100644 --- a/src/plainer.ts +++ b/src/plainer.ts @@ -14,7 +14,7 @@ import { TypeAnnotation, untransformValue, } from './transformer.js'; -import { includes, forEach } from './util.js'; +import { forEach } from './util.js'; import { parsePath } from './pathstringifier.js'; import { getDeep, setDeep } from './accessDeep.js'; import SuperJSON from './index.js'; @@ -122,16 +122,6 @@ const isDeep = (object: any, superJson: SuperJSON): boolean => isError(object) || isInstanceOfRegisteredClass(object, superJson); -function addIdentity(object: any, path: any[], identities: Map) { - const existingSet = identities.get(object); - - if (existingSet) { - existingSet.push(path); - } else { - identities.set(object, [path]); - } -} - interface Result { transformedValue: any; annotations?: MinimisedTree; @@ -185,62 +175,85 @@ export function generateReferentialEqualityAnnotations( } } -export const walker = ( +class PathNode { + constructor(readonly parent: PathNode | null, readonly key: string) {} +} + +const toPath = (node: PathNode | null): string[] => { + const path: string[] = []; + for (; node; node = node.parent) { + path.push(node.key); + } + + return path.reverse(); +}; + +interface Seen { + paths: (PathNode | null)[]; + result: Result | undefined; +} + +interface WalkContext { + superJson: SuperJSON; + dedupe: boolean; + /** Every non-primitive in visiting order. A missing `result` means the walk of it is still running. */ + seen: Map; + /** Annotations of the value the last `walk` call returned. */ + annotations: MinimisedTree; +} + +function walk( object: any, - identities: Map, - superJson: SuperJSON, - dedupe: boolean, - path: any[] = [], - objectsInThisPath: any[] = [], - seenObjects = new Map() -): Result => { + ctx: WalkContext, + parent: PathNode | null, + key: string | null +): any { const primitive = isPrimitive(object); + let seen: Seen | undefined; if (!primitive) { - addIdentity(object, path, identities); - - const seen = seenObjects.get(object); + const node = key === null ? parent : new PathNode(parent, key); + seen = ctx.seen.get(object); if (seen) { - // short-circuit result if we've seen this object before - return dedupe - ? { - transformedValue: null, - } - : seen; - } - } + seen.paths.push(node); + if (seen.result) { + if (ctx.dedupe) { + ctx.annotations = undefined; + return null; + } - if (!isDeep(object, superJson)) { - const transformed = transformValue(object, superJson); + ctx.annotations = seen.result.annotations; + return seen.result.transformedValue; + } - const result: Result = transformed - ? { - transformedValue: transformed.value, - annotations: [transformed.type], - } - : { - transformedValue: object, - }; - if (!primitive) { - seenObjects.set(object, result); + ctx.annotations = undefined; + return isDeep(object, ctx.superJson) + ? null + : leaf(object, ctx, seen); } - return result; + + seen = { paths: [node], result: undefined }; + ctx.seen.set(object, seen); + parent = node; + } + + if (!isDeep(object, ctx.superJson)) { + return leaf(object, ctx, seen); } - if (includes(objectsInThisPath, object)) { - // prevent circular references - return { - transformedValue: null, - }; + if (primitive && key !== null) { + parent = new PathNode(parent, key); } - const transformationResult = transformValue(object, superJson); + const transformationResult = transformValue(object, ctx.superJson); const transformed = transformationResult?.value ?? object; const transformedValue: any = isArray(transformed) ? [] : {}; - const innerAnnotations: Record> = {}; + let innerAnnotations: Record> | undefined; - forEach(transformed, (value, index) => { + const keys = Object.keys(transformed); + for (let i = 0; i < keys.length; i++) { + const index = keys[i]; if ( index === '__proto__' || index === 'constructor' || @@ -251,43 +264,88 @@ export const walker = ( ); } - const recursiveResult = walker( - value, - identities, - superJson, - dedupe, - [...path, index], - [...objectsInThisPath, object], - seenObjects - ); + transformedValue[index] = walk(transformed[index], ctx, parent, index); + + const childAnnotations = ctx.annotations; + if (isArray(childAnnotations)) { + innerAnnotations ??= {}; + innerAnnotations[escapeKey(index)] = childAnnotations; + } else if (isPlainObject(childAnnotations)) { + innerAnnotations ??= {}; + const prefix = escapeKey(index) + '.'; + const childKeys = Object.keys(childAnnotations); + for (let j = 0; j < childKeys.length; j++) { + innerAnnotations[prefix + childKeys[j]] = + childAnnotations[childKeys[j]]; + } + } + } - transformedValue[index] = recursiveResult.transformedValue; + const annotations: MinimisedTree = innerAnnotations + ? transformationResult + ? [transformationResult.type, innerAnnotations] + : innerAnnotations + : transformationResult + ? [transformationResult.type] + : undefined; - if (isArray(recursiveResult.annotations)) { - innerAnnotations[escapeKey(index)] = recursiveResult.annotations; - } else if (isPlainObject(recursiveResult.annotations)) { - forEach(recursiveResult.annotations, (tree, key) => { - innerAnnotations[escapeKey(index) + '.' + key] = tree; - }); + if (seen) { + seen.result = { transformedValue, annotations }; + } + + ctx.annotations = annotations; + return transformedValue; +} + +function leaf(object: any, ctx: WalkContext, seen: Seen | undefined) { + const transformed = transformValue(object, ctx.superJson); + const transformedValue = transformed ? transformed.value : object; + const annotations: MinimisedTree = transformed + ? [transformed.type] + : undefined; + + if (seen) { + seen.result = { transformedValue, annotations }; + } + + ctx.annotations = annotations; + return transformedValue; +} + +export function walkRoot( + object: any, + superJson: SuperJSON, + dedupe: boolean +): Result & { identities: Map } { + const ctx: WalkContext = { + superJson, + dedupe, + seen: new Map(), + annotations: undefined, + }; + const transformedValue = walk(object, ctx, null, null); + + const identities = new Map(); + ctx.seen.forEach((seen, value) => { + if (seen.paths.length > 1) { + identities.set(value, seen.paths.map(toPath)); } }); - const result: Result = isEmptyObject(innerAnnotations) - ? { - transformedValue, - annotations: !!transformationResult - ? [transformationResult.type] - : undefined, - } - : { - transformedValue, - annotations: !!transformationResult - ? [transformationResult.type, innerAnnotations] - : innerAnnotations, - }; - if (!primitive) { - seenObjects.set(object, result); - } + return { transformedValue, annotations: ctx.annotations, identities }; +} + +export const walker = ( + object: any, + identities: Map, + superJson: SuperJSON, + dedupe: boolean +): Result => { + const result = walkRoot(object, superJson, dedupe); + result.identities.forEach((paths, value) => identities.set(value, paths)); - return result; + return { + transformedValue: result.transformedValue, + annotations: result.annotations, + }; }; From c3e2f8bb920fda214580aa9ac34a0cf5c8fe2bb7 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 15 Oct 2026 21:53:16 +0200 Subject: [PATCH 6/9] perf: local deep copy instead of copy-anything deserialize() without inPlace deep-copies the json first. copy-anything 4.0.5 calls Object.prototype.toString twice per value (is-what) and reduce()s over getOwnPropertyNames and getOwnPropertySymbols. The local copy gives the same result: arrays and plain objects are cloned (enumerable own keys and symbols, __proto__ skipped), everything else is returned by reference. copy-anything 4.1.x keeps shared references in the copy, so the output matches 4.0.5, the version pinned in package-lock.json. This branch no longer imports copy-anything. Its entry in package.json is unchanged. --- src/index.ts | 2 +- src/util.ts | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index f47c9c8..3704788 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ import { generateReferentialEqualityAnnotations, walkRoot, } from './plainer.js'; -import { copy } from 'copy-anything'; +import { copy } from './util.js'; export default class SuperJSON { /** diff --git a/src/util.ts b/src/util.ts index a1c3a9e..e0452dd 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,3 +1,5 @@ +import { isArray } from './is.js'; + export function forEach( record: Record, run: (v: T, key: string) => void @@ -11,3 +13,56 @@ export function forEach( export function includes(arr: T[], value: T) { return arr.indexOf(value) !== -1; } + +const getType = (payload: any): string => + Object.prototype.toString.call(payload).slice(8, -1); + +// Same result as `copy` from copy-anything 4: clones arrays and plain objects (enumerable own keys +// and symbols, `__proto__` skipped) and returns everything else by reference. An object without +// @@toStringTag skips the Object.prototype.toString check, because its tag can only be "Array" or +// "Object" here. +export function copy(target: any): any { + if (typeof target !== 'object' || target === null) { + return target; + } + + if (Symbol.toStringTag in target) { + const type = getType(target); + if (type === 'Array') { + return target.map(copy); + } + + return type === 'Object' ? copyObject(target) : target; + } + + if (isArray(target)) { + return target.map(copy); + } + + return copyObject(target); +} + +function copyObject(target: any): any { + const prototype = Object.getPrototypeOf(target); + if (prototype !== Object.prototype || prototype.constructor !== Object) { + return target; + } + + const result: any = {}; + const keys = Object.keys(target); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (key !== '__proto__') { + result[key] = copy(target[key]); + } + } + + const symbols = Object.getOwnPropertySymbols(target); + for (let i = 0; i < symbols.length; i++) { + if (Object.prototype.propertyIsEnumerable.call(target, symbols[i])) { + result[symbols[i]] = copy(target[symbols[i]]); + } + } + + return result; +} From 54972d89b17daab7a9e6d95af6c1b56f860f1ba5 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 15 Oct 2026 21:53:16 +0200 Subject: [PATCH 7/9] perf: JS toISOString and a strict ISO parser for Dates Date#toISOString costs about 470 ns in V8; the same string built from getUTC* calls costs about 128 ns. new Date(string) costs about 206 ns; Date.UTC on the parsed digits costs about 151 ns. Both fall back to the native call outside the strict YYYY-MM-DDTHH:mm:ss.sssZ shape with a 4-digit year and in-range fields. date.test.ts compares both against the native functions on 60,000 inputs. --- src/date.test.ts | 71 +++++++++++++++++++++++++++++++++++++ src/date.ts | 87 ++++++++++++++++++++++++++++++++++++++++++++++ src/transformer.ts | 5 +-- 3 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 src/date.test.ts create mode 100644 src/date.ts diff --git a/src/date.test.ts b/src/date.test.ts new file mode 100644 index 0000000..399ed9b --- /dev/null +++ b/src/date.test.ts @@ -0,0 +1,71 @@ +import { parseISOString, toISOString } from './date.js'; + +import { expect, test } from 'vitest'; + +const sameTime = (a: Date, b: Date) => + Object.is(a.getTime(), b.getTime()); + +test('toISOString matches Date#toISOString', () => { + const times = [ + 0, + -1, + Date.UTC(999, 11, 31, 23, 59, 59, 999), + Date.UTC(1000, 0, 1), + Date.UTC(2024, 1, 29, 12, 5, 9, 7), + Date.UTC(9999, 11, 31, 23, 59, 59, 999), + Date.UTC(10000, 0, 1), + -62198755200000, + 8.64e15, + -8.64e15, + ]; + for (let i = 0; i < 20000; i++) { + times.push(Math.floor((Math.random() - 0.4) * 4e14)); + } + + for (const time of times) { + const date = new Date(time); + expect(toISOString(date)).toBe(date.toISOString()); + } +}); + +test('parseISOString matches new Date(string)', () => { + const inputs = [ + '2024-02-29T12:00:00.000Z', + '2023-02-29T12:00:00.000Z', + '2023-04-31T00:00:00.000Z', + '2023-00-10T00:00:00.000Z', + '2023-13-10T00:00:00.000Z', + '2023-01-00T00:00:00.000Z', + '2023-01-32T00:00:00.000Z', + '2023-01-01T24:00:00.000Z', + '2023-01-01T23:60:00.000Z', + '2023-01-01T23:59:60.000Z', + '0099-01-01T00:00:00.000Z', + '0999-12-31T23:59:59.999Z', + '1000-01-01T00:00:00.000Z', + '9999-12-31T23:59:59.999Z', + '+010000-01-01T00:00:00.000Z', + '-000001-01-01T00:00:00.000Z', + '2023-01-01T00:00:00.000+01:00', + '2023-01-01 00:00:00.000Z', + '2023-01-0aT00:00:00.000Z', + '2023-01-01T00:00:00.0-0Z', + 'not a date', + '', + ]; + for (let i = 0; i < 20000; i++) { + inputs.push(new Date(Math.floor((Math.random() - 0.4) * 4e14)).toISOString()); + } + + const chars = '0123456789-T:.Z+ x'; + for (let i = 0; i < 20000; i++) { + const base = inputs[Math.floor(Math.random() * inputs.length)].split(''); + base[Math.floor(Math.random() * base.length)] = + chars[Math.floor(Math.random() * chars.length)]; + inputs.push(base.join('')); + } + + for (const input of inputs) { + expect(sameTime(parseISOString(input), new Date(input))).toBe(true); + } +}); diff --git a/src/date.ts b/src/date.ts new file mode 100644 index 0000000..4130a96 --- /dev/null +++ b/src/date.ts @@ -0,0 +1,87 @@ +const pad2 = (n: number) => (n < 10 ? '0' + n : '' + n); +const pad3 = (n: number) => (n < 10 ? '00' + n : n < 100 ? '0' + n : '' + n); + +// Same output as Date#toISOString, which is several times slower in V8. +export function toISOString(date: Date): string { + const year = date.getUTCFullYear(); + if (year < 1000 || year > 9999) { + return date.toISOString(); + } + + return ( + year + + '-' + + pad2(date.getUTCMonth() + 1) + + '-' + + pad2(date.getUTCDate()) + + 'T' + + pad2(date.getUTCHours()) + + ':' + + pad2(date.getUTCMinutes()) + + ':' + + pad2(date.getUTCSeconds()) + + '.' + + pad3(date.getUTCMilliseconds()) + + 'Z' + ); +} + +const digits = (s: string, from: number, count: number): number => { + let n = 0; + for (let i = from; i < from + count; i++) { + const d = s.charCodeAt(i) - 48; + if (d < 0 || d > 9) { + return -1; + } + + n = n * 10 + d; + } + + return n; +}; + +// Same result as `new Date(value)`. Only the exact toISOString shape with in-range fields takes the +// fast path; everything else goes to the native parser. +export function parseISOString(value: string): Date { + if ( + typeof value === 'string' && + value.length === 24 && + value.charCodeAt(4) === 45 && + value.charCodeAt(7) === 45 && + value.charCodeAt(10) === 84 && + value.charCodeAt(13) === 58 && + value.charCodeAt(16) === 58 && + value.charCodeAt(19) === 46 && + value.charCodeAt(23) === 90 + ) { + const year = digits(value, 0, 4); + const month = digits(value, 5, 2); + const day = digits(value, 8, 2); + const hours = digits(value, 11, 2); + const minutes = digits(value, 14, 2); + const seconds = digits(value, 17, 2); + const ms = digits(value, 20, 3); + if ( + year >= 1000 && + month >= 1 && + month <= 12 && + day >= 1 && + hours >= 0 && + hours <= 23 && + minutes >= 0 && + minutes <= 59 && + seconds >= 0 && + seconds <= 59 && + ms >= 0 + ) { + const date = new Date( + Date.UTC(year, month - 1, day, hours, minutes, seconds, ms) + ); + if (date.getUTCDate() === day) { + return date; + } + } + } + + return new Date(value); +} diff --git a/src/transformer.ts b/src/transformer.ts index c50c256..3c18980 100644 --- a/src/transformer.ts +++ b/src/transformer.ts @@ -16,6 +16,7 @@ import { isURL, } from './is.js'; import SuperJSON from './index.js'; +import { parseISOString, toISOString } from './date.js'; export type PrimitiveTypeAnnotation = 'number' | 'undefined' | 'bigint'; @@ -74,8 +75,8 @@ const simpleRules = [ simpleTransformation( isDate, 'Date', - v => v.toISOString(), - v => new Date(v) + toISOString, + parseISOString ), simpleTransformation( From 42d650e11265805664ea67e64d63f3617f20a65a Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 15 Oct 2026 21:53:16 +0200 Subject: [PATCH 8/9] perf: one record per visited object, plain containers skip built-in rules Each visited object allocated a path node, a seen entry, a paths array and a result object. Now one Seen object is the first path node and holds the cached result. Plain objects and arrays with Array.prototype skip the typed-array and instanceof rules, because none of them can match. --- src/plainer.ts | 85 ++++++++++++++++++++++++++++++---------------- src/transformer.ts | 15 +++++++- 2 files changed, 69 insertions(+), 31 deletions(-) diff --git a/src/plainer.ts b/src/plainer.ts index ff19bdd..2160720 100644 --- a/src/plainer.ts +++ b/src/plainer.ts @@ -10,6 +10,7 @@ import { import { escapeKey, stringifyPath } from './pathstringifier.js'; import { isInstanceOfRegisteredClass, + transformRegisteredValue, transformValue, TypeAnnotation, untransformValue, @@ -176,32 +177,50 @@ export function generateReferentialEqualityAnnotations( } class PathNode { - constructor(readonly parent: PathNode | null, readonly key: string) {} + /** `key` is null only for the root. */ + constructor(readonly parent: PathNode | null, readonly key: string | null) {} +} + +/** The first visit of a non-primitive, which is also the path node of its children. */ +class Seen extends PathNode { + more: PathNode[] | undefined = undefined; + done = false; + value: any = undefined; + annotations: MinimisedTree = undefined; } const toPath = (node: PathNode | null): string[] => { const path: string[] = []; for (; node; node = node.parent) { - path.push(node.key); + if (node.key !== null) { + path.push(node.key); + } } return path.reverse(); }; -interface Seen { - paths: (PathNode | null)[]; - result: Result | undefined; -} +const pathsOf = (seen: Seen): string[][] => { + const paths = [toPath(seen)]; + seen.more?.forEach(node => paths.push(toPath(node))); + + return paths; +}; interface WalkContext { superJson: SuperJSON; dedupe: boolean; - /** Every non-primitive in visiting order. A missing `result` means the walk of it is still running. */ + /** Every non-primitive in visiting order. `done` is false while its walk is still running. */ seen: Map; /** Annotations of the value the last `walk` call returned. */ annotations: MinimisedTree; } +// No built-in rule (Date, Map, typed array...) can match an object with one of these prototypes. +const isPlainContainer = (object: any): boolean => + isPlainObject(object) || + (isArray(object) && Object.getPrototypeOf(object) === Array.prototype); + function walk( object: any, ctx: WalkContext, @@ -212,18 +231,17 @@ function walk( let seen: Seen | undefined; if (!primitive) { - const node = key === null ? parent : new PathNode(parent, key); seen = ctx.seen.get(object); if (seen) { - seen.paths.push(node); - if (seen.result) { + (seen.more ??= []).push(new PathNode(parent, key)); + if (seen.done) { if (ctx.dedupe) { ctx.annotations = undefined; return null; } - ctx.annotations = seen.result.annotations; - return seen.result.transformedValue; + ctx.annotations = seen.annotations; + return seen.value; } ctx.annotations = undefined; @@ -232,12 +250,13 @@ function walk( : leaf(object, ctx, seen); } - seen = { paths: [node], result: undefined }; + seen = new Seen(parent, key); ctx.seen.set(object, seen); - parent = node; + parent = seen; } - if (!isDeep(object, ctx.superJson)) { + const plainContainer = !primitive && isPlainContainer(object); + if (!plainContainer && !isDeep(object, ctx.superJson)) { return leaf(object, ctx, seen); } @@ -245,7 +264,9 @@ function walk( parent = new PathNode(parent, key); } - const transformationResult = transformValue(object, ctx.superJson); + const transformationResult = plainContainer + ? transformRegisteredValue(object, ctx.superJson) + : transformValue(object, ctx.superJson); const transformed = transformationResult?.value ?? object; const transformedValue: any = isArray(transformed) ? [] : {}; @@ -289,27 +310,31 @@ function walk( ? [transformationResult.type] : undefined; - if (seen) { - seen.result = { transformedValue, annotations }; - } - - ctx.annotations = annotations; - return transformedValue; + return finish(ctx, seen, transformedValue, annotations); } function leaf(object: any, ctx: WalkContext, seen: Seen | undefined) { const transformed = transformValue(object, ctx.superJson); - const transformedValue = transformed ? transformed.value : object; - const annotations: MinimisedTree = transformed - ? [transformed.type] - : undefined; + return transformed + ? finish(ctx, seen, transformed.value, [transformed.type]) + : finish(ctx, seen, object, undefined); +} + +function finish( + ctx: WalkContext, + seen: Seen | undefined, + value: any, + annotations: MinimisedTree +) { if (seen) { - seen.result = { transformedValue, annotations }; + seen.done = true; + seen.value = value; + seen.annotations = annotations; } ctx.annotations = annotations; - return transformedValue; + return value; } export function walkRoot( @@ -327,8 +352,8 @@ export function walkRoot( const identities = new Map(); ctx.seen.forEach((seen, value) => { - if (seen.paths.length > 1) { - identities.set(value, seen.paths.map(toPath)); + if (seen.more) { + identities.set(value, pathsOf(seen)); } }); diff --git a/src/transformer.ts b/src/transformer.ts index 3c18980..a627ce4 100644 --- a/src/transformer.ts +++ b/src/transformer.ts @@ -375,7 +375,8 @@ const applySimple = ( // Same rule order as before: class, symbol, custom, typed array, then the simple rules. The simple // rules are split by typeof because each one only accepts a single typeof. -export const transformValue = ( +/** Class, symbol and custom rules only. */ +export const transformRegisteredValue = ( value: any, superJson: SuperJSON ): { value: any; type: TypeAnnotation } | undefined => { @@ -405,6 +406,18 @@ export const transformValue = ( } } + return undefined; +}; + +export const transformValue = ( + value: any, + superJson: SuperJSON +): { value: any; type: TypeAnnotation } | undefined => { + const registered = transformRegisteredValue(value, superJson); + if (registered) { + return registered; + } + switch (typeof value) { case 'undefined': return applySimple(undefinedRule, value, superJson); From f2978577383d4d74f04f227e5bd74e7b44734959 Mon Sep 17 00:00:00 2001 From: Martin Date: Thu, 15 Oct 2026 21:53:17 +0200 Subject: [PATCH 9/9] perf: join flattened annotation keys once, not once per nesting level A plain container copied every annotation of its children into its own record with a longer key, so an annotation at depth d was copied d times (about 17,000 copies for a 2,519-annotation React Query cache). Children now hand up a pending list and keys are joined once, at the root or at the nearest transformed ancestor. The list keeps object key order: array indices ascending first, then insertion order. --- src/plainer.ts | 101 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 83 insertions(+), 18 deletions(-) diff --git a/src/plainer.ts b/src/plainer.ts index 2160720..62c4e20 100644 --- a/src/plainer.ts +++ b/src/plainer.ts @@ -186,7 +186,7 @@ class Seen extends PathNode { more: PathNode[] | undefined = undefined; done = false; value: any = undefined; - annotations: MinimisedTree = undefined; + annotations: Annotations = undefined; } const toPath = (node: PathNode | null): string[] => { @@ -207,13 +207,75 @@ const pathsOf = (seen: Seen): string[][] => { return paths; }; +/** + * The annotation record of a plain container, built lazily so each flattened key is joined once. + * Keys are kept as an object would order them: array indices ascending, then insertion order. + */ +class PendingAnnotations { + /** Pairs: escaped array-index key, tree. */ + readonly indices: any[] = []; + /** Pairs: escaped key and tree, or `key.` prefix and a nested PendingAnnotations. */ + readonly rest: any[] = []; +} + +const isArrayIndex = (key: string): boolean => { + const length = key.length; + if (length === 0 || length > 10) { + return false; + } + + for (let i = 0; i < length; i++) { + const code = key.charCodeAt(i); + if (code < 48 || code > 57) { + return false; + } + } + + if (key.charCodeAt(0) === 48) { + return length === 1; + } + + return length < 10 || +key < 4294967295; +}; + +function materialize( + pending: PendingAnnotations, + prefix: string, + out: Record> +): Record> { + const { indices, rest } = pending; + for (let i = 0; i < indices.length; i += 2) { + out[prefix + indices[i]] = indices[i + 1]; + } + + for (let i = 0; i < rest.length; i += 2) { + const value = rest[i + 1]; + if (value instanceof PendingAnnotations) { + materialize(value, prefix + rest[i], out); + } else { + out[prefix + rest[i]] = value; + } + } + + return out; +} + +const toRecord = ( + annotations: Annotations +): MinimisedTree => + annotations instanceof PendingAnnotations + ? materialize(annotations, '', {}) + : annotations; + +type Annotations = Tree | PendingAnnotations | undefined; + interface WalkContext { superJson: SuperJSON; dedupe: boolean; /** Every non-primitive in visiting order. `done` is false while its walk is still running. */ seen: Map; /** Annotations of the value the last `walk` call returned. */ - annotations: MinimisedTree; + annotations: Annotations; } // No built-in rule (Date, Map, typed array...) can match an object with one of these prototypes. @@ -270,7 +332,7 @@ function walk( const transformed = transformationResult?.value ?? object; const transformedValue: any = isArray(transformed) ? [] : {}; - let innerAnnotations: Record> | undefined; + let inner: PendingAnnotations | undefined; const keys = Object.keys(transformed); for (let i = 0; i < keys.length; i++) { @@ -288,24 +350,23 @@ function walk( transformedValue[index] = walk(transformed[index], ctx, parent, index); const childAnnotations = ctx.annotations; - if (isArray(childAnnotations)) { - innerAnnotations ??= {}; - innerAnnotations[escapeKey(index)] = childAnnotations; - } else if (isPlainObject(childAnnotations)) { - innerAnnotations ??= {}; - const prefix = escapeKey(index) + '.'; - const childKeys = Object.keys(childAnnotations); - for (let j = 0; j < childKeys.length; j++) { - innerAnnotations[prefix + childKeys[j]] = - childAnnotations[childKeys[j]]; + if (childAnnotations !== undefined) { + inner ??= new PendingAnnotations(); + const escaped = escapeKey(index); + if (childAnnotations instanceof PendingAnnotations) { + inner.rest.push(escaped + '.', childAnnotations); + } else if (isArrayIndex(escaped)) { + inner.indices.push(escaped, childAnnotations); + } else { + inner.rest.push(escaped, childAnnotations); } } } - const annotations: MinimisedTree = innerAnnotations + const annotations: Annotations = inner ? transformationResult - ? [transformationResult.type, innerAnnotations] - : innerAnnotations + ? [transformationResult.type, materialize(inner, '', {})] + : inner : transformationResult ? [transformationResult.type] : undefined; @@ -325,7 +386,7 @@ function finish( ctx: WalkContext, seen: Seen | undefined, value: any, - annotations: MinimisedTree + annotations: Annotations ) { if (seen) { seen.done = true; @@ -357,7 +418,11 @@ export function walkRoot( } }); - return { transformedValue, annotations: ctx.annotations, identities }; + return { + transformedValue, + annotations: toRecord(ctx.annotations), + identities, + }; } export const walker = (