Skip to content

Commit 029b5ec

Browse files
perf(utils, validator-ajv8): improve deepEquals performance and skip redundant Ajv schema re-registration (#5046)
* perf(utils): switch deepEquals to fast-equals with cycle detection Replaces lodash.isEqualWith with fast-equals.createCustomEqual configured with circular: true so cyclic formData/extraErrors do not crash with `Maximum call stack size exceeded`. Migrates the remaining lodash.isEqual call sites in useDeepCompareMemo, isRootSchema and findSelectedOptionInXxxOf to use deepEquals so the perf benefit is shared across the package. Adds regression tests for self-referential, mutually-referential and cyclic-array inputs to pin cycle handling in place. Refs #4291 * perf(validator-ajv8): cache rootSchema reference in handleSchemaUpdate retrieveSchema invokes isValid (and therefore handleSchemaUpdate) once per conditional or dependency branch evaluated during a render. On a form with a large rootSchema and many branches this used to dominate click cost because every call walked the entire rootSchema via deepEquals to detect changes. Cache the last-seen rootSchema reference and skip both the deepEquals and the Ajv re-registration when the reference matches. Falls back to the existing structural deepEquals when the reference changes, preserving correctness for callers that legitimately rebuild the schema. Refs #4291 * Apply suggestions from code review Co-authored-by: Heath C <51679588+heath-freenome@users.noreply.github.com> --------- Co-authored-by: Heath C <51679588+heath-freenome@users.noreply.github.com>
1 parent ede2f13 commit 029b5ec

10 files changed

Lines changed: 151 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ should change the heading of the (upcoming) version to include a major version b
1818

1919
# 6.5.2
2020

21+
## @rjsf/utils
22+
23+
- Switched `deepEquals` from `lodash.isEqualWith` to `fast-equals.createCustomEqual` with cycle detection enabled, and replaced direct `lodash.isEqual` usage in `useDeepCompareMemo`, `isRootSchema`, and `findSelectedOptionInXxxOf` with `deepEquals`, fixing [#4291](https://github.com/rjsf-team/react-jsonschema-form/issues/4291).
24+
25+
## @rjsf/validator-ajv8
26+
27+
- Cached the most recent `rootSchema` reference in `handleSchemaUpdate` so repeated `isValid` calls with the same root schema skip the deep-equality check and Ajv re-registration, fixing [#4291](https://github.com/rjsf-team/react-jsonschema-form/issues/4291).
28+
2129
## Dev / docs / playground
2230

2331
- Cleaned up testing to make registry mocks simpler using `getTestRegistry()` function

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/utils/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
},
6767
"dependencies": {
6868
"@x0k/json-schema-merge": "^1.0.3",
69+
"fast-equals": "^6.0.0",
6970
"fast-uri": "^3.1.0",
7071
"jsonpointer": "^5.0.1",
7172
"lodash": "^4.18.1",

packages/utils/src/deepEquals.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
1-
import isEqualWith from 'lodash/isEqualWith';
1+
import { createCustomEqual } from 'fast-equals';
22

3-
/** Implements a deep equals using the `lodash.isEqualWith` function, that provides a customized comparator that
4-
* assumes all functions are equivalent.
3+
/** Implements a deep equals using `fast-equals.createCustomEqual`. Functions
4+
* are always considered equal, and circular references are tracked to avoid
5+
* infinite recursion on self-referential inputs.
56
*
67
* @param a - The first element to compare
78
* @param b - The second element to compare
89
* @returns - True if the `a` and `b` are deeply equal, false otherwise
910
*/
10-
export default function deepEquals(a: any, b: any): boolean {
11-
return isEqualWith(a, b, (obj: any, other: any) => {
12-
if (typeof obj === 'function' && typeof other === 'function') {
13-
// Assume all functions are equivalent
14-
// see https://github.com/rjsf-team/react-jsonschema-form/issues/255
15-
return true;
16-
}
17-
return undefined; // fallback to default isEquals behavior
18-
});
19-
}
11+
const deepEquals = createCustomEqual({
12+
circular: true,
13+
createCustomConfig: () => ({
14+
areFunctionsEqual(_a, b) {
15+
return typeof b === 'function';
16+
},
17+
}),
18+
});
19+
20+
export default deepEquals;

packages/utils/src/isRootSchema.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import isEqual from 'lodash/isEqual';
21
import omit from 'lodash/omit';
32

3+
import deepEquals from './deepEquals';
44
import { FormContextType, Registry, RJSFSchema, StrictRJSFSchema } from './types';
55
import { REF_KEY, RJSF_REF_KEY } from './constants';
66

@@ -20,12 +20,12 @@ export default function isRootSchema<T = any, S extends StrictRJSFSchema = RJSFS
2020
schemaToCompare: S,
2121
): boolean {
2222
const { rootSchema, schemaUtils } = registry;
23-
if (isEqual(schemaToCompare, rootSchema)) {
23+
if (deepEquals(schemaToCompare, rootSchema)) {
2424
return true;
2525
}
2626
if (REF_KEY in rootSchema) {
2727
const resolvedSchema = schemaUtils.retrieveSchema(rootSchema);
28-
return isEqual(schemaToCompare, omit(resolvedSchema, RJSF_REF_KEY));
28+
return deepEquals(schemaToCompare, omit(resolvedSchema, RJSF_REF_KEY));
2929
}
3030
return false;
3131
}

packages/utils/src/schema/findSelectedOptionInXxxOf.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import get from 'lodash/get';
2-
import isEqual from 'lodash/isEqual';
32

43
import { CONST_KEY, DEFAULT_KEY, PROPERTIES_KEY } from '../constants';
4+
import deepEquals from '../deepEquals';
55
import { Experimental_CustomMergeAllOf, FormContextType, RJSFSchema, StrictRJSFSchema, ValidatorType } from '../types';
66
import retrieveSchema from './retrieveSchema';
77
import getDiscriminatorFieldFromSchema from '../getDiscriminatorFieldFromSchema';
@@ -42,7 +42,7 @@ export default function findSelectedOptionInXxxOf<
4242
const data = get(formData, selectorField);
4343
if (data !== undefined) {
4444
return xxxOfs.find((xxx) => {
45-
return isEqual(
45+
return deepEquals(
4646
get(xxx, [PROPERTIES_KEY, selectorField, DEFAULT_KEY], get(xxx, [PROPERTIES_KEY, selectorField, CONST_KEY])),
4747
data,
4848
);

packages/utils/src/useDeepCompareMemo.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
'use client';
22

33
import { useRef } from 'react';
4-
import isEqual from 'lodash/isEqual';
4+
5+
import deepEquals from './deepEquals';
56

67
/** Hook that stores and returns a `T` value. If `newValue` is the same as the stored one, then the stored one is
78
* returned to avoid having a component rerender due it being a different object. Otherwise, the `newValue` is stored
@@ -12,7 +13,7 @@ import isEqual from 'lodash/isEqual';
1213
*/
1314
export default function useDeepCompareMemo<T = unknown>(newValue: T): T {
1415
const valueRef = useRef<T>(newValue);
15-
if (!isEqual(newValue, valueRef.current)) {
16+
if (!deepEquals(newValue, valueRef.current)) {
1617
valueRef.current = newValue;
1718
}
1819
return valueRef.current;

packages/utils/test/deepEquals.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { deepEquals } from '../src';
22

33
describe('deepEquals()', () => {
4-
// Note: deepEquals implementation uses isEqualWith, so we focus on the behavioral differences we introduced.
54
it('should assume functions are always equivalent', () => {
65
expect(
76
deepEquals(
@@ -12,4 +11,45 @@ describe('deepEquals()', () => {
1211
expect(deepEquals({ foo() {} }, { foo() {} })).toBe(true);
1312
expect(deepEquals({ foo: { bar() {} } }, { foo: { bar() {} } })).toBe(true);
1413
});
14+
15+
it('does not stack-overflow on self-referential objects', () => {
16+
const a: any = { foo: 1 };
17+
a.self = a;
18+
const b: any = { foo: 1 };
19+
b.self = b;
20+
expect(() => deepEquals(a, b)).not.toThrow();
21+
expect(deepEquals(a, b)).toBe(true);
22+
});
23+
24+
it('detects differences in self-referential objects', () => {
25+
const a: any = { foo: 1 };
26+
a.self = a;
27+
const b: any = { foo: 2 };
28+
b.self = b;
29+
expect(deepEquals(a, b)).toBe(false);
30+
});
31+
32+
it('does not stack-overflow on mutually-referential objects', () => {
33+
const a1: any = { name: 'a' };
34+
const a2: any = { name: 'b' };
35+
a1.partner = a2;
36+
a2.partner = a1;
37+
38+
const b1: any = { name: 'a' };
39+
const b2: any = { name: 'b' };
40+
b1.partner = b2;
41+
b2.partner = b1;
42+
43+
expect(() => deepEquals(a1, b1)).not.toThrow();
44+
expect(deepEquals(a1, b1)).toBe(true);
45+
});
46+
47+
it('does not stack-overflow on cyclic arrays', () => {
48+
const a: any[] = [1];
49+
a.push(a);
50+
const b: any[] = [1];
51+
b.push(b);
52+
expect(() => deepEquals(a, b)).not.toThrow();
53+
expect(deepEquals(a, b)).toBe(true);
54+
});
1555
});

packages/validator-ajv8/src/validator.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,18 @@ export default class AJV8Validator<
4444
*/
4545
readonly suppressDuplicateFiltering?: SuppressDuplicateFilteringType;
4646

47+
/** Most recent `rootSchema` reference processed by `handleSchemaUpdate`.
48+
*
49+
* @private
50+
*/
51+
private lastSeenRootSchema?: S;
52+
53+
/** True once a `rootSchema` has been registered with Ajv in this lifecycle.
54+
*
55+
* @private
56+
*/
57+
private hasRegisteredRootSchema = false;
58+
4759
/** Constructs an `AJV8Validator` instance using the `options`
4860
*
4961
* @param options - The `CustomValidatorOptionsType` options that are used to create the AJV instance
@@ -75,6 +87,8 @@ export default class AJV8Validator<
7587
*/
7688
reset() {
7789
this.ajv.removeSchema();
90+
this.lastSeenRootSchema = undefined;
91+
this.hasRegisteredRootSchema = false;
7892
}
7993

8094
/** Runs the pure validation of the `schema` and `formData` without any of the RJSF functionality. Provided for use
@@ -181,9 +195,14 @@ export default class AJV8Validator<
181195

182196
/**
183197
* This function checks if a schema needs to be added and if the root schemas don't match it removes the old root schema from the ajv instance and adds the new one.
198+
* When called repeatedly with the same `rootSchema` reference the deep-equality check is skipped.
199+
*
184200
* @param rootSchema - The root schema used to provide $ref resolutions
185201
*/
186202
handleSchemaUpdate(rootSchema: S): void {
203+
if (this.lastSeenRootSchema === rootSchema && this.hasRegisteredRootSchema) {
204+
return;
205+
}
187206
const rootSchemaId = rootSchema[ID_KEY] ?? ROOT_SCHEMA_PREFIX;
188207
// add the rootSchema ROOT_SCHEMA_PREFIX as id.
189208
// if schema validator instance doesn't exist, add it.
@@ -194,6 +213,8 @@ export default class AJV8Validator<
194213
this.ajv.removeSchema(rootSchemaId);
195214
this.ajv.addSchema(rootSchema, rootSchemaId);
196215
}
216+
this.lastSeenRootSchema = rootSchema;
217+
this.hasRegisteredRootSchema = true;
197218
}
198219

199220
/** Validates data against a schema, returning true if the data is valid, or

packages/validator-ajv8/test/validator.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,54 @@ describe('AJV8Validator', () => {
144144
getSchemaSpy.mockRestore();
145145
expect(compileSpy).toHaveBeenCalledTimes(1);
146146
});
147+
it('skips Ajv re-registration when handleSchemaUpdate is called repeatedly with the same rootSchema reference', () => {
148+
const localValidator = new AJV8Validator({});
149+
const rootSchema: RJSFSchema = {
150+
type: 'object',
151+
properties: { foo: { type: 'string' } },
152+
};
153+
154+
const addSchemaSpy = jest.spyOn(localValidator.ajv, 'addSchema');
155+
const removeSchemaSpy = jest.spyOn(localValidator.ajv, 'removeSchema');
156+
157+
localValidator.handleSchemaUpdate(rootSchema);
158+
localValidator.handleSchemaUpdate(rootSchema);
159+
localValidator.handleSchemaUpdate(rootSchema);
160+
localValidator.handleSchemaUpdate(rootSchema);
161+
162+
expect(addSchemaSpy).toHaveBeenCalledTimes(1);
163+
expect(removeSchemaSpy).not.toHaveBeenCalled();
164+
165+
addSchemaSpy.mockRestore();
166+
removeSchemaSpy.mockRestore();
167+
});
168+
it('falls back to deep-equality when handleSchemaUpdate receives a different rootSchema reference', () => {
169+
const localValidator = new AJV8Validator({});
170+
const rootSchemaA: RJSFSchema = {
171+
type: 'object',
172+
properties: { foo: { type: 'string' } },
173+
};
174+
const rootSchemaAClone: RJSFSchema = JSON.parse(JSON.stringify(rootSchemaA));
175+
const rootSchemaB: RJSFSchema = {
176+
type: 'object',
177+
properties: { foo: { type: 'number' } },
178+
};
179+
180+
const addSchemaSpy = jest.spyOn(localValidator.ajv, 'addSchema');
181+
const removeSchemaSpy = jest.spyOn(localValidator.ajv, 'removeSchema');
182+
183+
localValidator.handleSchemaUpdate(rootSchemaA);
184+
localValidator.handleSchemaUpdate(rootSchemaAClone);
185+
expect(addSchemaSpy).toHaveBeenCalledTimes(1);
186+
expect(removeSchemaSpy).not.toHaveBeenCalled();
187+
188+
localValidator.handleSchemaUpdate(rootSchemaB);
189+
expect(removeSchemaSpy).toHaveBeenCalledTimes(1);
190+
expect(addSchemaSpy).toHaveBeenCalledTimes(2);
191+
192+
addSchemaSpy.mockRestore();
193+
removeSchemaSpy.mockRestore();
194+
});
147195
});
148196
describe('validator.validateFormData()', () => {
149197
describe('No custom validate function, single value', () => {

0 commit comments

Comments
 (0)