Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
64c9b63
Add `use_async_effect` shielding option
Archmonger Feb 17, 2026
95b823d
Fix edge case were `strictly_equal` could throw an exception
Archmonger Feb 17, 2026
726cd52
Add max queue size setting
Archmonger Feb 17, 2026
5171a1a
Move `GITHUB_ACTIONS` out of testing module
Archmonger Feb 17, 2026
fabfb88
Make pyscript utils more extensible
Archmonger Feb 17, 2026
15b6afb
Add missing changelog entry
Archmonger Feb 17, 2026
a13d21a
fix CI errors
Archmonger Feb 17, 2026
8c68d4a
Pyscript now supports ContextVars!
Archmonger Feb 17, 2026
b5be02e
Revert "Move `GITHUB_ACTIONS` out of testing module"
Archmonger Feb 18, 2026
247e329
Always append URL and QS to base websocket
Archmonger Feb 18, 2026
2040b45
bump version number
Archmonger Feb 18, 2026
e4dd8f6
fix test failures
Archmonger Feb 18, 2026
18791f9
Add changelog
Archmonger Feb 18, 2026
52be92a
Bump client version
Archmonger Feb 18, 2026
773bf7e
self review
Archmonger Feb 18, 2026
abbb27f
debounce user inputs to prevent character loss
Archmonger Feb 18, 2026
72e1974
Configurable debounce
Archmonger Apr 14, 2026
77bcc0b
fix formatting and type hint
Archmonger Apr 14, 2026
5148a1c
Fix flakey tests
Archmonger Apr 14, 2026
8b0c95d
expose max_queue_size within ReactPyConfig
Archmonger Apr 14, 2026
69b665f
queue backpressure
Archmonger Apr 14, 2026
db914fd
clean up http path/qs handling
Archmonger Apr 14, 2026
cc431df
Add test to ensure client input value takes priority.
Archmonger Apr 14, 2026
54526b9
More robust async event shielding
Archmonger Apr 14, 2026
8640a69
self review
Archmonger Apr 14, 2026
02a2ab7
Add coverage test
Archmonger Apr 14, 2026
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Don't forget to remove deprecated code on each major release!
- Added `reactpy.reactjs.component_from_string` to import ReactJS components from a string.
- 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.

### Changed

Expand All @@ -61,6 +62,7 @@ Don't forget to remove deprecated code on each major release!
- `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` 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 All @@ -85,6 +87,7 @@ Don't forget to remove deprecated code on each major release!
- Removed `reactpy.run`. See the documentation for the new method to run ReactPy applications.
- Removed `reactpy.backend.*`. See the documentation for the new method to run ReactPy applications.
- Removed `reactpy.core.types` module. Use `reactpy.types` instead.
- Removed `reactpy.utils.str_to_bool`.
- Removed `reactpy.utils.html_to_vdom`. Use `reactpy.utils.string_to_reactpy` instead.
- Removed `reactpy.utils.vdom_to_html`. Use `reactpy.utils.reactpy_to_string` instead.
- Removed `reactpy.vdom`. Use `reactpy.Vdom` instead.
Expand All @@ -101,6 +104,7 @@ Don't forget to remove deprecated code on each major release!
- Fixed a bug where script elements would not render to the DOM as plain text.
- Fixed a bug where the `key` property provided within server-side ReactPy code was failing to propagate to the front-end JavaScript components.
- Fixed a bug where `RuntimeError("Hook stack is in an invalid state")` errors could be generated when using a webserver that reuses threads.
- Fixed a bug where events on controlled inputs (e.g. `html.input({"onChange": ...})`) could be lost during rapid actions.
- Allow for ReactPy and ReactJS components to be arbitrarily inserted onto the page with any possible hierarchy.

## [1.1.0] - 2024-11-24
Expand Down
2 changes: 1 addition & 1 deletion src/js/packages/@reactpy/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,5 @@
"checkTypes": "tsc --noEmit"
},
"type": "module",
"version": "1.1.0"
"version": "1.1.1"
}
80 changes: 69 additions & 11 deletions src/js/packages/@reactpy/client/src/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,37 @@ 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;
};

type UserInputTarget =
| HTMLInputElement
| HTMLSelectElement
| HTMLTextAreaElement;

function trackUserInput(
event: TargetedEvent<any>,
setValue: (value: any) => void,
lastUserValue: MutableRefObject<any>,
lastChangeTime: MutableRefObject<number>,
lastInputDebounce: MutableRefObject<number>,
debounce: number,
): void {
if (!event.target) {
return;
}

const newValue = (event.target as UserInputTarget).value;
setValue(newValue);
lastUserValue.current = newValue;
lastChangeTime.current = Date.now();
lastInputDebounce.current = debounce;
}

export function Layout(props: { client: ReactPyClient }): JSX.Element {
const currentModel: ReactPyVdom = useState({ tagName: "" })[0];
const forceUpdate = useForceUpdate();
Expand Down Expand Up @@ -82,19 +113,46 @@ function UserInputElement({ model }: { model: ReactPyVdom }): JSX.Element {
const client = useContext(ClientContext);
const props = createAttributes(model, client);
const [value, setValue] = useState(props.value);
const lastUserValue = useRef(props.value);
const lastChangeTime = useRef(0);
const lastInputDebounce = useRef(DEFAULT_INPUT_DEBOUNCE);

// honor changes to value from the client via props
useEffect(() => setValue(props.value), [props.value]);

const givenOnChange = props.onChange;
if (typeof givenOnChange === "function") {
props.onChange = (event: TargetedEvent<any>) => {
// immediately update the value to give the user feedback
if (event.target) {
setValue((event.target as HTMLInputElement).value);
}
// allow the client to respond (and possibly change the value)
givenOnChange(event);
useEffect(() => {
// If the new prop value matches what we last sent, we are in sync.
// If it differs, we only update if sufficient time has passed since user input,
// effectively debouncing server overrides during rapid typing.
const now = Date.now();
if (
props.value === lastUserValue.current ||
now - lastChangeTime.current >= lastInputDebounce.current
) {
setValue(props.value);
}
}, [props.value]);

for (const [name, prop] of Object.entries(props)) {
if (typeof prop !== "function") {
continue;
}

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

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

Expand Down
7 changes: 2 additions & 5 deletions src/js/packages/@reactpy/client/src/mount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,9 @@ export function mountReactPy(props: MountProps) {
);

// Embed the initial HTTP path into the WebSocket URL
componentUrl.searchParams.append("http_pathname", window.location.pathname);
componentUrl.searchParams.append("path", window.location.pathname);
if (window.location.search) {
componentUrl.searchParams.append(
"http_query_string",
window.location.search,
);
componentUrl.searchParams.append("qs", window.location.search);
}

// Configure a new ReactPy client
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 @@ -60,6 +60,7 @@ export type ReactPyVdomEventHandler = {
target: string;
preventDefault?: boolean;
stopPropagation?: boolean;
debounce?: number;
};

export type ReactPyVdomImportSource = {
Expand Down
21 changes: 19 additions & 2 deletions src/js/packages/@reactpy/client/src/vdom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,12 @@ export function createAttributes(
function createEventHandler(
client: ReactPyClient,
name: string,
{ target, preventDefault, stopPropagation }: ReactPyVdomEventHandler,
{
target,
preventDefault,
stopPropagation,
debounce,
}: ReactPyVdomEventHandler,
): [string, () => void] {
const eventHandler = function (...args: any[]) {
const data = Array.from(args).map((value) => {
Expand All @@ -227,7 +232,19 @@ function createEventHandler(
});
client.sendMessage({ type: "layout-event", data, target });
};
eventHandler.isHandler = true;
(
eventHandler as typeof eventHandler & {
debounce?: number;
isHandler: boolean;
}
).isHandler = true;
if (typeof debounce === "number") {
(
eventHandler as typeof eventHandler & {
debounce?: number;
}
).debounce = debounce;
}
return [name, eventHandler];
}

Expand Down
14 changes: 14 additions & 0 deletions src/js/packages/@reactpy/client/src/websocket.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import type { CreateReconnectingWebSocketProps } from "./types";
import log from "./logger";

function syncBrowserLocation(url: URL): void {
// The window will always have a HTTP path, so ReactPy should always be aware of it.
url.searchParams.set("path", window.location.pathname);

if (window.location.search) {
// Set the query string parameter if the HTTP location has a query string.
url.searchParams.set("qs", window.location.search);
} else {
// Remove any existing (potentially stale) query string parameter if the current location doesn't have one
url.searchParams.delete("qs");
}
}

export function createReconnectingWebSocket(
props: CreateReconnectingWebSocketProps,
) {
Expand All @@ -15,6 +28,7 @@ export function createReconnectingWebSocket(
if (closed) {
return;
}
syncBrowserLocation(props.url);
socket.current = new WebSocket(props.url);
socket.current.onopen = () => {
everConnected = true;
Expand Down
2 changes: 1 addition & 1 deletion src/reactpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from reactpy.utils import Ref, reactpy_to_string, string_to_reactpy

__author__ = "The Reactive Python Team"
__version__ = "2.0.0b10"
__version__ = "2.0.0b11"

__all__ = [
"Ref",
Expand Down
8 changes: 8 additions & 0 deletions src/reactpy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,11 @@ def boolean(value: str | bool | int) -> bool:
validator=str,
)
"""The prefix for all ReactPy routes"""

REACTPY_MAX_QUEUE_SIZE = Option(
"REACTPY_MAX_QUEUE_SIZE",
default=1000,
mutable=True,
validator=int,
)
"""The maximum size for internal queues used by ReactPy"""
18 changes: 12 additions & 6 deletions src/reactpy/core/_life_cycle_hook.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

import logging
import sys
from asyncio import Event, Task, create_task, gather
from collections.abc import Callable
from contextvars import ContextVar, Token
Expand All @@ -28,9 +27,7 @@ class _HookStack(Singleton): # nocov
Life cycle hooks can be stored in a thread local or context variable depending
on the platform."""

_state: ThreadLocal[list[LifeCycleHook]] | ContextVar[list[LifeCycleHook]] = (
ThreadLocal(list) if sys.platform == "emscripten" else ContextVar("hook_state")
)
_state: ContextVar[list[LifeCycleHook]] = ContextVar("hook_state")

def get(self) -> list[LifeCycleHook]:
try:
Expand Down Expand Up @@ -268,5 +265,14 @@ def set_current(self) -> None:

def unset_current(self) -> None:
"""Unset this hook as the active hook in this thread"""
if HOOK_STACK.get().pop() is not self:
raise RuntimeError("Hook stack is in an invalid state") # nocov
hook_stack = HOOK_STACK.get()
if not hook_stack:
raise RuntimeError( # nocov
"Attempting to unset current life cycle hook but it no longer exists!\n"
"A separate process or thread may have deleted this component's hook stack!"
)
if hook_stack and hook_stack.pop() is not self:
raise RuntimeError( # nocov
"Hook stack is in an invalid state\n"
"A separate process or thread may have modified this component's hook stack!"
)
Loading
Loading