Skip to content

Commit 633df5b

Browse files
authored
Add client side event throttle (#1346)
1 parent 6148799 commit 633df5b

18 files changed

Lines changed: 1335 additions & 136 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ Don't forget to remove deprecated code on each major release!
3737
- Added `reactpy.reactjs.component_from_npm` to import ReactJS components from NPM.
3838
- Added `reactpy.h` as a shorthand alias for `reactpy.html`.
3939
- Added `reactpy.config.REACTPY_MAX_QUEUE_SIZE` to configure the maximum size of all ReactPy asyncio queues (e.g. receive buffer, send buffer, event buffer) before ReactPy begins waiting until a slot frees up. This can be used to constraint memory usage.
40+
- Events now support `debounce` and `throttle`, configurable per event via `event.debounce = <milliseconds>` and `EventHandler(fn, throttle=<milliseconds>)` respectively.
41+
- `debounce` waits until activity stops, then fires once. Default is 200 ms on `input`/`select`/`textarea`, 0 ms elsewhere.
42+
- `throttle` caps the rate how often an event is allowed to execute. No default; opt in per event.
4043

4144
### Changed
4245

@@ -60,9 +63,8 @@ Don't forget to remove deprecated code on each major release!
6063
- `reactpy.core.vdom._CustomVdomDictConstructor` has been moved to `reactpy.types.CustomVdomConstructor`.
6164
- `reactpy.core.vdom._EllipsisRepr` has been moved to `reactpy.types.EllipsisRepr`.
6265
- `reactpy.types.VdomDictConstructor` has been renamed to `reactpy.types.VdomConstructor`.
63-
- `REACTPY_ASYNC_RENDERING` can now de-duplicate and cascade renders where necessary.
66+
- `REACTPY_ASYNC_RENDERING` can now de-duplicate renders where necessary.
6467
- `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.
6668

6769
### Deprecated
6870

pyproject.toml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,10 @@ check = [
191191
'bun run --cwd "{root}/src/js/packages/@reactpy/client" checkTypes',
192192
'bun run --cwd "{root}/src/js/packages/@reactpy/app" checkTypes',
193193
]
194-
fix = ['bun install --cwd "{root}/src/js"', 'bun run --cwd "{root}/src/js" format']
194+
fix = [
195+
'bun install --cwd "{root}/src/js"',
196+
'bun run --cwd "{root}/src/js" format',
197+
]
195198
test = ['hatch run javascript:build_event_to_object --dev', 'bun test']
196199
build = [
197200
'hatch run "{root}/src/build_scripts/clean_js_dir.py"',
@@ -206,7 +209,9 @@ build = [
206209
build_event_to_object = [
207210
'hatch run "{root}/src/build_scripts/build_js_event_to_object.py" {args}',
208211
]
209-
build_client = ['hatch run "{root}/src/build_scripts/build_js_client.py" {args}']
212+
build_client = [
213+
'hatch run "{root}/src/build_scripts/build_js_client.py" {args}',
214+
]
210215
build_app = ['hatch run "{root}/src/build_scripts/build_js_app.py" {args}']
211216
publish_event_to_object = [
212217
'hatch run javascript:build_event_to_object',

src/build_scripts/install_deps.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""
2+
Development/debug script to parse pyproject.toml to find dependecies then install them in the local
3+
environment via `uv pip install -U <pkg_names>`
4+
"""
5+
6+
import subprocess
7+
import tomllib as toml
8+
from pathlib import Path
9+
10+
DEPENDENCIES = set()
11+
12+
13+
def find_deps(data):
14+
"""Recurse through all categories and find any list with `dependencies` in the name, then combine
15+
all dependencies into a single list"""
16+
if isinstance(data, dict):
17+
for key, value in data.items():
18+
if (
19+
"dependencies" in key
20+
and isinstance(value, list)
21+
and value
22+
and isinstance(value[0], str)
23+
):
24+
DEPENDENCIES.update(value)
25+
else:
26+
find_deps(value)
27+
elif isinstance(data, list):
28+
for item in data:
29+
find_deps(item)
30+
31+
32+
def install_deps():
33+
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
34+
with open(pyproject_path, "rb") as f:
35+
pyproject_data = toml.load(f)
36+
find_deps(pyproject_data)
37+
DEPENDENCIES.discard(
38+
"ruff"
39+
) # ruff only exists in dev dependencies for CI purposes.
40+
subprocess.run(["uv", "pip", "install", "-U", *DEPENDENCIES], check=False) # noqa: S607,S603
41+
42+
43+
if __name__ == "__main__":
44+
install_deps()

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

Lines changed: 123 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ import {
88
type TargetedEvent,
99
} from "preact";
1010
import { useContext, useEffect, useRef, useState } from "preact/hooks";
11+
import {
12+
HANDLER_DEBOUNCE,
13+
HANDLER_MARKER,
14+
HANDLER_THROTTLE,
15+
getInitialDebounce,
16+
isValidDebounce,
17+
type TaggedEventHandler,
18+
} from "./handler";
1119
import type {
1220
ImportSourceBinding,
1321
ReactPyComponent,
@@ -18,12 +26,25 @@ import type { ReactPyClient } from "./client";
1826

1927
const ClientContext = createContext<ReactPyClient>(null as any);
2028

21-
const DEFAULT_INPUT_DEBOUNCE = 200;
22-
23-
type ReactPyInputHandler = ((event: TargetedEvent<any>) => void) & {
24-
debounce?: number;
25-
isHandler?: boolean;
26-
};
29+
// Built-in default debounce (ms) used by user-input elements (``<input>``,
30+
// ``<select>``, ``<textarea>``) when no per-handler ``debounce`` is set.
31+
// Non-input elements default to 0 ms (no debounce) — set ``debounce`` or
32+
// ``throttle`` explicitly on the handler if you need either behavior.
33+
const DEFAULT_INPUT_DEBOUNCE_MS = 200;
34+
const DEFAULT_NON_INPUT_DEBOUNCE_MS = 0;
35+
36+
const USER_INPUT_TAGS = new Set(["input", "select", "textarea"]);
37+
38+
/**
39+
* Return the built-in default debounce (ms) for an element type.
40+
* Inputs default to 200 ms to preserve text-input coherency against
41+
* server-driven ``value`` updates; all other elements default to 0 ms.
42+
*/
43+
export function getDefaultDebounceMs(tagName: string): number {
44+
return USER_INPUT_TAGS.has(tagName)
45+
? DEFAULT_INPUT_DEBOUNCE_MS
46+
: DEFAULT_NON_INPUT_DEBOUNCE_MS;
47+
}
2748

2849
type UserInputTarget =
2950
| HTMLInputElement
@@ -49,6 +70,64 @@ function trackUserInput(
4970
lastInputDebounce.current = debounce;
5071
}
5172

73+
/**
74+
* Wrap ``handler`` so its outgoing call is throttled to at most once per
75+
* ``intervalMs`` milliseconds. Subsequent calls inside the window are
76+
* collapsed and the trailing call (with the most recent arguments) fires
77+
* once the window expires.
78+
*
79+
* Returns the original handler unchanged when ``intervalMs`` is missing or
80+
* invalid.
81+
*/
82+
function throttleHandler(
83+
handler: TaggedEventHandler,
84+
intervalMs: number,
85+
): TaggedEventHandler {
86+
if (!isValidDebounce(intervalMs)) {
87+
return handler;
88+
}
89+
let pendingArgs: any[] | null = null;
90+
let timer: number | null = null;
91+
let lastFireTime = 0;
92+
93+
const wrapped = function (...args: any[]) {
94+
const now = Date.now();
95+
const elapsed = now - lastFireTime;
96+
if (elapsed >= intervalMs) {
97+
// Leading edge: fire immediately, then start a cooldown.
98+
lastFireTime = now;
99+
(handler as (...a: any[]) => void)(...args);
100+
return;
101+
}
102+
// Trailing edge: remember the latest args and schedule a fire at the
103+
// end of the cooldown so no event is silently dropped.
104+
pendingArgs = args;
105+
if (timer === null) {
106+
timer = window.setTimeout(
107+
() => {
108+
timer = null;
109+
if (pendingArgs !== null) {
110+
const callArgs = pendingArgs;
111+
pendingArgs = null;
112+
lastFireTime = Date.now();
113+
(handler as (...a: any[]) => void)(...callArgs);
114+
}
115+
},
116+
Math.max(0, intervalMs - elapsed),
117+
);
118+
}
119+
} as TaggedEventHandler;
120+
121+
// Preserve the tag markers so downstream code can still introspect the
122+
// wrapped function.
123+
wrapped[HANDLER_MARKER] = true;
124+
const debounce = handler[HANDLER_DEBOUNCE];
125+
if (typeof debounce === "number") {
126+
wrapped[HANDLER_DEBOUNCE] = debounce;
127+
}
128+
return wrapped;
129+
}
130+
52131
export function Layout(props: { client: ReactPyClient }): JSX.Element {
53132
const currentModel: ReactPyVdom = useState({ tagName: "" })[0];
54133
const forceUpdate = useForceUpdate();
@@ -97,12 +176,29 @@ export function Element({ model }: { model: ReactPyVdom }): JSX.Element | null {
97176

98177
function StandardElement({ model }: { model: ReactPyVdom }) {
99178
const client = useContext(ClientContext);
179+
const attrs = createAttributes(model, client);
180+
// Apply the throttle wrapper to tagged handlers. ``debounce`` (server-→
181+
// client value reconciliation) only applies to user-input elements, so
182+
// it's intentionally ignored here; ``throttle`` works everywhere.
183+
for (const [name, prop] of Object.entries(attrs)) {
184+
if (typeof prop !== "function") {
185+
continue;
186+
}
187+
const handler = prop as TaggedEventHandler;
188+
if (!handler[HANDLER_MARKER]) {
189+
continue;
190+
}
191+
const throttle = handler[HANDLER_THROTTLE];
192+
if (isValidDebounce(throttle)) {
193+
attrs[name] = throttleHandler(handler, throttle as number);
194+
}
195+
}
100196
// Use createElement here to avoid warning about variable numbers of children not
101197
// having keys. Warning about this must now be the responsibility of the client
102198
// providing the models instead of the client rendering them.
103199
return createElement(
104200
model.tagName === "" ? Fragment : model.tagName,
105-
createAttributes(model, client),
201+
attrs,
106202
...createChildren(model, (child) => {
107203
return <Element model={child} key={child.attributes?.key} />;
108204
}),
@@ -115,7 +211,13 @@ function UserInputElement({ model }: { model: ReactPyVdom }): JSX.Element {
115211
const [value, setValue] = useState(props.value);
116212
const lastUserValue = useRef(props.value);
117213
const lastChangeTime = useRef(0);
118-
const lastInputDebounce = useRef(DEFAULT_INPUT_DEBOUNCE);
214+
// Seed the debounce window from the handlers themselves when possible,
215+
// otherwise fall back to the per-tagName built-in default (200 ms for
216+
// user-input tags, 0 ms elsewhere). This ensures the very first
217+
// server-driven update already respects the configured debounce.
218+
const lastInputDebounce = useRef(
219+
getInitialDebounce(props, getDefaultDebounceMs(model.tagName)),
220+
);
119221
const reconcileTimeout = useRef<number | null>(null);
120222

121223
// honor changes to value from the client via props
@@ -154,23 +256,30 @@ function UserInputElement({ model }: { model: ReactPyVdom }): JSX.Element {
154256
continue;
155257
}
156258

157-
const givenHandler = prop as ReactPyInputHandler;
158-
if (!givenHandler.isHandler) {
259+
const givenHandler = prop as TaggedEventHandler;
260+
if (!givenHandler[HANDLER_MARKER]) {
159261
continue;
160262
}
161263

264+
const handlerDebounce = givenHandler[HANDLER_DEBOUNCE];
265+
const effectiveDebounce = isValidDebounce(handlerDebounce)
266+
? handlerDebounce
267+
: getDefaultDebounceMs(model.tagName);
268+
269+
const throttled = isValidDebounce(givenHandler[HANDLER_THROTTLE])
270+
? throttleHandler(givenHandler, givenHandler[HANDLER_THROTTLE] as number)
271+
: givenHandler;
272+
162273
props[name] = (event: TargetedEvent<any>) => {
163274
trackUserInput(
164275
event,
165276
setValue,
166277
lastUserValue,
167278
lastChangeTime,
168279
lastInputDebounce,
169-
typeof givenHandler.debounce === "number"
170-
? givenHandler.debounce
171-
: DEFAULT_INPUT_DEBOUNCE,
280+
effectiveDebounce,
172281
);
173-
givenHandler(event);
282+
throttled(event);
174283
};
175284
}
176285

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Shared marker property names attached to event handler functions created by
2+
// `vdom.tsx::createEventHandler`. Centralizing these strings keeps `components.tsx`
3+
// and `vdom.tsx` in sync without relying on ad-hoc property names.
4+
5+
export const HANDLER_MARKER = "isHandler" as const;
6+
export const HANDLER_DEBOUNCE = "debounce" as const;
7+
export const HANDLER_THROTTLE = "throttle" as const;
8+
9+
export type HandlerMarker = typeof HANDLER_MARKER;
10+
export type HandlerDebounce = typeof HANDLER_DEBOUNCE;
11+
export type HandlerThrottle = typeof HANDLER_THROTTLE;
12+
13+
export type TaggedEventHandler = ((event: Event) => void) & {
14+
[HANDLER_MARKER]: true;
15+
[HANDLER_DEBOUNCE]?: number;
16+
[HANDLER_THROTTLE]?: number;
17+
};
18+
19+
/**
20+
* Returns the debounce value (in milliseconds) to use as the default for a user
21+
* input element when no user input has been recorded yet. Uses the maximum
22+
* debounce among the element's tagged handlers, falling back to the global
23+
* default if none of the handlers specify one.
24+
*/
25+
export function getInitialDebounce(
26+
props: Record<string, unknown>,
27+
fallback: number,
28+
): number {
29+
let max = 0;
30+
for (const value of Object.values(props)) {
31+
if (typeof value !== "function") {
32+
continue;
33+
}
34+
const candidate = (value as TaggedEventHandler)[HANDLER_DEBOUNCE];
35+
if (typeof candidate === "number" && candidate > max) {
36+
max = candidate;
37+
}
38+
}
39+
return max > 0 ? max : fallback;
40+
}
41+
42+
/**
43+
* Returns true when the given value is a finite, non-negative integer.
44+
* Used to validate debounce/throttle values arriving over the wire from the
45+
* Python layout.
46+
*/
47+
export function isValidDebounce(value: unknown): value is number {
48+
return (
49+
typeof value === "number" &&
50+
Number.isFinite(value) &&
51+
Number.isInteger(value) &&
52+
value >= 0
53+
);
54+
}
55+
56+
export const isValidThrottle = isValidDebounce;

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from "./client";
22
export * from "./components";
3+
export * from "./handler";
34
export * from "./mount";
45
export * from "./types";
56
export * from "./vdom";

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export type ReactPyVdomEventHandler = {
6161
preventDefault?: boolean;
6262
stopPropagation?: boolean;
6363
debounce?: number;
64+
throttle?: number;
6465
};
6566

6667
export type ReactPyVdomImportSource = {

0 commit comments

Comments
 (0)