Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Debounce emitted events in React #1864

Merged
merged 3 commits into from
Jan 13, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 31 additions & 24 deletions packages/material/test/renderers/MaterialArrayLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ describe('Material array layout', () => {
.find({ 'aria-label': 'Move down' }).length
).toBe(1);
});
it('should move item up if up button is presses', () => {
it('should move item up if up button is presses', (done) => {
const onChangeData: any = {
data: undefined
};
Expand All @@ -331,19 +331,23 @@ describe('Material array layout', () => {
.find('button')
.find({ 'aria-label': 'Move up' });
upButton.simulate('click');
expect(onChangeData.data).toEqual([
{
message: 'Yolo',
message2: 'Yolo 2'
},
{
message: 'El Barto was here',
message2: 'El Barto was here 2',
done: true
}
]);
// events are debounced for some time, so let's wait
setTimeout(() => {
expect(onChangeData.data).toEqual([
{
message: 'Yolo',
message2: 'Yolo 2'
},
{
message: 'El Barto was here',
message2: 'El Barto was here 2',
done: true
}
]);
done();
}, 50);
});
it('shoud move item down if down button is pressed', () => {
it('should move item down if down button is pressed', (done) => {
const onChangeData: any = {
data: undefined
};
Expand All @@ -368,17 +372,20 @@ describe('Material array layout', () => {
.find('button')
.find({ 'aria-label': 'Move down' });
upButton.simulate('click');
expect(onChangeData.data).toEqual([
{
message: 'Yolo',
message2: 'Yolo 2'
},
{
message: 'El Barto was here',
message2: 'El Barto was here 2',
done: true
}
]);
// events are debounced for some time, so let's wait
setTimeout(() => {
expect(onChangeData.data).toEqual([
{
message: 'Yolo',
message2: 'Yolo 2'
},
{
message: 'El Barto was here',
message2: 'El Barto was here 2',
done: true
}
]); done();
}, 50);
});
it('should have up button disabled for first element', () => {
wrapper = mount(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ describe('EnumArrayControl', () => {
expect(labels.last().text()).toBe('Bar');
});

test('oneOf items - updates data', () => {
let myData = undefined;
test('oneOf items - updates data', (done) => {
let myData: any = undefined;
wrapper = mount(
<JsonForms
schema={oneOfSchema}
Expand All @@ -131,7 +131,11 @@ describe('EnumArrayControl', () => {
);
const input = wrapper.find('input').first();
input.simulate('change', { target: { checked: true } });
expect(myData).toStrictEqual(['foo']);
// events are debounced for some time, so let's wait
setTimeout(() => {
expect(myData).toStrictEqual(['foo']);
done();
}, 50);
});

test('enum items - renders', () => {
Expand Down Expand Up @@ -177,8 +181,8 @@ describe('EnumArrayControl', () => {
expect(labels.at(2).text()).toBe('C');
});

test('enum items - updates data', () => {
let myData = undefined;
test('enum items - updates data', (done) => {
let myData: any = undefined;
wrapper = mount(
<JsonForms
schema={enumSchema}
Expand All @@ -192,6 +196,10 @@ describe('EnumArrayControl', () => {
);
const input = wrapper.find('input').first();
input.simulate('change', { target: { checked: true } });
expect(myData).toStrictEqual(['a']);
// events are debounced for some time, so let's wait
setTimeout(() => {
expect(myData).toStrictEqual(['a']);
done();
}, 50);
});
});
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