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
Changes from all 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 docs/source/about/changelog.rst
Original file line number Diff line number Diff line change
@@ -30,6 +30,7 @@ Unreleased
- :pull:`1281` - Added ``reactpy.Vdom`` primitive interface for creating VDOM dictionaries.
- :pull:`1281` - Added type hints to ``reactpy.html`` attributes.
- :pull:`1285` - Added support for nested components in web modules
- :pull:`1289` - Added support for inline JavaScript as event handlers or other attributes that expect a callable via ``reactpy.types.InlineJavaScript``

**Changed**

1 change: 1 addition & 0 deletions src/js/packages/@reactpy/client/src/types.ts
Original file line number Diff line number Diff line change
@@ -53,6 +53,7 @@ export type ReactPyVdom = {
children?: (ReactPyVdom | string)[];
error?: string;
eventHandlers?: { [key: string]: ReactPyVdomEventHandler };
inlineJavaScript?: { [key: string]: string };
importSource?: ReactPyVdomImportSource;
};

72 changes: 53 additions & 19 deletions src/js/packages/@reactpy/client/src/vdom.tsx
Original file line number Diff line number Diff line change
@@ -189,6 +189,12 @@ export function createAttributes(
createEventHandler(client, name, handler),
),
),
...Object.fromEntries(
Object.entries(model.inlineJavaScript || {}).map(
([name, inlineJavaScript]) =>
createInlineJavaScript(name, inlineJavaScript),
),
),
}),
);
}
@@ -198,23 +204,51 @@ 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 createInlineJavaScript(
name: string,
inlineJavaScript: string,
): [string, () => void] {
/* Function that will execute the string-like InlineJavaScript
via eval in the most appropriate way */
const wrappedExecutable = function (...args: any[]) {
function handleExecution(...args: any[]) {
const evalResult = eval(inlineJavaScript);
if (typeof evalResult == "function") {
return evalResult(...args);
}
}
if (args.length > 0 && args[0] instanceof Event) {
/* If being triggered by an event, set the event's current
target to "this". This ensures that inline
javascript statements such as the following work:
html.button({"onclick": 'this.value = "Clicked!"'}, "Click Me")*/
return handleExecution.call(args[0].currentTarget, ...args);
} else {
/* If not being triggered by an event, do not set "this" and
just call normally */
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
@@ -262,6 +262,10 @@ def _render_model_attributes(
attrs = raw_model["attributes"].copy()
new_state.model.current["attributes"] = attrs

if "inlineJavaScript" in raw_model:
inline_javascript = raw_model["inlineJavaScript"].copy()
new_state.model.current["inlineJavaScript"] = inline_javascript

if old_state is None:
self._render_model_event_handlers_without_old_state(
new_state, handlers_by_event
39 changes: 28 additions & 11 deletions src/reactpy/core/vdom.py
Original file line number Diff line number Diff line change
@@ -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,16 @@
EventHandlerDict,
EventHandlerType,
ImportSourceDict,
InlineJavaScript,
InlineJavaScriptDict,
VdomAttributes,
VdomChildren,
VdomDict,
VdomJson,
)

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

VDOM_JSON_SCHEMA = {
"$schema": "http://json-schema.org/draft-07/schema",
"$ref": "#/definitions/element",
@@ -42,6 +47,7 @@
"children": {"$ref": "#/definitions/elementChildren"},
"attributes": {"type": "object"},
"eventHandlers": {"$ref": "#/definitions/elementEventHandlers"},
"inlineJavaScript": {"$ref": "#/definitions/elementInlineJavaScripts"},
"importSource": {"$ref": "#/definitions/importSource"},
},
# The 'tagName' is required because its presence is a useful indicator of
@@ -71,6 +77,12 @@
},
"required": ["target"],
},
"elementInlineJavaScripts": {
"type": "object",
"patternProperties": {
".*": "str",
},
},
"importSource": {
"type": "object",
"properties": {
@@ -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, inline_javascript = (
separate_attributes_handlers_and_inline_javascript(attributes)
)
if REACTPY_CHECK_JSON_ATTRS.current:
json.dumps(attributes)

@@ -180,6 +194,9 @@ def __call__(
**({"children": children} if children else {}),
**({"attributes": attributes} if attributes else {}),
**({"eventHandlers": event_handlers} if event_handlers else {}),
**(
{"inlineJavaScript": inline_javascript} if inline_javascript else {}
),
**({"importSource": self.import_source} if self.import_source else {}),
}

@@ -212,26 +229,26 @@ def separate_attributes_and_children(
return _attributes, _children


def separate_attributes_and_event_handlers(
def separate_attributes_handlers_and_inline_javascript(
attributes: Mapping[str, Any],
) -> tuple[VdomAttributes, EventHandlerDict]:
) -> tuple[VdomAttributes, EventHandlerDict, InlineJavaScriptDict]:
_attributes: VdomAttributes = {}
_event_handlers: dict[str, EventHandlerType] = {}
_inline_javascript: dict[str, InlineJavaScript] = {}

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):
_inline_javascript[k] = InlineJavaScript(v)
elif isinstance(v, InlineJavaScript):
_inline_javascript[k] = v
else:
_attributes[k] = v
continue

_event_handlers[k] = handler

return _attributes, _event_handlers
return _attributes, _event_handlers, _inline_javascript


def _flatten_children(children: Sequence[Any]) -> list[Any]:
20 changes: 10 additions & 10 deletions src/reactpy/transforms.py
Original file line number Diff line number Diff line change
@@ -6,6 +6,16 @@
from reactpy.types import VdomAttributes, VdomDict


def attributes_to_reactjs(attributes: VdomAttributes):
"""Convert HTML attribute names to their ReactJS equivalents."""
attrs = cast(VdomAttributes, attributes.items())
attrs = cast(
VdomAttributes,
{REACT_PROP_SUBSTITUTIONS.get(k, k): v for k, v in attrs},
)
return attrs


class RequiredTransforms:
"""Performs any necessary transformations related to `string_to_reactpy` to automatically prevent
issues with React's rendering engine.
@@ -36,16 +46,6 @@ def normalize_style_attributes(self, vdom: dict[str, Any]) -> None:
)
}

@staticmethod
def html_props_to_reactjs(vdom: VdomDict) -> None:
"""Convert HTML prop names to their ReactJS equivalents."""
if "attributes" in vdom:
items = cast(VdomAttributes, vdom["attributes"].items())
vdom["attributes"] = cast(
VdomAttributes,
{REACT_PROP_SUBSTITUTIONS.get(k, k): v for k, v in items},
)

@staticmethod
def textarea_children_to_prop(vdom: VdomDict) -> None:
"""Transformation that converts the text content of a <textarea> to a ReactJS prop."""
22 changes: 22 additions & 0 deletions src/reactpy/types.py
Original file line number Diff line number Diff line change
@@ -768,6 +768,7 @@ class DangerouslySetInnerHTML(TypedDict):
"children",
"attributes",
"eventHandlers",
"inlineJavaScript",
"importSource",
]
ALLOWED_VDOM_KEYS = {
@@ -776,6 +777,7 @@ class DangerouslySetInnerHTML(TypedDict):
"children",
"attributes",
"eventHandlers",
"inlineJavaScript",
"importSource",
}

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


@@ -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["inlineJavaScript"]) -> InlineJavaScriptDict: ...
@overload
def __getitem__(self, key: Literal["importSource"]) -> ImportSourceDict: ...
def __getitem__(self, key: VdomDictKeys) -> Any:
return super().__getitem__(key)
@@ -839,6 +844,10 @@ def __setitem__(
self, key: Literal["eventHandlers"], value: EventHandlerDict
) -> None: ...
@overload
def __setitem__(
self, key: Literal["inlineJavaScript"], value: InlineJavaScriptDict
) -> None: ...
@overload
def __setitem__(
self, key: Literal["importSource"], value: ImportSourceDict
) -> None: ...
@@ -871,6 +880,7 @@ class VdomJson(TypedDict):
children: NotRequired[list[Any]]
attributes: NotRequired[VdomAttributes]
eventHandlers: NotRequired[dict[str, JsonEventTarget]]
inlineJavaScript: NotRequired[dict[str, InlineJavaScript]]
importSource: NotRequired[JsonImportSource]


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


class InlineJavaScript(str):
"""Simple subclass that flags a user's string in ReactPy VDOM attributes as executable JavaScript."""

pass


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

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

InlineJavaScriptMapping = Mapping[str, InlineJavaScript]
"""A generic mapping between attribute names to their inline javascript"""

InlineJavaScriptDict: TypeAlias = dict[str, InlineJavaScript]
"""A dict mapping between attribute names to their inline javascript"""


class VdomConstructor(Protocol):
"""Standard function for constructing a :class:`VdomDict`"""
8 changes: 6 additions & 2 deletions src/reactpy/utils.py
Original file line number Diff line number Diff line change
@@ -10,7 +10,7 @@
from lxml.html import fromstring

from reactpy import html
from reactpy.transforms import RequiredTransforms
from reactpy.transforms import RequiredTransforms, attributes_to_reactjs
from reactpy.types import ComponentType, VdomDict

_RefValue = TypeVar("_RefValue")
@@ -148,9 +148,13 @@ def _etree_to_vdom(
# Recursively call _etree_to_vdom() on all children
children = _generate_vdom_children(node, transforms, intercept_links)

# This transform is required prior to initializing the Vdom so InlineJavaScript
# gets properly parsed (ex. <button onClick="this.innerText = 'Clicked';")
attributes = attributes_to_reactjs(dict(node.items()))

# Convert the lxml node to a VDOM dict
constructor = getattr(html, str(node.tag))
el = constructor(dict(node.items()), children)
el = constructor(attributes, children)

# Perform necessary transformations on the VDOM attributes to meet VDOM spec
RequiredTransforms(el, intercept_links)
2 changes: 1 addition & 1 deletion src/reactpy/web/templates/react.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading