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
8 changes: 5 additions & 3 deletions docs/source/about/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down
3 changes: 2 additions & 1 deletion src/reactpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -27,6 +27,7 @@
__version__ = "2.0.0b2"

__all__ = [
"Event",
"Layout",
"Ref",
"Vdom",
Expand Down
46 changes: 45 additions & 1 deletion src/reactpy/core/events.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from __future__ import annotations

import asyncio
import dis
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

Expand Down Expand Up @@ -72,6 +73,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

Expand Down Expand Up @@ -106,6 +119,33 @@ def __init__(
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:
Expand Down Expand Up @@ -145,17 +185,21 @@ def to_event_handler_function(
async def wrapper(data: Sequence[Any]) -> None:
await function(*data)

cast(Any, wrapper).__wrapped__ = function

else:

async def wrapper(data: Sequence[Any]) -> None:
function(*data)

cast(Any, wrapper).__wrapped__ = function
return wrapper
elif not asyncio.iscoroutinefunction(function):

async def wrapper(data: Sequence[Any]) -> None:
function(data)

cast(Any, wrapper).__wrapped__ = function
return wrapper
else:
return function
Expand Down
4 changes: 3 additions & 1 deletion src/reactpy/core/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
91 changes: 91 additions & 0 deletions tests/test_core/test_events.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -315,3 +318,91 @@ 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


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
Loading