Skip to content

Commit bf06b60

Browse files
authored
Support inline JavaScript events (#1290)
1 parent c890965 commit bf06b60

File tree

13 files changed

+275
-43
lines changed

13 files changed

+275
-43
lines changed

docs/source/about/changelog.rst

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Unreleased
3030
- :pull:`1281` - Added ``reactpy.Vdom`` primitive interface for creating VDOM dictionaries.
3131
- :pull:`1281` - Added type hints to ``reactpy.html`` attributes.
3232
- :pull:`1285` - Added support for nested components in web modules
33+
- :pull:`1289` - Added support for inline JavaScript as event handlers or other attributes that expect a callable via ``reactpy.types.InlineJavaScript``
3334

3435
**Changed**
3536

src/js/packages/@reactpy/client/src/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export type ReactPyVdom = {
5353
children?: (ReactPyVdom | string)[];
5454
error?: string;
5555
eventHandlers?: { [key: string]: ReactPyVdomEventHandler };
56+
inlineJavaScript?: { [key: string]: string };
5657
importSource?: ReactPyVdomImportSource;
5758
};
5859

src/js/packages/@reactpy/client/src/vdom.tsx

+53-19
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,12 @@ export function createAttributes(
189189
createEventHandler(client, name, handler),
190190
),
191191
),
192+
...Object.fromEntries(
193+
Object.entries(model.inlineJavaScript || {}).map(
194+
([name, inlineJavaScript]) =>
195+
createInlineJavaScript(name, inlineJavaScript),
196+
),
197+
),
192198
}),
193199
);
194200
}
@@ -198,23 +204,51 @@ function createEventHandler(
198204
name: string,
199205
{ target, preventDefault, stopPropagation }: ReactPyVdomEventHandler,
200206
): [string, () => void] {
201-
return [
202-
name,
203-
function (...args: any[]) {
204-
const data = Array.from(args).map((value) => {
205-
if (!(typeof value === "object" && value.nativeEvent)) {
206-
return value;
207-
}
208-
const event = value as React.SyntheticEvent<any>;
209-
if (preventDefault) {
210-
event.preventDefault();
211-
}
212-
if (stopPropagation) {
213-
event.stopPropagation();
214-
}
215-
return serializeEvent(event.nativeEvent);
216-
});
217-
client.sendMessage({ type: "layout-event", data, target });
218-
},
219-
];
207+
const eventHandler = function (...args: any[]) {
208+
const data = Array.from(args).map((value) => {
209+
if (!(typeof value === "object" && value.nativeEvent)) {
210+
return value;
211+
}
212+
const event = value as React.SyntheticEvent<any>;
213+
if (preventDefault) {
214+
event.preventDefault();
215+
}
216+
if (stopPropagation) {
217+
event.stopPropagation();
218+
}
219+
return serializeEvent(event.nativeEvent);
220+
});
221+
client.sendMessage({ type: "layout-event", data, target });
222+
};
223+
eventHandler.isHandler = true;
224+
return [name, eventHandler];
225+
}
226+
227+
function createInlineJavaScript(
228+
name: string,
229+
inlineJavaScript: string,
230+
): [string, () => void] {
231+
/* Function that will execute the string-like InlineJavaScript
232+
via eval in the most appropriate way */
233+
const wrappedExecutable = function (...args: any[]) {
234+
function handleExecution(...args: any[]) {
235+
const evalResult = eval(inlineJavaScript);
236+
if (typeof evalResult == "function") {
237+
return evalResult(...args);
238+
}
239+
}
240+
if (args.length > 0 && args[0] instanceof Event) {
241+
/* If being triggered by an event, set the event's current
242+
target to "this". This ensures that inline
243+
javascript statements such as the following work:
244+
html.button({"onclick": 'this.value = "Clicked!"'}, "Click Me")*/
245+
return handleExecution.call(args[0].currentTarget, ...args);
246+
} else {
247+
/* If not being triggered by an event, do not set "this" and
248+
just call normally */
249+
return handleExecution(...args);
250+
}
251+
};
252+
wrappedExecutable.isHandler = false;
253+
return [name, wrappedExecutable];
220254
}

src/reactpy/core/layout.py

+4
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,10 @@ def _render_model_attributes(
262262
attrs = raw_model["attributes"].copy()
263263
new_state.model.current["attributes"] = attrs
264264

265+
if "inlineJavaScript" in raw_model:
266+
inline_javascript = raw_model["inlineJavaScript"].copy()
267+
new_state.model.current["inlineJavaScript"] = inline_javascript
268+
265269
if old_state is None:
266270
self._render_model_event_handlers_without_old_state(
267271
new_state, handlers_by_event

src/reactpy/core/vdom.py

+28-11
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from __future__ import annotations
33

44
import json
5+
import re
56
from collections.abc import Mapping, Sequence
67
from typing import (
78
Any,
@@ -23,12 +24,16 @@
2324
EventHandlerDict,
2425
EventHandlerType,
2526
ImportSourceDict,
27+
InlineJavaScript,
28+
InlineJavaScriptDict,
2629
VdomAttributes,
2730
VdomChildren,
2831
VdomDict,
2932
VdomJson,
3033
)
3134

35+
EVENT_ATTRIBUTE_PATTERN = re.compile(r"^on[A-Z]\w+")
36+
3237
VDOM_JSON_SCHEMA = {
3338
"$schema": "http://json-schema.org/draft-07/schema",
3439
"$ref": "#/definitions/element",
@@ -42,6 +47,7 @@
4247
"children": {"$ref": "#/definitions/elementChildren"},
4348
"attributes": {"type": "object"},
4449
"eventHandlers": {"$ref": "#/definitions/elementEventHandlers"},
50+
"inlineJavaScript": {"$ref": "#/definitions/elementInlineJavaScripts"},
4551
"importSource": {"$ref": "#/definitions/importSource"},
4652
},
4753
# The 'tagName' is required because its presence is a useful indicator of
@@ -71,6 +77,12 @@
7177
},
7278
"required": ["target"],
7379
},
80+
"elementInlineJavaScripts": {
81+
"type": "object",
82+
"patternProperties": {
83+
".*": "str",
84+
},
85+
},
7486
"importSource": {
7587
"type": "object",
7688
"properties": {
@@ -160,7 +172,9 @@ def __call__(
160172
"""The entry point for the VDOM API, for example reactpy.html(<WE_ARE_HERE>)."""
161173
attributes, children = separate_attributes_and_children(attributes_and_children)
162174
key = attributes.get("key", None)
163-
attributes, event_handlers = separate_attributes_and_event_handlers(attributes)
175+
attributes, event_handlers, inline_javascript = (
176+
separate_attributes_handlers_and_inline_javascript(attributes)
177+
)
164178
if REACTPY_CHECK_JSON_ATTRS.current:
165179
json.dumps(attributes)
166180

@@ -180,6 +194,9 @@ def __call__(
180194
**({"children": children} if children else {}),
181195
**({"attributes": attributes} if attributes else {}),
182196
**({"eventHandlers": event_handlers} if event_handlers else {}),
197+
**(
198+
{"inlineJavaScript": inline_javascript} if inline_javascript else {}
199+
),
183200
**({"importSource": self.import_source} if self.import_source else {}),
184201
}
185202

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

214231

215-
def separate_attributes_and_event_handlers(
232+
def separate_attributes_handlers_and_inline_javascript(
216233
attributes: Mapping[str, Any],
217-
) -> tuple[VdomAttributes, EventHandlerDict]:
234+
) -> tuple[VdomAttributes, EventHandlerDict, InlineJavaScriptDict]:
218235
_attributes: VdomAttributes = {}
219236
_event_handlers: dict[str, EventHandlerType] = {}
237+
_inline_javascript: dict[str, InlineJavaScript] = {}
220238

221239
for k, v in attributes.items():
222-
handler: EventHandlerType
223-
224240
if callable(v):
225-
handler = EventHandler(to_event_handler_function(v))
241+
_event_handlers[k] = EventHandler(to_event_handler_function(v))
226242
elif isinstance(v, EventHandler):
227-
handler = v
243+
_event_handlers[k] = v
244+
elif EVENT_ATTRIBUTE_PATTERN.match(k) and isinstance(v, str):
245+
_inline_javascript[k] = InlineJavaScript(v)
246+
elif isinstance(v, InlineJavaScript):
247+
_inline_javascript[k] = v
228248
else:
229249
_attributes[k] = v
230-
continue
231-
232-
_event_handlers[k] = handler
233250

234-
return _attributes, _event_handlers
251+
return _attributes, _event_handlers, _inline_javascript
235252

236253

237254
def _flatten_children(children: Sequence[Any]) -> list[Any]:

src/reactpy/transforms.py

+10-10
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@
66
from reactpy.types import VdomAttributes, VdomDict
77

88

9+
def attributes_to_reactjs(attributes: VdomAttributes):
10+
"""Convert HTML attribute names to their ReactJS equivalents."""
11+
attrs = cast(VdomAttributes, attributes.items())
12+
attrs = cast(
13+
VdomAttributes,
14+
{REACT_PROP_SUBSTITUTIONS.get(k, k): v for k, v in attrs},
15+
)
16+
return attrs
17+
18+
919
class RequiredTransforms:
1020
"""Performs any necessary transformations related to `string_to_reactpy` to automatically prevent
1121
issues with React's rendering engine.
@@ -36,16 +46,6 @@ def normalize_style_attributes(self, vdom: dict[str, Any]) -> None:
3646
)
3747
}
3848

39-
@staticmethod
40-
def html_props_to_reactjs(vdom: VdomDict) -> None:
41-
"""Convert HTML prop names to their ReactJS equivalents."""
42-
if "attributes" in vdom:
43-
items = cast(VdomAttributes, vdom["attributes"].items())
44-
vdom["attributes"] = cast(
45-
VdomAttributes,
46-
{REACT_PROP_SUBSTITUTIONS.get(k, k): v for k, v in items},
47-
)
48-
4949
@staticmethod
5050
def textarea_children_to_prop(vdom: VdomDict) -> None:
5151
"""Transformation that converts the text content of a <textarea> to a ReactJS prop."""

src/reactpy/types.py

+22
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,7 @@ class DangerouslySetInnerHTML(TypedDict):
768768
"children",
769769
"attributes",
770770
"eventHandlers",
771+
"inlineJavaScript",
771772
"importSource",
772773
]
773774
ALLOWED_VDOM_KEYS = {
@@ -776,6 +777,7 @@ class DangerouslySetInnerHTML(TypedDict):
776777
"children",
777778
"attributes",
778779
"eventHandlers",
780+
"inlineJavaScript",
779781
"importSource",
780782
}
781783

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

793796

@@ -818,6 +821,8 @@ def __getitem__(self, key: Literal["attributes"]) -> VdomAttributes: ...
818821
@overload
819822
def __getitem__(self, key: Literal["eventHandlers"]) -> EventHandlerDict: ...
820823
@overload
824+
def __getitem__(self, key: Literal["inlineJavaScript"]) -> InlineJavaScriptDict: ...
825+
@overload
821826
def __getitem__(self, key: Literal["importSource"]) -> ImportSourceDict: ...
822827
def __getitem__(self, key: VdomDictKeys) -> Any:
823828
return super().__getitem__(key)
@@ -839,6 +844,10 @@ def __setitem__(
839844
self, key: Literal["eventHandlers"], value: EventHandlerDict
840845
) -> None: ...
841846
@overload
847+
def __setitem__(
848+
self, key: Literal["inlineJavaScript"], value: InlineJavaScriptDict
849+
) -> None: ...
850+
@overload
842851
def __setitem__(
843852
self, key: Literal["importSource"], value: ImportSourceDict
844853
) -> None: ...
@@ -871,6 +880,7 @@ class VdomJson(TypedDict):
871880
children: NotRequired[list[Any]]
872881
attributes: NotRequired[VdomAttributes]
873882
eventHandlers: NotRequired[dict[str, JsonEventTarget]]
883+
inlineJavaScript: NotRequired[dict[str, InlineJavaScript]]
874884
importSource: NotRequired[JsonImportSource]
875885

876886

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

887897

898+
class InlineJavaScript(str):
899+
"""Simple subclass that flags a user's string in ReactPy VDOM attributes as executable JavaScript."""
900+
901+
pass
902+
903+
888904
class EventHandlerFunc(Protocol):
889905
"""A coroutine which can handle event data"""
890906

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

941+
InlineJavaScriptMapping = Mapping[str, InlineJavaScript]
942+
"""A generic mapping between attribute names to their inline javascript"""
943+
944+
InlineJavaScriptDict: TypeAlias = dict[str, InlineJavaScript]
945+
"""A dict mapping between attribute names to their inline javascript"""
946+
925947

926948
class VdomConstructor(Protocol):
927949
"""Standard function for constructing a :class:`VdomDict`"""

src/reactpy/utils.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from lxml.html import fromstring
1111

1212
from reactpy import html
13-
from reactpy.transforms import RequiredTransforms
13+
from reactpy.transforms import RequiredTransforms, attributes_to_reactjs
1414
from reactpy.types import ComponentType, VdomDict
1515

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

151+
# This transform is required prior to initializing the Vdom so InlineJavaScript
152+
# gets properly parsed (ex. <button onClick="this.innerText = 'Clicked';")
153+
attributes = attributes_to_reactjs(dict(node.items()))
154+
151155
# Convert the lxml node to a VDOM dict
152156
constructor = getattr(html, str(node.tag))
153-
el = constructor(dict(node.items()), children)
157+
el = constructor(attributes, children)
154158

155159
# Perform necessary transformations on the VDOM attributes to meet VDOM spec
156160
RequiredTransforms(el, intercept_links)

src/reactpy/web/templates/react.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export function bind(node, config) {
2929
function wrapEventHandlers(props) {
3030
const newProps = Object.assign({}, props);
3131
for (const [key, value] of Object.entries(props)) {
32-
if (typeof value === "function") {
32+
if (typeof value === "function" && value.isHandler) {
3333
newProps[key] = makeJsonSafeEventHandler(value);
3434
}
3535
}

0 commit comments

Comments
 (0)