Skip to content

Commit 72e1974

Browse files
committed
Configurable debounce
1 parent abbb27f commit 72e1974

File tree

10 files changed

+246
-38
lines changed

10 files changed

+246
-38
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ Don't forget to remove deprecated code on each major release!
6262
- `reactpy.types.VdomDictConstructor` has been renamed to `reactpy.types.VdomConstructor`.
6363
- `REACTPY_ASYNC_RENDERING` can now de-duplicate and cascade renders where necessary.
6464
- `REACTPY_ASYNC_RENDERING` is now defaulted to `True` for up to 40x performance improvements in environments with high concurrency.
65+
- Events now support debounce, which can now be configured per event with `event.debounce = <milliseconds>`. Note that `input`, `select`, and `textarea` elements default to 200ms debounce.
6566

6667
### Deprecated
6768

src/js/packages/@reactpy/client/src/components.tsx

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,34 @@ import type { ReactPyClient } from "./client";
1818

1919
const ClientContext = createContext<ReactPyClient>(null as any);
2020

21+
const DEFAULT_INPUT_DEBOUNCE = 200;
22+
23+
type ReactPyInputHandler = ((event: TargetedEvent<any>) => void) & {
24+
debounce?: number;
25+
isHandler?: boolean;
26+
};
27+
28+
type UserInputTarget = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
29+
30+
function trackUserInput(
31+
event: TargetedEvent<any>,
32+
setValue: (value: any) => void,
33+
lastUserValue: MutableRefObject<any>,
34+
lastChangeTime: MutableRefObject<number>,
35+
lastInputDebounce: MutableRefObject<number>,
36+
debounce: number,
37+
): void {
38+
if (!event.target) {
39+
return;
40+
}
41+
42+
const newValue = (event.target as UserInputTarget).value;
43+
setValue(newValue);
44+
lastUserValue.current = newValue;
45+
lastChangeTime.current = Date.now();
46+
lastInputDebounce.current = debounce;
47+
}
48+
2149
export function Layout(props: { client: ReactPyClient }): JSX.Element {
2250
const currentModel: ReactPyVdom = useState({ tagName: "" })[0];
2351
const forceUpdate = useForceUpdate();
@@ -84,6 +112,7 @@ function UserInputElement({ model }: { model: ReactPyVdom }): JSX.Element {
84112
const [value, setValue] = useState(props.value);
85113
const lastUserValue = useRef(props.value);
86114
const lastChangeTime = useRef(0);
115+
const lastInputDebounce = useRef(DEFAULT_INPUT_DEBOUNCE);
87116

88117
// honor changes to value from the client via props
89118
useEffect(() => {
@@ -93,24 +122,34 @@ function UserInputElement({ model }: { model: ReactPyVdom }): JSX.Element {
93122
const now = Date.now();
94123
if (
95124
props.value === lastUserValue.current ||
96-
now - lastChangeTime.current > 200
125+
now - lastChangeTime.current >= lastInputDebounce.current
97126
) {
98127
setValue(props.value);
99128
}
100129
}, [props.value]);
101130

102-
const givenOnChange = props.onChange;
103-
if (typeof givenOnChange === "function") {
104-
props.onChange = (event: TargetedEvent<any>) => {
105-
// immediately update the value to give the user feedback
106-
if (event.target) {
107-
const newValue = (event.target as HTMLInputElement).value;
108-
setValue(newValue);
109-
lastUserValue.current = newValue;
110-
lastChangeTime.current = Date.now();
111-
}
112-
// allow the client to respond (and possibly change the value)
113-
givenOnChange(event);
131+
for (const [name, prop] of Object.entries(props)) {
132+
if (typeof prop !== "function") {
133+
continue;
134+
}
135+
136+
const givenHandler = prop as ReactPyInputHandler;
137+
if (!givenHandler.isHandler) {
138+
continue;
139+
}
140+
141+
props[name] = (event: TargetedEvent<any>) => {
142+
trackUserInput(
143+
event,
144+
setValue,
145+
lastUserValue,
146+
lastChangeTime,
147+
lastInputDebounce,
148+
typeof givenHandler.debounce === "number"
149+
? givenHandler.debounce
150+
: DEFAULT_INPUT_DEBOUNCE,
151+
);
152+
givenHandler(event);
114153
};
115154
}
116155

src/js/packages/@reactpy/client/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export type ReactPyVdomEventHandler = {
6060
target: string;
6161
preventDefault?: boolean;
6262
stopPropagation?: boolean;
63+
debounce?: number;
6364
};
6465

6566
export type ReactPyVdomImportSource = {

src/js/packages/@reactpy/client/src/vdom.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ export function createAttributes(
206206
function createEventHandler(
207207
client: ReactPyClient,
208208
name: string,
209-
{ target, preventDefault, stopPropagation }: ReactPyVdomEventHandler,
209+
{ target, preventDefault, stopPropagation, debounce }: ReactPyVdomEventHandler,
210210
): [string, () => void] {
211211
const eventHandler = function (...args: any[]) {
212212
const data = Array.from(args).map((value) => {
@@ -227,7 +227,19 @@ function createEventHandler(
227227
});
228228
client.sendMessage({ type: "layout-event", data, target });
229229
};
230-
eventHandler.isHandler = true;
230+
(
231+
eventHandler as typeof eventHandler & {
232+
debounce?: number;
233+
isHandler: boolean;
234+
}
235+
).isHandler = true;
236+
if (typeof debounce === "number") {
237+
(
238+
eventHandler as typeof eventHandler & {
239+
debounce?: number;
240+
}
241+
).debounce = debounce;
242+
}
231243
return [name, eventHandler];
232244
}
233245

src/reactpy/core/events.py

Lines changed: 52 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def event(
1818
*,
1919
stop_propagation: bool = ...,
2020
prevent_default: bool = ...,
21+
debounce: int | None = ...,
2122
) -> EventHandler: ...
2223

2324

@@ -27,6 +28,7 @@ def event(
2728
*,
2829
stop_propagation: bool = ...,
2930
prevent_default: bool = ...,
31+
debounce: int | None = ...,
3032
) -> Callable[[Callable[..., Any]], EventHandler]: ...
3133

3234

@@ -35,6 +37,7 @@ def event(
3537
*,
3638
stop_propagation: bool = False,
3739
prevent_default: bool = False,
40+
debounce: int | None = None,
3841
) -> EventHandler | Callable[[Callable[..., Any]], EventHandler]:
3942
"""A decorator for constructing an :class:`EventHandler`.
4043
@@ -63,13 +66,17 @@ def my_callback(*data): ...
6366
Block the event from propagating further up the DOM.
6467
prevent_default:
6568
Stops the default actional associate with the event from taking place.
69+
debounce:
70+
Preserve client-side user input state for the given number of milliseconds
71+
before applying conflicting server updates.
6672
"""
6773

6874
def setup(function: Callable[..., Any]) -> EventHandler:
6975
return EventHandler(
7076
to_event_handler_function(function, positional_args=True),
7177
stop_propagation,
7278
prevent_default,
79+
debounce=debounce,
7380
)
7481

7582
return setup(function) if function is not None else setup
@@ -95,10 +102,12 @@ def __init__(
95102
stop_propagation: bool = False,
96103
prevent_default: bool = False,
97104
target: str | None = None,
105+
debounce: int | None = None,
98106
) -> None:
99107
self.function = to_event_handler_function(function, positional_args=False)
100108
self.prevent_default = prevent_default
101109
self.stop_propagation = stop_propagation
110+
self.debounce = debounce
102111
self.target = target
103112

104113
# Check if our `preventDefault` or `stopPropagation` methods were called
@@ -110,14 +119,16 @@ def __init__(
110119
if isinstance(func_to_inspect, partial):
111120
func_to_inspect = func_to_inspect.func
112121

113-
found_prevent_default, found_stop_propagation = _inspect_event_handler_code(
114-
func_to_inspect.__code__
122+
found_prevent_default, found_stop_propagation, found_debounce = (
123+
_inspect_event_handler_code(func_to_inspect.__code__)
115124
)
116125

117126
if found_prevent_default:
118127
self.prevent_default = True
119128
if found_stop_propagation:
120129
self.stop_propagation = True
130+
if found_debounce is not None:
131+
self.debounce = found_debounce
121132

122133
__hash__ = None # type: ignore
123134

@@ -130,6 +141,7 @@ def __eq__(self, other: object) -> bool:
130141
"function",
131142
"prevent_default",
132143
"stop_propagation",
144+
"debounce",
133145
"target",
134146
)
135147
)
@@ -184,8 +196,9 @@ def merge_event_handlers(
184196
"""Merge multiple event handlers into one
185197
186198
Raises a ValueError if any handlers have conflicting
187-
:attr:`~reactpy.core.proto.EventHandlerType.stop_propagation` or
188-
:attr:`~reactpy.core.proto.EventHandlerType.prevent_default` attributes.
199+
:attr:`~reactpy.core.proto.EventHandlerType.stop_propagation`,
200+
:attr:`~reactpy.core.proto.EventHandlerType.prevent_default`, or
201+
:attr:`~reactpy.core.proto.EventHandlerType.debounce` attributes.
189202
"""
190203
if not event_handlers:
191204
msg = "No event handlers to merge"
@@ -197,22 +210,28 @@ def merge_event_handlers(
197210

198211
stop_propagation = first_handler.stop_propagation
199212
prevent_default = first_handler.prevent_default
213+
debounce = first_handler.debounce
200214
target = first_handler.target
201215

202216
for handler in event_handlers:
203217
if (
204218
handler.stop_propagation != stop_propagation
205219
or handler.prevent_default != prevent_default
220+
or handler.debounce != debounce
206221
or handler.target != target
207222
):
208-
msg = "Cannot merge handlers - 'stop_propagation', 'prevent_default' or 'target' mismatch."
223+
msg = (
224+
"Cannot merge handlers - 'stop_propagation', 'prevent_default', "
225+
"'debounce' or 'target' mismatch."
226+
)
209227
raise ValueError(msg)
210228

211229
return EventHandler(
212230
merge_event_handler_funcs([h.function for h in event_handlers]),
213231
stop_propagation,
214232
prevent_default,
215233
target,
234+
debounce,
216235
)
217236

218237

@@ -235,43 +254,54 @@ async def await_all_event_handlers(data: Sequence[Any]) -> None:
235254

236255

237256
@lru_cache(maxsize=4096)
238-
def _inspect_event_handler_code(code: CodeType) -> tuple[bool, bool]:
257+
def _inspect_event_handler_code(code: CodeType) -> tuple[bool, bool, int | None]:
239258
prevent_default = False
240259
stop_propagation = False
260+
debounce = None
241261

242262
if code.co_argcount > 0:
243263
names = code.co_names
244264
check_prevent_default = "preventDefault" in names
245265
check_stop_propagation = "stopPropagation" in names
266+
check_debounce = "debounce" in names
246267

247-
if not (check_prevent_default or check_stop_propagation):
248-
return False, False
268+
if not (check_prevent_default or check_stop_propagation or check_debounce):
269+
return False, False, None
249270

250271
event_arg_name = code.co_varnames[0]
251272
last_was_event = False
273+
instructions = list(dis.get_instructions(code))
252274

253-
for instr in dis.get_instructions(code):
275+
for index, instr in enumerate(instructions):
254276
if (
255277
instr.opname in ("LOAD_FAST", "LOAD_FAST_BORROW")
256278
and instr.argval == event_arg_name
257279
):
258280
last_was_event = True
259281
continue
260282

261-
if last_was_event and instr.opname in (
262-
"LOAD_METHOD",
263-
"LOAD_ATTR",
264-
):
265-
if check_prevent_default and instr.argval == "preventDefault":
266-
prevent_default = True
267-
check_prevent_default = False
268-
elif check_stop_propagation and instr.argval == "stopPropagation":
269-
stop_propagation = True
270-
check_stop_propagation = False
271-
272-
if not (check_prevent_default or check_stop_propagation):
283+
if last_was_event:
284+
if instr.opname in ("LOAD_METHOD", "LOAD_ATTR"):
285+
if check_prevent_default and instr.argval == "preventDefault":
286+
prevent_default = True
287+
check_prevent_default = False
288+
elif check_stop_propagation and instr.argval == "stopPropagation":
289+
stop_propagation = True
290+
check_stop_propagation = False
291+
elif check_debounce and instr.opname == "STORE_ATTR":
292+
if instr.argval == "debounce" and index > 1:
293+
candidate = instructions[index - 2].argval
294+
if isinstance(candidate, int) and not isinstance(
295+
candidate, bool
296+
):
297+
debounce = candidate
298+
check_debounce = False
299+
300+
if not (
301+
check_prevent_default or check_stop_propagation or check_debounce
302+
):
273303
break
274304

275305
last_was_event = False
276306

277-
return prevent_default, stop_propagation
307+
return prevent_default, stop_propagation, debounce

src/reactpy/core/layout.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,11 @@ def _render_model_attributes(
401401
"target": target,
402402
"preventDefault": handler.prevent_default,
403403
"stopPropagation": handler.stop_propagation,
404+
**(
405+
{"debounce": handler.debounce}
406+
if handler.debounce is not None
407+
else {}
408+
),
404409
}
405410

406411
return None
@@ -426,6 +431,11 @@ def _render_model_event_handlers_without_old_state(
426431
"target": target,
427432
"preventDefault": handler.prevent_default,
428433
"stopPropagation": handler.stop_propagation,
434+
**(
435+
{"debounce": handler.debounce}
436+
if handler.debounce is not None
437+
else {}
438+
),
429439
}
430440

431441
return None

src/reactpy/core/vdom.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
"target": {"type": "string"},
7373
"preventDefault": {"type": "boolean"},
7474
"stopPropagation": {"type": "boolean"},
75+
"debounce": {"type": "integer", "minimum": 0},
7576
},
7677
"required": ["target"],
7778
},

src/reactpy/types.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,7 @@ class JsonEventTarget(TypedDict):
915915
target: str
916916
preventDefault: bool
917917
stopPropagation: bool
918+
debounce: NotRequired[int]
918919

919920

920921
class JsonImportSource(TypedDict):
@@ -939,6 +940,7 @@ class BaseEventHandler:
939940

940941
__slots__ = (
941942
"__weakref__",
943+
"debounce",
942944
"function",
943945
"prevent_default",
944946
"stop_propagation",
@@ -954,6 +956,9 @@ class BaseEventHandler:
954956
stop_propagation: bool
955957
"""Stops the default action associate with the event from taking place."""
956958

959+
debounce: int | None
960+
"""How long, in milliseconds, client-side user input state should be preserved."""
961+
957962
target: str | None
958963
"""Typically left as ``None`` except when a static target is useful.
959964
@@ -1124,6 +1129,8 @@ class Event(dict):
11241129
A light `dict` wrapper for event data passed to event handler functions.
11251130
"""
11261131

1132+
debounce: int | None
1133+
11271134
def __getattr__(self, name: str) -> Any:
11281135
value = self.get(name)
11291136
return Event(value) if isinstance(value, dict) else value

0 commit comments

Comments
 (0)