Skip to content

Support raw JavaScript events #1289

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
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