Skip to content

Commit ffce7b9

Browse files
committed
debounce refactoring and add throttle
1 parent a2af359 commit ffce7b9

12 files changed

Lines changed: 771 additions & 125 deletions

File tree

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 = {

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

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,25 @@
11
import eventToObject from "event-to-object";
22
import { Fragment } from "preact";
3+
import {
4+
HANDLER_DEBOUNCE,
5+
HANDLER_MARKER,
6+
HANDLER_THROTTLE,
7+
isValidDebounce,
8+
isValidThrottle,
9+
type TaggedEventHandler,
10+
} from "./handler";
11+
import { infer_bind_from_environment } from "./bind";
12+
import log from "./logger";
13+
import type { ReactPyClient } from "./client";
314
import type {
4-
ReactPyVdom,
5-
ReactPyVdomImportSource,
6-
ReactPyVdomEventHandler,
7-
ReactPyModule,
815
BindImportSource,
9-
ReactPyModuleBinding,
1016
ImportSourceBinding,
17+
ReactPyModule,
18+
ReactPyModuleBinding,
19+
ReactPyVdom,
20+
ReactPyVdomEventHandler,
21+
ReactPyVdomImportSource,
1122
} from "./types";
12-
import { infer_bind_from_environment } from "./bind";
13-
import log from "./logger";
14-
import type { ReactPyClient } from "./client";
1523

1624
export async function loadImportSource(
1725
vdomImportSource: ReactPyVdomImportSource,
@@ -211,8 +219,9 @@ function createEventHandler(
211219
preventDefault,
212220
stopPropagation,
213221
debounce,
222+
throttle,
214223
}: ReactPyVdomEventHandler,
215-
): [string, () => void] {
224+
): [string, TaggedEventHandler] {
216225
const eventHandler = function (...args: any[]) {
217226
const data = Array.from(args).map((value) => {
218227
const event = value as Event;
@@ -231,19 +240,27 @@ function createEventHandler(
231240
}
232241
});
233242
client.sendMessage({ type: "layout-event", data, target });
234-
};
235-
(
236-
eventHandler as typeof eventHandler & {
237-
debounce?: number;
238-
isHandler: boolean;
243+
} as TaggedEventHandler;
244+
eventHandler[HANDLER_MARKER] = true;
245+
if (debounce !== undefined) {
246+
if (!isValidDebounce(debounce)) {
247+
log.warn(
248+
`Ignoring invalid debounce value ${JSON.stringify(debounce)} ` +
249+
`on event handler "${name}": expected a non-negative integer.`,
250+
);
251+
} else {
252+
eventHandler[HANDLER_DEBOUNCE] = debounce;
253+
}
254+
}
255+
if (throttle !== undefined) {
256+
if (!isValidThrottle(throttle)) {
257+
log.warn(
258+
`Ignoring invalid throttle value ${JSON.stringify(throttle)} ` +
259+
`on event handler "${name}": expected a non-negative integer.`,
260+
);
261+
} else {
262+
eventHandler[HANDLER_THROTTLE] = throttle;
239263
}
240-
).isHandler = true;
241-
if (typeof debounce === "number") {
242-
(
243-
eventHandler as typeof eventHandler & {
244-
debounce?: number;
245-
}
246-
).debounce = debounce;
247264
}
248265
return [name, eventHandler];
249266
}

0 commit comments

Comments
 (0)