Skip to content
18 changes: 18 additions & 0 deletions src/js/packages/@reactpy/client/src/vdom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,24 @@ function createEventHandler(
name: string,
{ target, preventDefault, stopPropagation }: ReactPyVdomEventHandler,
): [string, () => void] {
if (target.indexOf("__javascript__: ") == 0) {
return [
name,
function (...args: any[]) {
function handleEvent(...args: any[]) {
const evalResult = eval(target.replace("__javascript__: ", ""));
if (typeof evalResult == "function") {
return evalResult(...args);
}
}
if (args.length > 0 && args[0] instanceof Event) {
return handleEvent.call(args[0].target, ...args);
} else {
return handleEvent(...args);
}
},
];
}
return [
name,
function (...args: any[]) {
Expand Down
33 changes: 24 additions & 9 deletions src/reactpy/core/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
ComponentType,
Context,
EventHandlerDict,
JavaScript,
Key,
LayoutEventMessage,
LayoutUpdateMessage,
Expand Down Expand Up @@ -118,7 +119,7 @@ async def deliver(self, event: LayoutEventMessage | dict[str, Any]) -> None:
# we just ignore the event.
handler = self._event_handlers.get(event["target"])

if handler is not None:
if handler is not None and not isinstance(handler, JavaScript):
try:
await handler.function(event["data"])
except Exception:
Expand Down Expand Up @@ -277,16 +278,23 @@ def _render_model_attributes(

model_event_handlers = new_state.model.current["eventHandlers"] = {}
for event, handler in handlers_by_event.items():
if event in old_state.targets_by_event:
target = old_state.targets_by_event[event]
if isinstance(handler, JavaScript):
target = "__javascript__: " + handler
prevent_default = False
stop_propagation = False
else:
target = uuid4().hex if handler.target is None else handler.target
prevent_default = handler.prevent_default
stop_propagation = handler.stop_propagation
if event in old_state.targets_by_event:
target = old_state.targets_by_event[event]
else:
target = uuid4().hex if handler.target is None else handler.target
new_state.targets_by_event[event] = target
self._event_handlers[target] = handler
model_event_handlers[event] = {
"target": target,
"preventDefault": handler.prevent_default,
"stopPropagation": handler.stop_propagation,
"preventDefault": prevent_default,
"stopPropagation": stop_propagation,
}

return None
Expand All @@ -301,13 +309,20 @@ def _render_model_event_handlers_without_old_state(

model_event_handlers = new_state.model.current["eventHandlers"] = {}
for event, handler in handlers_by_event.items():
target = uuid4().hex if handler.target is None else handler.target
if isinstance(handler, JavaScript):
target = "__javascript__: " + handler
prevent_default = False
stop_propagation = False
else:
target = uuid4().hex if handler.target is None else handler.target
prevent_default = handler.prevent_default
stop_propagation = handler.stop_propagation
new_state.targets_by_event[event] = target
self._event_handlers[target] = handler
model_event_handlers[event] = {
"target": target,
"preventDefault": handler.prevent_default,
"stopPropagation": handler.stop_propagation,
"preventDefault": prevent_default,
"stopPropagation": stop_propagation,
}

return None
Expand Down
12 changes: 9 additions & 3 deletions src/reactpy/core/vdom.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from __future__ import annotations

import json
import re
from collections.abc import Mapping, Sequence
from typing import (
Any,
Expand All @@ -23,12 +24,15 @@
EventHandlerDict,
EventHandlerType,
ImportSourceDict,
JavaScript,
VdomAttributes,
VdomChildren,
VdomDict,
VdomJson,
)

EVENT_ATTRIBUTE_PATTERN = re.compile(r"^on[A-Z]")

VDOM_JSON_SCHEMA = {
"$schema": "http://json-schema.org/draft-07/schema",
"$ref": "#/definitions/element",
Expand Down Expand Up @@ -216,14 +220,16 @@ def separate_attributes_and_event_handlers(
attributes: Mapping[str, Any],
) -> tuple[VdomAttributes, EventHandlerDict]:
_attributes: VdomAttributes = {}
_event_handlers: dict[str, EventHandlerType] = {}
_event_handlers: dict[str, EventHandlerType | JavaScript] = {}

for k, v in attributes.items():
handler: EventHandlerType
handler: EventHandlerType | JavaScript

if callable(v):
handler = EventHandler(to_event_handler_function(v))
elif isinstance(v, EventHandler):
elif EVENT_ATTRIBUTE_PATTERN.match(k) and isinstance(v, str):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might need to change isinstance(v, str) to type(v) == str, since technically isinstance(JavaScript(), str) is True.

handler = JavaScript(v)
elif isinstance(v, (EventHandler, JavaScript)):
handler = v
else:
_attributes[k] = v
Expand Down
6 changes: 5 additions & 1 deletion src/reactpy/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -885,6 +885,10 @@ class JsonImportSource(TypedDict):
fallback: Any


class JavaScript(str):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a docstring to explain that this class is a simple way of marking JavaScript code to be executed in by the browser

pass


class EventHandlerFunc(Protocol):
"""A coroutine which can handle event data"""

Expand Down Expand Up @@ -919,7 +923,7 @@ class EventHandlerType(Protocol):
EventHandlerMapping = Mapping[str, EventHandlerType]
"""A generic mapping between event names to their handlers"""

EventHandlerDict: TypeAlias = dict[str, EventHandlerType]
EventHandlerDict: TypeAlias = dict[str, EventHandlerType | JavaScript]
"""A dict mapping between event names to their handlers"""


Expand Down
2 changes: 1 addition & 1 deletion src/reactpy/web/templates/react.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function bind(node, config) {
function wrapEventHandlers(props) {
const newProps = Object.assign({}, props);
for (const [key, value] of Object.entries(props)) {
if (typeof value === "function") {
if (typeof value === "function" && value.toString().includes(".sendMessage")) {
newProps[key] = makeJsonSafeEventHandler(value);
}
}
Expand Down
94 changes: 94 additions & 0 deletions tests/test_core/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,97 @@ def outer_click_is_not_triggered(event):
await inner.click()

await poll(lambda: clicked.current).until_is(True)


async def test_javascript_event_as_arrow_function(display: DisplayFixture):
@reactpy.component
def App():
return reactpy.html.div(
reactpy.html.div(
reactpy.html.button(
{
"id": "the-button",
"onClick": '(e) => e.target.innerText = "Thank you!"',
},
"Click Me",
),
reactpy.html.div({"id": "the-parent"}),
)
)

await display.show(lambda: App())

button = await display.page.wait_for_selector("#the-button", state="attached")
assert await button.inner_text() == "Click Me"
await button.click()
assert await button.inner_text() == "Thank you!"


async def test_javascript_event_as_this_statement(display: DisplayFixture):
@reactpy.component
def App():
return reactpy.html.div(
reactpy.html.div(
reactpy.html.button(
{
"id": "the-button",
"onClick": 'this.innerText = "Thank you!"',
},
"Click Me",
),
reactpy.html.div({"id": "the-parent"}),
)
)

await display.show(lambda: App())

button = await display.page.wait_for_selector("#the-button", state="attached")
assert await button.inner_text() == "Click Me"
await button.click()
assert await button.inner_text() == "Thank you!"


async def test_javascript_event_after_state_update(display: DisplayFixture):
@reactpy.component
def App():
click_count, set_click_count = reactpy.hooks.use_state(0)
return reactpy.html.div(
{"id": "the-parent"},
reactpy.html.button(
{
"id": "button-with-reactpy-event",
"onClick": lambda _: set_click_count(click_count + 1),
},
"Click Me",
),
reactpy.html.button(
{
"id": "button-with-javascript-event",
"onClick": """javascript: () => {
let parent = document.getElementById("the-parent");
parent.appendChild(document.createElement("div"));
}""",
},
"No, Click Me",
),
*[reactpy.html.div("Clicked") for _ in range(click_count)],
)

await display.show(lambda: App())

button1 = await display.page.wait_for_selector(
"#button-with-reactpy-event", state="attached"
)
await button1.click()
await button1.click()
await button1.click()
button2 = await display.page.wait_for_selector(
"#button-with-javascript-event", state="attached"
)
await button2.click()
await button2.click()
await button2.click()
parent = await display.page.wait_for_selector("#the-parent", state="attached")
generated_divs = await parent.query_selector_all("div")

assert len(generated_divs) == 6
26 changes: 26 additions & 0 deletions tests/test_web/js_fixtures/callable-prop.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { h, render } from "https://unpkg.com/preact?module";
import htm from "https://unpkg.com/htm?module";

const html = htm.bind(h);

export function bind(node, config) {
return {
create: (type, props, children) => h(type, props, ...children),
render: (element) => render(element, node),
unmount: () => render(null, node),
};
}

// The intention here is that Child components are passed in here so we check that the
// children of "the-parent" are "child-1" through "child-N"
export function Component(props) {
var text = "DEFAULT";
if (props.setText && typeof props.setText === "function") {
text = props.setText("PREFIX TEXT: ");
}
return html`
<div id="${props.id}">
${text}
</div>
`;
}
22 changes: 22 additions & 0 deletions tests/test_web/test_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
assert_reactpy_did_not_log,
poll,
)
from reactpy.types import JavaScript
from reactpy.web.module import NAME_SOURCE, WebModule

JS_FIXTURES_DIR = Path(__file__).parent / "js_fixtures"
Expand Down Expand Up @@ -389,6 +390,27 @@ async def test_subcomponent_notation_as_obj_attrs(display: DisplayFixture):
assert len(form_label) == 1


async def test_callable_prop_with_javacript(display: DisplayFixture):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another test is needed to see if string_to_reactpy works well with this implementation.

module = reactpy.web.module_from_file(
"callable-prop", JS_FIXTURES_DIR / "callable-prop.js"
)
Component = reactpy.web.export(module, "Component")

@reactpy.component
def App():
return Component(
{
"id": "my-div",
"setText": JavaScript('(prefixText) => prefixText + "TEST 123"'),
}
)

await display.show(lambda: App())

my_div = await display.page.wait_for_selector("#my-div", state="attached")
assert await my_div.inner_text() == "PREFIX TEXT: TEST 123"


def test_module_from_string():
reactpy.web.module_from_string("temp", "old")
with assert_reactpy_did_log(r"Existing web module .* will be replaced with"):
Expand Down