From 514ea85826ebfe48f5827b44f9daced5bff0bc47 Mon Sep 17 00:00:00 2001 From: Igor Brasileiro Date: Mon, 9 Sep 2024 20:55:17 -0300 Subject: [PATCH] feat: improve deepEquals performance (#4292) * Change fast-deep-equal to fast-equals * Add changelog and change deepEquals depscription * run cs-format * Add JSDocs for isFunctions and customDeepEqual * Update CHANGELOG.md --------- Co-authored-by: Heath C <51679588+heath-freenome@users.noreply.github.com> --- CHANGELOG.md | 7 +++ package-lock.json | 10 ++++ package.json | 3 +- packages/utils/package.json | 1 + packages/utils/src/createSchemaUtils.ts | 2 +- packages/utils/src/deepEquals.ts | 47 +++++++++++++++---- .../utils/src/enumOptionsDeselectValue.ts | 7 ++- packages/utils/src/enumOptionsIsSelected.ts | 7 ++- packages/utils/src/parser/ParserValidator.ts | 6 +-- packages/utils/src/parser/schemaParser.ts | 8 ++-- packages/utils/src/schema/retrieveSchema.ts | 13 +++-- packages/utils/src/schema/toIdSchema.ts | 4 +- packages/utils/src/schema/toPathSchema.ts | 6 +-- packages/utils/test/deepEquals.test.ts | 2 +- .../src/precompiledValidator.ts | 8 ++-- 15 files changed, 89 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e649a01c0b..056c24867a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,13 @@ should change the heading of the (upcoming) version to include a major version b ## @rjsf/utils - Fixes an issue with dependencies computeDefaults to ensure we can get the dependencies defaults [#4271](https://github.com/rjsf-team/react-jsonschema-form/issues/4271) +- Updated `deepEquals()` to use `fast-equals.createCustomEqual()` instead of `lodash.isEqualWith()`, fixing [#4291](https://github.com/rjsf-team/react-jsonschema-form/issues/4291) + - Switched uses of `lodash.isEqual()` to `deepEquals()` in many of the utility functions as well + + +## @validator-ajv8 + +- Use `@rjsf/utils` `deepEquals()` instead of `lodash.isEqual()` to improve performance, fixing [#4291](https://github.com/rjsf-team/react-jsonschema-form/issues/4291) # 5.20.1 diff --git a/package-lock.json b/package-lock.json index 1a9c69d9e6..cbcd0f090c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16570,6 +16570,15 @@ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "dev": true }, + "node_modules/fast-equals": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz", + "integrity": "sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -35158,6 +35167,7 @@ "version": "5.20.1", "license": "Apache-2.0", "dependencies": { + "fast-equals": "^5.0.1", "json-schema-merge-allof": "^0.8.1", "jsonpointer": "^5.0.1", "lodash": "^4.17.21", diff --git a/package.json b/package.json index 0639401064..5852ea996c 100644 --- a/package.json +++ b/package.json @@ -79,5 +79,6 @@ "packages/validator-ajv6", "packages/validator-ajv8", "packages/snapshot-tests" - ] + ], + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/packages/utils/package.json b/packages/utils/package.json index 4e859ad700..d7245a16cb 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -36,6 +36,7 @@ "react": "^16.14.0 || >=17" }, "dependencies": { + "fast-equals": "^5.0.1", "json-schema-merge-allof": "^0.8.1", "jsonpointer": "^5.0.1", "lodash": "^4.17.21", diff --git a/packages/utils/src/createSchemaUtils.ts b/packages/utils/src/createSchemaUtils.ts index 39e743d945..b7e1c73b06 100644 --- a/packages/utils/src/createSchemaUtils.ts +++ b/packages/utils/src/createSchemaUtils.ts @@ -14,9 +14,9 @@ import { ValidatorType, } from './types'; import { + getClosestMatchingOption, getDefaultFormState, getDisplayLabel, - getClosestMatchingOption, getFirstMatchingOption, getMatchingOption, isFilesArray, diff --git a/packages/utils/src/deepEquals.ts b/packages/utils/src/deepEquals.ts index 2e2538848e..61aa6fe292 100644 --- a/packages/utils/src/deepEquals.ts +++ b/packages/utils/src/deepEquals.ts @@ -1,6 +1,37 @@ -import isEqualWith from 'lodash/isEqualWith'; +import { createCustomEqual, State } from 'fast-equals'; -/** Implements a deep equals using the `lodash.isEqualWith` function, that provides a customized comparator that +/** Check if all parameters are typeof function. + * + * @param a - The first element to check typeof + * @param b - The second element to check typeof + * @returns - if typeof a and b are equal to function return true, otherwise false + */ +function isFunctions(a: any, b: any) { + return typeof a === 'function' && typeof b === 'function'; +} + +/** Implements a deep equals using the `fast-equal.createCustomEqual` function, that provides a customized comparator that + * assumes all functions in objects are equivalent. + * + * @param a - The first element to compare + * @param b - The second element to compare + * @returns - True if the `a` and `b` are deeply equal, false otherwise + */ +const customDeepEqual = createCustomEqual({ + createInternalComparator: (comparator: (a: any, b: any, state: State) => boolean) => { + return (a: any, b: any, _idxA: any, _idxB: any, _parentA: any, _parentB: any, state: State) => { + if (isFunctions(a, b)) { + // Assume all functions are equivalent + // see https://github.com/rjsf-team/react-jsonschema-form/issues/255 + return true; + } + + return comparator(a, b, state); + }; + }, +}); + +/** Implements a deep equals using the `fast-equal.createCustomEqual` function, that provides a customized comparator that * assumes all functions are equivalent. * * @param a - The first element to compare @@ -8,12 +39,8 @@ import isEqualWith from 'lodash/isEqualWith'; * @returns - True if the `a` and `b` are deeply equal, false otherwise */ export default function deepEquals(a: any, b: any): boolean { - return isEqualWith(a, b, (obj: any, other: any) => { - if (typeof obj === 'function' && typeof other === 'function') { - // Assume all functions are equivalent - // see https://github.com/rjsf-team/react-jsonschema-form/issues/255 - return true; - } - return undefined; // fallback to default isEquals behavior - }); + if (isFunctions(a, b)) { + return true; + } + return customDeepEqual(a, b); } diff --git a/packages/utils/src/enumOptionsDeselectValue.ts b/packages/utils/src/enumOptionsDeselectValue.ts index 1dde198c32..88384321e5 100644 --- a/packages/utils/src/enumOptionsDeselectValue.ts +++ b/packages/utils/src/enumOptionsDeselectValue.ts @@ -1,7 +1,6 @@ -import isEqual from 'lodash/isEqual'; - import { EnumOptionsType, RJSFSchema, StrictRJSFSchema } from './types'; import enumOptionsValueForIndex from './enumOptionsValueForIndex'; +import deepEquals from './deepEquals'; /** Removes the enum option value at the `valueIndex` from the currently `selected` (list of) value(s). If `selected` is * a list, then that list is updated to remove the enum option value with the `valueIndex` in `allEnumOptions`. If it is @@ -22,7 +21,7 @@ export default function enumOptionsDeselectValue['value'] | EnumOptionsType['value'][] | undefined { const value = enumOptionsValueForIndex(valueIndex, allEnumOptions); if (Array.isArray(selected)) { - return selected.filter((v) => !isEqual(v, value)); + return selected.filter((v) => !deepEquals(v, value)); } - return isEqual(value, selected) ? undefined : selected; + return deepEquals(value, selected) ? undefined : selected; } diff --git a/packages/utils/src/enumOptionsIsSelected.ts b/packages/utils/src/enumOptionsIsSelected.ts index a1c9fed1dd..e7c782bade 100644 --- a/packages/utils/src/enumOptionsIsSelected.ts +++ b/packages/utils/src/enumOptionsIsSelected.ts @@ -1,5 +1,4 @@ -import isEqual from 'lodash/isEqual'; - +import deepEquals from './deepEquals'; import { EnumOptionsType, RJSFSchema, StrictRJSFSchema } from './types'; /** Determines whether the given `value` is (one of) the `selected` value(s). @@ -13,7 +12,7 @@ export default function enumOptionsIsSelected['value'] | EnumOptionsType['value'][] ) { if (Array.isArray(selected)) { - return selected.some((sel) => isEqual(sel, value)); + return selected.some((sel) => deepEquals(sel, value)); } - return isEqual(selected, value); + return deepEquals(selected, value); } diff --git a/packages/utils/src/parser/ParserValidator.ts b/packages/utils/src/parser/ParserValidator.ts index f1b771fc39..d6411a85f7 100644 --- a/packages/utils/src/parser/ParserValidator.ts +++ b/packages/utils/src/parser/ParserValidator.ts @@ -1,5 +1,4 @@ import get from 'lodash/get'; -import isEqual from 'lodash/isEqual'; import { ID_KEY } from '../constants'; import hashForSchema from '../hashForSchema'; @@ -15,6 +14,7 @@ import { ValidationData, ValidatorType, } from '../types'; +import deepEquals from '../deepEquals'; /** The type of the map of schema hash to schema */ @@ -67,7 +67,7 @@ export default class ParserValidator(schema)); diff --git a/packages/utils/src/parser/schemaParser.ts b/packages/utils/src/parser/schemaParser.ts index 70151f06a0..f7e8ffa538 100644 --- a/packages/utils/src/parser/schemaParser.ts +++ b/packages/utils/src/parser/schemaParser.ts @@ -1,10 +1,10 @@ import forEach from 'lodash/forEach'; -import isEqual from 'lodash/isEqual'; import { FormContextType, RJSFSchema, StrictRJSFSchema } from '../types'; -import { PROPERTIES_KEY, ITEMS_KEY } from '../constants'; +import { ITEMS_KEY, PROPERTIES_KEY } from '../constants'; import ParserValidator, { SchemaMap } from './ParserValidator'; -import { retrieveSchemaInternal, resolveAnyOrOneOfSchemas } from '../schema/retrieveSchema'; +import { resolveAnyOrOneOfSchemas, retrieveSchemaInternal } from '../schema/retrieveSchema'; +import deepEquals from '../deepEquals'; /** Recursive function used to parse the given `schema` belonging to the `rootSchema`. The `validator` is used to * capture the sub-schemas that the `isValid()` function is called with. For each schema returned by the @@ -24,7 +24,7 @@ function parseSchema(validator, schema, rootSchema, undefined, true); schemas.forEach((schema) => { - const sameSchemaIndex = recurseList.findIndex((item) => isEqual(item, schema)); + const sameSchemaIndex = recurseList.findIndex((item) => deepEquals(item, schema)); if (sameSchemaIndex === -1) { recurseList.push(schema); const allOptions = resolveAnyOrOneOfSchemas(validator, schema, rootSchema, true); diff --git a/packages/utils/src/schema/retrieveSchema.ts b/packages/utils/src/schema/retrieveSchema.ts index 0f46632f1c..7a66f47f56 100644 --- a/packages/utils/src/schema/retrieveSchema.ts +++ b/packages/utils/src/schema/retrieveSchema.ts @@ -1,5 +1,4 @@ import get from 'lodash/get'; -import isEqual from 'lodash/isEqual'; import set from 'lodash/set'; import times from 'lodash/times'; import transform from 'lodash/transform'; @@ -15,10 +14,10 @@ import { ANY_OF_KEY, DEPENDENCIES_KEY, IF_KEY, + ITEMS_KEY, ONE_OF_KEY, - REF_KEY, PROPERTIES_KEY, - ITEMS_KEY, + REF_KEY, } from '../constants'; import findSchemaDefinition, { splitKeyElementFromObject } from '../findSchemaDefinition'; import getDiscriminatorFieldFromSchema from '../getDiscriminatorFieldFromSchema'; @@ -27,6 +26,7 @@ import isObject from '../isObject'; import mergeSchemas from '../mergeSchemas'; import { FormContextType, GenericObjectType, RJSFSchema, StrictRJSFSchema, ValidatorType } from '../types'; import getFirstMatchingOption from './getFirstMatchingOption'; +import deepEquals from '../deepEquals'; /** Retrieves an expanded schema that has had all of its conditions, additional properties, references and dependencies * resolved and merged into the `schema` given a `validator`, `rootSchema` and `rawFormData` that is used to do the @@ -196,7 +196,10 @@ export function resolveSchema(allOfSchemaElements); - return allPermutations.map((permutation) => ({ ...schema, allOf: permutation })); + return allPermutations.map((permutation) => ({ + ...schema, + allOf: permutation, + })); } // No $ref or dependencies or allOf attribute was found, returning the original schema. return [schema]; @@ -293,7 +296,7 @@ export function resolveAllReferences( }; } - return isEqual(schema, resolvedSchema) ? schema : resolvedSchema; + return deepEquals(schema, resolvedSchema) ? schema : resolvedSchema; } /** Creates new 'properties' items for each key in the `formData` diff --git a/packages/utils/src/schema/toIdSchema.ts b/packages/utils/src/schema/toIdSchema.ts index 04fb79eaac..eaeb8dd5f7 100644 --- a/packages/utils/src/schema/toIdSchema.ts +++ b/packages/utils/src/schema/toIdSchema.ts @@ -1,11 +1,11 @@ import get from 'lodash/get'; -import isEqual from 'lodash/isEqual'; import { ALL_OF_KEY, DEPENDENCIES_KEY, ID_KEY, ITEMS_KEY, PROPERTIES_KEY, REF_KEY } from '../constants'; import isObject from '../isObject'; import { FormContextType, GenericObjectType, IdSchema, RJSFSchema, StrictRJSFSchema, ValidatorType } from '../types'; import retrieveSchema from './retrieveSchema'; import getSchemaType from '../getSchemaType'; +import deepEquals from '../deepEquals'; /** An internal helper that generates an `IdSchema` object for the `schema`, recursively with protection against * infinite recursion @@ -32,7 +32,7 @@ function toIdSchemaInternal { if (REF_KEY in schema || DEPENDENCIES_KEY in schema || ALL_OF_KEY in schema) { const _schema = retrieveSchema(validator, schema, rootSchema, formData); - const sameSchemaIndex = _recurseList.findIndex((item) => isEqual(item, _schema)); + const sameSchemaIndex = _recurseList.findIndex((item) => deepEquals(item, _schema)); if (sameSchemaIndex === -1) { return toIdSchemaInternal( validator, diff --git a/packages/utils/src/schema/toPathSchema.ts b/packages/utils/src/schema/toPathSchema.ts index e0b2abb368..a33ee03c7a 100644 --- a/packages/utils/src/schema/toPathSchema.ts +++ b/packages/utils/src/schema/toPathSchema.ts @@ -1,11 +1,10 @@ import get from 'lodash/get'; -import isEqual from 'lodash/isEqual'; import set from 'lodash/set'; import { + ADDITIONAL_PROPERTIES_KEY, ALL_OF_KEY, ANY_OF_KEY, - ADDITIONAL_PROPERTIES_KEY, DEPENDENCIES_KEY, ITEMS_KEY, NAME_KEY, @@ -18,6 +17,7 @@ import getDiscriminatorFieldFromSchema from '../getDiscriminatorFieldFromSchema' import { FormContextType, GenericObjectType, PathSchema, RJSFSchema, StrictRJSFSchema, ValidatorType } from '../types'; import getClosestMatchingOption from './getClosestMatchingOption'; import retrieveSchema from './retrieveSchema'; +import deepEquals from '../deepEquals'; /** An internal helper that generates an `PathSchema` object for the `schema`, recursively with protection against * infinite recursion @@ -40,7 +40,7 @@ function toPathSchemaInternal { if (REF_KEY in schema || DEPENDENCIES_KEY in schema || ALL_OF_KEY in schema) { const _schema = retrieveSchema(validator, schema, rootSchema, formData); - const sameSchemaIndex = _recurseList.findIndex((item) => isEqual(item, _schema)); + const sameSchemaIndex = _recurseList.findIndex((item) => deepEquals(item, _schema)); if (sameSchemaIndex === -1) { return toPathSchemaInternal( validator, diff --git a/packages/utils/test/deepEquals.test.ts b/packages/utils/test/deepEquals.test.ts index ba8eef53cc..cc7ea67648 100644 --- a/packages/utils/test/deepEquals.test.ts +++ b/packages/utils/test/deepEquals.test.ts @@ -1,7 +1,7 @@ import { deepEquals } from '../src'; describe('deepEquals()', () => { - // Note: deepEquals implementation uses isEqualWith, so we focus on the behavioral differences we introduced. + // Note: deepEquals implementation uses fast-equal.createCustomEqual, so we focus on the behavioral differences we introduced. it('should assume functions are always equivalent', () => { expect( deepEquals( diff --git a/packages/validator-ajv8/src/precompiledValidator.ts b/packages/validator-ajv8/src/precompiledValidator.ts index 3b201b9a90..648d52d274 100644 --- a/packages/validator-ajv8/src/precompiledValidator.ts +++ b/packages/validator-ajv8/src/precompiledValidator.ts @@ -1,21 +1,21 @@ import { ErrorObject } from 'ajv'; import get from 'lodash/get'; -import isEqual from 'lodash/isEqual'; import { CustomValidator, + deepEquals, ErrorSchema, ErrorTransformer, FormContextType, hashForSchema, ID_KEY, JUNK_OPTION_ID, + retrieveSchema, RJSFSchema, StrictRJSFSchema, toErrorList, UiSchema, ValidationData, ValidatorType, - retrieveSchema, } from '@rjsf/utils'; import { CompiledValidateFunction, Localizer, ValidatorFunctions } from './types'; @@ -92,10 +92,10 @@ export default class AJV8PrecompiledValidator< * @param [formData] - The form data to validate if any */ ensureSameRootSchema(schema: S, formData?: T) { - if (!isEqual(schema, this.rootSchema)) { + if (!deepEquals(schema, this.rootSchema)) { // Resolve the root schema with the passed in form data since that may affect the resolution const resolvedRootSchema = retrieveSchema(this, this.rootSchema, this.rootSchema, formData); - if (!isEqual(schema, resolvedRootSchema)) { + if (!deepEquals(schema, resolvedRootSchema)) { throw new Error( 'The schema associated with the precompiled validator differs from the rootSchema provided for validation' );