diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx index cae706787..73db8f72e 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -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[]) { diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index a32f97083..db399fdc9 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -41,6 +41,7 @@ ComponentType, Context, EventHandlerDict, + JavaScript, Key, LayoutEventMessage, LayoutUpdateMessage, @@ -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: @@ -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 @@ -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 diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 7ecddcf0e..3f6cf92d9 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import re from collections.abc import Mapping, Sequence from typing import ( Any, @@ -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", @@ -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): + handler = JavaScript(v) + elif isinstance(v, (EventHandler, JavaScript)): handler = v else: _attributes[k] = v diff --git a/src/reactpy/types.py b/src/reactpy/types.py index ba8ce31f0..0523b390a 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -885,6 +885,10 @@ class JsonImportSource(TypedDict): fallback: Any +class JavaScript(str): + pass + + class EventHandlerFunc(Protocol): """A coroutine which can handle event data""" @@ -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""" diff --git a/src/reactpy/web/templates/react.js b/src/reactpy/web/templates/react.js index 366be4fd0..b6914c6bc 100644 --- a/src/reactpy/web/templates/react.js +++ b/src/reactpy/web/templates/react.js @@ -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); } } diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index 310ddc880..262570a74 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -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 diff --git a/tests/test_web/js_fixtures/callable-prop.js b/tests/test_web/js_fixtures/callable-prop.js new file mode 100644 index 000000000..83ff1fc41 --- /dev/null +++ b/tests/test_web/js_fixtures/callable-prop.js @@ -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` +