Skip to content

Commit

Permalink
feat: improve deepEquals performance (#4292)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
igorbrasileiro and heath-freenome authored Sep 9, 2024
1 parent fc1c8c3 commit 514ea85
Show file tree
Hide file tree
Showing 15 changed files with 89 additions and 42 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,6 @@
"packages/validator-ajv6",
"packages/validator-ajv8",
"packages/snapshot-tests"
]
],
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
1 change: 1 addition & 0 deletions packages/utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/utils/src/createSchemaUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ import {
ValidatorType,
} from './types';
import {
getClosestMatchingOption,
getDefaultFormState,
getDisplayLabel,
getClosestMatchingOption,
getFirstMatchingOption,
getMatchingOption,
isFilesArray,
Expand Down
47 changes: 37 additions & 10 deletions packages/utils/src/deepEquals.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,46 @@
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<any>) => boolean) => {
return (a: any, b: any, _idxA: any, _idxB: any, _parentA: any, _parentB: any, state: State<any>) => {
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
* @param b - The second element to compare
* @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);
}
7 changes: 3 additions & 4 deletions packages/utils/src/enumOptionsDeselectValue.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -22,7 +21,7 @@ export default function enumOptionsDeselectValue<S extends StrictRJSFSchema = RJ
): EnumOptionsType<S>['value'] | EnumOptionsType<S>['value'][] | undefined {
const value = enumOptionsValueForIndex<S>(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;
}
7 changes: 3 additions & 4 deletions packages/utils/src/enumOptionsIsSelected.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -13,7 +12,7 @@ export default function enumOptionsIsSelected<S extends StrictRJSFSchema = RJSFS
selected: EnumOptionsType<S>['value'] | EnumOptionsType<S>['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);
}
6 changes: 3 additions & 3 deletions packages/utils/src/parser/ParserValidator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import get from 'lodash/get';
import isEqual from 'lodash/isEqual';

import { ID_KEY } from '../constants';
import hashForSchema from '../hashForSchema';
Expand All @@ -15,6 +14,7 @@ import {
ValidationData,
ValidatorType,
} from '../types';
import deepEquals from '../deepEquals';

/** The type of the map of schema hash to schema
*/
Expand Down Expand Up @@ -67,7 +67,7 @@ export default class ParserValidator<T = any, S extends StrictRJSFSchema = RJSFS
const existing = this.schemaMap[key];
if (!existing) {
this.schemaMap[key] = identifiedSchema;
} else if (!isEqual(existing, identifiedSchema)) {
} else if (!deepEquals(existing, identifiedSchema)) {
console.error('existing schema:', JSON.stringify(existing, null, 2));
console.error('new schema:', JSON.stringify(identifiedSchema, null, 2));
throw new Error(
Expand All @@ -91,7 +91,7 @@ export default class ParserValidator<T = any, S extends StrictRJSFSchema = RJSFS
* @throws - Error when the given `rootSchema` differs from the root schema provided during construction
*/
isValid(schema: S, _formData: T, rootSchema: S): boolean {
if (!isEqual(rootSchema, this.rootSchema)) {
if (!deepEquals(rootSchema, this.rootSchema)) {
throw new Error('Unexpectedly calling isValid() with a rootSchema that differs from the construction rootSchema');
}
this.addSchema(schema, hashForSchema<S>(schema));
Expand Down
8 changes: 4 additions & 4 deletions packages/utils/src/parser/schemaParser.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -24,7 +24,7 @@ function parseSchema<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends
) {
const schemas = retrieveSchemaInternal<T, S, F>(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<T, S, F>(validator, schema, rootSchema, true);
Expand Down
13 changes: 8 additions & 5 deletions packages/utils/src/schema/retrieveSchema.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -196,7 +196,10 @@ export function resolveSchema<T = any, S extends StrictRJSFSchema = RJSFSchema,
)
);
const allPermutations = getAllPermutationsOfXxxOf<S>(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];
Expand Down Expand Up @@ -293,7 +296,7 @@ export function resolveAllReferences<S extends StrictRJSFSchema = RJSFSchema>(
};
}

return isEqual(schema, resolvedSchema) ? schema : resolvedSchema;
return deepEquals(schema, resolvedSchema) ? schema : resolvedSchema;
}

/** Creates new 'properties' items for each key in the `formData`
Expand Down
4 changes: 2 additions & 2 deletions packages/utils/src/schema/toIdSchema.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -32,7 +32,7 @@ function toIdSchemaInternal<T = any, S extends StrictRJSFSchema = RJSFSchema, F
): IdSchema<T> {
if (REF_KEY in schema || DEPENDENCIES_KEY in schema || ALL_OF_KEY in schema) {
const _schema = retrieveSchema<T, S, F>(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<T, S, F>(
validator,
Expand Down
6 changes: 3 additions & 3 deletions packages/utils/src/schema/toPathSchema.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -40,7 +40,7 @@ function toPathSchemaInternal<T = any, S extends StrictRJSFSchema = RJSFSchema,
): PathSchema<T> {
if (REF_KEY in schema || DEPENDENCIES_KEY in schema || ALL_OF_KEY in schema) {
const _schema = retrieveSchema<T, S, F>(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<T, S, F>(
validator,
Expand Down
2 changes: 1 addition & 1 deletion packages/utils/test/deepEquals.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
8 changes: 4 additions & 4 deletions packages/validator-ajv8/src/precompiledValidator.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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'
);
Expand Down

0 comments on commit 514ea85

Please sign in to comment.