Skip to content

Commit 95018ca

Browse files
authored
Debounce emitted events in React
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.
1 parent 5b123ce commit 95018ca

File tree

4 files changed

+90
-45
lines changed

4 files changed

+90
-45
lines changed

packages/material/test/renderers/MaterialArrayLayout.test.tsx

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ describe('Material array layout', () => {
306306
.find({ 'aria-label': 'Move down' }).length
307307
).toBe(1);
308308
});
309-
it('should move item up if up button is presses', () => {
309+
it('should move item up if up button is presses', (done) => {
310310
const onChangeData: any = {
311311
data: undefined
312312
};
@@ -331,19 +331,23 @@ describe('Material array layout', () => {
331331
.find('button')
332332
.find({ 'aria-label': 'Move up' });
333333
upButton.simulate('click');
334-
expect(onChangeData.data).toEqual([
335-
{
336-
message: 'Yolo',
337-
message2: 'Yolo 2'
338-
},
339-
{
340-
message: 'El Barto was here',
341-
message2: 'El Barto was here 2',
342-
done: true
343-
}
344-
]);
334+
// events are debounced for some time, so let's wait
335+
setTimeout(() => {
336+
expect(onChangeData.data).toEqual([
337+
{
338+
message: 'Yolo',
339+
message2: 'Yolo 2'
340+
},
341+
{
342+
message: 'El Barto was here',
343+
message2: 'El Barto was here 2',
344+
done: true
345+
}
346+
]);
347+
done();
348+
}, 50);
345349
});
346-
it('shoud move item down if down button is pressed', () => {
350+
it('should move item down if down button is pressed', (done) => {
347351
const onChangeData: any = {
348352
data: undefined
349353
};
@@ -368,17 +372,20 @@ describe('Material array layout', () => {
368372
.find('button')
369373
.find({ 'aria-label': 'Move down' });
370374
upButton.simulate('click');
371-
expect(onChangeData.data).toEqual([
372-
{
373-
message: 'Yolo',
374-
message2: 'Yolo 2'
375-
},
376-
{
377-
message: 'El Barto was here',
378-
message2: 'El Barto was here 2',
379-
done: true
380-
}
381-
]);
375+
// events are debounced for some time, so let's wait
376+
setTimeout(() => {
377+
expect(onChangeData.data).toEqual([
378+
{
379+
message: 'Yolo',
380+
message2: 'Yolo 2'
381+
},
382+
{
383+
message: 'El Barto was here',
384+
message2: 'El Barto was here 2',
385+
done: true
386+
}
387+
]); done();
388+
}, 50);
382389
});
383390
it('should have up button disabled for first element', () => {
384391
wrapper = mount(

packages/material/test/renderers/MaterialEnumArrayRenderer.test.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,8 @@ describe('EnumArrayControl', () => {
116116
expect(labels.last().text()).toBe('Bar');
117117
});
118118

119-
test('oneOf items - updates data', () => {
120-
let myData = undefined;
119+
test('oneOf items - updates data', (done) => {
120+
let myData: any = undefined;
121121
wrapper = mount(
122122
<JsonForms
123123
schema={oneOfSchema}
@@ -131,7 +131,11 @@ describe('EnumArrayControl', () => {
131131
);
132132
const input = wrapper.find('input').first();
133133
input.simulate('change', { target: { checked: true } });
134-
expect(myData).toStrictEqual(['foo']);
134+
// events are debounced for some time, so let's wait
135+
setTimeout(() => {
136+
expect(myData).toStrictEqual(['foo']);
137+
done();
138+
}, 50);
135139
});
136140

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

180-
test('enum items - updates data', () => {
181-
let myData = undefined;
184+
test('enum items - updates data', (done) => {
185+
let myData: any = undefined;
182186
wrapper = mount(
183187
<JsonForms
184188
schema={enumSchema}
@@ -192,6 +196,10 @@ describe('EnumArrayControl', () => {
192196
);
193197
const input = wrapper.find('input').first();
194198
input.simulate('change', { target: { checked: true } });
195-
expect(myData).toStrictEqual(['a']);
199+
// events are debounced for some time, so let's wait
200+
setTimeout(() => {
201+
expect(myData).toStrictEqual(['a']);
202+
done();
203+
}, 50);
196204
});
197205
});

packages/react/src/JsonFormsContext.tsx

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ import {
7070
mapDispatchToArrayControlProps,
7171
i18nReducer
7272
} from '@jsonforms/core';
73-
import React, { ComponentType, Dispatch, ReducerAction, useContext, useEffect, useMemo, useReducer, useRef } from 'react';
73+
import debounce from 'lodash/debounce';
74+
import React, { ComponentType, Dispatch, ReducerAction, useCallback, useContext, useEffect, useMemo, useReducer, useRef } from 'react';
7475

7576
const initialCoreState: JsonFormsCore = {
7677
data: {},
@@ -110,7 +111,7 @@ const useEffectAfterFirstRender = (
110111

111112
export const JsonFormsStateProvider = ({ children, initState, onChange }: any) => {
112113
const { data, schema, uischema, ajv, validationMode } = initState.core;
113-
// Initialize core immediately
114+
114115
const [core, coreDispatch] = useReducer(
115116
coreReducer,
116117
undefined,
@@ -165,8 +166,28 @@ export const JsonFormsStateProvider = ({ children, initState, onChange }: any) =
165166
onChangeRef.current = onChange;
166167
}, [onChange]);
167168

169+
/**
170+
* A common pattern for users of JSON Forms is to feed back the data which is emitted by
171+
* JSON Forms to JSON Forms ('controlled style').
172+
*
173+
* Every time this happens, we dispatch the 'updateCore' action which will be a no-op when
174+
* the data handed over is the one which was just recently emitted. This allows us to skip
175+
* rerendering for all normal cases of use.
176+
*
177+
* However there can be extreme use cases, for example when using Chrome Auto-fill for forms,
178+
* which can cause JSON Forms to emit multiple change events before the parent component is
179+
* rerendered. Therefore not the very recent data, but the previous data is fed back to
180+
* JSON Forms first. JSON Forms recognizes that this is not the very recent data and will
181+
* validate, rerender and emit a change event again. This can then lead to data loss or even
182+
* an endless rerender loop, depending on the emitted events chain.
183+
*
184+
* To handle these edge cases in which many change events are sent in an extremely short amount
185+
* of time we debounce them over a short amount of time. 10ms was chosen as this worked well
186+
* even on low-end mobile device settings in the Chrome simulator.
187+
*/
188+
const debouncedEmit = useCallback(debounce((...args) => onChangeRef.current?.(...args), 10), []);
168189
useEffect(() => {
169-
onChangeRef.current?.({ data: core.data, errors: core.errors });
190+
debouncedEmit({ data: core.data, errors: core.errors });
170191
}, [core.data, core.errors]);
171192

172193
return (
@@ -216,7 +237,6 @@ export const ctxToOneOfEnumControlProps = (ctx: JsonFormsStateContext, props: Ow
216237
*/
217238
const options = useMemo(() => enumProps.options, [props.options, enumProps.schema]);
218239
return {...enumProps, options}
219-
220240
}
221241

222242
export const ctxToMultiEnumControlProps = (ctx: JsonFormsStateContext, props: OwnPropsOfControl) =>

packages/react/test/renderers/JsonForms.test.tsx

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -865,7 +865,7 @@ test('JsonForms should not crash with undefined uischemas', () => {
865865
);
866866
});
867867

868-
test('JsonForms should call onChange handler with new data', () => {
868+
test('JsonForms should call onChange handler with new data', (done) => {
869869
const onChangeHandler = jest.fn();
870870
const TestInputRenderer = withJsonFormsControlProps(props => (
871871
<input onChange={ev => props.handleChange('foo', ev.target.value)} />
@@ -893,13 +893,18 @@ test('JsonForms should call onChange handler with new data', () => {
893893
}
894894
});
895895

896-
const calls = onChangeHandler.mock.calls;
897-
const lastCallParameter = calls[calls.length - 1][0];
898-
expect(lastCallParameter.data).toEqual({ foo: 'Test Value' });
899-
expect(lastCallParameter.errors).toEqual([]);
896+
// events are debounced for some time, so let's wait
897+
setTimeout(() => {
898+
const calls = onChangeHandler.mock.calls;
899+
const lastCallParameter = calls[calls.length - 1][0];
900+
expect(lastCallParameter.data).toEqual({ foo: 'Test Value' });
901+
expect(lastCallParameter.errors).toEqual([]);
902+
done();
903+
}, 50);
904+
900905
});
901906

902-
test('JsonForms should call onChange handler with errors', () => {
907+
test('JsonForms should call onChange handler with errors', (done) => {
903908
const onChangeHandler = jest.fn();
904909
const TestInputRenderer = withJsonFormsControlProps(props => (
905910
<input onChange={ev => props.handleChange('foo', ev.target.value)} />
@@ -938,11 +943,16 @@ test('JsonForms should call onChange handler with errors', () => {
938943
}
939944
});
940945

941-
const calls = onChangeHandler.mock.calls;
942-
const lastCallParameter = calls[calls.length - 1][0];
943-
expect(lastCallParameter.data).toEqual({ foo: 'xyz' });
944-
expect(lastCallParameter.errors.length).toEqual(1);
945-
expect(lastCallParameter.errors[0].keyword).toEqual('minLength');
946+
// events are debounced for some time, so let's wait
947+
setTimeout(() => {
948+
const calls = onChangeHandler.mock.calls;
949+
const lastCallParameter = calls[calls.length - 1][0];
950+
expect(lastCallParameter.data).toEqual({ foo: 'xyz' });
951+
expect(lastCallParameter.errors.length).toEqual(1);
952+
expect(lastCallParameter.errors[0].keyword).toEqual('minLength');
953+
done();
954+
}, 50);
955+
946956
});
947957

948958
test('JsonForms should update if data prop is updated', () => {

0 commit comments

Comments
 (0)