diff --git a/packages/react/src/JsonFormsContext.tsx b/packages/react/src/JsonFormsContext.tsx index 5bb6ee2cbf..cbb911533c 100644 --- a/packages/react/src/JsonFormsContext.tsx +++ b/packages/react/src/JsonFormsContext.tsx @@ -70,7 +70,8 @@ import { mapDispatchToArrayControlProps, i18nReducer } from '@jsonforms/core'; -import React, { ComponentType, Dispatch, ReducerAction, useContext, useEffect, useMemo, useReducer, useRef } from 'react'; +import debounce from 'lodash/debounce'; +import React, { ComponentType, Dispatch, ReducerAction, useCallback, useContext, useEffect, useMemo, useReducer, useRef } from 'react'; const initialCoreState: JsonFormsCore = { data: {}, @@ -110,7 +111,7 @@ const useEffectAfterFirstRender = ( export const JsonFormsStateProvider = ({ children, initState, onChange }: any) => { const { data, schema, uischema, ajv, validationMode } = initState.core; - // Initialize core immediately + const [core, coreDispatch] = useReducer( coreReducer, undefined, @@ -165,8 +166,28 @@ export const JsonFormsStateProvider = ({ children, initState, onChange }: any) = onChangeRef.current = onChange; }, [onChange]); + /** + * A common pattern for users of JSON Forms is to feed back the data which is emitted by + * JSON Forms to JSON Forms ('controlled style'). + * + * Every time this happens, we dispatch the 'updateCore' action which will be a no-op when + * the data handed over is the one which was just recently emitted. This allows us to skip + * rerendering for all normal cases of use. + * + * However there can be extreme use cases, for example when using Chrome Auto-fill for forms, + * which can cause JSON Forms to emit multiple change events before the parent component is + * rerendered. Therefore not the very recent data, but the previous data is fed back to + * JSON Forms first. JSON Forms recognizes that this is not the very recent data and will + * validate, rerender and emit a change event again. This can then lead to data loss or even + * an endless rerender loop, depending on the emitted events chain. + * + * To handle these edge cases in which many change events are sent in an extremely short amount + * of time we debounce them over a short amount of time. 10ms was chosen as this worked well + * even on low-end mobile device settings in the Chrome simulator. + */ + const debouncedEmit = useCallback(debounce((...args) => onChangeRef.current?.(...args), 10), []); useEffect(() => { - onChangeRef.current?.({ data: core.data, errors: core.errors }); + debouncedEmit({ data: core.data, errors: core.errors }); }, [core.data, core.errors]); return ( @@ -216,7 +237,6 @@ export const ctxToOneOfEnumControlProps = (ctx: JsonFormsStateContext, props: Ow */ const options = useMemo(() => enumProps.options, [props.options, enumProps.schema]); return {...enumProps, options} - } export const ctxToMultiEnumControlProps = (ctx: JsonFormsStateContext, props: OwnPropsOfControl) => diff --git a/packages/react/test/renderers/JsonForms.test.tsx b/packages/react/test/renderers/JsonForms.test.tsx index ad4fce041f..46b50783ed 100644 --- a/packages/react/test/renderers/JsonForms.test.tsx +++ b/packages/react/test/renderers/JsonForms.test.tsx @@ -865,7 +865,7 @@ test('JsonForms should not crash with undefined uischemas', () => { ); }); -test('JsonForms should call onChange handler with new data', () => { +test('JsonForms should call onChange handler with new data', (done) => { const onChangeHandler = jest.fn(); const TestInputRenderer = withJsonFormsControlProps(props => ( props.handleChange('foo', ev.target.value)} /> @@ -893,13 +893,18 @@ test('JsonForms should call onChange handler with new data', () => { } }); - const calls = onChangeHandler.mock.calls; - const lastCallParameter = calls[calls.length - 1][0]; - expect(lastCallParameter.data).toEqual({ foo: 'Test Value' }); - expect(lastCallParameter.errors).toEqual([]); + // events are debounced for some time, so let's wait + setTimeout(() => { + const calls = onChangeHandler.mock.calls; + const lastCallParameter = calls[calls.length - 1][0]; + expect(lastCallParameter.data).toEqual({ foo: 'Test Value' }); + expect(lastCallParameter.errors).toEqual([]); + done(); + }, 50); + }); -test('JsonForms should call onChange handler with errors', () => { +test('JsonForms should call onChange handler with errors', (done) => { const onChangeHandler = jest.fn(); const TestInputRenderer = withJsonFormsControlProps(props => ( props.handleChange('foo', ev.target.value)} /> @@ -938,11 +943,16 @@ test('JsonForms should call onChange handler with errors', () => { } }); - const calls = onChangeHandler.mock.calls; - const lastCallParameter = calls[calls.length - 1][0]; - expect(lastCallParameter.data).toEqual({ foo: 'xyz' }); - expect(lastCallParameter.errors.length).toEqual(1); - expect(lastCallParameter.errors[0].keyword).toEqual('minLength'); + // events are debounced for some time, so let's wait + setTimeout(() => { + const calls = onChangeHandler.mock.calls; + const lastCallParameter = calls[calls.length - 1][0]; + expect(lastCallParameter.data).toEqual({ foo: 'xyz' }); + expect(lastCallParameter.errors.length).toEqual(1); + expect(lastCallParameter.errors[0].keyword).toEqual('minLength'); + done(); + }, 50); + }); test('JsonForms should update if data prop is updated', () => {