Skip to content

Commit 23a7297

Browse files
committed
Implement JavaScript type and handle eval
1 parent 0b14bc8 commit 23a7297

File tree

8 files changed

+191
-24
lines changed

8 files changed

+191
-24
lines changed

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,8 +198,23 @@ function createEventHandler(
198198
name: string,
199199
{ target, preventDefault, stopPropagation }: ReactPyVdomEventHandler,
200200
): [string, () => void] {
201-
if (target.indexOf("javascript:") == 0) {
202-
return [name, eval(target.replace("javascript:", ""))];
201+
if (target.indexOf("__javascript__: ") == 0) {
202+
return [
203+
name,
204+
function (...args: any[]) {
205+
function handleEvent(...args: any[]) {
206+
const evalResult = eval(target.replace("__javascript__: ", ""));
207+
if (typeof evalResult == "function") {
208+
return evalResult(...args);
209+
}
210+
}
211+
if (args.length > 0 && args[0] instanceof Event) {
212+
return handleEvent.call(args[0].target, ...args);
213+
} else {
214+
return handleEvent(...args);
215+
}
216+
},
217+
];
203218
}
204219
return [
205220
name,

src/reactpy/core/layout.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
ComponentType,
4242
Context,
4343
EventHandlerDict,
44+
JavaScript,
4445
Key,
4546
LayoutEventMessage,
4647
LayoutUpdateMessage,
@@ -118,7 +119,7 @@ async def deliver(self, event: LayoutEventMessage | dict[str, Any]) -> None:
118119
# we just ignore the event.
119120
handler = self._event_handlers.get(event["target"])
120121

121-
if handler is not None and not isinstance(handler, str):
122+
if handler is not None and not isinstance(handler, JavaScript):
122123
try:
123124
await handler.function(event["data"])
124125
except Exception:
@@ -277,8 +278,8 @@ def _render_model_attributes(
277278

278279
model_event_handlers = new_state.model.current["eventHandlers"] = {}
279280
for event, handler in handlers_by_event.items():
280-
if isinstance(handler, str):
281-
target = handler
281+
if isinstance(handler, JavaScript):
282+
target = "__javascript__: " + handler
282283
prevent_default = False
283284
stop_propagation = False
284285
else:
@@ -308,8 +309,8 @@ def _render_model_event_handlers_without_old_state(
308309

309310
model_event_handlers = new_state.model.current["eventHandlers"] = {}
310311
for event, handler in handlers_by_event.items():
311-
if isinstance(handler, str):
312-
target = handler
312+
if isinstance(handler, JavaScript):
313+
target = "__javascript__: " + handler
313314
prevent_default = False
314315
stop_propagation = False
315316
else:

src/reactpy/core/vdom.py

Lines changed: 9 additions & 5 deletions
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,15 @@
2324
EventHandlerDict,
2425
EventHandlerType,
2526
ImportSourceDict,
27+
JavaScript,
2628
VdomAttributes,
2729
VdomChildren,
2830
VdomDict,
2931
VdomJson,
3032
)
3133

34+
EVENT_ATTRIBUTE_PATTERN = re.compile(r"^on[A-Z]")
35+
3236
VDOM_JSON_SCHEMA = {
3337
"$schema": "http://json-schema.org/draft-07/schema",
3438
"$ref": "#/definitions/element",
@@ -216,16 +220,16 @@ def separate_attributes_and_event_handlers(
216220
attributes: Mapping[str, Any],
217221
) -> tuple[VdomAttributes, EventHandlerDict]:
218222
_attributes: VdomAttributes = {}
219-
_event_handlers: dict[str, EventHandlerType | str] = {}
223+
_event_handlers: dict[str, EventHandlerType | JavaScript] = {}
220224

221225
for k, v in attributes.items():
222-
handler: EventHandlerType | str
226+
handler: EventHandlerType | JavaScript
223227

224228
if callable(v):
225229
handler = EventHandler(to_event_handler_function(v))
226-
elif isinstance(v, str) and v.startswith("javascript:"):
227-
handler = v
228-
elif isinstance(v, EventHandler):
230+
elif EVENT_ATTRIBUTE_PATTERN.match(k) and isinstance(v, str):
231+
handler = JavaScript(v)
232+
elif isinstance(v, (EventHandler, JavaScript)):
229233
handler = v
230234
else:
231235
_attributes[k] = v

src/reactpy/types.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,10 @@ class JsonImportSource(TypedDict):
885885
fallback: Any
886886

887887

888+
class JavaScript(str):
889+
pass
890+
891+
888892
class EventHandlerFunc(Protocol):
889893
"""A coroutine which can handle event data"""
890894

@@ -919,7 +923,7 @@ class EventHandlerType(Protocol):
919923
EventHandlerMapping = Mapping[str, EventHandlerType]
920924
"""A generic mapping between event names to their handlers"""
921925

922-
EventHandlerDict: TypeAlias = dict[str, EventHandlerType | str]
926+
EventHandlerDict: TypeAlias = dict[str, EventHandlerType | JavaScript]
923927
"""A dict mapping between event names to their handlers"""
924928

925929

src/reactpy/web/templates/react.js

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,8 @@ 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") {
33-
if (value.toString().includes(".sendMessage")) {
34-
newProps[key] = makeJsonSafeEventHandler(value);
35-
} else {
36-
newProps[key] = value;
37-
}
32+
if (typeof value === "function" && value.toString().includes(".sendMessage")) {
33+
newProps[key] = makeJsonSafeEventHandler(value);
3834
}
3935
}
4036
return newProps;

tests/test_core/test_events.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -223,18 +223,15 @@ def outer_click_is_not_triggered(event):
223223
await poll(lambda: clicked.current).until_is(True)
224224

225225

226-
async def test_javascript_event(display: DisplayFixture):
226+
async def test_javascript_event_as_arrow_function(display: DisplayFixture):
227227
@reactpy.component
228228
def App():
229229
return reactpy.html.div(
230230
reactpy.html.div(
231231
reactpy.html.button(
232232
{
233233
"id": "the-button",
234-
"onClick": """javascript: () => {
235-
let parent = document.getElementById("the-parent");
236-
parent.appendChild(document.createElement("div"));
237-
}""",
234+
"onClick": '(e) => e.target.innerText = "Thank you!"',
238235
},
239236
"Click Me",
240237
),
@@ -256,6 +253,30 @@ def App():
256253
assert len(generated_divs) == 3
257254

258255

256+
async def test_javascript_event_as_this_statement(display: DisplayFixture):
257+
@reactpy.component
258+
def App():
259+
return reactpy.html.div(
260+
reactpy.html.div(
261+
reactpy.html.button(
262+
{
263+
"id": "the-button",
264+
"onClick": 'this.innerText = "Thank you!"',
265+
},
266+
"Click Me",
267+
),
268+
reactpy.html.div({"id": "the-parent"}),
269+
)
270+
)
271+
272+
await display.show(lambda: App())
273+
274+
button = await display.page.wait_for_selector("#the-button", state="attached")
275+
assert await button.inner_text() == "Click Me"
276+
await button.click()
277+
assert await button.inner_text() == "Thank you!"
278+
279+
259280
async def test_javascript_event_after_state_update(display: DisplayFixture):
260281
@reactpy.component
261282
def App():
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import React from "https://esm.sh/[email protected]"
2+
import ReactDOM from "https://esm.sh/[email protected]/client"
3+
4+
export {AgGridReact};
5+
6+
loadCSS("https://unpkg.com/@ag-grid-community/[email protected]/ag-grid.css");
7+
loadCSS("https://unpkg.com/@ag-grid-community/[email protected]/ag-theme-quartz.css")
8+
9+
function loadCSS(href) {
10+
var head = document.getElementsByTagName('head')[0];
11+
12+
if (document.querySelectorAll(`link[href="${href}"]`).length === 0) {
13+
// Creating link element
14+
var style = document.createElement('link');
15+
style.id = href;
16+
style.href = href;
17+
style.type = 'text/css';
18+
style.rel = 'stylesheet';
19+
head.append(style);
20+
}
21+
}
22+
23+
export function bind(node, config) {
24+
const root = ReactDOM.createRoot(node);
25+
return {
26+
create: (component, props, children) =>
27+
React.createElement(component, wrapEventHandlers(props), ...children),
28+
render: (element) => root.render(element),
29+
unmount: () => root.unmount()
30+
};
31+
}
32+
33+
function wrapEventHandlers(props) {
34+
const newProps = Object.assign({}, props);
35+
for (const [key, value] of Object.entries(props)) {
36+
if (typeof value === "function" && value.toString().includes('.sendMessage')) {
37+
newProps[key] = makeJsonSafeEventHandler(value);
38+
}
39+
}
40+
return newProps;
41+
}
42+
43+
function stringifyToDepth(val, depth, replacer, space) {
44+
depth = isNaN(+depth) ? 1 : depth;
45+
function _build(key, val, depth, o, a) { // (JSON.stringify() has it's own rules, which we respect here by using it for property iteration)
46+
return !val || typeof val != 'object' ? val : (a=Array.isArray(val), JSON.stringify(val, function(k,v){ if (a || depth > 0) { if (replacer) v=replacer(k,v); if (!k) return (a=Array.isArray(v),val=v); !o && (o=a?[]:{}); o[k] = _build(k, v, a?depth:depth-1); } }), o||(a?[]:{}));
47+
}
48+
return JSON.stringify(_build('', val, depth), null, space);
49+
}
50+
51+
function makeJsonSafeEventHandler(oldHandler) {
52+
// Since we can't really know what the event handlers get passed we have to check if
53+
// they are JSON serializable or not. We can allow normal synthetic events to pass
54+
// through since the original handler already knows how to serialize those for us.
55+
return function safeEventHandler() {
56+
57+
var filteredArguments = [];
58+
Array.from(arguments).forEach(function (arg) {
59+
if (typeof arg === "object" && arg.nativeEvent) {
60+
// this is probably a standard React synthetic event
61+
filteredArguments.push(arg);
62+
} else {
63+
filteredArguments.push(JSON.parse(stringifyToDepth(arg, 3, (key, value) => {
64+
if (key === '') return value;
65+
try {
66+
JSON.stringify(value);
67+
return value;
68+
} catch (err) {
69+
return (typeof value === 'object') ? value : undefined;
70+
}
71+
})))
72+
}
73+
});
74+
oldHandler(...Array.from(filteredArguments));
75+
};
76+
}

tests/test_web/test_module.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
poll,
1414
)
1515
from reactpy.web.module import NAME_SOURCE, WebModule
16+
from reactpy.types import JavaScript
1617

1718
JS_FIXTURES_DIR = Path(__file__).parent / "js_fixtures"
1819

@@ -389,6 +390,55 @@ async def test_subcomponent_notation_as_obj_attrs(display: DisplayFixture):
389390
assert len(form_label) == 1
390391

391392

393+
async def test_ag_grid_table(display: DisplayFixture):
394+
module = reactpy.web.module_from_file(
395+
"ag-grid-react", JS_FIXTURES_DIR / "ag-grid-react.js"
396+
)
397+
AgGridReact = reactpy.web.export(module, "AgGridReact")
398+
399+
@reactpy.component
400+
def App():
401+
dummy_bool, set_dummy_bool = reactpy.hooks.use_state(False)
402+
row_data, set_row_data = reactpy.hooks.use_state([
403+
{ "make": "Tesla", "model": "Model Y", "price": 64950, "electric": True },
404+
{ "make": "Ford", "model": "F-Series", "price": 33850, "electric": False },
405+
{ "make": "Toyota", "model": "Corolla", "price": 29600, "electric": False },
406+
])
407+
col_defs, set_col_defs = reactpy.hooks.use_state([
408+
{ "field": "make" },
409+
{ "field": "model" },
410+
{ "field": "price" },
411+
{ "field": "electric" },
412+
])
413+
default_col_def = {"flex": 1}
414+
row_selection = reactpy.hooks.use_memo(lambda: {"mode": "singleRow"})
415+
416+
return reactpy.html.div(
417+
{"id": "the-parent", "style": {"height": "100vh", "width": "100vw"}, "class": "ag-theme-quartz"},
418+
AgGridReact({
419+
"style": {"height": "500px"},
420+
"rowData": row_data,
421+
"columnDefs": col_defs,
422+
"defaultColDef": default_col_def,
423+
"selection": row_selection,
424+
"onRowSelected": lambda x: set_dummy_bool(not dummy_bool),
425+
"getRowId": JavaScript("(params) => String(params.data.model);")
426+
})
427+
)
428+
429+
await display.show(
430+
lambda: App()
431+
)
432+
433+
table_body = await display.page.wait_for_selector(".ag-body-viewport", state="attached")
434+
checkboxes = await table_body.query_selector_all(".ag-checkbox-input")
435+
await checkboxes[0].click()
436+
# Regrab checkboxes, since they should rerender
437+
checkboxes = await table_body.query_selector_all(".ag-checkbox-input")
438+
checked = await checkboxes[0].is_checked()
439+
assert checked is True
440+
441+
392442
def test_module_from_string():
393443
reactpy.web.module_from_string("temp", "old")
394444
with assert_reactpy_did_log(r"Existing web module .* will be replaced with"):

0 commit comments

Comments
 (0)