Skip to content

Commit

Permalink
Debounce emitted events in React
Browse files Browse the repository at this point in the history
There are some use cases in which a large amount of independent changes are
performed in an extremely low amount of time, potentially leading to data
loss or endless rerendering loops when using the React bindings.

An example for such a use case is Chrome auto-fill which can cause JSON Forms
to emit multiple change events before the parent component is rerendered. If
the parent component feeds the emitted data back to JSON Forms then it will
hand over not the latest data, but the previouslys emitted data first. JSON
Forms recognizes that this is not the very recent data and will validate,
rerender and emit a change event again, leading to the problematic behavior.

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.
We debounce the emitted events instead of the incoming data as we don't know
anything about the amount of work performed (and therefore time passed) on the
emitted data.
  • Loading branch information
sdirix committed Jan 13, 2022
1 parent 64147e1 commit d6bde0f
Show file tree
Hide file tree
Showing 2 changed files with 45 additions and 15 deletions.
28 changes: 24 additions & 4 deletions packages/react/src/JsonFormsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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) =>
Expand Down
32 changes: 21 additions & 11 deletions packages/react/test/renderers/JsonForms.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 => (
<input onChange={ev => props.handleChange('foo', ev.target.value)} />
Expand Down Expand Up @@ -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 => (
<input onChange={ev => props.handleChange('foo', ev.target.value)} />
Expand Down Expand Up @@ -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', () => {
Expand Down

0 comments on commit d6bde0f

Please sign in to comment.