From d483dc0b9df57c61022cf8ebdaf1cfb0ca832cbe Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 7 Dec 2025 05:45:18 -0800 Subject: [PATCH 1/4] Support dot-notation callbacks on events --- src/reactpy/__init__.py | 3 +- src/reactpy/core/events.py | 31 +++++++++++++++ src/reactpy/core/layout.py | 4 +- tests/test_core/test_events.py | 69 ++++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/reactpy/__init__.py b/src/reactpy/__init__.py index 00e2cfdeb..579a5b4c5 100644 --- a/src/reactpy/__init__.py +++ b/src/reactpy/__init__.py @@ -2,7 +2,7 @@ from reactpy._html import html from reactpy.core import hooks from reactpy.core.component import component -from reactpy.core.events import event +from reactpy.core.events import Event, event from reactpy.core.hooks import ( create_context, use_async_effect, @@ -27,6 +27,7 @@ __version__ = "2.0.0b2" __all__ = [ + "Event", "Layout", "Ref", "Vdom", diff --git a/src/reactpy/core/events.py b/src/reactpy/core/events.py index 34c0d0c04..dfe4acd51 100644 --- a/src/reactpy/core/events.py +++ b/src/reactpy/core/events.py @@ -1,6 +1,8 @@ from __future__ import annotations import asyncio +import contextlib +import inspect from collections.abc import Sequence from typing import Any, Callable, Literal, overload @@ -72,6 +74,18 @@ def setup(function: Callable[..., Any]) -> EventHandler: return setup(function) if function is not None else setup +class Event(dict): + def __getattr__(self, name: str) -> Any: + value = self.get(name) + return Event(value) if isinstance(value, dict) else value + + def preventDefault(self) -> None: + """Prevent the default action of the event.""" + + def stopPropagation(self) -> None: + """Stop the event from propagating.""" + + class EventHandler: """Turn a function or coroutine into an event handler @@ -102,6 +116,19 @@ def __init__( target: str | None = None, ) -> None: self.function = to_event_handler_function(function, positional_args=False) + + if not (stop_propagation and prevent_default): + with contextlib.suppress(Exception): + func_to_inspect = function + while hasattr(func_to_inspect, "__wrapped__"): + func_to_inspect = func_to_inspect.__wrapped__ + + source = inspect.getsource(func_to_inspect) + if not stop_propagation and ".stopPropagation()" in source: + stop_propagation = True + if not prevent_default and ".preventDefault()" in source: + prevent_default = True + self.prevent_default = prevent_default self.stop_propagation = stop_propagation self.target = target @@ -145,17 +172,21 @@ def to_event_handler_function( async def wrapper(data: Sequence[Any]) -> None: await function(*data) + wrapper.__wrapped__ = function + else: async def wrapper(data: Sequence[Any]) -> None: function(*data) + wrapper.__wrapped__ = function return wrapper elif not asyncio.iscoroutinefunction(function): async def wrapper(data: Sequence[Any]) -> None: function(data) + wrapper.__wrapped__ = function return wrapper else: return function diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index a81ecc6d7..879a71fa0 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -36,6 +36,7 @@ REACTPY_DEBUG, ) from reactpy.core._life_cycle_hook import LifeCycleHook +from reactpy.core.events import Event from reactpy.core.vdom import validate_vdom_json from reactpy.types import ( ComponentType, @@ -120,7 +121,8 @@ async def deliver(self, event: LayoutEventMessage | dict[str, Any]) -> None: if handler is not None: try: - await handler.function(event["data"]) + data = [Event(d) if isinstance(d, dict) else d for d in event["data"]] + await handler.function(data) except Exception: logger.exception(f"Failed to execute event handler {handler}") else: diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index 262570a74..07a2c11d2 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -1,12 +1,15 @@ import pytest import reactpy +from reactpy import component, html from reactpy.core.events import ( + Event, EventHandler, merge_event_handler_funcs, merge_event_handlers, to_event_handler_function, ) +from reactpy.core.layout import Layout from reactpy.testing import DisplayFixture, poll from tests.tooling.common import DEFAULT_TYPE_DELAY @@ -315,3 +318,69 @@ def App(): generated_divs = await parent.query_selector_all("div") assert len(generated_divs) == 6 + + +def test_detect_prevent_default(): + def handler(event: Event): + event.preventDefault() + + eh = EventHandler(handler) + assert eh.prevent_default is True + + +def test_detect_stop_propagation(): + def handler(event: Event): + event.stopPropagation() + + eh = EventHandler(handler) + assert eh.stop_propagation is True + + +def test_detect_both(): + def handler(event: Event): + event.preventDefault() + event.stopPropagation() + + eh = EventHandler(handler) + assert eh.prevent_default is True + assert eh.stop_propagation is True + + +def test_no_detect(): + def handler(event: Event): + pass + + eh = EventHandler(handler) + assert eh.prevent_default is False + assert eh.stop_propagation is False + + +def test_event_wrapper(): + data = {"a": 1, "b": {"c": 2}} + event = Event(data) + assert event.a == 1 + assert event.b.c == 2 + assert event["a"] == 1 + assert event["b"]["c"] == 2 + + +async def test_vdom_has_prevent_default(): + @component + def MyComponent(): + def handler(event: Event): + event.preventDefault() + + return html.button({"onClick": handler}) + + async with Layout(MyComponent()) as layout: + await layout.render() + # Check layout._event_handlers + # Find the handler + handler = next(iter(layout._event_handlers.values())) + assert handler.prevent_default is True + + +def test_event_export(): + from reactpy import Event + + assert Event is not None From a571c28f64a8de2895d4a06a6e35dd4ddba99d2f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 7 Dec 2025 05:48:57 -0800 Subject: [PATCH 2/4] fix type hints --- src/reactpy/core/events.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/reactpy/core/events.py b/src/reactpy/core/events.py index dfe4acd51..05ccc6523 100644 --- a/src/reactpy/core/events.py +++ b/src/reactpy/core/events.py @@ -4,7 +4,7 @@ import contextlib import inspect from collections.abc import Sequence -from typing import Any, Callable, Literal, overload +from typing import Any, Callable, Literal, cast, overload from anyio import create_task_group @@ -119,7 +119,7 @@ def __init__( if not (stop_propagation and prevent_default): with contextlib.suppress(Exception): - func_to_inspect = function + func_to_inspect = cast(Any, function) while hasattr(func_to_inspect, "__wrapped__"): func_to_inspect = func_to_inspect.__wrapped__ @@ -172,21 +172,21 @@ def to_event_handler_function( async def wrapper(data: Sequence[Any]) -> None: await function(*data) - wrapper.__wrapped__ = function + cast(Any, wrapper).__wrapped__ = function else: async def wrapper(data: Sequence[Any]) -> None: function(*data) - wrapper.__wrapped__ = function + cast(Any, wrapper).__wrapped__ = function return wrapper elif not asyncio.iscoroutinefunction(function): async def wrapper(data: Sequence[Any]) -> None: function(data) - wrapper.__wrapped__ = function + cast(Any, wrapper).__wrapped__ = function return wrapper else: return function From 312be68642b36a78a856996b7ecffdef9ff09bdd Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 7 Dec 2025 16:58:49 -0800 Subject: [PATCH 3/4] add changelog --- docs/source/about/changelog.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index e9bb8514a..aed931b99 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -31,9 +31,11 @@ Unreleased - :pull:`1281` - Added type hints to ``reactpy.html`` attributes. - :pull:`1285` - Added support for nested components in web modules - :pull:`1289` - Added support for inline JavaScript as event handlers or other attributes that expect a callable via ``reactpy.types.InlineJavaScript`` --:pull:`1307` - Added ``reactpy.web.reactjs_component_from_file`` to import ReactJS components from a file. --:pull:`1307` - Added ``reactpy.web.reactjs_component_from_url`` to import ReactJS components from a URL. --:pull:`1307` - Added ``reactpy.web.reactjs_component_from_string`` to import ReactJS components from a string. +- :pull:`1307` - Added ``reactpy.web.reactjs_component_from_file`` to import ReactJS components from a file. +- :pull:`1307` - Added ``reactpy.web.reactjs_component_from_url`` to import ReactJS components from a URL. +- :pull:`1307` - Added ``reactpy.web.reactjs_component_from_string`` to import ReactJS components from a string. +- :pull:`1308` - Event functions can now call ``event.preventDefault()`` and ``event.stopPropagation()`` methods directly on the event data object, rather than using the ``@event`` decorator. +- :pull:`1308` - Event data now supports accessing properties via dot notation (ex. ``event.target.value``). **Changed** From 0a111de72f0f9f187b50f0476c167537dc9aa9b3 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 7 Dec 2025 17:17:49 -0800 Subject: [PATCH 4/4] proper handling of false positives --- src/reactpy/core/events.py | 43 ++++++++++++++++++++++------------ tests/test_core/test_events.py | 22 +++++++++++++++++ 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/reactpy/core/events.py b/src/reactpy/core/events.py index 05ccc6523..ff71c6b6f 100644 --- a/src/reactpy/core/events.py +++ b/src/reactpy/core/events.py @@ -1,8 +1,7 @@ from __future__ import annotations import asyncio -import contextlib -import inspect +import dis from collections.abc import Sequence from typing import Any, Callable, Literal, cast, overload @@ -116,23 +115,37 @@ def __init__( target: str | None = None, ) -> None: self.function = to_event_handler_function(function, positional_args=False) - - if not (stop_propagation and prevent_default): - with contextlib.suppress(Exception): - func_to_inspect = cast(Any, function) - while hasattr(func_to_inspect, "__wrapped__"): - func_to_inspect = func_to_inspect.__wrapped__ - - source = inspect.getsource(func_to_inspect) - if not stop_propagation and ".stopPropagation()" in source: - stop_propagation = True - if not prevent_default and ".preventDefault()" in source: - prevent_default = True - self.prevent_default = prevent_default self.stop_propagation = stop_propagation self.target = target + # Check if our `preventDefault` or `stopPropagation` methods were called + # by inspecting the function's bytecode + func_to_inspect = cast(Any, function) + while hasattr(func_to_inspect, "__wrapped__"): + func_to_inspect = func_to_inspect.__wrapped__ + + code = func_to_inspect.__code__ + if code.co_argcount > 0: + event_arg_name = code.co_varnames[0] + last_was_event = False + + for instr in dis.get_instructions(func_to_inspect): + if instr.opname == "LOAD_FAST" and instr.argval == event_arg_name: + last_was_event = True + continue + + if last_was_event and instr.opname in ( + "LOAD_METHOD", + "LOAD_ATTR", + ): + if instr.argval == "preventDefault": + self.prevent_default = True + elif instr.argval == "stopPropagation": + self.stop_propagation = True + + last_was_event = False + __hash__ = None # type: ignore def __eq__(self, other: object) -> bool: diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index 07a2c11d2..6c6b1da26 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -384,3 +384,25 @@ def test_event_export(): from reactpy import Event assert Event is not None + + +def test_detect_false_positive(): + def handler(event: Event): + # This should not trigger detection + other = Event() + other.preventDefault() + other.stopPropagation() + + eh = EventHandler(handler) + assert eh.prevent_default is False + assert eh.stop_propagation is False + + +def test_detect_renamed_argument(): + def handler(e: Event): + e.preventDefault() + e.stopPropagation() + + eh = EventHandler(handler) + assert eh.prevent_default is True + assert eh.stop_propagation is True