Skip to content

Commit eb77ce9

Browse files
Fix 3367 so that multiple near simultaneous changes don't lose data (#4721)
* Fix 3367 so that multiple near simultaneous changes don't lose data Fixes #3367 by changing how the `onChange` callback works so that it queues up changes at the field level rather than the whole formData level - Updated `package*.json` to install `is-ci` and changed `prepare` to be `"is-ci || husky"` - Updated `.eslintrc.json` to add `"react-hooks/exhaustive-deps": "error",` so that we use hook dependencies properly - Updated `@rjsf/core`'s `CheckboxesWidget`, and `playground`'s `Header` and `Playground` to fix the dependencies - Updated `@rjsf/daisyui`'s `DateTimeWidget` and `DateWidget` to fix the dependencies - Updated `docs/index.md` to remove the details about older versions since we have them now - Updated `playground`'s samples to fix a few issues in them - Updated `customArray.tsx` to fix the `ArrayFieldTemplate` to use the correct `buttonsProps` prop on the `element` - Updated `customFieldAnyOf.tsx` to make it properly use the theme components by switching to `FieldTemplate`, `StringField` and `NumberField` - Updated `@rjsf/core` and `@rjsf/mantine` theme's `AltDateWidget` to fix a bug with how the clear button works with the updated `onChange` process - Updated `@rjsf/utils` to make a BREAKING CHANGE to the `FieldProps.onChange` prop to inject a `path?: (number | string)[]` before the `ErrorSchema` parameter - Updated `@rjsf/core` to fix #3367 as follows: - Updated `BooleanField` and `StringField` to add an `onWidgetChange` intermediate callback to insert `[]` into the field's `onChange()` callback - Updated `ArrayField` and `ObjectField` to inject the new `path` parameter as needed - Also, made the main `onChange` handler pass the value rather than building the whole `formData` and `errorSchema` - Updated `LayoutGridField` to update `onFieldChange()` to add the `path` on the handler and use the `dottedPath` to pass down the real `path` to `onChange` - Updated `LayoutMultiSchemaField`, `MultiSchemaField`, `NullField` and `SchemaField` to add the `path` on the handlers and passing to `onChange` - Updated `Form` to refactor the `onChange` handler to support queuing changes into a new `pendingChanges[]` array and calling the new `processPendingChange()` function - The `processPendingChange()` function takes the `newValue` and the `path` and sets that value into the `formData` and the `newErrorSchema` into the `errorSchema` - Updated the custom fields in the tests for `ArrayField`, `ObjectField` and `StringField` tests for the new `onChange` handling mechanism - Updated the tests for `LayoutGridField` and `LayoutMultiSchemaField` to deal with the new `onChange` handling mechanism - Updated the tests for `Form` to add testing of near simultaneous changes to verify the fix works - Updated the `custom-widgets-fields.md` documentation to reflect the changes to the `onChange` handling - Updated the `v6.x upgrade guide.md` to document the breaking changes around the `FieldProps.onChange` handling - Updated the `CHANGELOG.md` file accordingly * - Fixed up a few little things * - More minor fixes
1 parent afdeb70 commit eb77ce9

34 files changed

+656
-297
lines changed

.eslintrc.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"beforeSelfClosing": "always"
2020
}
2121
],
22+
"react-hooks/exhaustive-deps": "error",
2223
"curly": [
2324
2
2425
],

CHANGELOG.md

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

2222
- Added support for dynamic UI schema in array fields - the `items` property in `uiSchema` can now accept a function that returns a UI schema based on the array item's data, index, and form context ([#4706](https://github.com/rjsf-team/react-jsonschema-form/pull/4706))
2323
- Fixed checkbox widget to use current value instead of event target in onFocus/onBlur handlers, fixing [#4704](https://github.com/rjsf-team/react-jsonschema-form/issues/4704)
24+
- Updated all of the `XxxxField` components and `Form` to handle the new `path` parameter in `FieldProps.onChange`, making `Form` queue up changes so that they are all processed and no data is lost, fixing [#3367](https://github.com/rjsf-team/react-jsonschema-form/issues/3367)
25+
- Updated a bug in `AltDateWidget` related to the `clear` button not working after the fix for [#3367](https://github.com/rjsf-team/react-jsonschema-form/issues/3367)
26+
- Fixed the missing hook dependencies for the `CheckboxesWidget` so that they work properly
2427

2528
## @rjsf/chakra-ui
2629

@@ -29,6 +32,7 @@ should change the heading of the (upcoming) version to include a major version b
2932
## @rjsf/daisyui
3033

3134
- Fixed checkbox widget to use current value instead of event target in onFocus/onBlur handlers, fixing [#4704](https://github.com/rjsf-team/react-jsonschema-form/issues/4704)
35+
- Fixed the missing hook dependencies in the `DateTimeWidget` and `DateWidget` so that they work properly
3236

3337
## @rjsf/fluentui-rc
3438

@@ -62,6 +66,7 @@ should change the heading of the (upcoming) version to include a major version b
6266

6367
- Updated `UiSchema` type to support dynamic array item UI schemas - the `items` property can now be either a `UiSchema` object or a function that returns a `UiSchema` ([#4706](https://github.com/rjsf-team/react-jsonschema-form/pull/4706))
6468
- Added `title` property to `RJSFValidationError` [PR](https://github.com/rjsf-team/react-jsonschema-form/pull/4700)
69+
- BREAKING CHANGE: Updated the `FieldProps` interface's `onChange` handler to inject a new optional `path` before the `ErrorSchema` parameter as part of the fix for [#3367](https://github.com/rjsf-team/react-jsonschema-form/issues/3367)
6570

6671
## @rjsf/validator-ajv8
6772

@@ -72,6 +77,9 @@ should change the heading of the (upcoming) version to include a major version b
7277
- Added comprehensive documentation for dynamic UI schema feature with TypeScript examples ([#4706](https://github.com/rjsf-team/react-jsonschema-form/pull/4706))
7378
- Updated array documentation to reference the new dynamic UI schema capabilities ([#4706](https://github.com/rjsf-team/react-jsonschema-form/pull/4706))
7479
- Updated nearly all of the libraries in the `package.json` files to the latest non-breaking versions
80+
- Fixed the broken `Custom Array` sample
81+
- Improved the `Any Of with Custom Field` sample so that it renders using the appropriate theme components
82+
- Updated the `custom-widgets-fields.md` and `v6.x upgrade guide.md` to document the BREAKING CHANGE to the `FieldProps.onChange` behavior
7583

7684
# 6.0.0-beta.13
7785

docs/index.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
11
# react-jsonschema-form
22

33
The react-jsonschema-form docs have been moved [here](https://rjsf-team.github.io/react-jsonschema-form/docs).
4-
5-
We are in the process of migrating our versioned documentation. For documentation prior to version 5.0.0, please select the version in the bottom-right corner of this page.

package-lock.json

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

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"build-serial": "nx run-many --target=build --parallel=1",
1717
"start": "echo 'use \"npm run build\" from main directory and then \"npm start\" in the playground package'",
1818
"pre-commit:husky": "nx run-many --parallel=1 --target=precommit",
19-
"prepare": "husky install",
19+
"prepare": "is-ci || husky",
2020
"format": "prettier --write .",
2121
"format-check": "prettier --check .",
2222
"bump-all-packages": "echo 'NOTE: Make sure to sanity check the playground locally before commiting changes' && npm update --save && npm install && npm run lint && npm run build && npm run test",
@@ -69,6 +69,7 @@
6969
"eslint-plugin-react": "^7.37.5",
7070
"eslint-plugin-react-hooks": "^5.2.0",
7171
"husky": "^9.1.7",
72+
"is-ci": "^4.1.0",
7273
"jest": "^30.0.5",
7374
"jest-environment-jsdom": "^30.0.5",
7475
"jest-watch-typeahead": "^3.0.1",

packages/core/src/components/Form.tsx

Lines changed: 85 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,13 @@ import {
3838
createErrorHandler,
3939
unwrapErrorHandler,
4040
} from '@rjsf/utils';
41+
import _cloneDeep from 'lodash/cloneDeep';
4142
import _forEach from 'lodash/forEach';
4243
import _get from 'lodash/get';
4344
import _isEmpty from 'lodash/isEmpty';
4445
import _isNil from 'lodash/isNil';
4546
import _pick from 'lodash/pick';
47+
import _set from 'lodash/set';
4648
import _toPath from 'lodash/toPath';
4749

4850
import getDefaultRegistry from '../getDefaultRegistry';
@@ -272,6 +274,19 @@ export interface IChangeEvent<T = any, S extends StrictRJSFSchema = RJSFSchema,
272274
status?: 'submitted';
273275
}
274276

277+
/** The definition of a pending change that will be processed in the `onChange` handler
278+
*/
279+
interface PendingChange<T> {
280+
/** The path into the formData/errorSchema at which the `newValue`/`newErrorSchema` will be set */
281+
path?: (number | string)[];
282+
/** The new value to set into the formData */
283+
newValue?: T;
284+
/** The new errors to be set into the errorSchema, if any */
285+
newErrorSchema?: ErrorSchema<T>;
286+
/** The optional id of the field for which the change is being made */
287+
id?: string;
288+
}
289+
275290
/** The `Form` component renders the outer form and all the fields defined in the `schema` */
276291
export default class Form<
277292
T = any,
@@ -283,6 +298,10 @@ export default class Form<
283298
*/
284299
formElement: RefObject<any>;
285300

301+
/** The list of pending changes
302+
*/
303+
pendingChanges: PendingChange<T>[] = [];
304+
286305
/** Constructs the `Form` from the `props`. Will setup the initial state from the props. It will also call the
287306
* `onChange` handler if the initially provided `formData` is modified to add missing default values as part of the
288307
* state construction.
@@ -539,8 +558,7 @@ export default class Form<
539558
let customValidateErrors = {};
540559
if (typeof customValidate === 'function') {
541560
const errorHandler = customValidate(prevFormData, createErrorHandler<T>(prevFormData), uiSchema);
542-
const userErrorSchema = unwrapErrorHandler<T>(errorHandler);
543-
customValidateErrors = userErrorSchema;
561+
customValidateErrors = unwrapErrorHandler<T>(errorHandler);
544562
}
545563
return customValidateErrors;
546564
}
@@ -550,7 +568,8 @@ export default class Form<
550568
*
551569
* @param formData - The new form data to validate
552570
* @param schema - The schema used to validate against
553-
* @param altSchemaUtils - The alternate schemaUtils to use for validation
571+
* @param [altSchemaUtils] - The alternate schemaUtils to use for validation
572+
* @param [retrievedSchema] - An optionally retrieved schema for per
554573
*/
555574
validate(
556575
formData: T | undefined,
@@ -655,11 +674,16 @@ export default class Form<
655674
const retrievedSchema = schemaUtils.retrieveSchema(schema, formData);
656675
const pathSchema = schemaUtils.toPathSchema(retrievedSchema, '', formData);
657676
const fieldNames = this.getFieldNames(pathSchema, formData);
658-
const newFormData = this.getUsedFormData(formData, fieldNames);
659-
return newFormData;
677+
return this.getUsedFormData(formData, fieldNames);
660678
};
661679

662-
// Filtering errors based on your retrieved schema to only show errors for properties in the selected branch.
680+
/** Filtering errors based on your retrieved schema to only show errors for properties in the selected branch.
681+
*
682+
* @param schemaErrors - The schema errors to filter
683+
* @param [resolvedSchema] - An optionally resolved schema to use for performance reasons
684+
* @param [formData] - The formData to help filter errors
685+
* @private
686+
*/
663687
private filterErrorsBasedOnSchema(schemaErrors: ErrorSchema<T>, resolvedSchema?: S, formData?: any): ErrorSchema<T> {
664688
const { retrievedSchema, schemaUtils } = this.state;
665689
const _retrievedSchema = resolvedSchema ?? retrievedSchema;
@@ -705,23 +729,47 @@ export default class Form<
705729
return filterNilOrEmptyErrors(filteredErrors, prevCustomValidateErrors);
706730
}
707731

708-
/** Function to handle changes made to a field in the `Form`. This handler receives an entirely new copy of the
709-
* `formData` along with a new `ErrorSchema`. It will first update the `formData` with any missing default fields and
710-
* then, if `omitExtraData` and `liveOmit` are turned on, the `formData` will be filtered to remove any extra data not
711-
* in a form field. Then, the resulting formData will be validated if required. The state will be updated with the new
712-
* updated (potentially filtered) `formData`, any errors that resulted from validation. Finally the `onChange`
713-
* callback will be called if specified with the updated state.
732+
/** Pushes the given change information into the `pendingChanges` array and then calls `processPendingChanges()` if
733+
* the array only contains a single pending change.
714734
*
715-
* @param formData - The new form data from a change to a field
716-
* @param newErrorSchema - The new `ErrorSchema` based on the field change
717-
* @param id - The id of the field that caused the change
735+
* @param newValue - The new form data from a change to a field
736+
* @param [path] - The path to the change into which to set the formData
737+
* @param [newErrorSchema] - The new `ErrorSchema` based on the field change
738+
* @param [id] - The id of the field that caused the change
739+
*/
740+
onChange = (newValue: T | undefined, path?: (number | string)[], newErrorSchema?: ErrorSchema<T>, id?: string) => {
741+
this.pendingChanges.push({ newValue, path, newErrorSchema, id });
742+
if (this.pendingChanges.length === 1) {
743+
this.processPendingChange();
744+
}
745+
};
746+
747+
/** Function to handle changes made to a field in the `Form`. This handler gets the first change from the
748+
* `pendingChanges` list, containing the `newValue` for the `formData` and the `path` at which the `newValue` is to be
749+
* updated, along with a new, optional `ErrorSchema` for that same `path` and potentially the `id` of the field being
750+
* changed. It will first update the `formData` with any missing default fields and then, if `omitExtraData` and
751+
* `liveOmit` are turned on, the `formData` will be filtered to remove any extra data not in a form field. Then, the
752+
* resulting `formData` will be validated if required. The state will be updated with the new updated (potentially
753+
* filtered) `formData`, any errors that resulted from validation. Finally the `onChange` callback will be called, if
754+
* specified, with the updated state and the `processPendingChange()` function is called again.
718755
*/
719-
onChange = (formData: T | undefined, newErrorSchema?: ErrorSchema<T>, id?: string) => {
720-
const { extraErrors, omitExtraData, liveOmit, noValidate, liveValidate, onChange } = this.props;
721-
const { schemaUtils, schema } = this.state;
756+
processPendingChange() {
757+
if (this.pendingChanges.length === 0) {
758+
return;
759+
}
760+
const { newValue, path, id } = this.pendingChanges[0];
761+
let { newErrorSchema } = this.pendingChanges[0];
762+
const { extraErrors, omitExtraData, liveOmit, noValidate, liveValidate, onChange, idPrefix = '' } = this.props;
763+
const { formData: oldFormData, schemaUtils, schema, errorSchema } = this.state;
722764

765+
const isRootPath = !path || path.length === 0 || (path.length === 1 && path[0] === idPrefix);
723766
let retrievedSchema = this.state.retrievedSchema;
767+
let formData = isRootPath ? newValue : _cloneDeep(oldFormData);
724768
if (isObject(formData) || Array.isArray(formData)) {
769+
if (!isRootPath) {
770+
// If the newValue is not on the root path, then set it into the form data
771+
_set(formData, path, newValue);
772+
}
725773
const newState = this.getStateFromProps(this.props, formData);
726774
formData = newState.formData;
727775
retrievedSchema = newState.retrievedSchema;
@@ -738,6 +786,13 @@ export default class Form<
738786
};
739787
}
740788

789+
// First update the value in the newErrorSchema in a copy of the old error schema if it was specified and the path
790+
// is not the root
791+
if (newErrorSchema && !isRootPath) {
792+
const errorSchemaCopy = _cloneDeep(errorSchema);
793+
_set(errorSchemaCopy, path, newErrorSchema);
794+
newErrorSchema = errorSchemaCopy;
795+
}
741796
if (mustValidate) {
742797
const schemaValidation = this.validate(newFormData, schema, schemaUtils, retrievedSchema);
743798
let errors = schemaValidation.errors;
@@ -762,6 +817,7 @@ export default class Form<
762817
schemaValidationErrorSchema,
763818
};
764819
} else if (!noValidate && newErrorSchema) {
820+
// Merging 'newErrorSchema' into 'errorSchema' to display the custom raised errors.
765821
const errorSchema = extraErrors
766822
? (mergeObjects(newErrorSchema, extraErrors, 'preventDuplicates') as ErrorSchema<T>)
767823
: newErrorSchema;
@@ -771,8 +827,15 @@ export default class Form<
771827
errors: toErrorList(errorSchema),
772828
};
773829
}
774-
this.setState(state as FormState<T, S, F>, () => onChange && onChange({ ...this.state, ...state }, id));
775-
};
830+
this.setState(state as FormState<T, S, F>, () => {
831+
if (onChange) {
832+
onChange({ ...this.state, ...state }, id);
833+
}
834+
// Now remove the change we just completed and call this again
835+
this.pendingChanges.shift();
836+
this.processPendingChange();
837+
});
838+
}
776839

777840
/**
778841
* If the retrievedSchema has changed the new retrievedSchema is returned.
@@ -1029,7 +1092,7 @@ export default class Form<
10291092
const {
10301093
children,
10311094
id,
1032-
idPrefix,
1095+
idPrefix = '',
10331096
idSeparator,
10341097
className = '',
10351098
tagName,
@@ -1082,7 +1145,7 @@ export default class Form<
10821145
>
10831146
{showErrorList === 'top' && this.renderErrors(registry)}
10841147
<_SchemaField
1085-
name=''
1148+
name={idPrefix}
10861149
schema={schema}
10871150
uiSchema={uiSchema}
10881151
errorSchema={errorSchema}

0 commit comments

Comments
 (0)