Skip to content
Open
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
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 (server to client, and client to server) 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
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
2 changes: 2 additions & 0 deletions src/js/packages/@reactpy/client/src/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export function createReconnectingWebSocket(
if (closed) {
return;
}
props.url.searchParams.set("path", window.location.pathname);
props.url.searchParams.set("qs", window.location.search);
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