Skip to content
Draft
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
326 changes: 221 additions & 105 deletions src/js/packages/@reactpy/client/src/components.tsx

Large diffs are not rendered by default.

23 changes: 0 additions & 23 deletions src/js/packages/@reactpy/client/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,29 +16,6 @@ export type TaggedEventHandler = ((event: Event) => void) & {
[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
Expand Down
2 changes: 1 addition & 1 deletion src/js/packages/@reactpy/client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export type ReactPyComponent = ComponentType<{ model: ReactPyVdom }>;

export type ReactPyVdom = {
tagName: string;
attributes?: { [key: string]: string };
attributes?: { [key: string]: any };
children?: (ReactPyVdom | string)[];
error?: string;
eventHandlers?: { [key: string]: ReactPyVdomEventHandler };
Expand Down
20 changes: 18 additions & 2 deletions src/js/packages/@reactpy/client/src/vdom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,14 @@ function createEventHandler(
throttle,
}: ReactPyVdomEventHandler,
): [string, TaggedEventHandler] {
// Sequence number for the next outgoing event on this handler.
// The wrapper in ``UserInputElement`` may overwrite this before
// the event is actually sent (so that the wrapper, not this
// closure, owns the per-element monotonic counter and survives
// handler recreation on every server re-render). The default
// behavior (no wrapper) is to use a per-handler counter starting
// at 0.
let outgoingSeq = 0;
const eventHandler = function (...args: any[]) {
const data = Array.from(args).map((value) => {
const event = value as Event;
Expand All @@ -239,9 +247,17 @@ function createEventHandler(
return event;
}
});
client.sendMessage({ type: "layout-event", data, target });
} as TaggedEventHandler;
const seq = outgoingSeq++;
client.sendMessage({ type: "layout-event", data, target, seq });
} as TaggedEventHandler & {
_reactpy_peek_seq?: () => number;
_reactpy_set_seq?: (n: number) => void;
};
eventHandler[HANDLER_MARKER] = true;
eventHandler._reactpy_peek_seq = (): number => outgoingSeq;
eventHandler._reactpy_set_seq = (n: number): void => {
outgoingSeq = n;
};
if (debounce !== undefined) {
if (!isValidDebounce(debounce)) {
log.warn(
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.0b12"
__version__ = "2.0.0b13"

__all__ = [
"Ref",
Expand Down
83 changes: 79 additions & 4 deletions src/reactpy/core/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,22 @@ async def __aenter__(self) -> Layout:
_LifeCycleStateId, Task[LayoutUpdateMessage]
] = {}
self._render_tasks_ready: Semaphore = Semaphore(0)
# Maps a running render task back to the component it belongs to.
# Used by the stale-render detection in `_parallel_render` to drop
# out-of-order completions when async rendering is enabled and a
# newer render for the same component has already been scheduled.
self._render_task_to_lcs_id: dict[
Task[LayoutUpdateMessage], _LifeCycleStateId
] = {}
self._rendering_queue: _ThreadSafeQueue[_LifeCycleStateId] = _ThreadSafeQueue()
# Per-target event sequence tracking. Each incoming layout-event
# may carry an optional ``seq`` field (assigned by the client)
# which the server records here so it can be echoed back to the
# client as ``ackSeq`` on the originating element. This lets the
# client determine deterministically whether the server has
# processed all keystrokes without relying on a time-based
# debounce window.
self._last_event_seq_by_target: dict[str, int] = {}
root_model_state = _new_root_model_state(self.root, self._schedule_render_task)
self._root_life_cycle_state_id = root_id = root_model_state.life_cycle_state.id
self._model_states_by_life_cycle_state_id = {root_id: root_model_state}
Expand Down Expand Up @@ -107,8 +122,10 @@ async def __aexit__(
del self._event_processing_tasks
del self._rendering_queue
del self._render_tasks_by_id
del self._render_task_to_lcs_id
del self._root_life_cycle_state_id
del self._model_states_by_life_cycle_state_id
del self._last_event_seq_by_target

async def deliver(self, event: LayoutEventMessage | dict[str, Any]) -> None:
"""Dispatch an event to the targeted handler"""
Expand Down Expand Up @@ -138,6 +155,16 @@ async def _process_event_queue(
while True:
event = await queue.get()

# Record the client-assigned event sequence number (if any)
# so we can echo it back as ``ackSeq`` on the originating
# element. The client uses this to decide whether the
# server has caught up to the latest keystrokes.
seq = event.get("seq")
if isinstance(seq, int) and seq >= 0:
prev = self._last_event_seq_by_target.get(target, -1)
if seq > prev:
self._last_event_seq_by_target[target] = seq

# Retry a few times to handle potential re-render race conditions where
# the handler is temporarily removed and then re-added.
handler = self._event_handlers.get(target)
Expand Down Expand Up @@ -194,10 +221,20 @@ async def _parallel_render(self) -> LayoutUpdateMessage:
update_task: Task[LayoutUpdateMessage] = done.pop()
self._render_tasks.discard(update_task)

for lcs_id, task in list(self._render_tasks_by_id.items()):
if task is update_task:
del self._render_tasks_by_id[lcs_id]
break
lcs_id = self._render_task_to_lcs_id.pop(update_task, None)
if (
lcs_id is not None
and self._render_tasks_by_id.get(lcs_id) is not update_task
):
# A newer render has been scheduled for this component
# while we were in-flight. Drop this stale result so the
# client only receives the latest render per component.
continue
if (
lcs_id is not None
and self._render_tasks_by_id.get(lcs_id) is update_task
):
del self._render_tasks_by_id[lcs_id]

try:
return update_task.result()
Expand Down Expand Up @@ -380,13 +417,15 @@ def _render_model_attributes(
self._render_model_event_handlers_without_old_state(
new_state, handlers_by_event
)
self._inject_event_ack_seq(new_state, raw_model.get("tagName"))
return None

for old_event in set(old_state.targets_by_event).difference(handlers_by_event):
old_target = old_state.targets_by_event[old_event]
del self._event_handlers[old_target]

if not handlers_by_event:
self._inject_event_ack_seq(new_state, raw_model.get("tagName"))
return None

model_event_handlers = new_state.model.current["eventHandlers"] = {}
Expand All @@ -400,8 +439,43 @@ def _render_model_attributes(
self._event_handlers[target] = handler
model_event_handlers[event] = self._serialize_event_handler(handler, target)

self._inject_event_ack_seq(new_state, raw_model.get("tagName"))
return None

def _inject_event_ack_seq(
self, new_state: _ModelState, tag_name: str | None
) -> None:
"""Attach ``ackSeq`` to user-input element attributes so the client
can determine deterministically whether the server has processed
the latest keystrokes.

For each event handler bound to this element, we look up the
highest client-assigned sequence number the server has received
for that target and, if any was recorded, attach the maximum
across handlers as the element's ``ackSeq``.
"""
if tag_name not in {"input", "select", "textarea"}:
return
targets = list(new_state.targets_by_event.values())
if not targets:
return
max_ack: int | None = None
for target in targets:
ack = self._last_event_seq_by_target.get(target)
if ack is None:
continue
if max_ack is None or ack > max_ack:
max_ack = ack
if max_ack is None:
return
attrs = new_state.model.current.get("attributes")
if not isinstance(attrs, dict):
return
# Never let ackSeq leak into the user's attributes (it's not a
# real DOM attribute). Store it in a dedicated key the client
# recognizes.
attrs.setdefault("_reactpy_ack_seq", max_ack) # type: ignore[reportArgumentType]

def _render_model_event_handlers_without_old_state(
self,
new_state: _ModelState,
Expand Down Expand Up @@ -527,6 +601,7 @@ def _schedule_render_task(self, lcs_id: _LifeCycleStateId) -> None:
)
else:
task = create_task(self._create_layout_update(model_state))
self._render_task_to_lcs_id[task] = lcs_id
self._render_tasks.add(task)
self._render_tasks_by_id[lcs_id] = task
self._render_tasks_ready.release()
Expand Down
6 changes: 1 addition & 5 deletions src/reactpy/executors/pyscript/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,7 @@ def extend_pyscript_config(
) -> str:
# Extends ReactPy's default PyScript config with user provided values.
pyscript_config: dict[str, Any] = {
"packages": [
reactpy_pkg_string or _reactpy_pkg_string(),
"jsonpointer==3.*",
"ssl",
],
"packages": [reactpy_pkg_string or _reactpy_pkg_string(), "jsonpointer==3.*"],
"js_modules": {
"main": modules
or {
Expand Down
41 changes: 29 additions & 12 deletions tests/test_core/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -812,9 +812,16 @@ def on_change(event):

inp = await display.page.wait_for_selector("#controlled-input")

# Type a long string rapidly
# Use a moderate per-character delay so the browser's native input
# event system has time to update ``event.target.value`` between
# keystrokes. With ``delay=0`` or very low delays adjacent
# keystrokes get coalesced by the browser, causing onChange events
# to carry incorrect values. This is a well-known Playwright
# limitation — the per-handler ``debounce=N`` kwarg remains
# available for server-side flood control on real high-speed
# inputs.
target_text = "hello world this is a test"
await inp.type(target_text, delay=0)
await inp.type(target_text, delay=50)

# Wait a bit for all events to settle
await asyncio.sleep(0.5)
Expand Down Expand Up @@ -858,15 +865,17 @@ def on_change(event: Event):
async def test_controlled_input_default_debounce_reconciles_server_value(
display: DisplayFixture,
):
"""Verifies if the client keeps the latest user-provided input value even
if it received a conflicting server update within the debounce period, then
ultimately reconciles once debounce expires."""
"""Verifies that a configured ``debounce`` on the handler does not prevent
the server-transformed value from being applied — the debounce delays the
outgoing event, but once it fires and the server responds, the seq-based
reconciliation applies the server value immediately."""

@reactpy.component
def ControlledInput():
value, set_value = use_state("")

def on_change(event: Event):
event.debounce = 200
set_value(event.target.value.upper())

return reactpy.html.div(
Expand All @@ -885,19 +894,27 @@ def on_change(event: Event):
inp = await display.page.wait_for_selector("#controlled-input")
await inp.type("a", delay=0)

# The debounce delays the outgoing event, so the server value hasn't
# been updated yet — it still reflects the initial empty string.
# The client input shows the typed "a" because it's uncontrolled.
await asyncio.sleep(0.1)
assert (await inp.evaluate("node => node.value")) == "a"
server_value = await display.page.locator("#server-value").text_content()
assert server_value == "", (
f"expected empty before debounce expires, got {server_value!r}"
)

# Once the debounce window expires, the event is fired to the server,
# which uppercases the value and echoes it back. The seq-based
# reconciliation then applies the server value.
await display.page.wait_for_function(
"""
() => {
const input = document.getElementById('controlled-input');
const serverValue = document.getElementById('server-value');
return input?.value === 'a' && serverValue?.textContent === 'A';
return input?.value === 'A' && serverValue?.textContent === 'A';
}
"""
)
assert (await inp.evaluate("node => node.value")) == "a"
assert await display.page.locator("#server-value").text_content() == "A"

await display.page.wait_for_function(
"() => document.getElementById('controlled-input')?.value === 'A'"
)
assert (await inp.evaluate("node => node.value")) == "A"
assert await display.page.locator("#server-value").text_content() == "A"
Loading