Skip to content

Commit be5c82c

Browse files
authored
Add debounce for React Material inputs
Adds a custom hook for debouncing React Material input changes. This prevents that each keystroke results in a complete rerendering cycle which can impact perceived performance a lot. Fix eclipsesource#1645
1 parent d9fa0c3 commit be5c82c

16 files changed

+339
-138
lines changed

packages/examples/src/1645.ts

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
The MIT License
3+
4+
Copyright (c) 2021 EclipseSource Munich
5+
https://github.com/eclipsesource/jsonforms
6+
7+
Permission is hereby granted, free of charge, to any person obtaining a copy
8+
of this software and associated documentation files (the "Software"), to deal
9+
in the Software without restriction, including without limitation the rights
10+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
copies of the Software, and to permit persons to whom the Software is
12+
furnished to do so, subject to the following conditions:
13+
14+
The above copyright notice and this permission notice shall be included in
15+
all copies or substantial portions of the Software.
16+
17+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23+
THE SOFTWARE.
24+
*/
25+
import { registerExamples } from './register';
26+
import { UISchemaElement } from '@jsonforms/core';
27+
28+
export const schema = {
29+
type: 'object',
30+
properties: {
31+
propText0: {type: 'string'},
32+
propText1: {type: 'string'},
33+
propText2: {type: 'string'},
34+
propText3: {type: 'string'},
35+
propText4: {type: 'string'},
36+
propText5: {type: 'string'},
37+
propText6: {type: 'string'},
38+
propText7: {type: 'string'},
39+
propText8: {type: 'string'},
40+
propText9: {type: 'string'},
41+
propNumber0: {type: 'number'},
42+
propNumber1: {type: 'number'},
43+
propNumber2: {type: 'number'},
44+
propNumber3: {type: 'number'},
45+
propNumber4: {type: 'number'},
46+
propNumber5: {type: 'number'},
47+
propNumber6: {type: 'number'},
48+
propNumber7: {type: 'number'},
49+
propNumber8: {type: 'number'},
50+
propNumber9: {type: 'number'},
51+
}
52+
};
53+
54+
export const uischema: UISchemaElement = undefined;
55+
56+
export const data = {};
57+
58+
registerExamples([
59+
{
60+
name: '1645',
61+
label: 'Issue 1645',
62+
data,
63+
schema,
64+
uischema
65+
}
66+
]);

packages/examples/src/index.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import * as multiEnum from './multi-enum';
7575
import * as enumInArray from './enumInArray';
7676
import * as readonly from './readonly';
7777
import * as bug_1779 from './1779';
78+
import * as bug_1645 from './1645';
7879
export * from './register';
7980
export * from './example';
8081

@@ -134,5 +135,6 @@ export {
134135
multiEnum,
135136
enumInArray,
136137
readonly,
137-
bug_1779
138+
bug_1779,
139+
bug_1645
138140
};

packages/material/src/controls/MaterialAnyOfStringOrEnumControl.tsx

+16-12
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ import { Control, withJsonFormsControlProps } from '@jsonforms/react';
3838
import { Input } from '@material-ui/core';
3939
import { InputBaseComponentProps } from '@material-ui/core/InputBase';
4040
import merge from 'lodash/merge';
41-
import React from 'react';
41+
import React, { useMemo } from 'react';
42+
import { useDebouncedChange } from '../util';
4243
import { MaterialInputControl } from './MaterialInputControl';
4344

4445
const findEnumSchema = (schemas: JsonSchema[]) =>
@@ -64,17 +65,20 @@ const MuiAutocompleteInputText = (props: EnumCellProps & WithClassname) => {
6465
const enumSchema = findEnumSchema(schema.anyOf);
6566
const stringSchema = findTextSchema(schema.anyOf);
6667
const maxLength = stringSchema.maxLength;
67-
const appliedUiSchemaOptions = merge({}, config, uischema.options);
68-
let inputProps: InputBaseComponentProps = {};
69-
if (appliedUiSchemaOptions.restrict) {
70-
inputProps = { maxLength: maxLength };
71-
}
72-
if (appliedUiSchemaOptions.trim && maxLength !== undefined) {
73-
inputProps.size = maxLength;
74-
}
75-
const onChange = (ev: any) => handleChange(path, ev.target.value);
68+
const appliedUiSchemaOptions = useMemo(() => merge({}, config, uischema.options),[config, uischema.options]);
69+
const inputProps: InputBaseComponentProps = useMemo(() => {
70+
let propMemo: InputBaseComponentProps = {};
71+
if (appliedUiSchemaOptions.restrict) {
72+
propMemo = { maxLength: maxLength };
73+
}
74+
if (appliedUiSchemaOptions.trim && maxLength !== undefined) {
75+
propMemo.size = maxLength;
76+
}
77+
propMemo.list = props.id + 'datalist';
78+
return propMemo;
79+
},[appliedUiSchemaOptions,props.id]);
80+
const [inputText, onChange] = useDebouncedChange(handleChange, '', data, path);
7681

77-
inputProps.list = props.id + 'datalist';
7882
const dataList = (
7983
<datalist id={props.id + 'datalist'}>
8084
{enumSchema.enum.map(optionValue => (
@@ -85,7 +89,7 @@ const MuiAutocompleteInputText = (props: EnumCellProps & WithClassname) => {
8589
return (
8690
<Input
8791
type='text'
88-
value={data || ''}
92+
value={inputText}
8993
onChange={onChange}
9094
className={className}
9195
id={id}

packages/material/src/mui-controls/MuiInputInteger.tsx

+10-4
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ import { CellProps, WithClassname } from '@jsonforms/core';
2727
import Input from '@material-ui/core/Input';
2828
import { areEqual } from '@jsonforms/react';
2929
import merge from 'lodash/merge';
30+
import { useDebouncedChange } from '../util';
31+
32+
const toNumber = (value: string) =>
33+
value === '' ? undefined : parseInt(value, 10);
34+
const eventToValue = (ev:any) => toNumber(ev.target.value);
3035

3136
export const MuiInputInteger = React.memo(
3237
(props: CellProps & WithClassname) => {
@@ -41,15 +46,16 @@ export const MuiInputInteger = React.memo(
4146
config
4247
} = props;
4348
const inputProps = { step: '1' };
44-
const toNumber = (value: string) =>
45-
value === '' ? undefined : parseInt(value, 10);
49+
4650
const appliedUiSchemaOptions = merge({}, config, uischema.options);
4751

52+
const [inputValue, onChange] = useDebouncedChange(handleChange, '', data, path, eventToValue);
53+
4854
return (
4955
<Input
5056
type='number'
51-
value={data !== undefined && data !== null ? data : ''}
52-
onChange={ev => handleChange(path, toNumber(ev.target.value))}
57+
value={inputValue}
58+
onChange={onChange}
5359
className={className}
5460
id={id}
5561
disabled={!enabled}

packages/material/src/mui-controls/MuiInputNumber.tsx

+8-4
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ import { CellProps, WithClassname } from '@jsonforms/core';
2727
import Input from '@material-ui/core/Input';
2828
import { areEqual } from '@jsonforms/react';
2929
import merge from 'lodash/merge';
30+
import {useDebouncedChange} from '../util';
3031

32+
const toNumber = (value: string) =>
33+
value === '' ? undefined : parseFloat(value);
34+
const eventToValue = (ev:any) => toNumber(ev.target.value);
3135
export const MuiInputNumber = React.memo((props: CellProps & WithClassname) => {
3236
const {
3337
data,
@@ -40,15 +44,15 @@ export const MuiInputNumber = React.memo((props: CellProps & WithClassname) => {
4044
config
4145
} = props;
4246
const inputProps = { step: '0.1' };
43-
const toNumber = (value: string) =>
44-
value === '' ? undefined : parseFloat(value);
47+
4548
const appliedUiSchemaOptions = merge({}, config, uischema.options);
49+
const [inputValue, onChange] = useDebouncedChange(handleChange, '', data, path, eventToValue);
4650

4751
return (
4852
<Input
4953
type='number'
50-
value={data === undefined || data === null ? '' : data}
51-
onChange={ev => handleChange(path, toNumber(ev.target.value))}
54+
value={inputValue}
55+
onChange={onChange}
5256
className={className}
5357
id={id}
5458
disabled={!enabled}

packages/material/src/mui-controls/MuiInputNumberFormat.tsx

+6-6
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,12 @@
2222
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
2323
THE SOFTWARE.
2424
*/
25-
import React from 'react';
25+
import React, {useCallback} from 'react';
2626
import { CellProps, Formatted, WithClassname } from '@jsonforms/core';
2727
import Input from '@material-ui/core/Input';
2828
import { areEqual } from '@jsonforms/react';
2929
import merge from 'lodash/merge';
30+
import { useDebouncedChange } from '../util';
3031

3132
export const MuiInputNumberFormat = React.memo(
3233
(props: CellProps & WithClassname & Formatted<number>) => {
@@ -51,15 +52,14 @@ export const MuiInputNumberFormat = React.memo(
5152
}
5253
const formattedNumber = props.toFormatted(props.data);
5354

54-
const onChange = (ev: any) => {
55-
const validStringNumber = props.fromFormatted(ev.currentTarget.value);
56-
handleChange(path, validStringNumber);
57-
};
55+
const validStringNumber = useCallback((ev:any) => props.fromFormatted(ev.currentTarget.value),[props.fromFormatted]);
56+
const [inputValue, onChange] = useDebouncedChange(handleChange, '', formattedNumber, path, validStringNumber);
57+
5858

5959
return (
6060
<Input
6161
type='text'
62-
value={formattedNumber}
62+
value={inputValue}
6363
onChange={onChange}
6464
className={className}
6565
id={id}

packages/material/src/mui-controls/MuiInputText.tsx

+16-13
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,14 @@ import IconButton from '@material-ui/core/IconButton';
3131
import InputAdornment from '@material-ui/core/InputAdornment';
3232
import Close from '@material-ui/icons/Close';
3333
import { useTheme } from '@material-ui/core/styles';
34-
import { JsonFormsTheme } from '../util';
34+
import { JsonFormsTheme, useDebouncedChange } from '../util';
3535
import { InputBaseComponentProps } from '@material-ui/core';
3636

3737
interface MuiTextInputProps {
3838
muiInputProps?: InputProps['inputProps'];
3939
inputComponent?: InputProps['inputComponent'];
4040
}
41-
42-
export const MuiInputText = React.memo((props: CellProps & WithClassname & MuiTextInputProps) => {
41+
export const MuiInputText = React.memo((props: CellProps & WithClassname & MuiTextInputProps) => {
4342
const [showAdornment, setShowAdornment] = useState(false);
4443
const {
4544
data,
@@ -63,23 +62,27 @@ export const MuiInputText = React.memo((props: CellProps & WithClassname & MuiTe
6362
} else {
6463
inputProps = {};
6564
}
66-
65+
6766
inputProps = merge(inputProps, muiInputProps);
68-
67+
6968
if (appliedUiSchemaOptions.trim && maxLength !== undefined) {
7069
inputProps.size = maxLength;
71-
}
72-
const onChange = (ev: any) => handleChange(path, ev.target.value);
70+
};
71+
72+
const [inputText, onChange, onClear] = useDebouncedChange(handleChange, '', data, path);
73+
const onPointerEnter = () => setShowAdornment(true);
74+
const onPointerLeave = () => setShowAdornment(false);
7375

7476
const theme: JsonFormsTheme = useTheme();
75-
const inputDeleteBackgroundColor = theme.jsonforms?.input?.delete?.background || theme.palette.background.default;
77+
78+
const closeStyle = {background: theme.jsonforms?.input?.delete?.background || theme.palette.background.default, borderRadius: '50%'};
7679

7780
return (
7881
<Input
7982
type={
8083
appliedUiSchemaOptions.format === 'password' ? 'password' : 'text'
8184
}
82-
value={data || ''}
85+
value={inputText}
8386
onChange={onChange}
8487
className={className}
8588
id={id}
@@ -89,8 +92,8 @@ export const MuiInputText = React.memo((props: CellProps & WithClassname & MuiTe
8992
fullWidth={!appliedUiSchemaOptions.trim || maxLength === undefined}
9093
inputProps={inputProps}
9194
error={!isValid}
92-
onPointerEnter={() => setShowAdornment(true) }
93-
onPointerLeave={() => setShowAdornment(false) }
95+
onPointerEnter={onPointerEnter}
96+
onPointerLeave={onPointerLeave}
9497
endAdornment={
9598
<InputAdornment
9699
position='end'
@@ -103,9 +106,9 @@ export const MuiInputText = React.memo((props: CellProps & WithClassname & MuiTe
103106
>
104107
<IconButton
105108
aria-label='Clear input field'
106-
onClick={() => handleChange(path, undefined)}
109+
onClick={onClear}
107110
>
108-
<Close style={{background: inputDeleteBackgroundColor, borderRadius: '50%'}}/>
111+
<Close style={closeStyle}/>
109112
</IconButton>
110113
</InputAdornment>
111114
}

packages/material/src/mui-controls/MuiInputTime.tsx

+5-2
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { CellProps, WithClassname } from '@jsonforms/core';
2727
import Input from '@material-ui/core/Input';
2828
import { areEqual } from '@jsonforms/react';
2929
import merge from 'lodash/merge';
30+
import { useDebouncedChange } from '../util';
3031

3132
export const MuiInputTime = React.memo((props: CellProps & WithClassname) => {
3233
const {
@@ -40,11 +41,13 @@ export const MuiInputTime = React.memo((props: CellProps & WithClassname) => {
4041
config
4142
} = props;
4243
const appliedUiSchemaOptions = merge({}, config, uischema.options);
44+
const [inputValue, onChange] = useDebouncedChange(handleChange, '', data, path);
45+
4346
return (
4447
<Input
4548
type='time'
46-
value={data || ''}
47-
onChange={ev => handleChange(path, ev.target.value)}
49+
value={inputValue}
50+
onChange={onChange}
4851
className={className}
4952
id={id}
5053
disabled={!enabled}
+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { debounce } from 'lodash';
2+
import { useState, useCallback, useEffect } from 'react'
3+
4+
5+
const eventToValue = (ev: any) => ev.target.value;
6+
export const useDebouncedChange = (handleChange: (path: string, value: any) => void, defaultValue: any, data: any, path: string, eventToValueFunction: (ev: any) => any = eventToValue, timeout = 300): [any, React.ChangeEventHandler, () => void] => {
7+
const [input, setInput] = useState(data ?? defaultValue);
8+
useEffect(() => {
9+
setInput(data ?? defaultValue);
10+
}, [data]);
11+
const debouncedUpdate = useCallback(debounce((newValue: string) => handleChange(path, newValue), timeout), [handleChange, path, timeout]);
12+
const onChange = useCallback((ev: any) => {
13+
const newValue = eventToValueFunction(ev);
14+
setInput(newValue ?? defaultValue);
15+
debouncedUpdate(newValue);
16+
}, [debouncedUpdate, eventToValueFunction]);
17+
const onClear = useCallback(() => { setInput(defaultValue); handleChange(path, undefined) }, [defaultValue, handleChange, path]);
18+
return [input, onChange, onClear];
19+
};

packages/material/src/util/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@
2525
export * from './datejs';
2626
export * from './layout';
2727
export * from './theme';
28+
export * from './debounce';

0 commit comments

Comments
 (0)