Skip to content

Commit 5dd41ba

Browse files
authored
Support dot notation callbacks on events (#1308)
1 parent c6056fa commit 5dd41ba

File tree

5 files changed

+146
-6
lines changed

5 files changed

+146
-6
lines changed

docs/source/about/changelog.rst

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ Unreleased
3131
- :pull:`1281` - Added type hints to ``reactpy.html`` attributes.
3232
- :pull:`1285` - Added support for nested components in web modules
3333
- :pull:`1289` - Added support for inline JavaScript as event handlers or other attributes that expect a callable via ``reactpy.types.InlineJavaScript``
34-
-:pull:`1307` - Added ``reactpy.web.reactjs_component_from_file`` to import ReactJS components from a file.
35-
-:pull:`1307` - Added ``reactpy.web.reactjs_component_from_url`` to import ReactJS components from a URL.
36-
-:pull:`1307` - Added ``reactpy.web.reactjs_component_from_string`` to import ReactJS components from a string.
34+
- :pull:`1307` - Added ``reactpy.web.reactjs_component_from_file`` to import ReactJS components from a file.
35+
- :pull:`1307` - Added ``reactpy.web.reactjs_component_from_url`` to import ReactJS components from a URL.
36+
- :pull:`1307` - Added ``reactpy.web.reactjs_component_from_string`` to import ReactJS components from a string.
37+
- :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.
38+
- :pull:`1308` - Event data now supports accessing properties via dot notation (ex. ``event.target.value``).
3739

3840
**Changed**
3941

src/reactpy/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from reactpy._html import html
33
from reactpy.core import hooks
44
from reactpy.core.component import component
5-
from reactpy.core.events import event
5+
from reactpy.core.events import Event, event
66
from reactpy.core.hooks import (
77
create_context,
88
use_async_effect,
@@ -27,6 +27,7 @@
2727
__version__ = "2.0.0b2"
2828

2929
__all__ = [
30+
"Event",
3031
"Layout",
3132
"Ref",
3233
"Vdom",

src/reactpy/core/events.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import dis
45
from collections.abc import Sequence
5-
from typing import Any, Callable, Literal, overload
6+
from typing import Any, Callable, Literal, cast, overload
67

78
from anyio import create_task_group
89

@@ -72,6 +73,18 @@ def setup(function: Callable[..., Any]) -> EventHandler:
7273
return setup(function) if function is not None else setup
7374

7475

76+
class Event(dict):
77+
def __getattr__(self, name: str) -> Any:
78+
value = self.get(name)
79+
return Event(value) if isinstance(value, dict) else value
80+
81+
def preventDefault(self) -> None:
82+
"""Prevent the default action of the event."""
83+
84+
def stopPropagation(self) -> None:
85+
"""Stop the event from propagating."""
86+
87+
7588
class EventHandler:
7689
"""Turn a function or coroutine into an event handler
7790
@@ -106,6 +119,33 @@ def __init__(
106119
self.stop_propagation = stop_propagation
107120
self.target = target
108121

122+
# Check if our `preventDefault` or `stopPropagation` methods were called
123+
# by inspecting the function's bytecode
124+
func_to_inspect = cast(Any, function)
125+
while hasattr(func_to_inspect, "__wrapped__"):
126+
func_to_inspect = func_to_inspect.__wrapped__
127+
128+
code = func_to_inspect.__code__
129+
if code.co_argcount > 0:
130+
event_arg_name = code.co_varnames[0]
131+
last_was_event = False
132+
133+
for instr in dis.get_instructions(func_to_inspect):
134+
if instr.opname == "LOAD_FAST" and instr.argval == event_arg_name:
135+
last_was_event = True
136+
continue
137+
138+
if last_was_event and instr.opname in (
139+
"LOAD_METHOD",
140+
"LOAD_ATTR",
141+
):
142+
if instr.argval == "preventDefault":
143+
self.prevent_default = True
144+
elif instr.argval == "stopPropagation":
145+
self.stop_propagation = True
146+
147+
last_was_event = False
148+
109149
__hash__ = None # type: ignore
110150

111151
def __eq__(self, other: object) -> bool:
@@ -145,17 +185,21 @@ def to_event_handler_function(
145185
async def wrapper(data: Sequence[Any]) -> None:
146186
await function(*data)
147187

188+
cast(Any, wrapper).__wrapped__ = function
189+
148190
else:
149191

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

195+
cast(Any, wrapper).__wrapped__ = function
153196
return wrapper
154197
elif not asyncio.iscoroutinefunction(function):
155198

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

202+
cast(Any, wrapper).__wrapped__ = function
159203
return wrapper
160204
else:
161205
return function

src/reactpy/core/layout.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
REACTPY_DEBUG,
3737
)
3838
from reactpy.core._life_cycle_hook import LifeCycleHook
39+
from reactpy.core.events import Event
3940
from reactpy.core.vdom import validate_vdom_json
4041
from reactpy.types import (
4142
ComponentType,
@@ -120,7 +121,8 @@ async def deliver(self, event: LayoutEventMessage | dict[str, Any]) -> None:
120121

121122
if handler is not None:
122123
try:
123-
await handler.function(event["data"])
124+
data = [Event(d) if isinstance(d, dict) else d for d in event["data"]]
125+
await handler.function(data)
124126
except Exception:
125127
logger.exception(f"Failed to execute event handler {handler}")
126128
else:

tests/test_core/test_events.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import pytest
22

33
import reactpy
4+
from reactpy import component, html
45
from reactpy.core.events import (
6+
Event,
57
EventHandler,
68
merge_event_handler_funcs,
79
merge_event_handlers,
810
to_event_handler_function,
911
)
12+
from reactpy.core.layout import Layout
1013
from reactpy.testing import DisplayFixture, poll
1114
from tests.tooling.common import DEFAULT_TYPE_DELAY
1215

@@ -315,3 +318,91 @@ def App():
315318
generated_divs = await parent.query_selector_all("div")
316319

317320
assert len(generated_divs) == 6
321+
322+
323+
def test_detect_prevent_default():
324+
def handler(event: Event):
325+
event.preventDefault()
326+
327+
eh = EventHandler(handler)
328+
assert eh.prevent_default is True
329+
330+
331+
def test_detect_stop_propagation():
332+
def handler(event: Event):
333+
event.stopPropagation()
334+
335+
eh = EventHandler(handler)
336+
assert eh.stop_propagation is True
337+
338+
339+
def test_detect_both():
340+
def handler(event: Event):
341+
event.preventDefault()
342+
event.stopPropagation()
343+
344+
eh = EventHandler(handler)
345+
assert eh.prevent_default is True
346+
assert eh.stop_propagation is True
347+
348+
349+
def test_no_detect():
350+
def handler(event: Event):
351+
pass
352+
353+
eh = EventHandler(handler)
354+
assert eh.prevent_default is False
355+
assert eh.stop_propagation is False
356+
357+
358+
def test_event_wrapper():
359+
data = {"a": 1, "b": {"c": 2}}
360+
event = Event(data)
361+
assert event.a == 1
362+
assert event.b.c == 2
363+
assert event["a"] == 1
364+
assert event["b"]["c"] == 2
365+
366+
367+
async def test_vdom_has_prevent_default():
368+
@component
369+
def MyComponent():
370+
def handler(event: Event):
371+
event.preventDefault()
372+
373+
return html.button({"onClick": handler})
374+
375+
async with Layout(MyComponent()) as layout:
376+
await layout.render()
377+
# Check layout._event_handlers
378+
# Find the handler
379+
handler = next(iter(layout._event_handlers.values()))
380+
assert handler.prevent_default is True
381+
382+
383+
def test_event_export():
384+
from reactpy import Event
385+
386+
assert Event is not None
387+
388+
389+
def test_detect_false_positive():
390+
def handler(event: Event):
391+
# This should not trigger detection
392+
other = Event()
393+
other.preventDefault()
394+
other.stopPropagation()
395+
396+
eh = EventHandler(handler)
397+
assert eh.prevent_default is False
398+
assert eh.stop_propagation is False
399+
400+
401+
def test_detect_renamed_argument():
402+
def handler(e: Event):
403+
e.preventDefault()
404+
e.stopPropagation()
405+
406+
eh = EventHandler(handler)
407+
assert eh.prevent_default is True
408+
assert eh.stop_propagation is True

0 commit comments

Comments
 (0)