Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ Don't forget to remove deprecated code on each major release!
- Added `reactpy.reactjs.component_from_npm` to import ReactJS components from NPM.
- Added `reactpy.h` as a shorthand alias for `reactpy.html`.
- 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.
- Events now support `debounce` and `throttle`, configurable per event via `event.debounce = <milliseconds>` and `EventHandler(fn, throttle=<milliseconds>)` respectively.
- `debounce` waits until activity stops, then fires once. Default is 200 ms on `input`/`select`/`textarea`, 0 ms elsewhere.
- `throttle` caps the rate how often an event is allowed to execute. No default; opt in per event.

### Changed

Expand All @@ -60,9 +63,8 @@ Don't forget to remove deprecated code on each major release!
- `reactpy.core.vdom._CustomVdomDictConstructor` has been moved to `reactpy.types.CustomVdomConstructor`.
- `reactpy.core.vdom._EllipsisRepr` has been moved to `reactpy.types.EllipsisRepr`.
- `reactpy.types.VdomDictConstructor` has been renamed to `reactpy.types.VdomConstructor`.
- `REACTPY_ASYNC_RENDERING` can now de-duplicate and cascade renders where necessary.
- `REACTPY_ASYNC_RENDERING` can now de-duplicate renders where necessary.
- `REACTPY_ASYNC_RENDERING` is now defaulted to `True` for up to 40x performance improvements in environments with high concurrency.
- 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.

### Deprecated

Expand Down
9 changes: 7 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,10 @@ check = [
'bun run --cwd "{root}/src/js/packages/@reactpy/client" checkTypes',
'bun run --cwd "{root}/src/js/packages/@reactpy/app" checkTypes',
]
fix = ['bun install --cwd "{root}/src/js"', 'bun run --cwd "{root}/src/js" format']
fix = [
'bun install --cwd "{root}/src/js"',
'bun run --cwd "{root}/src/js" format',
]
test = ['hatch run javascript:build_event_to_object --dev', 'bun test']
build = [
'hatch run "{root}/src/build_scripts/clean_js_dir.py"',
Expand All @@ -206,7 +209,9 @@ build = [
build_event_to_object = [
'hatch run "{root}/src/build_scripts/build_js_event_to_object.py" {args}',
]
build_client = ['hatch run "{root}/src/build_scripts/build_js_client.py" {args}']
build_client = [
'hatch run "{root}/src/build_scripts/build_js_client.py" {args}',
]
build_app = ['hatch run "{root}/src/build_scripts/build_js_app.py" {args}']
publish_event_to_object = [
'hatch run javascript:build_event_to_object',
Expand Down
44 changes: 44 additions & 0 deletions src/build_scripts/install_deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""
Development/debug script to parse pyproject.toml to find dependecies then install them in the local
environment via `uv pip install -U <pkg_names>`
"""

import subprocess
import tomllib as toml
from pathlib import Path

DEPENDENCIES = set()


def find_deps(data):
"""Recurse through all categories and find any list with `dependencies` in the name, then combine
all dependencies into a single list"""
if isinstance(data, dict):
for key, value in data.items():
if (
"dependencies" in key
and isinstance(value, list)
and value
and isinstance(value[0], str)
):
DEPENDENCIES.update(value)
else:
find_deps(value)
elif isinstance(data, list):
for item in data:
find_deps(item)


def install_deps():
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
with open(pyproject_path, "rb") as f:
pyproject_data = toml.load(f)
find_deps(pyproject_data)
DEPENDENCIES.discard(
"ruff"
) # ruff only exists in dev dependencies for CI purposes.
subprocess.run(["uv", "pip", "install", "-U", *DEPENDENCIES], check=False) # noqa: S607,S603


if __name__ == "__main__":
install_deps()
137 changes: 123 additions & 14 deletions src/js/packages/@reactpy/client/src/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ import {
type TargetedEvent,
} from "preact";
import { useContext, useEffect, useRef, useState } from "preact/hooks";
import {
HANDLER_DEBOUNCE,
HANDLER_MARKER,
HANDLER_THROTTLE,
getInitialDebounce,
isValidDebounce,
type TaggedEventHandler,
} from "./handler";
import type {
ImportSourceBinding,
ReactPyComponent,
Expand All @@ -18,12 +26,25 @@ import type { ReactPyClient } from "./client";

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

const DEFAULT_INPUT_DEBOUNCE = 200;

type ReactPyInputHandler = ((event: TargetedEvent<any>) => void) & {
debounce?: number;
isHandler?: boolean;
};
// Built-in default debounce (ms) used by user-input elements (``<input>``,
// ``<select>``, ``<textarea>``) when no per-handler ``debounce`` is set.
// Non-input elements default to 0 ms (no debounce) — set ``debounce`` or
// ``throttle`` explicitly on the handler if you need either behavior.
const DEFAULT_INPUT_DEBOUNCE_MS = 200;
const DEFAULT_NON_INPUT_DEBOUNCE_MS = 0;

const USER_INPUT_TAGS = new Set(["input", "select", "textarea"]);

/**
* Return the built-in default debounce (ms) for an element type.
* Inputs default to 200 ms to preserve text-input coherency against
* server-driven ``value`` updates; all other elements default to 0 ms.
*/
export function getDefaultDebounceMs(tagName: string): number {
return USER_INPUT_TAGS.has(tagName)
? DEFAULT_INPUT_DEBOUNCE_MS
: DEFAULT_NON_INPUT_DEBOUNCE_MS;
}

type UserInputTarget =
| HTMLInputElement
Expand All @@ -49,6 +70,64 @@ function trackUserInput(
lastInputDebounce.current = debounce;
}

/**
* Wrap ``handler`` so its outgoing call is throttled to at most once per
* ``intervalMs`` milliseconds. Subsequent calls inside the window are
* collapsed and the trailing call (with the most recent arguments) fires
* once the window expires.
*
* Returns the original handler unchanged when ``intervalMs`` is missing or
* invalid.
*/
function throttleHandler(
handler: TaggedEventHandler,
intervalMs: number,
): TaggedEventHandler {
if (!isValidDebounce(intervalMs)) {
return handler;
}
let pendingArgs: any[] | null = null;
let timer: number | null = null;
let lastFireTime = 0;

const wrapped = function (...args: any[]) {
const now = Date.now();
const elapsed = now - lastFireTime;
if (elapsed >= intervalMs) {
// Leading edge: fire immediately, then start a cooldown.
lastFireTime = now;
(handler as (...a: any[]) => void)(...args);
return;
}
// Trailing edge: remember the latest args and schedule a fire at the
// end of the cooldown so no event is silently dropped.
pendingArgs = args;
if (timer === null) {
timer = window.setTimeout(
() => {
timer = null;
if (pendingArgs !== null) {
const callArgs = pendingArgs;
pendingArgs = null;
lastFireTime = Date.now();
(handler as (...a: any[]) => void)(...callArgs);
}
},
Math.max(0, intervalMs - elapsed),
);
}
} as TaggedEventHandler;

// Preserve the tag markers so downstream code can still introspect the
// wrapped function.
wrapped[HANDLER_MARKER] = true;
const debounce = handler[HANDLER_DEBOUNCE];
if (typeof debounce === "number") {
wrapped[HANDLER_DEBOUNCE] = debounce;
}
return wrapped;
}

export function Layout(props: { client: ReactPyClient }): JSX.Element {
const currentModel: ReactPyVdom = useState({ tagName: "" })[0];
const forceUpdate = useForceUpdate();
Expand Down Expand Up @@ -97,12 +176,29 @@ export function Element({ model }: { model: ReactPyVdom }): JSX.Element | null {

function StandardElement({ model }: { model: ReactPyVdom }) {
const client = useContext(ClientContext);
const attrs = createAttributes(model, client);
// Apply the throttle wrapper to tagged handlers. ``debounce`` (server-→
// client value reconciliation) only applies to user-input elements, so
// it's intentionally ignored here; ``throttle`` works everywhere.
for (const [name, prop] of Object.entries(attrs)) {
if (typeof prop !== "function") {
continue;
}
const handler = prop as TaggedEventHandler;
if (!handler[HANDLER_MARKER]) {
continue;
}
const throttle = handler[HANDLER_THROTTLE];
if (isValidDebounce(throttle)) {
attrs[name] = throttleHandler(handler, throttle as number);
}
}
// Use createElement here to avoid warning about variable numbers of children not
// having keys. Warning about this must now be the responsibility of the client
// providing the models instead of the client rendering them.
return createElement(
model.tagName === "" ? Fragment : model.tagName,
createAttributes(model, client),
attrs,
...createChildren(model, (child) => {
return <Element model={child} key={child.attributes?.key} />;
}),
Expand All @@ -115,7 +211,13 @@ function UserInputElement({ model }: { model: ReactPyVdom }): JSX.Element {
const [value, setValue] = useState(props.value);
const lastUserValue = useRef(props.value);
const lastChangeTime = useRef(0);
const lastInputDebounce = useRef(DEFAULT_INPUT_DEBOUNCE);
// Seed the debounce window from the handlers themselves when possible,
// otherwise fall back to the per-tagName built-in default (200 ms for
// user-input tags, 0 ms elsewhere). This ensures the very first
// server-driven update already respects the configured debounce.
const lastInputDebounce = useRef(
getInitialDebounce(props, getDefaultDebounceMs(model.tagName)),
);
const reconcileTimeout = useRef<number | null>(null);

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

const givenHandler = prop as ReactPyInputHandler;
if (!givenHandler.isHandler) {
const givenHandler = prop as TaggedEventHandler;
if (!givenHandler[HANDLER_MARKER]) {
continue;
}

const handlerDebounce = givenHandler[HANDLER_DEBOUNCE];
const effectiveDebounce = isValidDebounce(handlerDebounce)
? handlerDebounce
: getDefaultDebounceMs(model.tagName);

const throttled = isValidDebounce(givenHandler[HANDLER_THROTTLE])
? throttleHandler(givenHandler, givenHandler[HANDLER_THROTTLE] as number)
: givenHandler;

props[name] = (event: TargetedEvent<any>) => {
trackUserInput(
event,
setValue,
lastUserValue,
lastChangeTime,
lastInputDebounce,
typeof givenHandler.debounce === "number"
? givenHandler.debounce
: DEFAULT_INPUT_DEBOUNCE,
effectiveDebounce,
);
givenHandler(event);
throttled(event);
};
}

Expand Down
56 changes: 56 additions & 0 deletions src/js/packages/@reactpy/client/src/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Shared marker property names attached to event handler functions created by
// `vdom.tsx::createEventHandler`. Centralizing these strings keeps `components.tsx`
// and `vdom.tsx` in sync without relying on ad-hoc property names.

export const HANDLER_MARKER = "isHandler" as const;
export const HANDLER_DEBOUNCE = "debounce" as const;
export const HANDLER_THROTTLE = "throttle" as const;

export type HandlerMarker = typeof HANDLER_MARKER;
export type HandlerDebounce = typeof HANDLER_DEBOUNCE;
export type HandlerThrottle = typeof HANDLER_THROTTLE;

export type TaggedEventHandler = ((event: Event) => void) & {
[HANDLER_MARKER]: true;
[HANDLER_DEBOUNCE]?: number;
[HANDLER_THROTTLE]?: number;
};

/**
* Returns the debounce value (in milliseconds) to use as the default for a user
* input element when no user input has been recorded yet. Uses the maximum
* debounce among the element's tagged handlers, falling back to the global
* default if none of the handlers specify one.
*/
export function getInitialDebounce(
props: Record<string, unknown>,
fallback: number,
): number {
let max = 0;
for (const value of Object.values(props)) {
if (typeof value !== "function") {
continue;
}
const candidate = (value as TaggedEventHandler)[HANDLER_DEBOUNCE];
if (typeof candidate === "number" && candidate > max) {
max = candidate;
}
}
return max > 0 ? max : fallback;
}

/**
* Returns true when the given value is a finite, non-negative integer.
* Used to validate debounce/throttle values arriving over the wire from the
* Python layout.
*/
export function isValidDebounce(value: unknown): value is number {
return (
typeof value === "number" &&
Number.isFinite(value) &&
Number.isInteger(value) &&
value >= 0
);
}

export const isValidThrottle = isValidDebounce;
1 change: 1 addition & 0 deletions src/js/packages/@reactpy/client/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from "./client";
export * from "./components";
export * from "./handler";
export * from "./mount";
export * from "./types";
export * from "./vdom";
Expand Down
1 change: 1 addition & 0 deletions src/js/packages/@reactpy/client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export type ReactPyVdomEventHandler = {
preventDefault?: boolean;
stopPropagation?: boolean;
debounce?: number;
throttle?: number;
};

export type ReactPyVdomImportSource = {
Expand Down
Loading
Loading