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

Open
wants to merge 23 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
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
Expand Up @@ -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

**Changed**

Expand Down
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 };
inlineJavascript?: { [key: string]: string };
importSource?: ReactPyVdomImportSource;
};

Expand Down
64 changes: 45 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,12 @@ export function createAttributes(
createEventHandler(client, name, handler),
),
),
...Object.fromEntries(
Object.entries(model.inlineJavascript || {}).map(
([name, inlineJavaScript]) =>
createInlineJavascript(name, inlineJavaScript),
),
),
}),
);
}
Expand All @@ -198,23 +204,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 createInlineJavascript(
name: string,
inlineJavaScript: string,
): [string, () => void] {
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) {
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 "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
Expand Down
39 changes: 28 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,
InlineJavascriptDict,
JavaScript,
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",
Expand All @@ -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
Expand Down Expand Up @@ -71,6 +77,12 @@
},
"required": ["target"],
},
"elementInlineJavascripts": {
"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, inline_javascript = (
separate_attributes_handlers_and_inline_javascript(attributes)
)
if REACTPY_CHECK_JSON_ATTRS.current:
json.dumps(attributes)

Expand All @@ -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 {}),
}

Expand Down Expand Up @@ -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, 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):
_inline_javascript[k] = JavaScript(v)
elif isinstance(v, JavaScript):
_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]:
Expand Down
21 changes: 13 additions & 8 deletions src/reactpy/transforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,19 @@ 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},
)
def _attributes_to_reactjs(attributes: VdomAttributes):
"""Convert HTML attribute names to their ReactJS equivalents.

This method is private because it is called prior to instantiating a
Vdom class from a parsed html string, so it does not need to be called
as part of this class's instantiation (see comments in __init__ above).
"""
attrs = cast(VdomAttributes, attributes.items())
attrs = cast(
VdomAttributes,
{REACT_PROP_SUBSTITUTIONS.get(k, k): v for k, v in attrs},
)
return attrs
Comment on lines -40 to +52
Copy link
Contributor

@Archmonger Archmonger Apr 4, 2025

Choose a reason for hiding this comment

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

Revert this function and create a separate transform function within RequiredTransforms that performs the InlineJavascript transforms.

The cadence set within RequiredTransforms is that it never needs any external calls, it just "does everything" automatically during initialization.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure what to do here. I know about the RequiredTransforms cadence due to the comment in its __init__ function, but to handle the "onclick" vs "onClick", "attributes" vs "inlineJavaScript", and ^on\w+ vs ^on[A-Z]\w+ dilemma (which we decided to stick with camelCase regex), I have to convert the inline html attributes to camelCase prior to instantiating the Vdom object, which is also prior to calling the RequiredTransforms - since these transforms all operate on Vdom object. Note the call order below:

image

I injected the camelCase transform to occur on the parsed HTML etree object (again, prior to it being used to instantiate the Vdom object and applying the standard RequiredTransforms. And if conversion to camelCase happens prior to Vdom instantiation, then there's no need to execute it again after. That is why I deleted the old function and just created a new "private" one, where it's only private so that it won't be invoked within the official cadence.

Does this makes sense? So again, I'm a bit confused. Do you want me to just restore the old function in addition to keeping the new one while maintaining the call order I've implemented? In which case it will perform a camelCase conversion twice - once before Vdom and once after.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@Archmonger Just waiting on some clarification above.

Copy link
Contributor

Choose a reason for hiding this comment

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

Will respond on Friday. Been living and breathing my day job for the last few days.


@staticmethod
def textarea_children_to_prop(vdom: VdomDict) -> None:
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",
"inlineJavascript",
"importSource",
]
ALLOWED_VDOM_KEYS = {
Expand All @@ -776,6 +777,7 @@ class DangerouslySetInnerHTML(TypedDict):
"children",
"attributes",
"eventHandlers",
"inlineJavascript",
"importSource",
}

Expand All @@ -788,6 +790,7 @@ class VdomTypeDict(TypedDict):
children: NotRequired[Sequence[ComponentType | VdomChild]]
attributes: NotRequired[VdomAttributes]
eventHandlers: NotRequired[EventHandlerDict]
inlineJavascript: NotRequired[InlineJavascriptDict]
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["inlineJavascript"]) -> InlineJavascriptDict: ...
@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["inlineJavascript"], value: InlineJavascriptDict
) -> 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]]
inlineJavascript: NotRequired[dict[str, JavaScript]]
importSource: NotRequired[JsonImportSource]


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


class JavaScript(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"""

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"""

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

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


class VdomConstructor(Protocol):
"""Standard function for constructing a :class:`VdomDict`"""
Expand Down
3 changes: 2 additions & 1 deletion src/reactpy/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,11 @@ def _etree_to_vdom(

# Recursively call _etree_to_vdom() on all children
children = _generate_vdom_children(node, transforms, intercept_links)
attributes = RequiredTransforms._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)
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
Loading