Skip to content

Support inline JavaScript events (v2) #1290

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

Merged
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
13f588a
Update vdom.py
shawncrawley Mar 28, 2025
edf0498
Update layout.py
shawncrawley Mar 28, 2025
8ae45ce
Update vdom.tsx
shawncrawley Mar 28, 2025
c11c2c4
Update react.js
shawncrawley Mar 28, 2025
db770ac
Add missing parenthesis
shawncrawley Mar 28, 2025
67cd1e7
Update layout.py
shawncrawley Mar 28, 2025
36e33e4
Update vdom.py
shawncrawley Mar 28, 2025
61fc1d3
Adds test
shawncrawley Mar 29, 2025
258c4de
Update types.py
shawncrawley Mar 29, 2025
5ce5c32
Update types
shawncrawley Mar 29, 2025
0b14bc8
Add one more test
shawncrawley Mar 29, 2025
23a7297
Implement JavaScript type and handle eval
shawncrawley Mar 31, 2025
a109a3b
Fix broken test
shawncrawley Mar 31, 2025
3a159af
Replaces test for callable non-event prop
shawncrawley Mar 31, 2025
693b112
New branch off of #1289 to highlight vdom approach
shawncrawley Apr 1, 2025
c3ddb45
Remove now-needless JavaScript distinction logic
shawncrawley Apr 1, 2025
4920d95
Remove irrelevant comment
shawncrawley Apr 1, 2025
5d7dbdd
Adds test for string_to_reactpy
shawncrawley Apr 1, 2025
435fcbc
Apply hatch fmt
shawncrawley Apr 1, 2025
d809007
Update src/reactpy/types.py
shawncrawley Apr 3, 2025
5790e11
Rename "jsExecutables" to "inlineJavascript"
shawncrawley Apr 4, 2025
7c7f851
Apply formatting
shawncrawley Apr 4, 2025
59bb9f5
Ensure consistent capitalization for JavaScript
shawncrawley Apr 14, 2025
113611e
Convert private staticmethod to standalone method
shawncrawley Apr 19, 2025
0c378b5
Update docs/source/about/changelog.rst
Archmonger Apr 19, 2025
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
1 change: 1 addition & 0 deletions src/js/packages/@reactpy/client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export type ReactPyVdom = {
children?: (ReactPyVdom | string)[];
error?: string;
eventHandlers?: { [key: string]: ReactPyVdomEventHandler };
jsExecutables?: { [key: string]: string };
importSource?: ReactPyVdomImportSource;
};

Expand Down
63 changes: 44 additions & 19 deletions src/js/packages/@reactpy/client/src/vdom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@ export function createAttributes(
createEventHandler(client, name, handler),
),
),
...Object.fromEntries(
Object.entries(model.jsExecutables || {}).map(([name, executable]) =>
createJSExecutable(name, executable),
),
),
}),
);
}
Expand All @@ -198,23 +203,43 @@ function createEventHandler(
name: string,
{ target, preventDefault, stopPropagation }: ReactPyVdomEventHandler,
): [string, () => void] {
return [
name,
function (...args: any[]) {
const data = Array.from(args).map((value) => {
if (!(typeof value === "object" && value.nativeEvent)) {
return value;
}
const event = value as React.SyntheticEvent<any>;
if (preventDefault) {
event.preventDefault();
}
if (stopPropagation) {
event.stopPropagation();
}
return serializeEvent(event.nativeEvent);
});
client.sendMessage({ type: "layout-event", data, target });
},
];
const eventHandler = function (...args: any[]) {
const data = Array.from(args).map((value) => {
if (!(typeof value === "object" && value.nativeEvent)) {
return value;
}
const event = value as React.SyntheticEvent<any>;
if (preventDefault) {
event.preventDefault();
}
if (stopPropagation) {
event.stopPropagation();
}
return serializeEvent(event.nativeEvent);
});
client.sendMessage({ type: "layout-event", data, target });
};
eventHandler.isHandler = true;
return [name, eventHandler];
}

function createJSExecutable(
name: string,
executable: string,
): [string, () => void] {
const wrappedExecutable = function (...args: any[]) {
function handleExecution(...args: any[]) {
const evalResult = eval(executable);
if (typeof evalResult == "function") {
return evalResult(...args);
}
}
if (args.length > 0 && args[0] instanceof Event) {
return handleExecution.call(args[0].currentTarget, ...args);
} else {
return handleExecution(...args);
}
};
wrappedExecutable.isHandler = false;
return [name, wrappedExecutable];
}
4 changes: 4 additions & 0 deletions src/reactpy/core/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,10 @@ def _render_model_attributes(
attrs = raw_model["attributes"].copy()
new_state.model.current["attributes"] = attrs

if "jsExecutables" in raw_model:
executables = raw_model["jsExecutables"].copy()
new_state.model.current["jsExecutables"] = executables

if old_state is None:
self._render_model_event_handlers_without_old_state(
new_state, handlers_by_event
Expand Down
37 changes: 26 additions & 11 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,16 @@
EventHandlerDict,
EventHandlerType,
ImportSourceDict,
JavaScript,
JSExecutableDict,
VdomAttributes,
VdomChildren,
VdomDict,
VdomJson,
)

EVENT_ATTRIBUTE_PATTERN = re.compile(r"^on\w+")

VDOM_JSON_SCHEMA = {
"$schema": "http://json-schema.org/draft-07/schema",
"$ref": "#/definitions/element",
Expand All @@ -42,6 +47,7 @@
"children": {"$ref": "#/definitions/elementChildren"},
"attributes": {"type": "object"},
"eventHandlers": {"$ref": "#/definitions/elementEventHandlers"},
"jsExecutables": {"$ref": "#/definitions/elementJSExecutables"},
"importSource": {"$ref": "#/definitions/importSource"},
},
# The 'tagName' is required because its presence is a useful indicator of
Expand Down Expand Up @@ -71,6 +77,12 @@
},
"required": ["target"],
},
"elementJSExecutables": {
"type": "object",
"patternProperties": {
".*": "str",
},
},
"importSource": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -160,7 +172,9 @@ def __call__(
"""The entry point for the VDOM API, for example reactpy.html(<WE_ARE_HERE>)."""
attributes, children = separate_attributes_and_children(attributes_and_children)
key = attributes.get("key", None)
attributes, event_handlers = separate_attributes_and_event_handlers(attributes)
attributes, event_handlers, js_executables = (
separate_attributes_handlers_and_executables(attributes)
)
if REACTPY_CHECK_JSON_ATTRS.current:
json.dumps(attributes)

Expand All @@ -180,6 +194,7 @@ def __call__(
**({"children": children} if children else {}),
**({"attributes": attributes} if attributes else {}),
**({"eventHandlers": event_handlers} if event_handlers else {}),
**({"jsExecutables": js_executables} if js_executables else {}),
**({"importSource": self.import_source} if self.import_source else {}),
}

Expand Down Expand Up @@ -212,26 +227,26 @@ def separate_attributes_and_children(
return _attributes, _children


def separate_attributes_and_event_handlers(
def separate_attributes_handlers_and_executables(
attributes: Mapping[str, Any],
) -> tuple[VdomAttributes, EventHandlerDict]:
) -> tuple[VdomAttributes, EventHandlerDict, JSExecutableDict]:
_attributes: VdomAttributes = {}
_event_handlers: dict[str, EventHandlerType] = {}
_js_executables: dict[str, JavaScript] = {}

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

if callable(v):
handler = EventHandler(to_event_handler_function(v))
_event_handlers[k] = EventHandler(to_event_handler_function(v))
elif isinstance(v, EventHandler):
handler = v
_event_handlers[k] = v
elif EVENT_ATTRIBUTE_PATTERN.match(k) and isinstance(v, str):
_js_executables[k] = JavaScript(v)
elif isinstance(v, JavaScript):
_js_executables[k] = v
else:
_attributes[k] = v
continue

_event_handlers[k] = handler

return _attributes, _event_handlers
return _attributes, _event_handlers, _js_executables


def _flatten_children(children: Sequence[Any]) -> list[Any]:
Expand Down
22 changes: 22 additions & 0 deletions src/reactpy/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,7 @@ class DangerouslySetInnerHTML(TypedDict):
"children",
"attributes",
"eventHandlers",
"jsExecutables",
"importSource",
]
ALLOWED_VDOM_KEYS = {
Expand All @@ -776,6 +777,7 @@ class DangerouslySetInnerHTML(TypedDict):
"children",
"attributes",
"eventHandlers",
"jsExecutables",
"importSource",
}

Expand All @@ -788,6 +790,7 @@ class VdomTypeDict(TypedDict):
children: NotRequired[Sequence[ComponentType | VdomChild]]
attributes: NotRequired[VdomAttributes]
eventHandlers: NotRequired[EventHandlerDict]
jsExecutables: NotRequired[JSExecutableDict]
importSource: NotRequired[ImportSourceDict]


Expand Down Expand Up @@ -818,6 +821,8 @@ def __getitem__(self, key: Literal["attributes"]) -> VdomAttributes: ...
@overload
def __getitem__(self, key: Literal["eventHandlers"]) -> EventHandlerDict: ...
@overload
def __getitem__(self, key: Literal["jsExecutables"]) -> JSExecutableDict: ...
@overload
def __getitem__(self, key: Literal["importSource"]) -> ImportSourceDict: ...
def __getitem__(self, key: VdomDictKeys) -> Any:
return super().__getitem__(key)
Expand All @@ -839,6 +844,10 @@ def __setitem__(
self, key: Literal["eventHandlers"], value: EventHandlerDict
) -> None: ...
@overload
def __setitem__(
self, key: Literal["jsExecutables"], value: JSExecutableDict
) -> None: ...
@overload
def __setitem__(
self, key: Literal["importSource"], value: ImportSourceDict
) -> None: ...
Expand Down Expand Up @@ -871,6 +880,7 @@ class VdomJson(TypedDict):
children: NotRequired[list[Any]]
attributes: NotRequired[VdomAttributes]
eventHandlers: NotRequired[dict[str, JsonEventTarget]]
jsExecutables: NotRequired[dict[str, JavaScript]]
importSource: NotRequired[JsonImportSource]


Expand All @@ -885,6 +895,12 @@ class JsonImportSource(TypedDict):
fallback: Any


class JavaScript(str):
"""A simple way of marking JavaScript code to be executed client-side"""

pass


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

Expand Down Expand Up @@ -922,6 +938,12 @@ class EventHandlerType(Protocol):
EventHandlerDict: TypeAlias = dict[str, EventHandlerType]
"""A dict mapping between event names to their handlers"""

JSExecutableMapping = Mapping[str, JavaScript]
"""A generic mapping between attribute names to their javascript"""

JSExecutableDict: TypeAlias = dict[str, JavaScript]
"""A dict mapping between attribute names to their javascript"""


class VdomConstructor(Protocol):
"""Standard function for constructing a :class:`VdomDict`"""
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.isHandler) {
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
9 changes: 9 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,15 @@ def test_string_to_reactpy(case):
"key": "my-key",
},
},
# 9: Includes `jsExecutables` attribue
{
"source": """<button onclick="this.innerText = 'CLICKED'">Click Me</button>""",
"model": {
"tagName": "button",
"jsExecutables": {"onclick": "this.innerText = 'CLICKED'"},
"children": ["Click Me"],
},
},
],
)
def test_string_to_reactpy_default_transforms(case):
Expand Down
Loading